انتقل إلى المحتوى الرئيسي
هذا المشروع في مرحلة تجريبية. وجدت خطأ؟ افتح مشكلة على GitHub. لديك سؤال أو فكرة؟ اسأل في Discussions — نحب سماع ملاحظاتك!
كيف جودة الترجمة في هذه الصفحة؟

محاكاة ديناميكيات Fermi-Hubbard أحادية البعد باستخدام مجموعات التدفق

مهم

الدوال الموصوفة في هذا الدليل متاحة حاليًا فقط في واجهة Python البرمجية. ستتوفر وظائف مكافئة في واجهة C API في إصدار قادم.

هذا الدليل يبني دائرة لـالديناميكيات الزمنية لنموذج Fermi-Hubbard أحادي البعد، من كتابة الهاميلتوني إلى فحص كثافات المواقع المتطورة. اللي يجعل هذا المسار مثيرًا فكرة واحدة، مأخوذة من إطار مجموعات التدفق (flow sets) لـ Gandon وزملائه [1]، وهو العمل الذي ألهم أيضًا TransferVertexOperator في هذه الحزمة:

بدل تجميع حدود الهاميلتوني بعد التحويل من فرميون إلى كيوبت، جمّعها قبله، في مجموعات تدفق، وهي مجموعات جزئية أحادية البعد من الرسم البياني الموجَّه للتفاعلات الفرميونية، ومُشغِّلات النقل فيها تتبادل الإبدال فيما بينها.

هذا الاختيار يقود كل ما يلي. وهو يتطلب التالي:

  • ترميز مخصص من فرميون إلى كيوبت مصمَّم لمجموعات التدفق، يصرف هنا كيوبت مساعدًا (ancilla) واحدًا بحيث ما يعود عدد الكيوبتات المرتبط بتنفيذ حد هاميلتوني معيّن ينمو مع عدد الأنماط الفرميونية (كما هو الحال في Jordan-Wigner (JW)).

  • تركيب مخصص يستفيد من الإبدال داخل كل مجموعة. والعائد دائرة عمق بوابات الكيوبتين فيها ثابت بالنسبة لحجم النظام، مقابل النمو الخطي في تقسيم Trotter حدًا بحد المعتمد على JW.

الآلية التي تُدخل ترميزًا خاصًا بك في المترجم (CustomF2QLayout وMapperFnEvolutionSynthesis) تنطبق على أي نموذج.

انظر أيضًا

دليل الترجمة لمراحل المترجم المشار إليها طوال الدليل، ودليل المُخطِّطات للوصفة العامة لكتابة مُخطِّط مخصص.

مُشغِّلات النقل ومجموعات التدفق​

TransferVertexOperator يُبنى من مُشغِّلات الرؤوس VjV_j ومُشغِّلات النقل TjkT_{jk}. بدلالة مُشغِّلات الإنشاء والفناء الفرميونية،

Vj=1−2aj†aj ,Tjk=−12(aj†−aj)(ak†+ak) ,V_j = 1 - 2 a^\dagger_j a_j \, , \qquad T_{jk} = -\tfrac{1}{2} \left( a^\dagger_j - a_j \right) \left( a^\dagger_k + a_k \right) \, ,

فـVjV_j يقيس إشغال النمط jj (قيمته +1+1 لما يكون فارغًا و−1-1 لما يكون ممتلئًا)، بينما TjkT_{jk} ينقل فرميونًا على طول الحافة الموجَّهة j→kj \to k. لاحظ أن مؤشري TjkT_{jk} ليسا قابلين للتبديل. المؤشر الأول يحمل التركيبة السالبة والثاني يحمل الموجبة، وهذا ما يجعل TjkT_{jk} مختلفًا عن TkjT_{kj} ويعطي الحافة اتجاهها.

هذه المُشغِّلات تحقق علاقات إبدال مختلطة. مُشغِّلا نقل يعملان على مواقع منفصلة يتبادلان الإبدال بشكل بديهي، لأن كلًا منهما جداء زوجي من المُشغِّلات الفرميونية. الحالة المثيرة هي الموقع المشترك. هناك، يكون المُشغِّلان تبادليين إذا تدفقت الأسهم عبر ذلك الموقع (واحد يصل وواحد يغادر)، وتضادّيين (anticommute) إذا تصادمت (كلاهما يصل، أو كلاهما يغادر).

القاعدة أسهل ما تكون على الرسم. رسم الرؤوس كعُقد وكل TjkT_{jk} كسهم من jj إلى kk يعطي الرسم البياني الموجَّه للتفاعلات، هنا للسلسلة ذات المواقع الأربعة المستخدمة طوال هذا الدليل، وهي تحمل الاتجاهين لكل رابطة:

A four-site chain with both orientations of every bond drawn as arrows.

تتبّع القاعدة على الشكل. انظر إلى T1,2T_{1,2} وT2,3T_{2,3} مثلًا. سهم واحد يصل إلى الموقع 2 والآخر يغادره، فالفرميون يتدفق مباشرة عبره والمُشغِّلان يتبادلان الإبدال. وانظر بدلًا من ذلك إلى T1,2T_{1,2} وT3,2T_{3,2}. كلا السهمين يشيران إلى الموقع 2. يتصادمان، وهذان المُشغِّلان يتضادّان.

هذا هو الذي يجعل مجموعات التدفق ممكنة. اختر مجموعة أسهم تشكّل مسارًا موجَّهًا فكل زوج إما يلتقي رأسًا بذيل أو لا يلتقي أبدًا، فتتبادل المجموعة كلها الإبدال، حتى لو كانت المُشغِّلات الفردية تتداخل في مواقع مشتركة. فالتطور تحت مجموعة تدفق واحدة ما فيه خطأ Trotter، وكما تبيّن الخطوة 7، يمكن تنفيذه أيضًا بعمق دائرة ثابت.

1. هاميلتوني Fermi-Hubbard​

نموذج Fermi-Hubbard أحادي البعد على LL موقعًا (بدون سبين، بحدود مفتوحة) هو

H=−t∑j(aj†aj+1+aj+1†aj)+U∑jnjnj+1 .H = -t \sum_{j} \left( a^\dagger_j a_{j+1} + a^\dagger_{j+1} a_j \right) + U \sum_{j} n_j n_{j+1} \, .

باتباع المعادلة (9) من المرجع [1]، حد القفز هو t∑j(Tj,j+1+Tj+1,j)t \sum_j (T_{j,j+1} + T_{j+1,j}). التفاعل قطري، فيُعبَّر عنه بمُشغِّلات الرؤوس باستخدام nj=(1−Vj)/2n_j = (1 - V_j)/2:

njnj+1=14(1−Vj−Vj+1+VjVj+1) .n_j n_{j+1} = \tfrac{1}{4}\left( 1 - V_j - V_{j+1} + V_j V_{j+1} \right) \, .

كلا الجزأين يُبنيان مباشرة كـTransferVertexOperator. لاحظ أن VjV_j يُخزَّن كمدخل قطري (j, j):

>>> from collections import defaultdict
>>> from qiskit_fermions.operators import TransferVertexOperator
>>>
>>> def fermi_hubbard_1d(num_sites, tunneling, interaction):
... data: defaultdict[tuple[tuple[int, int], ...], complex] = defaultdict(complex)
... for j in range(num_sites - 1):
... k = j + 1
... # 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)
>>>
>>> num_sites = 4
>>> hamiltonian = fermi_hubbard_1d(num_sites, tunneling=1.0, interaction=2.0)
>>> print(format(hamiltonian))
1.500000e0 +0.000000e0j * ()
-5.000000e-1 +0.000000e0j * (V(0))
5.000000e-1 +0.000000e0j * (V(0) V(1))
1.000000e0 +0.000000e0j * (T(0,1))
1.000000e0 +0.000000e0j * (T(1,0))
-1.000000e0 +0.000000e0j * (V(1))
5.000000e-1 +0.000000e0j * (V(1) V(2))
1.000000e0 +0.000000e0j * (T(1,2))
1.000000e0 +0.000000e0j * (T(2,1))
-1.000000e0 +0.000000e0j * (V(2))
5.000000e-1 +0.000000e0j * (V(2) V(3))
1.000000e0 +0.000000e0j * (T(2,3))
1.000000e0 +0.000000e0j * (T(3,2))
-5.000000e-1 +0.000000e0j * (V(3))

المواقع الداخلية تحصل على معامل −1-1 على VjV_j لأنها تظهر في رابطتين، بينما مواقع الحدود تظهر في رابطة واحدة فقط. كل حد مذكور هنا يظهر في الرسم البياني المرسوم أعلاه. حدود T(j,k) هي أسهمه، وحدود V(j) هي عقده.

2. التقسيم إلى مجموعات تدفق​

بعدها، سمِّ كل حد بمجموعة التدفق التي ينتمي إليها. الخاصية groups موجودة لهذا الغرض. بوابة Evolution على مُشغِّل مجمَّع تتفكك إلى Evolution واحدة لكل مجموعة (الخطوة 6 تستفيد من هذا)، فالتجميع المقرَّر هنا على المستوى الفرميوني يبقى في الدائرة.

بالنسبة للسلسلة أحادية البعد، مجموعتا تدفق القفز هما الأسهم الموجَّهة شرقًا (Tj,j+1T_{j,j+1}) والأسهم الموجَّهة غربًا (Tj+1,jT_{j+1,j}). حدود التفاعل القطرية تتبادل الإبدال مع كل ما هو قطري وتشكّل مجموعة ثالثة:

>>> def flow_set_groups(operator):
... groups = []
... for terms, _ in operator.iter_terms():
... match terms:
... case [(left, right)] if right == left + 1:
... groups.append(0) # east-oriented: T_{j,j+1}
... case [(left, right)] if right == left - 1:
... groups.append(1) # west-oriented: T_{j+1,j}
... case _:
... groups.append(2) # diagonal interaction terms
... return groups
>>>
>>> hamiltonian.groups = flow_set_groups(hamiltonian)
>>> hamiltonian.num_groups()
3
>>> for index, group in enumerate(hamiltonian.split_out_groups()):
... print(index, sorted(terms for terms, _ in group.iter_terms()))
0 [[(0, 1)], [(1, 2)], [(2, 3)]]
1 [[(1, 0)], [(2, 1)], [(3, 2)]]
2 [[], [(0, 0)], [(0, 0), (1, 1)], [(1, 1)], [(1, 1), (2, 2)], [(2, 2)], [(2, 2), (3, 3)], [(3, 3)]]

المجموعتان 0 و1 هما مجموعتا التدفق. كل مجموعة مسار موجَّه على طول السلسلة، فبخاصية التدفق كل الحدود داخل المجموعة تتبادل الإبدال.

كل مجموعة كمان لازم تكون هرميتية بذاتها، لأن صيغة الجداء تأخذ أسّ المجموعات كل على حدة، وفقط العامل الهرميتي له تطور أحادي (unitary). groups_are_hermitian() يؤكد ذلك:

>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]

هذا يستحق وقفة، لأن مجموعتي القفز أحاديتا الاتجاه: المجموعة 0 فيها حدود موجَّهة شرقًا فقط والمجموعة 1 غربًا فقط. تقسيم هاميلتوني بهذه الطريقة عادةً يكسر الهرميتية، لأن aj†aka^\dagger_j a_k يحتاج شريكه المرافق ak†aja^\dagger_k a_j معه. TransferVertexOperator يتفادى المشكلة لأن TjkT_{jk} هرميتي أصلًا بذاته. إنه جداء مُشغِّلي Majorana (شوف تعريفه في الخطوة 1)، مش aj†aka^\dagger_j a_k مجردًا، فصورته في Jordan-Wigner تحمل معاملًا حقيقيًا بدل تخيلي. فاختيار تمثيل المُشغِّل هذا هو ما يجعل مجموعة تدفق أحادية الاتجاه عامل Trotter مشروعًا أصلًا.

ملاحظة

تسميات الشرق/الغرب المستخدمة في هذا الدليل هندسية، مع رسم السلسلة من اليسار إلى اليمين وزيادة فهارس الأنماط في ذلك الاتجاه؛ Tj,j+1T_{j,j+1} يشير شرقًا وTj+1,jT_{j+1,j} يشير غربًا. المرجع [1] غير متسق داخليًا في هذه التسمية (نصه وأشكاله يتعارضان) فهذا الدليل عمدًا لا يتبع النص، بل يُبقي التسميات من الرسم. لما تقارن مع الورقة، طابق مجموعات التدفق بأوزان باولي (1 و3 أدناه)، مش بكلمات الاتجاهات.

3. كتابة الترميز المخصص​

القسم IV B من المرجع [1] يصنّف الأشكال التي يمكن أن يتخذها ترميز محلي من فرميون إلى كيوبت عند تقييده بمجموعة تدفق. Jordan-Wigner العادي يستخدم Nq=NfN_q = N_f كيوبت ويحوّل كل حد قفز إلى باولي بوزن 2. التصنيف يبيّن أن صرف كيوبتات إضافية يشتري بنية في المُشغِّلات المرمَّزة.

الترميز المنفذ هنا هو البناء ذو تعديل التكافؤ الموزَّع (parity-delocalized) في الملحق B 2 (المعادلتان B4 وB5)، الذي يستخدم كيوبتًا مساعدًا واحدًا، فيكون Nq=Nf+1N_q = N_f + 1. تكافؤ الفرميونات يُوزَّع على زوج من الكيوبتات:

Vj=ZjZj+1 ,Tj,j+1=−12Xj+1 ,Tj+1,j=+12ZjXj+1Zj+2 .V_j = Z_j Z_{j+1} \, , \qquad T_{j,j+1} = -\tfrac{1}{2} X_{j+1} \, , \qquad T_{j+1,j} = +\tfrac{1}{2} Z_j X_{j+1} Z_{j+2} \, .

المؤشر j+2j+2 في التعبير الأخير يفرض الكيوبت الإضافي. الرابطة الأخيرة في السلسلة فيها j=Nf−2j = N_f - 2، فـZj+2Z_{j+2} فيها يقع على الكيوبت NfN_f، أي بعد آخر نمط فرميوني Nf−1N_f - 1 بواحد. ذلك الكيوبت الأخير هو المساعد، ولهذا Nq=Nf+1N_q = N_f + 1 وليس NfN_f. نفس الامتداد يظهر في VjV_j، الذي Zj+1Z_{j+1} فيه على الكيوبت المساعد عندما j=Nf−1j = N_f - 1.

العائد يظهر فورًا؛ مجموعة تدفق كاملة تُحوَّل إلى مجموع من باوليات بوزن 1.

ملاحظة

العوامل البادئة 12\tfrac{1}{2} تحددها المعايرة Tjk2=14T_{jk}^2 = \tfrac{1}{4}، والإشارة السالبة النسبية بين الاتجاهين تحددها متطابقة الجداء Tj,j+1=−VjVj+1Tj+1,jT_{j,j+1} = -V_j V_{j+1} T_{j+1,j}، التي تحققها باوليات أعلاه. الخطأ في أي منهما يعطي مُشغِّلًا يحترم علاقات الإبدال لكنه ما عاد يمثل نفس الهاميلتوني، فلازم تتحقق من الترميز عدديًا، كما هو موضح في الخطوة 4.

كتابة الترميز تعني كتابة دالة تحوّل مُشغِّل نقل معمَّمًا واحدًا إلى سلسلة باولي؛ وmap_transfer_vertex_generators() يتولى التكرار على الحدود وتركيبها:

>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_transfer_vertex_generators
>>>
>>> def flow_set_action(action, num_qubits):
... match action:
... case (j, k) if j == k:
... # vertex operator: V_j = Z_j Z_{j+1}
... return SparseObservable.from_sparse_list(
... [("ZZ", [j, j + 1], 1.0)], num_qubits=num_qubits
... )
... case (j, k) if k == j + 1:
... # east-oriented transfer operator: weight 1
... return SparseObservable.from_sparse_list(
... [("X", [k], -0.5)], num_qubits=num_qubits
... )
... case (j, k) if k == j - 1:
... # west-oriented transfer operator: weight 3
... return SparseObservable.from_sparse_list(
... [("ZXZ", [k, k + 1, k + 2], 0.5)], num_qubits=num_qubits
... )
... case _:
... raise ValueError(f"not a nearest-neighbor transfer operator: {action}")
>>>
>>> def flow_set_encoding(operator, num_qubits):
... return map_transfer_vertex_generators(
... operator,
... lambda action: flow_set_action(action, num_qubits),
... identity=lambda: SparseObservable.identity(num_qubits),
... compose=SparseObservable.compose,
... ).simplify()
مهم

MapperFnEvolutionSynthesis.mapper_fn يتوقع التوقيع (operator, num_qubits)، ولازم يكون نوع الإرجاع SparseObservable، وهذا يجعل الدالة قابلة للاستخدام مباشرة كإضافة للمترجم. شوف الخطوة 6.

تطبيقه على الهاميلتوني من الخطوة 1 يعطي المُشغِّل المرمَّز على Nf+1=5N_f + 1 = 5 كيوبتات:

>>> num_qubits = num_sites + 1
>>> encoded = flow_set_encoding(hamiltonian, num_qubits)
>>> for label, indices, coeff in sorted(encoded.to_sparse_list()):
... print(f"{label:3s} {str(indices):12s} {coeff.real:+.2f}")
[] +1.50
X [1] -0.50
X [2] -0.50
X [3] -0.50
ZXZ [0, 1, 2] +0.50
ZXZ [1, 2, 3] +0.50
ZXZ [2, 3, 4] +0.50
ZZ [0, 1] -0.50
ZZ [0, 2] +0.50
ZZ [1, 2] -1.00
ZZ [1, 3] +0.50
ZZ [2, 3] -1.00
ZZ [2, 4] +0.50
ZZ [3, 4] -0.50

فيه 14 حدًا، بثلاثة أشكال: القفز صار X وZXZ، والتفاعل ZZ، و[] في البداية هو المطابقة (identity)، ويسهم فقط بطور عام. لاحظ أن حدود ZZ تصل إلى الجيران الأقرب والتالين؛ VjVj+1V_jV_{j+1} يمتد من الكيوبت jj إلى j+2j+2، وهذه تكلفة توزيع التكافؤ.

التجميع حسب مجموعة التدفق يكشف اللاتماثل الذي يشتريه البناء. اسرد أوزان باولي الموجودة في كل مجموعة:

>>> for index, group in enumerate(hamiltonian.split_out_groups()):
... weights = {len(indices) for _, indices, _ in flow_set_encoding(group, num_qubits).to_sparse_list()}
... print(index, sorted(weights))
0 [1]
1 [3]
2 [0, 2]

مجموعة التدفق الموجَّهة شرقًا (المجموعة 0) تتكون بالكامل من باوليات بوزن 1. فتطورها الزمني طبقة من دورانات الكيوبت الواحد ولا يحتاج بوابات تشابك، بينما في Jordan-Wigner كل حد قفز بوزن 2 ويحتاجها. هذه هي المقايضة بين المكان والزمان في المرجع [1] في أبسط صورها: كيوبت إضافي واحد يحوّل نصف هاميلتوني القفز إلى دورانات كيوبت واحد.

مجموعة التدفق الموجَّهة غربًا (المجموعة 1) تدفع ثمن ذلك بوزن ثلاثة، والتفاعل (المجموعة 2) عند وزن اثنين، حيث مدخل الوزن صفر هو المطابقة. لا شيء من هذا ادعاء عن دائرة بعد؛ وما ينتجه التركيب من هذه الأوزان هو موضوع الخطوتين 6 و7.

4. التحقق من الترميز​

الترميز المخصص ما يفيد إلا إذا كان أمينًا (faithful)، والترميز المكتوب بخط اليد لازم يُفحص. ولأن هذا الترميز يستخدم Nf+1N_f + 1 كيوبت، فإن فضاء هيلبرت للكيوبتات ضعف الفضاء الفرميوني، فالهاميلتوني المرمَّز ما يقدر يساوي هاميلتوني Jordan-Wigner حدًا بحد. الاثنان مرتبطان بتماثل قياسي (isometry) بدلًا من ذلك.

من أين يأتي التماثل القياسي: في Jordan-Wigner الحالة الأساسية هي سلسلة الإشغال؛ الكيوبت jj يحمل njn_j. هذا الترميز بدل ذلك يخزّن تكافؤات تراكمية. عرِّف

b0=0 ,bj+1=n0⊕n1⊕⋯⊕nj ,b_0 = 0 \, , \qquad b_{j+1} = n_0 \oplus n_1 \oplus \dots \oplus n_j \, ,

فالبت bj+1b_{j+1} يسجّل تكافؤ كل الإشغالات حتى الموقع jj شاملًا. فيه Nf+1N_f + 1 بتًا من هذا النوع لـNfN_f إشغالًا، ومن هنا يأتي الكيوبت المساعد. الإشغال يُستعاد كـفرق بين البتات المتجاورة، nj=bj⊕bj+1n_j = b_j \oplus b_{j+1}. فالموقع المشغول يظهر كـجدار نطاق (domain wall) بين بتّين متجاورين، ولهذا يُسمى ترميز جدار النطاق (أو Kramers-Wannier). وهذا أيضًا سبب أن Vj=ZjZj+1V_j = Z_j Z_{j+1}: جداء مُشغِّلَي ZZ متجاورين يقرأ ذلك الفرق.

لو كتبنا هذه الخريطة لبضع حالات، يصير الهيكل واضحاً (لاحظ أن b0=0b_0 = 0 ثابتة، فنصف حالات الكيوبت فقط من أصل 2Nf+12^{N_f+1} موجودة في الصورة، ولهذا هي إيزومترية وليست وحدوية):

>>> def domain_wall_bits(occupations):
... parity = 0
... bits = [0]
... for occupation in occupations:
... parity ^= occupation
... bits.append(parity)
... return bits
>>>
>>> for occupations in ([0, 0, 0, 0], [1, 0, 0, 0], [1, 1, 0, 0], [1, 0, 1, 0]):
... bits = domain_wall_bits(occupations)
... # little-endian: reversed() prints qubit 0 on the right, as Qiskit labels do
... print("".join(map(str, occupations)), "->", "".join(str(b) for b in reversed(bits)))
0000 -> 00000
1000 -> 11110
1100 -> 00010
1010 -> 00110

فرميون واحد على الموقع 0 يقلب كل التكافؤات التراكمية فوقه، وهذا هو عدم التموضع أثناء العمل. لما نجمع هذه الأعمدة في مصفوفة نحصل على الإيزومترية WW، ويكون الترميز أميناً عندما يربط بين الهاملتونيتين:

H^flow set W=W H^JW .\hat{H}_{\text{flow set}} \, W = W \, \hat{H}_{\text{JW}} \, .

يعني أن تحويل حالة فرميونية إلى فضاء الكيوبتات ثم تطويرها هناك لازم يعطي نفس النتيجة لو طورناها في الفضاء الفرميوني أولاً ثم حولناها بعد ذلك.

>>> import numpy as np
>>> from qiskit.quantum_info import SparsePauliOp
>>> from qiskit_fermions.mappers.library import transfer_vertex_jordan_wigner
>>>
>>> def to_matrix(observable):
... # SparseObservable carries no dense-matrix method, so route through
... # SparsePauliOp. This is only ever needed for the checks below --
... # the encoding itself never leaves SparseObservable.
... return SparsePauliOp.from_sparse_observable(observable).to_matrix()
>>>
>>> def domain_wall_isometry(num_sites):
... isometry = np.zeros((2 ** (num_sites + 1), 2**num_sites), dtype=complex)
... for index in range(2**num_sites):
... occupations = [(index >> j) & 1 for j in range(num_sites)]
... bits = domain_wall_bits(occupations)
... isometry[sum(b << j for j, b in enumerate(bits)), index] = 1.0
... return isometry
>>>
>>> isometry = domain_wall_isometry(num_sites)
>>> np.allclose(isometry.conj().T @ isometry, np.eye(2**num_sites))
True
>>>
>>> def intertwines(operator):
... """Whether both mappings of `operator` agree through the isometry."""
... flow = to_matrix(flow_set_encoding(operator, num_qubits))
... reference = to_matrix(transfer_vertex_jordan_wigner(operator, num_sites).simplify())
... return np.allclose(flow @ isometry, isometry @ reference)
>>>
>>> intertwines(hamiltonian)
True
>>> # and, more strongly, once per flow set: a product formula evolves the groups
>>> # separately, so each one has to be intertwined on its own
>>> all(intertwines(group) for group in hamiltonian.split_out_groups())
True

علاقة الربط هذه تتحقق بالضبط، ولكل مجموعة تدفق على حدة. هذا التأكيد الثاني الأقوى هو اللي تعتمد عليه الخطوات اللاحقة: صيغة الضرب تطور المجموعات واحدة واحدة، وحاصل ضرب عوامل مترابطة هو نفسه مترابط، فأي تقريب مبني من هذه المجموعات يتطابق بين الترميزين مع إن الترميزين مختلفين. ونتيجة لذلك، لازم تتطابق الأطياف، وكل قيمة ذاتية في Jordan-Wigner تظهر مرتين في الفضاء الأكبر، مرة لكل قيمة من التكافؤ العام غير المقيد:

>>> jw_matrix = to_matrix(transfer_vertex_jordan_wigner(hamiltonian, num_sites).simplify())
>>> flow_matrix = to_matrix(flow_set_encoding(hamiltonian, num_qubits))
>>>
>>> jw_spectrum = np.linalg.eigvalsh(jw_matrix)
>>> flow_spectrum = np.linalg.eigvalsh(flow_matrix)
>>> np.allclose(
... np.sort(flow_spectrum),
... np.sort(np.concatenate([jw_spectrum, jw_spectrum])),
... )
True
>>> print(f"{jw_spectrum[0]:.10f} {flow_spectrum[0]:.10f}")
-1.8557725066 -1.8557725066

5. بناء الدائرة الفرميونية​

قبل ما نستخدم أي كيوبتات، نعبر عن الديناميكيات على المستوى الفرميوني. الـ FermionicCircuit تعمل على أنماط فرميونية بدل الكيوبتات، وبوابة Evolution على الهاملتونية هي الدائرة كلها:

>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicLieTrotter
>>>
>>> total_time = 1.0
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(
... Evolution(
... num_sites, hamiltonian, time=total_time, synthesis=FermionicLieTrotter()
... ),
... circuit.modes,
... )
>>> circuit.draw("mpl")
<Figure size ... with 1 Axes>

A single Evolution gate spanning all four fermionic modes.

هناك صندوق مبهم واحد فوق الأنماط الأربعة كلها، والجزء المهم هو إلى ماذا يتفكك. الوسيط synthesis يحدد كيف: FermionicLieTrotter هي صيغة الضرب من الرتبة الأولى، وهي تقسم المؤثر حسب groups وتطور كل مجموعة بدورها. يعني تقسيم مجموعات التدفق من الخطوة 2 هو نفسه تقسيم Trotter، ويكفي استدعاء واحد لـ decompose() لتنفيذه، فنحصل على بوابة Evolution واحدة لكل مجموعة تدفق بترتيب المجموعات: الشرق، ثم الغرب، ثم التفاعل القطري، وتحمل 3 و3 و8 حدود على التوالي.

>>> flow_set_circuit = circuit.decompose()
>>> flow_set_circuit.draw("mpl")
<Figure size ... with 1 Axes>

The same circuit decomposed into three Evolution gates, one per flow set.

لاحظ أن كل هذا يحصل داخل الفضاء الفرميوني: لم نذكر أي كيوبت بعد، والتقريب صار فعلاً. لذلك عدد خطوات Trotter مكانه هنا كمان، على شكل reps، الذي يقسم الزمن ويكرر المسح كله:

>>> def trotter_steps(reps):
... step = FermionicCircuit(num_sites)
... step.append(
... Evolution(
... num_sites,
... hamiltonian,
... time=total_time,
... synthesis=FermionicLieTrotter(reps=reps),
... ),
... step.modes,
... )
... return step.decompose()
>>>
>>> trotter_steps(3).count_ops()["Evolution"]
9

تسع بوابات: مجموعات التدفق الثلاث، تدور ثلاث مرات بثلث الزمن في كل مرة. هذا الدوران هو اللي يجعلها Trotterization وليست مجرد إعادة تقطيع لمجموعة واحدة. تكرار مجموعة واحدة ما يحقق شيئاً، لأن الأسيات المتتالية لنفس المؤثر تتبادل، فـ A(t/3) A(t/3) A(t/3) هي ببساطة A(t). الصيغة اللي تقرأ المجموعات تقدر تتبادلها بالتناوب، أما اللي تُطبَّق بعد التحويل فما تقدر، لأن التجميع يكون قد اختفى.

هذه هي الخطوة اللي تجعل التجميع مهماً. كل بوابة ناتجة تُحوَّل وتُركَّب بشكل مستقل، فكل ما بعدها يعمل على التقسيم الذي تقرر في المستوى الفرميوني. تخطي التفكيك يسلم المؤثر كله للمرحلة التالية بدلاً من ذلك، ويتم تجاهل التجميع.

6. التحويل (Transpile) بالترميز المخصص​

الترميز الآن جاهز ليستخدمه المترجم (Transpiler). نحتاج قطعتين إضافيتين:

  • تخطيط (layout). الـ TrivialF2QLayout الافتراضي يفترض كيوبتاً واحداً لكل نمط، وهذا غير صحيح في حالتنا. الـ CustomF2QLayout يربط السجل الفرميوني بـ QuantumRegister بحجم مختلف، وهكذا تدخل كيوبتات ancilla إلى خط المعالجة.

  • إضافة تركيب (synthesis plugin). الـ MapperFnEvolutionSynthesis يأخذ دالة التحويل مباشرة، ويحوّل بها هاملتونية بوابة Evolution، ويُخرج PauliEvolutionGate، مع الحفاظ على اصطلاح e−itHe^{-itH}. الـ product_formula الخاص به يقرر كيف تتحول هذه البوابة إلى دورانات. لاحظ أن هذه صيغة ضرب ثانية ومستقلة، تعمل على الكيوبتات بعد التحويل: هنا نمرر LieTrotter الخاص بـ Qiskit، والخطوة 7 تستبدله بواحدة تعرف عن مجموعات التدفق.

بما أن كل مقارنة لاحقة تعيد تشغيل خط المعالجة هذا، الأفضل نغلفه مرة واحدة:

>>> from qiskit.circuit import QuantumRegister
>>> from qiskit.passmanager import MultiStagePassManager
>>> from qiskit_fermions.transpiler import FermionicCircuitToDAG, QuantumDAGToCircuit
>>> from qiskit_fermions.transpiler.passes import (
... CustomF2QLayout,
... F2QSynthesis,
... MapperFnEvolutionSynthesis,
... )
>>>
>>> def flow_set_pass_manager(mode_register, product_formula=None):
... '''Build the pipeline mapping `mode_register` with the flow-set encoding.'''
... synthesis = F2QSynthesis()
... synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(
... flow_set_encoding, product_formula=product_formula
... )
... # this encoding spends one ancilla on top of the fermionic modes
... qubit_register = QuantumRegister(mode_register.size + 1, "q")
... return MultiStagePassManager(
... input=FermionicCircuitToDAG(),
... layout=CustomF2QLayout({mode_register: qubit_register}),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>>
>>> from qiskit.synthesis import LieTrotter
>>>
>>> pass_manager = flow_set_pass_manager(flow_set_circuit.register, LieTrotter())
>>> qubit_circuit = pass_manager.run(flow_set_circuit)
>>> qubit_circuit.num_qubits
5

أربعة أنماط فرميونية صارت دائرة من خمسة كيوبتات، وكيوبت ancilla جاء من التخطيط المخصص.

الآن لنشوف شو تحتوي الدائرة الناتجة فعلاً. التفكيك المتكرر يختزل بوابات التطور حتى U وCX، بحيث تُقاس كلفة البوابات ثنائية الكيوبت لاستراتيجيتي التركيب المقارنتين أدناه بنفس العملة: count_ops() يعطي عدد CX، وdepth() يأخذ دالة تصفية لقياس العمق ثنائي الكيوبت. لاحظ أن هذا الـ reps هو عمق تفكيك، ولا علاقة له بعدد خطوات Trotter التي تأخذها صيغة الضرب، والتي تحمل هي كمان اسم reps لاحقاً في هذا الدليل:

>>> decomposed = qubit_circuit.decompose(reps=6)
>>> decomposed.count_ops()["cx"]
26
>>> decomposed.depth(lambda instruction: len(instruction.qubits) == 2)
23

ست وعشرون بوابة CX في ثلاث وعشرين طبقة، يعني تقريباً ما في شيء يعمل بالتوازي. رسم الدائرة يوضح السبب:

>>> decomposed.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

The transpiled circuit under LieTrotter&#39;s term-by-term synthesis, a long serial chain of CX ladders.

كل حد تم تجميعه بمعزل عن غيره: تغيير أساس، ثم سلم من CX نزولاً إلى دوران واحد، ثم يُعكس السلم. مجموعة التدفق ذات الوزن 1 مجانية؛ حدودها دورانات منفردة بدون سلم حولها، لكن كل حد بوزن 3 يدفع أربع بوابات CX، ولأن السلالم تُصدر بالتتابع، ما في شيء يتداخل.

إذن التجميع يبقى حياً عبر خط المعالجة كله (ثلاث مجموعات تدفق، ثلاث PauliEvolutionGate محولة) ومع ذلك ما يوفر تقريباً شيئاً. Trotterization الفرميونية في الخطوة 5 أدت دورها؛ اللي يضيّعها هي الصيغة على جانب الكيوبتات. الـ LieTrotter يستلم مجموعة حدود ولا يُخبَر بشيء عنها، فيجمّع كل حد بمعزل. هو ما يقدر يعرف أنها كلها تتبادل، فيسلسل ما كان ممكن يشترك في تغيير أساس واحد.

هذه هي الفجوة اللي وُجد هذا الترميز ليسدها: صيغتا ضرب تعملان، والفرميونية فقط هي اللي أُعطيت الهيكل لحد الآن. الخطوة التالية تقدم تركيباً على جانب الكيوبتات يستغله هو كمان.

7. تركيب يعرف مجموعات التدفق​

استعادة الميزة تعني إخبار التركيب على جانب الكيوبتات عن الهيكل. هذا هو القسم IV C من المرجع [1]: بما أن كل حدود مجموعة التدفق تتبادل، يمكن تقطيرها في وقت واحد بدائرة Clifford واحدة، وتدويرها في أساس مشترك واحد وإرجاعها، بدل مرة لكل حد.

في هذا الترميز، دائرة Clifford بسيطة. التبديل بسلسلة من بوابات CZ على الجيران الأقرب يحوّل

Xj+1  ⟼  ZjXj+1Zj+2X_{j+1} \; \longmapsto \; Z_j X_{j+1} Z_{j+2}

في وقت واحد لكل jj. سلسلة CZ واحدة تحول مجموعة التدفق ذات الوزن 3 (الغربية) كلها إلى طبقة من دورانات XX مستقلة على كيوبت واحد. واجهة EvolutionSynthesis تطلب فقط دالة synthesize()، وهذا كل ما يطبقه هذا الصنف. هو لا يحمل عدد خطوات Trotter، لأنه يستلم مجموعة تدفق واحدة في كل مرة فلا يقدر يقسم التطور عبر المجموعات. هذا التقسيم مكانه مستوى أعلى، في الفضاء الفرميوني، حيث F2FSynthesisPlugin يرى كل المجموعات معاً (انظر الخطوة 9):

>>> from qiskit.circuit import QuantumCircuit
>>> from qiskit.synthesis.evolution import EvolutionSynthesis
>>>
>>> def cz_brickwork(circuit):
... # emit even and odd bonds as two layers, so the CZs are scheduled in
... # parallel: a chain of overlapping CZs has depth 2, not num_qubits - 1
... #
... # NOTE: CZ gates are diagonal, so they all commute and each is its own inverse.
... # This whole layer is therefore self-inverse, which is why the conjugation below
... # calls it twice rather than calling it and then its inverse.
... for offset in (0, 1):
... for j in range(offset, circuit.num_qubits - 1, 2):
... circuit.cz(j, j + 1)
>>>
>>> # The interaction reaches nearest (distance 1) and next-nearest (distance 2)
>>> # neighbors, so every interior qubit carries four Rzz gates and can only do one at
>>> # a time: four layers is the best any schedule can do. These offsets achieve it.
>>> def rzz_layers(circuit, angles):
... """Emit the diagonal Rzz gates in four layers of disjoint qubit pairs."""
... near = [(j, k) for j, k in angles if k - j == 1]
... far = [(j, k) for j, k in angles if k - j == 2]
... schedule = [
... [(j, k) for j, k in near if j % 2 == 0],
... [(j, k) for j, k in far if j % 4 in (0, 1)],
... [(j, k) for j, k in near if j % 2 == 1],
... [(j, k) for j, k in far if j % 4 in (2, 3)],
... ]
... # 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 flow set of the encoded Hamiltonian at a time.'''
...
... preserve_order = True
...
... def synthesize(self, evolution):
... circuit = QuantumCircuit(evolution.operator.num_qubits)
... east, west, diagonal = [], [], {}
...
... for label, indices, coeff in evolution.operator.to_sparse_list():
... angle = 2 * evolution.time * coeff.real
... match label, indices:
... case "", _: # the identity contributes only a global phase
... circuit.global_phase -= angle / 2
... case "X", [qubit]: # east flow set: already weight-1
... east.append((qubit, angle))
... case "ZXZ", [j, k, l] if (k, l) == (j + 1, j + 2):
... west.append((k, angle)) # needs the CZ conjugation below
... case "ZZ", [j, k]: # interaction: diagonal, see rzz_layers above
... diagonal[min(j, k), max(j, k)] = angle
... case _:
... raise ValueError(f"unexpected Pauli term: {label} on {indices}")
...
... # east flow set: bare rotations, no entangling gates at all
... for qubit, angle in east:
... circuit.rx(angle, qubit)
...
... # west flow set: one CZ chain diagonalizes the WHOLE set at once
... if west:
... cz_brickwork(circuit)
... for qubit, angle in west:
... circuit.rx(angle, qubit)
... cz_brickwork(circuit)
...
... if diagonal:
... rzz_layers(circuit, diagonal)
...
... return circuit
ملاحظة

هناك تفصيلتان سهل تفوتك. صنف EvolutionSynthesis مخصص لازم يعرّف خاصية preserve_order، لأن تمريرة التركيب عالي المستوى تقرأها.

كلتا الدالتين المساعدتين تُصدران بواباتهما في طبقات صريحة وليس بترتيب الحدود، لأن depth() يجدول البوابات بأسرع وقت ممكن، بترتيب إضافتها. وبما أن كل البوابات داخل إحدى هذه المجموعات تتبادل، فالترتيب حر الاختيار. التركيب حداً بحد يختار الترتيب بشكل سيئ. إصدار سلسلة CZ المتداخلة بالتتابع يعطي عمقاً ينمو مع عدد الكيوبتات بدل 2 الثابت اللي يقدر العتاد تحقيقه.

ملاحظة

لهذا السبب التركيب أعلاه لا يحمل عدد خطوات Trotter. هو يستلم مجموعة تدفق واحدة لكل بوابة، فأي تكرار داخله سيكرر داخل المجموعة بدل التناوب بين المجموعات:

repeating within one set: A(t/2) A(t/2) B(t/2) B(t/2)
what a Trotterization needs: A(t/2) B(t/2) A(t/2) B(t/2)

الشكل الأول ما يوفر شيئاً: الأسيات المتتالية لنفس المؤثر تتبادل، فـ A(t/2) A(t/2) هي ببساطة A(t) ويبقى الخطأ عند قيمته في خطوة واحدة مهما طلبنا من خطوات.

إذن عدد الخطوات مكانه FermionicLieTrotter الفرميوني من الخطوة 5، الذي يقرأ groups ويقدر يتبادلها بالتناوب. تقسيم العمل هذا هو المقصود: الصيغة الفرميونية تقرر كم خطوة وبأي ترتيب، وهذا الصنف يقرر كم رخيصاً تُنفَّذ كل مجموعة ناتجة.

لاختيار الترتيب، مرره إلى MapperFnEvolutionSynthesis.product_formula، وهو الوسيط اللي يأخذه flow_set_pass_manager. ما يتغير شيء آخر في خط المعالجة (ولا في الدائرة الفرميونية اللي يعمل عليها):

>>> pass_manager = flow_set_pass_manager(flow_set_circuit.register, FlowSetSynthesis())
>>>
>>> flow_circuit = pass_manager.run(flow_set_circuit).decompose(reps=6)
>>> flow_circuit.count_ops()["cx"]
22
>>> flow_circuit.depth(lambda instruction: len(instruction.qubits) == 2)
12
>>> flow_circuit.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

The flow-set-aware circuit, markedly shorter than the previous one, with entangling gates stacked into parallel layers.

من عمق 23 مع 26 بوابة CX إلى عمق 12 مع 22 بوابة CX. لاحظ أن هذا يعني بوابات تشابك أقل وقرابة نصف العمق. عند مقارنة الرسمين، الفرق ليس أن البوابات أرخص، بل أنها تتراكم. السلالم المتسلسلة اختفت؛ بوابات التشابك الآن تجلس فوق بعضها في طبقات مشتركة لأن مجموعة التدفق الغربية كلها تُدوَّر في أساس مشترك واحد والتفاعل يُصدر في طبقات من أزواج منفصلة.

ملاحظة

FlowSetSynthesis يطابق أنماط فهارس الكيوبتات المطلقة، وهذا آمن هنا فقط لأن كل مجموعة تدفق في هذه الهاملتونية تمتد على السجل كله. صيغة الضرب الفرميونية تضيّق كل عامل إلى الأنماط اللي يلمسها فعلاً، فأي تجميع تغطي مجموعاته جزءاً فقط من السلسلة سيصل بترقيم جديد ولن تتطابق الأنماط أعلاه.

ملاحظة

كلا الرسمين يعرضان الدائرة بعد decompose(reps=6)، الذي يعيد كتابة كل شيء بأساس U وCX، فبوابات CZ وRx وRzz اللي يصدرها FlowSetSynthesis ما تظهر كما هي. وهذا مقصود: يضع الدائرتين في نفس الأساس، وهذه الطريقة الوحيدة لتكون أعداد وأعماق البوابات ثنائية الكيوبت أعلاه قابلة للمقارنة. المهم في هذه الأشكال هو بنية الطبقات، وليس أسماء البوابات.

8. عمق ثابت على نطاق واسع​

السؤال المهم ليس العدد عند أربعة أنماط بل كيف يتدرج. طبقتا CZ المتشابكتان ما تنموان مع طول السلسلة، فالعمق ثنائي الكيوبت لخطوة Trotter واحدة لازم يكون ثابتاً. نقيسه مباشرة على هاملتونية Fermi-Hubbard الكاملة، وبعيداً عما يمكن محاكاته بالقوة الغاشمة:

>>> def evolution_stats(num_sites, product_formula):
... operator = fermi_hubbard_1d(num_sites, tunneling=1.0, interaction=2.0)
... operator.groups = flow_set_groups(operator)
...
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(
... num_sites, operator, time=total_time, synthesis=FermionicLieTrotter()
... ),
... circuit.modes,
... )
... circuit = circuit.decompose() # one Evolution per flow set, as in step 5
...
... pass_manager = flow_set_pass_manager(circuit.register, product_formula)
... decomposed = pass_manager.run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return decomposed.count_ops()["cx"], decomposed.depth(two_qubit)
>>>
>>> sites = [4, 10, 20, 50, 100]
>>> flow = [evolution_stats(n, FlowSetSynthesis()) for n in sites]
>>> lie_trotter = [evolution_stats(n, LieTrotter()) for n in sites]
>>>
>>> [depth for _, depth in flow]
[12, 12, 12, 12, 12]
>>> [depth for _, depth in lie_trotter]
[23, 47, 87, 207, 407]

العمق ثنائي الكيوبت يبقى 12 من أربعة أنماط حتى 100، بينما ينمو LieTrotter خطياً إلى 407. مجموعات التدفق تسمح له بقليل من التوازي داخل خط المعالجة، لكن كل مجموعة تبقى Trotterized حداً بحد، فالنمو يتباطأ فقط ولا يزول:

>>> import matplotlib.pyplot as plt
>>>
>>> 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(sites, [stat[index] for stat in lie_trotter], "o-", label="qubit-level")
... _ = panel.plot(sites, [stat[index] for stat in flow], "s-", label="flow set")
... _ = panel.set(xlabel="modes", title=title)
... _ = panel.legend()
>>> figure
<Figure size ... with 2 Axes>

Two-qubit depth against number of modes; the qubit-level LieTrotter grows linearly while the flow-set synthesis stays flat at 12.

عدد البوابات ينمو خطياً في الحالتين (هناك O(N)O(N) حد لتطبيقه، وهذا لا مفر منه)، لكن تركيب مجموعات التدفق يطبقها في عدد ثابت من الطبقات المتوازية. هذه هي نتيجة العمق الثابت في المرجع [1].

ملاحظة

العمق 12 يتوزع على مجموعات التدفق الثلاث كـ 0 + 4 + 8، عند أي طول سلسلة. مجموعة الشرق تساهم بـ صفر؛ هي دورانات Rx صرفة. مجموعة الغرب كلفتها 4، من طبقتي CZ المتشابكتين عند الدخول واثنتين عند الخروج؛ وإسقاط التفاعل يترك عمقاً ثنائي الكيوبت قدره أربعة عند أي طول سلسلة. أما الـ ثمانية المتبقية فهي التفاعل، حدود Rzz فيه تعمل على الجيران الأقرب والتاليين للأقرب معاً فتحتاج أربع طبقات منفصلة، كل منها تكلف بوابتي CX. الأربعة مثلى هنا: كل كيوبت داخلي يحمل أربع بوابات Rzz (اثنتان لكل جار واثنتان لكل جار تالٍ للأقرب) ولا يشارك إلا في واحدة في كل مرة، فما في جدولة تقدر تتفوق على ذلك.

ثلثا العمق هو التفاعل، وهذا يبين أن هذا الترميز يتفوق في القفز (hopping)، وبالتالي في ديناميكيات سلسلة فرميونات حرة. لكن لاحظ أن التفاعل لا يتبادل مع القفز. إهماله ليس تقريباً لـ Fermi-Hubbard بل نموذج مختلف. أين يقع التوازن لمسألة معينة يستحق الاستكشاف.

9. فحص الديناميكيات​

في خريطة domain-wall، يصير نمط الإشغال سلسلة تكافؤ تراكمي، فالحالة الابتدائية تُحضَّر بشكل مختلف عن Jordan-Wigner. هذه نتيجة عملية يسهل إغفالها لعدم تموضع التكافؤ. الدالة المساعدة من الخطوة 4 تعطي التسمية الصحيحة:

>>> occupations = [1, 0, 1, 0]
>>> initial_label = "".join(str(b) for b in reversed(domain_wall_bits(occupations)))
>>> initial_label
'00110'

بعد تحضير الحالة، يبقى سؤالان، والأفضل نسألهما كل على حدة: هل التركيب بمجموعات التدفق يحسب الشيء الصحيح، وهل يحسبه بكلفة أقل؟

نبدأ بالصحة. FlowSetSynthesis وLieTrotter الخاص بـ Qiskit يستلمان نفس مجموعات التدفق المرمزة من نفس Trotterization الفرميونية، فلازم يحققا نفس الوحدوية حتى طور عام. دوائر Clifford المخصصة تعيد تنظيم العمل ولا تغير النتيجة. المتجهات الحالية العشوائية تكشف أي اختلاف بكلفة أقل بكثير من بناء الوحدوية الكاملة:

>>> from qiskit.quantum_info import Statevector
>>>
>>> rng = np.random.default_rng(1234)
>>>
>>> def agrees_up_to_one_phase(reps, samples=3):
... """Compare both qubit-side syntheses of the same fermionic Trotterization."""
... step = trotter_steps(reps)
... trotter = flow_set_pass_manager(step.register, LieTrotter()).run(step)
... flow = flow_set_pass_manager(step.register, FlowSetSynthesis()).run(step)
... trotter, flow = trotter.decompose(reps=6), flow.decompose(reps=6)
... dimension = 2**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(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(reps) for reps in (1, 3)]
[(True, True), (True, True)]
ملاحظة

كلا نصفي هذا الفحص مهم. تداخل بمقياس واحد على حالة واحدة شرط لازم لكنه غير كافٍ: تركيب أخطأ في طور يعتمد على الحالة سيظل يظهره. اشتراط نفس الطور عبر عدة حالات عشوائية هو اللي يسد الفجوة. وفحص أكثر من قيمة reps مهم أيضاً، لأنه يؤكد أن التوافق يصمد أمام تبادل مجموعات التدفق بالتناوب بدل أن يصح فقط لمسح واحد.

إذن اختيار التركيب على جانب الكيوبتات لا يؤثر على الدقة، وهذا ما يجعل مقارنة الكلفة ذات معنى. السؤال المتبقي هو ماذا يوفر الترميز ككل مقابل البديل الواضح: Jordan-Wigner العادي على num_sites كيوبت، بدون ancilla وبدون دوائر Clifford مخصصة. كلٌّ من الترميز والتركيب على جانب الكيوبتات يختلف بين العمودين، لكن الفحص أعلاه أظهر أن الثاني لا يقدر يؤثر على النتيجة، فما تختبره الدقة (fidelity) هو الترميز. كما أن كلا الخطين يبدآن من نفس Trotterization الفرميونية، فأي فرق في الدقة سيدل على خطأ برمجي وليس مفاضلة. العمودان هما الترميزان، مجموعات التدفق مقابل Jordan-Wigner:

>>> def jordan_wigner_pass_manager(mode_register, product_formula):
... """The same pipeline under plain Jordan-Wigner: one qubit per mode."""
... synthesis = F2QSynthesis()
... synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(
... transfer_vertex_jordan_wigner, product_formula=product_formula
... )
... qubit_register = QuantumRegister(mode_register.size, "q")
... return MultiStagePassManager(
... input=FermionicCircuitToDAG(),
... layout=CustomF2QLayout({mode_register: qubit_register}),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>>
>>> from scipy.linalg import expm
>>>
>>> jw_state = np.zeros(2**num_sites, dtype=complex)
>>> jw_state[sum(o << j for j, o in enumerate(occupations))] = 1.0
>>> reference_jw = expm(-1j * total_time * jw_matrix) @ jw_state
>>> reference_flow = expm(-1j * total_time * flow_matrix) @ (isometry @ jw_state)
>>>
>>> def cost_and_fidelity(reps):
... step = trotter_steps(reps)
... flow = flow_set_pass_manager(step.register, FlowSetSynthesis()).run(step)
... jw = jordan_wigner_pass_manager(step.register, LieTrotter()).run(step)
... flow, jw = flow.decompose(reps=6), jw.decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... evolved_flow = Statevector.from_label(initial_label).evolve(flow).data
... evolved_jw = Statevector(jw_state).evolve(jw).data
... return (
... abs(np.vdot(evolved_flow, reference_flow)) ** 2,
... flow.count_ops()["cx"],
... flow.depth(two_qubit),
... abs(np.vdot(evolved_jw, reference_jw)) ** 2,
... jw.count_ops()["cx"],
... jw.depth(two_qubit),
... )
>>>
>>> for reps in (1, 2, 4, 8):
... flow_f, flow_cx, flow_d, jw_f, jw_cx, jw_d = cost_and_fidelity(reps)
... print(
... f"{reps} step(s): flow set {flow_f:.6f}"
... f" ({flow_cx:3d} CX, depth {flow_d:3d})"
... f" JW {jw_f:.6f} ({jw_cx:3d} CX, depth {jw_d:3d})"
... )
1 step(s): flow set 0.590661 ( 22 CX, depth 12) JW 0.590661 ( 18 CX, depth 14)
2 step(s): flow set 0.926656 ( 44 CX, depth 24) JW 0.926656 ( 36 CX, depth 26)
4 step(s): flow set 0.982053 ( 88 CX, depth 48) JW 0.982053 ( 72 CX, depth 50)
8 step(s): flow set 0.995395 (176 CX, depth 96) JW 0.995395 (144 CX, depth 98)

اقرأ عمود الدقة أولاً: متطابق، حتى آخر رقم مطبوع، في فضاءي هيلبرت مختلفين تحت ترميزين مختلفين. هذا هو أثر Trotterization الفرميونية. التقريب تحدد في الخطوة 5، قبل اختيار أي ترميز، فكلتا الدائرتين تحملان بالضبط خطأ صيغة من الرتبة الأولى على ثلاث مجموعات تدفق، وينخفض تقريباً أربعة أضعاف عند مضاعفة عدد الخطوات.

ما يختلف فيه الترميزان هو الكلفة، والمقايضة تستحق أن تُذكر بوضوح. مجموعة التدفق تصرف بوابات تشابك أكثر: 22 مقابل 18 في خطوة واحدة. وليس السبب هو ancilla، لأنه لا يحمل أي بوابة ثنائية الكيوبت خاصة به. السبب هو التفاعل، تماماً كما حذرت الخطوة 3: عدم تموضع التكافؤ يحوّل ثلاثة حدود ZZ بين جيران أقرب إلى سبعة تمتد على الجيران الأقرب و التاليين للأقرب، وكل منها يكلف بوابتي CX. هذا هو الثمن المدفوع لجعل القفز رخيصاً.

وهو يتفوق أيضاً بفارق ضئيل فقط في العمق عند هذا الحجم (12 مقابل 14)، لأن أربعة أنماط صغيرة جداً لتظهر السلوكيات التقاربية. المهم هو التدرج المقاس في الخطوة 8: ذلك العمق 12 ثابت، بينما عمق Jordan-Wigner ينمو مع السلسلة. عند 14 نمطاً يصير 12 مقابل 34.

تحذير

استدعاءات decompose() أعلاه ضرورية، لسببين مستقلين.

evolve() وOperator يستخدمان التعريف الدقيق لـ PauliEvolutionGate، فيتجاوزان أي تركيب مخصص على جانب الكيوبتات. التحقق مقابل دائرة غير مفككة يعطي دقة مثالية حتى لتركيب خاطئ عمداً.

ينطبق نفس الشيء مستوى أعلى، على التركيب الفرميوني. بوابة Evolution اللي لا تُفكَّك أبداً تذهب كاملة إلى مرحلة فرميون-إلى-كيوبت، التي تحولها بدون الرجوع إلى Evolution.synthesis أصلاً، فـ reps لن يكون له أي أثر بصمت. trotter_steps من الخطوة 5 يؤدي هذا التفكيك؛ وخط المعالجة اللي يحدد الطريقة بـ F2FSynthesis يحصل على نفس التوسيع من التمريرة بدلاً من ذلك.

كفحص أخير، لازم تعيد كثافات المواقع ⟨nj(t)⟩=(1−⟨Vj⟩)/2\langle n_j(t) \rangle = (1 - \langle V_j \rangle)/2 المحسوبة في الفضاء المرمز نتيجة Jordan-Wigner. هذا الفحص يخص الترميز وليس الدائرة، فلا يدخله أي تقريب Trotter. كلا التطورين الزمنيين يُحسبان بدقة، بأسّ المصفوفة للهاملتونيتين المعنيتين. لذلك أي اختلاف سيشير إلى الترميز وليس إلى صيغة ضرب:

>>> time = 0.8
>>> state_jw = np.zeros(2**num_sites, dtype=complex)
>>> state_jw[sum(o << j for j, o in enumerate(occupations))] = 1.0
>>> evolved_jw = expm(-1j * time * jw_matrix) @ state_jw
>>> evolved_flow = expm(-1j * time * flow_matrix) @ (isometry @ state_jw)
>>>
>>> for j in range(num_sites):
... v_flow = to_matrix(
... SparseObservable.from_sparse_list([("ZZ", [j, j + 1], 1.0)], num_qubits)
... )
... v_jw = to_matrix(SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_sites))
... density_flow = (1 - np.vdot(evolved_flow, v_flow @ evolved_flow).real) / 2
... density_jw = (1 - np.vdot(evolved_jw, v_jw @ evolved_jw).real) / 2
... print(f"site {j}: {density_flow:.10f} {density_jw:.10f}")
site 0: 0.6507935456 0.6507935456
site 1: 0.5963201678 0.5963201678
site 2: 0.3365016619 0.3365016619
site 3: 0.4163846247 0.4163846247

الكثافات تتطابق حتى عشر خانات عشرية.

أهم ما نخرج به​

  • جمّع قبل التحويل. مجموعات التدفق تُعرَّف على البيان الفرميوني الموجه للتفاعلات، فيُختار التجميع بينما بنية المسألة ما زالت متاحة. الخاصية groups تنقله إلى الدائرة، حيث يحوله استدعاء واحد لـ decompose() إلى بوابة Evolution واحدة لكل مجموعة تدفق.

  • كيوبتات ancilla تشتري هيكلاً. صرف كيوبت إضافي واحد يحوّل مجموعة تدفق كاملة إلى Paulis بوزن 1، وتطورها لا يحتاج بوابات تشابك. المرجع [1] يلاحظ هذه المقايضة بشكل عام. نسب كيوبت-إلى-نمط أكبر تسمح بدوائر تطور أقل عمقاً.

  • الترميز المخصص دالة واحدة. حوّل مؤثر نقل معمماً واحداً إلى سلسلة Pauli؛ وmap_transfer_vertex_generators() وMapperFnEvolutionSynthesis يتكفلان بالباقي.

  • التحويل والتجميع وحدهما لا يكفيان. تقسيم التطور حسب مجموعة التدفق فقط أبطأ النمو الخطي في العمق؛ والتركيب حداً بحد لكل مجموعة ما زال يضيّع حقيقة أن حدودها تتبادل. فقط تقديم EvolutionSynthesis يعرف مجموعات التدفق جعل العمق ثابتاً. الترميزات المخصصة والتجميع والتركيب المخصص تكمل بعضها: الثلاثة مطلوبة.

  • صيغتا ضرب، مهمتان. FermionicLieTrotter يثبت التقريب في الفضاء الفرميوني، حيث التجميع ما زال مرئياً، فهو وحده يحدد خطأ Trotter وعدد الخطوات. أما EvolutionSynthesis على جانب الكيوبتات فيقرر فقط كم رخيصاً تُنفَّذ كل مجموعة: الخطوة 9 تؤكد أن الخيارين يعطيان نفس الوحدوية. الفصل بينهما هو اللي يسمح بضبط الدقة والكلفة بشكل مستقل. انظر تركيب بوابة Evolution في الفضاء الفرميوني.

  • تحقق منه. ترميز يحقق علاقات التبادل قد يمثل مع ذلك هاملتونية مختلفة لو كان عامل أو إشارة خطأ. فحص علاقة الربط (أو الطيف، وهو أرخص) يكشف هذا.

ملاحظة

الـ FlowSetSynthesis أعلاه مكتوب عمداً لـ هذا الترميز على سلسلة أحادية البعد. هو يطابق تسميات Pauli المحددة اللي ينتجها الترميز ويطلق خطأ عند أي شيء آخر، بدل الرجوع بصمت إلى صيغة ضرب عامة. التنفيذ العام له نصفان. قراءة بنية مجموعات التدفق من groups الخاصة بالمؤثر هو ما يفعله F2FSynthesisPlugin للتطور أصلاً. أما اشتقاق Clifford المقطِّر من مجموعة المثبِّتات (stabilizer)، كما هو موصوف في القسم IV C من المرجع [1]، فهو النصف اللي يبقى خاصاً بالترميز.

وأيضاً، عدم تموضع التكافؤ يجعل حد التفاعل njnj+1n_j n_{j+1} بوزن 2 بدل أن يكون قطرياً في الكيوبتات المنفردة، وهذه الكلفة المقابلة لحدود القفز الأرخص.

التالي: بعدان​

كل ما سبق أحادي البعد، وعمداً. على السلسلة، مجموعات التدفق هي فقط اتجاها السهم، والترميز يحتاج ancilla واحداً. لا أحد من التبسيطين يصمد في بعدين، وهناك تكون الترميزات المحلية مطلوبة.

على شبكة مستوية، مجموعة التدفق هي مسار موجه (أو اتحاد مسارات موجهة منفصلة الرؤوس) عبر بيان التفاعل، وإيجاد مجموعة صغيرة من هذه المسارات تغطي كل حافة يصير مسألة توافقية بحد ذاتها، وليس تصنيفاً من سطرين. الترميزات أيضاً تتغير في طبيعتها؛ فبدل ancilla واحد للسلسلة كلها، تضيف واحداً لكل موقع أو خلية في الشبكة. Verstraete-Cirac يقرن كل موقع فرميوني بكيوبت مساعد خاص به، بنسبة كيوبت-إلى-نمط قدرها 22، بينما Derby-Klassen يضع واحداً في مركز كل plaquette فردية، بنسبة 3/23/2. عندها يكون لتخطيط السجل بنية حقيقية ثنائية الأبعاد، ومجموعة المثبِّتات اللي لازم الإسقاط عليها لم تعد تافهة. الهاملتونية المرمزة أمينة فقط على فضاء جزئي، وهذا يغير تحضير الحالة ووصفة التحقق من الخطوة 4.

القطع التي يبنيها هذا الدليل هي اللي تنتقل معنا: CustomF2QLayout لسجل حجمه يختلف عن عدد الأنماط، وmap_transfer_vertex_generators() للترميز نفسه، وMapperFnEvolutionSynthesis لربطه بالمترجم (Transpiler)، وEvolutionSynthesis مخصص ليجعل بنية مجموعات التدفق مجدية. فقط الترميز وتفكيك مجموعات التدفق يختلفان.

دليل مجموعات التدفق ثنائي الأبعاد يكمل من هنا، ويعمل على ترميز Verstraete-Cirac على شبكة مربعة: أربع مجموعات تدفق، ودائرة Clifford بعمق 2 لكل منها، وإسقاط المثبِّتات اللي يحتاجه التحقق بعد ذلك.

[1] (1,2,3,4,5,6,7,8,9)

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.