Fallback Calculation Chains

Fallback calculation chains keep an automated fare collection system charging deterministically when the primary fare engine cannot run. They are the degraded-mode path inside the broader Fare Rule Validation & Calculation Engines pipeline: when network partitions, stale rule tables, delayed GTFS-Realtime feeds, or validator clock skew make a normal fare decision impossible, the chain cascades through prioritized rule subsets, tolerance thresholds, and historical baselines to capture revenue without blocking the rider or defaulting to a punitive flat maximum. This page covers where the chain sits in that pipeline, how to build it in Python, and how to keep every degraded transaction auditable and reconcilable.

The distinction that matters is that fallback chains are not emergency overrides — they are engineered degradation paths. They activate on well-defined triggers: schema validation failure, rule-version mismatch beyond a tolerance window, or real-time position data outside acceptable latency bounds. Each tier tags its output with a calculation_mode and a confidence_score, so downstream reconciliation can separate a fully-priced fare from a conservatively-estimated one and settle the difference later. For transit operators and revenue analysts, the objective is to maintain policy compliance and auditability without introducing speculative pricing or double-charging during upstream instability.

Architecture: How an Event Descends the Chain

The chain intercepts an event only when the primary node cannot produce a trustworthy fare. It then drops the event through prioritized tiers, each strictly more conservative than the last, and each stamping the result with a mode and confidence before emitting a single audit payload with a derived idempotency key.

How a tap event descends the fallback calculation chain A tap event tests whether the primary engine is reachable. If yes, it is priced at PRIMARY mode with confidence 1.00 and flows straight to the audit emit. If no, the chain resolves a static fare from the cached GTFS schedule and zone matrix at FALLBACK_STATIC mode, confidence 0.65. That static fare is then tested against the agency cap: if it exceeds the cap it is clamped and demoted to FALLBACK_CONSERVATIVE mode, confidence 0.45; otherwise the static fare is kept. Every branch converges on a single audit payload carrying a derived idempotency key, and confidence decreases monotonically as tiers fall back. confidence 1.00 → 0.45 yes no yes no Tap event Primary engine reachable? PRIMARY live rules + GTFS-RT confidence 1.00 Resolve static fare cached GTFS + zone matrix FALLBACK_STATIC · 0.65 Fare above agency cap? Clamp to agency cap FALLBACK_CONSERVATIVE · 0.45 Keep static fare unchanged · confidence 0.65 Emit audit payload + derived idempotency_key one audit row per tap

The tiers form a fixed ladder. Confidence decreases monotonically as the chain falls back to coarser data, which lets reconciliation weight each transaction and prioritize which degraded fares to re-price once the primary engine recovers:

Tier calculation_mode Data source Confidence Typical trigger
0 PRIMARY Live rule tables + GTFS-RT 1.00 Normal operation
1 FALLBACK_STATIC Cached static GTFS schedule + zone matrix 0.65 GTFS_RT_TIMEOUT, feed lag
2 FALLBACK_CONSERVATIVE Static fare clamped to agency cap 0.45 Static fare exceeds cap; partial rule sync
3 FALLBACK_MAX_CAP Flat capped fare, last resort 0.30 No cached fare resolvable

Prerequisites & Environment

The chain is designed to run in two very different places: a central stream processor and a memory-constrained edge validator. Keep the dependency surface identical across both so the same code path produces the same fare.

  • Python 3.11+. Required for dataclass(slots=True), the mature zoneinfo timezone database, and exception-group handling in stream consumers. The reference code below runs on CPython 3.11 through 3.13.
  • Standard library only for the money path. decimal for fixed-point arithmetic, hashlib for idempotency keys, collections.deque for bounded caches, enum for mode/reason vocabularies, and logging for structured audit lines. Avoid third-party numeric libraries on the fare path — a stray float anywhere is a latent reconciliation bug.
  • Optional at the edges. pydantic v2 for inbound payload validation and zoneinfo for local-time normalization. On lightweight validators these are the only non-stdlib packages you should need.
  • AFC vendor assumptions. The chain consumes already-normalized events. Vendor-specific card layouts must resolve to a canonical media_hash upstream through Smart Card Schema Mapping, and structural rejection of malformed payloads belongs to the Schema Validation Pipelines that guard ingestion — not to the fallback tiers.
  • Data schema expectations. Each event carries tap_id, validator_id, an aware UTC timestamp, route_id, and origin/destination zone identifiers. Static fallback tables are a (route_id, zone_from) → Decimal map derived from the published GTFS schedule and refreshed on the same cadence as rule deployments.

Pipeline Routing & Event Decoupling

Modern AFC pipelines ingest tap events, GTFS-RT trip updates, and fare policy tables into a unified calculation graph. When the primary node stalls under feed degradation or message-queue backpressure, the fallback chain intercepts the event stream and applies a simplified but contractually valid model. This demands explicit routing logic that preserves event ordering while decoupling fare resolution from real-time dependency chains — the consumer must never block waiting on a live feed it can no longer reach.

When connectivity drops, the chain references cached Transfer Window Logic to apply conservative time buffers, so riders are not penalized for system-level latency while overlapping-journey revenue leakage is still prevented. Design the fallback graph to consume the static GTFS schedule as a baseline and apply configurable time offsets when GTFS-RT payloads exceed validation thresholds. The pipeline must emit an explicit calculation_mode flag alongside every fare amount, so reconciliation systems can segregate primary versus fallback transactions without manual intervention.

Core Implementation

The following implementation is a memory-efficient, error-resilient chain with explicit reconciliation tagging. It uses bounded state, fixed-point arithmetic, and structured logging suitable for high-throughput transit pipelines. Note that the money path never touches float, the idempotency key is derived rather than supplied, and failure routes to a deterministic tier rather than crashing the consumer thread.

from __future__ import annotations

import hashlib
import logging
import time
from collections import deque
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional, Generator

logger = logging.getLogger("fare.fallback")


class CalculationMode(str, Enum):
    PRIMARY = "PRIMARY"
    FALLBACK_STATIC = "FALLBACK_STATIC"
    FALLBACK_CONSERVATIVE = "FALLBACK_CONSERVATIVE"


class FallbackReason(str, Enum):
    NONE = "NONE"
    GTFS_RT_TIMEOUT = "GTFS_RT_TIMEOUT"
    RULE_VERSION_MISMATCH = "RULE_VERSION_MISMATCH"
    VALIDATOR_CLOCK_SKEW = "VALIDATOR_CLOCK_SKEW"


@dataclass(slots=True)
class TapEvent:
    tap_id: str
    validator_id: str
    timestamp: float          # POSIX seconds, UTC
    route_id: str
    zone_from: str
    zone_to: Optional[str] = None


@dataclass(slots=True)
class FareResult:
    amount: Decimal
    currency: str
    calculation_mode: CalculationMode
    fallback_reason: FallbackReason
    confidence_score: float
    idempotency_key: str
    audit_hash: str


class BoundedRuleCache:
    """Memory-bounded cache with FIFO eviction for static fare tables.

    __slots__ and a maxlen deque keep the footprint predictable on edge
    validators; the cache never grows without bound during rule churn.
    """

    __slots__ = ("_cache", "_order")

    def __init__(self, max_size: int = 5000) -> None:
        self._cache: dict[str, Decimal] = {}
        self._order: deque[str] = deque(maxlen=max_size)

    def get(self, key: str) -> Optional[Decimal]:
        return self._cache.get(key)

    def put(self, key: str, value: Decimal) -> None:
        if key in self._cache:
            return
        if len(self._cache) >= self._order.maxlen:
            evicted = self._order.popleft()
            self._cache.pop(evicted, None)
        self._cache[key] = value
        self._order.append(key)


class FallbackChain:
    def __init__(
        self,
        base_fare: Decimal,
        max_cap: Decimal,
        gtfs_rt_timeout_ms: int = 3000,
        clock_skew_tolerance_sec: int = 120,
    ) -> None:
        self.base_fare = base_fare
        self.max_cap = max_cap
        self.timeout_ms = gtfs_rt_timeout_ms
        self.static_cache = BoundedRuleCache()
        self._clock_skew_tolerance_sec = clock_skew_tolerance_sec

    def _compute_idempotency_key(self, event: TapEvent) -> str:
        """Derived, never client-supplied — a retried event cannot mint a second charge."""
        raw = f"{event.tap_id}:{event.validator_id}:{event.timestamp}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def _validate_clock_sync(self, event_ts: float) -> bool:
        drift = abs(time.time() - event_ts)
        return drift <= self._clock_skew_tolerance_sec

    def _resolve_fare_static(self, event: TapEvent) -> Decimal:
        """Fallback to the static GTFS schedule + zone mapping."""
        key = f"{event.route_id}:{event.zone_from}"
        cached = self.static_cache.get(key)
        if cached is not None:
            return cached
        # Conservative default: base fare rounded to the nearest cent.
        fare = self.base_fare.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        self.static_cache.put(key, fare)
        return fare

    def process(self, event: TapEvent) -> FareResult:
        mode = CalculationMode.PRIMARY
        reason = FallbackReason.NONE
        confidence = 1.0

        try:
            # Replace with the real RPC/DB call to the primary engine.
            if not self._validate_clock_sync(event.timestamp):
                raise TimeoutError("validator clock skew exceeds tolerance")
            # Primary logic would execute here and return early on success.
            raise ConnectionError("primary rule engine unreachable")
        except (ConnectionError, TimeoutError) as exc:
            reason = (
                FallbackReason.VALIDATOR_CLOCK_SKEW
                if isinstance(exc, TimeoutError)
                else FallbackReason.GTFS_RT_TIMEOUT
            )
            mode = CalculationMode.FALLBACK_STATIC
            confidence = 0.65
            logger.warning(
                "primary engine failed, routing to fallback chain",
                extra={"tap_id": event.tap_id, "reason": reason.value, "error": str(exc)},
            )

        # Fallback execution.
        fare = self._resolve_fare_static(event)
        if fare > self.max_cap:
            fare = self.max_cap
            mode = CalculationMode.FALLBACK_CONSERVATIVE
            confidence = 0.45

        id_key = self._compute_idempotency_key(event)
        audit_payload = f"{mode.value}|{reason.value}|{confidence}|{fare}"
        audit_hash = hashlib.sha256(audit_payload.encode("utf-8")).hexdigest()

        logger.info(
            "fallback.result",
            extra={"tap_id": event.tap_id, "mode": mode.value, "fare": str(fare)},
        )
        return FareResult(
            amount=fare,
            currency="USD",
            calculation_mode=mode,
            fallback_reason=reason,
            confidence_score=confidence,
            idempotency_key=id_key,
            audit_hash=audit_hash,
        )


def process_event_stream(
    events: Generator[TapEvent, None, None],
) -> Generator[FareResult, None, None]:
    """Lazily price a stream without materializing the whole queue in memory."""
    chain = FallbackChain(base_fare=Decimal("2.75"), max_cap=Decimal("12.00"))
    for event in events:
        yield chain.process(event)

The conservative tier is a clamp, not an estimate. For a resolved static fare fstaticf_\text{static} and an agency cap cc, the emitted amount is:

fout=min ⁣(fstatic,  c)f_\text{out} = \min\!\left(f_\text{static},\; c\right)

Because the clamp is deterministic, the same event under the same cached tables always produces the same audit hash — the property revenue assurance depends on when it replays a degraded window.

Schema Validation, Gates & Transit Edge Cases

Revenue analysts require strict audit trails, so the chain embeds explicit validation gates before it prices anything: fare-type resolution, zone-boundary checks, and concession eligibility. When primary discount tables are unavailable or partially synced, the chain routes through a constrained Discount Eligibility Engines path that defaults to the most conservative applicable concession — this prevents over-discounting during a sync failure while preserving rider trust. Gates should reject malformed payloads early, log a structured warning, and route to a deterministic tier rather than raising into the consumer loop.

Real-world deployments hit a small, recurring set of edges, each with a deterministic remedy:

  1. Validator clock skew. Field devices drift by minutes. Check drift against a rolling NTP sync; beyond 120 seconds, force fallback mode and tag VALIDATOR_CLOCK_SKEW. Reconciliation applies a time-window correction during nightly batch processing. The engine always reasons in aware UTC — normalize local validator time with zoneinfo before comparison, never with naive datetime.
  2. GTFS-RT latency thresholds. When trip_update payloads arrive more than five seconds behind schedule, assume the vehicle is stationary or delayed and apply static schedule offsets instead of interpolating a stale position. Validate fields against the official GTFS-Realtime specification.
  3. Null and encoding fallback. A missing zone_to or an unmappable route must resolve to the cached base fare, not a crash. Treat an empty string and None identically, and normalize card/route identifiers to a single encoding before the cache lookup so a byte-level mismatch never forces an unnecessary fallback tier.
  4. Policy version drift. If a rule deployment fails validation, lock to the last known good version — never compute against a partially applied schema. Use atomic file swaps or a database transaction for rule promotion.
  5. Overlapping journeys. When a tap-in arrives before the previous tap-out is processed, apply a conservative transfer buffer rather than charging a full new journey. This mirrors the offline strategy detailed in Building Graceful Degradation for Offline Fare Readers and prevents double-charging during sync recovery.

Monetary operations use fixed-point arithmetic throughout. Python’s decimal module (documentation) is mandatory for fare computation; idempotency keys are derived so at-least-once delivery from the edge becomes exactly-once accounting.

Integration Pattern: Reconciliation Hand-Off

The value of a fallback chain lives in its reconciliation footprint. Every transaction processed outside the primary engine carries an immutable audit payload:

  • calculation_mode: PRIMARY, FALLBACK_STATIC, FALLBACK_CONSERVATIVE, or FALLBACK_MAX_CAP
  • fallback_reason: enumerated string (GTFS_RT_TIMEOUT, RULE_VERSION_MISMATCH, VALIDATOR_CLOCK_SKEW)
  • confidence_score: float in [0.0, 1.0] indicating data freshness and rule completeness
  • idempotency_key: SHA-256 of tap_id + validator_id + event_timestamp, preventing double-posting during retry storms

Downstream reconciliation consumes these flags to route transactions into separate accounting buckets. The calculation_mode flag segregates the stream before an idempotent ledger upsert, and the low-confidence buckets feed the variance report that revenue analysts use to detect systemic feed degradation before it reaches riders:

Reconciliation hand-off routed by calculation mode A tagged transaction is switched on its calculation_mode into one of three accounting buckets: PRIMARY into the primary revenue bucket, FALLBACK_STATIC into the fallback variance bucket, and FALLBACK_CONSERVATIVE into the capped review bucket. All three buckets flow into a single idempotent ledger upsert keyed by idempotency_key, so retries never inflate revenue. The two low-confidence buckets also feed a variance report that revenue analysts use to detect systemic feed degradation. Tagged transaction immutable audit payload calculation mode? Primary revenue bucket mode = PRIMARY Fallback variance bucket mode = FALLBACK_STATIC Capped / review bucket FALLBACK_CONSERVATIVE Idempotent ledger upsert by idempotency_key Variance report low-confidence trend for revenue analysts

Implementing idempotent upserts in the ledger layer ensures network retries do not inflate daily revenue totals. Upstream, the offline half of this hand-off — buffering, signing, and replaying taps once the link returns — follows Fallback Routing Strategies; the thresholds that decide when each tier fires are calibrated in Threshold Tuning Frameworks so revenue teams can adjust triggers without redeploying the calculation core.

Performance & Scale Considerations

Fallback chains run under constrained memory, particularly on edge validators and lightweight microservices, so loading full GTFS-RT snapshots or historical fare matrices into RAM is unsustainable.

  • Bounded, TTL-driven caches. Use strict eviction (the BoundedRuleCache above caps entries with a deque(maxlen=...)), and set __slots__ on hot data structures to eliminate per-instance __dict__ overhead.
  • Streaming, not materializing. Process events through generators (process_event_stream) so the consumer never holds an entire queue in memory; back-pressure propagates naturally to the ingestion layer.
  • Sliding rule-version window. Keep only the last three policy snapshots. When a new deployment arrives, validate schema compatibility before promotion; on failure, retain the previous stable version and flag for manual review. This prevents OOM conditions during rapid policy churn while guaranteeing the chain always has a valid baseline.
  • Parallelism caveat. Shard consumers by validator_id, never round-robin, so per-device idempotency and transfer-window state stay on a single worker. Splitting one validator’s stream across workers reintroduces the race conditions the derived idempotency key exists to eliminate.

Operational Checklist

  1. Every fallback transaction emits calculation_mode and fallback_reason flags.
  2. All monetary values use decimal.Decimal with an explicit ROUND_HALF_UP rule — no float on the money path.
  3. Rule caches enforce bounded memory with deterministic FIFO eviction.
  4. Derived idempotency keys prevent duplicate ledger entries during retry storms.
  5. Downstream reconciliation pipelines segregate fallback and primary streams by mode.
  6. Clock-skew and GTFS-RT latency thresholds are configurable per agency.
  7. Audit hashes are stored immutably for compliance and dispute resolution.
  8. Consumers are sharded by validator_id to preserve per-device idempotency and transfer state.

Fallback calculation chains turn system fragility into operational resilience. By engineering deterministic degradation paths, operators maintain revenue capture, riders experience uninterrupted service, and Python builders ship pipelines that survive the messy reality of field deployments.

Part of Fare Rule Validation & Calculation Engines.