Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,30 @@ def _adsb_for_seeding() -> dict[str, dict]:
KNOWN_CLAIMS_PER_HEX_MAX = 64
known_claims: dict[str, deque] = {}

# ── Known-track holds: the link a claim leaves behind ─────────────────────────
# Written by services/known_claiming.py on every claim for (node, hex); read
# by the same module's hold path on the next frame from that node.
# node_id -> hex -> {"delay_us", "doppler_hz", "ts_ms",
# "prev_delay_us", "prev_doppler_hz", "prev_ts_ms",
# "fix": the claim's adsb_fix (original fix_ts_ms),
# "world", "n_claims", "n_hold"}
# A claim is evidence that THIS node's echo of THIS hex sits at that
# (delay, Doppler); the transponder fix that first supplied the identity is
# not needed to keep believing it one frame later. So the entry is the node's
# own measured track of the aircraft, and the hold path predicts the next
# frame's observation from it — which is what keeps a linked track linked
# after the tags stop and the cached fix ages out, instead of falling into the
# dark pool as a fresh ghost beside the aircraft it belongs to.
# Two samples, not one: the Doppler rate needs a difference, and a rate from
# ADS-B would re-introduce the dependency the hold exists to drop.
# Same unlocked discipline as known_claims above (single writer per node — the
# frame worker — dict writes atomic under the GIL). Bounded three ways:
# entries older than KNOWN_HOLD_MAX_GAP_S are dropped when that node's next
# frame is processed, feed_gc.prune_stale_stores prunes per hex for a node
# that stopped sending entirely, and a hold is one entry per (node, hex) that
# ever claimed — the same population known_claims is keyed by.
known_track_holds: dict[str, dict[str, dict]] = {}

# ── Track history: rolling position buffer per aircraft hex ───────────────────
# The TRUE frame. Everything internal compares against it — the speed gate's
# reference, the arc-motion log, the jump check, routes/test.py's ground-truth
Expand Down Expand Up @@ -588,6 +612,16 @@ def _adsb_for_seeding() -> dict[str, dict]:
# Claiming-stage exceptions absorbed by frame_processor's fail-open guard.
# Nonzero means the known lane is broken and silently contributing nothing.
known_claims_errors: int = 0
# Known-track hold (see known_track_holds above and services/known_claiming.py).
# claims counts detections claimed by the hold path — the ones that would have
# fallen into the dark pool as the tags stopped; expired counts hold entries
# dropped for exceeding KNOWN_HOLD_MAX_GAP_S; dropped_disagree counts holds
# discarded because a FRESH ADS-B fix for the same hex contradicted the held
# track (the ghost-lock guard: the hold may outlive the transponder, never
# disagree with it while it is still reporting).
known_hold_claims: int = 0
known_hold_expired: int = 0
known_hold_dropped_disagree: 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 @@ -1044,6 +1078,7 @@ def _reset_for_tests() -> None:
global adsb_seed_frames_autotagged, adsb_capture_ts_fallback
global known_claims_made, known_claim_contentions, known_claims_bound
global known_claims_errors, known_claims_visibility_rejects, known_claims_world_rejects
global known_hold_claims, known_hold_expired, known_hold_dropped_disagree
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 @@ -1084,6 +1119,7 @@ def _reset_for_tests() -> None:
multinode_tracks,
adsb_aircraft,
known_claims,
known_track_holds,
track_histories,
track_histories_public,
track_last_emit,
Expand Down Expand Up @@ -1148,6 +1184,7 @@ def _reset_for_tests() -> None:
known_claims_made = known_claim_contentions = known_claims_bound = 0
known_claims_errors = known_claims_visibility_rejects = 0
known_claims_world_rejects = 0
known_hold_claims = known_hold_expired = known_hold_dropped_disagree = 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
45 changes: 44 additions & 1 deletion backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from core import state
from core.task_registry import get_stale_tasks
from core.users import require_admin
from services import dark_follow, track_filter
from services import dark_follow, known_claiming, track_filter
from services.frame_processor import resolve_ground_truth_hex
from services.geo import haversine_km
from services.id_utils import is_transponder_hex, normalize_hex_key
Expand Down Expand Up @@ -467,6 +467,36 @@ async def get_anomaly_log():
)


# ── Known-track hold (path H) ─────────────────────────────────────────────────


@router.get("/api/test/known-hold")
async def get_known_hold():
"""Current hold window, in seconds of frame time (0 = feature off)."""
return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S}


@router.put("/api/test/known-hold")
async def put_known_hold(body: dict = Body(...), _admin=Depends(require_admin)):
"""Set the hold window live, so the feature can be A/B'd on a running
backend without a redeploy. 0 turns path H off entirely — the store stops
being written as well, so "off" is the behaviour that predates the hold
rather than a hold that never matches.

Admin-gated on the same precedent as put_simulation_config: this changes
which detections leave the dark pool for every node at once.
"""
v = body.get("max_gap_s")
if not isinstance(v, (int, float)) or isinstance(v, bool) or not (0 <= v <= 300):
raise HTTPException(400, detail="max_gap_s must be 0-300")
known_claiming.KNOWN_HOLD_MAX_GAP_S = float(v)
if v == 0:
# Off means off: a store left behind would come back the moment the
# window was reopened, holding tracks from before the experiment.
state.known_track_holds.clear()
return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S}


# ── Simulation physics config ─────────────────────────────────────────────────


Expand Down Expand Up @@ -1275,6 +1305,9 @@ def _solver_window_stats(minutes: float) -> dict:
kc_visibility_rejects = state.known_claims_visibility_rejects
kc_world_rejects = state.known_claims_world_rejects
kc_errors = state.known_claims_errors
kh_claims = state.known_hold_claims
kh_expired = state.known_hold_expired
kh_disagree = state.known_hold_dropped_disagree
# 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 @@ -1442,6 +1475,16 @@ def _solver_window_stats(minutes: float) -> dict:
"visibility_rejects": kc_visibility_rejects,
"world_rejects": kc_world_rejects,
"errors": kc_errors,
# Path H (services/known_claiming._claim_holds). claims is the
# detections held onto a hex after its transponder stopped
# explaining them; disagree the ghost-lock guard firing (a fresh
# fix contradicted the held track); holds the CURRENT size of the
# store, a gauge, summed over nodes — read beside claims, since a
# store that grows while claims does not is holds that never match.
"hold_claims": kh_claims,
"hold_expired": kh_expired,
"hold_dropped_disagree": kh_disagree,
"holds": sum(len(h) for h in list(state.known_track_holds.values())),
},
# 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
20 changes: 20 additions & 0 deletions backend/services/aircraft_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
normalize_hex_key,
passive_track_hex,
)
from services.known_claiming import KNOWN_CLAIM_MAX_FIX_AGE_S
from services.public_location import fuzz_node_cfg
from services.solve_uncertainty import solve_sigma_m, velocity_sigma_ms
from services.track_gates import (
Expand Down Expand Up @@ -309,6 +310,19 @@ def _claimed_single_node_entries(now: float) -> list[dict]:
lat, lon = fix.get("lat"), fix.get("lon")
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
continue
# A HELD claim (known_claiming path H) carries the LAST fix the
# transponder ever gave, however old — the hold is a radar link, not a
# position report. This section draws the fix itself, so once that fix
# is past the claiming path's own freshness cap there is nothing here
# worth drawing: the icon would sit where the aircraft was, not where
# it is, and grow more wrong the longer the hold succeeds. Skipped
# rather than dead-reckoned — one node's claim gives an arc, not a
# position, so there is no honest estimate to put in its place. Two or
# more claiming nodes are unaffected: those are the known lane's, and
# it solves them (see known_lane._build_solver_input's stale-fix seed).
_fix_age_s = (float(newest["ts_ms"]) - float(fix.get("fix_ts_ms") or 0)) / 1000.0
if newest.get("hold") and _fix_age_s > KNOWN_CLAIM_MAX_FIX_AGE_S:
continue
node_id = newest["node_id"]
delay_us = float(newest.get("delay_us") or 0.0)

Expand Down Expand Up @@ -341,6 +355,12 @@ def _claimed_single_node_entries(now: float) -> list[dict]:
"seen": round(max(0.0, now - float(newest["ts_ms"]) / 1000.0), 1),
"multinode": False,
"position_source": "adsb_single_node",
# True when the drawn fix is no longer being refreshed by a
# transponder (a hold still inside the freshness cap, or a
# cached fix that has aged during this claim's own lifetime),
# so the map can say the position is coasting rather than
# measured. Absent-as-false for every claim made today.
"adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")),
# Mandatory: the live/owner WS feeds drop any entry whose
# node_id is not in the connection's node set.
"node_id": node_id,
Expand Down
13 changes: 13 additions & 0 deletions backend/services/feed_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ def prune_stale_stores(now: float) -> None:
for h in stale_claims:
state.known_claims.pop(h, None)

# 4a-bis. Known-track holds, on the same rule and for the same reason.
# The claiming path expires a node's own entries against ITS frame clock,
# which never ticks again for a node that stopped sending — so a node that
# disconnects mid-claim would pin one entry per hex it ever held. Pruned
# per hex (not per node) so a still-live node keeps the tracks it is still
# holding.
for nid, holds in list(state.known_track_holds.items()):
for h, e in list(holds.items()):
if (now - e.get("ts_ms", 0) / 1000.0) > KNOWN_CLAIMS_STALE_S:
holds.pop(h, None)
if not holds:
state.known_track_holds.pop(nid, None)

# 4b. Prune stale ground-truth trails and track histories to bound
# memory and keep resolve_ground_truth_hex O(N) scans cheap.
#
Expand Down
Loading
Loading