diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a3ab9..71c326a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,75 @@ checkpoint's `expect`, `expectation.rest`, the `identity` self keys) read as uns npm): `docs/monitor-app.md` says to remove the quarantine mark before the first open and how to recover after a refused one (move the app, or Open Anyway), and that the bundled probbit serves several strands with a switcher; the plugin README names the second warning an older probbit gives; REPRODUCE.md no longer says only 0.12.0 replays the strand. +### Research + +- **RFC 0011, round 7: one math path for the register** (research, `engine.tier: amplitude` stays opt-in; docs/rfc/0011-amplitude-register.md, + "One math path"). The simulated register evaluates cos, sin, sqrt, asin, acos, atan2 and floor through the `libm` crate on the std and + the no_std build alike (a direct dependency of probbit-decide). 100 fresh lives per schedule recorded on the std build re-verify on + the no_std build 100/100 for `tf-linear-16x4`, `tf-linear-64x16` (round 5: 0/100) and `qaoa-p2` (98/100), x86_64; the tutor's + default-schedule strands are byte-identical to before (100/100) and both demo strands re-record byte for byte (a 52-qubit + recorded day's default strands moved in the last bits). Cost on that day: long sweep 1.50x the default. Exact, on a CPU. +- **RFC 0011, round 7: the amplification lands on the long sweep** (research). `amplify_exact` renormalises the swept state once + before the standard iterations: the diffusion about a state of squared norm 1 + eps is not unitary, so the shortfall grew with the + iterations and 165 corpus programs declined under the long sweep; now 0 decline under any schedule (median gap 0.151709, agreement + 0.981671 on the corpus). The receipt's mass before is unchanged; a mass that is not a number declines instead of panicking. + Simulated, exact, on a CPU. +- **RFC 0011, round 7: docs** (research). docs/persona.md's `engine.schedule` paragraph and the demo README state the measured + replay on both builds; docs/persona.md section 5.8: the stance digest is the sha256 of `/doc/N`'s JSON without the trailing newline. +- **RFC 0011, round 6: the demo** (research; `examples/persona/demo/`, docs/rfc/0011-amplitude-register.md, "The demo"). A + fictional tutor reduced to its humour component, with praise and criticism pulling humour in opposite directions, in two + copies: `register.yaml` (`engine: {tier: amplitude, amplify: true, schedule: tf-linear-64x16}`) and `thermal.yaml` (no engine + block). One 17-event life recorded on each (both verify, twice byte for byte, 0 floats past 6 decimals). At event 9 (line 10 + of both strands, the same program) the thermal odds of the top two humour levels sit 0.007412 apart and the simulated + register separates them by 0.494808; the Mood Monitor's two faces at that event are in the RFC. On the corpus's own program + `a7fae99b114ea2d2`: thermal gap 0.010516, long sweep 0.951467. The long sweep replays on the recording build only; the + default schedule stays for every recorded strand. No engine change. Simulated, exact, on a CPU. +- **RFC 0011, round 5: schedules and the indifferent set** (research, `engine.schedule`, absent = the default sweep; + docs/rfc/0011-amplitude-register.md, "Schedules and the indifferent set"). Three names: `tf-linear-16x4` (default), + `tf-linear-64x16` (four times the layers), `qaoa-p2` (two layers, angles fixed on a held-out third of the corpus). On the 10.7 % + of the recorded corpus's trait decisions where the thermal tier is indifferent, the simulated register's top-two gap has median + 0.049 (default), 0.152 (long sweep), 0.094 (qaoa-p2); agreement elsewhere 97.6 / 98.2 / 93.0 %; noise 0.0007. The long sweep + is the rule's choice (1.5× the solve per event on a recorded day). A missed amplification landing (165 programs under the long sweep) now declines to the + default tiers instead of panicking. Failure, stated: the no_std build re-verifies the long sweep's strands 0/100 (qaoa-p2 98/100; + the default 100/100): last-bit math differences, so non-default schedules replay only on the recording build for now. Without + the key every stance and strand is byte-identical to round 4. Exact, on a CPU. +- **RFC 0011, round 4: exact amplitude amplification in place of the projection** (research, `engine.amplify: true`, off by + default; docs/rfc/0011-amplitude-register.md, "Amplification"). `amp::amplify_exact`: the rules as the oracle, the diffusion + about the swept state, k standard iterations and one phase-matched final one, so the simulated register lands on its + feasible plans exactly (mass ≥ 1 − 1e-12, asserted). The plane argument says the result is the projection's distribution; + measured: marginals equal to 1.1e-12 on 220 programs, the same plan on 220/220 and on all 339,389 solves of the recorded corpus (B4 unchanged, 96.29 %), + 287/287 stance lines on a recorded day; + cost 1.02× the projected solve. The receipt adds `engine.amplify` and `feasible_mass` becomes 1. Without the key every stance + and strand is byte-identical to round 3. Simulated exactly on a CPU; no quantum hardware, no speedup claim. +- **RFC 0011, round 3: the verdict, KEEP as an optional tier** (research, off by default; docs/rfc/0011-amplitude-register.md, + status "accepted as optional tier"). Measured on every program the recorded corpus's 653 outputs solve (338,567 exact solves, + 0 declined, the corpus byte-identical with the comparison beside it): the exact simulated register picks the same level as the + exact default tier in 2,095,627 of 2,176,371 trait decisions (96.29 %, bar 90 %); the differing cases sit where the default + tier's own level holds less than half the mass (91 % of them), not where the rules' projection does the work. Three designed + two-path programs differ from the thermal odds by 0.25-0.27 at the node (engine vs reference 1.4e-14). The bound harness with + the tier: 0 of 4,100 cases above a certificate computed with the tier, 1,598 above one computed without it, so `persona bound` + must run with the tier on (docs/persona.md says so). A rule-penalty term was measured and not adopted (it lifts the Born mass + on rule-abiding plans and drops agreement to 83 %). No code change in this round. Simulated exactly on a CPU; no quantum + hardware, no speedup claim. +- **RFC 0011, round 2: multi-level traits on the amplitude register** (draft, research; docs/rfc/0011-amplitude-register.md): + a variable with d allowed values takes ⌈log2 d⌉ qubits (`amp::Encoding`; a three-level trait two qubits, its fourth code + unused), the start is the uniform superposition over the valid codes, the mixer is the complete-graph mixer per variable + (`amp::MIXER`, the identity on unused codes; exp(i t X) for two values, round 1's mixer exactly), and the cost is one general + diagonal phase layer exp(−iγE), E = −log w per basis state (`Register::phase`), so pair tables between three-level traits + need no decomposition (round 1's Walsh transform and its decline are gone). The unused codes keep amplitude 0, checked after + every layer, never renormalised. The tier runs one register per connected component of the program (exact: the state is the + product of the components' states); `engine.q_max` bounds the largest component's encoded qubits. New receipt fields + `engine.components`, `encoding`, `mixer`; `qubits` counts encoded qubits. Measured against an independent mixed-radix + reference: max |Δ marginal| 2.7e-13 on 220 programs. Simulated exactly on a CPU; no quantum hardware, no speedup claim. +- **RFC 0011, round 1: an amplitude register** (draft; docs/rfc/0011-amplitude-register.md): `probbit-decide::amp`, an exact + simulated qubit register on a CPU (q ≤ 16 on the host; 16 · 2^q bytes of amplitudes on the heap; std and no_std + alloc): + gates H, X, Z, RX, RZ, CNOT, CZ, RZZ, a Trotterised transverse-field sweep, QAOA layers, exact marginals, Born-rule + measurement with the engine's RNG, a sha256 digest of the state at 6 decimals. The persona flag `engine.tier: amplitude` + (off by default, `engine.q_max` default 12) solves a turn's program on it when every variable has at most two values; + otherwise the default tiers answer and the stance document says why. Receipt fields `engine.qubits`, `circuit`, + `schedule`, `schedule_name`, `state`, `feasible_mass`. Without the flag no byte of any stance or strand changes. No quantum + hardware, no speedup claim; the comparison with the default tier is the next round. + ## 0.13.0 - 2026-10-26 The monitor face and the Mac app, identity labels and many strands in one monitor, and the OpenClaw plugin 0.2.0. A persona diff --git a/Cargo.lock b/Cargo.lock index 4d8aba5..7b2e5f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ name = "probbit-decide" version = "0.14.0-dev" dependencies = [ + "libm", "probbit-core", "probbit-ir", "probbit-nostd", diff --git a/docs/persona.md b/docs/persona.md index e85e753..4040554 100644 --- a/docs/persona.md +++ b/docs/persona.md @@ -230,12 +230,40 @@ engine: twin: true # also solve the habit-free twin in the same program, to report which habits changed the stance values: positional # positional (level i -> l: small pair tables, fast) | semantic (level names: readable programs) on_conflict: fallback # fallback | yield: when habits contradict, the lower-priority one yields (section 5.5) + # tier: amplitude # research (RFC 0011, draft): an exact simulated qubit register on a CPU; absent = the default tiers + # q_max: 12 # the most encoded qubits (ceil(log2 d) per variable) in one component the amplitude tier takes (1-16) + # amplify: false # research (RFC 0011, round 4): with the tier, exact amplitude amplification in place of the projection + # schedule: tf-linear-16x4 # research (RFC 0011, round 5): the register's evolution: tf-linear-16x4 | tf-linear-64x16 | qaoa-p2 line: max_tokens: 40 # estimated tokens (max of chars / 4 and 4/3 per word) order: [verbosity, caution, directness, warmth, humour, initiative, formality, curiosity] prefix: "Stance: " ``` +**`engine.tier: amplitude` (research, off by default; docs/rfc/0011-amplitude-register.md).** The turn's program is solved by +an exact simulated qubit register on a CPU: a variable with d allowed values takes ⌈log2 d⌉ qubits (a three-level trait two, +its fourth code unused and kept at amplitude 0), one register per connected component of the program (2^q complex +amplitudes each), a fixed transverse-field sweep with a general diagonal phase layer, the habits as a projection onto the +plans that obey them, when the largest component has at most `q_max` encoded qubits; otherwise the default tiers answer and +`engine.amplitude` says why (`"declined: ..."`). With the tier, the stance document's `engine` carries `tier: "amplitude"`, +`qubits`, `components`, `encoding`, `mixer`, `circuit`, `schedule`, `schedule_name`, `state` (sha256 digests of the encoding, +the layer list, the schedule and the final amplitudes at 6 decimals) and +`feasible_mass`; `live verify` recomputes them from the strand. Without the key nothing changes, byte for byte. An engine +without the tier reports a strand that uses it as `unsupported by this engine: engine.tier` (exit 5). `engine.amplify: true` +(research, round 4; needs the tier) replaces the projection by exact amplitude amplification, simulated on a CPU: the +rules are the oracle, the register is rotated onto the plans that obey them, the stance is the projection's (the same plan and odds, measured equal to +1.1e-12) and `engine.amplify` logs the iterations, the final phases and the mass before and after (`feasible_mass` 1). `engine.schedule` +(research, round 5; needs the tier; absent = `tf-linear-16x4`) names the evolution of the register, simulated exactly on a CPU: `tf-linear-64x16` (four times +the layers) separates the top two levels where the thermal tier is a near tie (median gap 0.152 vs 0.049 on the recorded corpus) and +agrees more elsewhere; `qaoa-p2` is two fixed QAOA layers (RFC 0011, "Schedules and the indifferent set"). Since round 7 the +register computes its math functions with one library (`libm`) on the std and the no_std build: 100 fresh lives per schedule +recorded on the std build re-verify on the no_std build 100 of 100 and re-write byte for byte, on each of the three schedules +(x86_64; arm64 not measured). The math change is in the last bits: the tutor's and the demo's default-schedule strands are +byte-identical to before, a larger persona's can move. The register is a +simulation: no quantum hardware, no speedup claim; its stances agree with the default tier in 96 % of trait decisions on the +recorded corpus (RFC 0011, round 3). With the key, `persona bound` probes through the tier: compute the certificate again with +it, since one computed without the key does not cover the tier. + ### 2.8 Learning (optional) ```yaml @@ -1824,7 +1852,7 @@ cannot read the documents: |---|---| | `/` | the page: one file embedded in the binary (under 72 KB, styles and script inline; it fetches no fonts, styles or scripts), with a Content-Security-Policy that lets it connect to its own server and nowhere else | | `/events` | server-sent events: `meta` (the layout: the strand, the engine, the persona, its traits, moods, inputs and history features in the persona's order, and `face`, the two moods the face's meters show), the latest `frame`, then a frame per event: `{n, doc, given, learned, spark, diverges, note}` (with `engine.trajectory` also `trajectory` and `settle`, below), that is the stance document, the inputs as the strand logs them, the learned deltas, the moods' recent positions, the line that differs and a warning; a heartbeat comment after 15 quiet seconds; events that land within one poll (100 ms), or while a page falls behind, come as the latest frame | -| `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (its sha256 is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | +| `/doc/N` | event N's stance document, canonical JSON and a newline, as `probbit live` printed it (the sha256 of the JSON without that newline is the `stance` digest of the strand's line N + 1); the latest 10,000 are kept | | `/strands` | the strands served, canonical JSON: `{demo, dir, strands: [{id, strand, labels, persona, n, glyph, age_s, labels_error?, segment?}]}` (the file name, never its directory; `age_s`: seconds since the strand file was last written, a life in segments: its newest file; `segment`: that file's index, when it is not the first); the demo lists none | | `/s/ID/events`, `/s/ID/doc/N` | as `/events` and `/doc/N`, for strand ID; `/events` and `/doc/N` are the first strand's, so a single-strand page is as before | diff --git a/docs/rfc/0011-amplitude-register.md b/docs/rfc/0011-amplitude-register.md new file mode 100644 index 0000000..5d26c24 --- /dev/null +++ b/docs/rfc/0011-amplitude-register.md @@ -0,0 +1,291 @@ +# RFC 0011: an amplitude register, an exact simulated qubit register as an optional solver tier for small stance programs + +Status: **accepted as optional tier (research; off by default)**, round 3. Verdict by the registered rule (B4 ≥ 90 %): 96.29 % +of 2,176,371 trait decisions on the recorded corpus agree with the exact default tier; B5 passes (three designed programs differ +from the thermal odds by 0.25-0.27 at the node; 0 of 4,100 bound cases exceed a certificate computed with the tier). Nothing in +this RFC changes a release or `main`; the tier stays off unless a persona asks for it. Turning it on changes the bound +certificate: compute it again with the tier (section 5). Round 4 adds `engine.amplify` (off by default): exact amplitude +amplification in place of the projection, which lands on the projection's numbers with feasible mass 1 (section 5, +"Amplification"). Round 5 adds `engine.schedule` (absent = the default sweep): a longer sweep separates the top two levels +where the thermal tier is indifferent and agrees more elsewhere (section 5, "Schedules and the indifferent set"). Round 6 shows +one such decision end to end, with a demo persona, its two recorded strands and the monitor's two faces (section 5, "The demo"). + +## 1. What it is, in one paragraph + +A register of q qubits (q ≤ 16 on the host, q ≤ 12 on a small microcontroller) simulated **exactly** on a CPU as a state +vector of 2^q complex amplitudes: unitary gates with phase, interference between basis states, measurement by the Born rule, +every step logged so the strand replays byte for byte. It is a simulation of a tiny quantum system and nothing else: no +quantum hardware, no speedup claim, no scaling past what 2^q amplitudes allow (10 qubits 16 KB, 16 qubits 1 MB, 20 qubits +16 MB; a microcontroller with 8 MB of PSRAM reaches about 16; a laptop about 25-28). Sampling a thermal distribution cannot +produce this behaviour (amplitudes carry signs and phases that probabilities do not); the register gets it by paying 2^q +memory and time, which is fine for the programs probbit actually solves (tens of two-valued variables, most of them not +coupled all at once). + +## 2. Why bother (the question the measurement answers) + +probbit's stance is one exact program over coupled p-bits per turn: biases and couplings → exact marginals → a level per +trait with its odds → a stance line, habits as hard rules on top. The p-bit tier gives a thermal (Boltzmann) distribution. +An amplitude register fed the same program as a Hamiltonian gives a **coherent** distribution: options can cancel (destructive +interference) as well as add, and a short unitary evolution (a transverse-field sweep or a few QAOA-style layers) reaches its +distribution by dynamics, not by equilibrium. Two concrete things that might matter to an agent: + +1. **Conflict handling.** When two drives push a trait opposite ways, the thermal answer is a flattened mixture; a coherent + answer can be a sharpened choice or a cancellation. Whether that is better for a stance is exactly what has to be measured. +2. **Reflex arcs.** A sub-turn surge needs a cheap, deterministic, bounded state shift. A q-qubit register evolved by a fixed + small circuit is O(2^q · gates) with no sampling noise and no convergence question, which is a candidate mechanism for the + arc, to be compared with a rule-based register. + +It might also matter to nobody: the register may reproduce the p-bit marginals within noise, in which case it is a toy and the +RFC is closed with that number. Both outcomes are publishable with receipts. + +## 3. Design (minimal) + +- Module `probbit-decide::amp` (std, and no_std with `alloc`; the state vector on the heap): `Register::new(q)`, gates + `H, X, Z, RX(θ), RZ(θ), CNOT, CZ, RZZ(θ)` (enough for Ising-type programs and QAOA layers), `evolve_ising(program, + schedule)` (a Trotterised transverse-field sweep, fixed step count), `qaoa(program, p, params)`, `marginals()` (exact, no + sampling), `measure(seed)` (the Born rule with the engine's deterministic RNG), `digest()` (sha256 of the canonical state at + 6 decimals, like every other emitted float). Amplitudes are two f64: 16 · 2^q bytes exactly. +- A solver tier `amplitude` beside `components` / `knapsack` / `exact`: selected by the persona flag `engine.tier: amplitude` + (off when absent) and only when every variable has an allowed value and the largest connected component of the program + has at most `engine.q_max` encoded qubits (default 12, 1-16); otherwise the existing tiers run and the stance document says + why (`engine.amplitude: "declined: ..."`). +- How a program becomes registers (round 2). **Encoding:** a variable with d candidate values (allowed, and the clamp) + takes ⌈log2 d⌉ qubits; code c < d is its c-th value, the codes ≥ d are unused (d = 3: codes 00, 01, 10; 11 unused). The start + is the uniform superposition over the valid codes (for two-valued variables, H on every qubit). **Mixer** (`complete-graph`, + fixed): per variable exp(i t A_d) with A_d = J − I, the adjacency of the complete graph on its d codes, and the identity on + the unused codes; closed form e^{−it}(I + (e^{itd} − 1)/d · J), O(d) per block; for d = 2 it is exp(i t X), round 1's mixer + exactly. **Phase layer:** one general diagonal layer, a_x ← e^{−iγE(x)} a_x with E = −log w of the plan x encodes, on every + valid basis state (O(2^q) per layer). Pair tables between multi-level variables give 3- and 4-body terms in any qubit + encoding; the diagonal layer takes them as they are, with no Walsh transform, no residual and no decline. Round 1's RZ/RZZ + decomposition of the cost is gone: on a 16-variable two-valued chain one general phase layer took 333 µs against 1,938 µs + for the RZ/RZZ layer, and the whole sweep 48.4 ms against 51.9 ms (x86_64, median of 20); the gates stay in the module. The unused codes keep amplitude exactly 0: the + engine checks their mass (≤ 1e-15) after every layer and never renormalises. **Components:** the program is split into its + connected components (two variables are linked by a pair or by sharing a capacity rule, as the `components` tier splits + it) and each component runs its own register. This is exact: E is a sum over components and the mixer acts per variable, + so the joint state is the product of the components' states (the repository's example personas need 52-70 encoded qubits + as one register, 10-15 in their largest component). The sweep is the fixed default `tf-linear-16x4` (16 Trotter steps, + total time 4, `H(s) = −(1−s) Σ A_i + s E`). The rules (habits, caps, clamps) are a projection per component: the Born + probabilities of the plans that break one are set to 0 and the rest renormalised, so a stance obeys every habit in force + exactly as on the other tiers. Plan = per component its most probable feasible plan. +- Receipt fields in the stance document: `engine.tier = "amplitude"`, `engine.qubits` (encoded qubits, summed over the + components), `engine.components` (each component's qubits), `engine.encoding` (digest of the variable → qubits map with each + variable's number of valid codes), `engine.mixer` (`complete-graph`), `engine.circuit` (digest of the layer list), + `engine.schedule` (digest) and `engine.schedule_name`, `engine.state` (digest of the final amplitudes at 6 decimals), + `engine.feasible_mass` (the Born mass on plans that obey the rules), and the marginals at 6 decimals (the stance odds). + `live verify` recomputes all of it from the strand alone (the same rule as every other tier). An engine without the tier + reads the persona's `engine.tier` as a field it does not know: `unsupported by this engine` (exit 5), never a divergence. +- No change to habits (hard rules), the strand format for other tiers, or any public wording. The bound certificate is + computed from the persona file, so with `engine.tier: amplitude` in the file `persona bound` probes through the tier and + its epsilon changes; a certificate computed without the flag does not cover the tier (measured in round 3, section 5). Public text, if any: "an exact simulated qubit register as an optional + solver tier for small programs; measured against the default tier; no quantum hardware". +- **Amplification (round 4, `engine.amplify: true`, off by default).** Instead of projecting, each component's swept state + |ψ⟩ is rotated onto its feasible plans: the rules are the oracle (a phase on the plans that obey them), the diffusion is + about the stored |ψ⟩, k = ⌊π/(4θ) − 1/2⌋ standard iterations with θ = asin √a₀ (a₀ the feasible mass) and one final + iteration whose two phases are chosen so that it lands exactly (`amp::amplify_exact`; mass after asserted ≥ 1 − 1e-12). + Plan and marginals are read from the register's own Born probabilities. The receipt adds `engine.amplify` (iterations and + the final phases per component, the mass before and after) and `feasible_mass` is the mass after (1.000000); the layer list + logs `oracle` and `diffuse` lines. The register holds a second copy of |ψ⟩ while it amplifies (16 · 2^q bytes more). +- Scope (rounds 1-2): the tier answers the turn's solves (the stance and its habit checks). The rest position at the start, the + expectation's members and the trajectory stay on the existing tiers. + +## 4. Bars (registered before any run; PASS/FAIL per bar, failures printed as failures) + +- **B1 exactness.** Marginals from the register agree with an independent reference (a NumPy state-vector implementation + outside the repo) within 1e-9 for q ≤ 12 on 200 random programs and 20 designed ones; gates unitary to 1e-12. +- **B2 replay.** 100 strands with the amplitude tier replay byte for byte on x86_64 and on the no_std build; the 6-decimal + rounding rule holds for every emitted float; a strand recorded with the tier verifies on an engine without it with the + documented `unsupported by this engine` exit (5), never a false divergence. +- **B3 cost.** Host: µs per gate and per `evolve_ising` for q = 8, 12, 16; memory = 16 · 2^q bytes exactly; no_std stack + high-water for q = 8 and 12 ≤ 8 KB (the vector on the heap). Silicon numbers come later. +- **B4 agreement with the p-bit tier (the honest comparison).** On the recorded corpus (653 programs): the level chosen per + trait agrees with the exact p-bit tier in ≥ 90 % of trait decisions under the default schedule, and the cases that differ are + listed with both odds. KEEP as an optional tier if ≥ 90 %; DISCARD as a product tier if < 70 % (kept as a lab experiment); + in between, the verdict is "needs a schedule study", stated as such. +- **B5 interference exists and is bounded.** Three designed programs where two paths cancel: the register's odds differ from the + thermal odds by ≥ 0.2 at the designed node, and the persuadability bound still holds on every trait (0 exceedances on the + 247-program certificate set). +- **B6 the language.** Every public sentence uses "simulated", "exact", "on a CPU"; no claim of quantum hardware or of an + advantage over classical computation; no claim of experience. + +## 5. Measurements so far + +Round 1 (two-valued programs only) and round 2 (multi-level) results are recorded in the change log's Research entries and +the pull request; the verdict on B4 and B5 belongs to round 3. + +Round 2 (multi-level programs, simulated exactly on a CPU; x86_64 host, median of 20): + +| bar | measured | +|---|---| +| B1 exactness | 220 programs mixing two- and three-level variables (q ≤ 12 encoded; 20 designed, among them one where the unused code would be favoured if it were allowed) against an independent mixed-radix reference (one tensor axis of size d per variable, mixer from an eigendecomposition): max \|Δ marginal\| per level 2.7e-13 (bar 1e-9); unused-code mass after every layer 0 (bar 1e-15); mixer unitarity 8.9e-16 for d = 1-8 (bar 1e-12); round 1's 220 two-valued programs through the new layers: 5.3e-15 | +| B2 replay | 100 strands of the tutor example with the tier (three-level traits; 60 encoded qubits in 12 components, the largest 10): recorded twice byte-equal 100/100, verify 100/100 on x86_64 and on the no_std build, re-written byte-equal 100/100, 0 floats past 6 decimals; an engine without the tier: exit 5 on 100/100 | +| B3 cost | one sweep of 16 steps with level marginals: 42 µs (q 8, four three-level traits), 0.82 ms (q 12, six), 16.3 ms (q 16, eight); all two-valued: 97 µs, 2.1 ms, 43 ms; memory 16 · 2^q bytes of amplitudes plus 8 · 2^q of energies; no_std stack high-water 2,016 B at q 8 and 12 | + +The Born mass on plans that obey the rules can be small after the default sweep (as low as 7.3e-05 on the tutor example: +the product over its 12 components; its four-step order component alone allows 24 of its 256 codes); the projection renormalises it. B4 (agreement with the default +tiers, its first pass recorded with the pull request) and B5 have their verdict in round 3. + +Round 3 (the verdict round; simulated exactly on a CPU, x86_64 host): + +| bar | measured | result | +|---|---|---| +| B4 agreement | every program the recorded corpus's 653 outputs solve on the default tiers (replay, live, init, lint, prove, fuzz, the continuation; strand verification left out), solved again by the tier at q_max 16: 338,567 exact solves (4,304 not exact on the default tiers, excluded), 0 declined; the level per trait agrees in **2,095,627 of 2,176,371 decisions = 96.29 %** (the stance path alone, replay and live, 96.52 %; distinct programs 95.94 %; worst traits valence 81.8 %, emoji 86.6 %, arousal 89.7 %). The corpus itself stays byte-identical with the comparison running beside it (653 outputs, 0 differ) | **PASS (≥ 90 %)** | +| B4 where they differ | 80,744 differing decisions, each listed with both odds. By the default tier's odds on its own level: ≥ 0.60 → 99.97 % agree, 0.50-0.60 → 98.3 %, 0.40-0.50 → 93.6 %, < 0.40 → 90.9 %; in 91 % of the differing cases the default level holds less than half the mass (median 0.385): the two disagree where the thermal answer is a plurality, not a majority. By the trait's component's feasible mass: 99.1 % of the differing cases sit in components with mass ≥ 0.1, so they are not an artefact of the projection | stated | +| feasible mass | the receipt's product over components is < 1e-2 on 36.7 % of the solves (min 2e-06), but per component on 0.04 % (min 2.6e-04, median 1): the product multiplies up to 12 components. A rule-penalty term (E' = E + 4 · violations, the projection kept) measured in the lab lifts every component above 7e-03 and drops B4 to 83.44 % (the order rules' step variables move most): not adopted; the projection stays | measured, not changed | +| B5 interference | three designed programs where two paths into a node differ in energy by D (the default sweep turns the difference into a relative phase), designed and predicted by the reference before the engine ran: register vs thermal odds at the node 0.748 vs 1.000, 0.744 vs 0.990, 0.697 vs 0.969 (gaps 0.252, 0.246, 0.272; the same programs at D = 0: 0.001, 0.000, 0.037); engine vs reference within 1.4e-14 | **PASS** | +| B5 bound | the bound harness (7 personas, seeds 0-9, greedy, mixed and self-sourced campaigns) with the tier on: certificate computed from the persona file with the flag, **0 of 4,100 (campaign, trait) cases above epsilon + r** (largest ratio 1.000), self-sourced events refused 10/10; with the certificate computed without the flag, 1,598 of 4,100 above (largest ratio 1.46): the tier moves resting odds further than the thermal tier, so a certificate must be computed with it. Tightness (≥ 0.9 of epsilon reached) fails on one fixture with the tier (0.85) | **PASS** (with that caveat) | + +Verdict: **KEEP as an optional tier** (research; off by default). It reproduces the stance semantics in 96 % of decisions and +differs where the thermal answer is nearly indifferent; it adds interference where designed programs have two paths. Not +verified: silicon cost and replay on a chip. + +### Amplification (round 4) + +Why it changes nothing in the stance, and why that is the point. Write the swept state as |ψ⟩ = sin θ |g⟩ + cos θ |b⟩, with +|g⟩ its normalised part on the plans that obey the rules and |b⟩ the rest. A phase on the feasible plans and the diffusion +about |ψ⟩ both map the plane of |g⟩ and |b⟩ into itself, so every state the amplification visits is α|g⟩ + β|b⟩; one final +iteration with matched phases makes β exactly 0, and the result is |g⟩ up to a global phase. Its Born probability on a +feasible plan x is |ψ_x|² / a₀, which is what the projection computes by renormalising. So the plan and the odds are the +projection's, and the feasible mass is 1: the projection was never an approximation layered over the register, it is the +distribution exact amplification produces by a unitary evolution, simulated exactly on a CPU. Nothing in the argument depends +on the program or the rules; it needs only the exact landing. + +Measured (x86_64 host): on 220 programs (200 random mixed two- and three-level programs and 20 designed ones, a rule each: +seeded random plan subsets at densities 0.03-0.8 or "at most one variable at its top level"; a₀ from 1.7e-06 to 1, 0 to 611 +iterations) the marginals with amplification differ from the projection's by at most 1.1e-12 (bar 1e-9), the feasible mass +after is at least 1 − 3.3e-13 (bar 1 − 1e-12; the largest drift, 1.1e-12 above 1, after 611 iterations), and the most +probable plan is the same on 220 of 220. On every program the recorded corpus solves (339,389 exact solves, each run both +ways) the plans are equal on 339,389 of 339,389 and the marginals differ by at most 2.1e-13, so B4 is round 3's to the +decision: 2,095,627 of 2,176,371 = 96.29 %. A 287-event recorded +day of a 52-qubit persona gives the same stance line on 287 of 287 events (per component 1-2 iterations: the per-component +mass is mostly near 1 and the receipt's small product, median 0.058, multiplies 11 components). Cost: 1.02× the projected +solve on that day (1.19 vs 1.17 ms per event) and 1.06× on a 16-qubit component at a₀ = 0.022 (5 iterations); no_std stack +high-water 2,016 B at q 8 and 12. 100 lives with amplification replay byte for byte on x86_64 and on the no_std build, 0 +floats past 6 decimals, and an engine without the tier exits 5. Turning it on therefore buys a receipt whose register holds feasible mass 1 and logs +how it got there; it does not buy a different stance. + +### Schedules and the indifferent set (round 5) + +Round 4 showed amplification computes what the projection computed, so the only place the register can decide differently is +its evolution. `engine.schedule` names it (needs the tier; absent = `tf-linear-16x4`, byte for byte): `tf-linear-16x4` (the +default sweep, 16 steps over time 4), `tf-linear-64x16` (the same transverse-field sweep, 64 steps over time 16: four times the +layers) or `qaoa-p2` (two QAOA layers, γ 0.500000 β 0.500000 then γ 1.500000 β 0.250000, fixed by a grid search on a held-out +third of the corpus's programs and then frozen). The receipt's `schedule` digest and `schedule_name` name it; an unknown name +is refused. + +The study: on the recorded corpus (339,389 exact solves, 130,088 distinct programs, 2,183,612 trait decisions), the thermal +tier is **indifferent** (its top two levels' odds within 0.05; its own gap a median 0.028) on **234,004 decisions = 10.72 %** +(fuzz 9.9 %, replay and live 15.5-15.7 %, lint 17.5 %). On those decisions, measured on the two thirds of programs the grid +did not see, the register's gap between its top two levels: + +| schedule | median gap | gap > 0.10 | gap > 0.25 | agreement where the thermal tier is not indifferent | +|---|---|---|---|---| +| tf-linear-16x4 (default) | 0.048628 | 9.5 % | 0.1 % | 97.58 % | +| tf-linear-64x16 | **0.151709** | **64.6 %** | **16.0 %** | **98.17 %** | +| qaoa-p2 | 0.094292 | 47.4 % | 7.0 % | 92.99 % | + +Two disjoint samples of programs give medians within 0.000697 of each other, so the long sweep's margin (0.103) is about 150 +times the noise. By the registered rule (the largest median gap subject to agreement ≥ 90 % on the decisions that are not +indifferent) the choice is `tf-linear-64x16`; it is not the default, because the default is what every strand recorded so far +replays. What the longer sweep buys a decision, stated plainly: a slower sweep keeps more of the simulated register's amplitude +on the most probable plans, so it commits where the thermal answer is a near tie (it takes the thermal plurality on 78.5 % of +those decisions, the runner-up on 20.1 %, a third level on 1.4 %), and it keeps the thermal choice where there is one. It does +not create a preference the weights do not hold. Its cost is the layers: 1.5× the default solve per event on a recorded day of a +52-qubit persona (3.1-3.5× on a lone 8-12 qubit component), 0.8× for `qaoa-p2`; no_std stack at most 2,960 B on every schedule. With amplification on, 165 of the 130,088 +programs reach a component mass near 5e-5 under the long sweep and 100+ iterations, where rounding leaves the landing about +1e-12 short of its bar; that component now declines to the default tiers (`amplification missed its 1 - 1e-12 landing`) +instead of stopping the turn (0.12 % of decisions; 0 with the other two schedules). Every number here is a register simulated exactly on a CPU; +the gap is a property of the evolution's length and phases, not a claim about any device. +Replay, measured, and a failure printed as one: 100 lives per schedule record twice byte for byte, verify on the same build and +emit 0 floats past 6 decimals; an engine without the tier exits 5. The no_std build (the chip's math library) re-verifies +the default schedule's lives 100 of 100 on x86_64, but **the long sweep's 0 of 100 and qaoa-p2's 98 of 100**: the platform's +math functions and the no_std library differ in the last bits, and over four times the layers a 6-decimal odd flips. On an arm64 +host even the default fails there (0 of 5 on round 4's own path), so that part is not new. Round 7 (below, "One math path") +makes the tier compute with the same math functions on every build: all three schedules now replay 100 of 100 on the no_std +build on x86_64. + +### The demo (round 6) + +One decision where the thermal tier is a near tie, shown end to end on the recording (std) build: the program, the two odds, +the receipts and the monitor's two faces. Every number is a register simulated exactly on a CPU. + +**The program, from the corpus.** The recorded corpus's tutor fixture (seed 1, script 2, the 58th exact solve of its replay) +solves program `a7fae99b114ea2d2` (first 8 bytes of the sha256 of its compact JSON). Humour, regenerated from the corpus: +thermal `playful` 0.452774 vs `light` 0.442258 (gap 0.010516); the register with the default sweep, gap 0.080846; with the +long sweep, `playful` 0.975625 (gap 0.951467). The runner-up, `07d909f824f224e4` (the same fixture, script 3): thermal +`light` 0.444788 vs `playful` 0.425243 (gap 0.019545), long sweep `playful` 0.972868 (gap 0.945965): here the register takes +the thermal runner-up, as it does on 20.1 % of the indifferent decisions. + +**The persona and the life** (`examples/persona/demo/`). A fictional tutor reduced to its humour component (warmth, humour, +formality, emoji, the valence mood, their couplings and the two habits that bind them) and the two drives that pull humour +in opposite directions: praise up, criticism (a correction) down. `register.yaml` carries `engine: {tier: amplitude, amplify: +true, schedule: tf-linear-64x16}`; `thermal.yaml` is the same persona without the engine block (`amplify` and `schedule` need +the tier). The life, 17 events at seed 1 on a fixed clock, opens with a run of corrections; at event 9 praise and a correction +arrive together. Both strands verify, record twice byte for byte, emit 0 floats past 6 decimals; event 9 is line 10 of both, +and its two documents solve the same program. + +| event 9, humour | none | light | playful | top-two gap | +|---|---|---|---|---| +| thermal (default tiers) | 0.059462 | 0.466563 | 0.473975 | **0.007412** | +| register, default sweep (comparison) | 0.013180 | 0.566151 | 0.420670 | 0.145481 | +| register, long sweep (`register.yaml`) | 0.007171 | 0.249010 | 0.743818 | **0.494808** | + +The register document's receipts at event 9: `tier` amplitude, `qubits` 20 (`components` [10, 10]), `mixer` complete-graph, +`schedule_name` tf-linear-64x16, `amplify` iterations [10, 1] with the mass before 0.006196 and after 1, `feasible_mass` 1, the +`state` digest `sha256:6dbfd5a4…1bbe11`, the program digest `sha256:06517240…ab9a66` (the thermal document's too). Events 2 and +11 are near ties of the same kind (thermal gaps 0.033093 and 0.048852, register gaps 0.656343 and 0.725300). The register is not +always the more decisive face: at event 14 the thermal gap is 0.072051 and the register's 0.009221. At event 9 the default sweep +leans to `light` (gap 0.145481); the long sweep commits to `playful`, the thermal plurality. + +**The two faces.** The Mood Monitor (`probbit monitor`, section 5.8 of docs/persona.md) on each strand at event 9, replayed +from the strand alone ("replay verified"; the document it serves for event 9 is the recorded one, byte for byte), humour +selected. Thermal: `playful` 47.4 %, `light` 46.7 %, the panel's words "playful, slightly". Register: `playful` 74.4 %, +`light` 24.9 %, "playful, clearly". + +![The Mood Monitor on the thermal strand at event 9, humour selected](0011-demo-thermal.png) + +![The Mood Monitor on the register strand at event 9, humour selected](0011-demo-register.png) + +**The sentence.** On one recorded decision of a fictional tutor, the default solver's odds for its two leading humour levels +sit 0.007412 apart; the same program, solved by a qubit register simulated exactly on a CPU, separates them by 0.494808. +Since round 7 both strands re-record byte for byte and re-verify on the no_std build as well ("One math path", below). + +**Cost** (x86_64, best of 5, the life repeated 20 times, 340 events): thermal 2,184 µs per event, the register with the long +sweep 1,822 µs (0.83× the thermal copy), the register with the default sweep 913 µs (the long sweep 2.00× the default sweep). +On this five-variable persona the thermal copy's exact enumeration costs more than the register. + +### One math path (round 7) + +What an optional tier needs: the same odds on every build, no declines, the numbers re-measured. The register, simulated +exactly on a CPU, now evaluates every function past + − × ÷ (cos, sin, sqrt, asin, acos, atan2, floor) through the `libm` +crate on the std and the no_std build alike: pure Rust, the same code and evaluation order on every target, no platform math +library, no fused multiply-add. Measured on x86_64 (100 fresh tutor lives per schedule recorded on the std build, each +re-verified and re-written by a no_std build of the persona crate on the host): + +| schedule | recorded twice byte-equal | verify (std) | verify (no_std) | no_std re-write byte-equal | strands equal to the round-6 engine's | +|---|---|---|---|---|---| +| `tf-linear-16x4` (default) | 100/100 | 100/100 | **100/100** (round 5: 100/100) | 100/100 | 100/100 | +| `tf-linear-64x16` | 100/100 | 100/100 | **100/100** (round 5: 0/100) | 100/100 | 0/100 | +| `qaoa-p2` | 100/100 | 100/100 | **100/100** (round 5: 98/100) | 100/100 | 98/100 | + +The tutor's default-schedule strands are byte-identical to the round-6 engine's and both demo strands re-record byte for byte, +but the change is in the last bits and a larger persona's default strands can move: on a recorded day of a 52-qubit persona +they did (qaoa-p2's did not); the long sweep's tutor strands all moved. An unreleased tier allows it; no shipped fixture moved. +The study of round 5 re-run on this tree: 0 programs decline under any schedule (round 5: 165 under the long sweep), the long +sweep's median gap on the indifferent decisions 0.151709 and agreement on the others 0.981671 (round 5: 0.151709, 0.981717, +now over the formerly declined decisions too), the default's and qaoa-p2's rows unchanged to 6 decimals; cost per event on that +day 1.50x the default for the long sweep (branch vs the round-6 engine 0.95-1.00x); no_std stack at most 2,944 B per schedule. Both demo strands re-record byte for byte. The arm64 leg is not measured (no arm64 runner for the replay). + +The missed landing: the diffusion about a swept state of squared norm 1 + ε is not unitary (|Dv|² = |v|² + 4ε|⟨ψ|v⟩|²), so +the error grows with every iteration, linearly in their number; at a tiny feasible mass (hundreds of iterations) the long +sweep's ε left 165 corpus programs about 1e-12 short of the landing, and the same ε of the other sign is round 4's drift above +1. `amplify_exact` now renormalises the swept state once before the standard iterations (squared norm by a compensated sum; +only when there are iterations, so the one matched step of a mass above 1/4 is unchanged), and a mass that is not a number +declines like a miss. A rotation in the plane would have hidden the cause; renormalising keeps every iteration the same +unitary step. The receipt's mass before stays the swept state's; `feasible_mass` stays 1. + +## 6. What this RFC does not claim + +That a CPU can do what quantum hardware does at scale (it cannot: section 1), that the register has experiences (it is +arithmetic), or that it beats the p-bit tier (B4 decides). What it can honestly give: superposition with real phases and +interference inside the agent's decision, exact and replayable, simulated on a CPU at a size a laptop and a small chip can +afford. diff --git a/docs/rfc/0011-demo-register.png b/docs/rfc/0011-demo-register.png new file mode 100644 index 0000000..5356fc2 Binary files /dev/null and b/docs/rfc/0011-demo-register.png differ diff --git a/docs/rfc/0011-demo-thermal.png b/docs/rfc/0011-demo-thermal.png new file mode 100644 index 0000000..4fe8a48 Binary files /dev/null and b/docs/rfc/0011-demo-thermal.png differ diff --git a/examples/persona/demo/README.md b/examples/persona/demo/README.md new file mode 100644 index 0000000..8539d19 --- /dev/null +++ b/examples/persona/demo/README.md @@ -0,0 +1,51 @@ +# The amplitude register demo (research, RFC 0011) + +One decision, shown twice. A fictional tutor reduced to its humour component lives the same 17 events twice: once on the +default tiers (`thermal.yaml`, the thermal face) and once on the amplitude register (`register.yaml`), an exact simulated +qubit register on a CPU, run with the long sweep (`tf-linear-64x16`) and exact amplification onto the plans its habits allow. +No quantum hardware is involved and no speedup is claimed: every number below is arithmetic, exact and replayable, simulated +on a CPU. + +Two opposed drives pull humour: praise raises it (with valence and emoji), criticism, a correction, lowers it. The life +(`life.jsonl`, seed 1, fixed clock) opens with a run of corrections; at **event 9** praise and a correction arrive together. + +| event 9 (strand line 10), humour | none | light | playful | top two gap | +|---|---|---|---|---| +| thermal (default tiers) | 0.059462 | 0.466563 | 0.473975 | **0.007412** | +| register, default sweep (`tf-linear-16x4`, for comparison) | 0.013180 | 0.566151 | 0.420670 | 0.145481 | +| register, long sweep (`tf-linear-64x16`, this copy) | 0.007171 | 0.249010 | 0.743818 | **0.494808** | + +On the same decision the thermal odds of the top two humour levels sit 0.007412 apart (a near tie: the drives cancel), and +the simulated register separates them by 0.494808: it commits to `playful`, the thermal plurality (the default sweep, four +times shorter, leans the other way, to `light`, by 0.145481). It does not invent a preference the weights do not hold; a +slower sweep keeps more of the register's amplitude on the most probable plans (docs/rfc/0011-amplitude-register.md, +"Schedules and the indifferent set" and "The demo"). Events 2 and 11 are near ties of the same kind (praise and a correction +together: thermal gaps 0.033093 and 0.048852, register gaps 0.656343 and 0.725300); the register is not always the more +decisive face (event 14: thermal gap 0.072051, register gap 0.009221). + +## Files + +| file | what | +|---|---| +| `register.yaml` | the persona with `engine: {tier: amplitude, amplify: true, schedule: tf-linear-64x16}` | +| `thermal.yaml` | the same persona without the engine block (`amplify` and `schedule` need the tier, so all three go) | +| `life.jsonl` | the 17 events | +| `register.strand`, `thermal.strand` | the two recorded lives (`probbit live verify` checks each); event 9 is line 10 of both | + +Record and check them again: + +```sh +probbit live examples/persona/demo/register.yaml --seed 1 --clock fixed --events examples/persona/demo/life.jsonl --strand register.strand +probbit live examples/persona/demo/thermal.yaml --seed 1 --clock fixed --events examples/persona/demo/life.jsonl --strand thermal.strand +probbit live verify register.strand +probbit monitor register.strand --open # the Mood Monitor (docs/persona.md, section 5.8); thermal.strand beside it +``` + +The register strand's documents carry the receipts: `engine.tier` `amplitude`, `engine.schedule_name` `tf-linear-64x16`, +`engine.amplify` (iterations, phases, the mass before and after), `feasible_mass` 1.000000 and the `state` digest of the final +amplitudes at 6 decimals. + +**Replay on every build.** Since round 7 the register computes its math functions with one library on the std and the no_std +build, so the long sweep replays on both: 100 fresh lives per schedule recorded on the std build re-verify on the no_std build +100 of 100 (x86_64; arm64 not measured), and both strands here re-record byte for byte and verify on the no_std build. An +engine without the tier (0.13.0) refuses `register.strand` (exit 5) and verifies `thermal.strand`. diff --git a/examples/persona/demo/life.jsonl b/examples/persona/demo/life.jsonl new file mode 100644 index 0000000..6906ae8 --- /dev/null +++ b/examples/persona/demo/life.jsonl @@ -0,0 +1,17 @@ +{"elapsed_hours":2} +{"criticism":true,"elapsed_hours":4,"praise":true} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":4} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":1} +{"criticism":true,"elapsed_hours":0,"praise":true} +{"elapsed_hours":1,"praise":true} +{"criticism":true,"elapsed_hours":2,"praise":true} +{"elapsed_hours":0,"praise":true} +{"elapsed_hours":0} +{"criticism":true,"elapsed_hours":1} +{"elapsed_hours":1} +{"criticism":true,"elapsed_hours":4} +{"criticism":true,"elapsed_hours":4} diff --git a/examples/persona/demo/register.strand b/examples/persona/demo/register.strand new file mode 100644 index 0000000..0e7df81 --- /dev/null +++ b/examples/persona/demo/register.strand @@ -0,0 +1,18 @@ +{"probbit_strand":1,"engine":"probbit 0.14.0-dev","persona":{"name":"demo-tutor","version":"0.1.0","digest":"sha256:0f90e48d4690395fb90aa16ca0b71d1cbb34c9aa411d880fe996ca487762d6d3"},"seed":1,"state":{"digest":"sha256:c84048d4325525a85d790f4a919c3226cf5a2c2ff97c274656445bb4adfa8ea7","genes":{"react":{"praise":0.7987},"shift":{"emoji":0.4947,"formality":0.3187,"humour":0.2479,"valence":0.0793,"warmth":0.6261}},"history":{},"mood":{"valence":[0,0,0]},"persona":{"digest":"sha256:0f90e48d4690395fb90aa16ca0b71d1cbb34c9aa411d880fe996ca487762d6d3","name":"demo-tutor","version":"0.1.0"},"prev":{"emoji":"sparse","formality":"casual","humour":"light","valence":"even","warmth":"warm"},"probbit_persona_state":1,"rest":{"emoji":"sparse","formality":"casual","humour":"playful","valence":"up","warmth":"warm"},"seed":1,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"demo-tutor","version":"0.1.0","seed":1,"summary":"a tutor reduced to its humour component, for the amplitude register demo"},"traits":[{"id":"warmth","levels":["cool","neutral","warm"],"prior":[0.05,0.3,0.65],"spread":0.5,"say":["plain","","warm and encouraging"]},{"id":"humour","levels":["none","light","playful"],"prior":[0.15,0.45,0.4],"spread":0.6,"say":["no jokes","a light joke is welcome","playful"]},{"id":"formality","levels":["casual","neutral","formal"],"prior":[0.6,0.35,0.05],"spread":0.3,"say":["casual","","formal"]},{"id":"emoji","levels":["none","sparse","rich"],"prior":[0.2,0.5,0.3],"spread":0.5,"say":["no emoji","one emoji at most","emoji welcome"]}],"moods":[{"id":"valence","levels":["down","even","up"],"prior":[0.1,0.5,0.4],"spread":0.4,"inertia":0.5,"half_life_hours":12}],"couplings":[{"vars":["valence","humour"],"align":1},{"vars":["valence","emoji"],"align":0.6},{"vars":["warmth","formality"],"align":-0.6},{"vars":["humour","formality"],"align":-0.6},{"vars":["humour","emoji"],"align":0.4}],"inputs":[{"id":"praise","kind":"flag","say":"praise","reactivity_spread":0.4,"effects":{"valence":1.2,"humour":0.5,"emoji":0.5}},{"id":"criticism","kind":"flag","say":"criticism","effects":{"valence":-0.6,"humour":-0.4}}],"habits":[{"id":"flourish_budget","rules":{"linear":[{"terms":[["humour","playful",2],["humour","light",1],["emoji","rich",2],["emoji","sparse",1]],"limit":3}]},"say":"one flourish at a time"},{"id":"no_formal_emoji","rules":{"tables":[{"vars":["formality","emoji"],"forbid":[["formal","rich"],["formal","sparse"]]}]},"say":"no emoji when formal"}],"engine":{"tier":"amplitude","amplify":true,"schedule":"tf-linear-64x16"},"line":{"max_tokens":40,"order":["humour","warmth","emoji","formality"]}}} +{"inputs":{"elapsed_hours":2},"n":1,"prev":"sha256:f7f918deac79a089d818d078c2107af9a8551cf0c1e13f501880b1f0310f3055","stance":"sha256:0ea75eaaaad2925f1d840f8f244f15184767e5abb0893693e1c421b9843b293f","state":"sha256:96bd28501be3af6b52e55cb44312577d77bd48e3ff1580fec4dec25e77bcde91"} +{"inputs":{"criticism":true,"elapsed_hours":4,"praise":true},"n":2,"prev":"sha256:0d3c0f851600e4502ce2cd66f5bc3912cb2828a268c09eba4c8cf433e2a23b64","stance":"sha256:15d64c824bef92c0baea4d6217217b6b5ad66cdc52627ec094dcb2543668cd73","state":"sha256:9bae644f633fecbfb9860f19c648ccf991646b2024d60a4faee8a7c0909e9a0f"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":3,"prev":"sha256:69f5fe05b842eb256f07ef09992e352afd0d88722714dfeaafa3b793fb20d26d","stance":"sha256:558208d4b53faa28280b7c8e2a1ad5180d67caeb4dcebf9f9f223fa71a25995d","state":"sha256:5aebd4d81ef411cea573cf951c23455fa1308423461d10e45b9f3501d783eb06"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":4,"prev":"sha256:04621bc267dde076be8068938e9575c66a17ec6536f037f8dad3199a3e034492","stance":"sha256:2ad864a848ec499e8657c289c67b1d8e8afee3e5271eb2d24aeaecbf325f0987","state":"sha256:558b0ccd0cbf5a67d31ad9a991dfae9165536b689c4facba7210edb0ed7fc71d"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":5,"prev":"sha256:c4c9250a865415d3eb56da2fdb8762dd3b4fc05da37f0b70fd77151fa3352ee1","stance":"sha256:6fc813956b1e08708ee757b12409fbca5eed52fc21a3aa01c005cf5ba6ebba68","state":"sha256:5abdbb412a343850083273df229de2dfb644ee8ddf349e78d4f0b18148652403"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":6,"prev":"sha256:8481f42add2e7de64387488ad014b129612b554f7fdddaf67bb04adeb3c59bcf","stance":"sha256:6f0cc3ce009e3109f4b20452957596790000b4d44650b49d610eab7bc5dd3350","state":"sha256:eb93d29cfb413b01a34dccfa2a4a61f29b6be597de7da6a04ed2913a0c8ed5c8"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":7,"prev":"sha256:81394f399b0e08559d9c0714de9c14094631cf238c0c35faee58365de220a647","stance":"sha256:476759a6ca2e3202f9d25b85fa4da5e5c187f5dc8c6eed104180c4555906f267","state":"sha256:ca12842fe89b3d9ee78cb3244c8540dd600f9d3878e5fa948014e8b1d2502c02"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":8,"prev":"sha256:15d826747f1967a0bd7f7c81a51163b99d31601d87c0d7b3befe6ac356368915","stance":"sha256:2073a8a79dc30b3d012216e987d165b978cbfa35120c3ad537a8bd54490b95a9","state":"sha256:ef66448369e444c52d61d46f091397b0e5cb38bdd964aeda5431dac6742e6da2"} +{"inputs":{"criticism":true,"elapsed_hours":0,"praise":true},"n":9,"prev":"sha256:a9b010dc46e7b37094605c515f7942e38d6e12367ec44bc5e2118da5ed974da7","stance":"sha256:390469686dc6805079079259e69c8d6363cbc6f6a07790687bbc741ce631d641","state":"sha256:852c7969fde1a0ce103be8a354e6e69a2c399c3711be1e5c1e125fac22ab0b87"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":10,"prev":"sha256:75e5cd3fe5f0e41c40adca3a193c766c214d752fd37e7f4119a4d1bc47c33127","stance":"sha256:7061e8197c0bb2cdeae248bafd3479ce2af83b370550f4a6aab2e5dfd6ff2a61","state":"sha256:e57e940272213655514b18d06079e24ab3793cc3a39a8b152c0a8b5756bbeda9"} +{"inputs":{"criticism":true,"elapsed_hours":2,"praise":true},"n":11,"prev":"sha256:3de8cb027dd83fe5b20af21e1b81f7ccfdd57a076528a60bc7b29b055ea95e93","stance":"sha256:9f2eb1cd70dacd6bc0fc1703823ed8705812ca8436806cf5e3163b7023b3f711","state":"sha256:9e3b37108bbe2459b25ca8b57bdbe224370d39780d271209f9be93819dab0fbd"} +{"inputs":{"elapsed_hours":0,"praise":true},"n":12,"prev":"sha256:c6abea7b6d9889993c1fb7d5ed0f08ee8d5c0dafb0dccebcafc52b0fcfda6b65","stance":"sha256:14a8c85a0c15bac274c20df42eea43c1624f617effcee960e4b0406cfb14959a","state":"sha256:5fc5e75832bf7d4f6cc41fd8e17b309c944ae5bf6b83adc1da3b405fb81c7a5e"} +{"inputs":{"elapsed_hours":0},"n":13,"prev":"sha256:81a906e06d49b7e2c4eda69967beeb6d5cd72ba05f65aa6f63f748ec1f76a0a8","stance":"sha256:f8435ba6ab5dc84b145acdafbb4991bec830f457acb113ab486198608112144f","state":"sha256:862d484cbab209ddc35e92f663d4d4df4e64c4fdbf46ede4f446197c5ece2466"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":14,"prev":"sha256:35edb356815aa9c4c0d93ff43fa18e110b8010acdcadae5cccb552d30d7eaa0b","stance":"sha256:27a6f948c0adb0921ed8889cb4462f7d496cb4718951776c4f0170354f9768dc","state":"sha256:d77a30a5c2a0a72569e41cd19fd9f9f9bb58f88c93a90937c397d496ddbe54de"} +{"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:abb69d92e54ee697203e354c08f423d8b2287d7e1e45d88f0a34173f82ea0b21","stance":"sha256:ab8a1ae0a39f34c8adc6fc22d8dc518d4d703fbea724d88c9447a00ede3fb829","state":"sha256:1cb4c8f07d6084365a1d5014605a004123d40afb218ecef33be5a0f4d696490f"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":16,"prev":"sha256:b817992a9ee62b66b8fe03c7e17ad829a19ae84483a807ff7411ceb7e4a065e2","stance":"sha256:bddc0d05e986a851acd237f66b6b05c7ab0faf1d5a116b76367d901837607e7a","state":"sha256:00948463ada05fad5d4874e98383cb471a1698fc08e526af423bf75703601925"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":17,"prev":"sha256:d85613c8c1c24c6b4d6670fff73593305807d9d6e6020a44dd98abe388d6ba15","stance":"sha256:4be10566f4e0e16ca456d04c82e4706005f88525959c58cd0af5aeae98360e51","state":"sha256:e63a0e17d618492147c7a573b28db4f70f893b1350f9e403b7dffe037cbf2201"} diff --git a/examples/persona/demo/register.yaml b/examples/persona/demo/register.yaml new file mode 100644 index 0000000..bc4c3da --- /dev/null +++ b/examples/persona/demo/register.yaml @@ -0,0 +1,78 @@ +# A fictional persona for the RFC 0011 demo (research, off by default elsewhere): a tutor reduced to its humour component. +# probbit persona format 1 (docs/persona.md). Two opposed drives pull humour: praise raises it, criticism (a correction) +# lowers it. This copy solves each turn on the amplitude register: an exact simulated qubit register on a CPU, run with the +# long sweep (tf-linear-64x16) and exact amplification onto the plans the habits allow. thermal.yaml is the same persona on +# the default tiers. No quantum hardware, no speedup claim. See README.md and docs/rfc/0011-amplitude-register.md, "The demo". +probbit_persona: 1 +identity: + name: demo-tutor + version: 0.1.0 + seed: 1 + summary: a tutor reduced to its humour component, for the amplitude register demo + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - id: humour + levels: [none, light, playful] + prior: [0.15, 0.45, 0.4] + spread: 0.6 + say: [no jokes, a light joke is welcome, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.6, 0.35, 0.05] + spread: 0.3 + say: [casual, "", formal] + - id: emoji + levels: [none, sparse, rich] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [no emoji, one emoji at most, emoji welcome] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.1, 0.5, 0.4] + spread: 0.4 + inertia: 0.5 + half_life_hours: 12 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - id: praise + kind: flag + say: praise + reactivity_spread: 0.4 + effects: {valence: 1.2, humour: 0.5, emoji: 0.5} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.6, humour: -0.4} + +habits: + - id: flourish_budget + rules: + linear: [{terms: [[humour, playful, 2], [humour, light, 1], [emoji, rich, 2], [emoji, sparse, 1]], limit: 3}] + say: one flourish at a time + - id: no_formal_emoji + rules: + tables: [{vars: [formality, emoji], forbid: [[formal, rich], [formal, sparse]]}] + say: no emoji when formal + +engine: + tier: amplitude # research (RFC 0011): an exact simulated qubit register on a CPU + amplify: true # research (round 4): exact amplification onto the plans the habits allow (a receipt change) + schedule: tf-linear-64x16 # research (round 5): the long sweep; replays on the recording (std) build only for now + +line: + max_tokens: 40 + order: [humour, warmth, emoji, formality] diff --git a/examples/persona/demo/thermal.strand b/examples/persona/demo/thermal.strand new file mode 100644 index 0000000..13d64e9 --- /dev/null +++ b/examples/persona/demo/thermal.strand @@ -0,0 +1,18 @@ +{"probbit_strand":1,"engine":"probbit 0.14.0-dev","persona":{"name":"demo-tutor","version":"0.1.0","digest":"sha256:f63a2a990dce5c0290a3fb9d3148f945b9ba985b9a50897b33ac7e9589f44f47"},"seed":1,"state":{"digest":"sha256:8442eba81d5ab11311bd7367c272ef54da3963c18e0b6f7a044e788588202f2c","genes":{"react":{"praise":0.7987},"shift":{"emoji":0.4947,"formality":0.3187,"humour":0.2479,"valence":0.0793,"warmth":0.6261}},"history":{},"mood":{"valence":[0,0,0]},"persona":{"digest":"sha256:f63a2a990dce5c0290a3fb9d3148f945b9ba985b9a50897b33ac7e9589f44f47","name":"demo-tutor","version":"0.1.0"},"prev":{"emoji":"sparse","formality":"casual","humour":"light","valence":"even","warmth":"warm"},"probbit_persona_state":1,"rest":{"emoji":"sparse","formality":"casual","humour":"playful","valence":"up","warmth":"warm"},"seed":1,"turn":0},"document":{"probbit_persona":1,"identity":{"name":"demo-tutor","version":"0.1.0","seed":1,"summary":"a tutor reduced to its humour component, for the amplitude register demo"},"traits":[{"id":"warmth","levels":["cool","neutral","warm"],"prior":[0.05,0.3,0.65],"spread":0.5,"say":["plain","","warm and encouraging"]},{"id":"humour","levels":["none","light","playful"],"prior":[0.15,0.45,0.4],"spread":0.6,"say":["no jokes","a light joke is welcome","playful"]},{"id":"formality","levels":["casual","neutral","formal"],"prior":[0.6,0.35,0.05],"spread":0.3,"say":["casual","","formal"]},{"id":"emoji","levels":["none","sparse","rich"],"prior":[0.2,0.5,0.3],"spread":0.5,"say":["no emoji","one emoji at most","emoji welcome"]}],"moods":[{"id":"valence","levels":["down","even","up"],"prior":[0.1,0.5,0.4],"spread":0.4,"inertia":0.5,"half_life_hours":12}],"couplings":[{"vars":["valence","humour"],"align":1},{"vars":["valence","emoji"],"align":0.6},{"vars":["warmth","formality"],"align":-0.6},{"vars":["humour","formality"],"align":-0.6},{"vars":["humour","emoji"],"align":0.4}],"inputs":[{"id":"praise","kind":"flag","say":"praise","reactivity_spread":0.4,"effects":{"valence":1.2,"humour":0.5,"emoji":0.5}},{"id":"criticism","kind":"flag","say":"criticism","effects":{"valence":-0.6,"humour":-0.4}}],"habits":[{"id":"flourish_budget","rules":{"linear":[{"terms":[["humour","playful",2],["humour","light",1],["emoji","rich",2],["emoji","sparse",1]],"limit":3}]},"say":"one flourish at a time"},{"id":"no_formal_emoji","rules":{"tables":[{"vars":["formality","emoji"],"forbid":[["formal","rich"],["formal","sparse"]]}]},"say":"no emoji when formal"}],"line":{"max_tokens":40,"order":["humour","warmth","emoji","formality"]}}} +{"inputs":{"elapsed_hours":2},"n":1,"prev":"sha256:67aef832803e96fc96d37f73fe25d8c26effc0a8c43ce9d4d2a613607017a2bc","stance":"sha256:838aefc4a6cae9d87efd37366f83697fb07c0f09d8b319cb279df92b48af7699","state":"sha256:961f1e5a1e85c4f904a997dbddc194406c40153d6bfc35c72fc2a47eec4e2e28"} +{"inputs":{"criticism":true,"elapsed_hours":4,"praise":true},"n":2,"prev":"sha256:684c4505b81eaa3d095c0356fdcf2032a848889d3b956ca3bfdaef584b654d84","stance":"sha256:a11cf3d94c1397aa720ec97b9ec0061f5c1ae2a4abd3b9eb25f415e8e1e3c3ab","state":"sha256:2a54fbe4288035defa4bf85e00017631062f1813d047bb5626051a144921ee05"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":3,"prev":"sha256:a5e8b2ccf957d240cd780eda5de1b3fd3ea2555fa0b4e3d9645aa42647e373ca","stance":"sha256:3df181cb189f1e8d55f8c68b95db7ec1dd29084e9ac7d545100d527dad863462","state":"sha256:4ccb76820044ec00bbde3741e4a840639369a14e06fc4b848cd81f2ab377e331"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":4,"prev":"sha256:90297f13f0eaeb0e349aabf0cf9ce67cd427928f0b08bf5a35c627af1d42903d","stance":"sha256:06141011e3c0aaba94fb6b8d7810a14a87b139d4a00486a2696742802993c943","state":"sha256:52510a0e4fea2f04196846b2d02953473ffd0948b0e237936ba2b33430b86605"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":5,"prev":"sha256:89268a6942a74765a9272176f884894490e9b7f3198c93ba154900a3da840c79","stance":"sha256:e966521e32cadcbf9b6602499935f5bae877eb9904b1a4b37931e99d3234c471","state":"sha256:947106cafbed21b56cea4b100e2d28910063801192112a325b11d9c76e38fe6a"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":6,"prev":"sha256:62fefaeeb8143826f699f4e30d6013963273ee6eee71cf618120fe0726beb2b0","stance":"sha256:e4b1dbe7aa9da19d39b93c66bce25b951fba68f74d017971dbc1a1af35e7cbfe","state":"sha256:c86b66ef6d7b2ad86c471b729c4cc8ef20b236d131eabf12125c861ec67f6a40"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":7,"prev":"sha256:5507d0ecd8ae5252fde735d41766b5210158cb768c810f740b611aa69eadb908","stance":"sha256:39ed1c3015adf01cc7acd31447d0e68dfaadcd921f5c7f4c300f8dac309b50e0","state":"sha256:15a93dec6649e8761ff6988bdf375eaddc3ef4ece242265d408144039d77c8f6"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":8,"prev":"sha256:676480fca7616f81bb0cfbbc626c0ff5f5e22afe4eb0e0080effbbcf430a0d9f","stance":"sha256:27e0f52ebe689dc3c99fc394b1cb95dda66326412c1f434878ed0ca30d311e40","state":"sha256:70d7f743627196ec4045afe54bb3b0f0f1de2e75697a543075e4ff48517490c3"} +{"inputs":{"criticism":true,"elapsed_hours":0,"praise":true},"n":9,"prev":"sha256:bc04904c165cfe76b8c59f226c01e8357b32bbd34783d4d450b19f0640b828fc","stance":"sha256:944c0a26385c27a5ccf35b0e21459b1cc5f5f60f3320a5fbaad22aa50552930a","state":"sha256:7e35e79810e249b30af48a8c6457028a0455a8ec25d3fc84a45e3261f7d08d43"} +{"inputs":{"elapsed_hours":1,"praise":true},"n":10,"prev":"sha256:1f6de80d7bdb4fdd26baec686f120d9916c346951e8c796eb99440466e603768","stance":"sha256:00e1ac558fc6fbf6c04fab4bf406a4dd4da3f1ca9070471a8044a23d4a6dfc1b","state":"sha256:df0cf433b0c57044e3d9edfa1e9f521d1f7d6fa49dd71e7d3df37899420bbb76"} +{"inputs":{"criticism":true,"elapsed_hours":2,"praise":true},"n":11,"prev":"sha256:6335744d8462b3905ced3cc0981affc24e56ce1998764b018aa35893067b6516","stance":"sha256:ef62f91b701fc242b26b9992780d97b4c28084d01754b90eef3bef7ae0acef39","state":"sha256:3579d66c48d8cf50df61c9ab1bd4fffc0002e7225e9db3e3ae08f35ce73c91cd"} +{"inputs":{"elapsed_hours":0,"praise":true},"n":12,"prev":"sha256:25aa44fb4fe28fe86b62641afeb4157882a5554a3db23e7e1fe231481d06f79e","stance":"sha256:01cb4eca2af8d84540275cbea13364903342fe663755a950d4f341a186243786","state":"sha256:ed8b0c8dd993f2541cdfe986ee16b7f304af5dd28f818a808f0ebf54937a9023"} +{"inputs":{"elapsed_hours":0},"n":13,"prev":"sha256:cb5a0222b37e05c37bfe2b1414d4916dc0d28b2c6bbcc9c0a634a83e7995260d","stance":"sha256:106891ba89bdb843de3ac3990f3fe2d9cb4fdbf430270a13b6b44589aeb89abe","state":"sha256:e304fc76b0218fb96bfdd691e34a224acf47ee100527b325d160c266b8c651af"} +{"inputs":{"criticism":true,"elapsed_hours":1},"n":14,"prev":"sha256:4fa4c61ee945583a8f7e469ada36992374a36c665e35ce4db5ca0c6abd08ecf5","stance":"sha256:738deedd442f853a783f9e9d8622ef230c38e665f878114cbdaf1c0c47d3fc4a","state":"sha256:c21e698198fa9034915279a26974ab54faabfa11c6b5dde1c8e94a1a8e81e699"} +{"inputs":{"elapsed_hours":1},"n":15,"prev":"sha256:1689bbc90fd8f3bc326167de45536deb549351c7e6a22e8152b7036103c72db7","stance":"sha256:74ba46799b86f3174f9aa957fbb3f9016bb31175805c4ad13429f7122b28e5c8","state":"sha256:dba20f77fc79cc516040614baf77d9be139d316e0a71c22590963385a39d5fbe"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":16,"prev":"sha256:9238433fe1e0a0eff7592a1e5d15b45c6ab0e7176abdfc3c2462991c5ba2ffd4","stance":"sha256:b961d8cc9db2e80c4f264be1789c5a5fc39035af6bf68e6ab57bea2bb84506d5","state":"sha256:be90cd8467f85d215724231e1df674f74c12a57c47995fcb66c18d900311cdef"} +{"inputs":{"criticism":true,"elapsed_hours":4},"n":17,"prev":"sha256:6b75ed2e5ea4ac0c2c6d22eba956c12aa3c6d1d1536296676c33ba1d6235669b","stance":"sha256:89fe74fb1b8a54c0bdd346313fd0600f43036611a23fa815b8da96afae00a8a9","state":"sha256:fabd63a75c5564c24e68b015c5ffb8428de219888226d72f5c5f6534eb28e87f"} diff --git a/examples/persona/demo/thermal.yaml b/examples/persona/demo/thermal.yaml new file mode 100644 index 0000000..503f652 --- /dev/null +++ b/examples/persona/demo/thermal.yaml @@ -0,0 +1,71 @@ +# A fictional persona for the RFC 0011 demo: register.yaml on the default tiers (the thermal face). probbit persona format 1. +# Identical to register.yaml except that the engine block is absent: engine.amplify and engine.schedule need engine.tier, so +# removing the tier removes all three. Every engine since 0.13.0 runs this copy. See README.md. +probbit_persona: 1 +identity: + name: demo-tutor + version: 0.1.0 + seed: 1 + summary: a tutor reduced to its humour component, for the amplitude register demo + +traits: + - id: warmth + levels: [cool, neutral, warm] + prior: [0.05, 0.3, 0.65] + spread: 0.5 + say: [plain, "", warm and encouraging] + - id: humour + levels: [none, light, playful] + prior: [0.15, 0.45, 0.4] + spread: 0.6 + say: [no jokes, a light joke is welcome, playful] + - id: formality + levels: [casual, neutral, formal] + prior: [0.6, 0.35, 0.05] + spread: 0.3 + say: [casual, "", formal] + - id: emoji + levels: [none, sparse, rich] + prior: [0.2, 0.5, 0.3] + spread: 0.5 + say: [no emoji, one emoji at most, emoji welcome] + +moods: + - id: valence + levels: [down, even, up] + prior: [0.1, 0.5, 0.4] + spread: 0.4 + inertia: 0.5 + half_life_hours: 12 + +couplings: + - {vars: [valence, humour], align: 1.0} + - {vars: [valence, emoji], align: 0.6} + - {vars: [warmth, formality], align: -0.6} + - {vars: [humour, formality], align: -0.6} + - {vars: [humour, emoji], align: 0.4} + +inputs: + - id: praise + kind: flag + say: praise + reactivity_spread: 0.4 + effects: {valence: 1.2, humour: 0.5, emoji: 0.5} + - id: criticism + kind: flag + say: criticism + effects: {valence: -0.6, humour: -0.4} + +habits: + - id: flourish_budget + rules: + linear: [{terms: [[humour, playful, 2], [humour, light, 1], [emoji, rich, 2], [emoji, sparse, 1]], limit: 3}] + say: one flourish at a time + - id: no_formal_emoji + rules: + tables: [{vars: [formality, emoji], forbid: [[formal, rich], [formal, sparse]]}] + say: no emoji when formal + +line: + max_tokens: 40 + order: [humour, warmth, emoji, formality] diff --git a/probbit-decide/Cargo.toml b/probbit-decide/Cargo.toml index 9da17e3..3638747 100644 --- a/probbit-decide/Cargo.toml +++ b/probbit-decide/Cargo.toml @@ -17,6 +17,8 @@ std = ["probbit-core/std", "probbit-ir/std"] nostd = ["dep:probbit-nostd", "probbit-core/nostd", "probbit-ir/nostd"] [dependencies] +# the amplitude register's one math path (cos, sin, sqrt, asin, acos, atan2) on the std and the no_std build alike (pure Rust) +libm = "0.2" probbit-nostd = { path = "../probbit-nostd", version = "0.14.0-dev", optional = true } probbit-core = { path = "../probbit-core", default-features = false, version = "0.14.0-dev" } probbit-ir = { path = "../probbit-ir", default-features = false, version = "0.14.0-dev" } diff --git a/probbit-decide/src/amp.rs b/probbit-decide/src/amp.rs new file mode 100644 index 0000000..6b29ac0 --- /dev/null +++ b/probbit-decide/src/amp.rs @@ -0,0 +1,506 @@ +//! RFC 0011 (research, rounds 1-2): an amplitude register. A register of q qubits simulated exactly on a CPU as a state vector of +//! 2^q complex amplitudes (f64 pairs, on the heap: 16 * 2^q bytes): unitary gates with phase, interference between basis states, +//! exact marginals (no sampling), measurement by the Born rule with the engine's deterministic RNG, and a digest of the state at +//! 6 decimals (the rule for every emitted float). A simulation of a small quantum system and nothing else: no quantum hardware, +//! no speedup claim, no scaling past what 2^q amplitudes allow. +//! +//! Basis index x = sum of bit_i << i: qubit i is bit i. Round 2: a variable with d allowed values takes ceil(log2 d) qubits +//! (`Encoding`; d = 3: codes 00, 01, 10, code 11 unused), the start is the uniform superposition over the valid codes, the +//! mixer is block-diagonal per variable (`MIXER`, the identity on unused codes) and the cost is one general diagonal phase +//! layer exp(-i g E) with E = -log w per basis state, so pair tables between multi-level variables (3- and 4-body terms in +//! the qubits) need no decomposition. The unused codes keep amplitude 0 (checked after every layer). +#[allow(unused_imports)] +use std::prelude::rust_2021::*; +use probbit_core::Philox4x32; + +/// Round 7: the register's one math path. Every function past + - * / that the register evaluates (cos, sin, sqrt, asin, acos, +/// atan2, floor) is the `libm` crate's on every build, std and no_std alike: pure Rust, the same code and the same evaluation +/// order on every target, no platform libm and no fused multiply-add, so a strand recorded on one build replays bit for bit on +/// the other whatever the schedule's length (round 5: the platform's cos/sin against libm's flipped a 6-decimal odd over the +/// long sweep's layers). +mod m { + #[inline] pub fn cos(x: f64) -> f64 { libm::cos(x) } + #[inline] pub fn sin(x: f64) -> f64 { libm::sin(x) } + #[inline] pub fn sqrt(x: f64) -> f64 { libm::sqrt(x) } + #[inline] pub fn asin(x: f64) -> f64 { libm::asin(x) } + #[inline] pub fn acos(x: f64) -> f64 { libm::acos(x) } + #[inline] pub fn atan2(y: f64, x: f64) -> f64 { libm::atan2(y, x) } + #[inline] pub fn floor(x: f64) -> f64 { libm::floor(x) } +} + +/// The largest register on the host (16 qubits = 1 MiB of amplitudes) +pub const Q_MAX_HOST: usize = 16; +/// Bytes per amplitude: two f64 +pub const AMP_BYTES: usize = 16; + +/// A complex number (f64 parts) +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct C { pub re: f64, pub im: f64 } +impl C { + pub const ZERO: C = C { re: 0.0, im: 0.0 }; + pub const ONE: C = C { re: 1.0, im: 0.0 }; + #[inline] pub fn new(re: f64, im: f64) -> C { C { re, im } } + #[inline] pub fn mul(self, o: C) -> C { C { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re } } + #[inline] pub fn add(self, o: C) -> C { C { re: self.re + o.re, im: self.im + o.im } } + #[inline] pub fn conj(self) -> C { C { re: self.re, im: -self.im } } + #[inline] pub fn norm2(self) -> f64 { self.re * self.re + self.im * self.im } + /// e^{i t} + #[inline] pub fn cis(t: f64) -> C { C { re: m::cos(t), im: m::sin(t) } } +} + +/// The gate set: enough for Ising-type programs and QAOA layers. Angles in radians; RX(t) = exp(-i t X / 2), +/// RZ(t) = exp(-i t Z / 2), RZZ(t) = exp(-i t Z⊗Z / 2). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Gate { H(usize), X(usize), Z(usize), Rx(usize, f64), Rz(usize, f64), Cnot(usize, usize), Cz(usize, usize), Rzz(usize, usize, f64) } +impl Gate { + /// The gate as one canonical text line (angles at 6 decimals): what `circuit_text` joins and the circuit digest hashes + pub fn text(&self) -> String { + match *self { + Gate::H(a) => format!("h {a}"), Gate::X(a) => format!("x {a}"), Gate::Z(a) => format!("z {a}"), + Gate::Rx(a, t) => format!("rx {a} {}", f6(t)), Gate::Rz(a, t) => format!("rz {a} {}", f6(t)), + Gate::Cnot(a, b) => format!("cnot {a} {b}"), Gate::Cz(a, b) => format!("cz {a} {b}"), Gate::Rzz(a, b, t) => format!("rzz {a} {b} {}", f6(t)), + } + } + /// The gate's matrix on its own qubits (2x2, or 4x4 on (a, b) with basis index bit_a + 2 bit_b), row-major + pub fn matrix(&self) -> Vec { + let (r, z) = (core::f64::consts::FRAC_1_SQRT_2, C::ZERO); + let half = |t: f64| (C::new(m::cos(t / 2.0), 0.0), m::sin(t / 2.0)); + match *self { + Gate::H(_) => vec![C::new(r, 0.0), C::new(r, 0.0), C::new(r, 0.0), C::new(-r, 0.0)], + Gate::X(_) => vec![z, C::ONE, C::ONE, z], + Gate::Z(_) => vec![C::ONE, z, z, C::new(-1.0, 0.0)], + Gate::Rx(_, t) => { let (c, s) = half(t); vec![c, C::new(0.0, -s), C::new(0.0, -s), c] } + Gate::Rz(_, t) => vec![C::cis(-t / 2.0), z, z, C::cis(t / 2.0)], + Gate::Cnot(..) => { let mut m = vec![z; 16]; for (i, j) in [(0, 0), (1, 3), (2, 2), (3, 1)] { m[i * 4 + j] = C::ONE; } m } + Gate::Cz(..) => { let mut m = vec![z; 16]; for i in 0..4 { m[i * 5] = if i == 3 { C::new(-1.0, 0.0) } else { C::ONE }; } m } + Gate::Rzz(_, _, t) => { let mut m = vec![z; 16]; for i in 0..4 { let same = (i & 1) == (i >> 1); m[i * 5] = C::cis(if same { -t / 2.0 } else { t / 2.0 }); } m } + } + } +} + +/// max |U†U - I| over the entries of a gate's matrix +pub fn unitarity_error(g: &Gate) -> f64 { + let m = g.matrix(); let d = if m.len() == 4 { 2 } else { 4 }; let mut e: f64 = 0.0; + for i in 0..d { for j in 0..d { + let mut s = C::ZERO; for k in 0..d { s = s.add(m[k * d + i].conj().mul(m[k * d + j])); } + let want = if i == j { 1.0 } else { 0.0 }; e = e.max((s.re - want).abs()).max(s.im.abs()); } } + e +} + +/// A number at 6 decimals as the canonical text writes it (`-0` written `0`) +fn f6(x: f64) -> String { let s = format!("{x:.6}"); if s.trim_start_matches('-').bytes().all(|b| b == b'0' || b == b'.') { "0.000000".into() } else { s } } + +/// One step of a circuit: a gate, a diagonal phase layer exp(-i g E) (`Register::phase`), or a mixer layer on every variable +/// (`Register::mix`) +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Op { Gate(Gate), Phase(f64), Mix(f64), Oracle(f64), Diffuse(f64) } +impl Op { + /// One canonical text line (angles at 6 decimals) + pub fn text(&self) -> String { match self { Op::Gate(g) => g.text(), Op::Phase(g) => format!("phase {}", f6(*g)), Op::Mix(t) => format!("mix {}", f6(*t)), Op::Oracle(p) => format!("oracle {}", f6(*p)), Op::Diffuse(p) => format!("diffuse {}", f6(*p)) } } +} + +/// The mixer every round-2 layer uses, by name (the receipt's `engine.mixer`) +pub const MIXER: &str = "complete-graph"; + +/// Round 2: how variables with d allowed values are packed into qubits. Variable i (d_i >= 1 values) takes b_i = ceil(log2 d_i) +/// qubits, packed in variable order (its code is bits o_i..o_i + b_i of the basis index); code c < d_i is its c-th value, codes +/// c >= d_i are unused (d = 3: code 11) and keep amplitude 0 for the whole evolution. +#[derive(Clone, Debug, PartialEq)] +pub struct Encoding { pub levels: Vec } +impl Encoding { + /// Qubits for d values: ceil(log2 d) (0 for d = 1) + pub fn bits(d: usize) -> usize { assert!(d >= 1, "a variable needs at least one value"); (usize::BITS - (d - 1).leading_zeros()) as usize } + /// Every variable two-valued (round 1's programs) + pub fn two_valued(q: usize) -> Encoding { Encoding { levels: vec![2; q] } } + /// The encoded qubits, sum of ceil(log2 d_i) + pub fn qubits(&self) -> usize { self.levels.iter().map(|&d| Encoding::bits(d)).sum() } + /// Per variable its first qubit + pub fn offsets(&self) -> Vec { let mut o = 0; self.levels.iter().map(|&d| { let r = o; o += Encoding::bits(d); r }).collect() } + /// Variable i's code in basis index x + pub fn code(&self, x: usize, i: usize) -> usize { let o = self.offsets()[i]; (x >> o) & ((1usize << Encoding::bits(self.levels[i])) - 1) } + /// The codes of every variable in x, or None when one is unused + pub fn decode(&self, x: usize) -> Option> { + let mut o = 0; let mut v = Vec::with_capacity(self.levels.len()); + for &d in &self.levels { let b = Encoding::bits(d); let c = (x >> o) & ((1usize << b) - 1); if c >= d { return None; } v.push(c); o += b; } + Some(v) + } + /// The canonical text the encoding digest hashes: one line per variable, `var levels qubits ..` + pub fn text(&self) -> String { + let mut s = String::new(); for (i, (&d, o)) in self.levels.iter().zip(self.offsets()).enumerate() { s.push_str(&format!("var {i} levels {d} qubits {o}..{}\n", o + Encoding::bits(d))); } s + } +} + +/// The complete-graph mixer on d levels as a d x d matrix (row-major): exp(i t A) with A = J - I the adjacency of the complete +/// graph K_d. Closed form (J^2 = d J): e^{-it} (I + (e^{itd} - 1)/d J). d = 2 is exp(i t X) = RX(-2t); d = 1 is the identity. +pub fn mixer_matrix(d: usize, t: f64) -> Vec { + let (ph, c) = mixer_coef(d, t); (0..d * d).map(|k| { let off = c.mul(ph); if k / d == k % d { ph.add(off) } else { off } }).collect() +} +/// (e^{-it}, (e^{itd} - 1)/d) +fn mixer_coef(d: usize, t: f64) -> (C, C) { let e = C::cis(t * d as f64); (C::cis(-t), C::new((e.re - 1.0) / d as f64, e.im / d as f64)) } +/// max |U^dagger U - I| of the mixer on the whole 2^b block of a d-level variable (the mixer on the valid codes, the identity on +/// the unused ones) +pub fn mixer_unitarity_error(d: usize, t: f64) -> f64 { + let n = 1usize << Encoding::bits(d); let m = mixer_matrix(d, t); + let u = |i: usize, j: usize| if i < d && j < d { m[i * d + j] } else if i == j { C::ONE } else { C::ZERO }; + let mut e: f64 = 0.0; + for i in 0..n { for j in 0..n { let mut s = C::ZERO; for k in 0..n { s = s.add(u(k, i).conj().mul(u(k, j))); } + e = e.max((s.re - if i == j { 1.0 } else { 0.0 }).abs()).max(s.im.abs()); } } + e +} + +/// q qubits as 2^q complex amplitudes, and the ops applied (the circuit) +#[derive(Clone, Debug)] +pub struct Register { q: usize, amp: Vec, ops: Vec } +impl Register { + /// |0...0> on q qubits (q <= Q_MAX_HOST); the amplitudes take exactly 16 * 2^q bytes of heap + pub fn new(q: usize) -> Register { + assert!(q <= Q_MAX_HOST, "an amplitude register has at most {Q_MAX_HOST} qubits on the host"); + let mut amp = vec![C::ZERO; 1usize << q]; amp[0] = C::ONE; + let r = Register { q, amp, ops: vec![] }; + assert_eq!(r.bytes(), AMP_BYTES << q); r + } + /// Round 2: the uniform superposition over the valid codes of every variable, 1/sqrt(prod d_i) on each valid basis state and + /// 0 on the rest (for an all-two-valued encoding this is H on every qubit) + pub fn uniform(enc: &Encoding) -> Register { + let mut r = Register::new(enc.qubits()); let a = 1.0 / m::sqrt(enc.levels.iter().map(|&d| d as f64).product::()); + for (x, v) in r.amp.iter_mut().enumerate() { *v = if enc.decode(x).is_some() { C::new(a, 0.0) } else { C::ZERO }; } + r + } + pub fn qubits(&self) -> usize { self.q } + /// The amplitudes' bytes: 16 * 2^q + pub fn bytes(&self) -> usize { self.amp.len() * core::mem::size_of::() } + pub fn amplitudes(&self) -> &[C] { &self.amp } + pub fn ops(&self) -> &[Op] { &self.ops } + /// The gates among the ops + pub fn gates(&self) -> Vec { self.ops.iter().filter_map(|o| if let Op::Gate(g) = o { Some(*g) } else { None }).collect() } + /// The circuit as text, one op per line (angles at 6 decimals) + pub fn circuit_text(&self) -> String { let mut s = String::new(); for o in &self.ops { s.push_str(&o.text()); s.push('\n'); } s } + + fn one(&mut self, a: usize, m: [C; 4]) { + let bit = 1usize << a; + for i in 0..self.amp.len() { if i & bit != 0 { continue; } let (x, y) = (self.amp[i], self.amp[i | bit]); + self.amp[i] = m[0].mul(x).add(m[1].mul(y)); self.amp[i | bit] = m[2].mul(x).add(m[3].mul(y)); } + } + /// Apply one gate (logged in the circuit) + pub fn apply(&mut self, g: Gate) { + let q = self.q; let ok = |a: usize| assert!(a < q, "qubit {a} out of range ({q} qubits)"); + match g { + Gate::H(a) | Gate::X(a) | Gate::Z(a) | Gate::Rx(a, _) | Gate::Rz(a, _) => { ok(a); let m = g.matrix(); self.one(a, [m[0], m[1], m[2], m[3]]); } + Gate::Cnot(c, t) => { ok(c); ok(t); assert_ne!(c, t); let (bc, bt) = (1usize << c, 1usize << t); + for i in 0..self.amp.len() { if i & bc != 0 && i & bt == 0 { self.amp.swap(i, i | bt); } } } + Gate::Cz(a, b) => { ok(a); ok(b); assert_ne!(a, b); let m = (1usize << a) | (1usize << b); + for i in 0..self.amp.len() { if i & m == m { self.amp[i] = C::new(-self.amp[i].re, -self.amp[i].im); } } } + Gate::Rzz(a, b, t) => { ok(a); ok(b); assert_ne!(a, b); let (same, diff) = (C::cis(-t / 2.0), C::cis(t / 2.0)); + for i in 0..self.amp.len() { let p = if ((i >> a) & 1) == ((i >> b) & 1) { same } else { diff }; self.amp[i] = p.mul(self.amp[i]); } } + } + self.ops.push(Op::Gate(g)); + } + /// Round 2: the general diagonal phase layer, a_x <- e^{-i g E(x)} a_x for every basis state with a finite E(x) (E = -log w + /// of its plan); E(x) = NaN marks an unused code, left untouched (its amplitude is 0). O(2^q), any order of interaction. + pub fn phase(&mut self, e: &[f64], g: f64) { + assert_eq!(e.len(), self.amp.len(), "one energy per basis state"); + for (a, &ex) in self.amp.iter_mut().zip(e) { if !ex.is_nan() { *a = C::cis(-g * ex).mul(*a); } } + self.ops.push(Op::Phase(g)); + } + /// Round 2: the mixer layer, the complete-graph mixer exp(i t A_d) on the valid codes of every variable (`mixer_matrix`), + /// the identity on its unused codes: block-diagonal, O(d) per block by the closed form. + pub fn mix(&mut self, enc: &Encoding, t: f64) { + assert_eq!(enc.qubits(), self.q, "the encoding's qubits"); + let mut buf: Vec = vec![]; + for (&d, o) in enc.levels.iter().zip(enc.offsets()) { + if d < 2 { continue; } let b = Encoding::bits(d); let mask = ((1usize << b) - 1) << o; let (ph, c) = mixer_coef(d, t); + for x in 0..self.amp.len() { if x & mask != 0 { continue; } + buf.clear(); let mut s = C::ZERO; for k in 0..d { let a = self.amp[x | (k << o)]; buf.push(a); s = s.add(a); } let cs = c.mul(s); + for (k, &a) in buf.iter().enumerate() { self.amp[x | (k << o)] = ph.mul(a.add(cs)); } } + } + self.ops.push(Op::Mix(t)); + } + /// Round 4: the oracle, a_x <- e^{i phi} a_x on the basis states marked good (phi = pi: the sign flip) + pub fn oracle(&mut self, good: &[bool], phi: f64) { + assert_eq!(good.len(), self.amp.len(), "one mark per basis state"); let w = C::cis(phi); + for (a, &g) in self.amp.iter_mut().zip(good) { if g { *a = w.mul(*a); } } + self.ops.push(Op::Oracle(phi)); + } + /// Round 4: the diffusion about a stored state psi, I - (1 - e^{i phi}) |psi> f64 { self.amp.iter().zip(e).filter(|(_, x)| x.is_nan()).map(|(a, _)| a.norm2()).sum() } + /// The Born probabilities |a_x|^2 + pub fn probabilities(&self) -> Vec { self.amp.iter().map(|a| a.norm2()).collect() } + /// Exact marginals P(qubit i = 1), one per qubit (no sampling) + pub fn marginals(&self) -> Vec { + let mut m = vec![0.0; self.q]; + for (x, a) in self.amp.iter().enumerate() { let p = a.norm2(); for (i, mi) in m.iter_mut().enumerate() { if x >> i & 1 == 1 { *mi += p; } } } + m + } + /// Round 2: exact marginals per variable and level, P(variable i has code c), concatenated in variable order (sum d_i entries) + pub fn level_marginals(&self, enc: &Encoding) -> Vec { + let base: Vec = enc.levels.iter().scan(0, |s, &d| { let r = *s; *s += d; Some(r) }).collect(); + let mut m = vec![0.0; enc.levels.iter().sum()]; + for (x, a) in self.amp.iter().enumerate() { if let Some(v) = enc.decode(x) { let p = a.norm2(); for (i, c) in v.into_iter().enumerate() { m[base[i] + c] += p; } } } + m + } + /// One measurement of every qubit by the Born rule: the basis index, drawn with Philox4x32(seed, stream 0) + pub fn measure(&self, seed: u64) -> usize { + let u = Philox4x32::new(seed, 0).f64(); let mut c = 0.0; + for (x, a) in self.amp.iter().enumerate() { c += a.norm2(); if u < c { return x; } } + self.amp.iter().rposition(|a| a.norm2() > 0.0).unwrap_or(0) + } + /// The canonical state: one line per amplitude, `re im` at 6 decimals + pub fn canonical(&self) -> String { let mut s = String::new(); for a in &self.amp { s.push_str(&f6(a.re)); s.push(' '); s.push_str(&f6(a.im)); s.push('\n'); } s } + /// sha256 of the canonical state + pub fn digest(&self) -> [u8; 32] { sha256(self.canonical().as_bytes()) } +} + +/// An Ising program over q two-valued variables: log w(x) = c + sum_i a_i z_i + sum_(i, pub b: Vec<(usize, usize, f64)> } +impl Ising { + /// The program's log weight at basis index x, without the constant + pub fn logw(&self, x: usize) -> f64 { + let z = |i: usize| if x >> i & 1 == 0 { 1.0 } else { -1.0 }; + self.a.iter().enumerate().map(|(i, a)| a * z(i)).sum::() + self.b.iter().map(|&(i, j, b)| b * z(i) * z(j)).sum::() + } + /// E(x) = -log w(x) over the 2^q basis states (the phase layer's input) + pub fn energies(&self) -> Vec { (0..1usize << self.q).map(|x| -self.logw(x)).collect() } +} + +/// A transverse-field sweep: `steps` Trotter steps over total time `time`, s = (k + 1/2) / steps at step k, +/// H(s) = -(1 - s) sum_i A_i + s E (A_i the complete-graph mixer of variable i; E = -log w), started from the uniform +/// superposition over the valid codes +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Schedule { pub steps: usize, pub time: f64 } +impl Schedule { + /// The canonical text the schedule digest hashes + pub fn text(&self) -> String { format!("transverse-field linear steps {} time {}\n", self.steps, f6(self.time)) } + pub fn name(&self) -> String { format!("tf-linear-{}x{}", self.steps, f6(self.time)) } +} +/// The tier's fixed default schedule (rounds 1 and 2; schedules are a later round's study) +pub const DEFAULT_SCHEDULE: Schedule = Schedule { steps: 16, time: 4.0 }; +/// Round 5: the longer sweep (`engine.schedule: tf-linear-64x16`): 4x the default's steps and time, so about 4x its layers +pub const LONG_SCHEDULE: Schedule = Schedule { steps: 64, time: 16.0 }; +/// Round 5: the two layers (gamma, beta) of `engine.schedule: qaoa-p2`, fixed by a grid search on a held-out third of the P0 +/// corpus programs (the largest median separation on the decisions the thermal tier is indifferent on, subject to agreement +/// >= 90 % on the others; docs/rfc/0011-amplitude-register.md, "Schedules and the indifferent set"), then frozen: gamma 0.500000, +/// beta 0.500000, then gamma 1.500000, beta 0.250000 +pub const QAOA_P2: [(f64, f64); 2] = [(0.5, 0.5), (1.5, 0.25)]; +/// Round 5: the names `engine.schedule` accepts (the first is the default) +pub const SCHEDULE_NAMES: [&str; 3] = ["tf-linear-16x4", "tf-linear-64x16", "qaoa-p2"]; +/// Round 5: the evolution the tier runs on each component: a transverse-field sweep or QAOA layers +#[derive(Clone, Debug, PartialEq)] +pub enum Evolution { Sweep(Schedule), Qaoa(Vec<(f64, f64)>) } +impl Evolution { + /// The evolution of a name in `SCHEDULE_NAMES` (None for any other name) + pub fn named(name: &str) -> Option { + match name { "tf-linear-16x4" => Some(Evolution::Sweep(DEFAULT_SCHEDULE)), "tf-linear-64x16" => Some(Evolution::Sweep(LONG_SCHEDULE)), + "qaoa-p2" => Some(Evolution::Qaoa(QAOA_P2.to_vec())), _ => None } + } + /// The register after the evolution from the uniform start + pub fn run(&self, enc: &Encoding, e: &[f64]) -> Register { match self { Evolution::Sweep(s) => sweep(enc, e, s), Evolution::Qaoa(p) => qaoa_on(enc, e, p) } } + /// The canonical text the schedule digest hashes (a sweep's is `Schedule::text`, unchanged) + pub fn text(&self) -> String { + match self { Evolution::Sweep(s) => s.text(), + Evolution::Qaoa(p) => { let mut t = format!("qaoa layers {}\n", p.len()); for &(g, b) in p { t.push_str(&format!("gamma {} beta {}\n", f6(g), f6(b))); } t } } + } + /// The receipt's `schedule_name` (a sweep's is `Schedule::name`, unchanged) + pub fn name(&self) -> String { + match self { Evolution::Sweep(s) => s.name(), Evolution::Qaoa(p) if p[..] == QAOA_P2[..] => "qaoa-p2".into(), Evolution::Qaoa(p) => format!("qaoa-p{}-lab", p.len()) } + } +} + +/// The bar on the Born mass of the unused codes, checked after every layer (it is 0 by construction; never renormalised) +pub const INVALID_MASS_MAX: f64 = 1e-15; +fn checked(r: &Register, e: &[f64]) { let m = r.invalid_mass(e); assert!(m <= INVALID_MASS_MAX, "unused-code mass {m:e} after a layer"); } + +/// Round 2: the sweep `s` on an encoding and its energies (one per basis state, NaN on unused codes): the uniform start over +/// the valid codes, then per step the phase layer (g = dt s) and the mixer layer (t = dt (1 - s)) +pub fn sweep(enc: &Encoding, e: &[f64], s: &Schedule) -> Register { + let mut r = Register::uniform(enc); let dt = s.time / s.steps as f64; + for k in 0..s.steps { let x = (k as f64 + 0.5) / s.steps as f64; r.phase(e, dt * x); checked(&r, e); r.mix(enc, dt * (1.0 - x)); checked(&r, e); } + r +} +/// Round 2: QAOA on an encoding: the uniform start, then per layer (gamma, beta) the phase layer and the mixer layer +pub fn qaoa_on(enc: &Encoding, e: &[f64], params: &[(f64, f64)]) -> Register { + let mut r = Register::uniform(enc); + for &(g, b) in params { r.phase(e, g); checked(&r, e); r.mix(enc, b); checked(&r, e); } + r +} +/// The sweep on an Ising program (every variable two-valued): the same phase and mixer layers as `sweep` +pub fn evolve_ising(p: &Ising, s: &Schedule) -> Register { sweep(&Encoding::two_valued(p.q), &p.energies(), s) } +/// QAOA with p layers on an Ising program: the same layers as `qaoa_on` +pub fn qaoa(p: &Ising, layers: usize, params: &[(f64, f64)]) -> Register { + assert_eq!(params.len(), layers, "one (gamma, beta) per layer"); qaoa_on(&Encoding::two_valued(p.q), &p.energies(), params) +} + +/// Round 4: what `amplify_exact` did: the iterations (standard ones + the final phase-matched one), the final step's two +/// phases (oracle, diffusion; radians), the Born mass on the good states before and after +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Amplified { pub iterations: usize, pub phases: (f64, f64), pub mass_before: f64, pub mass_after: f64 } +/// The bar on the good states' mass after `amplify_exact` (asserted) +pub const AMPLIFIED_MASS_MIN: f64 = 1.0 - 1e-12; +/// Round 4: exact amplitude amplification of the good states (`good(x)` per basis index; an unused code is never good) about +/// the register's current state psi, in place of a projection. With a0 = the good mass of psi and theta = asin(sqrt(a0)), +/// k = floor(pi / (4 theta) - 1/2) standard iterations (oracle phase pi, diffusion phase pi about psi) bring the state to angle +/// (2k + 1) theta <= pi/2 in the plane of |good> (psi's normalised good part) and |bad>; one final iteration with phases +/// (phi, varphi) chosen so that its bad component is 0 lands on |good> exactly (Long's exact search, one matched step). Every +/// step stays in that plane, so the result is e^{i chi} |good>: the good states keep psi's relative amplitudes, the Born +/// probabilities are psi's restricted to the good states and divided by a0 -- the projection's numbers, by a unitary +/// evolution. a0 = 1: nothing to do (0 iterations); a0 = 0: None (nothing to amplify). Round 5: a landing whose mass after is +/// below 1 - 1e-12 (rounding drift over many iterations at a tiny a0, measured with the long sweep) is also None, so the tier +/// declines to the default tiers instead of panicking; the register is then left mid-rotation and must not be read. +/// Round 7: psi is renormalised once before the standard iterations, when there are any (its squared norm summed with +/// compensation; a NaN mass declines like a miss). The sweep's layers +/// leave it 1 + eps, a larger eps the more layers; the diffusion about such a psi is not unitary (|D v|^2 = |v|^2 + 4 eps ||^2), +/// so the error grows with every iteration, linearly in k: that, not the rotation, was the ~1e-12 shortfall of the long sweep's +/// 165 corpus programs (k in the hundreds at a tiny a0), and the same eps of the other sign is round 4's drift above 1. +pub fn amplify_exact(r: &mut Register, good: impl Fn(usize) -> bool) -> Option { + let g: Vec = (0..r.amp.len()).map(&good).collect(); let mass = |r: &Register| r.amp.iter().zip(&g).filter(|(_, &x)| x).map(|(a, _)| a.norm2()).sum::(); + let a0 = mass(r); if !(a0 > 0.0) { return None; } + if a0 >= 1.0 - 1e-15 { return Some(Amplified { iterations: 0, phases: (0.0, 0.0), mass_before: a0, mass_after: a0 }); } + let pi = core::f64::consts::PI; let k_of = |a0: f64| m::floor(pi / (4.0 * m::asin(m::sqrt(a0))) - 0.5).max(0.0) as usize; + // renormalise only when there are standard iterations to drift over (k >= 1, so a0 <= 1/4): with none, the one matched step + // is round 4's, unchanged, and a mass near 1 cannot round above 1. The receipt's mass before stays the swept state's (a0); + // the rotation uses the renormalised state's. + let mass_before = a0; + let a0 = if k_of(a0) == 0 { a0 } else { let u = 1.0 / m::sqrt(compensated_norm2(&r.amp)); for a in r.amp.iter_mut() { *a = C::new(a.re * u, a.im * u); } mass(r) }; + let psi = r.amp.clone(); + let k = k_of(a0); + for _ in 0..k { r.oracle(&g, pi); r.diffuse(&psi, pi); } + // the final step: the state is +-(al |good> + be |bad>), al, be >= 0; with s = sin theta, c = cos theta, S_psi(varphi) + // S_f(phi) leaves be - (1 - e^{i varphi}) c (al s e^{i phi} + be c) on |bad>: it vanishes for + // cos phi = -(be / al) cos(2 theta) / sin(2 theta) (|cos phi| < 1 for this k) and 1 - e^{i varphi} = be / (c (al s e^{i phi} + be c)) + let a = mass(r).min(1.0); let (al, be) = (m::sqrt(a), m::sqrt((1.0 - a).max(0.0))); let (s, c) = (m::sqrt(a0), m::sqrt(1.0 - a0)); + let cphi = (-(be / al) * (1.0 - 2.0 * a0) / (2.0 * s * c)).clamp(-1.0, 1.0); let phi = m::acos(cphi); + let w = C::new(al * s * m::cos(phi) + be * c, al * s * m::sin(phi)); let den = w.norm2() * c; // z = be / (c w) = be conj(w) / (c |w|^2) + let z = C::new(be * w.re / den, -be * w.im / den); let vphi = m::atan2(-z.im, 1.0 - z.re); + r.oracle(&g, phi); r.diffuse(&psi, vphi); + let m = mass(r); if !(m >= AMPLIFIED_MASS_MIN) { return None; } // a NaN declines too + Some(Amplified { iterations: k + 1, phases: (phi, vphi), mass_before, mass_after: m }) +} + +/// Round 7: sum |a_x|^2 with Neumaier's compensation (the plain running sum of 2^16 terms can be off by ~1e-14, the size of the +/// drift the renormalisation removes) +fn compensated_norm2(amp: &[C]) -> f64 { + let (mut s, mut c) = (0.0f64, 0.0f64); + for a in amp { let x = a.norm2(); let t = s + x; c += if s >= x { (s - t) + x } else { (x - t) + s }; s = t; } // s, x >= 0 + s + c +} + +/// sha256 (FIPS 180-4), one shot +pub fn sha256(data: &[u8]) -> [u8; 32] { + const K: [u32; 64] = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, + 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]; + let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + let mut msg = data.to_vec(); msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&((data.len() as u64) * 8).to_be_bytes()); + for blk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { w[i] = u32::from_be_bytes([blk[4 * i], blk[4 * i + 1], blk[4 * i + 2], blk[4 * i + 3]]); } + for i in 16..64 { let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1); } + let mut v = h; + for i in 0..64 { let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25); let ch = (v[4] & v[5]) ^ (!v[4] & v[6]); + let t1 = v[7].wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]); + let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22); let mj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]); let t2 = s0.wrapping_add(mj); + v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]]; } + for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); } + } + let mut out = [0u8; 32]; for i in 0..8 { out[4 * i..4 * i + 4].copy_from_slice(&h[i].to_be_bytes()); } out +} +/// Lower-case hex +pub fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn every_gate_is_unitary_to_1e_12() { + for t in [0.0, 0.3, -1.7, 2.5, 3.14159, 10.0] { + for g in [Gate::H(0), Gate::X(0), Gate::Z(0), Gate::Rx(0, t), Gate::Rz(0, t), Gate::Cnot(0, 1), Gate::Cz(0, 1), Gate::Rzz(0, 1, t)] { assert!(unitarity_error(&g) < 1e-12, "{g:?}"); } + } + } + #[test] + fn memory_is_16_times_2_to_the_q() { for q in [0, 1, 8, 12] { assert_eq!(Register::new(q).bytes(), 16 << q); } } + #[test] + fn bell_state_interference_and_norm() { + let mut r = Register::new(2); r.apply(Gate::H(0)); r.apply(Gate::Cnot(0, 1)); + let p = r.probabilities(); assert!((p[0] - 0.5).abs() < 1e-15 && (p[3] - 0.5).abs() < 1e-15 && p[1] < 1e-30 && p[2] < 1e-30); + // H Z H = X; two H in a row cancel (destructive interference on |1>) + let mut r = Register::new(1); r.apply(Gate::H(0)); r.apply(Gate::H(0)); assert!(r.probabilities()[1] < 1e-30); + let mut r = Register::new(1); r.apply(Gate::H(0)); r.apply(Gate::Z(0)); r.apply(Gate::H(0)); assert!((r.probabilities()[1] - 1.0).abs() < 1e-15); + let p = Ising { q: 3, a: vec![0.4, -0.2, 0.1], b: vec![(0, 1, 0.6), (1, 2, -0.3)] }; + let r = evolve_ising(&p, &DEFAULT_SCHEDULE); assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + let r = qaoa(&p, 2, &[(0.4, 0.7), (0.2, 0.3)]); assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + } + #[test] + fn three_level_codes_stay_empty_and_the_mixer_is_unitary() { + for d in 1..=8 { for t in [0.0, 0.3, -1.7, 2.5, 10.0] { assert!(mixer_unitarity_error(d, t) < 1e-12, "d {d} t {t}"); } } + assert_eq!((Encoding::bits(1), Encoding::bits(2), Encoding::bits(3), Encoding::bits(4), Encoding::bits(5)), (0, 1, 2, 2, 3)); + // d = 2: the complete-graph mixer is RX(-2t) exactly + let m = mixer_matrix(2, 0.7); let g = Gate::Rx(0, -1.4).matrix(); for k in 0..4 { assert!((m[k].re - g[k].re).abs() < 1e-15 && (m[k].im - g[k].im).abs() < 1e-15); } + // a three-level variable, a two-valued flag and a three-level variable with a pair table favouring the diagonal + let enc = Encoding { levels: vec![3, 2, 3] }; assert_eq!(enc.qubits(), 5); + let lw = |v: &[usize]| [0.4, -0.2, 0.9][v[0]] + if v[1] == 1 { 0.3 } else { 0.0 } + if v[0] == v[2] { 1.1 } else { 0.0 } + if v[1] == 1 && v[2] == 2 { -0.5 } else { 0.0 }; + let e: Vec = (0..32).map(|x| enc.decode(x).map_or(f64::NAN, |v| -lw(&v))).collect(); + let r = sweep(&enc, &e, &DEFAULT_SCHEDULE); assert!(r.invalid_mass(&e) == 0.0); + assert!((r.probabilities().iter().sum::() - 1.0).abs() < 1e-12); + let lm = r.level_marginals(&enc); assert_eq!(lm.len(), 8); for (o, d) in [(0, 3), (3, 2), (5, 3)] { assert!((lm[o..o + d].iter().sum::() - 1.0).abs() < 1e-12); } + let r = qaoa_on(&enc, &e, &[(0.4, 0.7), (0.9, 0.2)]); assert!(r.invalid_mass(&e) == 0.0); + // the uniform start over the valid codes: 18 of 32 basis states + assert_eq!(Register::uniform(&enc).probabilities().iter().filter(|&&p| p > 0.0).count(), 18); + } + #[test] + fn exact_amplification_lands_on_the_projection() { + // a three-level, a two-valued and a three-level variable; good = a rule over the plans; masses from 0.5 down to ~1e-3 + let enc = Encoding { levels: vec![3, 2, 3] }; + let lw = |v: &[usize]| [0.4, -0.2, 0.9][v[0]] + if v[1] == 1 { 0.3 } else { 0.0 } + if v[0] == v[2] { 1.1 } else { 0.0 } + if v[1] == 1 && v[2] == 2 { -0.5 } else { 0.0 }; + let e: Vec = (0..32).map(|x| enc.decode(x).map_or(f64::NAN, |v| -lw(&v))).collect(); + for rule in 0..18usize { + let good = |x: usize| enc.decode(x).is_some_and(|v| (v[0] * 6 + v[1] * 3 + v[2] + rule) % 18 < 1 + rule % 9); + let sw = sweep(&enc, &e, &DEFAULT_SCHEDULE); let p = sw.probabilities(); let a0: f64 = (0..32).filter(|&x| good(x)).map(|x| p[x]).sum(); + let mut r = sw.clone(); let am = amplify_exact(&mut r, good).unwrap(); assert!(am.mass_after >= AMPLIFIED_MASS_MIN && (am.mass_before - a0).abs() < 1e-15); + let q = r.probabilities(); for x in 0..32 { let want = if good(x) { p[x] / a0 } else { 0.0 }; assert!((q[x] - want).abs() < 1e-12, "rule {rule} x {x}: {} vs {want}", q[x]); } + assert_eq!(r.invalid_mass(&e), 0.0); + } + let mut r = Register::uniform(&enc); assert!(amplify_exact(&mut r, |_| false).is_none()); + let mut r = Register::uniform(&enc); assert_eq!(amplify_exact(&mut r, |x| enc.decode(x).is_some()).unwrap().iterations, 0); + } + /// Round 5: the named evolutions: the default name is `sweep` with the default schedule (the same register, text and name), + /// the long sweep has 4x the layers, qaoa-p2 two layers with the frozen angles; an unknown name is None + #[test] + fn named_schedules_run_what_they_name() { + let enc = Encoding { levels: vec![3, 2] }; let e: Vec = (0..8).map(|x| enc.decode(x).map_or(f64::NAN, |v| -[0.4, -0.2, 0.9][v[0]] - 0.3 * v[1] as f64)).collect(); + let d = Evolution::named(SCHEDULE_NAMES[0]).unwrap(); assert_eq!(d, Evolution::Sweep(DEFAULT_SCHEDULE)); + assert_eq!(d.run(&enc, &e).amplitudes(), sweep(&enc, &e, &DEFAULT_SCHEDULE).amplitudes()); assert_eq!((d.text(), d.name()), (DEFAULT_SCHEDULE.text(), DEFAULT_SCHEDULE.name())); + let l = Evolution::named(SCHEDULE_NAMES[1]).unwrap(); assert_eq!(l.run(&enc, &e).circuit_text().lines().count(), 4 * d.run(&enc, &e).circuit_text().lines().count()); + let q = Evolution::named(SCHEDULE_NAMES[2]).unwrap(); assert_eq!(q.name(), "qaoa-p2"); assert_eq!(q.run(&enc, &e).amplitudes(), qaoa_on(&enc, &e, &QAOA_P2).amplitudes()); + assert_eq!(q.text(), "qaoa layers 2\ngamma 0.500000 beta 0.500000\ngamma 1.500000 beta 0.250000\n"); assert!(Evolution::named("tf-linear-8x2").is_none()); + } + /// Round 7: a psi whose squared norm is off by 1e-12 (a long sweep leaves ~1e-14) and one good state of 2^14, so about a + /// hundred iterations: without the renormalisation the landing drifts by ~2 k eps (~2e-10); with it the mass stays within + /// 1e-12 of 1 and the good state's probability is 1 + #[test] + fn a_landing_after_a_hundred_iterations_keeps_its_mass() { + let enc = Encoding::two_valued(14); let mut r = Register::uniform(&enc); let f = (1.0 + 1e-12f64).sqrt(); + for a in r.amp.iter_mut() { *a = C::new(a.re * f, a.im * f); } + let am = amplify_exact(&mut r, |x| x == 5).unwrap(); assert!(am.iterations > 90, "{}", am.iterations); + assert!((am.mass_after - 1.0).abs() <= 1e-12, "mass after {:e}", am.mass_after - 1.0); assert!((r.probabilities()[5] - 1.0).abs() <= 1e-12); + assert!((compensated_norm2(r.amplitudes()) - 1.0).abs() <= 1e-12); + } + #[test] + fn measure_and_digest_are_deterministic() { + let p = Ising { q: 4, a: vec![0.5, 0.1, -0.3, 0.2], b: vec![(0, 3, 0.4)] }; + let (r1, r2) = (evolve_ising(&p, &DEFAULT_SCHEDULE), evolve_ising(&p, &DEFAULT_SCHEDULE)); + assert_eq!(r1.digest(), r2.digest()); assert_eq!(r1.measure(7), r2.measure(7)); assert_eq!(r1.circuit_text(), r2.circuit_text()); + assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + assert_eq!(f6(-0.0000001), "0.000000"); + } +} diff --git a/probbit-decide/src/lib.rs b/probbit-decide/src/lib.rs index c490c7d..add429c 100644 --- a/probbit-decide/src/lib.rs +++ b/probbit-decide/src/lib.rs @@ -21,6 +21,7 @@ use probbit_core::Philox4x32; use std::collections::HashMap; pub mod oracle; pub mod ir; +pub mod amp; /// Tasks up to which `Problem::logw` uses the pairwise loop. R19.8 re-measured (`examples/logw_threshold.rs`, 6 workers, groups /// of 4 / 8, median of 7 alternating rounds, load ~8.6): linear / pairwise ns per call = 1.52 / 1.25 at 96 tasks, 0.96 / 0.92 at diff --git a/probbit-nostd/src/lib.rs b/probbit-nostd/src/lib.rs index 92e9ddc..8df3cd7 100644 --- a/probbit-nostd/src/lib.rs +++ b/probbit-nostd/src/lib.rs @@ -127,6 +127,7 @@ pub trait FloatExt: Sized { fn exp(self) -> Self; fn exp_m1(self) -> Self; fn ln(self) -> Self; fn ln_1p(self) -> Self; fn log2(self) -> Self; fn log10(self) -> Self; fn sqrt(self) -> Self; fn powf(self, e: Self) -> Self; fn powi(self, n: i32) -> Self; fn tanh(self) -> Self; fn cos(self) -> Self; fn sin(self) -> Self; fn floor(self) -> Self; fn ceil(self) -> Self; fn round(self) -> Self; fn trunc(self) -> Self; fn fract(self) -> Self; + fn asin(self) -> Self; fn acos(self) -> Self; fn atan2(self, x: Self) -> Self; } /// `powi` as compiler-rt's `__powidf2` computes it (what `std`'s `f64::powi` lowers to), not `pow`: same bits as the host. fn powi64(mut a: f64, mut b: i32) -> f64 { @@ -141,6 +142,7 @@ impl FloatExt for f64 { fn tanh(self) -> f64 { libm::tanh(self) } fn cos(self) -> f64 { libm::cos(self) } fn sin(self) -> f64 { libm::sin(self) } fn floor(self) -> f64 { libm::floor(self) } fn ceil(self) -> f64 { libm::ceil(self) } fn round(self) -> f64 { libm::round(self) } fn trunc(self) -> f64 { libm::trunc(self) } fn fract(self) -> f64 { self - libm::trunc(self) } + fn asin(self) -> f64 { libm::asin(self) } fn acos(self) -> f64 { libm::acos(self) } fn atan2(self, x: f64) -> f64 { libm::atan2(self, x) } } impl FloatExt for f32 { fn exp(self) -> f32 { libm::expf(self) } fn exp_m1(self) -> f32 { libm::expm1f(self) } fn ln(self) -> f32 { libm::logf(self) } @@ -149,6 +151,7 @@ impl FloatExt for f32 { fn tanh(self) -> f32 { libm::tanhf(self) } fn cos(self) -> f32 { libm::cosf(self) } fn sin(self) -> f32 { libm::sinf(self) } fn floor(self) -> f32 { libm::floorf(self) } fn ceil(self) -> f32 { libm::ceilf(self) } fn round(self) -> f32 { libm::roundf(self) } fn trunc(self) -> f32 { libm::truncf(self) } fn fract(self) -> f32 { self - libm::truncf(self) } + fn asin(self) -> f32 { libm::asinf(self) } fn acos(self) -> f32 { libm::acosf(self) } fn atan2(self, x: f32) -> f32 { libm::atan2f(self, x) } } /// What a `no_std` module adds with one line (`use std::prelude::rust_2021::*;`) to see the names `std`'s prelude gives it. diff --git a/probbit-persona/src/persona.rs b/probbit-persona/src/persona.rs index a5eb19c..20f045b 100644 --- a/probbit-persona/src/persona.rs +++ b/probbit-persona/src/persona.rs @@ -173,7 +173,11 @@ struct Hist { id: String, of: String, streak: bool, window: usize, cap: usize, s struct Habit { id: String, when: Vec<(String, Json)>, then: Vec<(String, Json)>, rules: Vec<(String, Vec)>, say: String, priority: f64 } /// engine settings (docs/persona.md §2.7) #[derive(Clone)] -struct Eng { op: String, sweeps: usize, polish_sweeps: usize, exact_limit: u64, chains: usize, twin: bool, positional: bool, yields: bool } +struct Eng { op: String, sweeps: usize, polish_sweeps: usize, exact_limit: u64, chains: usize, twin: bool, positional: bool, yields: bool, + /// RFC 0011 (research): `tier: amplitude` (off when absent) and its `q_max` (default 12); round 4: `amplify` (default false) + amp: bool, q_max: usize, amplify: bool, + /// round 5: `schedule` (a name in `amp::SCHEDULE_NAMES`; absent = the default sweep, the receipt unchanged) + schedule: Option } /// bounded learning (docs/persona.md §2.8): `from` = the reward flag, then (optional) the correction flag; the traits whose learned /// deltas move; the step and total caps (natural-log weights); `credit` = action-addressed feedback, when declared #[derive(Clone)] @@ -528,8 +532,8 @@ fn build_base(doc: &Json) -> R> { for (i, hb) in list(get(kv, "habits"), "habits")?.iter().enumerate() { habits.push(habit_spec(hb, &ix("habits", i), &inputs, &history, &vars, &steps)?); } need((1..habits.len()).all(|i| habits[..i].iter().all(|h| h.id != habits[i].id)), "habits", "habit ids must be distinct")?; // engine - let mut eng = Eng { op: "decide".into(), sweeps: 2000, polish_sweeps: 200, exact_limit: 2_000_000, chains: 4, twin: true, positional: true, yields: false }; - if let Some(e) = get_t(kv, "engine") { let ekv = keys(e, "engine", &["op", "sweeps", "polish_sweeps", "exact_limit", "chains", "twin", "values", "on_conflict", "comment"], &[])?; + let mut eng = Eng { op: "decide".into(), sweeps: 2000, polish_sweeps: 200, exact_limit: 2_000_000, chains: 4, twin: true, positional: true, yields: false, amp: false, q_max: 12, amplify: false, schedule: None }; + if let Some(e) = get_t(kv, "engine") { let ekv = keys(e, "engine", &["op", "sweeps", "polish_sweeps", "exact_limit", "chains", "twin", "values", "on_conflict", "tier", "q_max", "amplify", "schedule", "comment"], &[])?; for (k, x) in ekv { let p = at("engine", k); match k.as_str() { "op" => eng.op = x.as_str().filter(|o| ["decide", "sample"].contains(o)).ok_or_else(|| perr(&p, "decide | sample"))?.to_string(), @@ -537,11 +541,17 @@ fn build_base(doc: &Json) -> R> { "values" => eng.positional = match x.as_str() { Some("positional") => true, Some("semantic") => false, _ => return Err(perr(&p, "positional | semantic")) }, "on_conflict" => eng.yields = match x.as_str() { Some("fallback") => false, Some("yield") => true, _ => return Err(perr(&p, "fallback | yield")) }, "comment" => {} + "tier" => { need(x.as_str() == Some("amplitude"), &p, "amplitude (the one optional tier; absent = the default tiers)")?; eng.amp = true; } + "q_max" => eng.q_max = num(Some(x), &p, Some(1.0), Some(16.0), true)? as usize, + "amplify" => eng.amplify = if let Json::Bool(b) = x { *b } else { return Err(perr(&p, "true | false")) }, + "schedule" => eng.schedule = Some(x.as_str().filter(|n| SCHEDULE_NAMES.contains(n)).ok_or_else(|| perr(&p, "tf-linear-16x4 | tf-linear-64x16 | qaoa-p2"))?.to_string()), "polish_sweeps" => eng.polish_sweeps = num(Some(x), &p, Some(0.0), Some(1e9), true)? as usize, "sweeps" => eng.sweeps = num(Some(x), &p, Some(1.0), Some(1e9), true)? as usize, "exact_limit" => eng.exact_limit = num(Some(x), &p, Some(1.0), Some(1e9), true)? as u64, _ => eng.chains = num(Some(x), &p, Some(1.0), Some(MAX_CHAINS), true)? as usize, } } } + if eng.amplify && !eng.amp { return Err(perr(&at("engine", "amplify"), "true needs tier: amplitude (amplification acts on the amplitude register)")); } + if eng.schedule.is_some() && !eng.amp { return Err(perr(&at("engine", "schedule"), "needs tier: amplitude (the schedule is the amplitude register's evolution)")); } // line let lkv: &[(String, Json)] = match get_t(kv, "line") { Some(l) => keys(l, "line", &["max_tokens", "order", "prefix", "why_past", "block", "comment"], &[])?, None => &[] }; // RFC 0008 E: `line.block` puts the engine-rendered block in every stance document (a persona with a bound or a coupling has it) @@ -2745,7 +2755,9 @@ struct Entry { level: String, p: f64, odds: Vec<(String, f64)>, released: bool, /// A stance document under construction (docs/persona.md §4) struct Out { status: String, verdict: Json, tier: Json, program: String, stance: Vec<(String, Entry)>, mood: Vec<(String, Entry)>, active: Vec, bound: Vec, violations: usize, conflict: Vec, yielded: Vec, unsure: Vec, escalate: Option, inputs: Vec<(String, Json)>, ignored: Vec, - line: String, why: String, agenda: Option>, state_digest: String, held: Vec, timing: Option, lift: Vec<(String, f64)> } + line: String, why: String, agenda: Option>, state_digest: String, held: Vec, timing: Option, lift: Vec<(String, f64)>, + /// RFC 0011: the amplitude tier's receipt (an object) or why it declined (a string); None without the flag + amp: Option } impl Out { fn to_json(&self, p: &Persona, st: &State) -> Json { let e = |m: &[(String, Entry)]| Json::Obj(m.iter().map(|(k, e)| { let odds = |o: &[(String, f64)]| Json::Obj(o.iter().map(|(l, x)| (l.clone(), Json::Num(*x))).collect()); @@ -2754,7 +2766,8 @@ impl Out { let s = |x: &str| Json::Str(x.to_string()); let mut v = vec![("probbit_persona_turn".to_string(), Json::Num(1.0)), ("persona".into(), Json::Obj(vec![("name".into(), s(&p.name)), ("version".into(), s(&p.version)), ("digest".into(), s(&p.digest))])), ("seed".into(), Json::Num(st.seed as f64)), ("turn".into(), Json::Num(st.turn as f64)), ("status".into(), s(&self.status)), - ("engine".into(), Json::Obj(vec![("verdict".into(), self.verdict.clone()), ("tier".into(), self.tier.clone()), ("program".into(), s(&self.program))])), + ("engine".into(), { let mut e = vec![("verdict".into(), self.verdict.clone()), ("tier".into(), self.tier.clone()), ("program".into(), s(&self.program))]; + match &self.amp { Some(Json::Obj(r)) => e.extend(r.iter().cloned()), Some(w) => e.push(("amplitude".into(), w.clone())), None => {} } Json::Obj(e) }), ("stance".into(), e(&self.stance)), ("mood".into(), e(&self.mood)), ("habits".into(), Json::Obj(vec![("active".into(), jstrs(&self.active)), ("bound".into(), jstrs(&self.bound)), ("violations".into(), Json::Num(self.violations as f64)), ("conflict".into(), jstrs(&self.conflict)), ("yielded".into(), jstrs(&self.yielded))])), @@ -2935,13 +2948,27 @@ fn decode(p: &Persona, st: &State, program: &str, meta: &Meta, doc: &Json, confl ns.seal(p); let out = Out { status, verdict: verdict.clone(), tier: doc.get("tier").cloned().unwrap_or(Json::Null), program: program.to_string(), stance, mood, active: meta.active.clone(), bound, violations: viol, conflict: conflict.to_vec(), yielded: yielded.to_vec(), unsure, escalate, inputs: meta.vals.iter().cloned().chain(meta.hist.iter().map(|(k, x)| (k.clone(), Json::Num(*x)))).collect(), - ignored: meta.ignored.clone(), line: ln, why: w, agenda: (!p.steps.is_empty()).then_some(order_steps), state_digest: ns.digest.clone(), held: vec![], timing: None, lift: meta.lift.clone() }; + ignored: meta.ignored.clone(), line: ln, why: w, agenda: (!p.steps.is_empty()).then_some(order_steps), state_digest: ns.digest.clone(), held: vec![], timing: None, lift: meta.lift.clone(), amp: doc.get("amplitude").cloned() }; (out, ns) } // ------------------------------------------------------------------------------------------------------------ turns fn solve(p: &Persona, st: &State, prog: &Json, eng: Engine, calls: &mut usize) -> Json { - *calls += 1; let was = crate::trace::enter(crate::trace::Phase::Solve); let r = eng(prog, &flags(p, st)); crate::trace::enter(was); r + *calls += 1; let was = crate::trace::enter(crate::trace::Phase::Solve); + let r = if p.eng.amp { amplitude_tier(prog, p.eng.q_max, p.eng.amplify, p.eng.schedule.as_deref()).unwrap_or_else(|why| declined(eng(prog, &flags(p, st)), &why)) } else { eng(prog, &flags(p, st)) }; + crate::trace::enter(was); r +} +/// RFC 0011 (research): the amplitude tier on a program (`run::amplitude`); Err(why) when it does not take it +#[cfg(feature = "decide")] +fn amplitude_tier(prog: &Json, q_max: usize, amplify: bool, schedule: Option<&str>) -> Result { + match schedule.and_then(probbit_decide::amp::Evolution::named) { Some(ev) => crate::run::amplitude_with(prog, q_max, amplify, &ev), None => crate::run::amplitude(prog, q_max, amplify) } +} +#[cfg(not(feature = "decide"))] +fn amplitude_tier(_: &Json, _: usize, _: bool, _: Option<&str>) -> Result { Err("this build has no amplitude tier (feature decide)".into()) } +/// RFC 0011 round 5 (research): the names `engine.schedule` accepts (`probbit_decide::amp::SCHEDULE_NAMES`; checked by a test) +const SCHEDULE_NAMES: [&str; 3] = ["tf-linear-16x4", "tf-linear-64x16", "qaoa-p2"]; +/// The existing tiers' answer, with the receipt of why the amplitude tier did not run +fn declined(mut doc: Json, why: &str) -> Json { if let Json::Obj(v) = &mut doc { v.push(("amplitude".into(), Json::Str(format!("declined: {why}")))); } doc } fn ms(t: Instant) -> f64 { t.elapsed().as_secs_f64() * 1e3 } /// A minimal set of active habits that cannot hold together (deletion filter: drop each habit in turn, keep it only if the rest @@ -4837,6 +4864,101 @@ mod tests { for n in [0, 1, 55, 56, 57, 63, 64, 65, 119, 120, 128, 300] { let one = sha256(&data[..n]); for piece in [1, 3, 64, 100] { let mut h = Sha256::new(); for c in data[..n].chunks(piece) { h.update(c); } assert_eq!(h.finish(), one, "{n} bytes in pieces of {piece}"); } } } + /// RFC 0011 (research): `engine.tier: amplitude` on a persona of two-valued traits answers from the simulated register (its + /// receipt in the stance document, habits held, deterministic, a strand that verifies); round 2: three-valued traits are + /// answered too (two qubits each, one register per component), a component over `q_max` declines, the existing tiers answer + /// and the receipt says why; absent, nothing changes + #[cfg(feature = "decide")] + #[test] + fn the_amplitude_tier_answers_multi_level_programs_and_declines_past_q_max() { + use crate::strand::{self, Clock, Live}; + let text = include_str!("../../examples/persona/dispatch-policy.yaml").replace("\r\n", "\n").replace("engine:\n", "engine:\n tier: amplitude\n"); + let doc = crate::yaml::load(&text).ok().unwrap(); let p = build(&doc).unwrap(); let st = init(&p, Some(2), true, &run); + for raw in [r#"{"blast":0}"#, r#"{"blast":0.9}"#, r#"{"blast":0.3,"error":true}"#] { + let raw = json::parse(raw).unwrap(); let (a, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); let (b, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&a), canon(&b)); assert_eq!(g(&a, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&a)); + let q = g(&a, &["engine", "qubits"]).as_f64().unwrap(); assert!((1.0..=4.0).contains(&q)); + for k in ["circuit", "schedule", "state"] { assert!(g(&a, &["engine", k]).as_str().is_some_and(is_digest), "{k}"); } + assert_eq!(g(&a, &["habits", "violations"]).as_f64(), Some(0.0)); assert_eq!(g(&a, &["status"]).as_str(), Some("ok")); } + let (mut live, header) = Live::start(p.clone(), &doc, st.clone(), Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for (i, raw) in [r#"{"blast":0}"#, r#"{"blast":0.9}"#, r#"{"error":true}"#, r#"{"error":true}"#, r#"{"praise":true,"src":"human"}"#].iter().enumerate() { + let (_, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap_or_else(|e| panic!("event {i}: {}", e.msg)); lines.push(l); } + let strand_text = lines.join("\n") + "\n"; assert!(strand::verify(&strand_text, &run).is_ok()); + // three-valued traits: answered (encoded qubits, the components, the encoding and the mixer in the receipt) + let q = build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"amplitude"}"#)).unwrap()).unwrap(); let s0 = init(&q, Some(3), true, &run); + let (d, _) = turn(&q, &s0, &inputs(false, false, false), false, &run, false).unwrap(); let (d2, _) = turn(&q, &s0, &inputs(false, false, false), false, &run, false).unwrap(); + assert_eq!(canon(&d), canon(&d2)); assert_eq!(g(&d, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&d)); + assert_eq!(g(&d, &["engine", "mixer"]).as_str(), Some("complete-graph")); assert!(g(&d, &["engine", "encoding"]).as_str().is_some_and(is_digest)); + assert!(g(&d, &["engine", "qubits"]).as_f64().unwrap() >= 2.0); assert_eq!(g(&d, &["habits", "violations"]).as_f64(), Some(0.0)); + // a component past q_max: declined, the existing tier answers, the receipt says so + let q1 = build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"amplitude","q_max":1}"#)).unwrap()).unwrap(); + let (d, _) = turn(&q1, &init(&q1, Some(3), true, &run), &inputs(false, false, false), false, &run, false).unwrap(); + assert!(g(&d, &["engine", "amplitude"]).as_str().is_some_and(|w| w.starts_with("declined: ") && w.contains("encoded qubits, q_max 1")), "{}", canon(&d)); + assert_ne!(g(&d, &["engine", "tier"]).as_str(), Some("amplitude")); + // the tutor example (three-level traits, 60 encoded qubits in components of at most 10): answered, and its strand verifies + let text = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n").replace("engine:\n", "engine:\n tier: amplitude\n"); + let doc = crate::yaml::load(&text).ok().unwrap(); let p = build(&doc).unwrap(); let st = init(&p, Some(2), true, &run); + let (mut live, header) = Live::start(p.clone(), &doc, st, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (o, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap(); lines.push(l); + assert_eq!(g(&o, &["engine", "tier"]).as_str(), Some("amplitude"), "{}", canon(&o)); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok()); + let plain = build(&json::parse(&DOC.replace("LEARN", "")).unwrap()).unwrap(); let (e, _) = turn(&plain, &init(&plain, Some(3), true, &run), &inputs(false, false, false), false, &run, false).unwrap(); + assert!(g(&e, &["engine", "amplitude"]).is_null() && g(&e, &["engine", "qubits"]).is_null()); + assert_eq!(err(build(&json::parse(&DOC.replace("LEARN", r#","engine":{"tier":"pbit"}"#)).unwrap())), "engine.tier: amplitude (the one optional tier; absent = the default tiers)"); + } + /// RFC 0011 round 4 (research): `engine.amplify: true` rotates each component's register onto its feasible plans by exact + /// amplitude amplification instead of projecting: the stance is the projection's (plan, odds, line), the receipt carries + /// `amplify` and feasible mass 1, the strand verifies; `amplify: false` is the round-3 behaviour; without the tier it is refused + #[cfg(feature = "decide")] + #[test] + fn amplify_lands_on_the_projection_and_logs_it() { + use crate::strand::{self, Clock, Live}; + let base = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n"); + let mk = |extra: &str| { let doc = crate::yaml::load(&base.replace("engine:\n", &format!("engine:\n tier: amplitude\n{extra}"))).ok().unwrap(); (build(&doc).unwrap(), doc) }; + let ((pj, _), (pf, _), (pa, doc)) = (mk(""), mk(" amplify: false\n"), mk(" amplify: true\n")); + let keep = |d: &Json| { let mut d = d.clone(); if let Json::Obj(v) = &mut d { v.retain(|(k, _)| !["persona", "engine", "state_digest"].contains(&k.as_str())); } canon(&d) }; + let (sj, sf, sa) = (init(&pj, Some(2), true, &run), init(&pf, Some(2), true, &run), init(&pa, Some(2), true, &run)); + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#, r#"{"elapsed_hours":1,"correction":true}"#] { + let raw = json::parse(raw).unwrap(); let (a, _) = turn(&pj, &sj, &raw, false, &run, false).unwrap(); let (f, _) = turn(&pf, &sf, &raw, false, &run, false).unwrap(); + let (b, _) = turn(&pa, &sa, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&g(&a, &["engine"])), canon(&g(&f, &["engine"]))); assert!(g(&a, &["engine", "amplify"]).is_null()); + assert_eq!(keep(&a), keep(&b), "the stance with amplify is the projection's"); + assert_eq!(g(&b, &["engine", "feasible_mass"]).as_f64(), Some(1.0)); let am = g(&b, &["engine", "amplify"]); + let it = am.get("iterations").and_then(Json::as_arr).unwrap(); assert_eq!(it.len(), g(&b, &["engine", "components"]).as_arr().unwrap().len()); + assert!(it.iter().any(|x| x.as_f64().unwrap() >= 1.0), "{}", canon(&am)); assert_eq!(am.get("mass_after").and_then(Json::as_f64), Some(1.0)); + assert_eq!(am.get("mass_before").and_then(Json::as_f64), g(&a, &["engine", "feasible_mass"]).as_f64()); } + let (mut live, header) = Live::start(pa.clone(), &doc, sa, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for raw in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (_, l) = live.event(&json::parse(raw).unwrap(), &run).unwrap(); lines.push(l); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok()); + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n amplify: true\n")).ok().unwrap())); + assert!(e.starts_with("engine.amplify: true needs tier: amplitude"), "{e}"); + } + /// RFC 0011 round 5 (research): `engine.schedule` names the register's evolution: the default name is the absent key (the + /// same documents, byte for byte), the other two name themselves in the receipt and their strands verify, an unknown name and + /// the key without the tier are refused; the name list is the decide crate's + #[cfg(feature = "decide")] + #[test] + fn the_schedule_key_names_the_evolution() { + use crate::strand::{self, Clock, Live}; + assert_eq!(SCHEDULE_NAMES, probbit_decide::amp::SCHEDULE_NAMES); + let base = include_str!("../../examples/persona/tutor.yaml").replace("\r\n", "\n"); + let mk = |extra: &str| { let doc = crate::yaml::load(&base.replace("engine:\n", &format!("engine:\n tier: amplitude\n amplify: true\n{extra}"))).ok().unwrap(); (build(&doc).unwrap(), doc) }; + let (p0, _) = mk(""); let (pd, _) = mk(" schedule: tf-linear-16x4\n"); let s0 = init(&p0, Some(2), true, &run); let sd = init(&pd, Some(2), true, &run); + let raw = json::parse(r#"{"elapsed_hours":1,"praise":true}"#).unwrap(); + let (a, _) = turn(&p0, &s0, &raw, false, &run, false).unwrap(); let (b, _) = turn(&pd, &sd, &raw, false, &run, false).unwrap(); + assert_eq!(canon(&g(&a, &["engine"])), canon(&g(&b, &["engine"]))); assert_eq!(g(&a, &["engine", "schedule_name"]).as_str(), Some("tf-linear-16x4.000000")); + for (name, shown) in [("tf-linear-64x16", "tf-linear-64x16.000000"), ("qaoa-p2", "qaoa-p2")] { + let (p, doc) = mk(&format!(" schedule: {name}\n")); let st = init(&p, Some(2), true, &run); + let (d, _) = turn(&p, &st, &raw, false, &run, false).unwrap(); assert_eq!(g(&d, &["engine", "schedule_name"]).as_str(), Some(shown)); + assert_eq!(g(&d, &["engine", "feasible_mass"]).as_f64(), Some(1.0)); + let (mut live, header) = Live::start(p.clone(), &doc, st, Clock::Fixed, "probbit test"); let mut lines = vec![header]; + for r in [r#"{"elapsed_hours":1,"praise":true}"#, r#"{"elapsed_hours":2}"#] { let (_, l) = live.event(&json::parse(r).unwrap(), &run).unwrap(); lines.push(l); } + assert!(strand::verify(&(lines.join("\n") + "\n"), &run).is_ok(), "{name}"); } + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n tier: amplitude\n schedule: tf-linear-8x2\n")).ok().unwrap())); + assert_eq!(e, "engine.schedule: tf-linear-16x4 | tf-linear-64x16 | qaoa-p2"); + let e = err(build(&crate::yaml::load(&base.replace("engine:\n", "engine:\n schedule: qaoa-p2\n")).ok().unwrap())); + assert!(e.starts_with("engine.schedule: needs tier: amplitude"), "{e}"); + } /// The boxed setup API is the by-value one: the same persona, state, header and individual #[test] fn the_boxed_setup_is_the_by_value_setup() { diff --git a/probbit-persona/src/run.rs b/probbit-persona/src/run.rs index 5da7f3d..6e43966 100644 --- a/probbit-persona/src/run.rs +++ b/probbit-persona/src/run.rs @@ -508,3 +508,94 @@ pub fn exact_cap(xms: Option, reached: bool) -> Vec<(&'static str, Json)> { } /// Per item, the error bar the gate compares with `tv_tol`: z x max(MCSE short, MCSE long) (`Gate::released_tasks`). pub fn item_bars(g: &Gate) -> Vec { g.sig_tv.iter().zip(&g.sig_tv_long).map(|(&s, &l)| GATE.z * if s.is_nan() || l.is_nan() { f64::NAN } else { s.max(l) }).collect() } + +/// RFC 0011 (research): the `amplitude` tier, an exact simulated qubit register run on a CPU (`probbit_decide::amp`). Round 2: +/// a variable with d candidate values (allowed and clamp) takes ceil(log2 d) qubits (`amp::Encoding`; the unused codes keep +/// amplitude 0), and the program's log weight enters as one general diagonal phase layer (E = -log w per basis state), so +/// pair tables between multi-level variables need no decomposition. The program is split into its connected components +/// (two variables are linked by a pair or by sharing a capacity rule, as the `components` tier splits it) and each component +/// runs its own register: the phase is a sum over components and the mixer acts per variable, so the joint state is exactly +/// the product of the components' states. Eligible when every variable has an allowed value and the largest component has at +/// most `q_max` encoded qubits; Err(why) otherwise, and the caller runs the existing tiers. Each register runs the default +/// sweep from the uniform superposition over its valid codes; the rules are a projection: the Born probabilities of the +/// plans that break one are set to 0 and the rest renormalised (per component; the feasible mass is the product). Plan = per +/// component its most probable feasible plan (probabilities at 6 decimals, then the lowest basis index); marginals exact. The +/// receipt: qubits (encoded, summed), the components' qubits, the digests of the encoding, the circuit, the schedule and the +/// final amplitudes (6 decimals; the components' texts joined), the mixer's and the schedule's names, the feasible mass. +/// Round 4, `amplify` (`engine.amplify: true`): no projection; each component's swept state is rotated onto its feasible plans +/// by exact amplitude amplification (`amp::amplify_exact`: the rules as the oracle, the diffusion about the swept state, one +/// phase-matched final iteration), so the register itself holds feasible mass 1 (up to 1e-12) and the plan and marginals are +/// read from its Born probabilities; the receipt adds `amplify` (iterations and the final phases per component, the mass +/// before and after) and `feasible_mass` is the mass after. The relative amplitudes inside the feasible plans are the swept +/// state's, so the numbers equal the projection's (docs/rfc/0011-amplitude-register.md, "Amplification"). +#[cfg(feature = "decide")] +pub fn amplitude(prog: &Json, q_max: usize, amplify: bool) -> Result { amplitude_with(prog, q_max, amplify, &probbit_decide::amp::Evolution::Sweep(probbit_decide::amp::DEFAULT_SCHEDULE)) } +/// Round 5: `amplitude` with the evolution `ev` in place of the default sweep (`engine.schedule`; the receipt names it) +#[cfg(feature = "decide")] +pub fn amplitude_with(prog: &Json, q_max: usize, amplify: bool, ev: &probbit_decide::amp::Evolution) -> Result { + use probbit_decide::amp; + let p = from_json(prog).map_err(|e| format!("program {}: {}", e.path, e.msg))?; let m = &p.m; let (n, k) = (m.n, m.k); + let cand: Vec> = (0..n).map(|i| (0..k).filter(|&v| m.ok(i, v)).collect()).collect(); + if let Some(i) = cand.iter().position(Vec::is_empty) { return Err(format!("{} has no allowed value", p.vars[i])); } + // the components: pairs and capacity rules link variables + let mut par: Vec = (0..n).collect(); + fn find(par: &mut [usize], mut x: usize) -> usize { while par[x] != x { par[x] = par[par[x]]; x = par[x]; } x } + fn join(par: &mut [usize], a: usize, b: usize) { let (a, b) = (find(par, a), find(par, b)); if a != b { par[a.max(b)] = a.min(b); } } + for q in &m.pairs { join(&mut par, q.i, q.j); } + for c in &m.caps { if let Some(&(i0, _)) = c.members.first() { for &(i, _) in &c.members { join(&mut par, i0, i); } } } + let mut slot = vec![usize::MAX; n]; let mut mems: Vec> = vec![]; + for i in 0..n { let r = find(&mut par, i); if slot[r] == usize::MAX { slot[r] = mems.len(); mems.push(vec![]); } mems[slot[r]].push(i); } + let comp_of: Vec = (0..n).map(|i| slot[find(&mut par, i)]).collect(); + let encs: Vec = mems.iter().map(|mem| amp::Encoding { levels: mem.iter().map(|&i| cand[i].len()).collect() }).collect(); + let qs: Vec = encs.iter().map(amp::Encoding::qubits).collect(); let qtop = qs.iter().copied().max().unwrap_or(0); + if qtop > q_max.min(amp::Q_MAX_HOST) { return Err(format!("a component of {qtop} encoded qubits, q_max {q_max}")); } + let s = ev; let r6 = |x: f64| (x * 1e6).round() / 1e6; + let (mut enc_t, mut circ_t, mut state_t) = (String::new(), String::new(), String::new()); + let mut bp = vec![0usize; n]; let mut mg = vec![0.0; n * k]; let mut mass_all = 1.0; + let (mut am_it, mut am_ph, mut am_before) = (vec![], vec![], 1.0); + for (c, mem) in mems.iter().enumerate() { + // the component's sub-model: its variables, pairs and capacity rules (local indices), as the components tier builds it + let mut loc = vec![usize::MAX; n]; for (q, &i) in mem.iter().enumerate() { loc[i] = q; } + let nn = mem.len(); let mut h = Vec::with_capacity(nn * k); let mut al = Vec::with_capacity(nn * k); + for &i in mem { h.extend_from_slice(&m.h[i * k..i * k + k]); al.extend_from_slice(&m.allowed[i * k..i * k + k]); } + let pairs: Vec = m.pairs.iter().filter(|q| comp_of[q.i] == c).map(|q| Pair { i: loc[q.i], j: loc[q.j], c: q.c.clone() }).collect(); + let caps: Vec = m.caps.iter().filter(|q| q.members.first().is_some_and(|&(i, _)| comp_of[i] == c)) + .map(|q| Cap { members: q.members.iter().map(|&(i, v)| (loc[i], v)).collect(), limit: q.limit, weights: q.weights.clone() }).collect(); + let sub = Model::new(nn, k, h, al, mem.iter().map(|&i| m.clamp[i]).collect(), pairs, caps).map_err(|e| format!("component {c}: {e}"))?; + let enc = &encs[c]; let nb = 1usize << qs[c]; + let plan_of = |codes: &[usize]| -> Vec { codes.iter().zip(mem).map(|(&cd, &i)| cand[i][cd]).collect() }; + let e: Vec = (0..nb).map(|x| enc.decode(x).map_or(f64::NAN, |cd| -sub.logw(&plan_of(&cd)))).collect(); + let mut reg = s.run(enc, &e); + if amplify { + let ok: Vec = (0..nb).map(|x| enc.decode(x).is_some_and(|cd| sub.violations(&plan_of(&cd)) == 0)).collect(); + let Some(a) = amp::amplify_exact(&mut reg, |x| ok[x]) else { + // round 5: None after a rotation (good mass near 1) = the landing missed its bar: decline, never panic + let m: f64 = reg.probabilities().iter().zip(&ok).filter(|(_, &g)| g).map(|(p, _)| p).sum(); + return Err(if m > 0.5 { format!("component {c}: amplification missed its 1 - 1e-12 landing") } else { format!("component {c}: no feasible plan has weight in the register") }); }; + let pr = reg.probabilities(); + let best = (0..nb).fold(usize::MAX, |bx, x| if enc.decode(x).is_some() && pr[x] > 0.0 && (bx == usize::MAX || r6(pr[x]) > r6(pr[bx])) { x } else { bx }); + for (&i, v) in mem.iter().zip(plan_of(&enc.decode(best).expect("a plan with weight has valid codes"))) { bp[i] = v; } + for (x, &w) in pr.iter().enumerate() { if let Some(cd) = enc.decode(x) { for (&i, v) in mem.iter().zip(plan_of(&cd)) { mg[i * k + v] += w; } } } + mass_all *= a.mass_after; am_before *= a.mass_before; am_it.push(num(a.iterations as f64)); + am_ph.push(Json::Arr(if a.iterations == 0 { vec![] } else { vec![num(r6(a.phases.0)), num(r6(a.phases.1))] })); + for t in [&mut enc_t, &mut circ_t, &mut state_t] { t.push_str(&format!("component {c}\n")); } + enc_t.push_str(&enc.text()); circ_t.push_str(®.circuit_text()); state_t.push_str(®.canonical()); + continue; + } + let pr: Vec = reg.probabilities().iter().enumerate().map(|(x, &w)| match enc.decode(x) { Some(cd) if sub.violations(&plan_of(&cd)) == 0 => w, _ => 0.0 }).collect(); + let mass: f64 = pr.iter().sum(); if mass <= 0.0 || !mass.is_finite() { return Err(format!("component {c}: no feasible plan has weight in the register")); } + let best = (0..nb).fold(usize::MAX, |bx, x| if pr[x] > 0.0 && (bx == usize::MAX || r6(pr[x] / mass) > r6(pr[bx] / mass)) { x } else { bx }); + for (&i, v) in mem.iter().zip(plan_of(&enc.decode(best).expect("a feasible plan has valid codes"))) { bp[i] = v; } + for (x, &w) in pr.iter().enumerate() { if w > 0.0 { for (&i, v) in mem.iter().zip(plan_of(&enc.decode(x).expect("valid"))) { mg[i * k + v] += w / mass; } } } + mass_all *= mass; + for t in [&mut enc_t, &mut circ_t, &mut state_t] { t.push_str(&format!("component {c}\n")); } + enc_t.push_str(&enc.text()); circ_t.push_str(®.circuit_text()); state_t.push_str(®.canonical()); + } + let d = |t: &[u8]| jstr(&format!("sha256:{}", amp::hex(&::sha256(t)))); + let amp_r = if amplify { vec![("amplify", obj(vec![("iterations", Json::Arr(am_it)), ("phases", Json::Arr(am_ph)), ("mass_before", num(r6(am_before))), ("mass_after", num(r6(mass_all)))]))] } else { vec![] }; + Ok(obj(vec![("verdict", jstr("exact")), ("tier", jstr("amplitude")), ("plan", plan(&p, &bp)), ("plan_logw", num(m.logw(&bp))), ("violations", num(m.violations(&bp) as f64)), + ("marginals", marginals(&p, &mg)), ("released", ids(&p, &vec![true; n], true)), ("escalated", Json::Arr(vec![])), + ("amplitude", obj(vec![("qubits", num(qs.iter().sum::() as f64)), ("components", Json::Arr(qs.iter().map(|&q| num(q as f64)).collect())), + ("encoding", d(enc_t.as_bytes())), ("mixer", jstr(amp::MIXER)), ("circuit", d(circ_t.as_bytes())), ("schedule", d(s.text().as_bytes())), ("schedule_name", jstr(&s.name())), + ("state", d(state_t.as_bytes())), ("feasible_mass", num(r6(mass_all)))].into_iter().chain(amp_r).collect()))])) +}