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

# Feature contract: the 33 features and the read-only mart

> 33 features across 6 physics groups, all read from fct_cliff_prediction_features. The machine layer never re-derives physics it reads the decomposition's output and writes nothing back.

The physics lives in the dbt transform layer. The machine layer reads it. That sentence is the entire design of the feature contract: `fct_cliff_prediction_features` is the single mart the five models consume, and every feature in the table was computed by the transform layer before ML ever runs.

<Warning>
  **Read-only mart contract.** The ML layer reads `fct_cliff_prediction_features` read-only. It never writes to the warehouse or the application. `driver_skill_residual_s` the decomposition's closure is **excluded** from the feature matrix; the models must not relearn what the skill layer already attributed.
</Warning>

## 33 features across 6 physics groups

Every feature has a physics interpretation. The grouping below matches `FEATURE_GROUPS` in `ml/src/schema.py` and the `features.groups` section of the model card the exact same ordering used during training.

<Note>
  **Phase 9 (2026-09-05): pruned from 42 to 24.** `powertrain`, `telemetry_cliff`, `weather_air`,
  `track` and `context` (18 columns) were dropped on a noise-floor group ablation across all
  three ablation-bearing model families each dropped group's contribution to headline accuracy
  fell inside the seed-refit noise floor in every family scored, so it costs ONNX size and
  browser payload without a measured accuracy return. `powertrain` and `telemetry_cliff` were
  the mart's entire consumption of `int_lap_telemetry_aggregates`; the ingestion and the
  telemetry data itself are unaffected, only their use as model features. See the execution
  plan's Phase 9 section for the full ablation table.
</Note>

<Note>
  **Phase 10a (2026-09-05): 24 → 33, and the nine come from a different sensor.** Every
  traffic feature before this one descends from FastF1's `DistanceToDriverAhead`, which
  `int_lap_air_state` divides by point speed to get a gap in seconds an approximation that
  assumes the car ahead is travelling at the same speed as the car behind, which is exactly
  false in the situation the feature exists to describe. The `proximity` group instead
  measures a time: each lap is cut into 100 fractions of `relative_distance`, the session
  clock at which a driver first enters a fraction is a crossing time of a fixed point on
  track, and the gap is the difference between two crossings of the same point. The source
  is the telemetry stream's **position channel**, 58.8M rows that had no consumer at all
  before this phase.

  Admitted on an add-ablation, not on the argument: p50 pinball 1.034661 → 1.012128 (1.54× its
  seed-refit noise floor), cliff macro-F1 0.372960 → 0.380950 (2.17×), stint-life AFT NLL
  1.956152 → 1.952005 (0.59×, inside noise). The `dirty_air` group **stays** a swap arm that
  drops it and keeps `proximity` lands inside noise on all three families, so the two are
  carrying different things. Validated independently of the telemetry it is built from,
  against the FIA's own 4,909 waved blue flags
  (`assert_proximity_agrees_with_blue_flags`).

  A permutation-null arm separates information from capacity nine more columns is nine more
  split candidates, which can flatter an eval fold on its own. With the proximity columns
  row-shuffled (capacity preserved, signal destroyed), the classifier's gain is **+0.008406,
  2.28× its floor, entirely information**, with a capacity effect of 0.11× floor. The p50
  regressor's total gain clears its floor but splits roughly evenly between information and
  capacity, with **neither half clearing on its own** so the quantile trio's improvement is
  real on this fold but is not cleanly attributable to traffic.
</Note>

| Group            | Count  | Features                                                                                                                                                                                                            |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stint_position` | 4      | `lap_number`, `lap_in_stint`, `age_in_stint`, `fuel_mass_kg`                                                                                                                                                        |
| `compound`       | 7      | `compound`, `compound_grip_peak`, `compound_wear_gradient`, `compound_optimal_temp_low`, `compound_optimal_temp_high`, `compound_cliff_onset_laps`, `compound_cliff_severity`                                       |
| `cliff_prior`    | 4      | `expected_compound_pace_s`, `expected_degradation_rate_s_per_lap`, `cliff_onset_passed`, `laps_past_cliff`                                                                                                          |
| `thermal`        | 4      | `push_residual`, `cumulative_push_load_surface`, `cumulative_push_load_bulk`, `surface_bulk_ratio`                                                                                                                  |
| `dirty_air`      | 4      | `dirty_air_share_lap`, `dirty_air_thermal_load_surface`, `dirty_air_thermal_load_bulk`, `air_state_dominant`                                                                                                        |
| `proximity`      | 9      | `share_lap_within_1s`, `share_lap_within_2s`, `share_lap_in_train`, `share_lap_behind_within_1s`, `time_within_1s`, `gap_ahead_min_s`, `gap_ahead_median_s`, `ahead_identity_stability`, `n_distinct_cars_ahead_3s` |
| **Total**        | **32** |                                                                                                                                                                                                                     |

<CardGroup cols={2}>
  <Card title="stint_position (4)" icon="clock">
    Lap number, stint position, fuel mass. The core temporal signal where in the stint this lap falls.
  </Card>

  <Card title="compound (7)" icon="circle">
    Compound identity, grip peak, wear gradient, optimal temp window, cliff-onset and severity priors. The compound-level physics prior baked into the Reference family.
  </Card>

  <Card title="cliff_prior (5)" icon="triangle-alert">
    Expected pace, degradation rate, whether the onset lap has passed, laps past the cliff, and a cliff candidate flag. The Kaplan-Meier survival prior from the physics layer ML's starting point, not its conclusion.
  </Card>

  <Card title="thermal (4)" icon="thermometer">
    Push residual, cumulative surface and bulk thermal load, and the surface-to-bulk ratio (added under Route C, C3). Encodes how hard the tyre has been working at each of the two thermal layers.
  </Card>

  <Card title="dirty_air (4)" icon="wind">
    Dirty-air share per lap, surface and bulk thermal load attributed to following traffic, and the dominant air-state label. Encodes the aerodynamic heat penalty from following another car, derived from FastF1's `DistanceToDriverAhead`.
  </Card>

  <Card title="proximity (9)" icon="car">
    True pairwise track gaps from the position channel: share of the lap within 1 s / 2 s of the car ahead, share spent in a train rather than a single tow, share with a car within 1 s **behind**, seconds within 1 s, closest and median gap ahead, how stable the car ahead's identity was, and how many distinct cars came within 3 s. The same physical hypothesis as `dirty_air` measured from a different sensor and both are kept, because the swap arm says they carry different things.
  </Card>
</CardGroup>

## The read-only contract in practice

<Info>
  **Two target spans.** The quantile trio and cliff classifier train on **114,270 laps** that carry the degradation and cliff targets; the stint-life regressor trains on **120,934 laps** the spans differ because a lap can have a valid stint-life count even where its next-lap jump is undefined.
</Info>

Ordinal encoding handles categoricals: the training map assigns each seen value an integer; NULL or unseen values get `−1` (the `MISSING_ORDINAL` sentinel). Continuous features keep `NaN` as `NaN` XGBoost learns the best split direction for missing values natively.

## Excluded columns

<Warning>
  **Read-only mart contract.** ML never writes the warehouse or the app. It reads
  [`fct_cliff_prediction_features`](/reference/models/fct/fct_cliff_prediction_features)
  read-only. The columns below are excluded from every model's feature matrix
  (`EXCLUDED_LEAKAGE_COLUMNS` in `ml/src/schema.py`), asserted by the
  [leakage-spine tests](/ml/ci/leakage-spine) every build.
</Warning>

Excluded (22 columns causal leakage / identifiers / targets / training
gate):

`circuit_key`, `drift_s_per_lap`, `driver_id`, `driver_skill_field_s`, `driver_skill_loro_mean_s`, `driver_skill_loro_s`, `driver_skill_proxy_s`, `driver_skill_residual_proxy_s`, `driver_skill_residual_s`, `is_training_eligible`, `lap_id`, `laps_until_cliff_class`, `next_3_lap_cumulative_jump_s`, `next_5_lap_cumulative_jump_s`, `next_lap_degradation_jump_detrended_s`, `next_lap_degradation_jump_s`, `race_id`, `race_year`, `remaining_stint_life_laps`, `stint_id`, `stint_length_laps`, `survival_weight`

## Relationships

<CardGroup cols={2}>
  <Card title="fct_cliff_prediction_features" href="/reference/models/fct/fct_cliff_prediction_features" icon="database">
    The mart this contract reads its full column list, lineage, and the tests that guard its grain.
  </Card>

  <Card title="Features and targets" href="/ml/features-and-targets" icon="list">
    How a mart row becomes a row of the `X` matrix encoding, fingerprinting, the forward-window audit, and the three targets.
  </Card>

  <Card title="Leakage spine" href="/ml/ci/leakage-spine" icon="shield">
    The 12 CI tests that assert these exclusions hold every build one idea per guard.
  </Card>

  <Card title="Model overview" href="/ml/overview" icon="microchip">
    Back to the ML front door.
  </Card>
</CardGroup>
