Skip to content
Merged
33 changes: 33 additions & 0 deletions backend/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,39 @@ def _assoc_alt_layers_km() -> tuple[float, ...]:
# timestamps.
CAL_FIX_DETECTION_SKEW_S = 2.0

# ── Calibration from the CLAIM lane (KNOWN_LANE_MODE != off) ─────────────────
# Since KNOWN_LANE_MODE defaulted to "binding" (#240, 2026-08-25) the emit-loop
# path above records nothing for a synthetic node: claiming strips the bound
# detections from the frame before the tracker sees them, so
# track.last_detection_adsb_hex never gets set and every synthetic node's
# newest calibration point is dated 2026-08-25. The claim lane is now the one
# source under that mode (services/known_claiming.py), under a rule much
# stricter than claiming itself.
#
# Residual bound for a claim that may characterize coverage, UNSCALED by fix
# age (unlike the claim gate itself). The claim gate is 10 µs / 25 Hz, which
# is where it has to be to bind an echo at all; the simulator's measurement
# noise is sigma 0.1–0.2 µs of delay and 2–4 Hz of Doppler
# (retina_simulation.world.generate_detections_for_node), so 3 µs / 8 Hz is
# still >5 sigma at the noisy end while shrinking the delay × Doppler area a
# WRONG aircraft can land in by ~10x. A claim is a bind; a calibration point
# is a claim this node would bet its coverage polygon on.
CAL_CLAIM_DELAY_US = 3.0
CAL_CLAIM_DOPPLER_HZ = 8.0

# Maturity bar, the counterpart of the emit path's ``n_detections >= 3``: a
# one-frame coincidence between a wrong hex's dead-reckoned fix and a clutter
# peak is exactly what a tight residual cannot rule out on its own, and it
# cannot repeat frame after frame at a consistent (delay, Doppler). Three
# claims of the same hex by the same node, with no gap larger than
# CAL_CLAIM_STREAK_GAP_S between consecutive ones, is a LINK rather than a
# coincidence. 10 s: a node's frames arrive ~1 s apart and the simulator's
# SNR-dependent miss rate reaches 40% at the detection threshold, so 2–4 s
# gaps are routine — the same reasoning that puts KNOWN_HOLD_MAX_GAP_S at 8 s,
# loosened because breaking a streak only costs a sample, not a track.
CAL_CLAIM_MIN_CLAIMS = 3
CAL_CLAIM_STREAK_GAP_S = 10.0

# ── ADS-B seeding (ADSB_SEED_MODE) ────────────────────────────────────────────
# A track view exports its ADS-B tag only if one of the newest N history
# detections carries it. A swapped track's newest detections go untagged
Expand Down
20 changes: 20 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,20 @@ def _adsb_for_seeding() -> dict[str, dict]:
# what stops a node that newly acquires a silent aircraft from feeding the
# dark pool and minting a twin key beside the lane's entry.
known_follow_claims: int = 0
# Empirical-coverage calibration from the CLAIM lane (see
# services/known_claiming._calibration_from_claim and services/calibration.py's
# fourth rule). recorded counts the points actually written; the five rejects
# are the five rules, charged in order, so exactly one of the six is bumped per
# non-hold claim and they sum to the claim count. Read them as a funnel: a
# recorded count of zero beside a large `immature` is a fleet whose links are
# too short-lived, and one beside a large `contested` is traffic too dense for
# an exclusive attribution — two different problems with the same symptom.
calibration_points_recorded: int = 0
calibration_claims_rejected_hold: int = 0
calibration_claims_rejected_stale_fix: int = 0
calibration_claims_rejected_residual: int = 0
calibration_claims_rejected_contested: int = 0
calibration_claims_rejected_immature: int = 0
# Dark track following (DARK_FOLLOW_MODE) — see services/dark_follow.py.
# targets is a GAUGE (the size of the current pseudo-state list, assigned on
# every rebuild), the other four are since-boot counters. The funnel reads
Expand Down Expand Up @@ -1086,6 +1100,9 @@ def _reset_for_tests() -> None:
global known_claims_errors, known_claims_visibility_rejects, known_claims_world_rejects
global known_hold_claims, known_hold_expired, known_hold_dropped_disagree
global known_follow_claims
global calibration_points_recorded, calibration_claims_rejected_hold
global calibration_claims_rejected_stale_fix, calibration_claims_rejected_residual
global calibration_claims_rejected_contested, calibration_claims_rejected_immature
global dark_follow_targets, dark_follow_claims, dark_follow_inputs
global dark_follow_published, dark_follow_dropped, dark_bottomup_shadowed
global dark_follow_inelig_cooldown, dark_follow_inelig_no_pos
Expand Down Expand Up @@ -1193,6 +1210,9 @@ def _reset_for_tests() -> None:
known_claims_world_rejects = 0
known_hold_claims = known_hold_expired = known_hold_dropped_disagree = 0
known_follow_claims = 0
calibration_points_recorded = calibration_claims_rejected_hold = 0
calibration_claims_rejected_stale_fix = calibration_claims_rejected_residual = 0
calibration_claims_rejected_contested = calibration_claims_rejected_immature = 0
dark_follow_targets = dark_follow_claims = dark_follow_inputs = 0
dark_follow_published = dark_follow_dropped = 0
dark_follow_inelig_cooldown = dark_follow_inelig_no_pos = 0
Expand Down
12 changes: 12 additions & 0 deletions backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,11 @@ def _solver_window_stats(minutes: float) -> dict:
kh_expired = state.known_hold_expired
kh_disagree = state.known_hold_dropped_disagree
kf_claims = state.known_follow_claims
cal_recorded = state.calibration_points_recorded
cal_rejects = {
reason: getattr(state, f"calibration_claims_rejected_{reason}")
for reason in ("hold", "stale_fix", "residual", "contested", "immature")
}
# Same one-lock snapshot for the follow lane's funnel and the
# per-reason ineligibility tally beside it: the two are only readable
# against each other (see the dark_follow block below), so they must
Expand Down Expand Up @@ -1658,6 +1663,13 @@ def _solver_window_stats(minutes: float) -> dict:
# detections that would otherwise have started a dark twin.
"follow_claims": kf_claims,
"holds": sum(len(h) for h in list(state.known_track_holds.values())),
# Empirical-coverage calibration, which under KNOWN_LANE_MODE != off
# comes only from this lane (services/calibration.py's fourth rule).
# recorded is points written; rejected is the five rules, charged in
# order — exactly one per non-hold claim, so they sum with recorded
# to the non-hold claim count.
"calibration_recorded": cal_recorded,
"calibration_rejected": cal_rejects,
},
# Dark published solves against the node pool their round had for the
# same aircraft (see the pooled/shortfalls block above). pct is null
Expand Down
Loading
Loading