Skip to content

Architecture

@biface edited this page May 1, 2026 · 3 revisions

Architecture principles

🇬🇧 English | 🇫🇷 Français


Separation: What and how…

The oxiflow development model enforces a strict three-pole separation:

graph TD
    PM["PhysicalModel\n(computes)"]
    SV["Solver\n(orchestrates)"]
    CC["ContextCalculator\n(executes)"]
    SC["Scenario\n— What —"]
    CF["SolverConfiguration\n— How —"]
    SR["SimulationResult"]
 
    PM -->|contains| SC
    SV -->|receives| SC
    SV -->|produces| SR
    SV -->|receives| CF
    CC -->|contains| CF
Loading
Pole Type Responsibility
What Scenario Problem declaration: model, mesh, initial and boundary conditions, domains
How SolverConfiguration Configuration: integrator, time step, calculators
Execution Solver Orchestrates the time integration loop

Structure: 3 core components, 2 linking elements

The architecture rests on three core components that carry the computation, connected by two linking elements that describe the problem and its resolution.

graph TD
    subgraph "Core components"
        PM["PhysicalModel\ndeclares requirements\ncomputes ∂u/∂t"]
        SOL["Solver\norchestrates the time loop"]
        CALC["ContextCalculator\nprovides a ContextValue\nto the solver"]
    end
 
    subgraph "Linking element — WHAT"
        SCEN["Scenario\nVec<Domain>\naggregates · validates"]
    end
 
    subgraph "Linking element — HOW"
        CFG["SolverConfiguration\nTimeConfiguration · integrator\ncalculators"]
    end
 
    PM -->|"RequiresContext\nrequired_variables()"| SCEN
    CALC -->|"Vec<Box<dyn ContextCalculator>>"| CFG
    SOL -->|"solve()"| SCEN
    SOL -->|"solve()"| CFG
Loading

The three core components

PhysicalModel — The computational core. It declares the variables it needs (trait RequiresContext) and computes the time derivative $∂u/∂t$ at each time step via compute_physics(). It is the concrete implementation of a physical domain (chromatography, heat transfer, etc.).

ContextCalculator — The context provider. It produces a ContextValue (scalar, vector, nodal field…) from the current simulation state. Calculators are chained by the solver in a determined execution order: each calculator may depend on the result of another. See Calculator chain ordering below.

Solver — The orchestrator. It receives a Scenario and a SolverConfiguration, runs the time loop, and produces a SimulationResult. It has no knowledge of the physics: it only knows in what order to call the components.

The two linking elements

ScenarioWhat to solve. Aggregates one or more Domain (model, mesh, boundary conditions). It validates the consistency of the problem before solving and exposes the context requirements across all domains.

SolverConfigurationHow to solve it. Groups the time configuration (TimeConfiguration), the choice of integrator (IntegratorKind), and the list of ContextCalculator instances provided by the user. It is the control panel that the solver consults to orchestrate each time step.


Scenario — from single-domain to multi-domain

Scenario is designed for multi-domain from the very first milestone. The single-domain case (Core Architecture) is the degenerate case: a single Domain in the vector. Domain coupling [INV-3] is introduced from v0.3.0 onwards.

Scenario
├── domains: Vec<Domain>           ← 1 domain at v0.1.0, N domains from v0.3.0
│   └── Domain
│       ├── id: DomainId           ← unique identifier (Hash + Eq)
│       ├── model: Box<dyn PhysicalModel>
│       ├── mesh:  Box<dyn Mesh>
│       └── boundary_conditions    Vec<Box<dyn BoundaryCondition>> (v0.2.0)
├── couplings: Vec<CouplingOp>     (reserved v0.3.0, INV-3)
├── interfaces: Vec<Interface>     (reserved v0.3.0)
└── t_start: f64

Chaining in multi-domain mode

In multi-domain mode, domains are not independent: they exchange information at interfaces. The chaining follows a contractual protocol:

sequenceDiagram
    participant S as Solver
    participant D1 as Domain 1
    participant D2 as Domain 2
    participant COP as CouplingOperator
 
    loop each step dt
        S->>D1: context_calculators → compute_physics
        S->>D2: context_calculators → compute_physics
        S->>COP: exchange(interface, D1.state, D2.state)
        note over COP: flux · continuity · constraint
        S->>D1: apply coupling flux
        S->>D2: apply coupling flux
        S->>D1: integrate → u_next
        S->>D2: integrate → u_next
    end
Loading

Requirement aggregation. Scenario::context_requirements() walks all domains, collects required_variables() from each physical model (and, from v0.2.0, from each boundary condition), and deduplicates. The solver verifies that the calculators supplied in SolverConfiguration cover all requirements before starting the loop.

Validation. Scenario::validate() detects inconsistencies before execution: incompatible meshes, duplicate domain identifiers, unmet context requirements. An explicit OxiflowError is returned to the caller rather than silently incorrect results.

Ergonomic helpers. Unifying single- and multi-domain scenarios into one type avoids the need for a separate MultiDomainScenario struct and eliminates a breaking API change at v0.3.0. The initial implementation cost is marginally higher, but pays off across subsequent milestones. Helper functions cover common single-domain usage without verbosity:

// v0.1.0 — single domain, t_start = 0
Scenario::single(model, mesh)
 
// v0.1.0 — with a custom start time
Scenario::single_from(model, mesh, t_start)
 
// v0.2.0 — add boundary conditions (via Domain builder)
Domain::new(id, model, mesh).with_boundary_conditions(bcs)
 
// v0.3.0 — add a coupling operator
scenario.with_coupling(op)

SolverConfiguration — the solver control panel

SolverConfiguration is the interface between the user and the resolution algorithm. It answers three questions: how far to simulate, how to integrate, with what context.

pub struct SolverConfiguration {
    pub time:        TimeConfiguration,               // how far and at what step
    pub integrator:  IntegratorKind,                  // Euler, RK4, DoPri45 (v0.4.0)…
    pub calculators: Vec<Box<dyn ContextCalculator>>, // user-supplied context
}
 
pub struct TimeConfiguration {
    pub t_end:        f64,
    pub step_control: StepControl,    // Fixed { dt } at v0.1.0, Adaptive at v0.4.0
    pub save_every:   Option<usize>,  // output sub-sampling
}

No exposed dx/nx. Spatial discretisation details (grid spacing, node count) remain internal to ContextCalculator implementations and the Mesh. No public API of SolverConfiguration exposes these quantities [INV-1].


Calculator chain ordering

At each call to build_calculator_chain, the execution order of calculators is determined by a hybrid two-path algorithm (DD-009):

graph TD
    A["build_calculator_chain()"] --> B{"any depends_on()\nnon-empty?"}
    B -->|No| C["Priority path\nstable sort by priority()\nO(n log n)"]
    B -->|Yes| D["Kahn path\ntopological sort\n+ priority tiebreaker"]
    D --> E{"cycle\ndetected?"}
    E -->|Yes| F["OxiflowError::\nCircularDependency"]
    E -->|No| G["ordered chain"]
    C --> G
Loading

Priority path (fast path)

When no calculator declares a non-empty depends_on(), calculators are sorted by ascending priority() (stable sort). This is the default path — no graph is built, no allocation beyond the sort. A total order by priority cannot contain a cycle.

Recommended priority ranges:

Range Purpose
0–49 System variables (Time, TimeStep) — injected directly by the solver
50–99 External data providers (experimental conditions, physical constants)
100+ Derived quantities (default)

Kahn path (topological sort)

When at least one calculator declares a non-empty depends_on(), Kahn's algorithm runs on the full dependency graph:

  • For each calculator C declaring depends_on: [X], an edge is drawn from every calculator providing X to C. Multiple providers of X produce multiple edges — all must precede C.
  • Built-in variables (Time, TimeStep) declared in depends_on() are ignored when building the graph: they are always available in ComputeContext before the chain runs.
  • Within each topological tier (nodes whose predecessors are all resolved), calculators are ordered by ascending priority().
  • A cycle returns OxiflowError::CircularDependency(ContextVariable) naming the variable involved.
graph LR
    TC["TimeCalculator\npriority: 0"]
    GC["GradientCalculator\npriority: 100\ndepends_on: []"]
    FC["FluxCalculator\npriority: 150\ndepends_on: [Gradient]"]
    PM["PhysicalModel\nreads context"]
 
    TC -->|"Time → Scalar(t)"| CTX["ComputeContext"]
    GC -->|"Gradient → VectorField(∇u)"| CTX
    FC -->|"Flux → VectorField(F)"| CTX
    GC --> FC
    CTX -->|"compute_physics(u, ctx)"| PM
Loading

Practical bound on calculator count

The graph construction is O(n² · d), where n is the number of calculators and d the average number of declared dependencies. The sort-based tiebreaker within each tier adds O(n² log n) in the worst case (fully sequential graph).

These complexities are acceptable in practice because the number of calculators in a simulation engine is structurally bounded. A physically rich scenario typically registers 20 to 30 calculators: spatial gradients, fluxes, temperatures, viscosities, source terms, adsorption isotherms. Reaching 100 calculators would be an architectural warning sign — it signals that concerns are not properly separated or that quantities that should be computed inside PhysicalModel are leaking into the calculator chain.

The working limit is therefore n ≤ 100 calculators. Beyond this threshold, the algorithm remains correct but its quadratic cost becomes measurable. If this limit is ever approached, two targeted improvements suffice without any interface change: replacing the sort-based queue with a BinaryHeap (O(log n) per operation) and pre-indexing provides → Vec<usize> to make graph construction O(n · d).


Contractual execution order

At each time step, the following order is mandatory. Deviating from it produces silently incorrect results:

sequenceDiagram
    participant S as Solver
    participant CTX as ComputeContext
    participant BC as Boundary conditions (v0.2.0)
    participant M as PhysicalModel
    participant I as Integrator
 
    loop each step dt
        S->>CTX: calculators → topological order
        S->>BC: apply(u, ctx)
        S->>M: compute_physics(u, ctx) → du/dt
        S->>I: integrate(du/dt, dt) → u_next
    end
Loading

Mesh — INV-1 and module hierarchy

src/mesh/
├── mod.rs              ← Mesh trait + public re-exports
├── structured/
│   └── mod.rs          ← UniformGrid1D (v0.1.0)
└── unstructured/       ← RESERVED v2.0.0 (FEM)

No public API exposes dx, nx, or raw indices [INV-1]. All spatial references go through the Mesh trait (Mesh abstraction interface, mesh module organisation).


ContextValue — two orthogonal axes

Axis Variant Active
Pointwise rank 0 Scalar(f64) Core architecture
Boolean flag Boolean(bool) Core architecture
Pointwise rank 1 Vector(DVector) Core architecture
Pointwise rank 2 tensor Matrix(DMatrix) Core architecture
Nodal scalar field ScalarField(DVector) Core architecture
Nodal vector field VectorField(DMatrix) Core architecture
Rank-4 tensor Tensor4 Finite Element Methods
Tensor field TensorField Finite Element Methods

Tensors of rank higher than 4 are outside the project scope. They may be contributed by third-party execution frameworks.

Covariant transformation semantics (D'_ij = J_ik · D_kl · J_jl^T) belong to DiscreteOperator [INV-2], not to the data container.


FEM invariants

INV Guarantee Active from
INV-1 No dx/nx in the public API v0.1.0
INV-2 Integrators decoupled from the spatial scheme v0.5.0
INV-3 Cross-domain coupling only via CouplingOperator v0.3.0
INV-4 All public traits are object-safe v2.0.0

Design decision index

See GitHub issues — type: decision


Principes d'architecture

🇬🇧 English | 🇫🇷 Français


Séparation : Quoi et comment…

Le modèle de développement d'oxiflow et son architecture impose une séparation stricte en trois pôles :

graph TD
    PM["PhysicalModel\n(calcule)"]
    SV["Solver\n(orchestre)"]
    CC["ContextCalculator\n(exécute)"]
    SC["Scenario\n— Quoi —"]
    CF["SolverConfiguration\n— Comment —"]
    SR["SimulationResult"]
 
    PM -->|contient| SC
    SV -->|reçoit| SC
    SV -->|produit| SR
    SV -->|reçoit| CF
    CC -->|contient| CF
Loading
Pôle Type Responsabilité
Quoi Scenario Déclaration du problème : modèle, maillage, conditions initiales et aux limites, domaines
Comment SolverConfiguration Configuration : intégrateur, pas de temps, calculateurs
Exécution Solver Orchestre la boucle temporelle

Structure : 3 composants fondamentaux, 2 éléments de liaison

L'architecture repose sur trois composants fondamentaux qui portent le calcul, reliés par deux éléments de liaison qui décrivent le problème et sa résolution.

graph TD
    subgraph "Composants fondamentaux"
        PM["PhysicalModel\ndéclare les besoins\ncalcule ∂u/∂t"]
        SOL["Solver\norchestre la boucle temporelle"]
        CALC["ContextCalculator\nfournit une ContextValue\nau solveur"]
    end
 
    subgraph "Élément de liaison — WHAT"
        SCEN["Scenario\nVec<Domain>\nagrège · valide"]
    end
 
    subgraph "Élément de liaison — HOW"
        CFG["SolverConfiguration\nTimeConfiguration · intégrateur\ncalculateurs"]
    end
 
    PM -->|"RequiresContext\nrequired_variables()"| SCEN
    CALC -->|"Vec<Box<dyn ContextCalculator>>"| CFG
    SOL -->|"solve()"| SCEN
    SOL -->|"solve()"| CFG
Loading

Les trois composants fondamentaux

PhysicalModel — Le cœur du calcul. Il déclare les variables dont il a besoin (trait RequiresContext) et calcule la dérivée temporelle $∂u/∂t$ à chaque pas de temps via compute_physics(). C'est l'implémentation concrète d'un domaine physique (chromatographie, transfert thermique, etc.).

ContextCalculator — Le fournisseur de contexte. Il produit une ContextValue (scalaire, vecteur, champ nodal…) à partir de l'état courant de la simulation. Les calculateurs sont chaînés par le solveur dans un ordre d'exécution déterminé : chaque calculateur peut dépendre du résultat d'un autre. Voir Ordonnancement de la chaîne de calculateurs ci-dessous.

Solver — L'orchestrateur. Il reçoit un Scenario et une SolverConfiguration, exécute la boucle temporelle, et produit un SimulationResult. Il ne connaît pas la physique : il sait seulement dans quel ordre appeler les composants.

Les deux éléments de liaison

ScenarioQuoi résoudre. Agrège un ou plusieurs Domain (modèle, maillage, conditions aux limites). Il valide la cohérence du problème avant la résolution et expose les besoins en contexte de l'ensemble des domaines.

SolverConfigurationComment le résoudre. Regroupe la configuration temporelle (TimeConfiguration), le choix de l'intégrateur (IntegratorKind), et la liste des ContextCalculator fournis par l'utilisateur. C'est la table de contrôle que le solveur consulte pour orchestrer chaque pas de temps.


Scenario — du domaine unique au multi-domaine

Scenario est conçu pour le multi-domaine dès le premier jalon. Le cas mono-domaine (Core Architecture) est le cas dégénéré : un seul Domain dans le vecteur. Le couplage inter-domaines [INV-3] apparaît à partir de la version v0.3.0.

Scenario
├── domains: Vec<Domain>           ← 1 domaine à v0.1.0, N domaines à partir de v0.3.0
│   └── Domain
│       ├── id: DomainId           ← identifiant unique (Hash + Eq)
│       ├── model: Box<dyn PhysicalModel>
│       ├── mesh:  Box<dyn Mesh>
│       └── boundary_conditions    Vec<Box<dyn BoundaryCondition>> (v0.2.0)
├── couplings: Vec<CouplingOp>     (réservé v0.3.0, INV-3)
├── interfaces: Vec<Interface>     (réservé v0.3.0)
└── t_start: f64

Enchaînement en mode multi-domaine

En mode multi-domaine, les domaines ne sont pas indépendants : ils échangent de l'information aux interfaces. L'enchaînement suit un protocole contractuel :

sequenceDiagram
    participant S as Solver
    participant D1 as Domain 1
    participant D2 as Domain 2
    participant COP as CouplingOperator
 
    loop chaque pas dt
        S->>D1: context_calculators → compute_physics
        S->>D2: context_calculators → compute_physics
        S->>COP: exchange(interface, D1.state, D2.state)
        note over COP: flux · continuité · contrainte
        S->>D1: apply coupling flux
        S->>D2: apply coupling flux
        S->>D1: integrate → u_next
        S->>D2: integrate → u_next
    end
Loading

Agrégation des besoins. Scenario::context_requirements() parcourt tous les domaines, collecte les required_variables() de chaque modèle physique (et, à v0.2.0, de chaque condition aux limites), et déduplique. Le solveur vérifie que les calculateurs fournis dans SolverConfiguration couvrent l'intégralité de ces besoins avant de lancer la boucle.

Validation. Scenario::validate() détecte les incohérences avant l'exécution : maillages incompatibles, identifiants de domaine en double, besoins en contexte non couverts. Un OxiflowError explicite est retourné à l'utilisateur plutôt qu'un résultat silencieusement incorrect.

Helpers ergonomiques. Ce choix initial évite d'avoir à concevoir plusieurs structures (Scenario, MultiDomainScenario) ; le coût est légèrement plus important à la première implémentation, mais il est rentable pour la suite des développements. Des fonctions de type helper réduisent le coût des usages simples :

// v0.1.0 — domaine unique, t_start = 0
Scenario::single(model, mesh)
 
// v0.1.0 — avec temps de départ personnalisé
Scenario::single_from(model, mesh, t_start)
 
// v0.2.0 — ajout de conditions aux limites (via le builder Domain)
Domain::new(id, model, mesh).with_boundary_conditions(bcs)
 
// v0.3.0 — ajout d'un opérateur de couplage
scenario.with_coupling(op)

SolverConfiguration — la table de contrôle du solveur

SolverConfiguration est l'interface entre l'utilisateur et l'algorithme de résolution. Elle répond à trois questions : jusqu'où simuler, comment intégrer, avec quels contextes.

pub struct SolverConfiguration {
    pub time:        TimeConfiguration,               // jusqu'où et à quel pas
    pub integrator:  IntegratorKind,                  // Euler, RK4, DoPri45 (v0.4.0)…
    pub calculators: Vec<Box<dyn ContextCalculator>>, // contextes fournis
}
 
pub struct TimeConfiguration {
    pub t_end:        f64,
    pub step_control: StepControl,    // Fixed { dt } à v0.1.0, Adaptive à v0.4.0
    pub save_every:   Option<usize>,  // sous-échantillonnage des sorties
}

Intégration sans dx/nx exposés. Les détails de discrétisation spatiale (pas de grille, nombre de nœuds) restent à l'intérieur des ContextCalculator et du Mesh. Aucune API publique de SolverConfiguration n'expose ces grandeurs [INV-1].


Ordonnancement de la chaîne de calculateurs

À chaque appel à build_calculator_chain, l'ordre d'exécution des calculateurs est déterminé par un algorithme hybride à deux chemins (DD-009) :

graph TD
    A["build_calculator_chain()"] --> B{"un depends_on()\nnon vide ?"}
    B -->|Non| C["Chemin priorité\ntri stable par priority()\nO(n log n)"]
    B -->|Oui| D["Chemin Kahn\ntri topologique\n+ tiebreaker priorité"]
    D --> E{"cycle\ndétecté ?"}
    E -->|Oui| F["OxiflowError::\nCircularDependency"]
    E -->|Non| G["chaîne ordonnée"]
    C --> G
Loading

Chemin priorité (fast path)

Lorsqu'aucun calculateur ne déclare un depends_on() non vide, les calculateurs sont triés par priority() croissant (tri stable). C'est le chemin par défaut — aucun graphe n'est construit. Un ordre total par priorité ne peut pas contenir de cycle.

Plages de priorité recommandées :

Plage Usage
0–49 Variables système (Time, TimeStep) — injectées directement par le solveur
50–99 Fournisseurs de données externes (conditions expérimentales, constantes physiques)
100+ Quantités dérivées (valeur par défaut)

Chemin Kahn (tri topologique)

Dès qu'un calculateur déclare un depends_on() non vide, l'algorithme de Kahn s'applique sur le graphe de dépendances complet :

  • Pour chaque calculateur C déclarant depends_on: [X], une arête est tracée de chaque calculateur fournissant X vers C. Plusieurs fournisseurs de X produisent plusieurs arêtes — tous doivent précéder C.
  • Les variables built-in (Time, TimeStep) déclarées dans depends_on() sont ignorées lors de la construction du graphe : elles sont toujours disponibles dans ComputeContext avant l'exécution de la chaîne.
  • Au sein de chaque tier topologique (nœuds dont tous les prédécesseurs sont résolus), les calculateurs sont triés par priority() croissant.
  • Un cycle retourne OxiflowError::CircularDependency(ContextVariable) en nommant la variable impliquée.
graph LR
    TC["TimeCalculator\npriorité : 0"]
    GC["GradientCalculator\npriorité : 100\ndepends_on : []"]
    FC["FluxCalculator\npriorité : 150\ndepends_on : [Gradient]"]
    PM["PhysicalModel\nlit le contexte"]
 
    TC -->|"Time → Scalar(t)"| CTX["ComputeContext"]
    GC -->|"Gradient → VectorField(∇u)"| CTX
    FC -->|"Flux → VectorField(F)"| CTX
    GC --> FC
    CTX -->|"compute_physics(u, ctx)"| PM
Loading

Limite pratique sur le nombre de calculateurs

La construction du graphe est en O(n² · d), où n est le nombre de calculateurs et d le nombre moyen de dépendances déclarées. Le tri à l'intérieur de chaque tier ajoute O(n² log n) dans le pire cas (graphe entièrement séquentiel).

Ces complexités sont acceptables en pratique car le nombre de calculateurs dans un moteur de simulation est structurellement borné. Un scénario physiquement riche en enregistre typiquement 20 à 30 : gradients spatiaux, flux, températures, viscosités, termes sources, isothermes d'adsorption. Atteindre 100 calculateurs constituerait un signal d'alerte architectural — cela indiquerait que les responsabilités ne sont pas correctement séparées, ou que des quantités qui devraient être calculées dans PhysicalModel fuient dans la chaîne de calculateurs.

La limite de bon fonctionnement est donc n ≤ 100 calculateurs. Au-delà, l'algorithme reste correct mais son coût quadratique devient mesurable. Si cette limite devait être approchée, deux améliorations ciblées suffiraient sans modifier l'interface : remplacer la file triée par un BinaryHeap (O(log n) par opération) et pré-indexer provides → Vec<usize> pour rendre la construction du graphe en O(n · d).


Ordre d'exécution contractuel

À chaque pas de temps, l'ordre suivant est imposé. Le déroger produit des résultats silencieusement incorrects :

sequenceDiagram
    participant S as Solver
    participant CTX as ComputeContext
    participant BC as Conditions aux limites (v0.2.0)
    participant M as PhysicalModel
    participant I as Intégrateur
 
    loop chaque pas dt
        S->>CTX: calculateurs → ordre topologique
        S->>BC: apply(u, ctx)
        S->>M: compute_physics(u, ctx) → du/dt
        S->>I: integrate(du/dt, dt) → u_next
    end
Loading

Maillage — INV-1 et hiérarchie de modules

src/mesh/
├── mod.rs              ← trait Mesh + re-exports publics
├── structured/
│   └── mod.rs          ← UniformGrid1D (v0.1.0)
└── unstructured/       ← RÉSERVÉ v2.0.0 (FEM)

Aucune API publique n'expose dx, nx ou des indices bruts [INV-1]. Toute référence spatiale passe par le trait Mesh (Interface d'abstraction du maillage, organisation du module de maillage).


ContextValue — deux axes orthogonaux

Axe Variant Actif
Objet ponctuel rang 0 Scalar(f64) Core architecture
Flag logique Boolean(bool) Core architecture
Objet ponctuel rang 1 Vector(DVector) Core architecture
Tenseur ponctuel rang 2 Matrix(DMatrix) Core architecture
Champ nodal scalaire ScalarField(DVector) Core architecture
Champ nodal vectoriel VectorField(DMatrix) Core architecture
Tenseur rang 4 Tensor4 Finite Element Methods
Champ de tenseurs TensorField Finite Element Methods

Les tenseurs de rang supérieur à 4 ne sont pas dans le périmètre des travaux. Ils peuvent faire l'objet d'un apport en tierce partie dans des cadres d'exécution dédiés.

La sémantique de transformation covariante (D'_ij = J_ik · D_kl · J_jl^T) appartient à DiscreteOperator [INV-2], pas au conteneur de données.


Invariants FEM

INV Garantie Actif depuis
INV-1 Pas de dx/nx dans l'API publique v0.1.0
INV-2 Intégrateurs découplés du schéma spatial v0.5.0
INV-3 Couplage inter-domaines uniquement via CouplingOperator v0.3.0
INV-4 Tous les traits publics sont object-safe v2.0.0

Index des décisions de design

Voir issues GitHub — type: decision