<a id="grouping-explanation"></a>

# Group operator terms: use the operator structure

A [`BosonOperator`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator) can tag each of its terms with a **group
index**. The tags carry no meaning of their own: they only say which terms belong together.
What the tags *mean* is decided by whatever consumes them. In this package the consumers are
the time-evolution gate, which is split into one factor per group, and the randomized qDRIFT
pass, which samples whole groups.

This guide shows how to assign groups, how to check the properties a consumer relies on, and
how to lay the operator out so that picking a few groups out of a large one stays cheap.

#### NOTE
No automatic grouping strategy ships with this package. You assign the indices yourself, from
what you know about your model.

## Assign groups

The running example is a three-site Bose-Hubbard chain. Each bond contributes a hopping pair, and
the on-site interactions form a diagonal piece. The simplest way to build a grouped operator is
[`from_terms_with_groups()`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator.from_terms_with_groups), which takes each term
together with its group index as an `(actions, coefficient, group)` triple. You decide the group
at the moment you write the term down, when you still know why it belongs there:

```pycon
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> bonds = [(0, 1), (1, 2)]
>>> terms = []
>>> for group, (left, right) in enumerate(bonds):  # one group per bond
...     terms.append(([cre(left), ann(right)], -1.0, group))
...     terms.append(([cre(right), ann(left)], -1.0, group))
>>> for site in range(3):  # the on-site interactions share one group
...     terms.append(([cre(site), cre(site), ann(site), ann(site)], 1.0, len(bonds)))
>>> hamiltonian = BosonOperator.from_terms_with_groups(terms)
>>> len(hamiltonian), hamiltonian.num_groups()
(7, 3)
```

The terms are stored in the order you give them, so the group indices cannot end up attached to the
wrong terms.

An operator that already exists has to be grouped after the fact, by assigning the
[`groups`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator.groups) attribute. That is where care is needed,
because [`from_dict()`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator.from_dict) does not specify the order in which
it stores its terms. Writing the list out by position can then attach an index to the wrong term.
Derive each index from the term itself instead:

```pycon
>>> existing = BosonOperator.from_dict({tuple(actions): coeff for actions, coeff, _ in terms})
>>> existing.groups = [
...     len(bonds)
...     if len({mode for _, mode in term}) == 1
...     else bonds.index(tuple(sorted({mode for _, mode in term})))
...     for term, _ in existing.iter_terms()
... ]
>>> existing.num_groups(), existing.equiv(hamiltonian)
(3, True)
```

The number of indices has to match the number of terms. Anything else is rejected, because a
too-short list would silently drop trailing terms wherever terms are iterated together with
their groups:

```pycon
>>> try:
...     hamiltonian.groups = [0, 1]
... except ValueError as error:
...     print(error)
expected one group index per term, but got 2 for 7 terms
```

## Check what the consumer relies on

Three functions in [`qiskit_bosons.operators.terms`](../pydoc/qiskit_bosons.operators.terms.html.md#module-qiskit_bosons.operators.terms) answer one narrow question each
about an existing grouping. None of them says a grouping is “correct”, because correctness is
relative to the consumer.

**Is each group Hermitian?** This is the property that matters most. The time evolution of a
group is only unitary if the group is Hermitian, and a lone hopping term $b^\dagger_i b_j$
is *not*. Only the pair with its adjoint is Hermitian:

```pycon
>>> from qiskit_bosons.operators.terms import groups_are_hermitian
>>> groups_are_hermitian(hamiltonian)
[True, True, True]
```

A grouping that splits a hopping pair is caught immediately:

```pycon
>>> split = BosonOperator.from_terms_with_groups(
...     [(actions, coeff, index) for index, (actions, coeff, _) in enumerate(terms)]
... )  # one term per group
>>> [bool(flag) for flag in groups_are_hermitian(split)]
[False, False, False, False, True, True, True]
```

The four hopping terms are caught. The three `True` entries are the on-site terms
$b^\dagger_i b^\dagger_i b_i b_i$, which are Hermitian by themselves. A mode can
legitimately repeat within a term, and nothing is dropped for doing so. The flags follow the
order of `terms`, which is why each one can be read against the term it describes.

**Are the coefficients uniform?** A randomized product formula weighs a group by the mean
*magnitude* of its coefficients, which is a faithful description of the group only if the
magnitudes agree:

```pycon
>>> from qiskit_bosons.operators.terms import group_coeff_means, groups_have_uniform_coeffs
>>> groups_have_uniform_coeffs(hamiltonian)
[True, True, True]
>>> group_coeff_means(hamiltonian)
[1.0, 1.0, 1.0]
```

Neither check implies the other: uniform coefficients do not make a group Hermitian, and a
Hermitian group can mix magnitudes. By default the magnitudes are compared, because that is what
[`group_coeff_means()`](../stubs/qiskit_bosons.operators.terms.group_coeff_means.html.md#qiskit_bosons.operators.terms.group_coeff_means) averages. A Hermitian pair with complex
coefficients has equal magnitudes but unequal coefficients, so it satisfies only the default form:

```pycon
>>> conjugate = BosonOperator.from_terms_with_groups(
...     [([cre(0), ann(1)], 1.0j, 0), ([cre(1), ann(0)], -1.0j, 0)]
... )
>>> groups_have_uniform_coeffs(conjugate)
[True]
>>> groups_have_uniform_coeffs(conjugate, abs=False)
[False]
```

#### NOTE
[`groups_are_hermitian()`](../stubs/qiskit_bosons.operators.terms.groups_are_hermitian.html.md#qiskit_bosons.operators.terms.groups_are_hermitian) is built on
[`is_hermitian()`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator.is_hermitian), which compares an operator with its
adjoint after normal ordering. That is an identity of the untruncated algebra, so a group that
passes is Hermitian as a bosonic operator. See [Design principles of operator representations](operators.html.md#operators-explanation) for how products of
ladder operators are ordered and compared.

## Order the terms by group

[`split_out_groups()`](../stubs/qiskit_bosons.operators.BosonOperator.html.md#qiskit_bosons.operators.BosonOperator.split_out_groups) returns one operator per
requested group. When the terms of a group are scattered through the operator, it has to scan
every term to find them. When each group forms one contiguous run, it binary-searches the group
boundaries instead, so the cost of a lookup follows the number of groups *requested* rather
than the number of terms *held*.

[`group_order()`](../stubs/qiskit_bosons.operators.terms.group_order.html.md#qiskit_bosons.operators.terms.group_order) produces that layout. It is a stable sort by
group index: the value of the operator does not change, and terms within a group keep their
relative order.

```pycon
>>> from qiskit_bosons.operators.terms import group_order
>>> scattered = BosonOperator.from_terms_with_groups(
...     [(actions, coeff, group) for (actions, coeff, _), group in zip(terms, [1, 0, 1, 0, 2, 2, 2])]
... )
>>> ordered = group_order(scattered)
>>> ordered.groups
[0, 0, 1, 1, 2, 2, 2]
>>> ordered.equiv(scattered)
True
```

Both layouts return the same operators; only the cost differs:

```pycon
>>> request = [2, 0]
>>> all(
...     a.equiv(b)
...     for a, b in zip(
...         scattered.split_out_groups(request), ordered.split_out_groups(request), strict=True
...     )
... )
True
```

## Next steps

The grouping is chosen here, at the bosonic level, while the structure of the model is still
visible.

- [Synthesize an Evolution gate in bosonic space](bosonic_synthesis.html.md#bosonic-synthesis-explanation) shows how to use it to Trotterize an evolution, and why
  that reduces an error which a qubit-level product formula cannot.
- [Design principles of operator representations](operators.html.md#operators-explanation) explains the operator data structure that carries the group
  indices, and how its terms are stored and ordered.
- [2. Group before mapping](bose_hubbard.html.md#bose-hubbard-grouping) applies a grouping to a complete model.
