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

# Features and targets: how a mart row becomes a training row

> How fct_cliff_prediction_features is loaded, encoded, fingerprinted, and audited and the three targets the models predict.

The `ml-features` stage (`ml/src/features.py`) is where a mart row becomes a row of the `X` matrix. It handles loading, ordinal encoding, season splitting, dataset fingerprinting, and the forward-window SQL audit before any model ever trains.

## The three targets

<Tabs>
  <Tab title="next_lap_degradation_jump_detrended_s">
    **What it measures:** the detrended per-lap pace change (in seconds) from this lap to the next. Positive means pace loss; negative means the tyre is still coming into its window.

    **Distribution:** legitimately negative roughly **44% of the time** a tyre warming up, or recovering from a dirty-air lap, genuinely gains pace. The target is not clipped at zero; predicting recovery is as important as predicting loss.

    **Detrending (Route C):** the raw `next_lap_degradation_jump_s` carried a subtle fuel artefact a downward drift over the stint as fuel burned off. The detrended version removes the within-stint linear slope ($\approx−0.072$ s/lap) before training, so the model learns the cliff signal rather than the fuel signal.

    **Models that use it:** `degradation_regressor_p10`, `degradation_regressor_p50`, `degradation_regressor_p90`.
  </Tab>

  <Tab title="laps_until_cliff_class">
    **What it measures:** how many laps until the next cliff event in this stint, bucketed into four ordered classes.

    | Class           | Meaning                                                  | Approx. share |
    | --------------- | -------------------------------------------------------- | ------------- |
    | `0_to_2`        | Cliff within the next two laps                           | \~9%          |
    | `3_to_5`        | Cliff three to five laps away                            | \~7%          |
    | `6_plus`        | Cliff six or more laps away, but still inside this stint | \~9%          |
    | `none_in_stint` | No cliff anywhere in the remaining stint                 | \~76%         |

    The class order is fixed (`CLIFF_CLASS_LABELS` in `ml/src/schema.py`) so XGBoost label integers are stable across builds.

    **Models that use it:** `cliff_classifier` (4-class `multi:softprob`).
  </Tab>

  <Tab title="remaining_stint_life_laps">
    **What it measures:** how many usable laps remain in the stint. This is a **synthesised** target: it is computed at feature-load time as `stint_length_laps − lap_in_stint`, not read from the mart. It is always ≥ 0.

    **Why synthesised:** `stint_length_laps` is available at join time (the full stint is already in the database), but it is not a feature knowing the final stint length would memorise the target. The synthesis happens inside `load_features`; the mask `PER_TARGET_FEATURE_MASK["stint_life_regressor"]` ensures `stint_length_laps` never enters `X` for this model.

    The training population is wider than for the degradation/cliff targets (**120,934 laps** vs 114,270) because a lap can have a valid `remaining_stint_life_laps` even where the next-lap degradation target is undefined.

    **Models that use it:** `stint_life_regressor` (squared-error regression, clipped ≥ 0).
  </Tab>
</Tabs>

## Ordinal encoding

Categoricals (`compound`, `air_state_dominant`) are encoded from the training map a deterministic integer assignment built once from the training set and persisted to `ml/models/encoders.json`. (`constructor_id` and `anomaly_class` were also ordinal-encoded categoricals before Phase 9 (2026-09-05) dropped both, with the rest of the `context` group, from the feature contract.)

```python theme={null}
# Characteristic clause from ml/src/features.py _build_encoders
for col in CATEGORICAL_COLUMNS:
    values = sorted(v for v in train_df[col].dropna().unique())
    encoders[col] = {str(v): i for i, v in enumerate(values)}

# Applied at scoring time
mapped = df[col].astype("object").map(encoders[col])
out[col] = mapped.fillna(MISSING_ORDINAL).astype("float32")  # NULL / unseen → −1
```

**The `MISSING_ORDINAL = −1.0` sentinel** handles:

* Null values (a lap with no compound assignment)
* Values seen at scoring time but absent from the training set (e.g. a new constructor)

XGBoost treats −1 like any other split point; the trees learn whether "unseen" is closer to one category or another based on training examples.

Continuous features keep `NaN` as `NaN`. XGBoost has native missing-value support it learns the optimal split direction for missing values and never requires imputation.

Booleans map `True → 1.0`, `False → 0.0`, `NULL → NaN`.

<AccordionGroup>
  <Accordion title="The dataset fingerprint" icon="fingerprint">
    After encoding, the feature matrix is fingerprinted with SHA-256:

    ```python theme={null}
    # Characteristic clause from ml/src/features.py _fingerprint
    ordered = X.reindex(columns=feature_cols).to_numpy(dtype=np.float32)
    ordered = ordered[np.lexsort(ordered.T[::-1])]  # canonical row order, NaN-stable
    h = hashlib.sha256()
    h.update(np.ascontiguousarray(ordered).tobytes())
    h.update(json.dumps({"seasons": training_seasons, "features": feature_cols}).encode())
    return h.hexdigest()
    ```

    The fingerprint encodes the encoded matrix, the training season list, and the feature column names. It is logged to stdout during `ml-features` and embedded in the model card. Given the same mart rows, the same features, and the same training seasons, the fingerprint is byte-for-byte identical across machines and runs so any two runs with the same fingerprint produced the same boosters.

    The card gates on the fingerprint: if the mart changes and the fingerprint changes, the card is out of date and must be regenerated.
  </Accordion>

  <Accordion title="The forward-window leakage audit" icon="search">
    The SQL from which the mart is built lives in `transform/models/**`. Before any model trains, `audit_forward_window` resolves every feature back to its defining expression with `sqlglot` and rejects three things: a `LEAD` or `FOLLOWING` window function, the two window constructs that can see the future; a forward self-join, where the reach lives in a `JOIN ... ON a.lap_in_stint < f.lap_in_stint` predicate rather than in a window (this is how the cliff scan is built, and a feature built the same way would once have passed silently); and its own blindness — if any lineage model fails to parse, or any feature resolves to no definition at all, that is reported as a violation rather than as a clean run.

    **A forward reach does not have to be a window, or a join.** It can live in the scope of a `GROUP BY`, which neither of those checks reads, so `audit_aggregation_scope` runs beside it: every `GROUP BY` in the mart's lineage must confine a group to at most one lap, or be declared in that model's `schema.yml` with a written reason the checker parses. The declaration names the grouping key set, so editing the SQL out from under one fails the build rather than silently exempting an aggregation nobody looked at. See the [leakage spine](/ml/ci/leakage-spine), guards 5–8.

    That last check is not decorative. The audit reads `compiled_code` from the dbt manifest, which is only populated by a command that compiles; against a `dbt parse` manifest it fell back to un-parseable Jinja and inspected **0 of 21 models and 0 of 42 features** while returning `[]`. An audit that resolves nothing returns exactly what a clean audit returns, so coverage is asserted before the result is trusted.

    ```bash theme={null}
    # Runs automatically as part of ml-features
    python -m ml.src.features --check
    ```

    The audit returns an empty list on success; CI runs it via `make ml-features`. It is one of the 12 leakage-spine guards see [The leakage spine](/ml/ci/leakage-spine) for the full set.

    The reason this lives in `ml/src/features.py` rather than in the transform tests is that it operates on the *compiled* SQL (the dbt manifest), not the dbt model source and it is conceptually the feature layer's responsibility to verify that what it reads is not forward-looking.
  </Accordion>
</AccordionGroup>

## Season split

The training/holdout season split is derived from the data never hard-coded:

```python theme={null}
def resolve_holdout_season(con) -> int:
    """Holdout = latest ingested season + 1. No literal year, ever."""
    return int(con.execute("SELECT MAX(race_year) + 1 FROM fct_cliff_prediction_features").fetchone()[0])
```

Today the holdout season is 2025 (no data yet). The final `TimeSeriesSplit` fold (2024) stands in as the evaluation holdout until 2025 ingests at which point the reveal happens with zero code change.

## Measured and rejected

Two obvious expansions of the (then-)42-feature set have been measured and **both were rejected**. They are recorded here so a later pass does not re-derive them. Both were run on the project's own harness: expanding-window season CV (train 2018 to season *k*−1, test season *k*, six folds), XGBoost at 300 trees / depth 6 / lr 0.08, `S.RANDOM_STATE`, `is_training_eligible` rows only. Measured 2026-08-22.

<Note>
  **Read against the current contract, not the one these were measured on.** Phase 9 (2026-09-05)
  subsequently dropped `weather_air` and `track` themselves (along with `powertrain`,
  `telemetry_cliff` and `context`) from the live feature set on a noise-floor ablation see
  [Feature contract](/ml/feature-contract). The two write-ups below still stand as the record of
  what was measured at the time, but a claim like "circuit identity already enters the feature
  set three times over through `track_energy_index`, `circuit_abrasiveness_index`, and the
  per-circuit-per-season compound cliff parameters" now describes a 42-feature set that no
  longer exists two of those three paths were removed by the prune.
</Note>

<AccordionGroup>
  <Accordion title="17 unused warehouse signals add nothing" icon="circle-minus">
    The warehouse stages a number of signals no model reads: `track_position`, `min_gap_s`, `tow_benefit_lap_s`, `time_in_dirty_air_s`, `track_temp_c`, `humidity_pct`, `wind_speed_ms`, the track-minus-ambient delta, `grid_position`, the four `speed_i1/i2/fl/st_kph` columns, `laps_remaining_in_race`, `race_distance_laps`, `sc_hazard_per_lap_shrunk` and `any_hazard_per_lap_shrunk`. Coverage runs 83 to 100% on all of them except `allocated_sets_per_driver`, which is 0%.

    Joined onto the mart and tested as a block:

    | Target                                     | 42 features | + candidates | change     |
    | ------------------------------------------ | ----------- | ------------ | ---------- |
    | degradation p50 (RMSE, lower better)       | 0.8271      | 0.8272       | **−0.01%** |
    | cliff classifier (macro-F1, higher better) | 0.2850      | 0.2823       | **−0.95%** |

    Nothing, in both directions of "nothing": no gain, and no loss worth calling a loss. This agrees with the project's own ablation artefacts, which already score `weather_air` and `track` as net-negative groups for the cliff classifier (removing `track` *improves* macro-F1 by 0.0047). The feature set is not starved. If anything it is slightly over-fed.

    The one target the block did move is stint life (+3.25%), and that is race-distance information the model already has through fuel mass.
  </Accordion>

  <Accordion title="Air density is not worth building" icon="wind">
    `pressure_hpa` sits in bronze, 100% non-null across all seven seasons, and `stg_weather` drops it in a 12-column projection. Staging it would unblock a proper air-density feature: dry-air plus vapour partial pressures via Tetens gives 0.902 to 1.241 kg/m³ across the calendar, a 37.6% spread driven by Mexico City's altitude. Downforce, drag and thermal load all scale with it, so the physics argument is a good one.

    It does not survive measurement. Both features added to the 42:

    | Target                      | base   | + air density | change     |
    | --------------------------- | ------ | ------------- | ---------- |
    | degradation p50 (RMSE)      | 0.8299 | 0.8326        | **−0.33%** |
    | cliff classifier (macro-F1) | 0.2834 | 0.2854        | **+0.70%** |

    Both sit inside the harness noise, and the reason is structural: air density is very nearly a per-circuit constant, and circuit identity already enters the feature set three times over through `track_energy_index`, `circuit_abrasiveness_index`, and the per-circuit-per-season compound cliff parameters. The model already knows Mexico is Mexico.

    Staging `pressure_hpa` remains cheap and defensible for completeness. It should not be sold as an ML win.
  </Accordion>
</AccordionGroup>

## Relationships

<CardGroup cols={2}>
  <Card title="Feature contract" href="/ml/feature-contract" icon="layers">
    The full 33-feature table, physics group descriptions, and the read-only mart contract.
  </Card>

  <Card title="Models" href="/ml/models" icon="microchip">
    How each target is used objective function, loss, and weighting.
  </Card>

  <Card title="Leakage spine" href="/ml/ci/leakage-spine" icon="shield">
    The 14 CI guards that enforce the exclusions and the forward-window audit every build.
  </Card>

  <Card title="Pipeline" href="/ml/pipeline" icon="workflow">
    Where this stage fits in the full `make ml-all` DAG.
  </Card>
</CardGroup>
