Skip to content

Facts

backend/facts.py turns the simulator state plus one candidate action into the JSON that Jev judges and the sentences the console shows. Everything that involves a number, a comparison or a lane index is computed here, so the judge only has to read. This page documents the schema, the geometry rules behind each field and the two render helpers.

Why geometry stays in code

Jev's documented weak spots are arithmetic, counting, numeric comparison, literal reading of negation and multi-hop reasoning, and irrelevant state lowers its accuracy. So the extractor sends a small, flat object per candidate with units in the field names, booleans for the comparisons a rule is likely to need, and nothing else. Facts come from the simulator's vehicle objects, never from the normalized observation the policy sees.

The schema

One object per candidate action. Here is a real one from a logged step, the ego in the rightmost lane and the candidate LANE_LEFT, with the fields added on Oct 17 (headway_s, time_to_collision_ahead_s, being_tailgated, traffic, history) filled in with illustrative values.

{
  "ego": {"lane": 4, "lane_count": 4, "speed_mps": 24.4, "target_speed_mps": 25.0},
  "action": {
    "name": "LANE_LEFT",
    "meaning": "move one lane to the left",
    "lane_after": 3,
    "target_speed_after_mps": 25.0,
    "lane_changes_last_10_steps_including_this_action": 2
  },
  "lane_after_action": {
    "is_leftmost": false,
    "is_rightmost": false,
    "vehicle_behind_gap_m": 1.5,
    "vehicle_behind_closing_speed_mps": -3.9,
    "vehicle_ahead_gap_m": 56.3,
    "vehicle_ahead_speed_mps": 20.4,
    "slower_vehicle_ahead_within_40_m": false,
    "headway_s": 2.3,
    "time_to_collision_ahead_s": 12.2,
    "being_tailgated": false
  },
  "current_lane": {
    "vehicle_ahead_gap_m": 7.2,
    "vehicle_ahead_speed_mps": 21.1,
    "slower_vehicle_ahead_within_40_m": true
  },
  "traffic": {"vehicles_within_50_m": 5},
  "history": {"lane_changes_last_10_steps": 1, "steps_in_current_lane": 4},
  "zones": []
}

Two blocks and one field are conditional. lane_right_of_lane_after appears only when the lane after the action is lane 1; zones holds one entry per zone not yet passed and is an empty list otherwise; and action.lane_changes_last_10_steps_including_this_action is present only when the action is a real lane change.

ego

Field Meaning
lane Current lane, numbered from the left starting at 1.
lane_count Lanes on the road, 4 in the demo configuration.
speed_mps Current speed.
target_speed_mps The speed the vehicle controller is holding.

action

Field Meaning
name One of LANE_LEFT, IDLE, LANE_RIGHT, FASTER, SLOWER.
meaning Plain words. A lane change at the road edge says it changes nothing: "keep lane 1, already the leftmost lane, so this action changes nothing". Without that, the judge scored a no-op lane change as a gap violation.
lane_after Lane after the action, clamped to the road.
target_speed_after_mps The controller's target after the action, from the vehicle's discrete speed grid: FASTER and SLOWER move speed_index one notch and clamp to the ends of target_speeds.
lane_changes_last_10_steps_including_this_action Only on a real lane change: history.lane_changes_last_10_steps plus one. The number the rate rule is judged on, computed here so the judge compares one number with the rule's instead of adding this action to the history. Left off other actions on purpose: Jev already scores IDLE after past changes near 0.08, and repeating the history count on it would read as IDLE breaking the rule for changes already made. Added Oct 17 after the first evaluation showed the judge underconfident on the rate rule.

lane_after_action

Describes the lane the ego will be in after the action, which is the current lane for IDLE, FASTER and SLOWER.

Field Meaning
is_leftmost, is_rightmost Lane 1 and lane lane_count.
vehicle_behind_gap_m Bumper to bumper gap to the nearest vehicle behind in that lane, or null.
vehicle_behind_closing_speed_mps That vehicle's speed minus the ego's speed, so positive means it is catching up. null when absent.
vehicle_ahead_gap_m Bumper to bumper gap to the nearest vehicle ahead, or null.
vehicle_ahead_speed_mps Its speed, or null.
slower_vehicle_ahead_within_40_m true when a vehicle ahead is within 40 m and slower than the ego.
headway_s Gap ahead divided by the target speed after the action, or null with no vehicle ahead. The target speed is used rather than the current speed because it is what the action commits to; for FASTER that makes the headway conservative.
time_to_collision_ahead_s Gap ahead divided by the closing speed (target speed after the action minus the vehicle ahead's speed), or null when not closing or with no vehicle ahead.
being_tailgated true when a vehicle behind in that lane is within 10 m and faster than the ego. For a lane change this is a cut-in close ahead of a closing vehicle.

traffic

Field Meaning
vehicles_within_50_m Other vehicles within 50 m of the ego longitudinally, in any lane. A density measure for rules such as "drive conservatively in dense traffic".

history

Field Meaning
lane_changes_last_10_steps Lane changes over the last ten transitions, counting the one this step's state implies.
steps_in_current_lane Consecutive previous steps spent in the current lane, 0 at the first step.

The history comes from EpisodeRunner, which keeps the ego's lane at the start of each past step and calls lane_history before building the facts. It is the same for every candidate in a step: it describes the past, not the action. A caller without a runner gets zeros.

current_lane and lane_right_of_lane_after

Both use the smaller "ahead" block: vehicle_ahead_gap_m, vehicle_ahead_speed_mps and slower_vehicle_ahead_within_40_m. current_lane is the ego's lane before the action. lane_right_of_lane_after is lane 2 and is present only when the action lands in lane 1 (and the road has more than one lane). It is the lane being passed, which the passing rule needs and which neither of the other blocks covers when the ego is already in lane 1.

zones

Field Meaning
name, lane From the operator's zone.
starts_in_m Distance from the ego to the zone start, floored at 0 once inside it.
ends_in_m Distance from the ego to the zone end.
action_enters_zone true when lane_after is the zone's lane and the ego's position after one step at its current speed falls inside the zone.

Zones whose end is behind the ego are dropped from the facts. The store retires them at the top of each step anyway (see constraints and checkers), so the two agree.

Geometry rules

Rule Where Detail
Lanes count from the left lane_number highway-env's lane index is zero-based; the facts add one.
Gaps are bumper to bumper _gaps Center distance minus the vehicle length (5 m), floored at 0.
Far vehicles are absent NEIGHBOUR_RANGE_M = 200 Beyond 200 m a neighbour is reported as null rather than as a large number.
Slower means slower and close _slower_ahead, SLOWER_AHEAD_RANGE_M = 40 Both conditions are required.
Tailgating is close and closing TAILGATE_RANGE_M = 10 A vehicle behind within 10 m whose speed exceeds the ego's.
Density is a count _traffic, TRAFFIC_RANGE_M = 50 Vehicles within 50 m along the road, any lane, ego excluded.
History is ten transitions lane_history, HISTORY_STEPS = 10 The runner keeps twenty past lanes so steps_in_current_lane can exceed ten.
A lane change counts itself _action_block A real lane change carries the history count plus one; the checker computes the same sum.
Margins use the speed after the action _headway_and_ttc Headway and time to collision divide by the target speed the action commits to.
One step is one second STEP_SECONDS = 1.0 Matches policy_frequency: 1; used for zone entry.
Neighbours come from the road road.neighbour_vehicles(ego, lane_tuple) highway-env's own lookup on the target lane, so the facts and the simulator agree.
Numbers are rounded to one decimal _r Keeps the state short and the cache key stable across floating-point noise.

Public functions

Function Returns
facts_for_action(env, action, zones, history=None) The facts object for one candidate from the live environment.
for_candidates(env, actions, zones, history=None) {action: facts} for the ranking, all sharing the same history.
lane_history(past_lanes, lane) The history block from the lanes of past steps and the current lane.
noop_actions(candidates) The actions whose facts show no change in lane or target speed; the shield excludes them from the least-bad fallback.
snapshot(env) The render snapshot for the record: ego lane, speed_mps, x_m and a list of other vehicles with id, lane, x_m, speed_mps. Ids are the index in road.vehicles, stable within an episode.
sentences(facts) The same facts as plain sentences for the console and the approval card.
lane_after, target_speed_after, action_meaning, lane_number Small helpers reused by the checkers' tests and the spike.

Sentences

The sentence renderer reads only the facts object, so what the operator reads is exactly what the judge saw. For the example above:

Ego speed 24.4 m/s, target 25 m/s, lane 4 of 4 (numbered from the left).
LANE_LEFT: move one lane to the left. Lane after: 3. Target speed after: 25 m/s. Lane changes in the last 10 steps including this one: 2.
Lane 3 after the action: nearest vehicle behind is 1.5 m away, closing at -3.9 m/s; nearest vehicle ahead is 56.3 m away at 20.4 m/s. Slower vehicle ahead within 40 m: no. Headway: 2.3 s. Time to collision ahead: 12.2 s. Tailgated within 10 m: no.
Current lane 4: nearest vehicle ahead is 7.2 m away at 21.1 m/s. Slower vehicle ahead within 40 m: yes.
Traffic: 5 vehicles within 50 m. Lane changes in the last 10 steps: 1. Steps in the current lane: 4.
Operator zones: none.

A lane-1 landing adds a sentence for the lane to its right, and each zone adds one that ends with "This action enters it: yes" or "no".

What is deliberately not sent

  • No normalized observation vector, Q-values or reward.
  • No vehicles beyond the nearest ahead and behind in the relevant lanes.
  • No absolute positions: zones are expressed as distances from the ego.
  • No rule text. The rule lives in the question, the state stays about the road.

Facts and the cache

The cache key in backend/jev_client.py hashes the facts object together with the model and the question. Any change to a field name, a rounding rule or a new block therefore invalidates every cached answer. That is the right behaviour, since a different state is a different question, but it means a warm cache for the demo seeds has to be rebuilt after editing this module. The Oct 17 additions did exactly that: every key changed, so the demo seeds need interlock run again before a mock-mode demo. The lane-change count added later that day changed only the keys of real lane-change candidates, since the other candidates' facts are byte for byte the same. See Jev integration.

Tests

tests/test_facts.py checks the schema against a live environment, that gaps match the simulator's own geometry, lane clamping at the edges, the no-op wording for edge lane changes, the lane-to-the-right block, the slower flag needing both gap and speed, headway, time to collision, tailgating and density against the simulator, lane_history over short and long sequences, the no-op action set, zone facts relative to the ego with the entry flag, the speed grid for target_speed_after, that only a real lane change carries the count including itself and that the checker agrees with it, and that the sentences cover every block. tests/test_episode.py checks that the history in every logged candidate matches the lanes of the preceding records.