Симуляція двовимірної динаміки Фермі-Габбарда з множинами потоків
Концепції цього посібника наразі доступні лише в Python API. Еквівалентна функціональність буде доступна в C API у майбутньому випуску.
Спершу прочитай посібник з одновимірних множин потоків. Цей посібник є його двовимірним продовженням і не повторює викладеного там: оператори переносу й вершин, що таке множина потоків, як groups переходить у схему і як користувацьке кодування підключається до транспілятора через MapperFnEvolutionSynthesis.
Про етапи транспілятора, на які посилаються далі, див. посібник із транспіляції, а про загальний рецепт написання користувацького мапера — посібник із мапперів.
Одновимірний посібник завершився згадкою, що для роботи з двома вимірами потрібні локальні кодування. Цей посібник робить такий крок для двовимірної моделі Фермі-Габбарда на квадратній ґратці, використовуючи кодування Верстрате-Цірака (VC) [2].
Є три відмінності від одновимірної задачі, що становлять основу цієї роботи:
-
Множини потоків множаться. Ланцюжок має дві орієнтації стрілок; квадратна ґратка має чотири (схід, захід, північ, південь), і кожна є об'єднанням неперетинних за вершинами орієнтованих шляхів (по одному на рядок або стовпець ґратки), а не єдиним шляхом.
-
Кодування отримує допоміжний кубіт на вузол, а не один на всю систему, що дає
співвідношення кубітів до мод . Оператори переносу стають паулієвими операторами ваги 3 (горизонтальні) і ваги 4 (вертикальні), а кожна з чотирьох множин потоків відображається у власну паулієву форму, тож кожна потребує власного діагоналізувального кліффорда.
-
Закодований гамільтоніан вірний лише на підпросторі: спільний власний підпростір
обмежень плакет, які несе кожне двовимірне локальне кодування [2] [3]. В одновимірному випадку кодування було простою ізометрією, а перевіркою була перевірка спектра. Тут порівняння слід проєктувати, як показує крок 9.
Виграш має ту саму форму, що й в одновимірному випадку: двокубітна глибина одного кроку Троттера стає сталою відносно розміру ґратки, на рівні ( для стрибків, що збігається з джерелом [1], плюс для взаємодії), тоді як тротеризація того самого закодованого гамільтоніана доданок за доданком зростає лінійно. Кількість вентилів, звісно, все ще зростає з кількістю зв'язків; від розміру перестає залежати те, скільки шарів ці вентилі займають. Глибини скрізь рахуються на абстрактному повнозв'язному регістрі; відображення кодування на обмежену карту зв'язності пристрою — окрема задача, яка обговорюється наприкінці.
Ґратка та її чотири орієнтації
Перша зміна видна на орієнтованому графі взаємодій. Вузли нумеруються в рядковому порядку, site(r, c) = r * cols + c, і кожен зв'язок несе обидві орієнтації, як і ланцюжок в одновимірному випадку, але тепер зв'язки йдуть уздовж двох осей:

Правило комутації те саме, що й в одновимірному випадку: два оператори переносу, що мають спільний вузол, комутують, коли стрілки проходять крізь нього, і антикомутують, коли вони зіштовхуються. Однак тепер вузол має до чотирьох інцидентних зв'язків замість двох, тож стрілки через нього можуть зіштовхуватися більшою кількістю способів, а набір стрілок, що ніколи не зіштовхуються, більше не є єдиним шляхом. Читання горизонтальних стрілок зліва направо дає одну таку множину, вертикальні стрілки згори вниз дають іншу, а обернення кожної дає ще дві. Це східна, західна, північна та південна множини потоків, навколо яких організовано все подальше.
Кодування Верстрате-Цірака
Верстрате-Цірак (VC) пов'язує кожну ферміонну моду із власним допоміжним кубітом. Для вузлів ґратки мода живе на кубіті , а її допоміжний кубіт — на кубіті , тож регістр містить кубітів. Оператори вершин лишаються максимально простими,
що й робить заповнення безпосередньо зчитуваними пізніше. Оператори переносу залежать від орієнтації ребра. Для горизонтального ребра , де — західний вузол, і вертикального ребра, де — північний вузол,
вагою-3 «трикутник» і вагою-4 «квадрат». Визначення кожної стрілки має значення: коли — західний вузол, вказує на схід, а коли — північний вузол, вона вказує на південь. Зворотні орієнтації випливають із тотожності , яка перетворює трикутник XXZ на YYZ, а квадрат XYXY на YXXY. Ці чотири різні мітки і є чотирма множинами потоків, і решта цього посібника стежить за ними.
Це не зовсім рівняння (A1) з Ref. [1], яке говорить «з точністю до однокубітних обертань» і відрізняється трьома пунктами, які варто назвати, бо читач, що звіряється зі статтею, їх помітить:
-
Префактор: рівняння (A1) його не містить. Множник тут забезпечує , що відповідає нормуванню , яке використовує
TransferVertexOperator. Стаття використовує свої оператори переносу лише всередині , де масштабний множник поглинається кутом обертання; тут же вони мають бути операторами, які задовольняють тотожність. -
Орієнтація: рівняння (A1) вибирає літери за парністю рядка , яка для горизонтального ребра однакова для обох кінців, тому воно не розрізняє і . Стаття відновлює орієнтацію окремо, спряженням шаром . Цей посібник натомість вбудовує її в кодування, виводячи обернену з , через що дві орієнтації потрапляють на різні матриці Паулі на кроці 3.
-
Вертикальна форма: рівняння (A1) чергує
XXXXіYYYYза парністю рядка, тоді як цей посібник використовує єдинуXYXY. Це два способи отримати те саме: асиметрію літер, яка змушує вертикальне ребро коректно конфліктувати з горизонтальними ребрами, що до нього сходяться. Не сприймай цю різницю як вільний вибір базису. Відмова від чергування за парністю та використання єдиноїXXXXпросто руйнує алгебру, тому різні літери перевіряються, а не припускаються.
Ансилу прикріплено до вузла, що дає відношення ; саме кодування Дербі-Классена розміщує допоміжний кубіт на кожну (непарну) плакетку, для меншого відношення . VC використано тут, тому що його оператори переносу такі, що діагоналізуючі їх кліфорди Ref. [1] наводить у замкненому вигляді.
1. Гамільтоніан на квадратній ґратці
Побудова та сама, що й в 1D, але зі списком ребер у двох вимірах. Вузли зберігають нумерацію за рядками, використану для малюнка вище, site(r, c) = r * cols + c, тож горизонтальні зв'язки з'єднують послідовні індекси, а вертикальні зв'язки з'єднують індекси, що відрізняються на cols:
>>> from collections import defaultdict
>>> from qiskit_fermions.operators import TransferVertexOperator
>>>
>>> def fermi_hubbard_2d(rows, cols, tunneling, interaction):
... site = lambda r, c: r * cols + c
... edges = [(site(r, c), site(r, c + 1)) for r in range(rows) for c in range(cols - 1)]
... edges += [(site(r, c), site(r + 1, c)) for r in range(rows - 1) for c in range(cols)]
... data: defaultdict[tuple[tuple[int, int], ...], complex] = defaultdict(complex)
... for j, k in edges:
... # hopping: -t (a^dag_j a_k + a^dag_k a_j) = t (T_jk + T_kj)
... data[((j, k),)] += tunneling
... data[((k, j),)] += tunneling
... # interaction: U n_j n_k with n_j = (1 - V_j) / 2
... data[()] += interaction / 4
... data[((j, j),)] -= interaction / 4
... data[((k, k),)] -= interaction / 4
... data[((j, j), (k, k))] += interaction / 4
... return TransferVertexOperator.from_dict(data)
>>>
>>> rows, cols = 3, 3
>>> num_sites = rows * cols
>>> hamiltonian = fermi_hubbard_2d(rows, cols, tunneling=1.0, interaction=2.0)
>>> print(f"Number of terms: {len(hamiltonian)}")
Number of terms: 46
2. Чотири потоки замість двох
Кожен доданок переходу класифікується за напрямком, куди вказує його стрілка на ґратці. Порівняння координат рядка/стовпця divmod двох кінців відокремлює горизонтальні ребра від вертикальних, а знак різниці визначає орієнтацію. Усе діагональне (одиниця, доданки та ) потрапляє до п'ятої групи:
Угода про орієнтацію. Назви просто зчитано з малюнка вище: стрілка йде на схід або захід, якщо вказує праворуч чи ліворуч уздовж рядка, і на північ або південь, якщо вказує вгору чи вниз уздовж стовпця. Оскільки нумерація йде за рядками, схід і південь є напрямками зростання індексу. Подальші кроки від назв не залежать (вони лише позначають групи), але їх легко сплутати, тож варто закріпити їх за малюнком. Ref. [1] називає потоки на квадратній ґратці так само.
>>> EAST, WEST, NORTH, SOUTH, DIAGONAL = range(5)
>>>
>>> def flow_set_groups(operator, cols):
... groups = []
... for terms, _ in operator.iter_terms():
... if len(terms) == 1 and terms[0][0] != terms[0][1]:
... j, k = terms[0]
... row_j, col_j = divmod(j, cols)
... row_k, col_k = divmod(k, cols)
... if row_j == row_k:
... groups.append(EAST if col_k > col_j else WEST)
... else:
... groups.append(SOUTH if row_k > row_j else NORTH)
... else:
... groups.append(DIAGONAL)
... return groups
>>>
>>> hamiltonian.groups = flow_set_groups(hamiltonian, cols)
>>>
>>> names = ["east", "west", "north", "south", "diagonal"]
>>> for index, name in enumerate(names):
... count = sum(1 for group in hamiltonian.groups if group == index)
... print(f"{name:9s} {count:2d}")
east 6
west 6
north 6
south 6
diagonal 22
У межах одного потоку кожна стрілка або стикується з іншою «голова до хвоста», або не стикується взагалі, як і в 1D, тож потік, як і раніше, комутує доданок за доданком, а його еволюція не несе похибки Троттера. Нове те, що потік тепер незв'язний: східний потік ґратки rows x cols складається з rows незалежних шляхів зліва направо. Ця незв'язність є подарунком, а не ускладненням, як покаже крок 6.
3. Закодуй оператор
Кодування — це одна функція, що відображає окремий узагальнений оператор переносу на рядок Паулі, який передається в map_transfer_vertex_generators(). Порівняно з версією для 1D, тіло має більше випадків: діагональний, горизонтальний, вертикальний та обернені орієнтації через .
Є одна проблема. map_transfer_vertex_generators() викликає зворотний виклик лише з генератором, а щоб визначити рядок Паулі зв'язку, потрібен ще й cols, щоб відновити координати рядка й стовпця з індексу моди. Замість того щоб вкладати функції для замикання, functools.partial() прив'язує його, що залишає обидві функції на верхньому рівні й незалежно викликуваними.
>>> from functools import partial
>>>
>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_transfer_vertex_generators
>>>
>>> def ancilla_qubit(mode, num_sites):
... """The auxiliary qubit paired with fermionic mode `mode`."""
... return num_sites + mode
>>>
>>> def vc_action(generator, width, cols):
... """Map one generalized transfer operator to its Verstraete-Cirac Pauli."""
... num_sites = width // 2
... ancilla = partial(ancilla_qubit, num_sites=num_sites)
... j, k = generator
... if j == k: # vertex operator V_j = Z_j
... return SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... row_j, col_j = divmod(j, cols)
... row_k, col_k = divmod(k, cols)
... if row_j == row_k: # horizontal: weight-3 triangle
... left, right = (j, k) if col_j < col_k else (k, j)
... operator = SparseObservable.from_sparse_list(
... [("XXZ", [left, right, ancilla(left)], 0.5)], num_qubits=width
... )
... forward = (j, k) == (left, right)
... else: # vertical: weight-4 square
... up, down = (j, k) if row_j < row_k else (k, j)
... operator = SparseObservable.from_sparse_list(
... [("XYXY", [up, down, ancilla(up), ancilla(down)], 0.5)], num_qubits=width
... )
... forward = (j, k) == (up, down)
... if forward:
... return operator
... # reverse orientation: T_kj = -V_j V_k T_jk
... v_j = SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... v_k = SparseObservable.from_sparse_list([("Z", [k], 1.0)], num_qubits=width)
... return (-1.0 * v_j.compose(v_k).compose(operator)).simplify()
>>>
>>> def vc_encoding(operator, width, cols):
... """Map a TransferVertexOperator to a qubit observable, Verstraete-Cirac style."""
... return map_transfer_vertex_generators(
... operator,
... partial(vc_action, width=width, cols=cols),
... identity=lambda: SparseObservable.identity(width),
... compose=SparseObservable.compose,
... ).simplify()
>>>
>>> # bind the lattice width once; the transpiler only ever passes (operator, width)
>>> encoding = partial(vc_encoding, cols=cols)
>>> num_qubits = 2 * num_sites
>>> num_qubits
18
Сенс групування перед відображенням видно одразу: кожен потік кодується в одну форму Паулі, однакову для всього потоку.
>>> for index, name in enumerate(names):
... observable = encoding(hamiltonian.split_out_groups()[index], num_qubits)
... shapes = sorted({label for label, _, _ in observable.to_sparse_list()})
... print(f"{name:9s} {shapes}")
east ['XXZ']
west ['YYZ']
north ['YXXY']
south ['XYXY']
diagonal ['', 'Z', 'ZZ']
Чотири форми переходу різні, тож кожен потік отримує власний кліфорд на кроці 5. Обернені орієнтації — це різні матриці Паулі, а не ті самі з перейменуванням; саме тому синтез потоків у 2D потребує чотирьох схем, тоді як синтез в 1D потребує однієї.
4. Транспілюй із власним кодуванням
Цей етап — той самий, що й в 1D, але з ширшим регістром кубітів: CustomF2QLayout відображає -модовий регістр на кубітів, MapperFnEvolutionSynthesis несе кодування, а його аргумент product_formula вибирає, як розкладається отриманий PauliEvolutionGate. Порівняй із кроком 6 посібника для 1D, який будує той самий конвеєр для однієї ансили.
>>> from qiskit.circuit import QuantumRegister
>>> from qiskit.passmanager import MultiStagePassManager
>>> from qiskit.synthesis import LieTrotter
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicLieTrotter
>>> from qiskit_fermions.transpiler import FermionicCircuitToDAG, QuantumDAGToCircuit
>>> from qiskit_fermions.transpiler.passes import (
... CustomF2QLayout,
... F2QSynthesis,
... MapperFnEvolutionSynthesis,
... )
>>>
>>> total_time = 1.0
>>>
>>> def flow_set_pass_manager(mode_register, encoding, product_formula=None):
... """Build the pipeline mapping `mode_register` with the VC `encoding`."""
... synthesis = F2QSynthesis()
... synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(
... encoding, product_formula=product_formula
... )
... # this encoding spends one ancilla per fermionic mode
... qubit_register = QuantumRegister(2 * mode_register.size, "q")
... return MultiStagePassManager(
... input=FermionicCircuitToDAG(),
... layout=CustomF2QLayout({mode_register: qubit_register}),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(
... Evolution(
... num_sites, hamiltonian, time=total_time, synthesis=FermionicLieTrotter()
... ),
... circuit.modes,
... )
>>>
>>> # as in 1D, one decompose() splits the Evolution group by group (five gates here,
>>> # the four hopping flow sets in group order, then the diagonal interaction)
>>> flow_set_circuit = circuit.decompose()
>>> flow_set_circuit.count_ops()["Evolution"]
5
П'ять вентилів, що несуть 6, 6, 6, 6 і 22 доданки, підраховані на кроці 2. Кожен відображається й синтезується незалежно, тож розбиття, визначене на ферміонному рівні, зберігається аж до схеми:
>>> pass_manager = flow_set_pass_manager(flow_set_circuit.register, encoding, LieTrotter())
>>> qubit_circuit = pass_manager.run(flow_set_circuit)
>>> qubit_circuit.num_qubits
18
>>>
>>> def two_qubit_stats(circuit):
... decomposed = circuit.decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return decomposed.count_ops().get("cx", 0), decomposed.depth(two_qubit)
>>>
>>> two_qubit_stats(qubit_circuit)
(144, 47)
Це базова лінія: кодування й групування вже є, але LieTrotter синтезує кожен потік доданок за доданком, а глибина зростає разом із ґраткою.
5. Кліфорд глибини 2 на потік
Механізм той самий, що й в 1D: спряжи весь комутуючий потік кліфордом, який відображає кожен його доданок у різну матрицю Паулі ваги 1, щоб еволюція всього потоку коштувала лише однокубітних обертань. В 1D це робив один «цегляний» шар вентилів CZ.
У 2D кожен потік потребує власної схеми, за розділом IV C і рис. 7 з Ref. [1]. Усі чотири діють на одну лінію ґратки за раз (рядок для схід/захід, стовпець для північ/південь) разом з ансилами цієї лінії, і всі чотири мають однакову триskладову форму:
-
Шар однокубітних обертань, що переводить кожен кубіт у базис, потрібний його потоку.
-
Перший шар
CX, завжди від вузла до ансили на тому самому вузлі. -
Другий шар
CX, що зв'язує сусідів уздовж лінії.
Кроки 2 і 3 — єдині двокубітні шари, тож двокубітна глибина дорівнює незалежно від довжини лінії. Нижче вони виписані по одній функції на потік. Обертання такі, які вони є, бо їх було знайдено спряженням доданків кожного потоку з вимогою різних образів ваги 1, тож сприймай їх як задані й спирайся на перевірку, що йде далі.
Дві горизонтальні множини найпростіші. Їхні доданки мають вагу 3, XXZ і YYZ, і відрізняються лише додатковим , що перетворює на на вузлах:
>>> from qiskit.circuit import QuantumCircuit
>>>
>>> def east_clifford(circuit, line, ancilla):
... """Diagonalize the east flow set (XXZ) on one lattice row."""
... for j in line:
... circuit.h(ancilla(j))
... for j in line[:-1]: # layer 1: each site with its own ancilla
... circuit.cx(ancilla(j), j)
... for j, k in zip(line, line[1:]): # layer 2: ancilla with the next site along
... circuit.cx(k, ancilla(j))
>>>
>>> def west_clifford(circuit, line, ancilla):
... """Diagonalize the west flow set (YYZ) on one lattice row."""
... for j in line:
... circuit.s(j) # the only difference from east: X -> Y on the sites
... circuit.h(ancilla(j))
... for j in line[:-1]:
... circuit.cx(ancilla(j), j)
... for j, k in zip(line, line[1:]):
... circuit.cx(k, ancilla(j))
Дві вертикальні множини мають вагу 4, XYXY і YXXY, і відповідно складніші: обертання чергуються залежно від позиції вздовж лінії, і другий шар CX теж чергується, зв'язуючи вузли на парних позиціях та ансили на непарних (або навпаки):
>>> def south_clifford(circuit, line, ancilla):
... """Diagonalize the south flow set (XYXY) on one lattice column."""
... for position, j in enumerate(line):
... if position % 2 == 0:
... circuit.sdg(ancilla(j))
... circuit.h(ancilla(j))
... else:
... circuit.h(j)
... circuit.s(j)
... circuit.h(ancilla(j))
... circuit.s(ancilla(j))
... for j in line: # layer 1
... circuit.cx(ancilla(j), j)
... for position, (j, k) in enumerate(zip(line, line[1:])): # layer 2
... if position % 2 == 0:
... circuit.cx(ancilla(j), ancilla(k)) # ancilla to ancilla
... else:
... circuit.cx(j, k) # site to site
>>>
>>> def north_clifford(circuit, line, ancilla):
... """Diagonalize the north flow set (YXXY) on one lattice column."""
... for position, j in enumerate(line):
... if position % 2 == 0:
... circuit.h(ancilla(j))
... circuit.s(ancilla(j))
... else:
... circuit.h(j)
... circuit.s(j)
... circuit.sdg(ancilla(j))
... circuit.h(ancilla(j))
... for j in line: # layer 1
... circuit.cx(ancilla(j), j)
... for position, (j, k) in enumerate(zip(line, line[1:])): # layer 2
... if position % 2 == 0:
... circuit.cx(k, j) # site to site, and the other way round from south
... else:
... circuit.cx(ancilla(j), ancilla(k))
Намальований на одній лінії з трьох вузлів (спочатку її вузли, потім їхні ансили), увесь механізм вміщається в одну картинку. Форма трикутника яскравіша з двох: зміна базису на ансилах, потім два шари CX, перший пов'язує кожен вузол з його власною ансилою, а другий сягає до наступного вузла вздовж лінії.
>>> line = [0, 1, 2] # three sites, with ancillas on qubits 3, 4, 5
>>> triangle = QuantumCircuit(6)
>>> east_clifford(triangle, line, lambda mode: 3 + mode)
>>> triangle.draw("mpl")
<Figure size ... with 1 Axes>
Квадратна форма потребує більше однокубітної роботи (обертання чергуються вздовж лінії), а її другий шар CX чергується між зв'язуванням вузлів і зв'язуванням ансил:
>>> square = QuantumCircuit(6)
>>> south_clifford(square, line, lambda mode: 3 + mode)
>>> square.draw("mpl")
<Figure size ... with 1 Axes>
Обидві мають глибину два шари CX, а додавання вузлів до лінії розширює їх, не збільшуючи глибини; це і є вся теза про сталу глибину, видима неозброєним оком. Кліфорди для заходу й півночі відрізняються лише однокубітними шарами, тому окремо не зображені.
Синтезу ще потрібно визначити, куди потрапляє кожен доданок. Лінія з вузлів несе зв'язків, кожен дає один доданок, тож допоміжна функція нижче повертає один запис на зв'язок: кубіт, на який потрапив доданок, і однокубітну матрицю Паулі, якою він став. Важливо, що їх зчитано зі спряження, а не передбачено. На відміну від 1D, де один «цегляний» шар CZ відображає у замкненому вигляді, тут такої формули немає, а ансили стовпцевої лінії не є неперервними. Єдине, що відрізняється між потоками, — рядок Паулі на вході, який крок 3 уже вивів:
>>> from qiskit.quantum_info import Clifford, SparsePauliOp
>>>
>>> def clifford_supports(clifford, pauli, line, ancilla, num_qubits):
... """Conjugate a flow set's terms to find which qubit each one lands on.
...
... `pauli` is the flow set's Pauli string, as printed in step 3. Its letters apply
... to the i-th term's qubits in the order below: the two sites of the bond, then
... their ancillas.
...
... This is the one check that needs SparsePauliOp: Clifford conjugation wants a
... `Pauli` and its symplectic bits, and SparseObservable exposes neither -- its own
... `evolve` conjugates by a Pauli, not by a Clifford.
... """
... supports = []
... for i in range(len(line) - 1):
... qubits = [line[i], line[i + 1], ancilla(line[i]), ancilla(line[i + 1])]
... term = SparsePauliOp.from_sparse_list(
... [(pauli, qubits[: len(pauli)], 1.0)], num_qubits=num_qubits
... )
... image = term.paulis[0].evolve(clifford, frame="s")
... # a weight-1 image has exactly one qubit with an X and/or Z bit set
... (support,) = np.nonzero(image.x | image.z)
... assert len(support) == 1, f"term {i} is not weight-1"
... supports.append((int(support[0]), image[support].to_label()))
... return supports
Образи мають бути різними й ваги 1, щоб кожен доданок можна було обертати незалежно на власному кубіті; двокубітна глибина уже видна в чотирьох побудовах вище, кожна з яких випускає два шари CX незалежно від довжини лінії. Нижче обидві властивості перевіряються для кожного потоку, кожної лінії та кількох розмірів ґратки. Крок 6 потім викликає clifford_supports, тож це робоча частина синтезу, а не лише демонстрація.
6. Синтез, що враховує потоки
Синтез отримує корисну фору. Оскільки Evolution сам розбивається по групах, synthesize викликається один раз на потік (тут п'ять разів: чотири потоки переходу плюс діагональна група), тож кожен доданок переходу, який він бачить, має одну форму Паулі, і один кліфорд діагоналізує їх усі. Йому лишається лише зчитати цю форму з першого доданка й розсортувати решту за лінією, на якій вони лежать. Далі йде спостереження, завдяки якому два виміри дешевші, ніж можна боятися:
Компоненти одного потоку вершинно не перетинаються: рядки західного потоку не мають спільних кубітів. Тому всі кліфорди потоку можна випустити разом, і вони поділяють ті самі два двокубітні шари, а не стають у чергу один рядок за одним. Отже, потік коштує фіксовану кількість шарів (два для кліфорда і два для його оберненого) незалежно від кількості рядків чи стовпців ґратки, через що повна глибина виявляється сталою.
>>> from qiskit.synthesis.evolution import EvolutionSynthesis
>>>
>>> # The interaction couples nearest neighbors only, so every interior site carries four
>>> # Rzz gates -- one per bond -- and can only do one at a time: four layers is the best
>>> # any schedule can achieve. Horizontal bonds split by column parity, vertical ones by
>>> # row parity, which achieves it.
>>> def rzz_layers(circuit, angles, cols):
... """Emit the diagonal Rzz gates in four layers of disjoint qubit pairs."""
... horizontal = [(j, k) for j, k in angles if k - j == 1]
... vertical = [(j, k) for j, k in angles if k - j == cols]
... schedule = [
... [(j, k) for j, k in horizontal if (j % cols) % 2 == 0],
... [(j, k) for j, k in horizontal if (j % cols) % 2 == 1],
... [(j, k) for j, k in vertical if (j // cols) % 2 == 0],
... [(j, k) for j, k in vertical if (j // cols) % 2 == 1],
... ]
... # emit layer by layer, since Qiskit's depth follows the order gates were added
... for layer in schedule:
... for j, k in layer:
... circuit.rzz(angles[j, k], j, k)
>>>
>>> class FlowSetSynthesis(EvolutionSynthesis):
... """Synthesize one 2D line flow set at a time, via its depth-2 Clifford."""
...
... preserve_order = True
...
... def __init__(self, rows, cols):
... self.rows, self.cols = rows, cols
...
... def rows_of_lattice(self):
... return [[r * self.cols + c for c in range(self.cols)] for r in range(self.rows)]
...
... def columns_of_lattice(self):
... return [[r * self.cols + c for r in range(self.rows)] for c in range(self.cols)]
...
... def synthesize(self, evolution):
... num_qubits = 2 * self.rows * self.cols
... ancilla = partial(ancilla_qubit, num_sites=self.rows * self.cols)
... circuit = QuantumCircuit(evolution.operator.num_qubits)
... terms = [
... (label, indices, 2 * evolution.time * coeff.real)
... for label, indices, coeff in evolution.operator.to_sparse_list()
... ]
...
... # `Evolution.decompose()` hands us one gate per flow set, so every hopping term
... # here shares a single Pauli shape and one Clifford diagonalizes all of them
... shapes = tuple({label for label, _, _ in terms} - {"", "Z", "ZZ"})
... match shapes:
... case ("XXZ",):
... clifford_function, lines = east_clifford, self.rows_of_lattice()
... along_rows = True
... case ("YYZ",):
... clifford_function, lines = west_clifford, self.rows_of_lattice()
... along_rows = True
... case ("XYXY",):
... clifford_function, lines = south_clifford, self.columns_of_lattice()
... along_rows = False
... case ("YXXY",):
... clifford_function, lines = north_clifford, self.columns_of_lattice()
... along_rows = False
... case (): # the diagonal group: no conjugation needed
... self.diagonal(circuit, terms)
... return circuit
... case _:
... raise ValueError(f"expected one known flow set per gate, got {shapes}")
...
... # a term's line is the row it sits in and its slot the column, or vice versa
... rotations: dict[int, list] = defaultdict(list)
... for _, indices, angle in terms:
... row, col = divmod(indices[0], self.cols)
... line, slot = (row, col) if along_rows else (col, row)
... rotations[line].append((slot, angle))
...
... active = sorted(rotations)
... # the lines of one flow set are vertex-disjoint, so all of their Cliffords
... # are emitted together and share the same two two-qubit layers
... conjugation = QuantumCircuit(num_qubits)
... for line in active:
... clifford_function(conjugation, lines[line], ancilla)
... circuit.compose(conjugation, inplace=True)
... for line in active:
... supports = clifford_supports(
... Clifford(conjugation), shapes[0], lines[line], ancilla, num_qubits
... )
... for slot, angle in rotations[line]:
... match supports[slot]:
... case qubit, "X":
... circuit.rx(angle, qubit)
... case qubit, "Y":
... circuit.ry(angle, qubit)
... case qubit, "Z":
... circuit.rz(angle, qubit)
... circuit.compose(conjugation.inverse(), inplace=True)
... return circuit
...
... def diagonal(self, circuit, terms):
... """Synthesize the diagonal group: a phase, single-qubit Z and paired Rzz."""
... rzz = {}
... for label, indices, angle in terms:
... match label, indices:
... case "", _: # the identity contributes only a global phase
... circuit.global_phase -= angle / 2
... case "Z", [qubit]: # a lone vertex operator
... circuit.rz(angle, qubit)
... case "ZZ", [j, k]: # interaction, see rzz_layers above
... rzz[min(j, k), max(j, k)] = angle
... case _:
... raise ValueError(f"unexpected Pauli term: {label} on {indices}")
... rzz_layers(circuit, rzz, self.cols)
Як і в 1D, це свідомо написано під це кодування: воно розпізнає чотири форми Паулі й видає помилку на будь-що інше замість тихого відкату. Воно також припускає розбиття «один потік на вентиль», описане вище, і прямо це заявляє; понад одна форма переходу в одному вентилі означає, що групування не було застосовано, а це помилка, а не те, що треба обходити.
Як і її 1D-аналог, цей синтез не приймає кількості кроків Троттера. Крок Троттера має циклічно проходити між потоками, але transpile_trotter_step нижче розкладає ферміонну схему на один вентиль Evolution на потік, тож цей метод бачить лише один потік за раз і міг би повторюватися лише всередині нього, що нічого не змінює: повторення одного оператора лише ділить його власну еволюцію на рівні частини.
Тому багатокрокова тротеризація потребує формули добутку, яка поважає groups оператора всередині одного вентиля, до розкладання. FermionicLieTrotter робить саме це, а крок 10 використовує його для вимірювання збіжності.
Зверни увагу на два класи зі схожими назвами. LieTrotter з Qiskit, використаний вище як базова лінія, діє на відображений гамільтоніан, де групування вже втрачено. FermionicLieTrotter є його ферміонним аналогом і саме він ще бачить потоки.
7. Результат: стала глибина
Усе досі виконувалося на єдиній ґратці , побудованій на кроці 1. Порівняння двох шляхів синтезу на зростаючих ґратках потребує повторення всієї цієї послідовності (оператор, групування, кодування, схема, менеджер проходів) для кожного розміру, тож її варто один раз обгорнути:
>>> def transpile_trotter_step(rows, cols, product_formula, steps=1):
... """Transpile `steps` Trotter steps on a `rows` x `cols` lattice."""
... num_sites = rows * cols
... operator = fermi_hubbard_2d(rows, cols, 1.0, 2.0)
... operator.groups = flow_set_groups(operator, cols)
... encoding = partial(vc_encoding, cols=cols)
...
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(
... num_sites,
... operator,
... time=total_time,
... synthesis=FermionicLieTrotter(reps=steps),
... ),
... circuit.modes,
... )
... circuit = circuit.decompose() # split into one Evolution per flow set
...
... pass_manager = flow_set_pass_manager(circuit.register, encoding, product_formula)
... return pass_manager.run(circuit)
Збір обох метрик для діапазону розмірів ґратки та їх побудова проти краю ґратки робить різницю очевидною:
>>> sizes = [3, 4, 5, 6, 7, 8]
>>> lie_trotter = [
... two_qubit_stats(transpile_trotter_step(n, n, LieTrotter())) for n in sizes
... ]
>>> flow = [
... two_qubit_stats(transpile_trotter_step(n, n, FlowSetSynthesis(n, n))) for n in sizes
... ]
>>>
>>> figure, axes = plt.subplots(1, 2, figsize=(9, 3.5), layout="constrained")
>>> for panel, index, title in zip(axes, (1, 0), ("two-qubit depth", "CX count")):
... _ = panel.plot(sizes, [stat[index] for stat in lie_trotter], "o-", label="qubit-level")
... _ = panel.plot(sizes, [stat[index] for stat in flow], "s-", label="flow set")
... _ = panel.set(xlabel="lattice edge $L$ (an $L \\times L$ lattice)", title=title)
... _ = panel.legend()
>>> figure
<Figure size ... with 2 Axes>
Двокубітна глибина дорівнює 24 і стала від до (тобто від 18 до 128 кубітів), тоді як LieTrotter, застосований до так само закодованого гамільтоніана, зростає лінійно з краєм ґратки, від 47 до 92. Кількість вентилів зростає в обох випадках, бо в будь-якому разі треба застосувати доданків; потоки усувають зростання кількості шарів, які ці вентилі займають.
Бюджет ділиться на 16 для переходу й вісім для взаємодії:
-
Кожен із чотирьох потоків коштує двокубітних шари: його кліфорд на вході та обернений на виході, обидва глибини два за побудовою, з лише однокубітними обертаннями між ними. Це , що є числом, яке наводить Ref. [1], оскільки воно враховує лише доданки переходу. Встановлення у коді вище відтворює його.
-
Діагональна група додає решту вісім. Її вентилі
Rzzупаковуються в чотири шари, і коженRzzрозкладається на два вентиліCXнавколо однокубітного обертання, тож . Чотири шари — оптимум: на квадратній ґратці взаємодія сягає лише найближчих сусідів, тож кожен внутрішній вузол несе чотири вентиліRzz(по одному на зв'язок) і може брати участь лише в одному за раз.
Ніщо в жодній із сум не посилається на розмір ґратки, у чому й увесь сенс.
8. Перевір схему
Синтез за потоками та LieTrotter описують той самий крок Троттера того самого закодованого гамільтоніана, тож їхні унітарні оператори мають збігатися з точністю до глобальної фази. Випадкові вектори стану виявляють будь-яку розбіжність значно дешевше, ніж побудова повного унітарного оператора:
Обидві схеми розкладаються до одно- та двокубітних вентилів перед застосуванням до стану. Якщо не розкладати, кожна високорівнева інструкція Evolution змушує Statevector будувати свій повний щільний оператор ; розклавши спочатку, він застосовує вентилі по одному, що тут у кілька сотень разів швидше.
>>> import numpy as np
>>> from qiskit.quantum_info import Statevector
>>>
>>> rng = np.random.default_rng(1234)
>>>
>>> def agrees_up_to_one_phase(rows, cols, samples=3):
... """Compare both syntheses on random states.
...
... Returns whether they agree up to a phase, and whether it is the *same* phase
... for every input state.
... """
... lie_trotter = transpile_trotter_step(
... rows, cols, LieTrotter()
... ).decompose(reps=6)
... flow = transpile_trotter_step(
... rows, cols, FlowSetSynthesis(rows, cols)
... ).decompose(reps=6)
... dimension = 2**lie_trotter.num_qubits
... residuals, phases = [], []
... for _ in range(samples):
... vector = rng.normal(size=dimension) + 1j * rng.normal(size=dimension)
... state = Statevector(vector / np.linalg.norm(vector))
... a = state.evolve(lie_trotter).data
... b = state.evolve(flow).data
... overlap = np.vdot(a, b)
... phases.append(overlap / abs(overlap))
... residuals.append(np.linalg.norm(b - a * overlap / abs(overlap)))
... # the SAME global phase must work for every input state
... spread = max(abs(phase - phases[0]) for phase in phases)
... return bool(max(residuals) < 1e-9), bool(spread < 1e-9)
>>>
>>> agrees_up_to_one_phase(rows, cols)
(True, True)
Обидві половини цієї перевірки важливі. Перекриття з одиничним модулем на одному стані необхідне, але недостатнє: синтез, що помилився в залежній від стану фазі, усе одно це покаже. Вимога однакової фази на кількох випадкових станах закриває цю прогалину.
9. Перевір фізику на підпросторі стабілізаторів
Це двовимірна складність. В 1D кодування було ізометрією, і порівняння спектрів з еталоном Йордана-Вігнера було достатнім. VC не є ізометрією: він використовує кубітів для мод, тож -вимірний простір містить -вимірний фізичний. Це типово для локальних кодувань у двох вимірах (зайві кубіти обмежені локальними комутуючими симетріями, по одній на плакетку [2] [3]), і це означає, що закодований гамільтоніан представляє ферміонний вірно лише на спільному власному підпросторі цих обмежень.
Слово стабілізатор у цій темі має подвійне навантаження, і ці два значення не пов'язані. Ref. [1] використовує його для комутуючих операторів переносу потоку, які утворюють групу стабілізаторів, діагоналізовану кліфордами кроку 5 (прийом компіляції, і саме це значення мається на увазі скрізь вище). Цей розділ використовує його в сенсі виправлення помилок: оператори, чий власний підпростір є фізичним підпростором. Ref. [1] прямо вказує на цю колізію й відкладає друге значення вбік; обмеження плакеток нижче є властивістю кодування VC [2], а не результатом фреймворку потоків.
Генератори тут побудовано, а не процитовано: по одному на кожну квадратну грань, і кожен є просто закодованою петлею реберних операторів навколо цієї грані.
Реберні оператори — природний об'єкт для цього. Замкнена петля з них є симетрією будь-якого гамільтоніана, що зберігає кількість частинок, тоді як відповідна петля операторів переносу — ні: вони відрізняються вершинним оператором на вузлі-джерелі кожного зв'язку,
Навколо замкненої петлі кожен вузол є джерелом рівно одного зв'язку, тож петля збирає по одному на вузол, що є локальною парністю чотирьох вузлів плакетки. Саме локальна парність створює різницю: гамільтоніан переносить частинки через межу плакетки, тож зберігає лише глобальну парність. Тому гола петля переносу не комутує з нею, тоді як реберна петля (що несе множник парності) комутує.
Оскільки кодування визначене на операторах переносу, відображення реберного оператора потребує співвідношення вище. Це зворотний виклик на п'ять рядків, і він вставляється в map_edge_vertex_generators() точно так, як vc_action вставляється в map_transfer_vertex_generators():
>>> from qiskit_fermions.mappers import map_edge_vertex_generators
>>> from qiskit_fermions.operators import EdgeVertexOperator
>>>
>>> def vc_edge_action(generator, width, cols):
... """Map one generalized *edge* operator to its Verstraete-Cirac Pauli."""
... j, k = generator
... if j == k: # a vertex operator is the same object in both algebras
... return SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... # The library decomposes T_jk = (i/2) V_j E_jk, so E_jk = -2i V_j T_jk. Reuse the
... # transfer encoding for T_jk rather than repeating its four cases here. Note that
... # `compose` left-multiplies its argument, so this composes to V_j T_jk; the other
... # order carries the opposite sign, since a vertex and a transfer operator sharing a
... # mode anticommute.
... vertex = SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... return (-2.0j * vc_action(generator, width, cols).compose(vertex)).simplify()
>>>
>>> def vc_edge_encoding(operator, width, cols):
... """Map an EdgeVertexOperator to a qubit observable, Verstraete-Cirac style."""
... return map_edge_vertex_generators(
... operator,
... partial(vc_edge_action, width=width, cols=cols),
... identity=lambda: SparseObservable.identity(width),
... compose=SparseObservable.compose,
... ).simplify()
>>>
>>> def plaquette_loops(rows, cols):
... """The directed edge loop around each square face."""
... at = lambda r, c: r * cols + c
... return [
... [(at(r, c), at(r, c + 1)), (at(r, c + 1), at(r + 1, c + 1)),
... (at(r + 1, c + 1), at(r + 1, c)), (at(r + 1, c), at(r, c))]
... for r in range(rows - 1)
... for c in range(cols - 1)
... ]
>>>
>>> def stabilizers(rows, cols):
... """One plaquette stabilizer per square face of the lattice."""
... num_qubits = 2 * rows * cols
... out = []
... for loop in plaquette_loops(rows, cols):
... loop_operator = EdgeVertexOperator.from_dict({tuple(loop): 1.0})
... encoded_loop = vc_edge_encoding(loop_operator, num_qubits, cols)
... out.append(encoded_loop / complex(encoded_loop.coeffs[0]))
... return out
>>>
>>> plaquette_stabilizers = stabilizers(rows, cols)
>>> for stabilizer in plaquette_stabilizers:
... print(stabilizer.to_sparse_list())
[('ZZYXXY', [1, 4, 9, 10, 12, 13], (1+0j))]
[('ZZYXXY', [2, 5, 10, 11, 13, 14], (1+0j))]
[('ZZYXXY', [4, 7, 12, 13, 15, 16], (1+0j))]
[('ZZYXXY', [5, 8, 13, 14, 16, 17], (1+0j))]
Кожен генератор має вагу , розподілену між двома фізичними кубітами його плакетки та чотирма ансилами. Вони інволютивні й комутують із закодованим гамільтоніаном, тож фізичний підпростір зберігається динамікою. Обидві властивості — твердження про оператори, тому їх перевіряють символьно в алгебрі SparseObservable, без матриць :
>>> encoded = encoding(hamiltonian, num_qubits)
>>> identity = SparseObservable.identity(num_qubits)
>>> for stabilizer in plaquette_stabilizers:
... square = stabilizer.compose(stabilizer).simplify()
... print(square == identity, stabilizer.commutes(encoded))
True True
True True
True True
True True
Зі стабілізаторами в руках порівняння не потребує відповідності базисів, бо означає, що заповнення живуть на тих самих фізичних кубітах в обох картинах. Тож: запиши добутковий стан безпосередньо на фізичних кубітах з ансилами в , спроєктуй його на підпростір стабілізаторів, еволюціонуй із закодованим гамільтоніаном і зчитай густини вузлів з обох боків.
Кожен крок тут залишається на рівні вектора. Проєктор застосовується по одному множнику замість перемноження, а еволюція в часі використовує expm_multiply(), що обчислює , ніколи не утворюючи . Оскільки закодований гамільтоніан є розрідженим -вимірним оператором, саме ця відмінність робить розділ дешевим.
>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.mappers.library import transfer_vertex_jordan_wigner
>>>
>>> # an exact evolution time, unrelated to the Trotter step above
>>> evolution_time = 0.6
>>> reference = transfer_vertex_jordan_wigner(hamiltonian, num_sites).simplify()
>>>
>>> def to_sparse_matrix(observable):
... # SparseObservable carries no dense- or sparse-matrix method, so route through
... # SparsePauliOp. This is only ever needed for the numerical checks below --
... # the encoding itself never leaves SparseObservable.
... return SparsePauliOp.from_sparse_observable(observable).to_matrix(sparse=True)
>>>
>>> def basis_state(occupation, num_qubits):
... """The computational basis state with `occupation` on the first qubits.
...
... Qubit `q` carries bit `2**q`, and the ancillas are the *high* qubits `N..2N-1`,
... so the same integer indexes the same site occupations on either register and
... leaves every ancilla in |0>.
... """
... index = int("".join(str(bit) for bit in reversed(occupation)), 2)
... state = np.zeros(2**num_qubits, dtype=complex)
... state[index] = 1.0
... return state
>>>
>>> def project_onto_codespace(state, stabilizers):
... """Apply the projector one (1 + S) factor at a time, then renormalize.
...
... The factors commute, so their product is itself a projector and the order does
... not matter. Multiplying it out would build a dense 2^2N x 2^2N operator; applied
... factor by factor this stays a handful of sparse matrix-vector products.
...
... Each factor is really (1 + S)/2, but every one contributes the same scalar, so the
... halvings only rescale the result and the final normalization absorbs them.
... """
... for stabilizer in stabilizers:
... state += to_sparse_matrix(stabilizer) @ state
... return state / np.linalg.norm(state)
>>>
>>> occupation = [1, 0, 1, 0, 1, 0, 0, 1, 0] # site occupations of the initial state
>>> initial_reference = basis_state(occupation, num_sites)
>>> initial_encoded = project_onto_codespace(
... basis_state(occupation, num_qubits), plaquette_stabilizers
... )
>>>
>>> evolved_encoded = expm_multiply(
... -1j * evolution_time * to_sparse_matrix(encoded), initial_encoded
... )
>>> evolved_reference = expm_multiply(
... -1j * evolution_time * to_sparse_matrix(reference), initial_reference
... )
>>>
>>> def density(state, qubit, width):
... """Site density <n_j> = (1 - <Z_j>) / 2 of a statevector."""
... z = to_sparse_matrix(
... SparseObservable.from_sparse_list([("Z", [qubit], 1.0)], num_qubits=width)
... )
... return (1 - (state.conj() @ (z @ state)).real) / 2
>>>
>>> encoded_densities = [density(evolved_encoded, j, num_qubits) for j in range(num_sites)]
>>> reference_densities = [density(evolved_reference, j, num_sites) for j in range(num_sites)]
>>>
>>> for j, density_value in enumerate(encoded_densities):
... print(f"site {j}: {density_value:.10f}")
site 0: 0.6750993356
site 1: 0.3689791258
site 2: 0.6750993356
site 3: 0.3818130396
site 4: 0.4868585996
site 5: 0.3818130396
site 6: 0.2693544696
site 7: 0.4916285849
site 8: 0.2693544696
>>> np.allclose(encoded_densities, reference_densities, atol=1e-12)
True
Це закодовані густини; вони збігаються з еталоном Йордана-Вігнера з машинною точністю на ґратці, чиє кодування вірне лише після проєкції.
10. Виміряй збіжність Троттера
Крок 8 встановив, що два синтези збігаються між собою, а це твердження про еквівалентність, а не про точність: обидва описують той самий крок Троттера, тож несуть ту саму похибку Троттера. Вимірювання цієї похибки потребує точного еталона, і крок 9 щойно його побудував.
Кількість кроків надходить від FermionicLieTrotter, ферміонного аналога формули рівня кубітів, використаної раніше як базова лінія. Застосований до всього згрупованого оператора перед розкладанням, його повторення циклічно проходить між чотирма потоками та діагональною групою, а не всередині жодного з них:
>>> initial_state = project_onto_codespace(
... basis_state(occupation, num_qubits), plaquette_stabilizers
... )
>>> exact = expm_multiply(
... -1j * total_time * to_sparse_matrix(encoded), initial_state
... )
>>>
>>> def trotter_fidelity(steps):
... # `steps` goes to the fermionic formula inside transpile_trotter_step, so the
... # repetition cycles between the flow sets rather than within any one of them
... circuit = transpile_trotter_step(
... rows, cols, FlowSetSynthesis(rows, cols), steps=steps
... )
... evolved = Statevector(initial_state).evolve(circuit.decompose(reps=6))
... return abs(np.vdot(evolved.data, exact)) ** 2
>>>
>>> for steps in (1, 2, 4, 8):
... print(f"{steps} step(s): fidelity {trotter_fidelity(steps):.6f}")
1 step(s): fidelity 0.053820
2 step(s): fidelity 0.704014
4 step(s): fidelity 0.932937
8 step(s): fidelity 0.982687
Вірність монотонно зростає до одиниці в міру скорочення кроків, що є поведінкою, яку має показувати формула добутку. Скорочення кроків — лише одна з двох ручок; FermionicSuzukiTrotter обмінює їх одну на одну на вищому порядку, як вимірює Синтез вентиля Evolution у ферміонному просторі.
Один крок цього гамільтоніана при є поганим наближенням (чотири потоки переходу та взаємодія не комутують), тож перший рядок далекий від одиниці, і в цьому суть: збіжність підтверджує побудову, а не якийсь один рядок.
Кожен множник розкладу охоплює кожну моду цієї ґратки, тож звуження множника до його носія тут є порожньою операцією, а абсолютні індекси кубітів, за якими зіставляє FlowSetSynthesis, не змінюються. Синтез, написаний для оператора, групи якого торкаються лише частини регістра, мав би враховувати це перепозначення.
Що варто запам'ятати
-
Два виміри змінюють облік, а не рецепт. Кожен інгредієнт із посібника для 1D переноситься без змін:
groupsдля групування,map_transfer_vertex_generators()для кодування,CustomF2QLayoutдля невідповідного регістра таMapperFnEvolutionSynthesisдля підключення його до транспайлера. Зросла кількість випадків усередині кожного. -
Розділеність робить 2D сталої глибини. Потік розкладається на вершинно не перетинні лінії, тож його кліфорди випускаються паралельно й поділяють два двокубітні шари незалежно від розміру ґратки. Чотири потоки та діагональна група дають фіксований бюджет: тут для чотирьох потоків плюс для діагональної групи взаємодії.
-
Кожна орієнтація — це власна форма Паулі. перетворює
XXZнаYYZ, аXYXYнаYXXY, тож чотири потоки потребують чотирьох кліфордів. Припущення, що один кліфорд покриває обидві орієнтації осі, — найлегший спосіб помилитися в 2D-синтезі потоків. -
Зчитуй носії зі спряження. В 1D один «цегляний» шар
CZдає образи в замкненому вигляді. У 2D такої формули немає, а лінії стовпців мають неперервні лише подекуди ансили, тож виведене вручну припущення, куди потрапляє кожен доданок, крихке. Спряження й перевірка результату коштують одну побудовуCliffordі не можуть бути хибними; це також означає, що однаково дійсний кліфорд, який переносить доданки в інше місце, і далі працює. -
Дай групуванню зробити сортування. Оскільки
Evolutionрозбивається по групах, синтез отримує один потік за раз і ніколи не мусить заново виводити розбиття: він зчитує одну форму Паулі з першого доданка й вибирає відповідний кліфорд. Припущення цього та видача помилки, коли воно не виконується, і тримаєsynthesizeкоротким. -
Кількість кроків лежить над розбиттям. Синтез тут бачить один потік на вентиль, тож не може ділити еволюцію між потоками.
FermionicLieTrotterробить це у ферміонному просторі, до розкладання, що й робить можливим тест збіжності взагалі. -
Перевіряй на підпросторі. -кубітне кодування мод вірне лише там, де це кажуть стабілізатори. Оскільки VC зберігає , найдешевша надійна перевірка — спроєктувати добутковий стан і порівняти густини: без інтертвайнера й без спектрального зіставлення, яке ненадійне, коли обмежений гамільтоніан виродженний.
Закодований гамільтоніан комутує зі стабілізаторами, тож проєкцію достатньо застосувати лише до початкового стану. Еволюція зберігає підпростір сама. На реальному обладнанні це також робить кодування корисним як ресурс виявлення помилок: стабілізатори можна вимірювати.
Далі
Два напрями розширюють сказане вище, і обидва є зміною інгредієнта, а не методу. Перший — кодування: Верстрате-Кірак було обрано тут, бо Ref. [1] наводить його діагоналізуючі кліфорди в замкненому вигляді, але Дербі-Классен витрачає одну ансилу на непарну плакетку замість однієї на вузол, для відношення кубітів до мод замість . Його оператори переносу мають інші форми Паулі, тож перенесення зводиться до переписування функції кодування з кроку 3 і пошуку кліфорда для кожної нової форми; групування, розкладка та каркас синтезу переносяться без змін.
Другий — сама ґратка. Усе тут припускає відкриті межі та один ферміонний вид. Періодичні межі замикають кожну лінію в цикл: стрілки, як і раніше, комутують, бо кожна стикується з наступною «голова до хвоста», але дві форми кліфордів з кроку 5 побудовано для лінії з кінцями. Цикл із вузлів несе зв'язків на орієнтацію, де лінія несе , тож зв'язок-обгортка не має місця в побудові й потребує окремого поводження. Спінова модель простіша: вона подвоює регістр і додає локальні члени взаємодії до діагональної групи, не торкаючись потоків. Жодне не порушує аргумент сталої глибини, але обидва змінюють облік, який, як каже перший висновок, і є тим, що два виміри роблять більшим.
Зв'язність обладнання тут не розглядається. Кожна схема вище побудована на абстрактному регістрі, де будь-яка пара кубітів може взаємодіяти, і наведені глибини пораховано в цьому режимі. Реальні пристрої мають обмежену карту зв'язності, і відображення цього кодування на неї — суттєва задача сама по собі: регістр VC є графом із кубітів, чиї вентилі діють між вузлом і його власною ансилою, між сусідніми вузлами та між сусідніми ансилами, тож розкладка має утримувати всі три види пар сусідніми одночасно. Якщо це неможливо, транспайлер вставляє вентилі SWAP, а шари, які вони додають, — це те, що конструкція зі сталою глибиною намагалася відіграти. Ref. [1] так само наводить свою цифру глибини за припущення квадратного графа зв'язності з CX як єдиним рідним заплутувачем. Пошук хороших розкладок і стратегій маршрутизації для локальних кодувань на заданій топології пристрою виходить за межі цього посібника.
A. Gandon, S. Piccinelli, M. Rossmannek, F. Tacchino, A. Baiardi, J. Nys, and I. Tavernelli, Stabilizer-based quantum simulation of fermion dynamics with local qubit encodings (2026), arXiv:2512.11418v2.
F. Verstraete and J. I. Cirac, Mapping local Hamiltonians of fermions to local Hamiltonians of spins, J. Stat. Mech. 2005, P09012 (2005).
Y.-A. Chen and Y. Xu, Equivalence between fermion-to-qubit mappings in two spatial dimensions, PRX Quantum 4, 010326 (2023).