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
21 changes: 21 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,27 @@ MENDER_PAT=
# SOLVER_POOL=1
# SOLVER_POOL_CALL_TIMEOUT_S=30

# Node reputation penalties (retina-analytics NodeReputation, evaluated every
# 60 s by services/tasks/periodic.reputation_evaluator). Multiplies EVERY
# penalty: trust 0.15/0.05, stale heartbeat 0.1, high detection rate 0.05,
# neighbour inconsistency 0.08, ADS-B cross-validation 0.1. Rewards are not
# scaled. 0 means no penalty is recorded at all, so no node can be blocked by
# any of these paths; 1 is the historical behaviour.
#
# The default is 0, and it is TEMPORARY (set 2026-09-18). The only trust input
# today is a single claim residual from the identity-first lane: one
# out-of-threshold residual scores a node 0.0, and 0.15 a pass takes it from
# 1.0 through the 0.2 block threshold in six minutes. That blocked a real
# mirrored node on the test droplet off ONE sample — and a blocked node has
# every frame dropped, cannot earn its way back (apply_reward is a no-op while
# blocked), and stays blocked across restarts because the block is persisted in
# the state snapshot. Put it back to 1 once trust is computed from enough
# evidence to act on.
#
# This only stops NEW penalties. Blocks a snapshot already carries are cleared
# with backend/scripts/unblock_nodes.py — see docs/runbook.md.
# REPUTATION_PENALTY_SCALE=0

# Detection-archive buffering (services/frame_processor.py). Frames are kept in
# the buffer across a failed Parquet write so a transient disk-full does not
# drop data, and the per-node key is released only by a successful write that
Expand Down
51 changes: 51 additions & 0 deletions backend/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
retina_tracker YAML config stays separate (loaded at runtime via config.yaml).
"""

import logging
import math
import os

Expand Down Expand Up @@ -478,6 +479,56 @@ def _assoc_alt_layers_km() -> tuple[float, ...]:
ADSB_TRUTH_INTERVAL_S = 120 # ADS-B truth fetcher sleep (s)
ADSB_BACKOFF_S = 300 # Rate-limit backoff (s)

# ── Node reputation penalties ────────────────────────────────────────────────
# Multiplier applied to EVERY reputation penalty in retina-analytics
# (NodeReputation.penalty_scale): trust 0.15/0.05 per evaluator pass, stale
# heartbeat 0.1, high detection rate 0.05, neighbour inconsistency 0.08, and
# the ADS-B cross-validation 0.1 charged from services/tasks/periodic.py.
# Rewards are untouched.
#
# 0 — no penalty is ever recorded, so no node can be blocked by any of
# these paths (a penalty of 0 is dropped entirely: no ledger entry, no
# reputation change).
# 1 — the historical behaviour.
#
# The default is 0, and that is a TEMPORARY stance taken 2026-09-18, not a
# decision that reputation should never bite. The only trust input today is a
# single claim residual from the identity-first lane, and one out-of-threshold
# residual scores a node 0.0: the evaluator then charges 0.15 every
# REPUTATION_INTERVAL_S (60 s) pass, so 1.0 crosses the 0.2 block threshold in
# six minutes. That is exactly what happened to a real mirrored node
# (node_ref ndebvzgeoij5t2l) on the test droplet from ONE sample — once
# blocked, record_detection_frame drops every frame it sends, apply_reward is
# a no-op, and the block is persisted by services/state_snapshot.py, so it
# survives restarts. Restore penalties (scale 1) once trust is computed from
# enough evidence to be worth acting on; the evaluator's own min-sample bar
# lives in retina_analytics.trust.TRUST_MIN_SAMPLES.
#
# Blocks already recorded in a snapshot are NOT cleared by this switch — it
# only stops new penalties. Use backend/scripts/unblock_nodes.py for those.


def _parse_penalty_scale(raw: str | None) -> float:
"""REPUTATION_PENALTY_SCALE as a non-negative finite float, else 0.

A malformed or negative value must not silently become "penalties on":
the safe reading of an unparseable gate here is the deployed default.
"""
if raw is None or not raw.strip():
return 0.0
try:
value = float(raw)
except (TypeError, ValueError):
logging.warning("REPUTATION_PENALTY_SCALE=%r is not a number — using 0 (no penalties)", raw)
return 0.0
if not math.isfinite(value) or value < 0:
logging.warning("REPUTATION_PENALTY_SCALE=%r is not finite and >= 0 — using 0 (no penalties)", raw)
return 0.0
return value


REPUTATION_PENALTY_SCALE = _parse_penalty_scale(os.getenv("REPUTATION_PENALTY_SCALE"))

# ── External ADS-B query regions ─────────────────────────────────────────────
ADSB_CELL_SPACING_KM = 400.0 # Lattice cell size for grouping nodes into queries
# Padding every query region carries around its members, sized against the
Expand Down
14 changes: 14 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from retina_analytics.association import InterNodeAssociator
from retina_analytics.manager import NodeAnalyticsManager
from retina_analytics.reputation import set_penalty_scale
from retina_custody.crypto_backend import SignatureVerifier
from retina_custody.models import NodeIdentity

Expand All @@ -27,6 +28,7 @@
GROUND_TRUTH_MAX, # noqa: F401 — re-exported, used via state.GROUND_TRUTH_MAX
N2_CONFIRM_MIN_EPOCHS,
N2_CONFIRM_MIN_SPAN_S,
REPUTATION_PENALTY_SCALE,
TRACK_HISTORY_MAX, # noqa: F401 — re-exported, used via state.TRACK_HISTORY_MAX
as_num,
)
Expand Down Expand Up @@ -148,6 +150,18 @@

node_analytics = NodeAnalyticsManager(storage_dir=COVERAGE_STORAGE_DIR, fov_mode=FOV_MODE)

# Every reputation penalty in retina-analytics is multiplied by this, and the
# default is 0 — no node can be blocked by a penalty (see
# config/constants.REPUTATION_PENALTY_SCALE for why, and
# backend/scripts/unblock_nodes.py for clearing blocks a snapshot already
# carries). It is a process-wide ClassVar, so it has to be set before
# anything reads it: at import here it lands before restore_snapshot()
# rebuilds the NodeReputation objects and before the reputation evaluator's
# first pass, which are the only two things that could act on it. Logged
# from main.py's startup, not here: this runs before logging.basicConfig,
# where an INFO line is dropped.
set_penalty_scale(REPUTATION_PENALTY_SCALE)


def _coverage_limit_for(node_id: str):
"""Shrink-only empirical prior for one node, from accumulated ADS-B fixes.
Expand Down
10 changes: 10 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

from config.constants import REPUTATION_PENALTY_SCALE
from core import state
from core.env_parsing import parse_comma_list
from pipeline.passive_radar import DEFAULT_NODE_CONFIG, PassiveRadarPipeline
Expand Down Expand Up @@ -185,6 +186,15 @@ async def lifespan(app: FastAPI):

await prime_pipeline_at_startup()

# Set at import in core/state.py (it must precede the restore below); said
# here, after logging is configured, so a deploy log says plainly whether
# node reputation penalties are on.
logging.info(
"Node reputation penalty scale: %.3g (%s)",
REPUTATION_PENALTY_SCALE,
"penalties DISABLED — no node can be blocked" if REPUTATION_PENALTY_SCALE == 0 else "penalties active",
)

# Restore persisted state before accepting connections
restored = restore_snapshot()

Expand Down
Loading
Loading