[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: see Issues with existing set-up.

  • #440: no way to supply custom dynamics. Or at least, tensorwaves does not understand those custom dynamics.

  • ADR-001: parameters and variables are to be expressed as sympy.Symbols.

  • #454: dynamics are specified as a mapping of sympy.Function to a sympy.Expr, but now there is no way to supply those sympy.Exprs with expected sympy.Symbols (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 StateTransitionGraphs (STG). Currently, we just work with a mapping of Particles 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 STGs. 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, we propose the following syntax:

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

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

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. 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.

Group 2: expression-only

A second branch of solutions would propose the following interface:

# 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).

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 sympy. Subclassing sympy.Function and Subclassing sympy.Expr follow 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.

Using composition

  • Positive

    • Implementation of the expression is transparent

  • Negative

    • 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 Protocol, see Type checking.

Subclassing sympy.Function

  • Positive

    • DynamicsFunction behaves as a Function

    • Implementation of the builder is kept together with the implementation of the expression.

  • Negative

    • It’s not possible to identify variables and parameters

Subclassing sympy.Expr

  • Positive

    • When recursing through the amplitude model, 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

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 contained within the dynamics class.

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):

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

    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).

      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:

      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:

    @attr.s(kw_only=True)
    class SympyModel:
        top: sp.Expr = attr.ib()
        # intensities: Dict[sp.Symbol, sp.Expr] = attr.ib(default={})
        # amplitudes: Dict[sp.Symbol, sp.Expr] = attr.ib(default={})
        dynamics: Dict[sp.Symbol, sp.Expr] = attr.ib(default={})
        parameters: Set[sp.Symbol]
        variables: Set[sp.Symbol]  # or: VariablePool
    
        def full_expression(self) -> sp.Expr:
            ...