# Action indices

> ActionName indices used in example_sequence.json, run_pipeline.py IDX_* constants, runners.ACTIONS labels, and scorer weight keys.

- Repository: xai-org/x-algorithm
- GitHub: https://github.com/xai-org/x-algorithm
- Human docs: https://grok-wiki.com/public/docs/xai-org-x-algorithm-23c09c39074c
- Complete Markdown: https://grok-wiki.com/public/docs/xai-org-x-algorithm-23c09c39074c/llms-full.txt

## Source Files

- `phoenix/run_pipeline.py`
- `phoenix/runners.py`
- `phoenix/README.md`
- `home-mixer/scorers/weighted_scorer.rs`
- `home-mixer/scorers/ranking_scorer.rs`
- `phoenix/recsys_model.py`

---

---
title: "Action indices"
description: "ActionName indices used in example_sequence.json, run_pipeline.py IDX_* constants, runners.ACTIONS labels, and scorer weight keys."
---

Phoenix stores each history item as a multi-hot vector of length `num_actions` and emits ranking logits of shape `[B, num_candidates, num_actions]`. The published `run_pipeline.py` path indexes that last dimension with proto `ActionName` ordinals. `runners.ACTIONS` and Home Mixer `PhoenixScores` name the same engagement types as fields, not as those ordinals.

<Warning>
Do not treat `runners.ACTIONS[i]` as `ActionName` value `i`. Favorite is ordinal `1` (`IDX_FAV`) in `example_sequence.json` and `run_pipeline.py`, and slot `0` (`favorite_score`) in `RecsysInferenceRunner`. Mixing the two spaces writes the wrong history bit or reads the wrong logit column.
</Warning>

The `ActionName` proto is not in this checkout. Only the six ordinals named on the `IDX_*` constants and in `phoenix/README.md` are documented here.

## Index spaces

| Space | Used by | Coordinate | Width |
| --- | --- | --- | --- |
| `ActionName` ordinals | `example_sequence.json` keys, `history_actions[..., idx]`, `run_pipeline.py` logit columns | Integer proto enum values | `config.json` `num_actions` (documented mini width `19`) |
| `runners.ACTIONS` | `run_ranker.py`, `run_retrieval.py`, `RecsysInferenceRunner.rank_candidates` | Dense `0..len(ACTIONS)-1` | `len(ACTIONS)` = `19` |
| `PhoenixScores` fields | `PhoenixScorer` output, `WeightedScorer`, `RankingScorer`, Kafka / experiment logs | Named `*_score` / `*_time` fields | 19 fields on the local snapshot; production crate adds more |

```text
ActionName last-dim   (example_sequence.json / run_pipeline.py)
  1  SERVER_TWEET_FAV                 4  SERVER_TWEET_REPLY
  5  SERVER_TWEET_QUOTE               6  SERVER_TWEET_RETWEET
 11  CLIENT_TWEET_RECAP_DWELLED      13  CLIENT_TWEET_VIDEO_QUALITY_VIEW
  other slots exist in [0, num_actions) and are unnamed in this repo

runners.ACTIONS last-dim   (run_ranker.py dummy)
  0  favorite_score   1  reply_score   2  repost_score  ...  18  dwell_time

Home Mixer PhoenixScores   (named fields, not indices)
  favorite_score, reply_score, retweet_score, ...
```

`num_actions` is not hardcoded in `PhoenixModel`. `run_pipeline.py` reads it from `retrieval/config.json` and sizes both the history tensor and the ranker unembedding from that width. `PhoenixModelConfig.num_actions` is set from `ranker/config.json`. Tests and dummy runners default to `19`. `BaseInferenceRunner._get_num_actions()` also falls back to `19` when the model config has no `num_actions` (retrieval).

## ActionName ordinals

These are the only proto names this repository prints. Use them as JSON keys and as `all_probs[:, idx]` columns after `run_pipeline.py` applies `jax.nn.sigmoid` to ranker logits.

| Ordinal | `IDX_*` | Proto comment | Demo weight | Pipeline table column |
| --- | --- | --- | --- | --- |
| `1` | `IDX_FAV` | `SERVER_TWEET_FAV` | `1.0` | `Fav` |
| `4` | `IDX_REPLY` | `SERVER_TWEET_REPLY` | `0.5` | `Reply` |
| `5` | `IDX_QUOTE` | `SERVER_TWEET_QUOTE` | unused | not printed |
| `6` | `IDX_RT` | `SERVER_TWEET_RETWEET` | `0.3` | `RT` |
| `11` | `IDX_DWELL` | `CLIENT_TWEET_RECAP_DWELLED` | `0.2` | `Dwell` |
| `13` | `IDX_VQV` | `CLIENT_TWEET_VIDEO_QUALITY_VIEW` | unused | `VQV` |

<ParamField body="IDX_FAV" type="int">
`1`. Favorite / like. Included in the demo weighted sum at `1.0`.
</ParamField>

<ParamField body="IDX_REPLY" type="int">
`4`. Reply. Demo weight `0.5`.
</ParamField>

<ParamField body="IDX_QUOTE" type="int">
`5`. Quote. Defined for encoding; not used in the demo weighted sum or printed columns.
</ParamField>

<ParamField body="IDX_RT" type="int">
`6`. Retweet / repost. Demo weight `0.3`.
</ParamField>

<ParamField body="IDX_DWELL" type="int">
`11`. Recap dwelled. Demo weight `0.2`.
</ParamField>

<ParamField body="IDX_VQV" type="int">
`13`. Video quality view. Printed only. Production `WeightedScorer` / `RankingScorer` gate VQV on video duration.
</ParamField>

The published demo score is:

```python
weighted = (
    all_probs[:, IDX_FAV] * 1.0
    + all_probs[:, IDX_REPLY] * 0.5
    + all_probs[:, IDX_RT] * 0.3
    + all_probs[:, IDX_DWELL] * 0.2
)
```

Those four coefficients are local to `run_pipeline.py`. They are not the Home Mixer weight tables.

## History encoding

`run_pipeline.py` loads `--sequence_file` (default `<artifacts_dir>/example_sequence.json`) and writes a left-aligned `[history_seq_len, num_actions]` matrix. Extra history items past `history_seq_len` are dropped. Missing keys stay `0.0`. Keys `>= num_actions` are ignored.

<ParamField body="user_id" type="uint64" required>
Viewer id hashed into user embeddings.
</ParamField>

<ParamField body="history[].post_id" type="uint64" required>
Item id hashed into history post embeddings. Padding slots stay `0`.
</ParamField>

<ParamField body="history[].author_id" type="uint64" required>
Author id hashed into history author embeddings.
</ParamField>

<ParamField body="history[].actions" type="object">
Map of stringified `ActionName` ordinal → float. Typical present value is `1.0`.
</ParamField>

<RequestExample>
```json example_sequence.json
{
  "user_id": 123456789,
  "history": [
    {
      "post_id": 2001,
      "author_id": 3001,
      "actions": { "1": 1.0, "11": 1.0 }
    }
  ]
}
```
</RequestExample>

That example sets favorite (`1`) and dwell (`11`) on one post. The shipped artifact sequence is described as three sports posts the user liked and dwelled on; the zip itself is a Git LFS pointer until you extract `oss-phoenix-artifacts`.

```python
history_actions = np.zeros((hist_len, num_actions), dtype=np.float32)
for i, item in enumerate(history[:hist_len]):
    history_post_ids[i] = item["post_id"]
    history_author_ids[i] = item["author_id"]
    for act_idx_str, act_val in item.get("actions", {}).items():
        idx = int(act_idx_str)
        if idx < num_actions:
            history_actions[i, idx] = float(act_val)
```

Both the retrieval user tower and the ranker project that vector with a learned `action_projection` of shape `[num_actions, D]`:

```python
actions_signed = (2 * actions - 1)          # 0 → -1, 1 → +1
action_emb = actions_signed @ action_projection
action_emb = action_emb * jnp.any(actions, axis=-1, keepdims=True)
```

An all-zero action row (padding, or a history item with no `actions`) contributes a zero action embedding. `run_pipeline.py` does not populate `history_continuous_actions`, so the ranker's continuous dwell head sees zeros for history.

<Steps>
<Step title="Write ActionName keys, not ACTIONS positions">
Use `"1"` for favorite, not `"0"`. A `"0": 1.0` bit is a valid last-dim slot but is not a documented favorite.
</Step>
<Step title="Keep keys inside num_actions">
`if idx < num_actions` silently drops anything at or above the published width. Confirm `num_actions` in `retrieval/config.json` and `ranker/config.json` after extract.
</Step>
<Step title="Pad is automatic">
The encoder allocates `history_seq_len` zeros, then fills `history[:hist_len]`. You do not pad the JSON.
</Step>
<Step title="Confirm on the ranked table">
After `uv run run_pipeline.py --artifacts_dir artifacts/oss-phoenix-artifacts`, the printed `Fav` / `Reply` / `RT` / `Dwell` / `VQV` columns are `all_probs` at ordinals `1`, `4`, `6`, `11`, `13`.
</Step>
</Steps>

## runners.ACTIONS labels

`ACTIONS` is the dummy ranker / retrieval vocabulary. `run_ranker.py` and `run_retrieval.py` set `num_actions = len(ACTIONS)` and print labels in this order. `RecsysInferenceRunner` maps `probs[:, :, i]` onto `p_<ACTIONS[i]>` and ranks by column `0`.

| Dense index | `ACTIONS` label | `RankingOutput` field |
| --- | --- | --- |
| `0` | `favorite_score` | `p_favorite_score` |
| `1` | `reply_score` | `p_reply_score` |
| `2` | `repost_score` | `p_repost_score` |
| `3` | `photo_expand_score` | `p_photo_expand_score` |
| `4` | `click_score` | `p_click_score` |
| `5` | `profile_click_score` | `p_profile_click_score` |
| `6` | `vqv_score` | `p_vqv_score` |
| `7` | `share_score` | `p_share_score` |
| `8` | `share_via_dm_score` | `p_share_via_dm_score` |
| `9` | `share_via_copy_link_score` | `p_share_via_copy_link_score` |
| `10` | `dwell_score` | `p_dwell_score` |
| `11` | `quote_score` | `p_quote_score` |
| `12` | `quoted_click_score` | `p_quoted_click_score` |
| `13` | `follow_author_score` | `p_follow_author_score` |
| `14` | `not_interested_score` | `p_not_interested_score` |
| `15` | `block_author_score` | `p_block_author_score` |
| `16` | `mute_author_score` | `p_mute_author_score` |
| `17` | `report_score` | `p_report_score` |
| `18` | `dwell_time` | `p_dwell_time` |

`NEGATIVE_FEEDBACK_INDICES = [14, 15, 16, 17]` refers to this dense space (`not_interested`, `block_author`, `mute_author`, `report`). It is not an `ActionName` table.

`CONTINUOUS_ACTIONS` is a separate length-8 vocabulary for `history_continuous_actions`:

| Continuous index | Label |
| --- | --- |
| `0` | `reserved` |
| `1` | `dwell_time` |
| `2` | `video_watch_time` |
| `3` | `scroll_depth` |
| `4` | `reserved_3` |
| `5` | `reserved_4` |
| `6` | `reserved_5` |
| `7` | `reserved_6` |

`PhoenixModel` reads only index `1` (`dwell_time`) from history. The ranker also predicts `continuous_preds` of width `PhoenixModelConfig.num_continuous_actions` (default `8`) via `continuous_unembeddings`. Dummy `create_example_batch(..., include_continuous_actions=True)` writes exponential noise into `[:, :, 1]`.

## PhoenixScores and scorer weight keys

Home Mixer never indexes logits by integer. `PhoenixScorer` fills `PostCandidate.phoenix_scores`, then `WeightedScorer` / `RankingScorer` multiply named fields by weight constants or feature-switch params.

The local snapshot in `home-mixer/candidate_pipeline/candidate.rs` has these optional `f64` fields:

| Field | Kafka / experiment key | `WeightedScorer` weight | `RankingScorer` weight |
| --- | --- | --- | --- |
| `favorite_score` | `favorite` | `FAVORITE_WEIGHT` | `FavoriteWeight` |
| `reply_score` | `reply` | `REPLY_WEIGHT` | `ReplyWeight` |
| `retweet_score` | `retweet` | `RETWEET_WEIGHT` | `RetweetWeight` |
| `photo_expand_score` | `photo_expand` | `PHOTO_EXPAND_WEIGHT` | `PhotoExpandWeight` |
| `click_score` | `click` | `CLICK_WEIGHT` | `ClickWeight` |
| `profile_click_score` | `profile_click` | `PROFILE_CLICK_WEIGHT` | `ProfileClickWeight` |
| `vqv_score` | `vqv` | `VQV_WEIGHT` if `video_duration_ms > MIN_VIDEO_DURATION_MS`, else `0` | `VqvWeight` via `vqv_weight()` |
| `share_score` | `share` | `SHARE_WEIGHT` | `ShareWeight` |
| `share_via_dm_score` | `share_via_dm` | `SHARE_VIA_DM_WEIGHT` | `ShareViaDmWeight` |
| `share_via_copy_link_score` | `share_via_copy_link` | `SHARE_VIA_COPY_LINK_WEIGHT` | `ShareViaCopyLinkWeight` |
| `dwell_score` | `dwell` | `DWELL_WEIGHT` | `DwellWeight` |
| `quote_score` | `quote` | `QUOTE_WEIGHT` | `QuoteWeight` |
| `quoted_click_score` | `quoted_click` | `QUOTED_CLICK_WEIGHT` | `QuotedClickWeight` |
| `follow_author_score` | `follow_author` | `FOLLOW_AUTHOR_WEIGHT` | `FollowAuthorWeight` |
| `not_interested_score` | `not_interested` | `NOT_INTERESTED_WEIGHT` | `NotInterestedWeight` (negative group) |
| `block_author_score` | `block_author` | `BLOCK_AUTHOR_WEIGHT` | `BlockAuthorWeight` (negative group) |
| `mute_author_score` | `mute_author` | `MUTE_AUTHOR_WEIGHT` | `MuteAuthorWeight` (negative group) |
| `report_score` | `report` | `REPORT_WEIGHT` | `ReportWeight` (negative group) |
| `dwell_time` | `dwell_time` | `CONT_DWELL_TIME_WEIGHT` | `ContDwellTimeWeight` |

`ACTIONS` uses `repost_score` for the same engagement `PhoenixScores` and Kafka call `retweet_score` / `retweet`. Missing scores become `0.0` (`score.unwrap_or(0.0) * weight`).

Production `RankingScorer` and `vm_ranker.rs` also read fields that exist on the unpublished `xai_candidate_pipeline` `PhoenixScores` (re-exported from `home-mixer/models/candidate.rs`), not on the local snapshot:

| Extra field | Kafka / experiment key | `RankingScorer` weight |
| --- | --- | --- |
| `quoted_vqv_score` | `quoted_vqv` | `QuotedVqvWeight` via `quoted_vqv_weight()` |
| `click_dwell_time` | not in the Kafka insert list | `ContClickDwellTimeWeight` |
| `not_dwelled_score` | `not_dwelled` | `NotDwelledWeight` (negative group) |

`RankingScorer` treats `not_interested + block_author + mute_author + report + not_dwelled` as the negative sum used by `offset_score`. Numeric weight values live in unpublished `crate::params` / feature switches, not in this checkout.

<Info>
`PhoenixScorer` returns default (empty) scores when `query.scoring_sequence` is missing. `ScoringSequenceQueryHydrator` loads that sequence from the unpublished user-action aggregation service; it does not parse `example_sequence.json`.
</Info>

## Constraints

- History JSON keys are strings of integers. `int("1")` is `ActionName` favorite; `"favorite_score"` raises `ValueError`.
- Last-dim width must match the checkpoint unembedding `[emb_size, num_actions]`. Retrieval and ranker each have their own `config.json`.
- `run_pipeline.py` uses retrieval `num_actions` for the history tensor passed to both stages.
- Dummy `run_ranker.py` ranks by `probs[:, :, 0]` (`ACTIONS` favorite). The published pipeline ranks by the four-term `ActionName` weighted sum.
- `mask_neg_feedback_on_negatives` exists on `PhoenixModelConfig` (default `True`) and is unused in the published forward pass.
- Production VQV weight is zero for short or missing video; the demo always prints `IDX_VQV` and never multiplies it.

## Errors and verification

| Symptom | Cause |
| --- | --- |
| History has no effect | All-zero `actions`, or keys that do not match `ActionName` ordinals |
| Action silently missing | Key `>= num_actions` |
| Favorite column looks empty after editing JSON | Wrote `"0"` (dense `ACTIONS` favorite) instead of `"1"` |
| `FileNotFoundError` on the sequence file | Artifacts not extracted; default path is `<artifacts_dir>/example_sequence.json` |
| Shape error on `history_actions` | `num_actions` mismatch between the JSON-built tensor and the loaded `action_projection` / `unembeddings` |

Success signal for the published path is the ranked table: `Score` is the four-term weighted sum, `Ret` is retrieval dot-product, and `Fav` / `Reply` / `RT` / `Dwell` / `VQV` are sigmoid probabilities at ordinals `1`, `4`, `6`, `11`, `13`. Dummy `run_ranker.py` instead prints all 19 `ACTIONS` labels ordered by predicted favorite at dense index `0`. Retrieval tests construct batches with `num_actions = 19`.

## Next

<CardGroup>
<Card title="Customize a user sequence" href="/customize-user-sequence">
`example_sequence.json` fields, padding to `history_seq_len`, and `--top_k_retrieval` / `--top_k_display`.
</Card>
<Card title="Multi-action scoring" href="/multi-action-scoring">
Per-action logits, the demo weighted sum, and production `WeightedScorer` versus `RankingScorer`.
</Card>
<Card title="Scorers and weights" href="/scorers-and-weights">
Cluster fallback, weight formulas, VQV gates, author diversity, and `TopKScoreSelector`.
</Card>
<Card title="Run the inference pipeline" href="/run-inference-pipeline">
Load checkpoints, encode the sequence, retrieve from `sports_corpus.npz`, and print the ranked table.
</Card>
</CardGroup>
