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

# Decision vector

> gold.decision_vector — per-domain risk and urgency scores. The formulas, weights, NULL defaults, and the allowed/forbidden-actions gate for restock, rebalance, and markdown.

`gold.decision_vector` is the **scoring layer**. It reads [`decision_context`](/en/developers/decision-layer/decision-context)
and emits one row per `(organization_id, variant_id, shop_id, snapshot_date, domain)` carrying
a **risk vector** — scores in `[0, 1]` — plus the action gate (`allowed_actions` /
`forbidden_actions`) and an audit trail (`applied_rules`).

| Concept     | Value                                                                                                      |
| ----------- | ---------------------------------------------------------------------------------------------------------- |
| Primary key | `(organization_id, variant_id, shop_id, snapshot_date, domain)`                                            |
| `domain`    | `"restock"`, `"rebalance"`, or `"markdown"`                                                                |
| Write mode  | `MERGE` on the PK — domains stack without a DDL migration                                                  |
| Writers     | `BuildDecisionVectorTask` (restock), `BuildDecisionVectorRebalanceTask`, `BuildDecisionVectorMarkdownTask` |

`variant_id` / `shop_id` are `NOT NULL` here (the MERGE key requires it), even though they are
nullable upstream on `decision_context`.

## The `scores` STRUCT

A single `NOT NULL` STRUCT carries every domain's score fields side-by-side. **Sub-fields are
nullable** so a row populated by one domain leaves the others' sub-fields NULL — keeping the
table single-shape across domains. Non-nullness for the *populated* domain is guaranteed by
construction: every NULL upstream input is coalesced to a neutral default before scoring.

| Sub-field          | Domain    | Range    | Meaning                                          |
| ------------------ | --------- | -------- | ------------------------------------------------ |
| `restock_urgency`  | restock   | `[0, 1]` | operational urgency of restocking                |
| `stockout_risk`    | restock   | `[0, 1]` | probability of stockout over the next 30 days    |
| `overstock_risk`   | restock   | `[0, 1]` | probability of overstock at end of season        |
| `surplus_score`    | rebalance | `[0, 1]` | extent the position is over target               |
| `deficit_score`    | rebalance | `[0, 1]` | extent the position is under target              |
| `transfer_urgency` | rebalance | `[0, 1]` | deficit × forecast confidence × season proximity |
| `surplus_units`    | rebalance | `≥ 0`    | explicit unit excess `max(0, stock − target)`    |
| `deficit_units`    | rebalance | `≥ 0`    | explicit unit shortfall `max(0, target − stock)` |

## The action gate

Each row also carries three `NOT NULL` arrays:

* **`allowed_actions`** — baseline emits the domain itself, e.g. `["restock"]`.
* **`forbidden_actions`** — `[]` in v1.1; future SOURCING / SIZING rules populate it.
* **`applied_rules`** — audit trail, **always non-empty** (`size(applied_rules) > 0`). The
  first element is a domain qualifier (e.g. `markdown_domain_qualifier:v1`); subsequent
  elements are the IDs of any scoring business-logic rules that fired.

## Scoring by domain

<Tabs>
  <Tab title="Restock">
    Source: `build_decision_vector/scoring.py`. All weights are module constants.

    **`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 (subtractive)
    )
    ```

    **`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
    ```

    **NULL defaults:** `days_of_cover → 30` (neutral), `forecast_30d → 0` (no demand),
    `gross_margin_pct → 0`, `aged_stock_flag → false`. An all-NULL row scores `(0, 0, 0)`.
  </Tab>

  <Tab title="Rebalance">
    Source: `build_decision_vector_rebalance/scoring_rebalance.py`. Runs after the restock
    writer in the daily DAG.

    **`surplus_score`** ∈ `[0, 1]` — aged stock is *subtractive* (don't route aged stock that
    won't sell elsewhere either).

    ```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` is recomputed via the shared restock
    helper so both domains stay in lock-step. Lead-time penalty is *additive* (a long lead
    time makes a deficit more urgent — you can't quickly restock to fix it).

    ```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]` — multiplicative: a zero on any factor collapses it to
    zero (don't route on an untrusted forecast or a transfer that won't pay back by season end).

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

    **NULL defaults:** `target_stock → current_stock` (**always NULL in v1** → ratios are 0),
    `current_stock → 0`, `days_of_cover → 30`, `lead_time_days → 0`, `forecast_confidence → 0`,
    `days_to_season_end → 90` (Phase-2 hook). With v1's NULL `target_stock`, both ratio signals
    are 0 across the catalogue — correct deterministic behavior until a target lands.
  </Tab>

  <Tab title="Markdown">
    Source: `build_decision_vector/scoring_markdown.py`. **Markdown reuses the restock STRUCT
    slots** — no new sub-fields:

    | Slot              | Restock meaning       | Markdown meaning                            |
    | ----------------- | --------------------- | ------------------------------------------- |
    | `restock_urgency` | restock urgency       | `markdown_score` ∈ `[0, 1]`                 |
    | `stockout_risk`   | stockout probability  | `recommended_discount_pct / 100` (fraction) |
    | `overstock_risk`  | overstock probability | `1.0` if `aged_stock_flag` else `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`** — looked up from the versioned YAML
    `pipelines/shared/config/markdown_discount_lookup.yaml`, then capped:

    ```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)            # the cap is enforced even if the table value exceeds it
    ```

    The lookup maps `brand_tier × severity → discount %` with a per-tier cap. Unknown brands
    fall back to `default_tier`. The loader is strict (no silent defaults): a missing file,
    unsupported `version`, missing key, bad severity, or `low_max_days ≥ medium_max_days` all
    raise `MarkdownLookupError` and fail the task.

    <Accordion title="markdown_discount_lookup.yaml (example)">
      ```yaml theme={null}
      version: 1
      default_tier: standard
      brand_tier_map:
        brand-premium-1: premium
        brand-budget-1: budget
      aged_stock_thresholds_days:   # tier → days threshold for the markdown aged flag
        premium: 120
        standard: 90
        budget: 60
      aged_stock_severity_buckets:  # days → low / medium / high
        low_max_days: 60
        medium_max_days: 120
      discounts:                    # tier → severity → discount %
        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:   # policy cap per tier
        premium: 30
        standard: 50
        budget: 70
      ```
    </Accordion>
  </Tab>
</Tabs>

## Validation

* **Errors** (fail the task): `decision_vector_not_empty`, `decision_vector_required_fields`,
  `decision_vector_pk_unique`, `decision_vector_applied_rules_non_empty`,
  `decision_vector_domain_allowed_v11`, and NULL-safe `BETWEEN 0 AND 1` bounds on the
  rebalance scores (`surplus_score`, `deficit_score`, `transfer_urgency`).
* **Warnings**: range checks on the restock scores (`restock_urgency`, `stockout_risk`,
  `overstock_risk`).

Scores are clamped by construction, so an out-of-range failure means a real bug — fail loud.

<Note>
  Scoring weights are module-level constants today; tuning is a reviewed PR, not a settings
  knob. A future calibration path may surface them via gold settings once production data
  shows it is needed.
</Note>

## Source

* Schema: `pipelines/shared/schemas/gold/decision_vector.py`
* Scoring: `build_decision_vector/{scoring.py, scoring_markdown.py}`,
  `build_decision_vector_rebalance/scoring_rebalance.py`
* Markdown lookup: `pipelines/shared/config/markdown_discount_lookup.yaml`
* Repo doc: `docs/gold/decision-vector.md`
