File size: 10,523 Bytes
feaa032
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
import math

import pennylane as qml

import torch
import torch.nn as nn
import torch.nn.functional as F

from config import DROPOUT, QUANTUM_DEVICE

DEVICE_CHOICES = ("auto", "lightning.gpu", "lightning.qubit")

# Printed at most once per process, not once per model construction --
# an 8-arm x 5-seed ablation grid builds 40 of these, and 40 identical
# "falling back to CPU" lines would bury the one thing worth knowing.
_reported = set()


def _try_construct(name, n_qubits):

    try:
        # batch_obs batches the 3*n_qubits expval() observables into fewer
        # statevector passes -- pure execution optimization, not a change
        # to what's computed. Retry without it if this particular build
        # doesn't expose the kwarg, rather than treating the absence of a
        # batching optimization as a reason to fail over to a different
        # simulator entirely.
        return qml.device(name, wires=n_qubits, batch_obs=True)
    except TypeError:
        return qml.device(name, wires=n_qubits)


def _build_device(n_qubits, preference, verbose):
    """
    Resolves which PennyLane device actually backs the circuit. See
    config.QUANTUM_DEVICE for what each preference means.

    A fallback here is a device SWAP, not an algorithm change -- lightning
    .gpu and lightning.qubit compute the identical function, exactly. It is
    reported (once) because a training run silently spending 20-30x longer
    than expected because a GPU didn't attach is a footgun worth knowing
    about immediately, not because the choice changes any result.
    """

    if preference not in DEVICE_CHOICES:
        raise ValueError(
            f"QUANTUM_DEVICE must be one of {DEVICE_CHOICES}, got "
            f"{preference!r}"
        )

    order = ["lightning.gpu", "lightning.qubit"] if preference == "auto" else [preference]

    errors = []

    for name in order:

        try:
            dev = _try_construct(name, n_qubits)

        except Exception as e:

            errors.append(f"{name}: {type(e).__name__}: {e}")
            continue

        if name == "lightning.gpu" and "gpu_attached" not in _reported:
            _reported.add("gpu_attached")
            print("[QuantumEvolutionLayer] circuit running on lightning.gpu "
                  "(NVIDIA cuStateVec)")

        elif name == "lightning.qubit" and preference == "auto" and errors:
            key = "cpu_fallback"
            if key not in _reported:
                _reported.add(key)
                print(
                    f"[QuantumEvolutionLayer] lightning.gpu unavailable "
                    f"({errors[-1]}) -- circuit running on lightning.qubit "
                    f"(CPU) instead. Measured 20-30x slower than the GPU "
                    f"device (notebooks/diagnose_quantum_speed.py). If this "
                    f"machine has an NVIDIA GPU, `pip install "
                    f"pennylane-lightning[gpu]`; if not, this is the "
                    f"correct fallback and nothing is wrong."
                )

        if verbose:
            print(f"[QuantumEvolutionLayer] device: {name}")

        return dev, name

    if preference != "auto":
        raise RuntimeError(
            f"QUANTUM_DEVICE={preference!r} was requested explicitly but "
            f"device construction failed: {errors[-1]}\n"
            f"Install it with `pip install pennylane-lightning[gpu]` on a "
            f"CUDA-capable machine, or set config.QUANTUM_DEVICE='auto' to "
            f"fall back to lightning.qubit automatically instead of "
            f"raising."
        )

    raise RuntimeError(
        "No PennyLane device could be constructed, including the CPU "
        "fallback lightning.qubit -- check the pennylane-lightning "
        "install:\n" + "\n".join(errors)
    )


class QuantumEvolutionLayer(nn.Module):
    """
    Quantum evolution layer: compress -> circuit -> expand, gated back
    into the hypothesis stream.

    v45: the circuit drives a phase
    -------------------------------
    The v44 audit's blunt finding was that this layer was decoration --
    54 circuit parameters out of 10.58M (0.0005%), wrapped in a classical
    MLP that did all the work, and bit-exactly a constant function until
    the AngleEmbedding rotation was fixed. Removing it changed nothing
    measurable, which is not a good place for the component a project is
    named after.

    v45 gives it something only a quantum circuit naturally produces: a
    PHASE. The circuit already measures <X>, <Y> and <Z> per wire, and

        phi = atan2(mean <Y>, mean <X>)

    is the azimuthal angle of the measured Bloch vector -- the physically
    meaningful phase of the state the circuit prepared. That angle is fed
    to models/interference.py as the hypothesis phase, where it decides
    whether two hypotheses reinforce or cancel each other's support for
    an option. The circuit therefore participates in an operation with no
    classical equivalent, rather than contributing a 54-parameter
    perturbation to a residual stream.

    ClassicalControlLayer computes its phase the same way from an equally
    sized classical bottleneck (parameter counts stay matched to the
    scalar), so `A1b_quantum_only` vs `A1c_classical_control_only` still
    isolates the circuit and nothing else.

    v45 also lets `lightning.gpu` (NVIDIA cuStateVec) back this circuit
    instead of the CPU simulator `lightning.qubit` -- see
    `config.QUANTUM_DEVICE`. Same circuit, same trained function; only the
    hardware executing it changes. Doubling the hypothesis count (support
    + attack) doubled how often this runs per forward call, which is what
    makes the GPU device worth having rather than optional polish.
    """

    def __init__(self, dim, n_qubits=12, n_layers=3, verbose=False,
                 quantum_device=QUANTUM_DEVICE):

        super().__init__()

        self.n_qubits = n_qubits

        self.compress = nn.Sequential(
            nn.Linear(dim, dim),
            nn.GELU(),
            nn.Linear(dim, n_qubits),
        )

        dev, self.device_name = _build_device(n_qubits, quantum_device, verbose)

        @qml.qnode(dev, interface="torch")
        def circuit(inputs, weights):

            # Hadamard puts every wire in |+>, the +1 eigenstate of Pauli-X.
            # AngleEmbedding's DEFAULT rotation is "X", and RX(t)|+> =
            # exp(-i t/2)|+> -- a global phase, which no expectation value
            # can observe. Written that way (as this layer was until the
            # audit), the circuit's output is bit-exactly independent of
            # `inputs`: measured std across distinct inputs was 0.000e+00,
            # i.e. the whole layer was a learned constant and every quantum
            # ablation run before this fix measured nothing.
            #
            # rotation="Y" keeps the intended "start in uniform
            # superposition" motif while making the encoding observable:
            # RY(t)|+> tilts the Bloch vector off the equator. Measured
            # output std across inputs: 0.123 (vs 0.094 for dropping the
            # Hadamard and keeping RX, 0.089 for RZ).
            for i in range(n_qubits):
                qml.Hadamard(wires=i)

            qml.AngleEmbedding(inputs, wires=range(n_qubits), rotation="Y")

            qml.StronglyEntanglingLayers(weights, wires=range(n_qubits))

            return (
                [qml.expval(qml.PauliX(i)) for i in range(n_qubits)]
                + [qml.expval(qml.PauliY(i)) for i in range(n_qubits)]
                + [qml.expval(qml.PauliZ(i)) for i in range(n_qubits)]
            )

        weight_shapes = {"weights": (n_layers, n_qubits, 3)}

        self.quantum = qml.qnn.TorchLayer(circuit, weight_shapes)

        self.expand = nn.Sequential(
            nn.Linear(n_qubits * 3, dim),
            nn.GELU(),
            nn.LayerNorm(dim),
            nn.Dropout(DROPOUT),
        )

        self.fusion_gate = nn.Sequential(
            nn.Linear(dim * 2, dim),
            nn.GELU(),
            nn.Linear(dim, dim),
            nn.Sigmoid(),
        )

        self.correction = nn.Sequential(
            nn.Linear(dim, dim),
            nn.GELU(),
            nn.Dropout(DROPOUT),
            nn.Linear(dim, dim),
        )

        # Single scalar, matched by ClassicalControlLayer, so the phase
        # channel cannot explain a quantum-vs-classical gap through
        # parameter count.
        self.phase_gain = nn.Parameter(torch.tensor(1.0))

        self.energy_head = nn.Sequential(
            nn.Linear(dim, dim),
            nn.GELU(),
            nn.LayerNorm(dim),
            nn.Linear(dim, dim // 2),
            nn.GELU(),
            nn.Linear(dim // 2, 1),
        )

        self.norm = nn.LayerNorm(dim)

        # Printed so it can be compared directly against
        # ClassicalControlLayer's printed count at runtime, rather than
        # trusting a hand calculation. Opt-in: this used to fire on every
        # model construction, including inside evaluation loops.
        if verbose:
            total_params = sum(p.numel() for p in self.parameters())
            circuit_params = sum(p.numel() for p in self.quantum.parameters())
            print(
                f"[QuantumEvolutionLayer] total parameters: {total_params:,} "
                f"(circuit weights: {circuit_params:,}, device: {self.device_name})"
            )

    def forward(self, H):

        B, K, D = H.shape

        H_norm = F.normalize(H, dim=-1)

        z = self.compress(H_norm.reshape(B * K, D))

        z = math.pi * torch.tanh(z)

        measurements = self.quantum(z)

        # measurements is [<X_0..X_{n-1}>, <Y_0..Y_{n-1}>, <Z_0..Z_{n-1}>].
        # The azimuthal angle of the mean Bloch vector is the phase the
        # circuit actually prepared -- see the class docstring.
        n = self.n_qubits

        x_mean = measurements[:, :n].mean(dim=-1)
        y_mean = measurements[:, n:2 * n].mean(dim=-1)

        phase = self.phase_gain * torch.atan2(y_mean, x_mean)

        q = self.expand(measurements)

        H_flat = H.reshape(B * K, D)

        gate = self.fusion_gate(torch.cat([H_flat, q], dim=-1))

        q = gate * q + (1 - gate) * H_flat

        q = self.norm(q + self.correction(q))

        energy = self.energy_head(q)

        energy = torch.tanh(energy).squeeze(-1)

        q = q.reshape(B, K, D)

        energy = energy.reshape(B, K)

        phase = phase.reshape(B, K)

        return q, energy, phase