wai-quantum: a person's taste, learned from their own choices between equals #496
21 changed files with 1349 additions and 22 deletions
6
.github/workflows/wai-web-wasm.yml
vendored
6
.github/workflows/wai-web-wasm.yml
vendored
|
|
@ -168,15 +168,15 @@ jobs:
|
|||
# vector, what does not run saying why, plan, lint, draw and methods as JSON,
|
||||
# critique, equivalence and variants as JSON, the classical shortcuts, the
|
||||
# workbench run and sealed in the page, a program's history recorded and
|
||||
# blamed there, the reader's rules checking a proposal, and what is new in
|
||||
# a program and in a workbench entry.
|
||||
# blamed there, the reader's rules checking a proposal, what is new in a
|
||||
# program and in a workbench entry, and the reader's taste learned there.
|
||||
- name: The job engine in a page (native unit tests)
|
||||
shell: bash
|
||||
working-directory: wai/wai-web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test --locked --lib --features quantum_engine quantum_engine:: 2>&1 | tee "$RUNNER_TEMP/wai-web-engine.log"
|
||||
grep -q "test result: ok. 10 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log"
|
||||
grep -q "test result: ok. 11 passed; 0 failed" "$RUNNER_TEMP/wai-web-engine.log"
|
||||
# The crate's native unit tests, run on the host: the pure-Rust halves of the
|
||||
# bindings, which wasm_tests does not run. They decode the committed integer
|
||||
# payloads to their sidecars' SHA-256 (int_payload::tests) and check that the
|
||||
|
|
|
|||
|
|
@ -1562,6 +1562,24 @@ wire format may change and such changes are recorded here.
|
|||
- **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has.
|
||||
- **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it.
|
||||
|
||||
### wai-quantum: a person's taste, learned from their own choices between equals
|
||||
|
||||
Feature 10 of the co-design report: a personal, plural taste model.
|
||||
|
||||
- **The model** (`engine::taste`):
|
||||
- a choice is recorded only when the engine proves the two programs the same circuit (`equiv::equivalent_programs`) or the same state (`equiv::same_state`), by a proof and never by random inputs alone: it is about taste, never correctness;
|
||||
- a Bradley–Terry logistic over differences of features the engine measures (gates, two-qubit gates, depth, two-qubit depth, T gates, modelled error) and of the program's text (statements, kinds of gate, repetition, comments), fitted with a ridge penalty by Newton's method, so the same choices give the same weights on every host; a feature that barely varied over the choices is scaled by a floor on its spread, so it cannot turn a large difference between candidates into certainty;
|
||||
- the rubric's parts (economy, clarity, insight, precision, utility): each learned from the choices that name it, and a ranking says where a part, or the cost, puts another first;
|
||||
- profiles are personal and plural, by name, and never pooled;
|
||||
- typicality (one less the novelty of `engine::novelty`) is shown beside each ranking and never learned; the novelty dial (−1 to 1) blends the order by taste with the order by novelty, each standardised over the candidates, so at either end the order is by novelty alone;
|
||||
- measured: how many of its own choices a profile predicts, each from the ones before it and each held out from the rest (over the last 100), the choices nothing measured separates, and whether a ranking's first three are built more alike than the first three by cost;
|
||||
- `next`: the pair the profile is least sure of that the engine proves equal.
|
||||
- **The command line:** `wai-quantum taste choose | show | rank | next`, over files or a specification's elites (`--spec`), the profiles in `quantum-taste.json` beside the user's key.
|
||||
- **For agents:** `taste_rank` and `taste_next` read the person's profile. No tool records a choice: only the person chooses.
|
||||
- **The Studio:** "Your taste" in the Workbench (Compare two, with the part a choice was about; Rank by my taste, with each one's typicality and the dial; profiles by name). An alternative that is the same circuit as the draft, taken whole or refused whole, is a choice, and Improve orders its candidates by the profile.
|
||||
- **For hosts:** `taste::call`, `engine_taste` in the page and `taste` in the desktop app.
|
||||
- **CI:** wai-web's native engine tests count 11.
|
||||
|
||||
### wai-quantum: what is new, what is a known construction found again, and aberrations
|
||||
|
||||
Feature 9 of the co-design report: a novelty, rediscovery and serendipity ledger.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ The engine says what is new in a program: the known construction it is, found ag
|
|||
- **The Workbench:** each kept generator is named for what it is, and its entry shows it with the measures it beats the reference on that the cost does not weigh. A candidate that breaks only one rule of the specification (a gate, or the line) and would be verified without it is an aberration. It is never kept: the log says what it would cost, and "Try without that rule" loosens the specification, for a new archive under it.
|
||||
- **Found** keeps what is worth a second look: a different route to a known result, an aberration, a verified entry far from everything it was compared with. Each can be opened in the editor, saved with a note, or dismissed. Its count says how many were flagged and how many saved. It is kept in this browser.
|
||||
|
||||
## Your taste
|
||||
|
||||
"Your taste", in the Workbench, learns what you prefer from your own choices:
|
||||
|
||||
- **Compare two** shows the two kept generators your profile is least sure of. The engine proves that they do the same thing, so the choice is about taste, never correctness. "This one" keeps it, and "About" says what it was for: economy, clarity, insight, precision or utility.
|
||||
- **From Alternatives too:** an alternative that is the same circuit as the draft, taken whole or refused whole, is a choice between the two. One taken in part says nothing. Improve orders its candidates by your profile once it has a choice.
|
||||
- **Rank by my taste** orders the kept generators. Each shows its typicality (how near the known constructions it is built), which the profile never learns and only the dial moves from typical to novel. The ranking says where a part, or the cost, puts another first, and whether its first three are built more alike than the cost's.
|
||||
- **Profiles:** each name is a profile of its own, kept in this browser; nothing pools them. The summary says how many of your choices the profile predicts, each from the ones before.
|
||||
|
||||
## Versions
|
||||
|
||||
The Versions pane records each change to the program and who made it:
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ export class TabEngine {
|
|||
// in one entry of an archive.
|
||||
novelty(args) { return this.call('novelty', { args }); }
|
||||
discoverNovelty(spec, archive, entry) { return this.call('discover_novelty', { spec, archive, entry }); }
|
||||
// The person's taste model: `name` is features, choose, summary, rank or next; the profile goes in `args`.
|
||||
taste(name, args) { return this.call('taste', { name, args }); }
|
||||
async signer() { return (await this.call('signer')).did; }
|
||||
cancel() {
|
||||
this.worker?.terminate();
|
||||
|
|
@ -162,6 +164,7 @@ export class DesktopEngine {
|
|||
locks(name, args) { return this.call('locks', { name, args: JSON.stringify(args) }); }
|
||||
novelty(args) { return this.call('novelty', { args: JSON.stringify(args) }); }
|
||||
discoverNovelty(spec, archive, entry) { return this.call('discover_novelty', { spec: JSON.stringify(spec), archive: JSON.stringify(archive), id: entry }); }
|
||||
taste(name, args) { return this.call('taste', { name, args: JSON.stringify(args) }); }
|
||||
async signer() { return (await this.call('signer', {})).did; }
|
||||
saveJob(name, job) { return this.call('save_job', { name, job: JSON.stringify(job, null, 2) }); }
|
||||
saveProgram(name, text) { return this.call('save_program', { name, text }); }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { el, sameness } from './dom.js';
|
||||
import { chips } from './critique-view.js';
|
||||
import { noveltyLabel } from './novelty.js';
|
||||
import { declared } from './generators.js';
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const svg = (tag, attrs = {}, ...kids) => {
|
||||
|
|
@ -78,7 +79,10 @@ export function atlas(draft, cands, onPick) {
|
|||
// `check(base, qasm)` gives the reader's rules' verdict on each change: a
|
||||
// candidate whose every change breaks one is left out with the rule.
|
||||
// `found` is the Found pane, which keeps a different route to a known result.
|
||||
export function galleryPane({ engine, draft, model, apply, check = null, found = null }) {
|
||||
// `taste` is the reader's taste profiles (taste.js): an alternative that is
|
||||
// the same circuit as the draft, taken whole or refused whole, is a choice
|
||||
// between the two, and Improve's candidates are ordered by the profile.
|
||||
export function galleryPane({ engine, draft, model, apply, check = null, found = null, taste = null }) {
|
||||
const intent = el('input', { class: 'qs-input', id: 'qs-explore-aim', placeholder: 'Toward… fewer qubits, another entangler, more symmetry', autocomplete: 'off', 'aria-label': 'What Explore should aim for' });
|
||||
const improveBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-improve', type: 'button', text: 'Improve', title: 'The same circuit, cheaper: each checked equivalent to the draft' });
|
||||
const exploreBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-explore', type: 'button', text: 'Explore', title: 'Different circuits toward the aim, from the model, each measured by the engine' });
|
||||
|
|
@ -112,6 +116,24 @@ export function galleryPane({ engine, draft, model, apply, check = null, found =
|
|||
return { ...c, measures: crit, equivalence, novelty, distance: lineDistance(src, c.qasm), base };
|
||||
}
|
||||
|
||||
// A proposal taken whole or refused whole is a choice between it and the
|
||||
// draft, when the engine proved them the same circuit. One taken in part,
|
||||
// or with changes the rules blocked, says nothing either way.
|
||||
async function learn(r, c, src) {
|
||||
if (!taste || !r?.offered || r.blocked || declared(src).length || c.equivalence?.verdict !== 'equivalent') return;
|
||||
const took = r.accepted === r.offered ? true : r.accepted === 0 ? false : null;
|
||||
if (took == null) return;
|
||||
await taste.choose(took ? c.qasm : src, took ? src : c.qasm, { task: 'equivalent' }).catch(() => {});
|
||||
}
|
||||
|
||||
// Improve's candidates in the order of the reader's taste, once it has a choice.
|
||||
async function byTaste(src, cands) {
|
||||
if (!taste || cands.length < 2 || declared(src).length || !taste.profile().choices.length) return { cands, said: '' };
|
||||
const r = await taste.rank(cands.map((c, i) => ({ name: String(i), qasm: c.qasm })), {}, 0).catch(() => null);
|
||||
if (!r) return { cands, said: '' };
|
||||
return { cands: r.ranked.map((x) => cands[Number(x.name)]), said: `Ordered by your taste (${taste.name()}, ${r.choices} choice${r.choices === 1 ? '' : 's'}).` };
|
||||
}
|
||||
|
||||
function show(mode, src, base, cands, refused, said) {
|
||||
const cards = cands.map((c, i) => {
|
||||
const s = sameness(c.equivalence);
|
||||
|
|
@ -132,7 +154,7 @@ export function galleryPane({ engine, draft, model, apply, check = null, found =
|
|||
c.changes?.length ? el('p', { class: 'qs-note', text: c.changes.join('; ') }) : null,
|
||||
el('details', { class: 'qs-cand-src' }, el('summary', { text: 'Program' }), el('pre', { class: 'qs-ai-code', text: c.qasm })),
|
||||
el('div', { class: 'qs-actions' },
|
||||
el('button', { class: 'qs-btn qs-small qs-apply', type: 'button', text: 'Apply', onclick: () => apply(c.qasm, `${mode === 'improve' ? 'An improvement' : 'An exploration'} from ${c.source === 'engine' ? 'the engine' : 'the model'}: ${c.idea || c.label}. ${s.text[0].toUpperCase() + s.text.slice(1)}.`, by) })));
|
||||
el('button', { class: 'qs-btn qs-small qs-apply', type: 'button', text: 'Apply', onclick: async () => learn(await apply(c.qasm, `${mode === 'improve' ? 'An improvement' : 'An exploration'} from ${c.source === 'engine' ? 'the engine' : 'the model'}: ${c.idea || c.label}. ${s.text[0].toUpperCase() + s.text.slice(1)}.`, by), c, src) })));
|
||||
return card;
|
||||
});
|
||||
const pick = (i, go) => {
|
||||
|
|
@ -171,7 +193,8 @@ export function galleryPane({ engine, draft, model, apply, check = null, found =
|
|||
else if (r.equivalence.verdict === 'different') refused.push(`${r.label}: not the same circuit (${r.equivalence.why})`);
|
||||
else if (!cands.some((x) => x.qasm.trim() === r.qasm.trim()) && r.qasm.trim() !== src.trim()) cands.push(r);
|
||||
}
|
||||
show('improve', src, base, cands, refused, said);
|
||||
const ordered = await byTaste(src, cands);
|
||||
show('improve', src, base, ordered.cands, refused, [ordered.said, said].filter(Boolean).join(' '));
|
||||
} catch (e) { body.replaceChildren(el('p', { class: 'qs-empty', text: e.message })); }
|
||||
finally { busy = false; improveBtn.disabled = exploreBtn.disabled = false; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { critiquePanel } from './critique-view.js';
|
|||
import { galleryPane } from './gallery.js';
|
||||
import { workbenchPane } from './workbench.js';
|
||||
import { foundPane } from './novelty.js';
|
||||
import { tasteStore } from './taste.js';
|
||||
import { versionsPane, HANDS, whoName } from './versions.js';
|
||||
import { rulesPane, heldLines, LOCK, UNLOCK } from './rules.js';
|
||||
import { declared, inputsBar, withInputs } from './generators.js';
|
||||
|
|
@ -191,6 +192,9 @@ function mount(root) {
|
|||
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-circuit-h', text: 'Circuit' }), el('span', { class: 'qs-grow' }), el('span', { class: 'qs-note', id: 'qs-plan' }), asText),
|
||||
diagram, circuit, legend, critique.node, lens.node);
|
||||
|
||||
// ---- the reader's taste, learned from their choices ----
|
||||
const taste = tasteStore(native);
|
||||
|
||||
// ---- what was found worth a second look ----
|
||||
const found = foundPane({ open: (it) => offer(it.source, it.by, `${it.title}, from Found.`) });
|
||||
|
||||
|
|
@ -202,6 +206,7 @@ function mount(root) {
|
|||
apply: (qasm, note, by) => offer(qasm, by, note),
|
||||
check: (base, qasm) => local.locks('check', { base, proposed: qasm, locks: rules.locks(), inputs: genInputs() }),
|
||||
found,
|
||||
taste,
|
||||
});
|
||||
|
||||
// ---- the workbench ----
|
||||
|
|
@ -212,6 +217,7 @@ function mount(root) {
|
|||
apply: (qasm, note, by) => offer(qasm, by, note),
|
||||
save: (name, body) => saveFile(name, body, 'application/json'),
|
||||
found,
|
||||
taste,
|
||||
});
|
||||
|
||||
// ---- the result ----
|
||||
|
|
|
|||
|
|
@ -337,3 +337,25 @@
|
|||
.qs-status { margin-left: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .qs * { transition: none !important; } }
|
||||
|
||||
/* Your taste: choices between programs the engine proves equal, a ranking with typicality beside it. */
|
||||
.qs-taste { margin: 8px 12px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); }
|
||||
.qs-taste > summary { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); }
|
||||
.qs-taste > summary .qs-badge:empty { display: none; }
|
||||
.qs-taste-body { display: flex; flex-direction: column; gap: 8px; padding: 0 10px 10px; }
|
||||
.qs-taste-body .qs-note { margin: 0; }
|
||||
.qs-taste-name { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); }
|
||||
.qs-taste-name .qs-input { width: 9em; }
|
||||
.qs-taste-dial { display: flex; align-items: center; gap: 8px; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); }
|
||||
.qs-taste-dial input { flex: 1; min-width: 0; accent-color: var(--accent); }
|
||||
.qs-taste-pair:empty, .qs-taste-ranked:empty { display: none; }
|
||||
.qs-taste-pair, .qs-taste-ranked { display: flex; flex-direction: column; gap: 6px; }
|
||||
.qs-taste-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; }
|
||||
.qs-taste-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 8px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.qs-taste-side pre { margin: 0; max-height: 220px; }
|
||||
.qs-taste-list { margin: 0; padding: 0 0 0 20px; display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
|
||||
.qs-taste-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
||||
.qs-taste-list .qs-chip { border: 0; cursor: pointer; }
|
||||
.qs-taste-list .qs-chip:hover, .qs-taste-list .qs-chip:focus-visible { color: var(--text-primary); outline: 1px solid var(--accent); }
|
||||
.qs-taste-typ { display: inline-flex; align-items: center; gap: 6px; font: 11px var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; color: var(--text-secondary); }
|
||||
.qs-taste-bar { width: 48px; height: 6px; border-radius: 3px; background: linear-gradient(to right, var(--accent) calc(var(--t) * 100%), var(--border) 0); }
|
||||
|
|
|
|||
162
wai/quantum-studio/src/taste.js
Normal file
162
wai/quantum-studio/src/taste.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// Your taste in circuits, learned from your own choices (engine::taste).
|
||||
//
|
||||
// A choice is only between programs the engine proves do the same thing, so it
|
||||
// is about taste and never about correctness: two kept generators of the
|
||||
// Workbench, or an alternative and the draft it rewrites, taken whole or
|
||||
// refused whole. Each choice may name the part of the rubric it was about
|
||||
// (economy, clarity, insight, precision, utility), and each part is learned
|
||||
// apart, so where they disagree it shows. A profile is one person's, under a
|
||||
// name they give it, kept in this browser; nothing pools profiles. A ranking
|
||||
// shows each program's typicality (how near the known constructions it is
|
||||
// built), which the profile never learns and only the novelty dial moves, and
|
||||
// says whether it narrows what it puts first.
|
||||
import { el } from './dom.js';
|
||||
|
||||
const SAVED = 'wai.quantum-studio.taste';
|
||||
export const PARTS = [
|
||||
['economy', 'what it spends: gates, depth, T gates'],
|
||||
['clarity', 'how readily it is read'],
|
||||
['insight', 'what it shows about the problem'],
|
||||
['precision', 'how exactly it does its job, under noise too'],
|
||||
['utility', 'how readily it is used elsewhere'],
|
||||
];
|
||||
const blank = (name) => ({ taste: 1, name, choices: [] });
|
||||
const num = (x) => String(Math.round(x * 1000) / 1000);
|
||||
|
||||
// The profiles in this browser, and the engine's calls on the current one.
|
||||
export function tasteStore(engine) {
|
||||
const state = { current: 'mine', profiles: {} };
|
||||
try { Object.assign(state, JSON.parse(localStorage.getItem(SAVED) || '{}')); } catch (e) {}
|
||||
const keep = () => { try { localStorage.setItem(SAVED, JSON.stringify(state)); } catch (e) {} };
|
||||
const heard = new Set();
|
||||
const profile = () => (state.profiles[state.current] ??= blank(state.current));
|
||||
return {
|
||||
name: () => state.current,
|
||||
names: () => Object.keys(state.profiles),
|
||||
use(name) { state.current = name || 'mine'; profile(); keep(); for (const f of heard) f(); },
|
||||
profile,
|
||||
// Record that `chosen` is preferred to `other`; the engine refuses a pair it does not prove equal.
|
||||
async choose(chosen, other, { part = null, task = 'equivalent', inputs = {} } = {}) {
|
||||
const r = await engine.taste('choose', { profile: profile(), chosen, other, part, task, inputs, at: Math.floor(Date.now() / 1000) });
|
||||
state.profiles[state.current] = r.profile;
|
||||
keep();
|
||||
for (const f of heard) f();
|
||||
return r;
|
||||
},
|
||||
summary: () => engine.taste('summary', { profile: profile() }),
|
||||
rank: (candidates, inputs = {}, dial = 0) => engine.taste('rank', { profile: profile(), candidates, inputs, dial }),
|
||||
next: (candidates, inputs = {}, task = 'equivalent') => engine.taste('next', { profile: profile(), candidates, inputs, task }),
|
||||
onChange: (f) => heard.add(f),
|
||||
};
|
||||
}
|
||||
|
||||
// A profile's summary in words.
|
||||
export function summaryText(s) {
|
||||
if (!s?.choices) return 'No choices yet. Compare two kept generators, or apply or refuse an alternative that is the same circuit as the draft, and your taste is learned from what you choose.';
|
||||
const parts = Object.entries(s.parts ?? {}).map(([k, n]) => `${k} ${n}`);
|
||||
const out = [`${s.choices} choice${s.choices === 1 ? '' : 's'}${parts.length ? ` (${parts.join(', ')})` : ''}`];
|
||||
if (s.as_made?.of) out.push(`it predicts ${s.as_made.right} of the ${s.as_made.of} you made after your first, each from the ones before`);
|
||||
if (s.leanings?.length) out.push(`it leans toward ${s.leanings.map((l) => l.words).join(', ')}`);
|
||||
if (s.unmeasured) out.push(`${s.unmeasured} differ in nothing the engine measures`);
|
||||
return out.join(' · ') + '.';
|
||||
}
|
||||
|
||||
// Where a ranking's parts and the cost put another first, and whether it narrows.
|
||||
export function disagreeText(r) {
|
||||
const d = (r.disagree ?? []).map((x) => `${x.part === 'cost' ? 'the cost' : x.part} puts ${x.first} first`);
|
||||
return d.length ? `Disagreeing: ${d.join('; ')}.` : null;
|
||||
}
|
||||
export function narrowText(r) {
|
||||
const n = r.narrowing;
|
||||
if (n?.ranked == null || n?.baseline == null) return null;
|
||||
return `The first ${n.first} are ${n.ranked.toFixed(2)} apart in how they are built; by ${n.by}, ${n.baseline.toFixed(2)}${n.narrows ? ': this ranking narrows what comes first' : ''}.`;
|
||||
}
|
||||
|
||||
// The Workbench's taste: `candidates()` the kept generators ({name, qasm,
|
||||
// cost}) or null, read at `inputs()` for the task `task()`; `onPick(name)`
|
||||
// shows one.
|
||||
export function tastePanel({ store, candidates, inputs, task, onPick }) {
|
||||
const count = el('span', { class: 'qs-badge', id: 'qs-taste-count' });
|
||||
const summaryLine = el('p', { class: 'qs-note', id: 'qs-taste-summary' });
|
||||
const profileIn = el('input', { class: 'qs-input qs-small', id: 'qs-taste-profile', value: store.name(), autocomplete: 'off', spellcheck: 'false', list: 'qs-taste-profiles', 'aria-label': 'The taste profile’s name' });
|
||||
const names = el('datalist', { id: 'qs-taste-profiles' });
|
||||
const compareBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-taste-compare', type: 'button', text: 'Compare two', title: 'The two kept generators your profile is least sure of: choose the one you prefer' });
|
||||
const rankBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-taste-rank', type: 'button', text: 'Rank by my taste' });
|
||||
const dial = el('input', { type: 'range', min: '-1', max: '1', step: '0.25', value: '0', id: 'qs-taste-dial', 'aria-label': 'The novelty dial, from the typical to the novel' });
|
||||
const pairBox = el('div', { class: 'qs-taste-pair', id: 'qs-taste-pair' });
|
||||
const rankBox = el('div', { class: 'qs-taste-ranked', id: 'qs-taste-ranked' });
|
||||
const node = el('details', { class: 'qs-taste', id: 'qs-taste' },
|
||||
el('summary', {}, el('span', { text: 'Your taste' }), count),
|
||||
el('div', { class: 'qs-taste-body' },
|
||||
el('div', { class: 'qs-actions' }, el('label', { class: 'qs-taste-name' }, el('span', { text: 'Profile' }), profileIn), names, compareBtn, rankBtn),
|
||||
el('label', { class: 'qs-taste-dial' }, el('span', { text: 'typical' }), dial, el('span', { text: 'novel' })),
|
||||
summaryLine, pairBox, rankBox,
|
||||
el('p', { class: 'qs-note', text: 'You choose only between programs the engine proves do the same thing, so a choice is about taste, never correctness. The profile is yours, kept in this browser under its name. A ranking shows how typical each one is, which the profile never learns and only the dial moves.' })));
|
||||
|
||||
async function show() {
|
||||
names.replaceChildren(...store.names().map((n) => el('option', { value: n })));
|
||||
const s = await store.summary().catch(() => null);
|
||||
summaryLine.textContent = summaryText(s);
|
||||
count.textContent = s?.choices ? String(s.choices) : '';
|
||||
}
|
||||
const none = () => { pairBox.replaceChildren(el('p', { class: 'qs-empty', text: 'Start an archive and keep two generators or more to compare and rank them.' })); };
|
||||
|
||||
async function compare() {
|
||||
const cs = candidates();
|
||||
if (!cs || cs.length < 2) { none(); return; }
|
||||
pairBox.replaceChildren(el('p', { class: 'qs-note', text: 'The engine is finding the pair your profile is least sure of…' }));
|
||||
try {
|
||||
const n = await store.next(cs, inputs(), task());
|
||||
if (!n.a) { pairBox.replaceChildren(el('p', { class: 'qs-empty', text: `Nothing to compare: ${n.why}.` })); return; }
|
||||
const [a, b] = [cs.find((c) => c.name === n.a), cs.find((c) => c.name === n.b)];
|
||||
const part = el('select', { class: 'qs-input qs-small', id: 'qs-taste-part', 'aria-label': 'What the choice is about' },
|
||||
el('option', { value: '', text: 'the whole' }), ...PARTS.map(([k, w]) => el('option', { value: k, text: k, title: w })));
|
||||
const side = (c, o) => el('div', { class: 'qs-taste-side', 'data-name': c.name },
|
||||
el('div', { class: 'qs-cand-head' },
|
||||
el('span', { class: 'qs-cand-title', text: `${c.name}${c.cost != null ? ` · cost ${num(c.cost)}` : ''}` }),
|
||||
el('button', { class: 'qs-btn qs-small qs-taste-pick', type: 'button', text: 'This one', onclick: () => pick(c, o, part.value || null) })),
|
||||
el('pre', { class: 'qs-ai-code', text: c.qasm }));
|
||||
pairBox.replaceChildren(
|
||||
el('p', { class: 'qs-note', text: `Which do you prefer? The engine proves they do the same thing (${n.how}).` }),
|
||||
el('div', { class: 'qs-taste-sides' }, side(a, b), side(b, a)),
|
||||
el('div', { class: 'qs-actions' }, el('label', { class: 'qs-taste-name' }, el('span', { text: 'About' }), part),
|
||||
el('button', { class: 'qs-btn qs-small', id: 'qs-taste-skip', type: 'button', text: 'Skip', onclick: () => pairBox.replaceChildren() })));
|
||||
} catch (e) { pairBox.replaceChildren(el('p', { class: 'qs-empty', text: e.message })); }
|
||||
}
|
||||
|
||||
async function pick(c, o, part) {
|
||||
try {
|
||||
await store.choose(c.qasm, o.qasm, { part, task: task(), inputs: inputs() });
|
||||
pairBox.replaceChildren(el('p', { class: 'qs-note', id: 'qs-taste-chosen', text: `Kept: ${c.name} over ${o.name}${part ? `, for its ${part}` : ''}. ` },
|
||||
el('button', { class: 'qs-btn qs-small', id: 'qs-taste-another', type: 'button', text: 'Another', onclick: compare })));
|
||||
if (rankBox.childElementCount) await rank();
|
||||
} catch (e) { pairBox.replaceChildren(el('p', { class: 'qs-empty', text: e.message })); }
|
||||
}
|
||||
|
||||
async function rank() {
|
||||
const cs = candidates();
|
||||
if (!cs?.length) { none(); return; }
|
||||
try {
|
||||
const r = await store.rank(cs, inputs(), Number(dial.value));
|
||||
const list = el('ol', { class: 'qs-taste-list', id: 'qs-taste-list' }, ...r.ranked.map((x) => el('li', { 'data-name': x.name },
|
||||
el('button', { class: 'qs-chip', type: 'button', text: x.name, onclick: () => onPick?.(x.name) }),
|
||||
el('span', { class: 'qs-taste-typ', title: 'Typicality: how near the known constructions it is built, from 1 (built as one is) to 0 (nothing shared)' },
|
||||
el('span', { class: 'qs-taste-bar', style: `--t: ${x.typicality ?? 0}` }), el('span', { text: x.typicality == null ? 'typicality —' : `typicality ${x.typicality.toFixed(2)}` })),
|
||||
el('span', { class: 'qs-note', text: `score ${x.score}${x.cost != null ? ` · cost ${num(x.cost)}` : ''}${Object.keys(x.parts ?? {}).length ? ` · ${Object.entries(x.parts).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}` }))));
|
||||
const d = disagreeText(r), nw = narrowText(r);
|
||||
rankBox.replaceChildren(...[
|
||||
r.choices ? null : el('p', { class: 'qs-note', text: 'No choices yet: ordered by the dial alone.' }),
|
||||
list,
|
||||
d ? el('p', { class: 'qs-note', id: 'qs-taste-disagree', text: d }) : null,
|
||||
nw ? el('p', { class: 'qs-note', id: 'qs-taste-narrow', text: nw }) : null].filter(Boolean));
|
||||
} catch (e) { rankBox.replaceChildren(el('p', { class: 'qs-empty', text: e.message })); }
|
||||
}
|
||||
|
||||
profileIn.addEventListener('change', () => { store.use(profileIn.value.trim() || 'mine'); profileIn.value = store.name(); rankBox.replaceChildren(); pairBox.replaceChildren(); });
|
||||
dial.addEventListener('change', () => { if (rankBox.childElementCount) rank(); });
|
||||
compareBtn.addEventListener('click', compare);
|
||||
rankBtn.addEventListener('click', rank);
|
||||
store.onChange(show);
|
||||
show();
|
||||
return { node, show, compare, rank };
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
import { el } from './dom.js';
|
||||
import { declared } from './generators.js';
|
||||
import { noveltyLabel, unaskedText } from './novelty.js';
|
||||
import { tastePanel } from './taste.js';
|
||||
|
||||
const SAVED = 'wai.quantum-studio.workbench';
|
||||
// A cost as the Studio shows it: a sum of weighted measures, to three places.
|
||||
|
|
@ -128,8 +129,9 @@ const byOf = (p) => (p === 'mutate' ? { kind: 'engine', name: 'the blind search'
|
|||
|
||||
// `save(name, text)` writes a file where the reader keeps them and gives back
|
||||
// where it went. `apply(source, note, by)` offers a generator to the editor as
|
||||
// `by`'s proposal. `found` is the Found pane.
|
||||
export function workbenchPane({ engine, draft, model, apply, save, found = null }) {
|
||||
// `by`'s proposal. `found` is the Found pane; `taste` the reader's taste
|
||||
// profiles (taste.js), learned from choices between kept generators.
|
||||
export function workbenchPane({ engine, draft, model, apply, save, found = null, taste = null }) {
|
||||
const startBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-start', type: 'button', text: 'Start', title: 'A new archive, seeded with the program in the editor as the reference' });
|
||||
const task = el('select', { class: 'qs-input qs-small', id: 'qs-wb-task', 'aria-label': 'What each member must do' },
|
||||
el('option', { value: 'prepares', text: 'prepare the same state from |0…0⟩' }), el('option', { value: 'equivalent', text: 'be the same circuit (unitary)' }));
|
||||
|
|
@ -160,11 +162,15 @@ export function workbenchPane({ engine, draft, model, apply, save, found = null
|
|||
const mapBox = el('div', { class: 'qs-wb-mapbox', id: 'qs-wb-mapbox' });
|
||||
const entry = el('div', { class: 'qs-wb-entry', id: 'qs-wb-entry' });
|
||||
const log = el('ol', { class: 'qs-wb-log', id: 'qs-wb-log', 'aria-live': 'polite' });
|
||||
let spec = null, archive = null, summary = null, selected = null, openedFrom = null, busy = false;
|
||||
// The kept generators, each the best of its cell, read at the largest size.
|
||||
const largest = () => Object.fromEntries(Object.entries(spec?.inputs ?? {}).map(([k, vs]) => [k, Math.max(...vs)]));
|
||||
const elites = () => (archive ? Object.values(archive.cells).map((i) => archive.entries[i]).sort((a, b) => a.cost - b.cost).map((e) => ({ name: e.id, qasm: e.source, cost: e.cost })) : null);
|
||||
const tasteUi = taste ? tastePanel({ store: taste, candidates: elites, inputs: largest, task: () => spec?.task ?? 'equivalent', onPick: (id) => pick(id) }) : null;
|
||||
const node = el('section', { class: 'qs-pane qs-wb', 'aria-labelledby': 'qs-wb-h' },
|
||||
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-wb-h', text: 'Workbench' }), el('span', { class: 'qs-grow' }), startBtn),
|
||||
form, acts, stats, mapBox, entry, log);
|
||||
...[form, acts, stats, mapBox, entry, tasteUi?.node, log].filter(Boolean));
|
||||
|
||||
let spec = null, archive = null, summary = null, selected = null, openedFrom = null, busy = false;
|
||||
const say = (text, cls = '', ...more) => { log.prepend(el('li', { class: cls }, text, ...more)); while (log.children.length > 12) log.lastChild.remove(); };
|
||||
const persist = () => { try { localStorage.setItem(SAVED, JSON.stringify({ spec, archive })); } catch (e) {} };
|
||||
const setBusy = (b) => { busy = b; for (const x of [startBtn, searchBtn, askBtn, submitBtn]) x.disabled = b; };
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ self.onmessage = async (ev) => {
|
|||
case 'history': text = e().history(ev.data.name, JSON.stringify(ev.data.args ?? {})); break;
|
||||
case 'locks': text = mod.engine_locks(ev.data.name, JSON.stringify(ev.data.args ?? {})); break;
|
||||
case 'novelty': text = mod.engine_novelty(JSON.stringify(ev.data.args ?? {})); break;
|
||||
case 'taste': text = mod.engine_taste(ev.data.name, JSON.stringify(ev.data.args ?? {})); break;
|
||||
case 'discover_novelty': text = mod.engine_discover_novelty(JSON.stringify(ev.data.spec), JSON.stringify(ev.data.archive), ev.data.entry); break;
|
||||
default: throw new Error(`no operation ${op}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
// - Alternatives: Improve offers the engine's rewrite checked equivalent, plotted and
|
||||
// applied; Explore without a model says it needs one; a rewrite that reaches the GHZ
|
||||
// state by a different route is labelled so and flagged in Found, saved there with a
|
||||
// note, and kept with the page;
|
||||
// note, and kept with the page; an alternative that is the same circuit, taken whole, is
|
||||
// a choice the reader's taste learns from;
|
||||
// - the classical-shortcut lens: a Clifford ring with a closing rotation layer priced by
|
||||
// the cut-rank sum, a Clifford circuit easy at any width, a contraction searched only
|
||||
// when thorough, and a check marked stale once the program changes;
|
||||
|
|
@ -42,7 +43,10 @@
|
|||
// blind edits, keeps the reader's log-depth tree as the cheapest, opens it in the
|
||||
// editor, and comes back with the page; the tree is named a known construction, GHZ by
|
||||
// doubling, with what it beats the reference on that the cost does not weigh; on a
|
||||
// line, the tree is an aberration, offered with the rule to drop, and flagged; with the
|
||||
// line, the tree is an aberration, offered with the rule to drop, and flagged; your taste:
|
||||
// two kept generators compared and one chosen for its economy, the archive ranked by the
|
||||
// profile with each one's typicality, the dial turned, a second profile kept apart, and
|
||||
// the profiles kept with the page; with the
|
||||
// model, its generators are judged at every size, a wrong one and one with a gate the
|
||||
// specification forbids left out (an aberration), and it is told the specification and
|
||||
// shown the cheapest found; its CZ idea in Explore is a different route to GHZ;
|
||||
|
|
@ -224,6 +228,8 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten
|
|||
await page.click('#qs-review-apply');
|
||||
await page.waitForFunction(() => /rz\(0\.25 \+ 0\.5\) q\[0\];/.test(window.quantumStudio.view.state.doc.toString()), null, { timeout: LONG });
|
||||
check(true, 'taking it puts the alternative in the editor');
|
||||
await page.waitForFunction(() => document.querySelector('#qs-taste-count')?.textContent === '1', null, { timeout: LONG });
|
||||
check(/^1 choice\b/.test(await page.textContent('#qs-taste-summary')), 'taken whole, an alternative that is the same circuit is a choice the reader’s taste learns from');
|
||||
await page.click('#qs-explore');
|
||||
await page.waitForSelector('#qs-alt-nomodel', { timeout: LONG });
|
||||
check(true, 'Explore without a model says it needs one');
|
||||
|
|
@ -236,9 +242,10 @@ const chip = (k) => page.$eval(`#qs-measures [data-k=${k}]`, (c) => c.textConten
|
|||
await setProgram('OPENQASM 3.0;\nqubit[3] q;\nh q[2];\nx q[0];\nx q[0];\ncx q[2], q[1];\ncx q[1], q[0];\n');
|
||||
await page.click('#qs-improve');
|
||||
await page.waitForSelector('.qs-cand .qs-novelty', { timeout: LONG });
|
||||
const nv = await page.textContent('.qs-cand .qs-novelty');
|
||||
check(nv === 'a different route to GHZ, by a chain of CNOTs' && await page.$('.qs-cand.find'), `a rewrite that makes the GHZ state, built unlike the chain, is labelled a different route (${nv})`);
|
||||
check(/0\.\d\d apart/.test(await page.getAttribute('.qs-cand .qs-novelty', 'title')), 'the label says how far apart they are built');
|
||||
const labels = await page.$$eval('.qs-cand .qs-novelty', (bs) => bs.map((b) => [b.textContent, b.title]));
|
||||
const route = labels.find(([t]) => t === 'a different route to GHZ, by a chain of CNOTs');
|
||||
check(route && await page.$('.qs-cand.find'), `a rewrite that makes the GHZ state, built unlike the chain, is labelled a different route (${labels.map(([t]) => t).join(' | ')})`);
|
||||
check(/0\.\d\d apart/.test(route?.[1] ?? ''), 'the label says how far apart they are built');
|
||||
await page.waitForSelector('#qs-found-list .qs-found-item[data-kind=route]', { timeout: LONG });
|
||||
check((await page.textContent('#qs-found-count')) === '1 flagged · 0 saved' && /different route/.test(await page.textContent('#qs-found-list')) && /Improve/.test(await page.textContent('#qs-found-list')), 'Found flags it, and says where it came from');
|
||||
await page.click('#qs-found-list .qs-found-save');
|
||||
|
|
@ -475,6 +482,37 @@ const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb-
|
|||
await page.waitForFunction(() => /a known construction/.test(document.querySelector('#qs-wb-novelty')?.textContent ?? ''), null, { timeout: LONG });
|
||||
const novel = await page.textContent('#qs-wb-novelty');
|
||||
check(/GHZ, by doubling/.test(novel) && /Better than the reference on what the cost does not weigh: two-qubit depth \d+ against \d+/.test(novel), `the entry says what it is and what it beats the reference on unasked (${novel})`);
|
||||
// Your taste: two kept generators, which the engine proves make the same state, compared.
|
||||
if (!(await page.$eval('#qs-taste', (d) => d.open))) await page.click('#qs-taste > summary');
|
||||
await page.click('#qs-taste-compare');
|
||||
await page.waitForSelector('.qs-taste-pick', { timeout: LONG });
|
||||
const pair = await page.$$eval('.qs-taste-side', (ss) => ss.map((s) => s.dataset.name));
|
||||
check(pair.length === 2 && pair[0] !== pair[1] && /proves they do the same thing \(state\)/.test(await page.textContent('#qs-taste-pair')), `Compare two offers the pair the profile is least sure of, proved equal (${pair.join(', ')})`);
|
||||
await page.selectOption('#qs-taste-part', 'economy');
|
||||
await page.click('.qs-taste-side >> nth=0 >> .qs-taste-pick');
|
||||
await page.waitForSelector('#qs-taste-chosen', { timeout: LONG });
|
||||
check(new RegExp(`^Kept: ${pair[0]} over ${pair[1]}, for its economy`).test(await page.textContent('#qs-taste-chosen')), `the choice is kept, with what it was about (${await page.textContent('#qs-taste-chosen')})`);
|
||||
await page.waitForFunction(() => document.querySelector('#qs-taste-count')?.textContent === '2', null, { timeout: LONG });
|
||||
check(/^2 choices \(economy 1\)/.test(await page.textContent('#qs-taste-summary')), `the profile counts its choices and their parts (${await page.textContent('#qs-taste-summary')})`);
|
||||
await page.click('#qs-taste-rank');
|
||||
await page.waitForSelector('#qs-taste-list li', { timeout: LONG });
|
||||
const ranked = await page.$$eval('#qs-taste-list li', (ls) => ls.map((l) => l.textContent));
|
||||
const kept = await page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('wai.quantum-studio.workbench')).archive.cells).length);
|
||||
check(ranked.length === kept && ranked.every((t) => /typicality \d\.\d\d/.test(t) && /score -?\d/.test(t) && /cost \d/.test(t)), `Rank by my taste orders every kept generator, each with its typicality, score and cost (${ranked.length} of ${kept}: ${ranked[0]})`);
|
||||
check(kept <= 3 || /The first 3 are \d\.\d\d apart in how they are built; by cost, \d\.\d\d/.test(await page.textContent('#qs-taste-ranked')), 'the ranking says whether it narrows what comes first, against the cost');
|
||||
await page.evaluate(() => { window.qsListBefore = document.querySelector('#qs-taste-list'); });
|
||||
await page.$eval('#qs-taste-dial', (d) => { d.value = '1'; d.dispatchEvent(new Event('change')); });
|
||||
await page.waitForFunction(() => document.querySelector('#qs-taste-list') && document.querySelector('#qs-taste-list') !== window.qsListBefore, null, { timeout: LONG });
|
||||
check((await page.$$('#qs-taste-list li')).length === kept, 'turning the dial ranks them again');
|
||||
await page.$eval('#qs-taste-dial', (d) => { d.value = '0'; d.dispatchEvent(new Event('change')); });
|
||||
await page.fill('#qs-taste-profile', 'teaching');
|
||||
await page.dispatchEvent('#qs-taste-profile', 'change');
|
||||
await page.waitForFunction(() => /^No choices yet/.test(document.querySelector('#qs-taste-summary')?.textContent ?? ''), null, { timeout: LONG });
|
||||
check((await page.textContent('#qs-taste-count')) === '', 'a second profile is kept apart: it has no choices');
|
||||
await page.fill('#qs-taste-profile', 'mine');
|
||||
await page.dispatchEvent('#qs-taste-profile', 'change');
|
||||
await page.waitForFunction(() => document.querySelector('#qs-taste-count')?.textContent === '2', null, { timeout: LONG });
|
||||
check(true, 'and the first comes back with its choices');
|
||||
await setProgram(CHAIN);
|
||||
await page.click('#qs-wb-open');
|
||||
await page.waitForSelector('#qs-review:not([hidden]) #qs-review-apply', { timeout: LONG });
|
||||
|
|
@ -498,6 +536,7 @@ const wbIdle = () => page.waitForFunction(() => !document.querySelector('#qs-wb-
|
|||
await page.waitForFunction(() => /Taken up again: 102 evaluated/.test(document.querySelector('#qs-wb-log')?.textContent ?? ''), null, { timeout: LONG });
|
||||
check(/by person/.test(await page.textContent('#qs-wb-stats')) && (await page.inputValue('#qs-wb-sizes')) === 'n = 3, 5, 8', 'the archive and its specification come back with the page');
|
||||
await lintSays(/qubits/);
|
||||
check((await page.textContent('#qs-taste-count')) === '2' && (await page.inputValue('#qs-taste-profile')) === 'mine', 'the taste profiles come back with the page');
|
||||
check(/^\d+ flagged · 1 saved$/.test(await page.textContent('#qs-found-count')) && (await page.inputValue('#qs-found-list .qs-found-item.saved input')) === 'the ladder, read backwards', 'what Found keeps comes back with the page, with its note');
|
||||
// On a line, the tree breaks only the rule that gates join neighbours: an aberration.
|
||||
if (!(await page.$eval('#qs-wb-spec', (d) => d.open))) await page.click('#qs-wb-spec > summary');
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ A cheap route proves a design classically easy. None being cheap proves nothing
|
|||
- `verify_quantum_result`
|
||||
- `discover_evaluate`, `discover_submit` and `discover_archive` (the workbench, below)
|
||||
- `novelty_qasm` and `discover_novelty` (what is new, below)
|
||||
- `taste_rank` and `taste_next` (your taste, below)
|
||||
- `history_commit`, `history_changes`, `history_blame` and `history_log` (a program's history, below)
|
||||
- `list_methods`
|
||||
|
||||
|
|
@ -255,6 +256,25 @@ wai-quantum discover novelty ghz-all.json e12 # one entry of an archive
|
|||
|
||||
A high distance is a reason to look, not a claim that nobody has built it: the engine knows the constructions above and the programs it is given, not the literature.
|
||||
|
||||
## Your taste
|
||||
|
||||
`wai-quantum taste` learns your taste in circuits from your own choices:
|
||||
|
||||
```sh
|
||||
wai-quantum taste choose mine.qasm theirs.qasm --part clarity # you prefer mine: recorded once the engine proves them equal
|
||||
wai-quantum taste show # what it has learned, and how many of your choices it predicts
|
||||
wai-quantum taste rank a.qasm b.qasm c.qasm --novelty 0.5 # ranked by your taste, each with its typicality
|
||||
wai-quantum taste next --spec ghz-all.json # the pair of an archive's elites it is least sure of
|
||||
```
|
||||
|
||||
- **Only between equals.** A choice is recorded only when the engine proves the two programs the same circuit (or, with `--task prepares`, the same state from |0…0⟩). So a choice is about taste, never about correctness.
|
||||
- **Decomposed.** `--part` says what a choice was about: economy, clarity, insight, precision or utility. Each part is learned from its own choices, and a ranking says where a part, or the cost, puts another program first.
|
||||
- **Plural and personal.** `--profile NAME` keeps several (for teaching, for one device). They live in `quantum-taste.json` beside your key (`--taste FILE` for another), and nothing pools them.
|
||||
- **Typicality is shown, never learned.** Each program in a ranking shows how near the known constructions it is built. The profile never learns it. `--novelty` (−1 to 1) blends the order by your taste with the order by novelty: 0 is your taste alone, and either end orders by novelty alone.
|
||||
- **Measured.** `show` says how many of your choices it predicts, each from the ones before it and each held out from the rest. `rank` says whether its first three are built more alike than the first three by cost.
|
||||
|
||||
The model is a Bradley–Terry logistic over differences of what the engine measures: the gate counts and depths, the modelled error, and the program's text (its statements, its kinds of gate, how much repeats, its comments). It is fitted with a ridge penalty, so the same choices give the same weights everywhere. Through `wai-quantum mcp` an agent can rank programs by your profile (`taste_rank`) and ask which pair to show you next (`taste_next`), but it cannot record a choice: only you choose.
|
||||
|
||||
## The research tools
|
||||
|
||||
`wai-quantum lab …` holds the research tools:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ mod history;
|
|||
mod lab;
|
||||
mod locks;
|
||||
mod mcp;
|
||||
mod taste;
|
||||
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -216,6 +217,11 @@ enum Command {
|
|||
#[command(subcommand)]
|
||||
cmd: locks::Cmd,
|
||||
},
|
||||
/// Your taste in circuits, learned from your own choices between programs the engine proves equal (`wai-quantum taste --help`)
|
||||
Taste {
|
||||
#[command(subcommand)]
|
||||
cmd: taste::Cmd,
|
||||
},
|
||||
/// The research tools: error correction, DMRG, neural states, resource estimates and more (`wai-quantum lab help`)
|
||||
Lab {
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
|
|
@ -719,6 +725,7 @@ fn main() {
|
|||
Command::Discover { cmd } => discover::main(cmd),
|
||||
Command::History { cmd } => history::main(cmd),
|
||||
Command::Locks { cmd } => locks::main(cmd),
|
||||
Command::Taste { cmd } => taste::main(cmd),
|
||||
Command::Novelty { file, against, reference, weigh, json } => {
|
||||
let inputs = INPUTS.get().cloned().unwrap_or_default();
|
||||
let text = read_text(file.as_deref()).unwrap_or_else(|e| die(&e));
|
||||
|
|
|
|||
|
|
@ -132,6 +132,18 @@ fn tools() -> Value {
|
|||
"description": "For one entry of a workbench archive, read at the specification's largest size: its nearest known construction or elite and the distance, each known construction it is found to be (and whether by a different route), and the measures it beats the reference on that the specification's cost does not weigh.",
|
||||
"inputSchema": { "type": "object", "required": ["spec", "id"], "properties": { "spec": { "type": "string", "description": "The specification's path." }, "archive": { "type": "string", "description": "The archive's path, if not beside the specification." }, "id": { "type": "string", "description": "The entry's id." } } },
|
||||
},
|
||||
{
|
||||
"name": "taste_rank",
|
||||
"title": "Rank programs by the person's taste",
|
||||
"description": "Programs ranked by the person's own taste profile, learned from the choices they made between programs the engine proved equal: each with its score, each rubric part's score (economy, clarity, insight, precision, utility), and its typicality (nearness to the known constructions), which the profile never learns and only the novelty dial moves; where the parts and the cost put another first; and whether the ranking narrows what comes first. An agent cannot record a choice: only the person chooses, with `wai-quantum taste choose`.",
|
||||
"inputSchema": { "type": "object", "properties": { "programs": { "type": "array", "items": { "type": "object", "required": ["name", "qasm"], "properties": { "name": { "type": "string" }, "qasm": { "type": "string" }, "cost": { "type": "number" } } } }, "inputs": inputs_schema(), "spec": { "type": "string", "description": "A workbench specification's path: rank its archive's elites instead." }, "archive": { "type": "string" }, "novelty": { "type": "number", "description": "The novelty dial, -1 (toward the typical) to 1 (toward the novel)." }, "profile": { "type": "string", "description": "The profile's name (default mine)." } } },
|
||||
},
|
||||
{
|
||||
"name": "taste_next",
|
||||
"title": "The pair to show the person next",
|
||||
"description": "Among programs (or a specification's elites), the pair the person's taste profile is least sure of that the engine proves equal: the comparison that teaches the profile most. Show it to the person; only they choose.",
|
||||
"inputSchema": { "type": "object", "properties": { "programs": { "type": "array", "items": { "type": "object", "required": ["name", "qasm"], "properties": { "name": { "type": "string" }, "qasm": { "type": "string" } } } }, "inputs": inputs_schema(), "spec": { "type": "string" }, "archive": { "type": "string" }, "task": { "type": "string", "enum": ["equivalent", "prepares"] }, "profile": { "type": "string" } } },
|
||||
},
|
||||
{
|
||||
"name": "history_commit",
|
||||
"title": "Record your edit in a program's history",
|
||||
|
|
@ -180,6 +192,9 @@ fn call(name: &str, args: &Value, memory_gb: u64, key: &SigningKey, client: &Cli
|
|||
if name.starts_with("history_") {
|
||||
return history(name, args, key, client);
|
||||
}
|
||||
if name.starts_with("taste_") {
|
||||
return crate::taste::tool(name, args, None);
|
||||
}
|
||||
// A tool that reads a generator at its inputs itself takes them as given.
|
||||
let raw = args;
|
||||
let args = &at_inputs(args)?;
|
||||
|
|
@ -364,7 +379,7 @@ fn handle(msg: &Value, memory_gb: u64, key: &SigningKey, client: &mut Client) ->
|
|||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "wai-quantum", "title": "wai-quantum", "version": env!("CARGO_PKG_VERSION") },
|
||||
"instructions": "Quantum programs in OpenQASM, run on this machine as the quantum job service runs them. Plan or lint before a large run. Every result names its method and accuracy; quote its numbers rather than estimating them. When you change a person's program, record the change with history_commit: it is kept as yours, and the person sees which lines and gates you wrote. The person's rules bind your changes: lines between // @lock and // @unlock stay as they are, and NAME.locks.json beside the program may name the gates allowed, hold the qubit declarations, name what to avoid, or a region to keep to. history_changes shows what each change would break before you make it."
|
||||
"instructions": "Quantum programs in OpenQASM, run on this machine as the quantum job service runs them. Plan or lint before a large run. Every result names its method and accuracy; quote its numbers rather than estimating them. When you change a person's program, record the change with history_commit: it is kept as yours, and the person sees which lines and gates you wrote. The person's rules bind your changes: lines between // @lock and // @unlock stay as they are, and NAME.locks.json beside the program may name the gates allowed, hold the qubit declarations, name what to avoid, or a region to keep to. history_changes shows what each change would break before you make it. Call a program new only when novelty_qasm says so, and then as the engine's distance from what it knows, not a search of the literature. To offer the person programs to choose from, taste_rank orders them by their own taste; only the person records a choice."
|
||||
})),
|
||||
"ping" => ok(json!({})),
|
||||
"tools/list" => ok(json!({ "tools": tools() })),
|
||||
|
|
@ -416,10 +431,11 @@ mod tests {
|
|||
assert_eq!(init["result"]["serverInfo"]["name"], "wai-quantum");
|
||||
let told = init["result"]["instructions"].as_str().unwrap();
|
||||
assert!(told.contains("history_commit") && told.contains("// @lock"), "an agent is told to record its changes and keep the person's rules: {told}");
|
||||
assert!(told.contains("novelty_qasm") && told.contains("only the person records a choice"), "and what it may call new, and that the person's taste is theirs: {told}");
|
||||
assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), 1, &key(), &mut Client::default()).is_none());
|
||||
let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), 1, &key(), &mut Client::default()).unwrap();
|
||||
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "discover_evaluate", "discover_submit", "discover_archive", "novelty_qasm", "discover_novelty", "history_commit", "history_changes", "history_blame", "history_log", "list_methods"]);
|
||||
assert_eq!(names, ["run_quantum_circuit", "plan_quantum_circuit", "lint_qasm", "critique_qasm", "check_equivalent", "improve_qasm", "classical_shortcuts", "draw_circuit", "verify_quantum_result", "discover_evaluate", "discover_submit", "discover_archive", "novelty_qasm", "discover_novelty", "taste_rank", "taste_next", "history_commit", "history_changes", "history_blame", "history_log", "list_methods"]);
|
||||
let run = handle(
|
||||
&json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "run_quantum_circuit", "arguments": { "qasm": "OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];", "observables": ["Z0 Z1"] } } }),
|
||||
1,
|
||||
|
|
|
|||
296
wai/wai-quantum-cli/src/taste.rs
Normal file
296
wai/wai-quantum-cli/src/taste.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! `wai-quantum taste`: the person's taste in circuits, learned from their
|
||||
//! own choices between programs the engine proves equal
|
||||
//! ([`wai_quantum::engine::taste`]). The profiles are kept in
|
||||
//! `$XDG_CONFIG_HOME/wai/quantum-taste.json` (or `~/.config/wai/…`), beside
|
||||
//! this user's key, or in `--taste FILE`:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "profiles": { "mine": { "taste": 1, "name": "mine", "choices": [ … ] } } }
|
||||
//! ```
|
||||
//!
|
||||
//! Only the person records a choice. Through `wai-quantum mcp` an agent may
|
||||
//! rank programs by a profile and ask for the pair to compare next, and
|
||||
//! cannot choose.
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use wai_quantum::engine::discover::Evaluator;
|
||||
use wai_quantum::engine::taste::{self, Candidate, Profile};
|
||||
use wai_quantum::qasm::Inputs;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct Where {
|
||||
/// The profile's name: one person can keep several (for teaching, for one device)
|
||||
#[arg(long, default_value = "mine")]
|
||||
profile: String,
|
||||
/// The file the profiles are kept in
|
||||
#[arg(long, value_name = "FILE")]
|
||||
taste: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct Among {
|
||||
/// The programs
|
||||
files: Vec<PathBuf>,
|
||||
/// A workbench specification: its archive's elites instead, read at its largest size
|
||||
#[arg(long, value_name = "SPEC")]
|
||||
spec: Option<PathBuf>,
|
||||
/// The archive, if not beside the specification
|
||||
#[arg(long)]
|
||||
archive: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Cmd {
|
||||
/// Record that you prefer one program to another, once the engine proves them equal
|
||||
Choose {
|
||||
/// The program you prefer
|
||||
chosen: PathBuf,
|
||||
/// The other
|
||||
other: PathBuf,
|
||||
/// What the choice was about: economy, clarity, insight, precision or utility
|
||||
#[arg(long)]
|
||||
part: Option<String>,
|
||||
/// `prepares`: equal when they make the same state from |0…0⟩; otherwise the same circuit
|
||||
#[arg(long, default_value = "equivalent")]
|
||||
task: String,
|
||||
#[command(flatten)]
|
||||
at: Where,
|
||||
},
|
||||
/// What a profile has learned, and how many of your own choices it predicts
|
||||
Show {
|
||||
#[command(flatten)]
|
||||
at: Where,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Programs ranked by a profile, each with its typicality, and where the parts and the cost disagree
|
||||
Rank {
|
||||
#[command(flatten)]
|
||||
among: Among,
|
||||
/// The novelty dial: −1 toward the typical, 1 toward the novel
|
||||
#[arg(long, default_value_t = 0.0, allow_hyphen_values = true)]
|
||||
novelty: f64,
|
||||
#[command(flatten)]
|
||||
at: Where,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// The pair the profile is least sure of, among programs the engine proves equal: the choice that teaches it most
|
||||
Next {
|
||||
#[command(flatten)]
|
||||
among: Among,
|
||||
/// `prepares` or `equivalent` (a specification's own task by default)
|
||||
#[arg(long)]
|
||||
task: Option<String>,
|
||||
#[command(flatten)]
|
||||
at: Where,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// This user's profiles file.
|
||||
pub fn user_path() -> Option<PathBuf> {
|
||||
wai_quantum_cli::keys::user_path().map(|k| k.with_file_name("quantum-taste.json"))
|
||||
}
|
||||
|
||||
fn file_of(given: Option<&Path>) -> Result<PathBuf, String> {
|
||||
given.map(Path::to_path_buf).or_else(user_path).ok_or_else(|| "no HOME to keep the profiles in: pass --taste FILE".to_string())
|
||||
}
|
||||
|
||||
/// Every profile in `path`, as JSON (none if it does not exist yet).
|
||||
fn read_all(path: &Path) -> Result<Value, String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(t) => serde_json::from_str(&t).map_err(|e| format!("{}: not JSON: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({ "profiles": {} })),
|
||||
Err(e) => Err(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile `name` in `path`, or a new one.
|
||||
pub fn profile(path: &Path, name: &str) -> Result<Profile, String> {
|
||||
match read_all(path)?["profiles"].get(name) {
|
||||
Some(v) => Profile::from_json(v),
|
||||
None => Ok(Profile::new(name)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save(path: &Path, p: &Profile) -> Result<(), String> {
|
||||
let mut all = read_all(path)?;
|
||||
all["profiles"][&p.name] = serde_json::to_value(p).map_err(|e| e.to_string())?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&all).expect("profiles are JSON") + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The elites of a specification's archive as candidates, the inputs of
|
||||
/// its largest size, and its task.
|
||||
pub fn elites(spec: &Path, archive: Option<&Path>) -> Result<(Vec<Candidate>, Inputs, String), String> {
|
||||
let ev = Evaluator::new(crate::discover::read_spec(spec)?)?;
|
||||
let a = crate::discover::load(&ev, &crate::discover::archive_path(spec, archive))?;
|
||||
let inputs = ev.spec.instances().last().cloned().unwrap_or_default();
|
||||
let task = serde_json::to_value(ev.spec.task).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "equivalent".into());
|
||||
let mut c: Vec<Candidate> = a.elites().into_iter().map(|e| Candidate { name: e.id.clone(), source: e.source.clone(), cost: Some(e.cost) }).collect();
|
||||
c.sort_by(|x, y| x.name.cmp(&y.name));
|
||||
Ok((c, inputs, task))
|
||||
}
|
||||
|
||||
fn among(m: &Among) -> Result<(Vec<Candidate>, Inputs, Option<String>), String> {
|
||||
if let Some(spec) = &m.spec {
|
||||
let (c, i, t) = elites(spec, m.archive.as_deref())?;
|
||||
return Ok((c, i, Some(t)));
|
||||
}
|
||||
if m.files.len() < 2 {
|
||||
return Err("give two programs or more, or --spec".into());
|
||||
}
|
||||
let c = m.files.iter().map(|p| Ok(Candidate { name: p.display().to_string(), source: super::read_text(Some(p))?, cost: None })).collect::<Result<_, String>>()?;
|
||||
Ok((c, super::INPUTS.get().cloned().unwrap_or_default(), None))
|
||||
}
|
||||
|
||||
/// A summary in words.
|
||||
pub fn summary_text(s: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
let parts: Vec<String> = s["parts"].as_object().into_iter().flatten().map(|(k, n)| format!("{k} {n}")).collect();
|
||||
out += &format!("profile {}: {} choice{}{}\n", s["name"].as_str().unwrap_or(""), s["choices"], if s["choices"] == 1 { "" } else { "s" }, if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) });
|
||||
let (m, h) = (&s["as_made"], &s["held_out"]);
|
||||
if m["of"].as_u64().unwrap_or(0) > 0 {
|
||||
out += &format!("it predicts {} of the {} made after the first, each from the ones before; {} of {} held out\n", m["right"], m["of"], h["right"], h["of"]);
|
||||
}
|
||||
let lean: Vec<&str> = s["leanings"].as_array().into_iter().flatten().filter_map(|l| l["words"].as_str()).collect();
|
||||
if !lean.is_empty() {
|
||||
out += &format!("leans toward {}\n", lean.join(", "));
|
||||
}
|
||||
if s["unmeasured"].as_u64().unwrap_or(0) > 0 {
|
||||
out += &format!("{} choice(s) differ in nothing the engine measures\n", s["unmeasured"]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A ranking in words.
|
||||
pub fn rank_text(r: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
if r["choices"] == 0 {
|
||||
out += "no choices yet: ordered by the novelty dial alone\n";
|
||||
}
|
||||
for (i, x) in r["ranked"].as_array().into_iter().flatten().enumerate() {
|
||||
let parts: Vec<String> = x["parts"].as_object().into_iter().flatten().map(|(k, v)| format!("{k} {v}")).collect();
|
||||
let typ = x["typicality"].as_f64().map(|t| format!("typicality {t:.2}")).unwrap_or_else(|| "typicality —".into());
|
||||
let cost = x["cost"].as_f64().map(|c| format!(" cost {c}")).unwrap_or_default();
|
||||
out += &format!("{:>2}. {} score {} {typ}{cost}{}\n", i + 1, x["name"].as_str().unwrap_or(""), x["score"], if parts.is_empty() { String::new() } else { format!(" ({})", parts.join(", ")) });
|
||||
}
|
||||
let dis: Vec<String> = r["disagree"].as_array().into_iter().flatten().map(|d| format!("{} puts {} first", if d["part"] == "cost" { "the cost".to_string() } else { d["part"].as_str().unwrap_or("").to_string() }, d["first"].as_str().unwrap_or(""))).collect();
|
||||
if !dis.is_empty() {
|
||||
out += &format!("disagreeing: {}\n", dis.join("; "));
|
||||
}
|
||||
let n = &r["narrowing"];
|
||||
if let (Some(a), Some(b)) = (n["ranked"].as_f64(), n["baseline"].as_f64()) {
|
||||
out += &format!("the first {} are {a:.2} apart; by {}, {b:.2}{}\n", n["first"], n["by"].as_str().unwrap_or(""), if n["narrows"] == true { ": this ranking narrows what comes first" } else { "" });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn main(cmd: Cmd) {
|
||||
let r = (|| match cmd {
|
||||
Cmd::Choose { chosen, other, part, task, at } => {
|
||||
let path = file_of(at.taste.as_deref())?;
|
||||
let mut p = profile(&path, &at.profile)?;
|
||||
let inputs = super::INPUTS.get().cloned().unwrap_or_default();
|
||||
let v = taste::choose(&mut p, &super::read_text(Some(&chosen))?, &super::read_text(Some(&other))?, part.as_deref(), &task, &inputs, now())?;
|
||||
save(&path, &p)?;
|
||||
println!("recorded: {} over {} (the engine proves them equal: {}); {} has {} choices", chosen.display(), other.display(), v["how"].as_str().unwrap_or(""), p.name, p.choices.len());
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Show { at, json } => {
|
||||
let s = taste::summary(&profile(&file_of(at.taste.as_deref())?, &at.profile)?);
|
||||
if json { println!("{s}") } else { print!("{}", summary_text(&s)) }
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Rank { among: m, novelty, at, json } => {
|
||||
let p = profile(&file_of(at.taste.as_deref())?, &at.profile)?;
|
||||
let (c, inputs, _) = among(&m)?;
|
||||
let r = taste::rank(&p, &c, &inputs, novelty)?;
|
||||
if json { println!("{r}") } else { print!("{}", rank_text(&r)) }
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Next { among: m, task, at, json } => {
|
||||
let p = profile(&file_of(at.taste.as_deref())?, &at.profile)?;
|
||||
let (c, inputs, own) = among(&m)?;
|
||||
let r = taste::next(&p, &c, &inputs, task.or(own).as_deref().unwrap_or("equivalent"))?;
|
||||
if json {
|
||||
println!("{r}");
|
||||
} else if r["a"].is_null() {
|
||||
println!("no pair to compare: {}", r["why"].as_str().unwrap_or(""));
|
||||
} else {
|
||||
println!("compare {} and {}: the engine proves them equal ({}); the profile gives the first {}", r["a"].as_str().unwrap_or(""), r["b"].as_str().unwrap_or(""), r["how"].as_str().unwrap_or(""), r["p_a"]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
})();
|
||||
r.unwrap_or_else(|e: String| super::die(&e));
|
||||
}
|
||||
|
||||
/// The taste tools an agent may call through MCP, reading the person's
|
||||
/// profiles from `file` (this user's by default): `taste_rank` and
|
||||
/// `taste_next`, over `programs` (`[{name, qasm}]`) or a specification's
|
||||
/// elites (`spec`, `archive`).
|
||||
pub fn tool(name: &str, args: &Value, file: Option<&Path>) -> Result<Value, String> {
|
||||
let path = file_of(file)?;
|
||||
let p = profile(&path, args.get("profile").and_then(Value::as_str).unwrap_or("mine"))?;
|
||||
let (c, inputs, own) = if let Some(spec) = args.get("spec").and_then(Value::as_str) {
|
||||
let (c, i, t) = elites(Path::new(spec), args.get("archive").and_then(Value::as_str).map(Path::new))?;
|
||||
(c, i, Some(t))
|
||||
} else {
|
||||
let c: Vec<Candidate> = args
|
||||
.get("programs")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("give \"programs\": [{name, qasm}], or \"spec\"")?
|
||||
.iter()
|
||||
.map(|x| Ok(Candidate { name: x.get("name").and_then(Value::as_str).ok_or("a program needs a name")?.to_string(), source: x.get("qasm").and_then(Value::as_str).ok_or("a program needs its qasm")?.to_string(), cost: x.get("cost").and_then(Value::as_f64) }))
|
||||
.collect::<Result<_, String>>()?;
|
||||
let inputs: Inputs = match args.get("inputs") {
|
||||
None | Some(Value::Null) => Inputs::new(),
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("inputs: {e}"))?,
|
||||
};
|
||||
(c, inputs, None)
|
||||
};
|
||||
match name {
|
||||
"taste_rank" => taste::rank(&p, &c, &inputs, args.get("novelty").and_then(Value::as_f64).unwrap_or(0.0)),
|
||||
"taste_next" => taste::next(&p, &c, &inputs, args.get("task").and_then(Value::as_str).or(own.as_deref()).unwrap_or("equivalent")),
|
||||
_ => Err(format!("no tool {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_profile_is_kept_by_name_and_an_agent_ranks_with_it() {
|
||||
let dir = std::env::temp_dir().join(format!("wai-quantum-taste-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let file = dir.join("taste.json");
|
||||
let bell = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n";
|
||||
let padded = format!("{bell}cx q[0], q[1];\ncx q[0], q[1];\n");
|
||||
let mut p = profile(&file, "teaching").unwrap();
|
||||
taste::choose(&mut p, bell, &padded, Some("economy"), "equivalent", &Inputs::new(), 1).unwrap();
|
||||
save(&file, &p).unwrap();
|
||||
save(&file, &Profile::new("device")).unwrap();
|
||||
assert_eq!(profile(&file, "teaching").unwrap().choices.len(), 1);
|
||||
assert_eq!(profile(&file, "device").unwrap().choices.len(), 0, "profiles are kept apart");
|
||||
let r = tool("taste_rank", &json!({ "profile": "teaching", "programs": [{ "name": "padded", "qasm": padded }, { "name": "bell", "qasm": bell }] }), Some(&file)).unwrap();
|
||||
assert_eq!(r["ranked"][0]["name"], "bell", "{r}");
|
||||
assert!(rank_text(&r).contains(" 1. bell"), "{}", rank_text(&r));
|
||||
assert!(summary_text(&taste::summary(&profile(&file, "teaching").unwrap())).starts_with("profile teaching: 1 choice (economy 1)"));
|
||||
let n = tool("taste_next", &json!({ "profile": "teaching", "programs": [{ "name": "padded", "qasm": padded }, { "name": "bell", "qasm": bell }] }), Some(&file)).unwrap();
|
||||
assert_eq!(n["how"], "tableau", "{n}");
|
||||
assert!(tool("taste_choose", &json!({ "programs": [] }), Some(&file)).unwrap_err().contains("no tool"), "an agent cannot choose");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use wai_quantum::ed25519_dalek::SigningKey;
|
||||
use wai_quantum::engine::discover::{self, Archive, Budget, Evaluator, SearchOptions, Spec, search};
|
||||
use wai_quantum::engine::{catalog, critique, draw, energy, equiv, job, locks, novelty, shortcuts, versions};
|
||||
use wai_quantum::engine::{catalog, critique, draw, energy, equiv, job, locks, novelty, shortcuts, taste, versions};
|
||||
|
||||
/// What a result says of its energy here.
|
||||
const UNMETERED: &str = "the desktop app does not meter a run's energy";
|
||||
|
|
@ -223,6 +223,17 @@ fn novelty(args: String) -> String {
|
|||
.unwrap_or_else(error_json)
|
||||
}
|
||||
|
||||
/// `name` on the person's taste model ([`taste::call`]), the profile kept by
|
||||
/// the Studio and passed in the arguments: the answer, or `{error}`.
|
||||
#[tauri::command]
|
||||
fn taste(name: String, args: String) -> String {
|
||||
serde_json::from_str::<Value>(&args)
|
||||
.map_err(|e| format!("{name}: the arguments are not JSON: {e}"))
|
||||
.and_then(|a| taste::call(&name, &a))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(error_json)
|
||||
}
|
||||
|
||||
/// What is new in one entry of an archive ([`discover::novelty`]), or `{error}`.
|
||||
#[tauri::command]
|
||||
fn discover_novelty(spec: String, archive: String, id: String) -> String {
|
||||
|
|
@ -432,7 +443,7 @@ fn main() {
|
|||
};
|
||||
tauri::Builder::default()
|
||||
.manage(Studio { key, job_bytes: job_bytes(), running: Arc::new(Mutex::new(None)), cancelled: Arc::new(Mutex::new(false)) })
|
||||
.invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, discover_novelty, instantiate, history, locks, novelty])
|
||||
.invoke_handler(tauri::generate_handler![run_job, cancel, plan_job, methods, lint, critique, equivalent, variants, shortcuts, draw, layout, verify, signer, save_job, save_program, chat, models, discover_start, discover_submit, discover_search, discover_evaluate, discover_novelty, instantiate, history, locks, novelty, taste])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("the window opens");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ never creates information.
|
|||
| `quantum` | byte-exact statevector simulation (dyadic Clifford+T+P(k)), as `wai.quantum.circuit2` by default or as the frozen `wai.quantum.circuit` |
|
||||
| `quantum_toolchain` | algorithm library, backend recommendation, **OpenQASM 2/3 ingest of what the mainstream toolchains actually export** — both register and both measurement spellings, `rx`/`ry`/`rz`/`sx`/`u`/`u2`/`u3`/`crz`, and exact rational-multiple-of-pi angles (non-dyadic angles are refused, never rounded); **device-ready OpenQASM 2/3** (`device::device_qasm`): every gate lowered exactly to its dialect's standard library, multi-controlled gates included, every qubit measured into `c[i]`, and the service's counts read back onto the crate's basis; **native programs** (`native::compile`): a processor's own basis (`rz`/`sx`/`x`/`cz`; `prx`/`cz`; or `gpi`/`gpi2`/`zz` with angles in turns, written exactly; the Z rotations carried in the phases of the last two) on its own qubits, routed over its coupling map, and checked against the circuit by simulation before it is sent — Bloch / entanglement / purity analysis |
|
||||
| `quantum_qasm` | **the OpenQASM reader the job service runs**, for every surface that takes a program: OpenQASM 3 and the OpenQASM 2 forms in common use, every register, a gate on a whole register, gate definitions with parameters, `ctrl`/`negctrl` modifiers, angle expressions, comments, and measurement in every spelling. A program that measures and then acts, resets after a gate or uses `if` is kept as dynamic steps. Generators: integer `input`s, `const`s and `for` loops (ranges, steps, sets, nested), with integer arithmetic in indices and sizes and `if` on integers decided on reading, so one program describes a family of circuits; `qasm::parse_with` reads it at given inputs and `qasm::instantiate` writes that circuit out as plain OpenQASM. `while`, `inv`, `pow` and anything else not listed are refused with their line. Lowered to each method's input exactly up to a global phase. `qasm::dyadic_circuit` gives the exact state vector's circuit (dyadic angles) for a program whose measurements all come at the end, and refuses any other with the reason |
|
||||
| `quantum_engine` | **the quantum job service's engine, for every host**: a job (an OpenQASM program and what is asked of it: shots, Pauli expectation values, amplitudes, noise) planned onto the strongest method it admits, with the reason and the memory and work it will take; run; answered in the service's JSON with its accuracy class; and sealed over the request and the result in their canonical JSON (`engine::job`, `engine::receipt`), so a result from the service, the command line or a browser tab checks the same way. Also a program's circuit drawn as text (`engine::draw`); the specification-and-evaluator workbench (`engine::discover`: a specification, the engine's verdict on a generator at every size, and an archive of the cheapest verified generator in every cell of two measures, sealed); a program's history, attributed (`engine::versions`: versions in a sealed chain, each with who made it and how, proposals reviewed change by change, and each line and gate in the hand that wrote it, which a job's receipt can bind); the person's rules for proposals (`engine::locks`: held blocks, the gates allowed, the qubit declarations, what to avoid and a region, checked change by change); what is new in a program (`engine::novelty`: its structural distance to the known constructions and to programs given, each known construction it is proved to be and whether by a different route, and the strengths a cost did not ask for; a workbench candidate that breaks only a rule is an aberration, with the cost it would have); and the CPU energy the kernel counts for one process (`engine::energy`). It builds for `wasm32-unknown-unknown`, where it runs on one thread and claims no time |
|
||||
| `quantum_engine` | **the quantum job service's engine, for every host**: a job (an OpenQASM program and what is asked of it: shots, Pauli expectation values, amplitudes, noise) planned onto the strongest method it admits, with the reason and the memory and work it will take; run; answered in the service's JSON with its accuracy class; and sealed over the request and the result in their canonical JSON (`engine::job`, `engine::receipt`), so a result from the service, the command line or a browser tab checks the same way. Also a program's circuit drawn as text (`engine::draw`); the specification-and-evaluator workbench (`engine::discover`: a specification, the engine's verdict on a generator at every size, and an archive of the cheapest verified generator in every cell of two measures, sealed); a program's history, attributed (`engine::versions`: versions in a sealed chain, each with who made it and how, proposals reviewed change by change, and each line and gate in the hand that wrote it, which a job's receipt can bind); the person's rules for proposals (`engine::locks`: held blocks, the gates allowed, the qubit declarations, what to avoid and a region, checked change by change); what is new in a program (`engine::novelty`: its structural distance to the known constructions and to programs given, each known construction it is proved to be and whether by a different route, and the strengths a cost did not ask for; a workbench candidate that breaks only a rule is an aberration, with the cost it would have); a person's taste (`engine::taste`: a Bradley–Terry model of their own choices between programs the engine proves equal, part by part of a rubric, with typicality shown beside it and moved only by a dial, and how well it predicts them measured); and the CPU energy the kernel counts for one process (`engine::energy`). It builds for `wasm32-unknown-unknown`, where it runs on one thread and claims no time |
|
||||
| `quantum_stabilizer` | stabilizer (CHP) tableau — `O(n²)`, scales far past statevector |
|
||||
| `quantum_mps` | tensor-network (matrix-product-state) backend |
|
||||
| `quantum_pauli` | sparse-Pauli / Heisenberg observable propagation over the dyadic gate set (up to 64 qubits) |
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
//! caller's own circuits), each known construction the engine proves it to
|
||||
//! be found again (and whether by a different route), and the strengths a
|
||||
//! cost did not ask for.
|
||||
//! - [`taste`] (with `quantum_receipt`): a person's taste in circuits,
|
||||
//! learned from their own choices between programs the engine proves
|
||||
//! equal, part by part of a rubric; typicality shown beside it and moved
|
||||
//! only by a dial, and how well it predicts the person measured.
|
||||
//! - [`versions`] (with `quantum_receipt`): a program's history, attributed.
|
||||
//! Every version names its parents, who made it (the person, a model, an
|
||||
//! outside agent, the engine) and how (an edit, a proposal reviewed change
|
||||
|
|
@ -73,4 +77,6 @@ pub mod receipt;
|
|||
pub mod run;
|
||||
pub mod shortcuts;
|
||||
#[cfg(feature = "quantum_receipt")]
|
||||
pub mod taste;
|
||||
#[cfg(feature = "quantum_receipt")]
|
||||
pub mod versions;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ pub fn known(n: u32) -> Vec<Known> {
|
|||
}
|
||||
|
||||
/// A gate as a token: its kind and its qubits relative to its first.
|
||||
fn token(op: &qasm::Op) -> String {
|
||||
pub(super) fn token(op: &qasm::Op) -> String {
|
||||
let first = op.qubits.first().copied().unwrap_or(0) as i64;
|
||||
let rel: Vec<String> = op.qubits.iter().map(|&q| (q as i64 - first).to_string()).collect();
|
||||
format!("{}:{}", critique::kind_name(op.kind), rel.join(","))
|
||||
|
|
|
|||
658
wai/wai-quantum/src/engine/taste.rs
Normal file
658
wai/wai-quantum/src/engine/taste.rs
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
//! A person's taste in circuits, learned from their own choices.
|
||||
//!
|
||||
//! - **Only between equals.** A choice is between two programs the engine
|
||||
//! proves do the same thing (the same circuit, or the same state from
|
||||
//! `|0…0⟩`), so it is about taste and never about correctness. A pair the
|
||||
//! engine cannot prove equal is refused.
|
||||
//! - **Decomposed.** A choice may name what it was about, one of the
|
||||
//! rubric's [`PARTS`]: economy, clarity, insight, precision, utility (after
|
||||
//! Inglis and Aberdein's appraisals of proofs). Each part is learned from
|
||||
//! the choices that name it and the whole from all of them, so where the
|
||||
//! parts disagree it shows.
|
||||
//! - **Plural and personal.** A [`Profile`] is one person's, under a name
|
||||
//! they give it (for teaching, for one device). Nothing pools profiles, and
|
||||
//! no score belongs to anyone but its profile.
|
||||
//! - **Typicality is shown, never learned.** How near a program is to the
|
||||
//! known constructions ([`super::novelty`]) is shown beside each ranking and
|
||||
//! moved only by the novelty dial, which blends the order by taste with the
|
||||
//! order by novelty: a preference model left to learn it sharpens toward
|
||||
//! the typical.
|
||||
//! - **Measured.** A profile says how many of its own choices it predicts,
|
||||
//! each from the ones made before it and each held out from the rest, and a
|
||||
//! ranking says whether it narrows what it puts first: the mean structural
|
||||
//! distance among its first few, against the same number by cost.
|
||||
//!
|
||||
//! The model is a Bradley–Terry logistic over differences of [`FEATURES`],
|
||||
//! measures of a program at its inputs and of its text, fitted with a ridge
|
||||
//! penalty by Newton's method: the same choices give the same weights on
|
||||
//! every host.
|
||||
|
||||
use super::{critique, equiv, novelty};
|
||||
use crate::qasm::{self, Inputs, Program};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// The form of a profile this engine reads and writes.
|
||||
pub const FORM: u32 = 1;
|
||||
|
||||
/// The features a choice is learned from, with the words a leaning toward
|
||||
/// less and toward more of each is said in. Counts are taken as `ln(1 + x)`,
|
||||
/// so a choice between 3 and 4 gates weighs about as much as one between 30
|
||||
/// and 40; `error` is `−ln` of the critique's modelled fidelity; `repetition`
|
||||
/// is the share of consecutive gate pairs that repeat one already seen.
|
||||
pub const FEATURES: &[(&str, &str, &str)] = &[
|
||||
("gates", "fewer gates", "more gates"),
|
||||
("two_qubit", "fewer two-qubit gates", "more two-qubit gates"),
|
||||
("depth", "less depth", "more depth"),
|
||||
("two_qubit_depth", "less two-qubit depth", "more two-qubit depth"),
|
||||
("t_count", "fewer T gates", "more T gates"),
|
||||
("error", "less modelled error", "more modelled error"),
|
||||
("statements", "shorter text", "longer text"),
|
||||
("kinds", "fewer kinds of gate", "more kinds of gate"),
|
||||
("repetition", "less repetition", "more repetition"),
|
||||
("comments", "fewer comments", "more comments"),
|
||||
];
|
||||
|
||||
/// The rubric's parts a choice may be about.
|
||||
pub const PARTS: &[(&str, &str)] = &[
|
||||
("economy", "what it spends: gates, depth, T gates"),
|
||||
("clarity", "how readily it is read"),
|
||||
("insight", "what it shows about the problem"),
|
||||
("precision", "how exactly it does its job, under noise too"),
|
||||
("utility", "how readily it is used elsewhere"),
|
||||
];
|
||||
|
||||
/// The ridge penalty, on weights over features scaled to their spread.
|
||||
const RIDGE: f64 = 1.0;
|
||||
/// The least spread a feature is scaled by: a feature that barely varied
|
||||
/// over the choices is not given a weight that a larger difference between
|
||||
/// candidates would turn into certainty.
|
||||
const SPREAD_FLOOR: f64 = 0.1;
|
||||
/// A leaning is named when its weight, on the feature scaled to its spread
|
||||
/// over the choices, is at least this.
|
||||
const LEANING: f64 = 0.3;
|
||||
/// The most choices the profile's measures are taken over (the last ones).
|
||||
const MEASURED: usize = 100;
|
||||
/// The verdicts a choice accepts: proofs, not evidence on random inputs.
|
||||
const PROOFS: &[&str] = &["tableau", "exact", "state"];
|
||||
|
||||
/// One side of a choice: the program, its digest and its features.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Side {
|
||||
pub digest: String,
|
||||
pub source: String,
|
||||
pub features: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
/// A choice: the program preferred, the other, what it was about, and how
|
||||
/// the engine proved them equal.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Choice {
|
||||
pub chosen: Side,
|
||||
pub other: Side,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub part: Option<String>,
|
||||
/// `equivalent` (the same circuit) or `prepares` (the same state from
|
||||
/// `|0…0⟩`).
|
||||
pub task: String,
|
||||
/// The check that proved it: `tableau`, `exact` or `state`.
|
||||
pub how: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub inputs: Inputs,
|
||||
/// When, in the host's seconds since 1970; 0 if it gave none.
|
||||
#[serde(default)]
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
/// One person's taste under one name: every choice they made, in order.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Profile {
|
||||
pub taste: u32,
|
||||
pub name: String,
|
||||
pub choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(name: &str) -> Profile {
|
||||
Profile { taste: FORM, name: name.to_string(), choices: Vec::new() }
|
||||
}
|
||||
|
||||
/// A profile from its JSON, in a form this engine reads.
|
||||
pub fn from_json(v: &Value) -> Result<Profile, String> {
|
||||
let p: Profile = serde_json::from_value(v.clone()).map_err(|e| format!("the taste profile is not one: {e}"))?;
|
||||
if p.taste != FORM {
|
||||
return Err(format!("a taste profile of form {}; this engine reads form {FORM}", p.taste));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn features_of(src: &str, p: &Program) -> BTreeMap<String, f64> {
|
||||
let m = critique::measures(p);
|
||||
let count = |k: &str| (m[k].as_u64().unwrap_or(0) as f64).ln_1p();
|
||||
let text: Vec<&str> = src.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
|
||||
let comments = text.iter().filter(|l| l.starts_with("//")).count();
|
||||
let statements = text.iter().filter(|l| !l.starts_with("//") && !matches!(**l, "{" | "}")).count();
|
||||
let toks: Vec<String> = p.ops.iter().map(novelty::token).collect();
|
||||
let pairs: Vec<(&String, &String)> = toks.iter().zip(toks.iter().skip(1)).collect();
|
||||
let distinct: BTreeSet<&(&String, &String)> = pairs.iter().collect();
|
||||
let repetition = if pairs.is_empty() { 0.0 } else { 1.0 - distinct.len() as f64 / pairs.len() as f64 };
|
||||
let fidelity = m["fidelity_model"]["estimate"].as_f64().unwrap_or(1.0).max(f64::MIN_POSITIVE);
|
||||
let mut f = BTreeMap::new();
|
||||
for k in ["gates", "two_qubit", "depth", "two_qubit_depth", "t_count"] {
|
||||
f.insert(k.to_string(), count(k));
|
||||
}
|
||||
f.insert("error".into(), -fidelity.ln());
|
||||
f.insert("statements".into(), (statements as f64).ln_1p());
|
||||
f.insert("kinds".into(), m["by_gate"].as_object().map_or(0, |o| o.len()) as f64);
|
||||
f.insert("repetition".into(), repetition);
|
||||
f.insert("comments".into(), (comments as f64).ln_1p());
|
||||
f
|
||||
}
|
||||
|
||||
/// A program's [`FEATURES`], read at `inputs`.
|
||||
pub fn features(src: &str, inputs: &Inputs) -> Result<BTreeMap<String, f64>, String> {
|
||||
let p = qasm::parse_with(src, inputs).map_err(|e| format!("it does not read: {e}"))?;
|
||||
Ok(features_of(src, &p))
|
||||
}
|
||||
|
||||
fn vector(f: &BTreeMap<String, f64>) -> Vec<f64> {
|
||||
FEATURES.iter().map(|(k, _, _)| f.get(*k).copied().unwrap_or(0.0)).collect()
|
||||
}
|
||||
|
||||
fn side(src: &str, p: &Program) -> Side {
|
||||
Side { digest: hex(blake3::hash(src.as_bytes()).as_bytes()), source: src.to_string(), features: features_of(src, p) }
|
||||
}
|
||||
|
||||
/// Whether the engine proves `a` and `b` equal for `task`: the verdict.
|
||||
fn equal(a: &Program, b: &Program, task: &str) -> Result<Value, String> {
|
||||
match task {
|
||||
"equivalent" => Ok(equiv::equivalent_programs(a, b)),
|
||||
"prepares" => Ok(equiv::same_state(a, b)),
|
||||
_ => Err(format!("{task}: a task is equivalent (the same circuit) or prepares (the same state)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn proved(v: &Value) -> bool {
|
||||
v["verdict"] == "equivalent" && v["how"].as_str().is_some_and(|h| PROOFS.contains(&h))
|
||||
}
|
||||
|
||||
/// Record that the person prefers `chosen` to `other`, read at `inputs`,
|
||||
/// once the engine proves them equal for `task`: what proved it, or why the
|
||||
/// pair is refused.
|
||||
pub fn choose(profile: &mut Profile, chosen: &str, other: &str, part: Option<&str>, task: &str, inputs: &Inputs, at: u64) -> Result<Value, String> {
|
||||
if let Some(p) = part
|
||||
&& !PARTS.iter().any(|(k, _)| *k == p)
|
||||
{
|
||||
return Err(format!("{p}: a part is one of {}", PARTS.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
if chosen.trim() == other.trim() {
|
||||
return Err("the two programs are the same text: there is nothing to choose".into());
|
||||
}
|
||||
let a = qasm::parse_with(chosen, inputs).map_err(|e| format!("the program chosen does not read: {e}"))?;
|
||||
let b = qasm::parse_with(other, inputs).map_err(|e| format!("the other program does not read: {e}"))?;
|
||||
let v = equal(&a, &b, task)?;
|
||||
if !proved(&v) {
|
||||
let what = if task == "prepares" { "make the same state" } else { "are the same circuit" };
|
||||
return Err(format!("the engine does not prove that they {what} ({}): a choice is only between programs that do the same thing", v["why"].as_str().unwrap_or("unchecked")));
|
||||
}
|
||||
profile.choices.push(Choice { chosen: side(chosen, &a), other: side(other, &b), part: part.map(str::to_string), task: task.to_string(), how: v["how"].as_str().unwrap_or("").to_string(), inputs: inputs.clone(), at });
|
||||
Ok(json!({ "how": v["how"], "choices": profile.choices.len() }))
|
||||
}
|
||||
|
||||
fn dot(a: &[f64], b: &[f64]) -> f64 {
|
||||
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
fn sigmoid(t: f64) -> f64 {
|
||||
if t >= 0.0 { 1.0 / (1.0 + (-t).exp()) } else { t.exp() / (1.0 + t.exp()) }
|
||||
}
|
||||
|
||||
/// `h x = g` for a symmetric positive-definite `h`, by elimination with
|
||||
/// partial pivoting.
|
||||
fn solve(mut h: Vec<Vec<f64>>, mut g: Vec<f64>) -> Vec<f64> {
|
||||
let d = g.len();
|
||||
for c in 0..d {
|
||||
let p = (c..d).fold(c, |best, r| if h[r][c].abs() > h[best][c].abs() { r } else { best });
|
||||
h.swap(c, p);
|
||||
g.swap(c, p);
|
||||
let pivot = h[c].clone();
|
||||
for r in c + 1..d {
|
||||
let f = h[r][c] / pivot[c];
|
||||
for (x, p) in h[r][c..].iter_mut().zip(&pivot[c..]) {
|
||||
*x -= f * p;
|
||||
}
|
||||
g[r] -= f * g[c];
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; d];
|
||||
for c in (0..d).rev() {
|
||||
x[c] = (g[c] - (c + 1..d).map(|k| h[c][k] * x[k]).sum::<f64>()) / h[c][c];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Weights fitted to `xs`, each the chosen program's features less the
|
||||
/// other's: in the features' own units, and on the features scaled to their
|
||||
/// spread over these choices.
|
||||
fn fit(xs: &[Vec<f64>]) -> (Vec<f64>, Vec<f64>) {
|
||||
let d = FEATURES.len();
|
||||
if xs.is_empty() {
|
||||
return (vec![0.0; d], vec![0.0; d]);
|
||||
}
|
||||
let spread: Vec<f64> = (0..d)
|
||||
.map(|j| {
|
||||
(xs.iter().map(|x| x[j] * x[j]).sum::<f64>() / xs.len() as f64).sqrt().max(SPREAD_FLOOR)
|
||||
})
|
||||
.collect();
|
||||
let z: Vec<Vec<f64>> = xs.iter().map(|x| x.iter().zip(&spread).map(|(a, s)| a / s).collect()).collect();
|
||||
let mut w = vec![0.0; d];
|
||||
for _ in 0..50 {
|
||||
let mut g: Vec<f64> = w.iter().map(|x| RIDGE * x).collect();
|
||||
let mut h = vec![vec![0.0; d]; d];
|
||||
for (j, row) in h.iter_mut().enumerate() {
|
||||
row[j] = RIDGE;
|
||||
}
|
||||
for x in &z {
|
||||
let p = sigmoid(dot(&w, x));
|
||||
for j in 0..d {
|
||||
g[j] -= (1.0 - p) * x[j];
|
||||
for k in 0..d {
|
||||
h[j][k] += p * (1.0 - p) * x[j] * x[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
let step = solve(h, g);
|
||||
let mut largest = 0.0f64;
|
||||
for (wj, s) in w.iter_mut().zip(&step) {
|
||||
*wj -= s;
|
||||
largest = largest.max(s.abs());
|
||||
}
|
||||
if largest < 1e-12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(w.iter().zip(&spread).map(|(a, s)| a / s).collect(), w)
|
||||
}
|
||||
|
||||
fn differences<'a>(choices: impl Iterator<Item = &'a Choice>) -> Vec<Vec<f64>> {
|
||||
choices.map(|c| vector(&c.chosen.features).iter().zip(vector(&c.other.features)).map(|(a, b)| a - b).collect()).collect()
|
||||
}
|
||||
|
||||
/// The fitted weights for one part (`Some`) or for every choice, in the
|
||||
/// features' units and scaled to their spread, and the choices they came
|
||||
/// from.
|
||||
pub fn model(profile: &Profile, part: Option<&str>) -> (Vec<f64>, Vec<f64>, usize) {
|
||||
let xs = differences(profile.choices.iter().filter(|c| part.is_none() || c.part.as_deref() == part));
|
||||
let n = xs.len();
|
||||
let (w, scaled) = fit(&xs);
|
||||
(w, scaled, n)
|
||||
}
|
||||
|
||||
/// What a profile has learned and how well: its choices, each part's count,
|
||||
/// its leanings in words, how many of its choices it predicts (each from the
|
||||
/// ones made before it, `as_made`, and each held out from all the others,
|
||||
/// `held_out`, over the last [`MEASURED`]), and how many no measured feature
|
||||
/// separates (`unmeasured`: made on something the engine does not measure).
|
||||
pub fn summary(profile: &Profile) -> Value {
|
||||
let xs = differences(profile.choices.iter());
|
||||
let (_, scaled) = fit(&xs);
|
||||
let mut lean: Vec<(usize, f64)> = scaled.iter().copied().enumerate().filter(|(_, w)| w.abs() >= LEANING).collect();
|
||||
lean.sort_by(|a, b| b.1.abs().total_cmp(&a.1.abs()).then(a.0.cmp(&b.0)));
|
||||
let leanings: Vec<Value> = lean.iter().take(3).map(|&(j, w)| json!({ "feature": FEATURES[j].0, "words": if w < 0.0 { FEATURES[j].1 } else { FEATURES[j].2 }, "weight": (w * 1000.0).round() / 1000.0 })).collect();
|
||||
let from = xs.len().saturating_sub(MEASURED);
|
||||
let (mut made, mut made_of, mut held, mut held_of) = (0, 0, 0, 0);
|
||||
for k in from.max(1)..xs.len() {
|
||||
made_of += 1;
|
||||
made += usize::from(dot(&fit(&xs[..k]).0, &xs[k]) > 0.0);
|
||||
}
|
||||
for i in from..xs.len() {
|
||||
let rest: Vec<Vec<f64>> = xs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, x)| x.clone()).collect();
|
||||
held_of += 1;
|
||||
held += usize::from(dot(&fit(&rest).0, &xs[i]) > 0.0);
|
||||
}
|
||||
let parts: BTreeMap<&str, usize> = PARTS.iter().map(|(k, _)| (*k, profile.choices.iter().filter(|c| c.part.as_deref() == Some(*k)).count())).filter(|(_, n)| *n > 0).collect();
|
||||
json!({
|
||||
"name": profile.name,
|
||||
"choices": profile.choices.len(),
|
||||
"parts": parts,
|
||||
"leanings": leanings,
|
||||
"as_made": { "right": made, "of": made_of },
|
||||
"held_out": { "right": held, "of": held_of },
|
||||
"unmeasured": xs.iter().filter(|x| x.iter().all(|v| v.abs() < 1e-12)).count(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A candidate to rank or pair: its name, its program, and its cost if the
|
||||
/// caller has one.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub cost: Option<f64>,
|
||||
}
|
||||
|
||||
fn round(x: f64) -> f64 {
|
||||
(x * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
fn mean_distance(ps: &[&Program]) -> Option<f64> {
|
||||
let mut sum = 0.0;
|
||||
let mut n = 0;
|
||||
for i in 0..ps.len() {
|
||||
for j in i + 1..ps.len() {
|
||||
sum += novelty::distance(ps[i], ps[j]);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
(n > 0).then(|| sum / n as f64)
|
||||
}
|
||||
|
||||
/// `candidates` read at `inputs` and ranked by the profile and the novelty
|
||||
/// dial (`dial`, −1 for the typical to 1 for the novel): each is placed by
|
||||
/// `(1 − |dial|)·z(score) + dial·z(novelty)`, `z` standardising over these
|
||||
/// candidates, so at 0 the order is the profile's, at 1 the most novel's
|
||||
/// first and at −1 the most typical's. Each is shown with its score (in
|
||||
/// logits above or below the mean of these candidates), its novelty and
|
||||
/// typicality, and each part's score; the first of each part and of the
|
||||
/// cost where they differ from the ranking's; and whether the ranking
|
||||
/// narrows what it puts first (`narrowing`).
|
||||
pub fn rank(profile: &Profile, candidates: &[Candidate], inputs: &Inputs, dial: f64) -> Result<Value, String> {
|
||||
let dial = dial.clamp(-1.0, 1.0);
|
||||
let progs: Vec<Program> = candidates.iter().map(|c| qasm::parse_with(&c.source, inputs).map_err(|e| format!("{} does not read: {e}", c.name))).collect::<Result<_, _>>()?;
|
||||
let fs: Vec<Vec<f64>> = candidates.iter().zip(&progs).map(|(c, p)| vector(&features_of(&c.source, p))).collect();
|
||||
let (w, _, n) = model(profile, None);
|
||||
let parts: Vec<(&str, Vec<f64>)> = PARTS.iter().filter_map(|(k, _)| {
|
||||
let (pw, _, pn) = model(profile, Some(k));
|
||||
(pn > 0).then_some((*k, pw))
|
||||
}).collect();
|
||||
let novel: Vec<Option<f64>> = candidates.iter().map(|c| novelty::assess(&c.source, inputs, &[], None, &[])["novelty"].as_f64()).collect();
|
||||
// Scores about the candidates' mean: only their differences mean anything.
|
||||
let centred = |w: &[f64]| -> Vec<f64> {
|
||||
let s: Vec<f64> = fs.iter().map(|f| dot(w, f)).collect();
|
||||
let mean = s.iter().sum::<f64>() / s.len().max(1) as f64;
|
||||
s.iter().map(|x| x - mean).collect()
|
||||
};
|
||||
let score = centred(&w);
|
||||
let part_scores: Vec<Vec<f64>> = parts.iter().map(|(_, pw)| centred(pw)).collect();
|
||||
let z = |v: &[f64]| -> Vec<f64> {
|
||||
let mean = v.iter().sum::<f64>() / v.len().max(1) as f64;
|
||||
let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / v.len().max(1) as f64).sqrt();
|
||||
v.iter().map(|x| if sd > 1e-12 { (x - mean) / sd } else { 0.0 }).collect()
|
||||
};
|
||||
let (zs, zn) = (z(&score), z(&novel.iter().map(|v| v.unwrap_or(0.0)).collect::<Vec<_>>()));
|
||||
let fin: Vec<f64> = zs.iter().zip(&zn).map(|(s, n)| (1.0 - dial.abs()) * s + dial * n).collect();
|
||||
let mut order: Vec<usize> = (0..candidates.len()).collect();
|
||||
order.sort_by(|&a, &b| fin[b].total_cmp(&fin[a]).then(a.cmp(&b)));
|
||||
let ranked: Vec<Value> = order.iter().map(|&i| {
|
||||
let by_part: BTreeMap<&str, f64> = parts.iter().zip(&part_scores).map(|((k, _), ps)| (*k, round(ps[i]))).collect();
|
||||
json!({
|
||||
"name": candidates[i].name,
|
||||
"score": round(score[i]),
|
||||
"final": round(fin[i]),
|
||||
"novelty": novel[i].map(round),
|
||||
"typicality": novel[i].map(|v| round(1.0 - v)),
|
||||
"cost": candidates[i].cost,
|
||||
"parts": by_part,
|
||||
})
|
||||
}).collect();
|
||||
let first = order.first().map(|&i| candidates[i].name.clone());
|
||||
let mut disagree = Vec::new();
|
||||
for (k, pw) in &parts {
|
||||
let best = (0..candidates.len()).max_by(|&a, &b| dot(pw, &fs[a]).total_cmp(&dot(pw, &fs[b])).then(b.cmp(&a)));
|
||||
if let Some(i) = best
|
||||
&& Some(&candidates[i].name) != first.as_ref()
|
||||
{
|
||||
disagree.push(json!({ "part": k, "first": candidates[i].name }));
|
||||
}
|
||||
}
|
||||
let costs: Option<Vec<f64>> = candidates.iter().map(|c| c.cost).collect();
|
||||
let by_cost: Option<Vec<usize>> = costs.as_ref().map(|cs| {
|
||||
let mut o: Vec<usize> = (0..cs.len()).collect();
|
||||
o.sort_by(|&a, &b| cs[a].total_cmp(&cs[b]).then(a.cmp(&b)));
|
||||
o
|
||||
});
|
||||
if let Some(o) = &by_cost
|
||||
&& let Some(&i) = o.first()
|
||||
&& Some(&candidates[i].name) != first.as_ref()
|
||||
{
|
||||
disagree.push(json!({ "part": "cost", "first": candidates[i].name }));
|
||||
}
|
||||
let k = 3.min(candidates.len());
|
||||
let narrowing = (candidates.len() > k).then(|| {
|
||||
let baseline: Vec<usize> = by_cost.clone().unwrap_or_else(|| (0..candidates.len()).collect());
|
||||
let top = |o: &[usize]| mean_distance(&o[..k].iter().map(|&i| &progs[i]).collect::<Vec<_>>());
|
||||
let (r, b) = (top(&order), top(&baseline));
|
||||
json!({ "first": k, "ranked": r.map(round), "baseline": b.map(round), "by": if by_cost.is_some() { "cost" } else { "order" }, "narrows": matches!((r, b), (Some(r), Some(b)) if r < b - 0.05) })
|
||||
});
|
||||
Ok(json!({ "choices": n, "dial": dial, "ranked": ranked, "disagree": disagree, "narrowing": narrowing }))
|
||||
}
|
||||
|
||||
/// The pair of `candidates` the profile is least sure of, among those whose
|
||||
/// measured features differ, that the engine proves equal for `task`: a
|
||||
/// choice between them teaches the most. Ties go to the pair built most
|
||||
/// unlike.
|
||||
pub fn next(profile: &Profile, candidates: &[Candidate], inputs: &Inputs, task: &str) -> Result<Value, String> {
|
||||
let progs: Vec<Program> = candidates.iter().map(|c| qasm::parse_with(&c.source, inputs).map_err(|e| format!("{} does not read: {e}", c.name))).collect::<Result<_, _>>()?;
|
||||
let fs: Vec<Vec<f64>> = candidates.iter().zip(&progs).map(|(c, p)| vector(&features_of(&c.source, p))).collect();
|
||||
let (w, _, _) = model(profile, None);
|
||||
let mut pairs = Vec::new();
|
||||
for i in 0..candidates.len() {
|
||||
for j in i + 1..candidates.len() {
|
||||
let x: Vec<f64> = fs[i].iter().zip(&fs[j]).map(|(a, b)| a - b).collect();
|
||||
if x.iter().all(|v| v.abs() < 1e-12) {
|
||||
continue;
|
||||
}
|
||||
pairs.push((dot(&w, &x), novelty::distance(&progs[i], &progs[j]), i, j));
|
||||
}
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.abs().total_cmp(&b.0.abs()).then(b.1.total_cmp(&a.1)).then((a.2, a.3).cmp(&(b.2, b.3))));
|
||||
for &(t, _, i, j) in pairs.iter().take(12) {
|
||||
let v = equal(&progs[i], &progs[j], task)?;
|
||||
if proved(&v) {
|
||||
return Ok(json!({ "a": candidates[i].name, "b": candidates[j].name, "how": v["how"], "p_a": round(sigmoid(t)) }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "a": null, "why": if pairs.is_empty() { "no two of them differ in anything the engine measures" } else { "the engine proves none of the least certain pairs equal" } }))
|
||||
}
|
||||
|
||||
fn candidates_of(args: &Value) -> Result<Vec<Candidate>, String> {
|
||||
args.get("candidates")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("give \"candidates\": [{name, qasm, cost?}]")?
|
||||
.iter()
|
||||
.map(|c| {
|
||||
Ok(Candidate {
|
||||
name: c.get("name").and_then(Value::as_str).ok_or("a candidate needs a name")?.to_string(),
|
||||
source: c.get("qasm").and_then(Value::as_str).ok_or("a candidate needs its qasm")?.to_string(),
|
||||
cost: c.get("cost").and_then(Value::as_f64),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The taste model as a page, an app or an agent calls it, the profile kept
|
||||
/// by the caller and passed as JSON: `features` (`qasm`, `inputs`), `choose`
|
||||
/// (`profile`, `chosen`, `other`, `part`, `task`, `inputs`, `at`; answers
|
||||
/// with the profile), `summary` (`profile`), `rank` (`profile`,
|
||||
/// `candidates`, `inputs`, `dial`) and `next` (`profile`, `candidates`,
|
||||
/// `inputs`, `task`). A missing profile is a new one named `mine`.
|
||||
pub fn call(op: &str, args: &Value) -> Result<Value, String> {
|
||||
let inputs: Inputs = match args.get("inputs") {
|
||||
None | Some(Value::Null) => Inputs::new(),
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("{op}: inputs: {e}"))?,
|
||||
};
|
||||
let mut profile = match args.get("profile") {
|
||||
None | Some(Value::Null) => Profile::new("mine"),
|
||||
Some(v) => Profile::from_json(v)?,
|
||||
};
|
||||
let s = |k: &str| args.get(k).and_then(Value::as_str).ok_or_else(|| format!("{op}: give \"{k}\""));
|
||||
let task = args.get("task").and_then(Value::as_str).unwrap_or("equivalent");
|
||||
match op {
|
||||
"features" => Ok(json!({ "features": features(s("qasm")?, &inputs)? })),
|
||||
"choose" => {
|
||||
let r = choose(&mut profile, s("chosen")?, s("other")?, args.get("part").and_then(Value::as_str), task, &inputs, args.get("at").and_then(Value::as_u64).unwrap_or(0))?;
|
||||
Ok(json!({ "how": r["how"], "summary": summary(&profile), "profile": profile }))
|
||||
}
|
||||
"summary" => Ok(summary(&profile)),
|
||||
"rank" => rank(&profile, &candidates_of(args)?, &inputs, args.get("dial").and_then(Value::as_f64).unwrap_or(0.0)),
|
||||
"next" => next(&profile, &candidates_of(args)?, &inputs, task),
|
||||
_ => Err(format!("{op}: no such call on the taste model")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BELL: &str = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n";
|
||||
|
||||
fn chain(n: usize, extra: &str) -> String {
|
||||
let mut s = format!("OPENQASM 3.0;\nqubit[{n}] q;\nh q[0];\n");
|
||||
for i in 0..n - 1 {
|
||||
s += &format!("cx q[{i}], q[{}];\n", i + 1);
|
||||
}
|
||||
s + extra
|
||||
}
|
||||
|
||||
fn cands(xs: &[(&str, &str)]) -> Vec<Candidate> {
|
||||
xs.iter().map(|(n, s)| Candidate { name: n.to_string(), source: s.to_string(), cost: None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_choice_is_only_between_programs_the_engine_proves_equal() {
|
||||
let mut p = Profile::new("mine");
|
||||
let padded = format!("{BELL}x q[1];\nx q[1];\n");
|
||||
let r = choose(&mut p, BELL, &padded, None, "equivalent", &Inputs::new(), 7).unwrap();
|
||||
assert_eq!(r["how"], "tableau", "{r}");
|
||||
assert_eq!(p.choices[0].at, 7);
|
||||
let other = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\n";
|
||||
assert!(choose(&mut p, BELL, other, None, "equivalent", &Inputs::new(), 0).unwrap_err().contains("does not prove"));
|
||||
assert!(choose(&mut p, BELL, BELL, None, "equivalent", &Inputs::new(), 0).unwrap_err().contains("same text"));
|
||||
assert!(choose(&mut p, BELL, &padded, Some("beauty"), "equivalent", &Inputs::new(), 0).unwrap_err().contains("a part is one of"));
|
||||
assert_eq!(p.choices.len(), 1);
|
||||
// The same state, built otherwise, is equal for a preparation and not as a circuit.
|
||||
let swapped = "OPENQASM 3.0;\nqubit[2] q;\nh q[1];\ncx q[1], q[0];\n";
|
||||
assert!(choose(&mut p, swapped, BELL, None, "equivalent", &Inputs::new(), 0).is_err());
|
||||
assert_eq!(choose(&mut p, swapped, BELL, None, "prepares", &Inputs::new(), 0).unwrap()["how"], "state");
|
||||
}
|
||||
|
||||
/// Someone who always takes fewer two-qubit gates over fewer one-qubit
|
||||
/// ones, with the gate count equal, is predicted, and the gate count is
|
||||
/// not named as a leaning.
|
||||
#[test]
|
||||
fn a_person_who_prefers_fewer_two_qubit_gates_is_learned_and_predicted() {
|
||||
let mut p = Profile::new("mine");
|
||||
for n in 2..9 {
|
||||
let ones = chain(n, "h q[0];\nh q[0];\n");
|
||||
let twos = chain(n, "cx q[0], q[1];\ncx q[0], q[1];\n");
|
||||
choose(&mut p, &ones, &twos, None, "equivalent", &Inputs::new(), 0).unwrap();
|
||||
}
|
||||
let s = summary(&p);
|
||||
assert_eq!(s["choices"], 7);
|
||||
assert_eq!((s["held_out"]["right"].as_u64(), s["held_out"]["of"].as_u64()), (Some(7), Some(7)), "{s}");
|
||||
assert_eq!((s["as_made"]["right"].as_u64(), s["as_made"]["of"].as_u64()), (Some(6), Some(6)), "{s}");
|
||||
let words: Vec<&str> = s["leanings"].as_array().unwrap().iter().map(|l| l["words"].as_str().unwrap()).collect();
|
||||
assert!(words.iter().any(|w| w.contains("two-qubit") || w.contains("error")) && !words.contains(&"fewer gates"), "{s}");
|
||||
let r = rank(&p, &cands(&[("twos", &chain(5, "cx q[1], q[2];\ncx q[1], q[2];\n")), ("ones", &chain(5, "x q[3];\nx q[3];\n")), ("plain", &chain(5, ""))]), &Inputs::new(), 0.0).unwrap();
|
||||
assert_eq!(r["ranked"][2]["name"], "twos", "{r}");
|
||||
}
|
||||
|
||||
/// Each part is learned from its own choices, and a ranking says where
|
||||
/// the parts put a different program first.
|
||||
#[test]
|
||||
fn the_parts_are_learned_apart_and_their_disagreement_is_shown() {
|
||||
let mut p = Profile::new("mine");
|
||||
let looped = |n: usize, extra: &str| format!("OPENQASM 3.0;\nqubit[{n}] q;\nh q[0];\nfor uint i in [0:{}] {{\n cx q[i], q[i + 1];\n}}\n{extra}", n - 2);
|
||||
// Two gates that undo each other, on the line of the Hadamard: more gates, no more text.
|
||||
let pad = |s: String| s.replacen("h q[0];\n", "h q[0]; x q[0]; x q[0];\n", 1);
|
||||
for n in 3..8 {
|
||||
// Economy: fewer gates. Clarity: the loop over the gates written out.
|
||||
choose(&mut p, &chain(n, ""), &pad(chain(n, "")), Some("economy"), "equivalent", &Inputs::new(), 0).unwrap();
|
||||
choose(&mut p, &looped(n, ""), &chain(n, ""), Some("clarity"), "equivalent", &Inputs::new(), 0).unwrap();
|
||||
}
|
||||
let r = rank(&p, &cands(&[("written out", &chain(6, "")), ("looped, padded", &pad(looped(6, "")))]), &Inputs::new(), 0.0).unwrap();
|
||||
let parts = r["ranked"][0]["parts"].as_object().unwrap();
|
||||
assert!(parts.contains_key("economy") && parts.contains_key("clarity"), "{r}");
|
||||
let firsts: BTreeMap<&str, &str> = r["disagree"].as_array().unwrap().iter().map(|d| (d["part"].as_str().unwrap(), d["first"].as_str().unwrap())).collect();
|
||||
assert_eq!(firsts.len(), 1, "one part disagrees with the ranking's first: {r}");
|
||||
let whole = r["ranked"][0]["name"].as_str().unwrap();
|
||||
let (part, first) = firsts.iter().next().unwrap();
|
||||
assert_ne!(first, &whole, "{r}");
|
||||
assert!(["economy", "clarity"].contains(part));
|
||||
assert_eq!(summary(&p)["parts"], json!({ "clarity": 5, "economy": 5 }));
|
||||
}
|
||||
|
||||
/// With no choices the dial alone orders them: toward the novel, then
|
||||
/// toward the typical.
|
||||
#[test]
|
||||
fn the_dial_moves_the_ranking_between_the_typical_and_the_novel() {
|
||||
let p = Profile::new("mine");
|
||||
let typical = chain(4, "");
|
||||
let odd = "OPENQASM 3.0;\nqubit[4] q;\nh q[3];\ncx q[3], q[2];\nswap q[2], q[0];\ncx q[2], q[0];\ncx q[0], q[1];\n";
|
||||
let c = cands(&[("typical", &typical), ("odd", odd)]);
|
||||
let up = rank(&p, &c, &Inputs::new(), 1.0).unwrap();
|
||||
let down = rank(&p, &c, &Inputs::new(), -1.0).unwrap();
|
||||
assert_eq!((up["ranked"][0]["name"].as_str(), down["ranked"][0]["name"].as_str()), (Some("odd"), Some("typical")), "{up} {down}");
|
||||
assert_eq!(up["ranked"][1]["typicality"], 1.0, "the chain is a known construction: {up}");
|
||||
assert!(!FEATURES.iter().any(|(k, _, _)| k.contains("novel") || k.contains("typical")), "typicality is never learned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_choices_give_the_same_weights_and_the_profile_reads_back() {
|
||||
let mut p = Profile::new("teaching");
|
||||
for n in 2..6 {
|
||||
choose(&mut p, &chain(n, ""), &chain(n, "z q[0];\nz q[0];\n"), Some("economy"), "equivalent", &Inputs::new(), 0).unwrap();
|
||||
}
|
||||
let (a, _, _) = model(&p, None);
|
||||
let back = Profile::from_json(&serde_json::to_value(&p).unwrap()).unwrap();
|
||||
let (b, _, _) = model(&back, None);
|
||||
assert_eq!(a.iter().map(|x| x.to_bits()).collect::<Vec<_>>(), b.iter().map(|x| x.to_bits()).collect::<Vec<_>>());
|
||||
assert_eq!(summary(&p), summary(&back));
|
||||
let mut v = serde_json::to_value(&p).unwrap();
|
||||
v["taste"] = json!(2);
|
||||
assert!(Profile::from_json(&v).unwrap_err().contains("form 2"));
|
||||
}
|
||||
|
||||
/// A ranking says whether it narrows what it puts first, against the
|
||||
/// order by cost; the next pair is one the engine proves equal.
|
||||
#[test]
|
||||
fn narrowing_is_measured_and_the_next_pair_is_proved_equal() {
|
||||
let mut p = Profile::new("mine");
|
||||
choose(&mut p, &chain(4, ""), &chain(4, "x q[2];\nx q[2];\n"), None, "equivalent", &Inputs::new(), 0).unwrap();
|
||||
let mut c = cands(&[("a", &chain(4, "")), ("b", &chain(4, "x q[2];\nx q[2];\n")), ("c", &chain(4, "h q[1];\nh q[1];\n")), ("d", &chain(4, "cx q[2], q[3];\ncx q[2], q[3];\n")), ("wrong", "OPENQASM 3.0;\nqubit[4] q;\nh q[0];\n")]);
|
||||
for (i, x) in c.iter_mut().enumerate() {
|
||||
x.cost = Some(i as f64);
|
||||
}
|
||||
let r = rank(&p, &c, &Inputs::new(), 0.0).unwrap();
|
||||
// One choice does not make the model certain about programs unlike its pair.
|
||||
assert!(r["ranked"].as_array().unwrap().iter().all(|x| x["score"].as_f64().unwrap().abs() < 10.0), "{r}");
|
||||
let nw = &r["narrowing"];
|
||||
assert_eq!((nw["first"].as_u64(), nw["by"].as_str()), (Some(3), Some("cost")), "{r}");
|
||||
assert!((0.0..=1.0).contains(&nw["ranked"].as_f64().unwrap()) && nw["narrows"].is_boolean(), "{r}");
|
||||
let n = next(&p, &c, &Inputs::new(), "equivalent").unwrap();
|
||||
assert!(n["a"].is_string() && n["a"] != "wrong" && n["b"] != "wrong", "{n}");
|
||||
assert_eq!(n["how"], "tableau");
|
||||
let none = next(&p, &cands(&[("x", &chain(3, "")), ("y", &chain(3, ""))]), &Inputs::new(), "equivalent").unwrap();
|
||||
assert!(none["a"].is_null() && none["why"].as_str().unwrap().contains("differ"), "{none}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_call_it_with_the_profile_as_json() {
|
||||
let r = call("choose", &json!({ "chosen": BELL, "other": format!("{BELL}h q[0];\nh q[0];\n"), "part": "clarity" })).unwrap();
|
||||
assert_eq!((r["profile"]["name"].as_str(), r["summary"]["choices"].as_u64()), (Some("mine"), Some(1)), "{r}");
|
||||
let s = call("summary", &json!({ "profile": r["profile"] })).unwrap();
|
||||
assert_eq!(s["parts"]["clarity"], 1);
|
||||
let f = call("features", &json!({ "qasm": "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[0];\n", "inputs": { "n": 3 } })).unwrap();
|
||||
assert_eq!(f["features"]["kinds"], 1.0, "{f}");
|
||||
let ranked = call("rank", &json!({ "profile": r["profile"], "candidates": [{ "name": "bell", "qasm": BELL }], "dial": 0.5 })).unwrap();
|
||||
assert_eq!(ranked["ranked"][0]["name"], "bell");
|
||||
assert!(call("nonsense", &json!({})).unwrap_err().contains("no such call"));
|
||||
assert!(call("rank", &json!({})).unwrap_err().contains("candidates"));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
use ed25519_dalek::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use wai::engine::discover::{self, Archive, Budget, Evaluator, SearchOptions, Spec, search};
|
||||
use wai::engine::{catalog, critique, draw, equiv, job, locks, novelty, shortcuts, versions};
|
||||
use wai::engine::{catalog, critique, draw, equiv, job, locks, novelty, shortcuts, taste, versions};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// What a result run in a tab says of its energy.
|
||||
|
|
@ -289,6 +289,17 @@ pub fn engine_discover_novelty(spec: &str, archive_json: &str, id: &str) -> Stri
|
|||
.unwrap_or_else(error_json)
|
||||
}
|
||||
|
||||
/// `op` on the person's taste model ([`taste::call`]), the profile kept by
|
||||
/// the page and passed in the arguments as JSON: the answer, or `{error}`.
|
||||
#[wasm_bindgen]
|
||||
pub fn engine_taste(op: &str, args: &str) -> String {
|
||||
serde_json::from_str::<Value>(args)
|
||||
.map_err(|e| format!("{op}: the arguments are not JSON: {e}"))
|
||||
.and_then(|a| taste::call(op, &a))
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(error_json)
|
||||
}
|
||||
|
||||
/// A generator written out at `inputs` (JSON, as `{"n": 5}`) as plain
|
||||
/// OpenQASM: `{text}`, or `{error}`.
|
||||
#[wasm_bindgen]
|
||||
|
|
@ -383,6 +394,19 @@ mod tests {
|
|||
assert!(engine_discover_novelty(&spec, &sub["archive"].to_string(), "e99").contains("no such entry"));
|
||||
}
|
||||
|
||||
/// The taste model in the page: a choice between programs the engine
|
||||
/// proves equal is kept in the profile the page holds, and ranks.
|
||||
#[test]
|
||||
fn taste_is_learned_and_ranks_in_the_page() {
|
||||
let bell = "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n";
|
||||
let padded = format!("{bell}cx q[0], q[1];\ncx q[0], q[1];\n");
|
||||
let r: Value = serde_json::from_str(&engine_taste("choose", &json!({ "chosen": bell, "other": padded, "part": "economy" }).to_string())).unwrap();
|
||||
assert_eq!(r["summary"]["choices"], 1, "{r}");
|
||||
let ranked: Value = serde_json::from_str(&engine_taste("rank", &json!({ "profile": r["profile"], "candidates": [{ "name": "padded", "qasm": padded }, { "name": "bell", "qasm": bell }] }).to_string())).unwrap();
|
||||
assert_eq!(ranked["ranked"][0]["name"], "bell", "{ranked}");
|
||||
assert!(engine_taste("choose", &json!({ "chosen": bell, "other": "OPENQASM 3.0;\nqubit[2] q;\n" }).to_string()).contains("does not prove"));
|
||||
}
|
||||
|
||||
fn bell(extra: &str) -> String {
|
||||
format!("{{\"qasm\": \"OPENQASM 3.0; qubit[2] q; h q[0]; cx q[0], q[1];\"{extra}}}")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue