Flagging Impossible Journey Sequences in Tap Streams
The task on this page is to validate the order of a credential’s taps against the rules of a physical journey and emit a typed flag whenever the sequence is impossible: a second tap-IN with no tap-OUT between, a tap-OUT with no prior IN, or a re-entry at a station the rider could not have reached. Where a velocity check asks whether two taps are too far apart in space, this asks whether a run of taps forms a journey a single rider could actually make. It is one signal inside the Tap Pattern Anomaly Detection scorer, part of the wider Fraud Detection & Revenue Protection subsystem, and it is written for the transit ops teams, revenue analysts, and Python developers who need per-credential sequence validation that survives redelivery and out-of-order jitter. Each tap is assumed already normalized to a canonical media event; this page owns only the state machine and the flags it raises.
Sequence State Machine
A closed-loop or gated open-loop credential moves through a tiny state machine: it is either IDLE (no open leg) or IN_TRANSIT (tapped in, awaiting an exit). A tap-IN from IDLE opens a leg; a tap-OUT from IN_TRANSIT closes it. The two impossible transitions are a tap-IN while already IN_TRANSIT (a double-IN, no exit recorded) and a tap-OUT while IDLE (an orphan exit). A re-entry — a tap-IN that closes and immediately reopens — is legal in time but can still be impossible in geography, and that is the third flag. The machine below shows every transition and which ones raise a flag:
Step 1 — The State and the Typed Flags
Model the machine explicitly. A SeqState enum holds the two states, a Violation enum names the three impossible outcomes, and a MediaSequenceState dataclass carries the small per-credential memory the machine needs: the current state, the last tap (station and time), and a monotonic-check anchor. Keeping the flag types as an enum rather than free-text strings is what lets the parent scorer weight them and lets an analyst filter a review queue by violation class.
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional
logger = logging.getLogger("transit.sequence_check")
MONEY = Decimal("0.01")
class SeqState(str, Enum):
IDLE = "idle"
IN_TRANSIT = "in_transit"
class Violation(str, Enum):
DOUBLE_IN = "double_in"
ORPHAN_OUT = "orphan_out"
IMPOSSIBLE_REENTRY = "impossible_reentry"
class SequenceError(Exception):
"""Base exception for sequence validation failures."""
@dataclass(frozen=True)
class SeqTap:
media_hash: str
station_id: str
tap_utc: datetime
event_type: str # 'IN' | 'OUT'
fare_value: Decimal # fare booked on this tap, minor units
@dataclass
class MediaSequenceState:
state: SeqState = SeqState.IDLE
last_exit_station: Optional[str] = None
last_tap_utc: Optional[datetime] = None
@dataclass(frozen=True)
class SequenceFlag:
media_hash: str
violation: Violation
fare_at_risk: Decimal
evidence: str
Step 2 — Advancing the Machine
The SequenceValidator advances one credential’s state per tap and returns a SequenceFlag only when a transition is impossible; a clean transition returns None. Out-of-order delivery is guarded up front — a tap older than the last one seen for this media is dropped rather than allowed to corrupt the state — and the impossible-re-entry check delegates the geographic question to an injected reachability predicate, so the state machine stays free of coordinate math. There is no bare except; the reachability callback is expected to be total, and a malformed tap raises a typed error the caller handles.
from typing import Callable, Dict
# Reachability predicate: could a rider get from `exit_station` to `entry_station`
# within `gap_seconds`? Supplied by the caller (e.g. a graph or velocity check).
Reachable = Callable[[str, str, float], bool]
class SequenceValidator:
"""Per-media tap-sequence state machine emitting typed violation flags."""
def __init__(self, reachable: Reachable) -> None:
self._reachable = reachable
self._states: Dict[str, MediaSequenceState] = {}
def _quantize(self, amount: Decimal) -> Decimal:
return amount.quantize(MONEY, rounding=ROUND_HALF_UP)
def _flag(self, tap: SeqTap, violation: Violation, evidence: str) -> SequenceFlag:
logger.warning("media=%s %s: %s", tap.media_hash, violation.value, evidence)
return SequenceFlag(
media_hash=tap.media_hash,
violation=violation,
fare_at_risk=self._quantize(tap.fare_value),
evidence=evidence,
)
def advance(self, tap: SeqTap) -> Optional[SequenceFlag]:
if tap.tap_utc.tzinfo is None:
raise SequenceError(f"Naive timestamp for {tap.media_hash}")
if tap.event_type not in ("IN", "OUT"):
raise SequenceError(f"Unknown event_type {tap.event_type!r}")
st = self._states.setdefault(tap.media_hash, MediaSequenceState())
# Drop out-of-order taps: they belong to a jitter-correction pass, not here.
if st.last_tap_utc is not None and tap.tap_utc < st.last_tap_utc:
logger.info("media=%s dropping out-of-order tap at %s",
tap.media_hash, tap.station_id)
return None
flag: Optional[SequenceFlag] = None
if tap.event_type == "IN":
if st.state is SeqState.IN_TRANSIT:
flag = self._flag(
tap, Violation.DOUBLE_IN,
f"tap-IN at {tap.station_id} while a leg opened earlier is still open",
)
# State stays IN_TRANSIT; the earlier leg is unresolved.
else:
# Legal re-entry from IDLE: check geography if we have a prior exit.
if st.last_exit_station is not None and st.last_tap_utc is not None:
gap = (tap.tap_utc - st.last_tap_utc).total_seconds()
if not self._reachable(st.last_exit_station, tap.station_id, gap):
flag = self._flag(
tap, Violation.IMPOSSIBLE_REENTRY,
f"re-entry at {tap.station_id} unreachable from "
f"{st.last_exit_station} in {gap:.0f}s",
)
st.state = SeqState.IN_TRANSIT
else: # OUT
if st.state is SeqState.IDLE:
flag = self._flag(
tap, Violation.ORPHAN_OUT,
f"tap-OUT at {tap.station_id} with no prior open IN leg",
)
# State stays IDLE; nothing to close.
else:
st.state = SeqState.IDLE
st.last_exit_station = tap.station_id
st.last_tap_utc = tap.tap_utc
return flag
Two decisions carry the correctness. A DOUBLE_IN deliberately leaves the state IN_TRANSIT rather than resetting: the original leg was never closed, so the credential is still, as far as the machine knows, mid-journey, and pretending otherwise would mask a second violation on the next tap. And the geographic question is fully delegated — the validator never imports coordinate math, so the same machine works whether reachability comes from a station adjacency graph or from the sibling velocity check.
Validation & Test Cases
Exercise the machine with a clean journey, a double-IN, an orphan-OUT, and an impossible re-entry. The reachability stub treats any gap under two minutes as too short to cross the network.
from datetime import datetime, timezone
def reachable(exit_station: str, entry_station: str, gap_seconds: float) -> bool:
# Toy rule: crossing to a different station needs at least 120s.
if exit_station == entry_station:
return True
return gap_seconds >= 120.0
def tap(station, hhmm, kind, fare="2.50"):
h, m = hhmm.split(":")
return SeqTap("CARD_X", station,
datetime(2026, 7, 3, int(h), int(m), tzinfo=timezone.utc),
kind, Decimal(fare))
v = SequenceValidator(reachable)
# Clean journey: IN then OUT -> no flags
assert v.advance(tap("A", "08:00", "IN")) is None
assert v.advance(tap("A", "08:20", "OUT")) is None
# Double-IN: second IN with no OUT between -> DOUBLE_IN flag, fare at risk
v2 = SequenceValidator(reachable)
assert v2.advance(tap("A", "09:00", "IN")) is None
dbl = v2.advance(tap("B", "09:05", "IN"))
assert dbl is not None and dbl.violation is Violation.DOUBLE_IN
assert dbl.fare_at_risk == Decimal("2.50")
# Orphan-OUT: OUT while IDLE -> ORPHAN_OUT flag
v3 = SequenceValidator(reachable)
orphan = v3.advance(tap("C", "10:00", "OUT"))
assert orphan is not None and orphan.violation is Violation.ORPHAN_OUT
# Impossible re-entry: exit A at 11:00, re-enter B 60s later -> under 120s, unreachable
v4 = SequenceValidator(reachable)
v4.advance(tap("A", "10:59", "IN"))
v4.advance(tap("A", "11:00", "OUT"))
reentry = v4.advance(tap("B", "11:01", "IN")) # 60s gap < 120s reachability floor
assert reentry is not None and reentry.violation is Violation.IMPOSSIBLE_REENTRY
The double-IN case is the one that protects revenue directly: a second tap-IN with no exit between is the classic signature of a card handed back over a gate for a second rider, and the flag books the second tap’s Decimal("2.50") as at-risk. The orphan-OUT and impossible-re-entry cases prove the machine distinguishes a structural violation (an exit with nothing to close) from a geographic one (a legal transition to an unreachable place), so a review queue can triage them differently.
Edge Cases & Debugging for Transit Ops
Sequence validation is where real telemetry noise masquerades as fraud, so each violation needs a suppression story:
- Missing tap-outs. An open-loop bus leg or a gate that failed to register an exit leaves the machine
IN_TRANSIT; the next legitimate tap-IN then reads asDOUBLE_IN. Age out an unclosed leg after a maximum journey time so an honest missing-exit does not brand the next trip as fraud, and reconcile orphaned legs nightly through the Fallback Calculation Chains. - Out-of-order delivery. Hardware batch-writes can deliver a media’s taps slightly out of sequence. The
advancemethod drops taps older than the last seen, but a jitter-correction buffer upstream — reordering within a bounded window before the machine sees them — is what prevents a late early-tap from being lost entirely. - Transfer versus re-entry. A tap-OUT followed by a nearby tap-IN inside the transfer window is an ordinary interchange, not a violation; the machine only flags re-entry when the reachability predicate rejects it, so a genuine transfer aligned with Transfer Window Logic passes cleanly.
- Idempotent redelivery. Because
advancemutates state, a redelivered tap double-advances the machine and can fabricate aDOUBLE_IN. Dedupe on(media_hash, tap_utc, station_id)upstream before the tap reaches the validator, exactly as the parent scorer requires.
Integration Note
This validator is one signal inside the parent Tap Pattern Anomaly Detection scorer: the scorer owns the per-media state map and calls advance on each tap, folding any returned SequenceFlag into its weighted risk score alongside the velocity and re-use signals. Its closest sibling is Detecting Card Sharing with Tap Velocity Checks, and the two are complementary by design: the velocity check answers the reachability question this machine delegates, so a production wiring passes the velocity checker’s plausibility test in as the reachable predicate. Sequence covers order, velocity covers space, and together they close both ways a single credential can betray that it is being shared.
FAQ
Why keep DOUBLE_IN in the IN_TRANSIT state instead of resetting?
IN_TRANSIT would erase the evidence that an exit is missing and could mask a third violation on the next tap. Leaving the state open means the machine keeps signalling the unresolved leg until a real tap-OUT closes it or a maximum-journey timeout ages it out.
How is an impossible re-entry different from a velocity flag?
Won't missing tap-outs make this flag legitimate riders?
IN_TRANSIT, and the next honest tap-IN would then read as a DOUBLE_IN. The fix is a maximum-journey timeout that closes a stale leg before the next trip, plus a nightly reconciliation of orphaned legs through the fallback calculation chains. Only a second IN inside a plausible journey time is a real sharing signal.
Does a sequence flag block the rider at the gate?
Decimal fare-at-risk and accepts the tap; enforcement happens out of band. A single structural violation often has an innocent cause such as a missed exit, so the flag feeds a review queue and only a sustained pattern on one credential drives account action.
Related
- Tap Pattern Anomaly Detection — the parent scorer this sequence signal plugs into.
- Detecting Card Sharing with Tap Velocity Checks — the sibling signal that answers the reachability question this machine delegates.
- Fare Evasion Analytics — where sequence-violation rates aggregate into network-level evasion estimates.
- Transfer Window Logic — the evaluator that distinguishes a legitimate interchange from a flagged re-entry.
- Fallback Calculation Chains — how orphaned legs and missing tap-outs get an estimated fare.
Part of Tap Pattern Anomaly Detection, within Fraud Detection & Revenue Protection.