From 2feb34ae876beddac1353f0fdf253793b8382c89 Mon Sep 17 00:00:00 2001 From: Allan Scott Date: Fri, 1 May 2026 20:08:30 -0400 Subject: [PATCH] =?UTF-8?q?fix-mps-encoding-non-factorizing:=20=C2=A71-?= =?UTF-8?q?=C2=A78=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the cross-coupled-by-sum bond-2 MPS encoding (Alternative A from the change's design.md) so the canonical hierarchical-polysemantic example has a Gram that genuinely differs from the rung-0 product-state factorization, fixing the silent factorization documented in the post-mortem of `add-mps-concept-encoding`. - compiler helper: generalize the Ry-segment matcher in compute_concept_gram_mps to accept linear-combination angle expressions via ast walking; new `unrecognized_angle_expression` error kind for non-linear forms - angle evaluator: extend evaluate_angle to recurse on top-level + / - so `Ry(qs[1], a + b)` parses cleanly through markdown_parser and the effect_parser - example: re-pin to `Ry(q0,a); CNOT; Ry(q1, a+b); CNOT; Ry(q2, b+c)`; refresh tier table (sub-mate 0.882 / super-sib {0.335, 0.593, 0.753} / cross [0.000, 0.178]), heatmap, polysemy column, β values, and add the `non_factorized_gram` verification rule - demo: update prepare/query circuits to the cross-coupled angles, re-pin the tier ranges, and reword §6 narrative to ground the hierarchy in the cross-coupling rather than the staircase entanglement - docs: README hierarchical-polysemy section, post-mortem appended to archived `add-mps-concept-encoding/design.md` (only edit to archived content), and a "Schmidt rank > 1 ≠ non-factorized overlap" caveat box plus updated rung-1 entry in the research note - tests: re-pin tier-band assertions, add the non-factorization assertion, replace the obsolete mixed-signs / param-position-mismatch tests with new tests for cross-coupled, single-bound-param-degenerate, swapped-position, and unrecognized_angle_expression sad paths - tech-debt: add §5.7 (verifier blind spot — Gram factorization vs. encoding entanglement) with a back-link from §5.6 OpenSpec: validates strict; all 29 change tasks ticked. Test suite: 774 passed, 6 skipped. Demo passes with max error 0.0077 < 0.0469 threshold. Co-Authored-By: Claude Opus 4.7 --- README.md | 18 +- demos/larql_polysemantic_hierarchical/demo.py | 64 ++-- ...semantic-encoding-beyond-product-states.md | 38 ++- .../larql-polysemantic-hierarchical.q.orca.md | 247 +++++++------- .../add-mps-concept-encoding/design.md | 9 + .../fix-mps-encoding-non-factorizing/tasks.md | 58 ++-- openspec/changes/tech-debt-backlog/tasks.md | 28 ++ q_orca/angle.py | 52 +++ q_orca/compiler/concept_gram_mps.py | 311 +++++++++++++----- tests/test_compiler.py | 127 ++++++- tests/test_examples.py | 56 +++- 11 files changed, 717 insertions(+), 291 deletions(-) diff --git a/README.md b/README.md index a8def1d..07b4fd4 100644 --- a/README.md +++ b/README.md @@ -597,12 +597,18 @@ sparse-autoencoder dictionaries report *graded* within-cluster similarity and **sub-cluster structure** that flat block tiers cannot express. [`examples/larql-polysemantic-hierarchical.q.orca.md`](examples/larql-polysemantic-hierarchical.q.orca.md) lifts the encoding to **bond-dimension-2 matrix product states** via a -`Ry(q0,α); CNOT(q0,q1); Ry(q1,β); CNOT(q1,q2); Ry(q2,γ)` staircase. The -12 concepts are organized as a two-level hierarchy — 3 super-groups -(`animals`, `fruits`, `vehicles`) × 2 sub-clusters × 2 concepts — and the -Gram matrix splits into **four** ordered tiers: self 1.000 / -sub-cluster-mate 0.882 / super-group-sibling [0.47, 0.54] / cross-group -[0.12, 0.25]. +`Ry(q0,α); CNOT(q0,q1); Ry(q1,α+β); CNOT(q1,q2); Ry(q2,β+γ)` staircase +with **cross-coupled angle expressions** — the q1 and q2 rotations bind +linear combinations of the bound parameters rather than single +parameters. The cross-coupling is what produces a non-factorized Gram: +the bare staircase `Ry(q0,α)·CNOT·Ry(q1,β)·CNOT·Ry(q2,γ)·|000>` +*factorizes* as `∏_k cos((θ_{i,k} − θ_{j,k})/2)` across qubits despite +having Schmidt rank 2 — a surprising mathematical fact documented in the +`fix-mps-encoding-non-factorizing` change. The 12 concepts are organized +as a two-level hierarchy — 3 super-groups (`animals`, `fruits`, +`vehicles`) × 2 sub-clusters × 2 concepts — and the Gram matrix splits +into **four** ordered tiers: self 1.000 / sub-cluster-mate 0.882 / +super-group-sibling {0.335, 0.593, 0.753} / cross-group [0.000, 0.178]. The optional `q_orca.compiler.concept_gram_mps.compute_concept_gram_mps(machine)` helper produces the Gram matrix for machines following the CNOT-staircase diff --git a/demos/larql_polysemantic_hierarchical/demo.py b/demos/larql_polysemantic_hierarchical/demo.py index 302a916..2c564a2 100644 --- a/demos/larql_polysemantic_hierarchical/demo.py +++ b/demos/larql_polysemantic_hierarchical/demo.py @@ -15,15 +15,22 @@ 4-tier) Gram signatures. Topology: 3 super-groups × 2 sub-clusters × 2 concepts = 12 concepts on a -3-qubit register. Each concept is encoded as the bond-2 MPS - - |c_i> = Ry(q0, α_i) CNOT(q0, q1) Ry(q1, β_i) CNOT(q1, q2) Ry(q2, γ_i) |000> - -with α ∈ {0, 2π/3, 4π/3} (super-group), β ∈ {-0.75, +0.75} (sub-cluster), -and γ ∈ {-0.35, +0.35} (concept). This produces analytic -`||²` tiers — self 1.0, sub-cluster-mate 0.882, super-group- -sibling [0.47, 0.54], cross-group [0.12, 0.25] — distinct from the -flat-block clusters demo's three uniform tiers. +3-qubit register. Each concept is encoded as the *cross-coupled* bond-2 +MPS + + |c_i> = Ry(q0, α_i) CNOT(q0, q1) Ry(q1, α_i + β_i) CNOT(q1, q2) Ry(q2, β_i + γ_i) |000> + +with α ∈ {0, 2π/3, 4π/3} (super-group), β ∈ {-0.5, +0.5} (sub-cluster), +and γ ∈ {-0.35, +0.35} (concept). The q1 and q2 rotations bind linear- +combination angles (`α + β`, `β + γ`) — the cross-coupling is what makes +the Gram non-factorized. The bare staircase (single-parameter Ry on each +qubit) factorizes as `∏_k cos((θ_{i,k} − θ_{j,k})/2)` despite Schmidt rank +2 — a known counter-example documented in the +`fix-mps-encoding-non-factorizing` design note. The cross-coupled +encoding produces analytic `||²` tiers — self 1.000, sub- +cluster-mate 0.882 (uniform), super-group-sibling {0.335, 0.593, 0.753}, +cross-group [0.000, 0.178] — distinct from the flat-block clusters +demo's three uniform tiers. Usage: pip install q-orca[quantum] @@ -75,13 +82,13 @@ def banner(title: str) -> None: def heatmap_tier(value: float) -> str: - """4-tier ASCII heatmap: '#' ≥ 0.7, 'o' ∈ [0.3, 0.7), '.' ∈ [0.1, 0.3), blank < 0.1.""" + """4-tier ASCII heatmap: '#' ≥ 0.7, 'o' ∈ [0.3, 0.7), '.' ∈ [0.05, 0.3), blank < 0.05.""" v = abs(value) if v >= 0.7: return "#" if v >= 0.3: return "o" - if v >= 0.1: + if v >= 0.05: return "." return " " @@ -89,7 +96,7 @@ def heatmap_tier(value: float) -> str: def print_gram_heatmap(gram: np.ndarray) -> None: """Print |gram|² as a 4-tier 12×12 ASCII heatmap with hierarchy labels.""" gsq = np.abs(gram) ** 2 - print(" |gram[i,j]|² (# ≥ 0.7, o ∈ [0.3, 0.7), . ∈ [0.1, 0.3), blank < 0.1)") + print(" |gram[i,j]|² (# ≥ 0.7, o ∈ [0.3, 0.7), . ∈ [0.05, 0.3), blank < 0.05)") print(" ", "".join(f"{i:>3}" for i in range(12))) for i in range(12): row = "".join(f" {heatmap_tier(gsq[i, j])}" for j in range(12)) @@ -100,9 +107,9 @@ def print_gram_heatmap(gram: np.ndarray) -> None: def build_query_circuit(prepare_angles: tuple, query_angles: tuple): """Build a prepare(feature) + query(concept) circuit on 3 qubits. - Mirrors the .q.orca.md effect strings exactly: - prepare: Ry(q0, a); CNOT(q0,q1); Ry(q1, b); CNOT(q1,q2); Ry(q2, c) - query: Ry(q2,-c); CNOT(q1,q2); Ry(q1,-b); CNOT(q0,q1); Ry(q0,-a) + Mirrors the .q.orca.md effect strings exactly (cross-coupled bond-2): + prepare: Ry(q0, a); CNOT(q0,q1); Ry(q1, a+b); CNOT(q1,q2); Ry(q2, b+c) + query: Ry(q2,-(b+c)); CNOT(q1,q2); Ry(q1,-(a+b)); CNOT(q0,q1); Ry(q0,-a) """ from qiskit import QuantumCircuit @@ -110,13 +117,13 @@ def build_query_circuit(prepare_angles: tuple, query_angles: tuple): a, b, c = prepare_angles qc.ry(a, 0) qc.cx(0, 1) - qc.ry(b, 1) + qc.ry(a + b, 1) qc.cx(1, 2) - qc.ry(c, 2) + qc.ry(b + c, 2) a2, b2, c2 = query_angles - qc.ry(-c2, 2) + qc.ry(-(b2 + c2), 2) qc.cx(1, 2) - qc.ry(-b2, 1) + qc.ry(-(a2 + b2), 1) qc.cx(0, 1) qc.ry(-a2, 0) qc.measure(range(3), range(3)) @@ -221,11 +228,11 @@ def main() -> None: ) print( f" super-group-sib ||² : min={min(super_sib):.4f} " - f"max={max(super_sib):.4f} (n={len(super_sib)}; analytic [0.47, 0.54])" + f"max={max(super_sib):.4f} (n={len(super_sib)}; analytic {{0.335, 0.593, 0.753}})" ) print( f" cross-group ||² : min={min(cross):.4f} " - f"max={max(cross):.4f} (n={len(cross)}; analytic [0.12, 0.25])" + f"max={max(cross):.4f} (n={len(cross)}; analytic [0.000, 0.178])" ) # 4. Per-concept polysemy column (|f> = |dog>) @@ -276,13 +283,18 @@ def main() -> None: print(" tiers : 1.000 / 0.720 / ≲ 0.09") print(" (self / cluster-mate / cross-cluster — three flat tiers)") print() - print(" rung-1 (this demo, MPS bond-2 CNOT staircase):") - print(" |c_i> = Ry(q0,α) CNOT(q0,q1) Ry(q1,β) CNOT(q1,q2) Ry(q2,γ) |000>") - print(" tiers : 1.000 / 0.882 / [0.47, 0.54] / [0.12, 0.25]") + print(" rung-1 (this demo, cross-coupled bond-2 MPS):") + print(" |c_i> = Ry(q0,α) CNOT(q0,q1) Ry(q1,α+β) CNOT(q1,q2) Ry(q2,β+γ) |000>") + print(" tiers : 1.000 / 0.882 / {0.335, 0.593, 0.753} / [0.000, 0.178]") print(" (self / sub-mate / super-sib / cross-group — four ordered tiers)") print() - print(" The CNOT staircase entangles adjacent qubits, lifting the") - print(" block diagonal of rung-0 into a graded two-level hierarchy.") + print(" The bond-2 CNOT staircase is a *prerequisite* for this hierarchy") + print(" (it gives the register an entangled MPS structure), but it is not") + print(" sufficient on its own — the bare staircase Ry(q0,α)·CNOT·Ry(q1,β)·") + print(" CNOT·Ry(q2,γ)·|000> has a Gram identical to rung-0's product-state") + print(" Gram despite Schmidt rank 2. The cross-coupled angle structure") + print(" (α+β on q1, β+γ on q2) is what breaks the factorization and") + print(" produces the graded four-tier hierarchy.") raise SystemExit(0 if passed else 1) diff --git a/docs/research/polysemantic-encoding-beyond-product-states.md b/docs/research/polysemantic-encoding-beyond-product-states.md index c145b56..e1dec15 100644 --- a/docs/research/polysemantic-encoding-beyond-product-states.md +++ b/docs/research/polysemantic-encoding-beyond-product-states.md @@ -127,6 +127,25 @@ per-axis independent. The moment you leave, that factorization dies. Inner products become contractions, amplitudes become correlated, and the analytic helper needs more machinery. +> **Caveat: Schmidt rank > 1 ≠ non-factorized overlap.** "Leaving +> the product-state manifold" is *necessary* for non-factorized +> overlap but *not sufficient*. The bare Ry-CNOT staircase +> `Ry(q0,α)·CNOT·Ry(q1,β)·CNOT·Ry(q2,γ)·|000>` produces a state +> with Schmidt rank 2 across both bipartitions (it is genuinely +> entangled), yet its Gram still factorizes as +> `∏_k cos((θ_{i,k} − θ_{j,k})/2)` — exactly the rung-0 inner- +> product map. Entanglement of the *state* and factorization of +> the *inner-product map* are independent properties; the latter +> depends on whether each angle parameter affects only one +> qubit's amplitude (factorizes) or leaks across qubits +> (doesn't). The bare staircase happens to keep each parameter +> on its primary qubit, so the leak is zero. The fix used by the +> canonical rung-1 example below is to bind *linear combinations* +> of the angle parameters to the q1 and q2 rotations (e.g., +> `Ry(q1, α + β)`, `Ry(q2, β + γ)`), forcing each parameter to +> leak into more than one qubit's amplitude. Detailed proof in +> `openspec/changes/fix-mps-encoding-non-factorizing/design.md`. + ### The unification analogy The transition from product states to entangled states maps cleanly @@ -161,9 +180,10 @@ more expressive than rung 0, but still polynomial in all parameters. #### Rung 1 — MPS with bond dimension 2 -- **Effect**: `Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], b); CNOT(qs[1], qs[2]); Ry(qs[2], c)` +- **Effect (canonical, cross-coupled)**: `Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], a + b); CNOT(qs[1], qs[2]); Ry(qs[2], b + c)` - **Parameters per concept**: `n` (same as rung 0, but they now - couple) + couple — and **must couple across qubits** for the Gram to + leave the rung-0 factorized form) - **Overlap**: `O(n · χ⁶)` transfer-matrix contraction at χ=2 — polynomial, closed-form-ish - **Capacity at clean tiers**: `N = O(n³)` by available hierarchy @@ -173,8 +193,18 @@ more expressive than rung 0, but still polynomial in all parameters. - **What it cannot**: non-local correlations across the qubit line, arbitrary 2-designs - **Q-orca surface**: already-parsed gates (CNOT, Ry), multi-gate - effect string, same parametric-expansion path as rung 0 -- **Proposed in**: `openspec/changes/add-mps-concept-encoding/` + effect string, linear-combination angle expressions, same + parametric-expansion path as rung 0 +- **Note on the bare staircase.** The single-parameter variant + `Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], b); CNOT(qs[1], qs[2]); Ry(qs[2], c)` + *factorizes* as `∏_k cos((θ_{i,k} − θ_{j,k})/2)` — its Gram is + identical to rung 0's despite the state having Schmidt rank 2 + (see caveat box above). The cross-coupled-by-sum variant breaks + the factorization while staying within the bond-2 MPS family. + Both variants are accepted by `compute_concept_gram_mps`; the + canonical hierarchical example uses the cross-coupled variant. +- **Shipped in**: `examples/larql-polysemantic-hierarchical.q.orca.md` + (post-`fix-mps-encoding-non-factorizing`) #### Rung 2 — hardware-efficient ansatz, depth L diff --git a/examples/larql-polysemantic-hierarchical.q.orca.md b/examples/larql-polysemantic-hierarchical.q.orca.md index c567b5c..afbdd6c 100644 --- a/examples/larql-polysemantic-hierarchical.q.orca.md +++ b/examples/larql-polysemantic-hierarchical.q.orca.md @@ -11,89 +11,114 @@ This is the **rung-1** companion to `larql-polysemantic-clusters.q.orca.md` product-state manifold — concept overlaps split into three uniform tiers (self 1.0 / cluster-mate 0.72 / cross-cluster < 0.09). This example lifts the encoding to a bond-dimension-2 matrix product state via a -`Ry; CNOT; Ry; CNOT; Ry` staircase, which introduces correlations between -adjacent qubits and produces a *four-tier* hierarchical Gram matrix: -self / sub-cluster-mate / super-group-sibling / cross-group. The full -ladder is in `docs/research/polysemantic-encoding-beyond-product-states.md`. +`Ry; CNOT; Ry; CNOT; Ry` staircase with **cross-coupled angle +parameters** — the q1 and q2 rotations bind sums of two parameters +each, leaking each angle into more than one qubit's amplitude. The +staircase alone (with single-parameter Ry rotations on the `|000>` +vacuum) gives a Schmidt-rank-2 entangled state, but its Gram still +factorizes as `cos((α_i − α_j)/2)·cos((β_i − β_j)/2)·cos((γ_i − γ_j)/2)` +— mathematically identical to rung 0. The cross-coupling is what +breaks that factorization and produces a genuinely *four-tier* +hierarchical Gram matrix: self / sub-cluster-mate / +super-group-sibling / cross-group. The full ladder is in +`docs/research/polysemantic-encoding-beyond-product-states.md`; the +mathematical argument for why the bare staircase factorizes is in +`openspec/changes/fix-mps-encoding-non-factorizing/design.md` (until +that change is archived). ## Concept geometry (12 concepts, 3-qubit register) -Each concept `c_i` is prepared as the bond-2 MPS - - |c_i> = Ry(q0, α_i) CNOT(q0, q1) Ry(q1, β_i) CNOT(q1, q2) Ry(q2, γ_i) |000> - -on a 3-qubit register. The CNOT staircase introduces 1D locality: q0 is -the chain root, q1 is correlated with q0 only, and q2 is correlated with -q0 only through q1. This naturally maps onto a two-level hierarchy: - -- **Super-group** index — encoded in `α` (the q0 rotation, chain root). - Three super-groups at evenly-spaced cyclic angles `α ∈ {0, 2π/3, 4π/3}`. - Cross-group concepts are well-separated because they differ on the - chain root. -- **Sub-cluster** index — encoded in `β` (the q1 rotation). Two - sub-clusters per super-group at offsets `β ∈ {-0.75, +0.75}`. - Same-super-group, different-sub-cluster pairs share the chain root but - diverge on the middle qubit. -- **Concept** index within sub-cluster — encoded in `γ` (the q2 rotation). - Two concepts per sub-cluster at offsets `γ ∈ {-0.35, +0.35}`. Concepts - within a sub-cluster share both the chain root and the middle qubit - and differ only on the leaf. +Each concept `c_i` is prepared as the cross-coupled bond-2 MPS + + |c_i> = Ry(q0, α_i) CNOT(q0, q1) Ry(q1, α_i + β_i) CNOT(q1, q2) Ry(q2, β_i + γ_i) |000> + +on a 3-qubit register. Each angle parameter has a "primary" qubit +(α → q0, β → q1, γ → q2) but `β` also leaks into the q2 rotation and +`α` also leaks into the q1 rotation through the linear-combination +angles `α + β` and `β + γ`. The CNOT staircase introduces 1D locality +*and* the cross-coupling makes the inner-product map non-factorized +across qubits — the four-tier Gram structure below depends on **both** +the chain entanglement and the angle leakage. The natural mapping +onto a two-level hierarchy is: + +- **Super-group** index — encoded primarily in `α` (the q0 rotation). + Three super-groups at evenly-spaced cyclic angles + `α ∈ {0, 2π/3, 4π/3}`. Cross-group concepts are well-separated + because they differ on the chain root. +- **Sub-cluster** index — encoded primarily in `β` (which drives the + q1 rotation through `α + β` and contributes to the q2 rotation + through `β + γ`). Two sub-clusters per super-group at offsets + `β ∈ {-0.5, +0.5}`. Same-super-group, different-sub-cluster pairs + share the chain root but diverge on the middle and leaf qubits. +- **Concept** index within sub-cluster — encoded in `γ` (the q2 + rotation through the `β + γ` term). Two concepts per sub-cluster at + offsets `γ ∈ {-0.35, +0.35}`. Concepts within a sub-cluster share + the chain root and the middle qubit and differ only on the leaf. The 12 concrete angle triples (with `α₂ = 2π/3 ≈ 2.094`, `α₃ = 4π/3 ≈ 4.189`): | i | concept | super-group | sub-cluster | (α, β, γ) | |----|-------------|-------------|-------------|----------------------| -| 0 | dog | animals | mammals | ( 0.000, -0.75, -0.35) | -| 1 | cat | animals | mammals | ( 0.000, -0.75, 0.35) | -| 2 | robin | animals | birds | ( 0.000, 0.75, -0.35) | -| 3 | eagle | animals | birds | ( 0.000, 0.75, 0.35) | -| 4 | strawberry | fruits | berries | ( 2.094, -0.75, -0.35) | -| 5 | blueberry | fruits | berries | ( 2.094, -0.75, 0.35) | -| 6 | mango | fruits | tropical | ( 2.094, 0.75, -0.35) | -| 7 | papaya | fruits | tropical | ( 2.094, 0.75, 0.35) | -| 8 | car | vehicles | land | ( 4.189, -0.75, -0.35) | -| 9 | bike | vehicles | land | ( 4.189, -0.75, 0.35) | -| 10 | plane | vehicles | air | ( 4.189, 0.75, -0.35) | -| 11 | drone | vehicles | air | ( 4.189, 0.75, 0.35) | +| 0 | dog | animals | mammals | ( 0.000, -0.50, -0.35) | +| 1 | cat | animals | mammals | ( 0.000, -0.50, 0.35) | +| 2 | robin | animals | birds | ( 0.000, 0.50, -0.35) | +| 3 | eagle | animals | birds | ( 0.000, 0.50, 0.35) | +| 4 | strawberry | fruits | berries | ( 2.094, -0.50, -0.35) | +| 5 | blueberry | fruits | berries | ( 2.094, -0.50, 0.35) | +| 6 | mango | fruits | tropical | ( 2.094, 0.50, -0.35) | +| 7 | papaya | fruits | tropical | ( 2.094, 0.50, 0.35) | +| 8 | car | vehicles | land | ( 4.189, -0.50, -0.35) | +| 9 | bike | vehicles | land | ( 4.189, -0.50, 0.35) | +| 10 | plane | vehicles | air | ( 4.189, 0.50, -0.35) | +| 11 | drone | vehicles | air | ( 4.189, 0.50, 0.35) | ### Gram matrix (analytic, four-tier hierarchy) -The CNOT-staircase MPS overlap is not a separable product over qubits -(unlike rung 0). Numerical contraction via -`compute_concept_gram_mps` produces this four-tier structure on the -off-diagonal of `||²`: +The cross-coupled MPS overlap is not a separable product over qubits +(unlike rung 0 — and unlike the bare CNOT staircase, which despite +being entangled has a Gram identical to rung 0; see the design note +referenced above). Numerical contraction via `compute_concept_gram_mps` +produces this four-tier structure on the off-diagonal of +`||²`: | tier | members | analytic ||² | |----------------------------|--------------------------------------|----------------------------| | self | i = j (12 entries) | 1.000 | | sub-cluster-mate | same α, same β, different γ (6 pairs)| 0.882 (uniform) | -| super-group-sibling | same α, different β (12 pairs) | 0.472 – 0.535 | -| cross-group | different α (48 pairs) | 0.118 – 0.250 | +| super-group-sibling | same α, different β (12 pairs) | 0.335, 0.593, 0.753 | +| cross-group | different α (48 pairs) | 0.000 – 0.178 | + +(The super-sibling tier resolves into three discrete values per +super-group block: 0.753 when the cross-coupling adds constructively, +0.593 when γ is matched, and 0.335 when the cross-coupling adds +destructively. All three sit strictly between sub-cluster-mate and +cross-group; the tier ordering is preserved with margin.) -Sketch (4-tier ASCII heatmap, `#` ≥ 0.7, `o` ∈ [0.3, 0.7), `.` ∈ [0.1, 0.3), -blank < 0.1): +Sketch (4-tier ASCII heatmap, `#` ≥ 0.7, `o` ∈ [0.3, 0.7), `.` ∈ [0.05, 0.3), +blank < 0.05): ``` - dog cat rob eag | str blu man pap | car bik pla drn -dog [ # # o o | . . . . | . . . . ] -cat [ # # o o | . . . . | . . . . ] -rob [ o o # # | . . . . | . . . . ] -eag [ o o # # | . . . . | . . . . ] -str [ . . . . | # # o o | . . . . ] -blu [ . . . . | # # o o | . . . . ] -man [ . . . . | o o # # | . . . . ] -pap [ . . . . | o o # # | . . . . ] -car [ . . . . | . . . . | # # o o ] -bik [ . . . . | . . . . | # # o o ] -pla [ . . . . | . . . . | o o # # ] -drn [ . . . . | . . . . | o o # # ] + dog cat rob eag | str blu man pap | car bik pla drn +dog [ # # o o | . . | . . . . ] +cat [ # # # o | . . | . . . . ] +rob [ o # # # | . . . . | . . ] +eag [ o o # # | . . . . | . . ] +str [ . . . . | # # o o | . . ] +blu [ . . . . | # # # o | . . ] +man [ . . | o # # # | . . . . ] +pap [ . . | o o # # | . . . . ] +car [ . . | . . . . | # # o o ] +bik [ . . | . . . . | # # # o ] +pla [ . . . . | . . | o # # # ] +drn [ . . . . | . . | o o # # ] ``` -Each 4×4 super-group diagonal block resolves into two 2×2 sub-cluster -blocks (the `#` regions, sub-cluster-mates) sandwiched against the -2×2 super-group siblings (the `o` regions). Inter-group blocks fall -into the cross-group tier (`.` regions). `compute_concept_gram_mps` in -the compiler package produces this matrix exactly for this machine. +Each 4×4 super-group diagonal block resolves into a 2×2 sub-cluster +core (the `#` regions, sub-cluster-mates and the cross-coupling- +constructive super-sibling) and a banded super-sibling tail (the `o` +regions). Inter-group blocks fall into the cross-group tier (`.` +and blank regions). `compute_concept_gram_mps` in the compiler package +produces this matrix exactly for this machine. ## Loaded feature |f> = |dog> (single-concept load) @@ -106,23 +131,24 @@ exposes the four-tier structure in the clearest form: |------------------|-----------|--------------|-------------------|--------------------| | dog (0) | animals | mammals | 1.000 | self | | cat (1) | animals | mammals | 0.882 | sub-cluster-mate | -| robin (2) | animals | birds | 0.535 | super-group-sib | -| eagle (3) | animals | birds | 0.472 | super-group-sib | -| strawberry (4) | fruits | berries | 0.250 | cross-group | -| blueberry (5) | fruits | berries | 0.221 | cross-group | -| mango (6) | fruits | tropical | 0.134 | cross-group | -| papaya (7) | fruits | tropical | 0.118 | cross-group | -| car (8) | vehicles | land | 0.250 | cross-group | -| bike (9) | vehicles | land | 0.221 | cross-group | -| plane (10) | vehicles | air | 0.134 | cross-group | -| drone (11) | vehicles | air | 0.118 | cross-group | - -Four ordered tiers — **1.0** (self) → **0.88** (sub-cluster-mate) → **~0.50** -(super-group sibling) → **~0.18** (cross-group). Compare against -`larql-polysemantic-clusters`'s flat 1.00 / 0.72 / ≲ 0.09 three-tier -structure: that example demonstrates the *block* polysemantic phenomenon -on the product manifold, this one lifts it to a *graded hierarchical* -phenomenon on the bond-2 MPS manifold. +| robin (2) | animals | birds | 0.593 | super-group-sib | +| eagle (3) | animals | birds | 0.335 | super-group-sib | +| strawberry (4) | fruits | berries | 0.063 | cross-group | +| blueberry (5) | fruits | berries | 0.055 | cross-group | +| mango (6) | fruits | tropical | 0.000 | cross-group | +| papaya (7) | fruits | tropical | 0.000 | cross-group | +| car (8) | vehicles | land | 0.063 | cross-group | +| bike (9) | vehicles | land | 0.055 | cross-group | +| plane (10) | vehicles | air | 0.140 | cross-group | +| drone (11) | vehicles | air | 0.079 | cross-group | + +Four ordered tiers — **1.0** (self) → **0.88** (sub-cluster-mate) → +**0.34 – 0.59** (super-group sibling) → **≤ 0.14** (cross-group). +Compare against `larql-polysemantic-clusters`'s flat 1.00 / 0.72 / +≲ 0.09 three-tier structure: that example demonstrates the *block* +polysemantic phenomenon on the product manifold, this one lifts it to +a *graded hierarchical* phenomenon on the cross-coupled bond-2 MPS +manifold. ## Note on the multi-query circuit @@ -159,7 +185,7 @@ The companion demo `demos/larql_polysemantic_hierarchical/demo.py` runs > 3-qubit concept register in `|000>`. No feature has been prepared. ## state feature_loaded -> `|f> = |dog>` prepared via `prepare_concept(0.0, -0.75, -0.35)`. +> `|f> = |dog>` prepared via `prepare_concept(0.0, -0.5, -0.35)`. ## state queried_dog > `query_concept(dog)` applied — self-query. `P(|000>) = 1.000`. @@ -168,34 +194,34 @@ The companion demo `demos/larql_polysemantic_hierarchical/demo.py` runs > `query_concept(cat)` applied — sub-cluster-mate (mammals). `P(|000>) ≈ 0.882`. ## state queried_robin -> `query_concept(robin)` applied — super-group sibling (animals/birds). `P(|000>) ≈ 0.535`. +> `query_concept(robin)` applied — super-group sibling (animals/birds). `P(|000>) ≈ 0.593`. ## state queried_eagle -> `query_concept(eagle)` applied — super-group sibling (animals/birds). `P(|000>) ≈ 0.472`. +> `query_concept(eagle)` applied — super-group sibling (animals/birds). `P(|000>) ≈ 0.335`. ## state queried_strawberry -> `query_concept(strawberry)` applied — cross-group (fruits). `P(|000>) ≈ 0.250`. +> `query_concept(strawberry)` applied — cross-group (fruits). `P(|000>) ≈ 0.063`. ## state queried_blueberry -> `query_concept(blueberry)` applied — cross-group (fruits). `P(|000>) ≈ 0.221`. +> `query_concept(blueberry)` applied — cross-group (fruits). `P(|000>) ≈ 0.055`. ## state queried_mango -> `query_concept(mango)` applied — cross-group (fruits). `P(|000>) ≈ 0.134`. +> `query_concept(mango)` applied — cross-group (fruits). `P(|000>) ≈ 0.000`. ## state queried_papaya -> `query_concept(papaya)` applied — cross-group (fruits). `P(|000>) ≈ 0.118`. +> `query_concept(papaya)` applied — cross-group (fruits). `P(|000>) ≈ 0.000`. ## state queried_car -> `query_concept(car)` applied — cross-group (vehicles). `P(|000>) ≈ 0.250`. +> `query_concept(car)` applied — cross-group (vehicles). `P(|000>) ≈ 0.063`. ## state queried_bike -> `query_concept(bike)` applied — cross-group (vehicles). `P(|000>) ≈ 0.221`. +> `query_concept(bike)` applied — cross-group (vehicles). `P(|000>) ≈ 0.055`. ## state queried_plane -> `query_concept(plane)` applied — cross-group (vehicles). `P(|000>) ≈ 0.134`. +> `query_concept(plane)` applied — cross-group (vehicles). `P(|000>) ≈ 0.140`. ## state queried_drone -> `query_concept(drone)` applied — cross-group (vehicles). `P(|000>) ≈ 0.118`. +> `query_concept(drone)` applied — cross-group (vehicles). `P(|000>) ≈ 0.079`. ## state done [final] > Measurement collapsed the 3-qubit register to a classical bitstring. @@ -203,19 +229,19 @@ The companion demo `demos/larql_polysemantic_hierarchical/demo.py` runs ## transitions | Source | Event | Guard | Target | Action | |--------------------|-------------------|-------|-----------------------|----------------------------------------------| -| idle | load_feature | | feature_loaded | prepare_concept(0.0, -0.75, -0.35) | -| feature_loaded | query_dog | | queried_dog | query_concept(0.0, -0.75, -0.35) | -| feature_loaded | query_cat | | queried_cat | query_concept(0.0, -0.75, 0.35) | -| feature_loaded | query_robin | | queried_robin | query_concept(0.0, 0.75, -0.35) | -| feature_loaded | query_eagle | | queried_eagle | query_concept(0.0, 0.75, 0.35) | -| feature_loaded | query_strawberry | | queried_strawberry | query_concept(2.094, -0.75, -0.35) | -| feature_loaded | query_blueberry | | queried_blueberry | query_concept(2.094, -0.75, 0.35) | -| feature_loaded | query_mango | | queried_mango | query_concept(2.094, 0.75, -0.35) | -| feature_loaded | query_papaya | | queried_papaya | query_concept(2.094, 0.75, 0.35) | -| feature_loaded | query_car | | queried_car | query_concept(4.189, -0.75, -0.35) | -| feature_loaded | query_bike | | queried_bike | query_concept(4.189, -0.75, 0.35) | -| feature_loaded | query_plane | | queried_plane | query_concept(4.189, 0.75, -0.35) | -| feature_loaded | query_drone | | queried_drone | query_concept(4.189, 0.75, 0.35) | +| idle | load_feature | | feature_loaded | prepare_concept(0.0, -0.5, -0.35) | +| feature_loaded | query_dog | | queried_dog | query_concept(0.0, -0.5, -0.35) | +| feature_loaded | query_cat | | queried_cat | query_concept(0.0, -0.5, 0.35) | +| feature_loaded | query_robin | | queried_robin | query_concept(0.0, 0.5, -0.35) | +| feature_loaded | query_eagle | | queried_eagle | query_concept(0.0, 0.5, 0.35) | +| feature_loaded | query_strawberry | | queried_strawberry | query_concept(2.094, -0.5, -0.35) | +| feature_loaded | query_blueberry | | queried_blueberry | query_concept(2.094, -0.5, 0.35) | +| feature_loaded | query_mango | | queried_mango | query_concept(2.094, 0.5, -0.35) | +| feature_loaded | query_papaya | | queried_papaya | query_concept(2.094, 0.5, 0.35) | +| feature_loaded | query_car | | queried_car | query_concept(4.189, -0.5, -0.35) | +| feature_loaded | query_bike | | queried_bike | query_concept(4.189, -0.5, 0.35) | +| feature_loaded | query_plane | | queried_plane | query_concept(4.189, 0.5, -0.35) | +| feature_loaded | query_drone | | queried_drone | query_concept(4.189, 0.5, 0.35) | | queried_dog | measure_done | | done | | | queried_cat | measure_done | | done | | | queried_robin | measure_done | | done | | @@ -230,14 +256,15 @@ The companion demo `demos/larql_polysemantic_hierarchical/demo.py` runs | queried_drone | measure_done | | done | | ## actions -| Name | Signature | Effect | -|-----------------|------------------------------------------|-----------------------------------------------------------------------------------------| -| prepare_concept | (qs, a: angle, b: angle, c: angle) -> qs | Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], b); CNOT(qs[1], qs[2]); Ry(qs[2], c) | -| query_concept | (qs, a: angle, b: angle, c: angle) -> qs | Ry(qs[2], -c); CNOT(qs[1], qs[2]); Ry(qs[1], -b); CNOT(qs[0], qs[1]); Ry(qs[0], -a) | +| Name | Signature | Effect | +|-----------------|------------------------------------------|-----------------------------------------------------------------------------------------------------| +| prepare_concept | (qs, a: angle, b: angle, c: angle) -> qs | Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], a + b); CNOT(qs[1], qs[2]); Ry(qs[2], b + c) | +| query_concept | (qs, a: angle, b: angle, c: angle) -> qs | Ry(qs[2], -b - c); CNOT(qs[1], qs[2]); Ry(qs[1], -a - b); CNOT(qs[0], qs[1]); Ry(qs[0], -a) | ## verification rules - unitarity: Ry and CNOT preserve norm; every transition evolves the 3-qubit register unitarily from `|000>` -- mps_bond_2_encoding: each concept is prepared as a bond-dimension-2 MPS via the `Ry; CNOT; Ry; CNOT; Ry` staircase — the minimal entangling pattern beyond rung 0 -- hierarchical_overlap: four ordered tiers — self 1.000 / sub-cluster-mate 0.882 / super-group-sibling [0.472, 0.535] / cross-group [0.118, 0.250] — with strict ordering across tier boundaries +- mps_bond_2_cross_coupled_encoding: each concept is prepared as a bond-dimension-2 MPS via the `Ry; CNOT; Ry; CNOT; Ry` staircase with cross-coupled angle expressions (`a`, `a + b`, `b + c`) — the linear-combination angles produce a non-factorized Gram, distinguishing this rung from the bare staircase (whose Gram factorizes despite Schmidt rank 2) +- hierarchical_overlap: four ordered tiers — self 1.000 / sub-cluster-mate 0.882 / super-group-sibling {0.335, 0.593, 0.753} / cross-group [0.000, 0.178] — with strict ordering across tier boundaries +- non_factorized_gram: the encoding's `||²` differs by ≥ 0.05 on at least one off-diagonal entry from the same-angle product-state Gram `∏_k cos((θ_{i,k} − θ_{j,k})/2)²` — the cross-coupling makes the Gram structurally distinct from rung 0 - no_cloning: the prepared feature is not duplicated; each query needs a fresh prepare+query sequence (the demo runs 12 independent circuits) -- measurement_collapse_allowed: `done` is the intended collapse sink — each branch ends in measurement; analytic `P(|000>)` per query (1.000 self, 0.882 sub-mate, 0.47–0.54 super-sib, 0.12–0.25 cross) is documented in the polysemy-scores table above +- measurement_collapse_allowed: `done` is the intended collapse sink — each branch ends in measurement; analytic `P(|000>)` per query (1.000 self, 0.882 sub-mate, 0.34–0.59 super-sib, ≤ 0.14 cross) is documented in the polysemy-scores table above diff --git a/openspec/changes/add-mps-concept-encoding/design.md b/openspec/changes/add-mps-concept-encoding/design.md index c643d56..0146949 100644 --- a/openspec/changes/add-mps-concept-encoding/design.md +++ b/openspec/changes/add-mps-concept-encoding/design.md @@ -196,3 +196,12 @@ math between demo and test. keeps the helper simple and the error mode unambiguous. A more permissive detector is future work once a second MPS topology (brick-wall, tree) is shipped. + +## Post-mortem (added 2026-05-01) + +The staircase encoding shipped in this change has Schmidt rank 2 +but its Gram still factorizes as +⟨c_i | c_j⟩ = ∏_k cos((θ_{i,k} − θ_{j,k})/2). The four-tier +signature emerges from the angle design, not the entanglement. +The example, helper, demo, and docs are corrected in the +follow-up change `fix-mps-encoding-non-factorizing`. diff --git a/openspec/changes/fix-mps-encoding-non-factorizing/tasks.md b/openspec/changes/fix-mps-encoding-non-factorizing/tasks.md index c7594f4..95599af 100644 --- a/openspec/changes/fix-mps-encoding-non-factorizing/tasks.md +++ b/openspec/changes/fix-mps-encoding-non-factorizing/tasks.md @@ -2,7 +2,7 @@ ## 1. Re-derive the encoding and tier bands -- [ ] 1.1 Pin the new encoding shape to **Alternative A** +- [x] 1.1 Pin the new encoding shape to **Alternative A** (cross-coupled-by-sum), as recommended in design.md: prepare_concept(a, b, c): @@ -18,7 +18,7 @@ per concept (12 concepts), with `gram_prod` the product-state `Ry(q0,a)·Ry(q1,b)·Ry(q2,c)·|000⟩` Gram. Expected diff ≈ 0.32. -- [ ] 1.2 Run an angle-design pass for 12 concepts under the new +- [x] 1.2 Run an angle-design pass for 12 concepts under the new encoding, targeting four ordered tiers with strict inter-tier separation. Starting point: the archived design's α ∈ {0, 2π/3, 4π/3}, β ∈ {-0.75, +0.75}, γ ∈ {-0.35, +0.35}; sweep @@ -27,34 +27,34 @@ cross-group band are all non-overlapping with ≥ 0.05 gap between adjacent bands. -- [ ] 1.3 Record the achieved per-tier bands (exact numerical +- [x] 1.3 Record the achieved per-tier bands (exact numerical values from the angle-design pass) here, so downstream tasks can re-pin against them. -- [ ] 1.4 Re-derive the polysemy column for `|f⟩ = |c_0⟩` (the +- [x] 1.4 Re-derive the polysemy column for `|f⟩ = |c_0⟩` (the "dog" loaded feature) under the new encoding. Verify that all four tiers still appear in the single column. ## 2. Compiler helper update (option 2a) -- [ ] 2.1 In `q_orca/compiler/concept_gram_mps.py`, generalize the +- [x] 2.1 In `q_orca/compiler/concept_gram_mps.py`, generalize the Ry-segment matcher from a single-bound-param shape to a linear-combination shape. Capture the angle expression as a string and parse it into a list of `(coefficient, parameter_name)` pairs. Whitespace-tolerant `+`, `-`, identifier tokens, optional integer/float coefficients. -- [ ] 2.2 At call-site evaluation, substitute the bound argument +- [x] 2.2 At call-site evaluation, substitute the bound argument values into the parsed linear combination to produce the float angle that the staircase walker passes to `_apply_1q`. -- [ ] 2.3 Add a new error kind to `MpsGramConfigurationError`: +- [x] 2.3 Add a new error kind to `MpsGramConfigurationError`: `unrecognized_angle_expression`, raised when the angle expression is neither a bound parameter nor a linear combination of them. Message names the offending expression, the action, the machine, and lists the supported shapes. -- [ ] 2.4 Verify that the strict single-bound-param staircase +- [x] 2.4 Verify that the strict single-bound-param staircase (the archived rung-1 shape) still parses successfully under the generalized matcher. The single-bound-param case is just the linear combination `1·α` with one term — the generalized @@ -62,14 +62,14 @@ ## 3. Update the example file -- [ ] 3.1 Rewrite the `prepare_concept` and `query_concept` effect +- [x] 3.1 Rewrite the `prepare_concept` and `query_concept` effect strings in `examples/larql-polysemantic-hierarchical.q.orca.md` to match the new encoding. The query effect remains the exact inverse of the prepare effect (gate order reversed, angle signs negated, CNOTs self-inverse — so the `b + c` of prep becomes `−(b + c) = −b − c` on the q2 segment, etc.). -- [ ] 3.2 Re-pin the leading paragraph: drop the false claim that +- [x] 3.2 Re-pin the leading paragraph: drop the false claim that the rung-1 staircase produces non-factorized overlap "by virtue of being entangled". Replace with the corrected framing: the staircase is *necessary* (gives bond-2 MPS @@ -78,26 +78,26 @@ the four-tier hierarchy framing; only the *mechanism* explanation changes. -- [ ] 3.3 Re-pin the analytic per-tier band table to the bands +- [x] 3.3 Re-pin the analytic per-tier band table to the bands from task 1.3. -- [ ] 3.4 Re-pin the ASCII Gram heatmap to the new bands. The +- [x] 3.4 Re-pin the ASCII Gram heatmap to the new bands. The heatmap's four-tier visual structure is preserved; only the cell values shift slightly. -- [ ] 3.5 Re-pin the polysemy column table for `|f⟩ = |dog⟩` +- [x] 3.5 Re-pin the polysemy column table for `|f⟩ = |dog⟩` from task 1.4. -- [ ] 3.6 Adjust the 12 concept (α, β, γ) triples if task 1.2's +- [x] 3.6 Adjust the 12 concept (α, β, γ) triples if task 1.2's angle pass selected a tweaked design. Otherwise leave them as the archived values. ## 4. Update the demo -- [ ] 4.1 In `demos/larql_polysemantic_hierarchical/demo.py`, +- [x] 4.1 In `demos/larql_polysemantic_hierarchical/demo.py`, re-pin the §3 analytic Gram heatmap to the new bands. -- [ ] 4.2 In §6 (rung-0 vs. rung-1 recap), reword the narrative. +- [x] 4.2 In §6 (rung-0 vs. rung-1 recap), reword the narrative. Drop "the CNOT staircase entangles adjacent qubits, lifting the block diagonal of rung-0 into a graded two-level hierarchy" — this was true of the new encoding but false of @@ -105,49 +105,49 @@ cross-coupled angle structure, with the bond-2 register as a *prerequisite* for the structure but not its sole cause. -- [ ] 4.3 If the demo prints the analytic vs. simulated Gram +- [x] 4.3 If the demo prints the analytic vs. simulated Gram side-by-side, verify the simulated values match the new analytic values within shot-noise tolerance (default 1024 shots). ## 5. Update documentation -- [ ] 5.1 In `README.md`, find the "Hierarchical polysemy" section +- [x] 5.1 In `README.md`, find the "Hierarchical polysemy" section (~lines 591-606) and reword to accurately describe the new encoding. Explicitly note that the particular Ry-CNOT-Ry-CNOT-Ry-on-vacuum staircase factorizes — this is the surprising mathematical fact this change documents. -- [ ] 5.2 Append a short post-mortem section (~10 lines) at the +- [x] 5.2 Append a short post-mortem section (~10 lines) at the end of `openspec/changes/add-mps-concept-encoding/design.md` pointing forward to this change. Do **not** rewrite the archived design — the post-mortem is the only edit to archived content. Exact text in this change's design.md §"Post-mortem entry in archived design". -- [ ] 5.3 In `docs/research/polysemantic-encoding-beyond-product- +- [x] 5.3 In `docs/research/polysemantic-encoding-beyond-product- states.md`, insert a "Caveat: Schmidt rank > 1 ≠ non- factorized overlap" box near the factorization formula (lines 124-128). Cite the bare Ry-CNOT staircase as the canonical counter-example. -- [ ] 5.4 Update the rung-1 entry in the same research note to +- [x] 5.4 Update the rung-1 entry in the same research note to reflect that *some* bond-2 encodings still factorize, and that the rung-1 example uses a cross-coupled variant. ## 6. Tests -- [ ] 6.1 Re-pin +- [x] 6.1 Re-pin `tests/test_examples.py::test_larql_polysemantic_hierarchical_pipeline` tier-band assertions to the bands from task 1.3. -- [ ] 6.2 Add a non-factorization assertion to the same test: +- [x] 6.2 Add a non-factorization assertion to the same test: compute the same-angle product-state Gram and assert that `max | |gram_mps|² − |gram_prod|² | ≥ 0.05` on at least one off-diagonal entry. This catches a future encoding regression that silently re-introduces factorization. -- [ ] 6.3 Extend `tests/test_compiler.py::TestComputeConceptGramMps` +- [x] 6.3 Extend `tests/test_compiler.py::TestComputeConceptGramMps` with happy-path tests for the generalized angle-expression input: a cross-coupled effect string, a brick-pattern-ish effect string (if option 2a is general enough — otherwise @@ -155,19 +155,19 @@ (parses as a 1-term linear combination, must produce the same Gram as before). -- [ ] 6.4 Add a sad-path test for the new +- [x] 6.4 Add a sad-path test for the new `unrecognized_angle_expression` error: an effect with a non-linear angle expression like `Ry(qs[1], a * b)` or `Ry(qs[1], sin(a))` — must raise `MpsGramConfigurationError` with the new error kind, naming the offending expression. -- [ ] 6.5 Run the full test suite — `tests/test_examples.py`, +- [x] 6.5 Run the full test suite — `tests/test_examples.py`, `tests/test_compiler.py`, and the CLI integration tests — and verify everything passes. ## 7. Tech-debt cross-reference -- [ ] 7.1 In `openspec/changes/tech-debt-backlog/tasks.md`, mark +- [x] 7.1 In `openspec/changes/tech-debt-backlog/tasks.md`, mark §5.6 (verifier blind spot: exhaustive syndrome coverage) with a cross-link to a new entry §5.7 added by this change: @@ -182,11 +182,11 @@ ## 8. OpenSpec validation -- [ ] 8.1 Run `openspec validate fix-mps-encoding-non-factorizing +- [x] 8.1 Run `openspec validate fix-mps-encoding-non-factorizing --strict` and resolve any issues. The change set has two MODIFIED capability deltas (`language` and `compiler`) and no ADDED or REMOVED requirements. -- [ ] 8.2 Run `openspec list` and verify the change appears as +- [x] 8.2 Run `openspec list` and verify the change appears as pending. After the change ships and is archived (post- merge), it moves to `openspec/changes/archive/`. diff --git a/openspec/changes/tech-debt-backlog/tasks.md b/openspec/changes/tech-debt-backlog/tasks.md index 1ac7299..7443c19 100644 --- a/openspec/changes/tech-debt-backlog/tasks.md +++ b/openspec/changes/tech-debt-backlog/tasks.md @@ -580,6 +580,34 @@ remaining findings. explicitly partial (annotated by the user). Pairs naturally with the compound-condition parser work in §5.1. (Source: 2026-05-01 example library QA, bug 2 root cause.) + See §5.7 for an analogous "structural property the verifier + cannot see" gap on the quantum-encoding side. + +- [ ] 5.7 **Verifier blind spot — Gram factorization vs. encoding + entanglement.** A future verifier rule should flag any + hierarchical-polysemantic encoding whose Gram matches the same- + angle product-state Gram + (`gram[i,j] == ∏_k cos((θ_{i,k} − θ_{j,k})/2)`), so the class of + bug behind `fix-mps-encoding-non-factorizing` (a CNOT-staircase + encoding with Schmidt rank 2 but a Gram identical to rung 0's + product-state Gram) is caught at verify time rather than at + example-library QA time. Spec sketch: add a rule + `gram_factorization_distinct_from_product_state` (gated on a + machine declaring an `mps_bond_2_*_encoding` verification rule + or a sibling marker) that builds the per-call-site product-state + Gram and asserts at least one off-diagonal entry differs by ≥ 0.05 + in `|gram|²`. Companion to §5.6: both are cases where a structural + property the user cares about isn't directly observable in the + AST and has to be derived from running the encoding's contraction + side-by-side with a reference. Motivating incident: + `add-mps-concept-encoding` shipped a four-tier example whose + Gram silently factorized as the rung-0 product-state map; the + fix is in `openspec/changes/fix-mps-encoding-non-factorizing/` + (and that change's `design.md` post-mortem section). Gated behind + a separate proposal once a second concrete example exists where + the factorization status is non-obvious — out of scope for the + immediate fix. + (Source: 2026-05-01 `fix-mps-encoding-non-factorizing` post-mortem.) ## 6. How to use this file diff --git a/q_orca/angle.py b/q_orca/angle.py index e77e737..ce009fc 100644 --- a/q_orca/angle.py +++ b/q_orca/angle.py @@ -26,16 +26,28 @@ def evaluate_angle( - name*pi or pi*name: gamma*pi, pi*gamma A leading minus is permitted on every form. + Linear combinations of any of the above are also accepted at the top level + using ``+`` and ``-`` operators (e.g., ``a + b``, ``-a - b``, + ``2*pi + gamma``). Terms are evaluated recursively and summed. + Literal forms are tried first, so a context field literally named ``pi`` cannot shadow the ``pi`` literal. Raises ValueError for anything else. """ text = text.strip() + + # Linear combination: split on top-level + / - and sum the terms. + terms = _split_linear_combination(text) + if terms is not None: + return sum(evaluate_angle(term, context) for term in terms) + sign = 1 if text.startswith("-"): sign = -1 text = text[1:].strip() + elif text.startswith("+"): + text = text[1:].strip() # Decimal literal try: @@ -110,3 +122,43 @@ def evaluate_angle( else "." ) ) + + +def _split_linear_combination(text: str) -> Optional[list[str]]: + """Split a linear combination on top-level ``+`` / ``-`` operators. + + Returns a list of signed term strings (e.g., ``"a + b"`` -> ``["a", "+b"]``, + ``"-a - b"`` -> ``["-a", "-b"]``), or ``None`` if no top-level split was + found. The leading sign of the first term is preserved. + """ + if not text: + return None + + terms: list[str] = [] + paren_depth = 0 + start = 0 + # Skip a leading sign so we don't split on it. + i = 1 if text[0] in "+-" else 0 + while i < len(text): + c = text[i] + if c == "(": + paren_depth += 1 + elif c == ")": + paren_depth -= 1 + elif paren_depth == 0 and c in "+-": + # Distinguish a top-level operator from a unary sign attached + # to a preceding operator (e.g., ``2*-a`` shouldn't split here). + j = i - 1 + while j >= 0 and text[j] == " ": + j -= 1 + if j >= 0 and text[j] not in "+-*/(": + terms.append(text[start:i].strip()) + start = i # include the sign as part of the next term + i += 1 + continue + i += 1 + + if not terms: + return None + terms.append(text[start:].strip()) + return terms diff --git a/q_orca/compiler/concept_gram_mps.py b/q_orca/compiler/concept_gram_mps.py index f8af135..c67417c 100644 --- a/q_orca/compiler/concept_gram_mps.py +++ b/q_orca/compiler/concept_gram_mps.py @@ -2,11 +2,12 @@ Optional analysis utility for machines that follow the *MPS (matrix product state) hierarchical polysemantic* concept-encoding convention -documented in ``add-mps-concept-encoding``. The CNOT-staircase -preparation lifts the rung-0 product-state ansatz from -``compute_concept_gram`` to a bond-2 entangled family that admits a -*four*-tier Gram structure (self / sub-cluster / super-group / -cross-group), one tier richer than the product-state helper. +documented in ``add-mps-concept-encoding`` (and corrected in +``fix-mps-encoding-non-factorizing``). The CNOT-staircase preparation +lifts the rung-0 product-state ansatz from ``compute_concept_gram`` to +a bond-2 entangled family that admits a *four*-tier Gram structure +(self / sub-cluster / super-group / cross-group), one tier richer than +the product-state helper. Convention assumed by this helper: @@ -15,17 +16,25 @@ -> qs`` — exactly ``n`` angle parameters where ``n`` matches the size of the ``qubits`` register declared in ``## context``. 2. A CNOT-staircase effect, either the preparation form - ``Ry(qs[0], p_0); CNOT(qs[0], qs[1]); Ry(qs[1], p_1); - CNOT(qs[1], qs[2]); ... Ry(qs[n-1], p_{n-1})`` or the inverse - form (gate order reversed, angle signs negated, CNOTs self- - inverse). The default ``concept_action_label="query_concept"`` - targets the inverse (query) form used by the canonical example. + ``Ry(qs[0], ); CNOT(qs[0], qs[1]); Ry(qs[1], ); + CNOT(qs[1], qs[2]); ... Ry(qs[n-1], )`` or the inverse + form (Ry order reversed, expressions negated, CNOTs self-inverse so + they reappear in reversed position). Each ```` SHALL be a + *linear combination of the action's bound angle parameters* — i.e., + a sum of terms each of the form ``c · p`` where ``c`` is an + optional numeric coefficient (defaulting to 1) and ``p`` is one of + the action's angle parameter names. A single bound parameter + (``Ry(qs[0], a)``) is the degenerate one-term linear combination + ``1·a`` and is accepted. The default + ``concept_action_label="query_concept"`` targets the inverse (query) + form used by the canonical example. 3. ``N >= 1`` call sites to that action in the transitions table, each with a literal ``n``-tuple of angle arguments. Given such a machine, ``compute_concept_gram_mps`` enumerates the -call sites in declaration order, builds the statevector -``|c_i> = |0^n>`` for each, and +call sites in declaration order, evaluates each Ry's linear-combination +angle expression at the call site's bound argument values, builds the +statevector ``|c_i> = |0^n>``, and returns the ``N x N`` matrix with ``gram[i, j] = ``. The helper is **not** on the main compile / verify / simulate path; @@ -43,6 +52,7 @@ from __future__ import annotations +import ast import re from typing import TYPE_CHECKING @@ -54,13 +64,27 @@ class MpsGramConfigurationError(ValueError): - """Raised when a machine doesn't meet the MPS concept-gram convention.""" + """Raised when a machine doesn't meet the MPS concept-gram convention. + The ``kind`` attribute (when set) classifies the failure mode: -# A single Ry segment, e.g. `Ry(qs[0], a)` or `Ry(qs[2], -c)`. + - ``unrecognized_angle_expression``: a Ry segment's angle expression + is not a linear combination of the action's bound angle parameters + (e.g., ``a * b``, ``sin(a)``, ``a^2``, or a bare numeric literal). + """ + + def __init__(self, message: str, *, kind: str | None = None): + super().__init__(message) + self.kind = kind + + +# A single Ry segment, e.g. `Ry(qs[0], a)`, `Ry(qs[2], -c)`, or +# `Ry(qs[1], a + b)`. The angle expression is captured as a string and +# parsed separately as a linear combination of the action's bound +# angle parameters (see `_parse_linear_combination`). _RY_SEGMENT_RE = re.compile( r"^\s*Ry\s*\(\s*qs\[\s*(?P\d+)\s*\]\s*,\s*" - r"(?P-?)\s*(?P[A-Za-z_][A-Za-z0-9_]*)\s*\)\s*$" + r"(?P.+?)\s*\)\s*$" ) # A single CNOT segment, e.g. `CNOT(qs[0], qs[1])`. @@ -70,6 +94,89 @@ class MpsGramConfigurationError(ValueError): ) +# Sentinel returned by `_parse_linear_combination` for non-linear +# expressions; the caller raises `MpsGramConfigurationError` with kind +# `unrecognized_angle_expression`. +class _NonLinearExpr(Exception): + pass + + +def _parse_linear_combination( + expr: str, param_names: list[str] +) -> dict[str, float]: + """Parse ``expr`` as a linear combination of ``param_names``. + + Returns a ``{param_name: coefficient}`` mapping; parameters not + referenced by the expression are absent from the mapping. + + Raises ``_NonLinearExpr`` if the expression is not a linear + combination — e.g., contains products of two parameters + (``a * b``), function calls (``sin(a)``), exponentiation (``a**2``), + or bare numeric literals with no parameter reference. The caller + converts this into ``MpsGramConfigurationError`` with kind + ``unrecognized_angle_expression``. + """ + try: + tree = ast.parse(expr, mode="eval").body + except SyntaxError as e: # pragma: no cover - defensive + raise _NonLinearExpr(f"syntax error: {e}") from e + + coeffs: dict[str, float] = {} + + def add(name: str, value: float) -> None: + if name not in param_names: + raise _NonLinearExpr( + f"unknown identifier {name!r}; expected one of " + f"{param_names}" + ) + coeffs[name] = coeffs.get(name, 0.0) + value + + def walk(node: ast.AST, sign: float) -> None: + if isinstance(node, ast.Name): + add(node.id, sign) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + walk(node.operand, -sign) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.UAdd): + walk(node.operand, sign) + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + walk(node.left, sign) + walk(node.right, sign) + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Sub): + walk(node.left, sign) + walk(node.right, -sign) + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mult): + left, right = node.left, node.right + if ( + isinstance(left, ast.Constant) + and isinstance(left.value, (int, float)) + and isinstance(right, ast.Name) + ): + add(right.id, sign * float(left.value)) + elif ( + isinstance(right, ast.Constant) + and isinstance(right.value, (int, float)) + and isinstance(left, ast.Name) + ): + add(left.id, sign * float(right.value)) + else: + raise _NonLinearExpr( + "product of two non-constant terms is not a " + "linear combination" + ) + elif isinstance(node, ast.Constant): + raise _NonLinearExpr( + f"bare numeric literal {node.value!r} is not a linear " + f"combination of bound parameters" + ) + else: + raise _NonLinearExpr( + f"unsupported expression node {type(node).__name__}" + ) + + walk(tree, 1.0) + return coeffs + + def _find_concept_action( machine: QMachineDef, label: str ) -> QActionSignature: @@ -99,16 +206,27 @@ def _check_signature( def _parse_staircase_effect( machine: QMachineDef, action: QActionSignature, n_qubits: int -) -> bool: - """Parse a CNOT-staircase effect, returning ``is_inverse``. +) -> tuple[bool, list[dict[str, float]]]: + """Parse a CNOT-staircase effect. - Accepts either the preparation form - ``Ry(qs[0], p_0); CNOT(qs[0], qs[1]); ...; Ry(qs[n-1], p_{n-1})`` - or the inverse form - ``Ry(qs[n-1], -p_{n-1}); CNOT(qs[n-2], qs[n-1]); ...; - Ry(qs[0], -p_0)``. + Returns ``(is_inverse, ry_coeffs)`` where ``ry_coeffs[i]`` is the + parsed linear combination (``{param_name: coefficient}``) for the + Ry rotation acting on qubit ``i`` in *qubit-index order* (so + ``ry_coeffs[0]`` is the Ry on qs[0] regardless of which end of the + staircase the gate appears at). - Raises ``MpsGramConfigurationError`` on any deviation. + Accepts either the preparation form + ``Ry(qs[0], ); CNOT(qs[0], qs[1]); ...; Ry(qs[n-1], + )`` or the inverse form ``Ry(qs[n-1], ); + CNOT(qs[n-2], qs[n-1]); ...; Ry(qs[0], )``. Each ```` + SHALL be a linear combination of the action's bound angle parameters + (see `_parse_linear_combination`); the canonical inverse form uses + the negation of the prep form's expressions, but this helper does + not enforce a sign convention — it evaluates whatever linear + combination the user wrote. + + Raises ``MpsGramConfigurationError`` on any deviation from the + staircase shape or on a non-linear angle expression. """ effect = (action.effect or "").strip() segments = [s.strip() for s in effect.split(";") if s.strip()] @@ -122,11 +240,14 @@ def _parse_staircase_effect( f"{n_qubits - 1} adjacent CNOTs, alternating)" ) + param_names = [p.name for p in action.parameters] + # The staircase alternates Ry, CNOT, Ry, CNOT, ..., Ry. Both prep and # inverse forms preserve that alternation; they differ only in - # qubit/angle ordering and angle sign. - parsed_ry: list[tuple[int, str, str]] = [] # (qubit, sign, name) - parsed_cnot: list[tuple[int, int]] = [] # (control, target) + # qubit ordering. Each Ry's angle expression is parsed as a linear + # combination of the action's bound parameters. + parsed_ry: list[tuple[int, dict[str, float]]] = [] + parsed_cnot: list[tuple[int, int]] = [] for idx, seg in enumerate(segments): if idx % 2 == 0: m = _RY_SEGMENT_RE.match(seg) @@ -134,12 +255,26 @@ def _parse_staircase_effect( raise MpsGramConfigurationError( f"machine {machine.name!r}: action {action.name!r} " f"effect segment {seg!r} (position {idx}) is not of " - f"the form `Ry(qs[i], [-]name)`; the CNOT staircase " + f"the form `Ry(qs[i], )`; the CNOT staircase " f"requires Ry rotations at even positions" ) - parsed_ry.append( - (int(m.group("qubit")), m.group("sign"), m.group("name")) - ) + qubit = int(m.group("qubit")) + expr = m.group("expr") + try: + coeffs = _parse_linear_combination(expr, param_names) + except _NonLinearExpr as e: + raise MpsGramConfigurationError( + f"machine {machine.name!r}: action {action.name!r} " + f"Ry(qs[{qubit}], {expr}): angle expression " + f"{expr!r} is not a linear combination of the " + f"action's bound angle parameters {param_names} " + f"({e}). mps concept-gram accepts angle expressions " + f"of the form `c_0·p_0 + c_1·p_1 + ...` (sums of " + f"terms, each an optional numeric coefficient times " + f"a single bound parameter name)", + kind="unrecognized_angle_expression", + ) from e + parsed_ry.append((qubit, coeffs)) else: m = _CNOT_SEGMENT_RE.match(seg) if not m: @@ -151,37 +286,24 @@ def _parse_staircase_effect( ) parsed_cnot.append((int(m.group("control")), int(m.group("target")))) - # Sign uniformity (all positive = prep, all negative = inverse). - signs = {sign for _, sign, _ in parsed_ry} - if len(signs) > 1: - raise MpsGramConfigurationError( - f"machine {machine.name!r}: action {action.name!r} effect " - f"{effect!r} mixes positive and negated angle signs across " - f"the {n_qubits} Ry gates; mps concept-gram accepts the " - f"all-positive preparation form or the all-negated inverse " - f"form, not a mix" - ) - is_inverse = signs == {"-"} - - # Validate the qubit / CNOT pattern. - if is_inverse: - # Inverse form: Ry order is qs[n-1], qs[n-2], ..., qs[0]; - # CNOTs are (n-2, n-1), (n-3, n-2), ..., (0, 1). - expected_ry_qubits = list(range(n_qubits - 1, -1, -1)) + # Determine prep vs inverse from the qubit ordering of the Ry + # rotations (the one structural feature the two forms differ on + # that doesn't depend on user-chosen sign conventions). + actual_ry_qubits = [q for q, _ in parsed_ry] + prep_qubits = list(range(n_qubits)) + inverse_qubits = list(range(n_qubits - 1, -1, -1)) + if actual_ry_qubits == prep_qubits: + is_inverse = False + expected_cnots = [(k, k + 1) for k in range(n_qubits - 1)] + elif actual_ry_qubits == inverse_qubits: + is_inverse = True expected_cnots = [(k, k + 1) for k in range(n_qubits - 2, -1, -1)] else: - # Preparation form: Ry order is qs[0], qs[1], ..., qs[n-1]; - # CNOTs are (0, 1), (1, 2), ..., (n-2, n-1). - expected_ry_qubits = list(range(n_qubits)) - expected_cnots = [(k, k + 1) for k in range(n_qubits - 1)] - - actual_ry_qubits = [q for q, _, _ in parsed_ry] - if actual_ry_qubits != expected_ry_qubits: raise MpsGramConfigurationError( f"machine {machine.name!r}: action {action.name!r} effect " f"{effect!r} applies Ry on qubits {actual_ry_qubits}; the " - f"{'inverse' if is_inverse else 'preparation'} CNOT " - f"staircase requires Ry on qubits {expected_ry_qubits}" + f"CNOT staircase requires Ry on qubits {prep_qubits} " + f"(preparation form) or {inverse_qubits} (inverse form)" ) if parsed_cnot != expected_cnots: @@ -192,22 +314,14 @@ def _parse_staircase_effect( f"staircase requires adjacent CNOTs {expected_cnots}" ) - # Validate each Ry's angle param-name matches the declared parameter - # at its signature position (= the qubit index it acts on, since - # both forms enumerate qubits 0..n-1). - param_names = [p.name for p in action.parameters] - for qubit, _sign, name in parsed_ry: - if name != param_names[qubit]: - raise MpsGramConfigurationError( - f"machine {machine.name!r}: action {action.name!r} effect " - f"applies Ry on qs[{qubit}] with angle {name!r}, but " - f"signature position {qubit} declares parameter " - f"{param_names[qubit]!r}; mps concept-gram requires " - f"positional alignment between angle parameters and " - f"qubit indices" - ) + # Re-index the Ry coefficients by qubit so callers can look up + # ``ry_coeffs[k]`` for the rotation on ``qs[k]`` regardless of the + # gate-order in which it appeared. + ry_coeffs: list[dict[str, float]] = [None] * n_qubits # type: ignore[list-item] + for qubit, coeffs in parsed_ry: + ry_coeffs[qubit] = coeffs - return is_inverse + return is_inverse, ry_coeffs _CNOT = None # type: ignore[var-annotated] @@ -253,32 +367,53 @@ def _apply_cnot(np_module, state, control: int, target: int): return state -def _build_concept_state(np_module, n_qubits: int, angles: list[float], is_inverse: bool): - """Build |c_i> for a single call site under the given angle tuple. - - ``angles[k]`` is the literal value bound to the parameter at - signature position ``k`` (i.e., the angle on ``qs[k]`` under the - preparation form). The same tuple drives both forms — the inverse - form negates each angle on the way to the matrix. +def _evaluate_ry_angle( + coeffs: dict[str, float], param_names: list[str], bound: list[float] +) -> float: + """Substitute ``bound[i]`` for ``param_names[i]`` in the linear + combination ``coeffs`` and return the resulting float angle.""" + angle = 0.0 + for name, c in coeffs.items(): + idx = param_names.index(name) + angle += c * bound[idx] + return angle + + +def _build_concept_state( + np_module, + n_qubits: int, + bound: list[float], + is_inverse: bool, + param_names: list[str], + ry_coeffs: list[dict[str, float]], +): + """Build |c_i> for a single call site under the given bound-argument tuple. + + ``bound[k]`` is the literal value bound to the parameter at + signature position ``k``. Each Ry's float angle is computed by + evaluating ``ry_coeffs[k]`` (a ``{param_name: coefficient}`` + dictionary) under the ``bound`` substitution. """ state = np_module.zeros((2,) * n_qubits, dtype=complex) state[(0,) * n_qubits] = 1.0 if is_inverse: - # Inverse: Ry(qs[n-1], -p_{n-1}); CNOT(n-2, n-1); ...; - # Ry(qs[1], -p_1); CNOT(0, 1); Ry(qs[0], -p_0). + # Inverse: Ry(qs[n-1], ); CNOT(n-2, n-1); ...; + # Ry(qs[1], ); CNOT(0, 1); Ry(qs[0], ). for k in range(n_qubits - 1, -1, -1): + theta = _evaluate_ry_angle(ry_coeffs[k], param_names, bound) state = _apply_1q( - np_module, state, _ry_matrix(np_module, -angles[k]), k + np_module, state, _ry_matrix(np_module, theta), k ) if k > 0: state = _apply_cnot(np_module, state, k - 1, k) else: - # Prep: Ry(qs[0], p_0); CNOT(0, 1); Ry(qs[1], p_1); ...; - # CNOT(n-2, n-1); Ry(qs[n-1], p_{n-1}). + # Prep: Ry(qs[0], ); CNOT(0, 1); Ry(qs[1], ); ...; + # CNOT(n-2, n-1); Ry(qs[n-1], ). for k in range(n_qubits): + theta = _evaluate_ry_angle(ry_coeffs[k], param_names, bound) state = _apply_1q( - np_module, state, _ry_matrix(np_module, angles[k]), k + np_module, state, _ry_matrix(np_module, theta), k ) if k < n_qubits - 1: state = _apply_cnot(np_module, state, k, k + 1) @@ -345,7 +480,8 @@ def compute_concept_gram_mps( action = _find_concept_action(machine, concept_action_label) _check_signature(machine, action, n_qubits) - is_inverse = _parse_staircase_effect(machine, action, n_qubits) + is_inverse, ry_coeffs = _parse_staircase_effect(machine, action, n_qubits) + param_names = [p.name for p in action.parameters] call_sites = [ t for t in machine.transitions @@ -385,7 +521,10 @@ def compute_concept_gram_mps( # the research note `docs/research/polysemantic-encoding-beyond- # product-states.md` for the closed-form derivation. states = [ - _build_concept_state(np, n_qubits, angles[i].tolist(), is_inverse) + _build_concept_state( + np, n_qubits, angles[i].tolist(), is_inverse, + param_names, ry_coeffs, + ) for i in range(n_calls) ] flat_states = [s.reshape(-1) for s in states] diff --git a/tests/test_compiler.py b/tests/test_compiler.py index c24aeb1..2993515 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -1394,39 +1394,136 @@ def test_non_staircase_wrong_segment_kind_raises(self): assert "Hadamard(qs[1])" in message assert "CNOT(qs[i], qs[j])" in message - def test_mixed_signs_raises(self): - """A mix of positive and negative angle signs is neither a clean - prep nor a clean inverse — reject.""" - from q_orca import MpsGramConfigurationError, compute_concept_gram_mps + def test_cross_coupled_angles_happy_path(self): + """The canonical cross-coupled-by-sum encoding parses and produces a + Gram that differs measurably from the same-angle product-state Gram + (i.e., does NOT factorize).""" + import numpy as np + + from q_orca import compute_concept_gram_mps + + machine = self._make_machine( + "(qs, a: angle, b: angle, c: angle) -> qs", + "Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], a + b); " + "CNOT(qs[1], qs[2]); Ry(qs[2], b + c)", + ["0.0, -0.5, -0.35", "0.0, -0.5, 0.35", "0.0, 0.5, -0.35"], + action_name="prepare_concept", + ) + gram = compute_concept_gram_mps( + machine, concept_action_label="prepare_concept" + ) + assert gram.shape == (3, 3) + gsq = np.abs(gram) ** 2 + np.testing.assert_allclose(np.diag(gsq), np.ones(3), atol=1e-9) + + # Build the same-angle product-state Gram and confirm the cross- + # coupled variant is structurally distinct on at least one entry. + triples = [(0.0, -0.5, -0.35), (0.0, -0.5, 0.35), (0.0, 0.5, -0.35)] + prod = np.zeros((3, 3)) + for i, (ai, bi, ci) in enumerate(triples): + for j, (aj, bj, cj) in enumerate(triples): + prod[i, j] = ( + np.cos((ai - aj) / 2) + * np.cos((bi - bj) / 2) + * np.cos((ci - cj) / 2) + ) + diff = np.abs(gsq - prod ** 2) + np.fill_diagonal(diff, 0.0) + assert diff.max() >= 0.05 + + def test_single_bound_param_degenerate_linear_combination(self): + """The strict single-bound-param staircase (the archived shape) must + still parse — it is the degenerate one-term linear combination + ``1·p`` and the helper SHALL produce the same Gram it produced + before generalization.""" + import numpy as np + + from q_orca import compute_concept_gram_mps + + machine = self._make_machine( + "(qs, a: angle, b: angle, c: angle) -> qs", + self._staircase_prep(3), + ["0.1, 0.2, 0.3", "0.4, 0.5, 0.6"], + action_name="prepare_concept", + ) + gram = compute_concept_gram_mps( + machine, concept_action_label="prepare_concept" + ) + assert gram.shape == (2, 2) + np.testing.assert_allclose(np.abs(np.diag(gram)), np.ones(2), atol=1e-9) + # Bare staircase factorizes — the off-diagonal must equal the + # product-state Gram for this same set of angle triples. + triples = [(0.1, 0.2, 0.3), (0.4, 0.5, 0.6)] + ai, bi, ci = triples[0] + aj, bj, cj = triples[1] + expected = ( + np.cos((ai - aj) / 2) + * np.cos((bi - bj) / 2) + * np.cos((ci - cj) / 2) + ) + np.testing.assert_allclose(abs(gram[0, 1]), abs(expected), atol=1e-9) + + def test_swapped_param_position_accepted(self): + """A linear-combination Ry need not bind its `positionally aligned` + parameter — `Ry(qs[0], b)` is a valid one-term linear combination + and the helper accepts it (this was rejected pre-generalization).""" + from q_orca import compute_concept_gram_mps machine = self._make_machine( "(qs, a: angle, b: angle, c: angle) -> qs", - "Ry(qs[0], a); CNOT(qs[0], qs[1]); Ry(qs[1], -b); " + "Ry(qs[0], b); CNOT(qs[0], qs[1]); Ry(qs[1], a); " "CNOT(qs[1], qs[2]); Ry(qs[2], c)", ["0.1, 0.2, 0.3"], ) + gram = compute_concept_gram_mps(machine) + assert gram.shape == (1, 1) + + def test_unrecognized_angle_expression_product_raises(self): + """A non-linear angle expression (multiplication of two parameters) + must raise `unrecognized_angle_expression`. The parser's parametric- + template validator already rejects such expressions, so the helper + only sees them when a caller builds a machine programmatically; + this test mutates a parsed action's effect string to mimic that + bypass and exercises the helper's own structural error path.""" + from q_orca import MpsGramConfigurationError, compute_concept_gram_mps + + machine = self._make_machine( + "(qs, a: angle, b: angle, c: angle) -> qs", + self._staircase_inverse(3), + ["0.1, 0.2, 0.3"], + ) + action = next(a for a in machine.actions if a.name == "query_concept") + action.effect = ( + "Ry(qs[2], -c); CNOT(qs[1], qs[2]); Ry(qs[1], a * b); " + "CNOT(qs[0], qs[1]); Ry(qs[0], -a)" + ) with pytest.raises(MpsGramConfigurationError) as exc_info: compute_concept_gram_mps(machine) + assert exc_info.value.kind == "unrecognized_angle_expression" message = str(exc_info.value) - assert "mixes positive and negated" in message + assert "a * b" in message + assert "linear combination" in message - def test_param_position_mismatch_raises(self): - """Swapping which parameter drives which qubit is a numerically - wrong shape — reject.""" + def test_unrecognized_angle_expression_function_call_raises(self): + """A non-linear angle expression with a function call (e.g., + ``sin(a)``) is also rejected with `unrecognized_angle_expression`. + Same parser-bypass approach as the multiplication test.""" from q_orca import MpsGramConfigurationError, compute_concept_gram_mps machine = self._make_machine( "(qs, a: angle, b: angle, c: angle) -> qs", - # Position 0 should drive `a`, but here it drives `b`. - "Ry(qs[0], b); CNOT(qs[0], qs[1]); Ry(qs[1], a); " - "CNOT(qs[1], qs[2]); Ry(qs[2], c)", + self._staircase_inverse(3), ["0.1, 0.2, 0.3"], ) + action = next(a for a in machine.actions if a.name == "query_concept") + action.effect = ( + "Ry(qs[2], -c); CNOT(qs[1], qs[2]); Ry(qs[1], sin(a)); " + "CNOT(qs[0], qs[1]); Ry(qs[0], -a)" + ) with pytest.raises(MpsGramConfigurationError) as exc_info: compute_concept_gram_mps(machine) - message = str(exc_info.value) - assert "qs[0]" in message and "'b'" in message and "'a'" in message - assert "positional alignment" in message + assert exc_info.value.kind == "unrecognized_angle_expression" + assert "sin(a)" in str(exc_info.value) def test_unsupported_bond_dim_raises(self): from q_orca import MpsGramConfigurationError, compute_concept_gram_mps diff --git a/tests/test_examples.py b/tests/test_examples.py index e477fd6..2197198 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -316,32 +316,58 @@ def test_larql_polysemantic_hierarchical_pipeline(self): cr_min, cr_max = min(cross_pairs), max(cross_pairs) # Sub-cluster-mate tier: tight band around 0.882 (analytic - # cos²(0.35) for the γ-only difference). - assert 0.85 <= sub_min <= sub_max <= 0.90, ( - f"sub-cluster-mate tier outside [0.85, 0.90]: " + # cos²(0.35) for the γ-only difference under cross-coupled angles). + assert 0.87 <= sub_min <= sub_max <= 0.90, ( + f"sub-cluster-mate tier outside [0.87, 0.90]: " f"min={sub_min:.4f} max={sub_max:.4f}" ) - # Super-group-sibling tier: band around [0.47, 0.54]. - assert 0.45 <= sup_min <= sup_max <= 0.56, ( - f"super-group-sibling tier outside [0.45, 0.56]: " + # Super-group-sibling tier: three discrete values + # {0.335, 0.593, 0.753} per super-group block under cross-coupling. + assert 0.32 <= sup_min and sup_max <= 0.77, ( + f"super-group-sibling tier outside [0.32, 0.77]: " f"min={sup_min:.4f} max={sup_max:.4f}" ) - # Cross-group tier: wider band [0.11, 0.26]. Higher than rung-0 - # because cyclic α at 2π/3 spacing gives a cos²(π/3)=0.25 floor on - # cross-group α factor; β/γ alignment determines the rest. - assert 0.10 <= cr_min and cr_max <= 0.26, ( - f"cross-group tier outside [0.10, 0.26]: " - f"min={cr_min:.4f} max={cr_max:.4f}" + # Cross-group tier: range [0.000, 0.178] under cross-coupled + # encoding with cyclic α at 2π/3 spacing. + assert cr_max <= 0.20, ( + f"cross-group tier max above 0.20: max={cr_max:.4f}" ) - # Tier ordering: sub > super > cross (strict separation). - assert sub_min > sup_max + 0.20, ( + # Tier ordering: sub > super > cross (strict separation, ≥ 0.05 + # margin between adjacent bands per the spec). + assert sub_min - sup_max >= 0.05, ( f"insufficient sub→super gap: " f"sub_min={sub_min:.4f} super_max={sup_max:.4f}" ) - assert sup_min > cr_max + 0.15, ( + assert sup_min - cr_max >= 0.05, ( f"insufficient super→cross gap: " f"super_min={sup_min:.4f} cross_max={cr_max:.4f}" ) + + # Non-factorization criterion: the cross-coupled MPS Gram differs + # measurably from the same-angle product-state Gram. The bare + # staircase (single-bound-param Ry rotations) factorizes as + # ∏_k cos((θ_{i,k} − θ_{j,k})/2); the cross-coupled variant must + # break that factorization on at least one off-diagonal entry. + triples = [ + tuple(b.value for b in t.bound_arguments) for t in query_call_sites + ] + gram_prod = np.zeros((12, 12)) + for i in range(12): + ai, bi, ci = triples[i] + for j in range(12): + aj, bj, cj = triples[j] + gram_prod[i, j] = ( + np.cos((ai - aj) / 2) + * np.cos((bi - bj) / 2) + * np.cos((ci - cj) / 2) + ) + diff = np.abs(gsq - gram_prod ** 2) + # Zero out the diagonal (both Grams give 1 there). + np.fill_diagonal(diff, 0.0) + assert diff.max() >= 0.05, ( + "cross-coupled Gram unexpectedly factorizes: " + f"max |gram_mps^2 − gram_prod^2| = {diff.max():.6f} < 0.05" + )