Prorating Shared-Corridor Revenue Across Operators

The task on this page is to split one collected fare across the operators who jointly serve a shared corridor, dividing it by an agreed weighting — route distance, vehicle-hours, or a flat contractual split — so the operator shares sum back to the exact fare with no cent invented or lost. When a single ticket carries a rider along a trunk corridor served by a city bus for two stops and a regional rail line for eight, both operators have a claim on that fare, and the split has to be reproducible to the cent across millions of journeys. This is a deep-dive within Inter-Agency Settlement Clearing, inside Revenue Reconciliation & Settlement; the shares it produces are the obligations the clearing engine later nets.

The corridor case is distinct from a two-tap transfer split. A cross-operator transfer, whose eligibility is decided in calculating cross-operator transfer windows with Python, divides a fare between exactly two legs at a boundary crossing. A corridor split can span three or more operators along one continuous priced segment, weighted by a physical measure rather than a flat agreement, so the remainder handling has to stay exact across an arbitrary number of legs. This page owns that multi-leg corridor split; it takes the transfer decision as already-made upstream context and does not re-derive it.

Corridor Proration Flow

The split runs as a short pipeline: pick the weighting basis from the corridor agreement, compute each leg’s exact real-valued share, floor every share to whole cents, then hand the leftover cents to the legs with the largest fractional remainders until the parts re-sum to the collected fare. The final equality check is not decorative — it is the guarantee the settlement ledger depends on.

Largest-remainder proration flow for a shared corridor fare A collected corridor fare and its legs enter the pipeline. The agreement selects a weighting basis: distance, vehicle-hours, or a flat split. Each leg's exact share is computed as the fare times its weight over the total weight. Shares are floored to whole cents, leaving a leftover of a few cents. The leftover cents are distributed one each to the legs with the largest fractional remainders. A final guard checks whether the shares sum exactly to the fare: if yes the split is accepted, if no it raises a conservation error. sum == fare sum ≠ fare Collected corridor fare F + legs and weights Select weighting basis distance · vehicle-hours · flat split from corridor agreement Exact share per leg F · w_i / Σ w_j Floor to whole cents track fractional remainder Distribute leftover largest remainders first Accepted shares become obligations ConservationError shares drifted from F

Given a collected fare FF in whole cents and an agreed non-negative weight wiw_i for each corridor leg, every leg’s raw share is the proportional slice

si=Fwijwjs_i = F \cdot \frac{w_i}{\sum_j w_j}

Because FwiF \cdot w_i almost never divides the total weight into whole cents, the raw shares are real numbers that must be resolved to integer cents without breaking isi=F\sum_i s_i = F. The largest-remainder method does exactly that: floor every share, then allocate the handful of leftover cents to whichever legs were rounded down the most. Kept in Decimal, the reconstructed shares provably re-sum to FF.

Step 1 — Model the Corridor and Its Weight Bases

A corridor split needs more than a bare weight per operator; the weight is derived from a physical measure that the settlement agreement names. Model each leg with its raw measures and select the basis at split time, so the same corridor definition can be re-prorated under distance today and vehicle-hours after a contract renegotiation without editing leg data.

from __future__ import annotations

import logging
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum

logger = logging.getLogger("transit.settlement.corridor")


class WeightBasis(Enum):
    DISTANCE_KM = "distance_km"
    VEHICLE_HOURS = "vehicle_hours"
    FLAT = "flat"


class ProrationError(Exception):
    """Base exception for corridor proration failures."""


class ConservationError(ProrationError):
    """Reconstructed shares failed to sum to the collected fare."""


@dataclass(frozen=True)
class CorridorLeg:
    operator_id: str
    distance_km: Decimal        # revenue kilometres this operator carried
    vehicle_hours: Decimal      # in-service vehicle-hours on the segment
    flat_weight: Decimal        # contractually fixed split weight

    def weight_for(self, basis: WeightBasis) -> Decimal:
        table = {
            WeightBasis.DISTANCE_KM: self.distance_km,
            WeightBasis.VEHICLE_HOURS: self.vehicle_hours,
            WeightBasis.FLAT: self.flat_weight,
        }
        weight = table[basis]
        if weight < Decimal("0"):
            raise ProrationError(f"Negative {basis.value} weight for {self.operator_id}.")
        return weight

Distances and hours are Decimal too, not float — a corridor measured to three decimal kilometres still has to produce a deterministic weight, and mixing a binary float measure into a monetary split reintroduces exactly the drift the split is meant to avoid.

Step 2 — Split with the Largest-Remainder Method

The split takes the collected fare and the chosen basis, computes each leg’s exact Decimal share, floors it, and then walks the legs in descending remainder order handing out the leftover cents one at a time. Ties are broken deterministically by operator id so the same corridor always prorates identically — a settlement that shifts a cent between operators on re-run is a dispute waiting to happen.

from decimal import ROUND_DOWN
from typing import Sequence


def prorate_corridor(
    fare_cents: Decimal,
    legs: Sequence[CorridorLeg],
    basis: WeightBasis,
) -> dict[str, Decimal]:
    """Split a collected corridor fare across legs by the chosen basis so the
    integer-cent shares sum exactly to fare_cents."""
    if fare_cents < Decimal("0"):
        raise ProrationError("Collected fare cannot be negative.")
    if not legs:
        raise ProrationError("A corridor split needs at least one leg.")

    fare = fare_cents.quantize(Decimal("1"))
    weights = {leg.operator_id: leg.weight_for(basis) for leg in legs}
    total_weight = sum(weights.values(), Decimal("0"))
    if total_weight <= Decimal("0"):
        raise ProrationError(f"Total {basis.value} weight must be positive.")

    floors: dict[str, Decimal] = {}
    remainders: dict[str, Decimal] = {}
    for op, w in weights.items():
        exact = fare * (w / total_weight)
        whole = exact.quantize(Decimal("1"), rounding=ROUND_DOWN)
        floors[op] = whole
        remainders[op] = exact - whole

    leftover = int(fare - sum(floors.values()))
    # Rank by remainder descending, breaking ties by operator id for determinism.
    ranked = sorted(remainders, key=lambda op: (-remainders[op], op))
    for op in ranked[:leftover]:
        floors[op] += Decimal("1")

    allocated = sum(floors.values(), Decimal("0"))
    if allocated != fare:
        raise ConservationError(
            f"Corridor shares summed to {allocated}, expected {fare}."
        )
    logger.info(
        "Prorated %s cents across %d legs on %s basis.",
        fare, len(legs), basis.value,
    )
    return floors

The leftover is guaranteed to be a small non-negative integer strictly less than the number of legs, because flooring each share can only ever remove a fractional part below one cent. Distributing exactly that many cents to the highest-remainder legs is what closes the gap, and the closing ConservationError guard turns any arithmetic slip into an immediate, traceable failure rather than a silent cent leak into settlement.

Validation & Test Cases

Exercise the split against a clean even division and against a fare that cannot divide evenly. The even split proves the happy path; the odd-cent case proves the remainder is placed deterministically and the parts still re-sum to the fare.

from decimal import Decimal

legs = [
    CorridorLeg("CITY_BUS", Decimal("2.0"), Decimal("0.20"), Decimal("1")),
    CorridorLeg("REGIONAL_RAIL", Decimal("8.0"), Decimal("0.40"), Decimal("1")),
]

# Even case: a 500c fare on a 2km / 8km corridor splits 20% / 80% cleanly.
shares = prorate_corridor(Decimal("500"), legs, WeightBasis.DISTANCE_KM)
assert shares == {"CITY_BUS": Decimal("100"), "REGIONAL_RAIL": Decimal("400")}
assert sum(shares.values()) == Decimal("500")

# Odd-cent case: 101c split three ways (1/3 each) cannot divide evenly.
tri = [
    CorridorLeg("OP_A", Decimal("1"), Decimal("1"), Decimal("1")),
    CorridorLeg("OP_B", Decimal("1"), Decimal("1"), Decimal("1")),
    CorridorLeg("OP_C", Decimal("1"), Decimal("1"), Decimal("1")),
]
odd = prorate_corridor(Decimal("101"), tri, WeightBasis.FLAT)
# 33.67 each floors to 33 (sum 99); the two leftover cents go to the first legs by tie-break.
assert odd == {"OP_A": Decimal("34"), "OP_B": Decimal("34"), "OP_C": Decimal("33")}
assert sum(odd.values()) == Decimal("101")

# Error case: an all-zero weight basis cannot define a split.
zero_weight = [CorridorLeg("OP_X", Decimal("0"), Decimal("0"), Decimal("0"))]
try:
    prorate_corridor(Decimal("100"), zero_weight, WeightBasis.DISTANCE_KM)
    raise AssertionError("expected ProrationError")
except ProrationError:
    pass

The three-way case is the one that protects settlement. Each exact share is 101 × 1/3 = 33.67…, which floors to 33 for a total of 99; the two leftover cents are handed to OP_A and OP_B because all remainders tie and the operator-id tie-break is stable. The parts sum to 101 exactly, and — crucially — they sum to 101 the same way every time the corridor is re-prorated, so no operator gains or loses a cent on a re-run.

Edge Cases & Gotchas

  • Zero-weight legs. An operator with a zero measure on the chosen basis correctly receives a zero share, but a corridor where every weight is zero has no defined split and must raise rather than divide by zero.
  • Basis swaps mid-period. If a contract switches from distance to vehicle-hours partway through a settlement period, prorate each sub-period under its own basis; re-prorating the whole period under one basis silently rewrites already-settled shares.
  • More legs than leftover cents. The leftover is always smaller than the leg count, so some legs never receive a remainder cent. That is correct — the largest-remainder method awards cents only to the legs most under-allocated by flooring.
  • Rounding direction. Always floor, then top up. Rounding each share to nearest independently can overshoot the fare, producing a negative leftover that the top-up loop cannot resolve.

Integration Note

This split is the source of the corridor obligations that Inter-Agency Settlement Clearing aggregates: each leg’s share becomes a directed claim from the fare-collecting operator to the operator that carried that leg. Because the shares provably sum to the collected fare, they enter the clearing engine’s obligation matrix without introducing a conservation break — the exact failure that engine’s zero-sum assertion is built to catch. Its closest sibling is Building a Clearinghouse Netting Batch in Python, which takes the matrix these obligations feed and reduces it to the minimal set of cash transfers; a proration bug here would surface there as a batch that fails to net to zero.

FAQ

Why floor and top up instead of rounding each share to the nearest cent?
Because independent nearest-cent rounding does not conserve the total. Rounding each share up or down on its own can make the parts sum to a cent or two above or below the collected fare, and there is no leftover to distribute when the sum overshoots. Flooring every share guarantees the sum is at or below the fare by a known number of cents, and the largest-remainder top-up hands out exactly that many cents to the legs rounded down the most — so the parts always re-sum to F.
Which weighting basis should a corridor agreement use?
It is a contractual choice, not a technical one, which is why the basis is selected at split time rather than baked into the leg data. Distance rewards the operator that carried the rider furthest; vehicle-hours rewards the operator that spent the most service time, which suits congested urban segments where kilometres understate cost; a flat split is simplest to audit when operators agree to share equally. Store the raw measures on each CorridorLeg so the same corridor can be re-prorated under a renegotiated basis without re-deriving the data.
How are ties in the remainder broken?
Deterministically, by operator id. When two legs have identical fractional remainders — common in an even three-way split — the leftover cent must go somewhere, and picking by a stable operator-id sort means the same corridor always prorates to the same shares. A non-deterministic tie-break would shift a cent between operators on re-run, which reads as an unexplained settlement discrepancy during a dispute.
Can the same method split a corridor across more than three operators?
Yes. The largest-remainder method is agnostic to leg count; the leftover to distribute is always strictly less than the number of legs, so it scales to any number of operators sharing a corridor. Only the legs most under-allocated by flooring receive a remainder cent, and the conservation guard still proves the shares sum exactly to the fare regardless of how many operators participate.

Part of Inter-Agency Settlement Clearing, within Revenue Reconciliation & Settlement.