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:

Per-media tap-sequence state machine A credential starts in the IDLE state. A tap-IN moves it to IN_TRANSIT, opening a leg. From IN_TRANSIT a tap-OUT returns it to IDLE, closing the leg cleanly. Two transitions are impossible and raise flags: a second tap-IN while already IN_TRANSIT raises DOUBLE_IN and keeps the state IN_TRANSIT, and a tap-OUT while IDLE raises ORPHAN_OUT and stays IDLE. A separate check on a legal re-entry, where a tap-IN follows a recent tap-OUT at an unreachable station, raises IMPOSSIBLE_REENTRY. tap-IN — open leg tap-OUT — close leg IDLE no open leg IN_TRANSIT leg open ORPHAN_OUT tap-OUT while IDLE DOUBLE_IN tap-IN while IN_TRANSIT IMPOSSIBLE_REENTRY re-entry at unreachable station

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:

  1. 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 as DOUBLE_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.
  2. Out-of-order delivery. Hardware batch-writes can deliver a media’s taps slightly out of sequence. The advance method 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.
  3. 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.
  4. Idempotent redelivery. Because advance mutates state, a redelivered tap double-advances the machine and can fabricate a DOUBLE_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?
Because the first leg was never closed. A second tap-IN does not resolve the open leg — it compounds the problem — so resetting to a fresh 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?
They test different edges of the same journey. The velocity check compares two consecutive taps and flags when the implied speed between them is physically impossible. The re-entry check fires only on a legal state transition — a tap-OUT then a later tap-IN — and asks whether the rider could have reached the new entry station in the elapsed gap. The sequence machine delegates that geographic question to a reachability predicate, which in production is often the velocity check itself, so the two signals reinforce rather than duplicate each other.
Won't missing tap-outs make this flag legitimate riders?
They will if you never age out an open leg. Open-loop legs and failed exit gates routinely leave a credential 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?
No. Like every signal in the anomaly scorer, this validator emits a flag with a 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.

Part of Tap Pattern Anomaly Detection, within Fraud Detection & Revenue Protection.