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

# Action vector

> gold.action_vector — the resolved recommendation. How decision-vector scores become concrete quantities and discounts, the confidence and the explanation, batch vs on-demand resolution.

`gold.action_vector` is the **resolution layer**. It turns the [decision vector](/en/developers/decision-layer/decision-vector)'s
abstract scores into a **concrete recommendation** — a quantity to reorder, units to transfer,
or a discount percentage — plus a confidence and an enriched "why" snapshot the app renders.

It is precomputed **daily, right after `decision_vector`**, so the app reads recommendations
instantly over Postgres instead of running the \~60 s on-demand resolver per article.

| Concept       | Value                                                                        |
| ------------- | ---------------------------------------------------------------------------- |
| Primary key   | `(organization_id, variant_id, shop_id, snapshot_date, domain)`              |
| `domain`      | `"restock"`, `"markdown"`, `"rebalance"`                                     |
| Write mode    | `MERGE` on the PK (idempotent)                                               |
| Builder       | `BuildActionVectorTask` (`pipelines/layers/gold/tasks/build_action_vector/`) |
| Lakebase sync | `<env>.gold_lakebase.action_vector` (triggered CDC, merge-on-PK)             |

## Columns

| Column                      | Type                    | Meaning                                                                  |
| --------------------------- | ----------------------- | ------------------------------------------------------------------------ |
| `recommended_qty`           | INT, nullable           | Units to reorder (restock) / transfer (rebalance); **NULL for markdown** |
| `recommended_discount_pct`  | DOUBLE, nullable        | Markdown discount in `[0, 100]`; **NULL for restock / rebalance**        |
| `recommendation_confidence` | DOUBLE, nullable        | Per-recommendation confidence in `[0, 1]`                                |
| `decision_vector_snapshot`  | STRING (JSON), NOT NULL | The enriched "why" — `context` / `formula` / `applied_rules` arrays      |

Exactly one of `recommended_qty` / `recommended_discount_pct` is populated per row, enforced
by the `action_vector_domain_payload_consistent` validation rule.

The `decision_vector_snapshot` is an **opaque JSON string** (the nested arrays are
arbitrary-shaped). The app's decision-explanation UI renders the `context` (input
conditions), `formula` (intermediate values), and `applied_rules` (rule IDs that fired). For
rebalance, the source/destination shops travel inside this JSON
(`source_shop_id` / `destination_shop_id`).

## How scores become recommendations

```mermaid theme={null}
flowchart TD
  DV[gold.decision_vector] --> R{domain}
  SS[gold.stock_snapshot] --> R
  R -->|restock| Q[resolve_score_driven_position<br/>scores + current_stock → recommended_qty]
  R -->|markdown| D[resolve_recommended_discount_position<br/>markdown_score + discount fraction → recommended_discount_pct]
  R -->|rebalance| M[apply_rebalance_matching<br/>greedy bipartite: surplus shop → deficit shop]
  Q --> AV[(gold.action_vector)]
  D --> AV
  M --> AV
```

* **Restock** — `resolve_score_driven_position` reads the latest restock decision vector and
  `stock_snapshot.current_stock_qty` per position and computes `recommended_qty`. With no
  decision-vector row, it degrades gracefully to `FillToTarget` math against a fallback target.
* **Markdown** — `resolve_recommended_discount_position` consumes `markdown_score` and the
  discount fraction from the decision vector.
* **Rebalance** — `apply_rebalance_matching` is a **global, per-org** optimization: it pairs
  surplus shops with deficit shops via greedy bipartite matching (highest `transfer_urgency`
  deficit pulls from highest `surplus_score` source, `qty = min(surplus_units, deficit_units)`,
  tie-break on `shop_id`). It writes **one destination-keyed row per matched transfer**.
  SOURCING-phase rules can veto specific pairings.

Positions **without** a decision-vector row are intentionally not written — the app finds no
row and falls back to the on-demand resolver.

## Batch vs on-demand — same cores, one source of truth

The daily batch and the app's interactive **simulation** call the **same** per-position
resolver cores, so a precomputed `action_vector` row matches what an on-demand simulation
would produce.

```mermaid theme={null}
sequenceDiagram
  participant App as Solya app (Decision tab)
  participant Sim as simulate_action()
  participant Res as resolver cores
  participant Gold as gold.decision_vector / stock_snapshot

  App->>Sim: action_family + strategy + ruleset_id
  Sim->>Res: resolve_items_for_plan (read-only)
  Res->>Gold: read latest scores + stock
  Res-->>Sim: enriched items (qty / discount + why)
  Sim-->>App: "what would land if a plan were created now"
```

`simulate_action()` (`pipelines/shared/workflow_execution/simulate.py`) is a read-only wrapper
— it never persists a plan. It validates the `ruleset_id` belongs to the org (raising
`RulesetNotOwnedError` on a cross-tenant id) before any rule fetch.

### Default strategy per action family

| Action family | Default strategy                                                            |
| ------------- | --------------------------------------------------------------------------- |
| `RESTOCK`     | `ScoreDrivenQuantityStrategy(fallback_target=30)`                           |
| `REBALANCE`   | `ScoreDrivenMatchingStrategy(surplus_threshold=0.5, deficit_threshold=0.5)` |
| `MARKDOWN`    | `RecommendedDiscountStrategy(max_discount_pct=70.0)`                        |

The app passes its own chosen strategy; these are only the pre-selected defaults. A strategy
of the wrong family for the requested action is rejected.

## Confidence

`recommendation_confidence ∈ [0, 1]` is computed from the score vector — inputs include the
restock score separation (how clearly one score dominates), data sufficiency (how complete the
inputs were), and whether constraints forbid the action. It is the value the app gates task
materialization and pre-fill on. (Restock rows may legitimately carry a NULL confidence, which
downstream consumers treat as "always keep".)

## Validation

* **Errors**: `action_vector_not_empty`, `action_vector_required_fields`,
  `action_vector_pk_unique`, `action_vector_domain_allowed_v1`,
  `action_vector_domain_payload_consistent` (qty XOR discount per domain).
* **Warnings**: `recommendation_confidence` in `[0, 1]`; `recommended_discount_pct` in
  `[0, 100]`.

## Source

* Schema: `pipelines/shared/schemas/gold/action_vector.py`
* Builder: `pipelines/layers/gold/tasks/build_action_vector/{task.py, executor.py}`
* Resolver cores: `pipelines/shared/workflow_execution/{items_resolver_score_driven.py, items_resolver_recommended_discount.py, scoring/rebalance_matching.py}`
* On-demand entry: `pipelines/shared/workflow_execution/simulate.py`
