# [ADR-002] Inserting dynamics

- **Status**: proposed
- **Deciders**: @redeboer @spflueger

## Context and problem statement

Physics models usually include assumptions that simplify the structure of the
model. For example, splitting a model into a product of independent parts, in
which every part contains a certain responsibility. In case of partial wave
amplitude models, we can make a separation into a spin part and a dynamical
part. While the spin part controls the probability w.r.t angular kinematic
variables, the dynamics controls the probability on variable like the invariant
mass of states.

Generally, a dynamics part is simply a function, which is defined in complex
space, and consists of:

- a mathematical **expression** (`sympy.Expr`)
- a set of **parameters** in that expression that can be tweaked (optimized)
- a set of (kinematic) **variables** to which the expression applies

### Technical story

- [#124](https://github.com/ComPWA/expertsystem/issues/124): see
  {ref}`adr/002:Issues with existing set-up`.
- [#440](https://github.com/ComPWA/expertsystem/issues/440): no way to supply
  custom dynamics. Or at least, {mod}`tensorwaves` does not understand those
  custom dynamics.
- [ADR-001](./001.md): parameters _and_ variables are to be expressed as
  `sympy.Symbol`s.
- [#454](https://github.com/ComPWA/expertsystem/pull/454): dynamics are
  specified as a mapping of `sympy.Function` to a `sympy.Expr`, but now there
  is no way to supply those `sympy.Expr`s with expected `sympy.Symbol`s
  (parameters and variables).

### Issues with existing set-up

- There is no clear way to apply dynamics functions to a specific decaying
  particle, that is, to a specific edge of the `StateTransitionGraph`s (`STG`).
  Currently, we just work with a mapping of `Particle`s to some dynamics
  expression, but this is not feasible when there there are identical particles
  on the edges.
- The set of variables to which a dynamics expression applies, is determined by
  the position within the `STG` that it is applied to. For instance, a
  relativistic Breit-Wigner that is applied to the resonance in some 1-to-3
  isobar decay (described by an `STG` with final state edges 2, 3, 4 and
  intermediate edge 1) would work on the invariant mass of edge 3 and 4
  (`mSq_3+4`).
- Just like variables, parameters need to be identifiable from their position
  within the `STG` (take a relativistic Breit-Wigner _with form factors_, which
  would require break-up momentum as a parameter), but also require some
  suggested starting value (e.g. expected pole position). These starting values
  are usually taken from the edge and node properties within the `STG`.

## Decision drivers

The following points are nice to have or can influence the decision but are not
essential and can be part of the users responsibility.

1. The parameters that a dynamics functions requires, are registered
   automatically and linked together.
2. Kinematic variables used in dynamics functions are also linked
   appropriately.
3. It is easy to define custom dynamics (no boilerplate code).

### Solution requirements

1. It is easy to apply dynamics to specific components of the `STG`s. Note:
   it's important that the dynamics can be applied to resonances of some
   **selected** graphs and not generally all graphs in which the resonance
   appears.
2. Where possible, suggested (initial) parameter values are provided as well.
3. It is possible to use and inspect the dynamics expression itself
   independently from the `expertsystem`.
4. Follow open-closed principle. Probably the most important decision driver.
   The solution should be flexible enough to handle any possible scenario,
   without having to change the interface defined in requirement 1!

## Considered solutions

### Group 1: expression builder

To satisfy [requirement 1](#solution-requirements), we propose the following
syntax:

```python
# model: ModelInfo
# graph: StateTransitionGraph
model.set_dynamics(graph, edge_id=1, expression_builder)
```

:::{toggle}

Another style would be to have `ModelInfo` contain a reference to the list of
`StateTransitionGraph`s. The user then needs some other way to express which
edges to apply the dynamics function to:

```python
model.set_dynamics(
    filter=lambda p: p.name.startswith("f(0)"),
    edge_id=1,
    expression_builder,
)
```

:::

Here, `expression_builder` is some function or method that can create a
dynamics expression. It can also be a class that contains both the
implementation of the expression and a static method to build itself from a
`StateTransitionGraph`.

The dynamics expression needs to be formulated in such a way that it satisfies
[the rest of the requirements](#solution-requirements). The following options
illustrate three different ways of formulating a dynamics expression, each
taking a relativistic Breit-Wigner and a relativistic Breit-Wigner _with form
factor_ as example.

```{toctree}
---
maxdepth: 1
---
002/composition
002/function
002/expr
```

### Group 2: expression-only

A second branch of solutions would propose the following interface:

```python
# model: ModelInfo
# graph: StateTransitionGraph
model.set_dynamics(graph, edge_id=1, expression)
```

The key difference is the usage of general sympy expression `sympy.Expr` as an
argument instead of constructing this through some builder object.

## Solution evaluation

### 1: Expression builder

All of the solutions have the drawback arising from the choice of interface
using a `expression_builder`. This enforces the logic of correctly coupling
variables and parameters into these builders. This is extremely hard to get
right, since the code has to be able to handle arbitrarily complex models. And
always knowing what the user would like to do is more or less impossible.
Therefore it is much better to use a already built expression that is assumed
to be correctly built (see [solution group 2](#group-2-expression-only)).

All of the solutions in this group also have the following additional
drawbacks. These are however more related to the correct building of the
dynamics expression:

- There is an implicit assumption on the signature of the expression: the first
  arguments are assumed to be the (kinematic) `variables` and the remaining
  arguments are `parameters`. In addition, the arguments cannot be keywords,
  but have to be positional.
- The number of `variables` and `parameters` is only verified at runtime (no
  static typing, other than a check that each of the elements is
  `sympy.Symbol`).

Composition is the cleanest design, but is less in tune with the design of
{mod}`sympy`. {doc}`002/function` and {doc}`002/expr` follow {mod}`sympy`
implementations, but result in an obscure inheritance hierarchy with implicit
conventions. This can result in some nasty bugs, for instance if one were to
`__call__` method in either the `sympy.Function` or `sympy.Expr`
implementation.

Pros and Cons that are specific to each of the implementations are listed
below.

#### {doc}`002/composition`

- **Positive**
  - Implementation of the expression is transparent
- **Negative**
  - {ref}`adr/002/composition:Alternative signature`.
  - The only way to see that `relativistic_breit_wigner_from_graph` is the
    builder for `relativistic_breit_wigner`, is from its name. This makes it
    implementing custom dynamics inconvenient and error-prone.
  - Signature of the builder can only be checked with a
    {class}`~typing.Protocol`, see {ref}`adr/002/composition:Type checking`.

#### {doc}`002/function`

- **Positive**
  - `DynamicsFunction` behaves as a {class}`~sympy.core.function.Function`
  - Implementation of the builder is kept together with the implementation of
    the expression.
- **Negative**
  - It's not possible to identify variables and parameters

#### {doc}`002/expr`

- **Positive**
  - When
    {doc}`recursing through the amplitude model <sympy:tutorial/manipulation>`,
    it is still possible to identify instances of `DynamicsExpr` (before
    `doit()` has been called).
  - Additional properties and methods can be added and carried around by the
    class.
- **Negative**
  - Boilerplate code required when implementing custom dynamics
  - {ref}`adr/002/expr:Issue with lambdify`

### 2: Expression-only

**Positive**: This choice of interface follows the principle of SOLID more than
solution group 1. By handing a complete expression of the dynamics to the
setter, its sole responsibility is to insert this expression at the correct
place in the full model expression.

**Negative**: There are no direct negative aspects to this solution, as it just
splits up responsibilities. The construction of the expression with the correct
linking of parameters and initial values etc has to be performed by some other
code. This code is subject to the same issues mentioned in the individual
solutions of group 1.

## Decision outcome

Use a composition based solution from group 2.

Important is the definition of the interface following solution group 2. This
ensures to be open-closed and keep the responsibilities separated.

The `expertsystem` favors **composition over inheritance**: we intend to use
inheritance only to define interfaces, not to insert behavior. As such, the
design of the `expertsystem` is fundamentally different than that of SymPy.
That's another reason to favor composition here: the interfaces are not
determined by the dependency and instead remain
{doc}`contained within the dynamics class <002/composition>`.

We decide to keep responsibilities as separated as possible. This means that:

1. The only responsibility of `set_dynamics` method is to attribute some
   expression (`sympy.Expr`) the correct symbol within the complete amplitude
   model. For now, this position is specified using some `StateTransitionGraph`
   and an edge ID, but this syntax may be improved later (see
   [#458](https://github.com/ComPWA/expertsystem/pull/458)):

   ```python
   def set_dynamics(
           self,
           graph: StateTransitionGraph,
           edge_id: int,
           expression: sp.Expr,
       ) -> None:
           # dynamics_symbol = graph + edge_id
           # self.dynamics[dynamics_symbol] = expression
   ```

   It is assumed that the `expression` is correct.

2. The user has the responsibility of formulating the `expression` with the
   correct symbols. To aid the user in the construction of such expressions
   some building code can handle some of the common tasks, such as

   - A `VariablePool` can facilitate finding the correct symbol names (to avoid
     typos).

     ```python
     mass = variable_pool.get_invariant_mass(graph, edge_id)
     ```

   - A `dynamics` module provides descriptions of common line-shapes as well as
     some helper functions. An example would be:

     ```python
     inv_mass, mass0, gamma0 = build_relativistic_breit_wigner(
         graph, edge_id, particle
     )
     rel_bw: sympy.Expr = relativistic_breit_wigner(inv_mass, mass0, gamma0)
     model.set_dynamics(graph, edge, rel_bw)
     ```

3. The `SympyModel` has the responsibility of defining a the full model in
   terms of an expression and keeping track of variables and parameters, for
   instance:

   ```python
   @attr.s(kw_only=True)
   class SympyModel:
       top: sp.Expr = attr.ib()
       # intensities: Dict[sp.Symbol, sp.Expr] = attr.ib(default=dict())
       # amplitudes: Dict[sp.Symbol, sp.Expr] = attr.ib(default=dict())
       dynamics: Dict[sp.Symbol, sp.Expr] = attr.ib(default=dict())
       parameters: Set[sp.Symbol]
       variables: Set[sp.Symbol]  # or: VariablePool

       def full_expression(self) -> sp.Expr:
           ...
   ```
