> ## Documentation Index
> Fetch the complete documentation index at: https://docs.solya.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Vecteur de décision

> gold.decision_vector — scores de risque et d'urgence par domaine. Les formules, pondérations, valeurs par défaut NULL, et la porte des actions autorisées/interdites pour restock, rebalance et markdown.

`gold.decision_vector` est la **couche de scoring**. Il lit [`decision_context`](/fr/developers/decision-layer/decision-context)
et émet une ligne par `(organization_id, variant_id, shop_id, snapshot_date, domain)` portant
un **vecteur de risque** — des scores dans `[0, 1]` — plus la porte des actions
(`allowed_actions` / `forbidden_actions`) et une piste d'audit (`applied_rules`).

| Concept         | Valeur                                                                                                     |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| Clé primaire    | `(organization_id, variant_id, shop_id, snapshot_date, domain)`                                            |
| `domain`        | `"restock"`, `"rebalance"` ou `"markdown"`                                                                 |
| Mode d'écriture | `MERGE` sur la PK — les domaines s'empilent sans migration DDL                                             |
| Writers         | `BuildDecisionVectorTask` (restock), `BuildDecisionVectorRebalanceTask`, `BuildDecisionVectorMarkdownTask` |

`variant_id` / `shop_id` sont `NOT NULL` ici (la clé MERGE l'exige), bien qu'elles soient
nullables en amont sur `decision_context`.

## La STRUCT `scores`

Une seule STRUCT `NOT NULL` porte les champs de score de chaque domaine côte à côte. **Les
sous-champs sont nullables** : une ligne remplie par un domaine laisse NULL les sous-champs des
autres — gardant la table d'une seule forme entre domaines. La non-nullité du domaine
*rempli* est garantie par construction : chaque entrée NULL en amont est ramenée à une valeur
neutre avant le scoring.

| Sous-champ         | Domaine   | Plage    | Signification                                          |
| ------------------ | --------- | -------- | ------------------------------------------------------ |
| `restock_urgency`  | restock   | `[0, 1]` | urgence opérationnelle de réassort                     |
| `stockout_risk`    | restock   | `[0, 1]` | probabilité de rupture sur les 30 prochains jours      |
| `overstock_risk`   | restock   | `[0, 1]` | probabilité de surstock en fin de saison               |
| `surplus_score`    | rebalance | `[0, 1]` | mesure du dépassement de la cible                      |
| `deficit_score`    | rebalance | `[0, 1]` | mesure du déficit sous la cible                        |
| `transfer_urgency` | rebalance | `[0, 1]` | déficit × confiance de prévision × proximité de saison |
| `surplus_units`    | rebalance | `≥ 0`    | excédent explicite `max(0, stock − cible)`             |
| `deficit_units`    | rebalance | `≥ 0`    | manque explicite `max(0, cible − stock)`               |

## La porte des actions

Chaque ligne porte aussi trois tableaux `NOT NULL` :

* **`allowed_actions`** — la base émet le domaine lui-même, p. ex. `["restock"]`.
* **`forbidden_actions`** — `[]` en v1.1 ; de futures règles SOURCING / SIZING le rempliront.
* **`applied_rules`** — piste d'audit, **toujours non vide** (`size(applied_rules) > 0`). Le
  premier élément est un qualifieur de domaine (p. ex. `markdown_domain_qualifier:v1`) ; les
  suivants sont les IDs des règles métier de scoring qui se sont déclenchées.

## Scoring par domaine

<Tabs>
  <Tab title="Restock">
    Source : `build_decision_vector/scoring.py`. Toutes les pondérations sont des constantes de
    module.

    **`restock_urgency`** ∈ `[0, 1]`

    ```text theme={null}
    cover_signal    = 1 - min(days_of_cover / 30, 1)
    forecast_signal = min(forecast_30d / 100, 1)
    margin_signal   = clamp(gross_margin_pct / 100, 0, 1)
    aged_signal     = 1 if aged_stock_flag else 0

    urgency = clamp01(
        0.5 * cover_signal       # W_COVER
      + 0.3 * forecast_signal    # W_FORECAST
      + 0.2 * margin_signal      # W_MARGIN
      - 0.1 * aged_signal        # W_AGED_PENALTY (soustractif)
    )
    ```

    **`stockout_risk`** ∈ `[0, 1]`

    ```text theme={null}
    cover_factor      = clamp01(1 - days_of_cover / 30)
    forecast_modifier = 0.5 + 0.5 * min(forecast_30d / 100, 1)   # ∈ [0.5, 1.0]
    risk              = cover_factor * forecast_modifier
    ```

    **`overstock_risk`** ∈ `[0, 1]`

    ```text theme={null}
    cover_factor = clamp01((days_of_cover - 30) / (90 - 30))
    aged_factor  = 1 if aged_stock_flag else 0
    risk         = 0.6 * cover_factor + 0.4 * aged_factor    # W_OVERSTOCK_COVER / _AGED
    ```

    **Défauts NULL :** `days_of_cover → 30` (neutre), `forecast_30d → 0` (pas de demande),
    `gross_margin_pct → 0`, `aged_stock_flag → false`. Une ligne tout-NULL score `(0, 0, 0)`.
  </Tab>

  <Tab title="Rebalance">
    Source : `build_decision_vector_rebalance/scoring_rebalance.py`. S'exécute après le writer
    restock dans le DAG quotidien.

    **`surplus_score`** ∈ `[0, 1]` — le stock dormant est *soustractif* (ne pas router du stock
    dormant qui ne se vendra pas mieux ailleurs).

    ```text theme={null}
    target_safe   = max(coalesce(target_stock, current_stock), 1)
    ratio_signal  = clamp01((current_stock - target_safe) / target_safe)
    cover_signal  = clamp01((days_of_cover - 30) / (90 - 30))
    aged_signal   = 1 if aged_stock_flag else 0

    surplus = clamp01(
        0.7  * ratio_signal       # W_SURPLUS_RATIO
      + 0.3  * cover_signal       # W_SURPLUS_COVER_EXCESS
      - 0.15 * aged_signal        # W_SURPLUS_AGED_PENALTY
    )
    ```

    **`deficit_score`** ∈ `[0, 1]` — `stockout_risk` est recalculé via le helper restock
    partagé pour que les deux domaines restent synchronisés. La pénalité de délai est *additive*
    (un long délai rend un déficit plus urgent — on ne peut pas réassortir vite pour le corriger).

    ```text theme={null}
    target_safe       = max(coalesce(target_stock, current_stock), 1)
    ratio_signal      = clamp01((target_safe - current_stock) / target_safe)
    stockout_signal   = clamp01(stockout_risk)
    lead_time_signal  = clamp01(lead_time_days / 30)

    deficit = clamp01(
        0.7 * ratio_signal       # W_DEFICIT_RATIO
      + 0.3 * stockout_signal    # W_DEFICIT_STOCKOUT
      + 0.2 * lead_time_signal   # W_DEFICIT_LEAD_TIME
    )
    ```

    **`transfer_urgency`** ∈ `[0, 1]` — multiplicatif : un zéro sur n'importe quel facteur la
    ramène à zéro (ne pas router sur une prévision non fiable ou un transfert non rentabilisé
    avant la fin de saison).

    ```text theme={null}
    confidence_signal = clamp01(coalesce(forecast_confidence, 0))
    proximity_signal  = clamp01(1 - coalesce(days_to_season_end, 90) / 90)
    urgency           = clamp01(deficit_score * confidence_signal * proximity_signal)
    ```

    **Défauts NULL :** `target_stock → current_stock` (**toujours NULL en v1** → ratios à 0),
    `current_stock → 0`, `days_of_cover → 30`, `lead_time_days → 0`, `forecast_confidence → 0`,
    `days_to_season_end → 90` (hook Phase 2). Avec le `target_stock` NULL de la v1, les deux
    signaux de ratio sont à 0 sur tout le catalogue — comportement déterministe correct jusqu'à
    ce qu'une cible arrive.
  </Tab>

  <Tab title="Markdown">
    Source : `build_decision_vector/scoring_markdown.py`. **Markdown réutilise les slots STRUCT
    du restock** — aucun nouveau sous-champ :

    | Slot              | Sens restock            | Sens markdown                               |
    | ----------------- | ----------------------- | ------------------------------------------- |
    | `restock_urgency` | urgence de réassort     | `markdown_score` ∈ `[0, 1]`                 |
    | `stockout_risk`   | probabilité de rupture  | `recommended_discount_pct / 100` (fraction) |
    | `overstock_risk`  | probabilité de surstock | `1.0` si `aged_stock_flag` sinon `0.0`      |

    **`markdown_score`** ∈ `[0, 1]`

    ```text theme={null}
    score = clamp01(
        0.6 * (1 if aged_stock_signal else 0)   # W_AGED_SIGNAL
      + 0.4 * clamp01(overstock_risk)            # W_OVERSTOCK
      - 0.2 * clamp01(seasonality_penalty)       # W_SEASONALITY_PENALTY
    )
    ```

    **`recommended_discount_pct`** — recherché dans le YAML versionné
    `pipelines/shared/config/markdown_discount_lookup.yaml`, puis plafonné :

    ```text theme={null}
    raw = lookup.discounts[brand_tier][aged_stock_severity]
    cap = lookup.max_discount_pct_per_brand_tier[brand_tier]
    pct = min(raw, cap)            # le cap est imposé même si la valeur de table le dépasse
    ```

    Le lookup mappe `brand_tier × severity → remise %` avec un plafond par tier. Les marques
    inconnues retombent sur `default_tier`. Le loader est strict (aucun défaut silencieux) : un
    fichier manquant, une `version` non supportée, une clé manquante, une severity invalide, ou
    `low_max_days ≥ medium_max_days` lèvent tous `MarkdownLookupError` et échouent la tâche.

    <Accordion title="markdown_discount_lookup.yaml (exemple)">
      ```yaml theme={null}
      version: 1
      default_tier: standard
      brand_tier_map:
        brand-premium-1: premium
        brand-budget-1: budget
      aged_stock_thresholds_days:   # tier → seuil en jours du flag dormant markdown
        premium: 120
        standard: 90
        budget: 60
      aged_stock_severity_buckets:  # jours → low / medium / high
        low_max_days: 60
        medium_max_days: 120
      discounts:                    # tier → severity → remise %
        premium: {low: 5,  medium: 15, high: 25}
        standard: {low: 10, medium: 20, high: 40}
        budget: {low: 15,  medium: 30, high: 60}
      max_discount_pct_per_brand_tier:   # plafond de politique par tier
        premium: 30
        standard: 50
        budget: 70
      ```
    </Accordion>
  </Tab>
</Tabs>

## Validation

* **Erreurs** (échouent la tâche) : `decision_vector_not_empty`,
  `decision_vector_required_fields`, `decision_vector_pk_unique`,
  `decision_vector_applied_rules_non_empty`, `decision_vector_domain_allowed_v11`, et des
  bornes `BETWEEN 0 AND 1` (NULL-safe) sur les scores rebalance (`surplus_score`,
  `deficit_score`, `transfer_urgency`).
* **Avertissements** : contrôles de plage sur les scores restock (`restock_urgency`,
  `stockout_risk`, `overstock_risk`).

Les scores sont clampés par construction, donc un dépassement de plage signale un vrai bug —
échec bruyant.

<Note>
  Les pondérations de scoring sont des constantes de module aujourd'hui ; le réglage passe par
  une PR revue, pas par un bouton de réglages. Un futur chemin de calibration pourra les
  exposer via les réglages gold une fois que les données de production le justifieront.
</Note>

## Source

* Schéma : `pipelines/shared/schemas/gold/decision_vector.py`
* Scoring : `build_decision_vector/{scoring.py, scoring_markdown.py}`,
  `build_decision_vector_rebalance/scoring_rebalance.py`
* Lookup markdown : `pipelines/shared/config/markdown_discount_lookup.yaml`
* Doc repo : `docs/gold/decision-vector.md`
