Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Demo] The KAK theorem #1227

Open
wants to merge 37 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
15c4690
add dummy files
dwierichs Oct 3, 2024
5372259
some text and structure ideas
dwierichs Oct 3, 2024
59000ea
Merge branch 'master' into kak-theorem
dwierichs Oct 14, 2024
7b21c93
outline
dwierichs Oct 14, 2024
8fee71b
draft
dwierichs Oct 17, 2024
497a8ef
polish
dwierichs Oct 17, 2024
edf6fa4
mooooore
dwierichs Oct 18, 2024
ea8eebb
almost finalize draft
dwierichs Oct 18, 2024
c2485e4
polish
dwierichs Oct 23, 2024
8f9100a
Merge branch 'master' into kak-theorem
dwierichs Oct 23, 2024
366837e
punctuation
dwierichs Oct 23, 2024
9812f37
Apply suggestions from code review
dwierichs Oct 28, 2024
951b6b1
code review
dwierichs Oct 29, 2024
ea28b4b
Merge branch 'kak-theorem' of github.com:PennyLaneAI/qml into kak-the…
dwierichs Oct 29, 2024
6ba0f44
Merge branch 'master' into kak-theorem
dwierichs Oct 29, 2024
c218fe1
code review cont
dwierichs Nov 4, 2024
02b9bc7
more review
dwierichs Nov 4, 2024
94d23a1
references
dwierichs Nov 4, 2024
ef2b9ac
whitespace
dwierichs Nov 4, 2024
699a35f
polish
dwierichs Nov 5, 2024
f5ca7ef
polish
dwierichs Nov 6, 2024
c0a6a82
quotes
dwierichs Nov 6, 2024
09629fd
remove empty doi
dwierichs Nov 6, 2024
62fc819
code review; undo accidental minuscilization
dwierichs Nov 11, 2024
533f0ce
black
dwierichs Nov 11, 2024
d3f82f8
spoiler: factorization
dwierichs Nov 13, 2024
11958ef
Merge branch 'master' into kak-theorem
dwierichs Nov 13, 2024
c9da2f6
typo
dwierichs Nov 18, 2024
60ba852
Merge branch 'master' into kak-theorem
dwierichs Nov 18, 2024
7ce490d
Apply suggestions from code review
dwierichs Nov 20, 2024
6d19c38
Update demonstrations/tutorial_kak_theorem.metadata.json
dwierichs Nov 20, 2024
daeb3ec
accidental deletion
dwierichs Nov 20, 2024
20f4cdb
review
dwierichs Nov 20, 2024
17ae2e2
Merge branch 'master' into kak-theorem
dwierichs Nov 20, 2024
6ec9b4d
formatting
dwierichs Nov 21, 2024
9c7578f
revert multi-mathfrak
dwierichs Nov 21, 2024
2b91e03
review
dwierichs Nov 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion demonstrations/tutorial_kak_theorem.metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
],
"seoDescription": "Learn about the KAK theorem and how it powers circuit decompositions.",
"doi": "",
"canonicalURL": "/qml/demos/tutorial_kak_theorem",
"references": [
{
"id": "hall",
Expand Down
105 changes: 53 additions & 52 deletions demonstrations/tutorial_kak_theorem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,25 @@
The KAK theorem is a beautiful mathematical result from Lie theory, with
particular relevance for quantum computing. It can be seen as a
generalization of the singular value decomposition, as it decomposes a group
dwierichs marked this conversation as resolved.
Show resolved Hide resolved
element :math:`U` (think: unitary operator) into :math:`U=K_1AK_2`, where
element :math:`U` (think: unitary operator) into :math:`U=K_1AK_2,` where
:math:`K_{1,2}` and :math:`A` belong to special subgroups that we will introduce.
This means the KAK theorem falls under the large umbrella of matrix factorizations,
and it allows us to use it for quantum circuit decompositions.
and it allows us to break down arbitrary quantum circuits into smaller building blocks,
i.e., we can use it for quantum circuit decompositions.

In this demo, we will discuss so-called symmetric spaces, which arise from
certain subgroups of Lie groups. For this, we will focus on the Lie algebras
of these groups. With these tools in our hands, we will then learn about
the KAK theorem itself.
In this demo, we will dive into Lie algebras and their groups. Then, we will discuss
so-called symmetric spaces, which arise from certain subgroups of those Lie groups.
With these tools in our hands, we will then learn about the KAK theorem itself, which
exploits a so-called Cartan decomposition to break up such a Lie group.

We will make all steps explicit on a toy example on paper and in code.
We will make all steps explicit on a toy example on paper and in code, which splits
single-qubit gates into a well-known sequence of rotations.
Finally, we will get to know a handy decomposition of arbitrary
two-qubit unitaries into rotation gates as an application of the KAK theorem.

two-qubit unitaries into rotation gates as another application of the KAK theorem.

.. figure:: ../_static/demo_thumbnails/opengraph_demo_thumbnails/OGthumbnail_kak_theorem.png
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tk to replace with the actual image

:align: center
:width: 60%
:width: 70%
:target: javascript:void(0)

Along the way, we will put some non-essential mathematical details
Expand All @@ -32,7 +33,7 @@

In the following we will assume a basic understanding of vector spaces,
linear maps, and Lie algebras. To review those topics, we recommend a look
at your favourite linear algebra material. For the latter also see our
at your favourite linear algebra material. For the latter, also see our
:doc:`introduction to (dynamical) Lie algebras </demos/tutorial_liealgebra/>`.

Without further ado, let's get started!
Expand All @@ -51,8 +52,8 @@
see our :doc:`intro to (dynamical) Lie algebras </demos/tutorial_liealgebra/>`).

A *Lie algebra* :math:`\mathfrak{g}` is a vector space with an additional operation
that takes two vectors to a new vector, the *Lie bracket*. :math:`\mathfrak{g}` must be
closed under the Lie bracket to form an algebra.
that takes two vectors to a new vector, the *Lie bracket*. To form an algebra, :math:`\mathfrak{g}` must be
closed under the Lie bracket.
For our purposes, the vectors will always be matrices and the Lie bracket will be the matrix
commutator.

Expand All @@ -75,11 +76,11 @@
.. admonition:: Math detail: our Lie algebras are real
:class: note

:math:`\mathfrak{su}(n)` is a *real* Lie algebra, i.e., it is a vector space over the
real numbers :math:`\mathbb{R}.` This means that scalar-vector multiplication is
The algebra :math:`\mathfrak{su}(n)` is a *real* Lie algebra, i.e., it is a vector space over the
real numbers, :math:`\mathbb{R}.` This means that scalar-vector multiplication is
only valid between vectors (complex-valued matrices) and real scalars.

There is a simple way to see this; Multiplying a skew-Hermitian matrix
There is a simple way to see this. Multiplying a skew-Hermitian matrix
:math:`x\in\mathfrak{su}(n)` by a complex number :math:`c\in\mathbb{C}` will yield
:math:`(cx)^\dagger=\overline{c} x^\dagger=-\overline{c} x,` so that
the result might no longer be skew-Hermitian, i.e. no longer in the algebra! If we keep it to real scalars
Expand All @@ -91,10 +92,10 @@
Let us set up :math:`\mathfrak{su}(2)` in code.
Note that the algebra itself consists of *skew*-Hermitian matrices, but we will work
with the Hermitian counterparts as inputs, i.e., we will skip the factor :math:`i.`
We can check that :math:`\mathfrak{su}(2)` is closed under commutators, by
We can check that :math:`\mathfrak{su}(2)` is closed under commutators by
computing all nested commutators, the so-called *Lie closure*, and observing
that the closure is not larger than :math:`\mathfrak{su}(2)` itself.
Of course we could also check the closure manually for this small example.
Of course, we could also check the closure manually for this small example.
"""

from itertools import product, combinations
Expand All @@ -115,11 +116,11 @@
print(f"All operators are traceless: {np.allclose(traces, 0.)}")

######################################################################
# We find that :math:`\mathfrak{su}(2)` indeed is closed, and that it is a 3-dimensional
# We find that :math:`\mathfrak{su}(2)` is indeed closed, and that it is a 3-dimensional
# space, as expected from the explicit expression above.
# We also picked a correct representation with traceless operators.
#
# .. admonition:: Math detail: (semi-)simple Lie algebras
# .. admonition:: Math detail: (semi)simple Lie algebras
# :class: note
#
# Our main result for this demo will be the KAK theorem, which applies to so-called
Expand Down Expand Up @@ -151,8 +152,8 @@
# subalgebras, the correspondence is well-known to quantum practitioners: Exponentiate
# a skew-Hermitian matrix to obtain a unitary operation, i.e., a quantum gate.
#
# Interaction between group and algebra
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Interaction between Lie groups and algebras
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#
# We will make use of a particular interaction between the algebra :math:`\mathfrak{g}` and
# its group :math:`\mathcal{G},` called the *adjoint action* of :math:`\mathcal{G}` on
Expand All @@ -178,14 +179,14 @@
#
# \text{Ad}_{\exp(x)}(y) = \exp(\text{ad}_x) (y),
#
# where we applied the exponential map to :math:`\text{ad}_x`, which maps from :math:`\mathfrak{g}`
# where we applied the exponential map to :math:`\text{ad}_x,` which maps from :math:`\mathfrak{g}`
# to itself, via its series representation.
# We will refer to this relationship as the *adjoint identity*.
# We talk about Ad and ad in more detail in the box below, and refer to our demo
# We talk about :math:`\text{Ad}` and :math:`\text{ad}` in more detail in the box below, and refer to our demo
# :doc:`g-sim: Lie algebraic classical simulations </demos/tutorial_liesim/>` for
# further discussion.
#
# .. admonition:: Derivation: Adjoint representations
# .. admonition:: Derivation: adjoint representations
# :class: note
#
# We begin this derivation with the *adjoint action* of :math:`\mathcal{G}` on itself,
Expand All @@ -198,7 +199,7 @@
#
# The map :math:`\Psi_{\exp(x)}` (with fixed subscript) is a smooth map from the Lie group
# :math:`\mathcal{G}` to itself, so that we may differentiate it. This leads to the
# differential :math:`\text{Ad}_{\exp(x)}=d\Psi_{\exp(x)}` which maps the tangent spaces of
# differential :math:`\text{Ad}_{\exp(x)}=d\Psi_{\exp(x)},` which maps the tangent spaces of
# :math:`\mathcal{G}` to itself. At the identity, where
# the algebra :math:`\mathfrak{g}` forms the tangent space, we find
#
Expand All @@ -215,12 +216,12 @@
#
# .. math::
#
# \text{ad}_{\circ}(y)&=d\text{Ad}_\circ(y)\\
# \text{ad}_{\circ}(y)&=d\text{Ad}_\circ(y),\\
# \text{ad}: \mathfrak{g}\times \mathfrak{g}&\to\mathfrak{g},
# \ (x, y)\mapsto \text{ad}_x(y) = [x, y].
#
# It is a non-trivial observation that this differential equals the commutator!
# With ad we arrived at a map that *represents* the action of an algebra element
# With :math:`\text{ad}` we arrived at a map that *represents* the action of an algebra element
# :math:`x` on the vector space that is the algebra itself. That is, we found the
# *adjoint representation* of :math:`\mathfrak{g}.`
#
Expand Down Expand Up @@ -281,12 +282,12 @@ def is_orthogonal(op, basis):
#
# .. math::
#
# [\mathfrak{k}, \mathfrak{p}] \subset& \mathfrak{p} \qquad \text{(Reductive property)}\\
# [\mathfrak{k}, \mathfrak{p}] \subset& \mathfrak{p} \qquad \text{(Reductive property)},\\
# [\mathfrak{p}, \mathfrak{p}] \subset& \mathfrak{k} \qquad \text{(Symmetric property)}.
#
# The first property tells us that :math:`\mathfrak{p}` is left intact by the adjoint action of
# :math:`\mathfrak{k}`. The second property suggests that :math:`\mathfrak{p}` behaves like the
# "opposite" of a subalgebra, i.e., all commutators lie in its complement, the subalgebra
# :math:`\mathfrak{k}.` The second property suggests that :math:`\mathfrak{p}` behaves like the
# *opposite* of a subalgebra, i.e., all commutators lie in its complement, the subalgebra
# :math:`\mathfrak{k}.` Due to the adjoint identity from above, the first property also holds for
# group elements acting on algebra elements; for all :math:`x\in\mathfrak{p}` and
# :math:`K\in\mathcal{K}=\exp(\mathfrak{k}),` we have
Expand Down Expand Up @@ -339,7 +340,7 @@ def is_orthogonal(op, basis):
# **Example**
#
# For our example, we consider the subalgebra :math:`\mathfrak{k}=\mathfrak{u}(1)`
# of :math:`\mathfrak{su}(2)` that generates Pauli :math:`Z` rotations:
# of :math:`\mathfrak{su}(2)` that generates Pauli-:math:`Z` rotations:
#
# .. math::
#
Expand Down Expand Up @@ -394,15 +395,15 @@ def check_cartan_decomposition(g, k, space_name):
#
# The symmetric property of a Cartan decomposition
# :math:`([\mathfrak{p}, \mathfrak{p}]\subset\mathfrak{k})` tells us that :math:`\mathfrak{p}`
# is "very far" from being a subalgebra (commutators never end up in :math:`\mathfrak{p}` again).
# is *very far* from being a subalgebra (commutators never end up in :math:`\mathfrak{p}` again).
# This also gives us information about potential subalgebras *within* :math:`\ \mathfrak{p}.`
# Assume we have a subalgebra :math:`\mathfrak{a}\subset\mathfrak{p}.` Then the commutator
# between any two elements :math:`x, y\in\mathfrak{a}` must satisfy
#
# .. math::
#
# [x, y] \in \mathfrak{a} \subset \mathfrak{p}
# &\Rightarrow [x, y]\in\mathfrak{p} \text{(subalgebra property)} \\
# &\Rightarrow [x, y]\in\mathfrak{p} \text{(subalgebra property)}, \\
# [x, y] \in [\mathfrak{a}, \mathfrak{a}] \subset [\mathfrak{p}, \mathfrak{p}]
# \subset \mathfrak{k} &\Rightarrow [x, y]\in\mathfrak{k}\ \text{(symmetric property)}.
#
Expand Down Expand Up @@ -501,7 +502,7 @@ def check_cartan_decomposition(g, k, space_name):
# #. It is linear, i.e., :math:`\theta(x + cy)=\theta(x) +c \theta(y),`
# #. It is compatible with the commutator, i.e., :math:`\theta([x, y])=[\theta(x),\theta(y)],` and
# #. It is an *involution*, i.e., :math:`\theta(\theta(x)) = x,`
# or :math:`\theta^2=\mathbb{I}_{\mathfrak{g}}`
# or :math:`\theta^2=\mathbb{I}_{\mathfrak{g}}.`
#
# In short, we demand that :math:`\theta` be an *involutive automorphism* of :math:`\mathfrak{g}.`
#
Expand All @@ -512,9 +513,9 @@ def check_cartan_decomposition(g, k, space_name):
# .. math::
#
# &\theta([x_+, x_+]) = [\theta(x_+), \theta(x_+)] = [x_+, x_+]
# &\ \Rightarrow\ [x_+, x_+]\in\mathfrak{g}_+\\
# &\ \Rightarrow\ [x_+, x_+]\in\mathfrak{g}_+ ,\\
# &\theta([x_+, x_-]) = [\theta(x_+), \theta(x_-)] = -[x_+, x_-]
# &\ \Rightarrow\ [x_+, x_-]\in\mathfrak{g}_-\\
# &\ \Rightarrow\ [x_+, x_-]\in\mathfrak{g}_- ,\\
# &\theta([x_-, x_-]) = [\theta(x_-), \theta(x_-)] = (-1)^2 [x_-, x_-]
# &\ \Rightarrow\ [x_-, x_-]\in\mathfrak{g}_+.
#
Expand Down Expand Up @@ -559,7 +560,7 @@ def check_cartan_decomposition(g, k, space_name):
# = \mathbb{I}_{\mathfrak{g}},
#
# where we used the projectors' property :math:`\Pi_{\mathfrak{k}}^2=\Pi_{\mathfrak{k}}` and
# :math:`\Pi_{\mathfrak{p}}^2=\Pi_{\mathfrak{p}}`, as well as the fact that
# :math:`\Pi_{\mathfrak{p}}^2=\Pi_{\mathfrak{p}},` as well as the fact that
# :math:`\Pi_{\mathfrak{k}}\Pi_{\mathfrak{p}}=\Pi_{\mathfrak{p}}\Pi_{\mathfrak{k}}=0` because
# the spaces :math:`\mathfrak{k}` and :math:`\mathfrak{p}` are orthogonal to each other.
#
Expand Down Expand Up @@ -600,7 +601,7 @@ def check_cartan_decomposition(g, k, space_name):
#
# In our example, an involution that reproduces our choice
# :math:`\mathfrak{k}=\text{span}_{\mathbb{R}} \{iZ\}` is :math:`\theta_Z(x) = Z x Z`
# (Convince yourself that it is an involution that respects commutators, or verify that
# (convince yourself that it is an involution that respects commutators, or verify that
# it matches :math:`\theta_{\mathfrak{k}}` from above).


Expand Down Expand Up @@ -638,7 +639,7 @@ def theta_Y(x):
print(f"Under theta_Y, the operators\n{su2}\nhave the eigenvalues\n{eigvals}")

######################################################################
# This worked! a new involution gave us a new subalgebra and Cartan decomposition.
# This worked! A new involution gave us a new subalgebra and Cartan decomposition.
#
# .. admonition:: Math detail: classification of Cartan decompositions
dwierichs marked this conversation as resolved.
Show resolved Hide resolved
# :class: note
Expand All @@ -650,7 +651,7 @@ def theta_Y(x):
# plays a big role when talking about decompositions without getting stuck on details
# like the choice of basis or the representation of the algebra as matrices.
# For example, there are only three types of Cartan decompositions of the special unitary
# algebra :math:`\mathfrak{su}(n)`, called AI, AII, and AIII. The subalgebras
# algebra :math:`\mathfrak{su}(n),` called AI, AII, and AIII. The subalgebras
# :math:`\mathfrak{k}` for these decompositions are the special orthogonal algebra
# :math:`\mathfrak{so}(n)` (AI), the unitary symplectic algebra :math:`\mathfrak{sp}(n)` (AII),
# and a sum of (special) unitary algebras
Expand Down Expand Up @@ -683,7 +684,7 @@ def theta_Y(x):
# \mathcal{G} &= \mathcal{K}\mathcal{P}, \text{ or }\\
# \forall\ G\in\mathcal{G}\ \ \exists K\in\mathcal{K}, x\in\mathfrak{p}: \ G &= K \exp(x).
#
# This "KP" decomposition can be seen as the "group version" of
# This *KP* decomposition can be seen as the *group version* of
# :math:`\mathfrak{g} = \mathfrak{k} \oplus\mathfrak{p}` and is known as a *global* Cartan
# decomposition of :math:`\mathcal{G}.`
#
Expand All @@ -693,7 +694,7 @@ def theta_Y(x):
# canonical choice.
# Given a horizontal vector :math:`x\in\mathfrak{p},` we can always construct a second CSA
# :math:`\mathfrak{a}_x\subset\mathfrak{p}` that contains :math:`x.` As any two CSAs can be mapped
# to each other by some subalgebra element :math:`y\in\mathfrak{k}` using the adjoint action Ad,
# to each other by some subalgebra element :math:`y\in\mathfrak{k}` using the adjoint action :math:`\text{Ad},`
# we know that a :math:`y` exists such that
#
# .. math::
Expand Down Expand Up @@ -723,7 +724,7 @@ def theta_Y(x):
# where we abbreviated :math:`\mathcal{A} = \exp(\mathfrak{a}).`
#
# Chaining the two steps together and combining the left factor :math:`K^{-1}` with the group
dwierichs marked this conversation as resolved.
Show resolved Hide resolved
# :math:`\mathcal{K}` in the "KP" decomposition, we obtain the *KAK theorem*
# :math:`\mathcal{K}` in the *KP* decomposition, we obtain the *KAK theorem*
#
# .. math::
#
Expand All @@ -737,7 +738,7 @@ def theta_Y(x):
# the exponential of a CSA element, i.e., of commuting elements from the horizontal subspace
# :math:`\mathfrak{p}.` This may already hint at the usefulness of the KAK theorem for matrix
# factorizations in general, and for quantum circuit decompositions in particular.
# Given a group operation :math:`G=\exp(x)` with :math:`x\in\mathfrak{g}`, there are two
# Given a group operation :math:`G=\exp(x)` with :math:`x\in\mathfrak{g},` there are two
# subalgebra elements :math:`y_{1,2}\in\mathfrak{k}` (or subgroup elements
# :math:`K_{1,2}=\exp(y_{1,2})\in \mathcal{K}`) and a Cartan subgalgebra element
# :math:`a\in\mathfrak{a}` so that
Expand All @@ -746,8 +747,8 @@ def theta_Y(x):
#
# G\in\mathcal{G} \quad\Rightarrow\quad G=K_1 \exp(a) K_2.
#
# If :math:`x` happens to be from the horizontal subspace :math:`\mathfrak{p}`, so that
# :math:`G\in \mathcal{P}\subset\mathcal{G}`, we know that the two subgroup elements :math:`K_1`
# If :math:`x` happens to be from the horizontal subspace :math:`\mathfrak{p},` so that
# :math:`G\in \mathcal{P}\subset\mathcal{G},` we know that the two subgroup elements :math:`K_1`
# and :math:`K_2` will in fact be related, namely
#
# .. math::
Expand All @@ -768,9 +769,9 @@ def theta_Y(x):
print(qml.ops.one_qubit_decomposition(G, 0, rotations="ZYZ"))

######################################################################
# If we pick a "horizontal gate", i.e., a gate :math:`G\in\mathcal{P}`, we obtain the same
# If we pick a *horizontal gate*, i.e., a gate :math:`G\in\mathcal{P}`, we obtain the same
# rotation angle for the initial and final :math:`R_Z` rotations, up to the expected sign, and
# a shift by some multiple of :math:`2\pi`.
# a shift by some multiple of :math:`2\pi.`

horizontal_x = -0.1j * p[0] - 4.1j * p[1]
print(horizontal_x)
Expand All @@ -783,7 +784,7 @@ def theta_Y(x):
######################################################################
# Other choices for involutions or---equivalently---subalgebras :math:`\mathfrak{k}` will
# lead to other decompositions of ``Rot``. For example, using :math:`\theta_Y` from above
# together with the CSA :math:`\mathfrak{a_Y}=\text{span}_{\mathbb{R}} \{iX\},` we find the
# together with the CSA :math:`\mathfrak{a}_Y=\text{span}_{\mathbb{R}} \{iX\},` we find the
# decomposition
#
# .. math::
Expand Down Expand Up @@ -835,7 +836,7 @@ def theta_Y(x):
#
# .. math::
#
# \mathfrak{a} = \text{span}_{\mathbb{R}}\{iX_0X_1, iY_0Y_1, iZ_0Z_1\}
# \mathfrak{a} = \text{span}_{\mathbb{R}}\{iX_0X_1, iY_0Y_1, iZ_0Z_1\}.
#
# These three operators commute, making :math:`\mathfrak{a}` Abelian.
# They also form a *maximal* Abelian algebra within :math:`\mathfrak{p},` which is less obvious.
Expand Down Expand Up @@ -896,7 +897,7 @@ def su4_gate(params):
# decomposition of a Lie algebra to decompose its Lie group.
# This allows us to break down arbitrary quantum gates from that group,
# as we implemented in code for the groups of single-qubit and two-qubit gates,
# :math:`SU(2).` and :math:`SU(4).`
# :math:`SU(2)` and :math:`SU(4).`
#
# If you are interested in other applications of Lie theory in the field of
# quantum computing, you are in luck! It has been a handy tool throughout the last
Expand Down
Loading