CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

68

Quality

85%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

chain.pyskills/drive-engine/

"""Flight-chain connection classification — pure, no I/O, no clock.

A drive engine must decide, for each consecutive pair of flights in a trip, whether
a GROUND leg exists between them. This is the §D taxonomy of issue #156, as amended
by the owner-decided C2 / W2: ground legs exist only at the ground endpoints of a
flight chain — the first departure and the last arrival — plus wherever a lodging
check-in (or evidence the operator left the terminal) breaks the chain.

Classification of a consecutive pair (N, N+1), in precedence order:

1. A lodging check-in sits between them → OVERNIGHT. Not a connection at all; it
   decomposes into a normal arrival leg (N → lodging) and a normal departure leg
   (lodging → N+1). Holds regardless of whether the airports match.
2. Different arrival/departure airports, no lodging between → DIFFERENT_AIRPORT_
   TRANSFER. The operator must physically cross town (LHR→LGW); a single transfer
   leg is generated. Silence here would strand him, so this ALWAYS generates
   (R3 different-airport rule).
3. Same airport, no lodging between → SAME_AIRPORT. Per the owner's C2 decision this
   DEFAULTS TO SILENCE (airside connection, no ground leg) at any gap length, and
   emits an arrival+departure pair ONLY on positive evidence the operator left the
   terminal (a fresh geofence fix outside the airport). The accepted residual is a
   narrow false-absent when a hotel is booked outside TripIt AND no fresh fix
   exists — silence, the safe failure for a layover.

The chain's FIRST departure is always ground-reached (its origin resolves via
position_at) and its LAST arrival always gets a drive-away leg. Interior
departures reached by a connecting flight (no lodging, no "left" evidence) are
suppressed — this is the defect behind the live `Drive: → CPH (SK915)` block,
where flight-assist routed a ground drive to an airport the operator reaches by a
prior flight.

Pure: the caller supplies, per pair, whether a lodging check-in falls between the
two flights and whether there is fresh evidence the operator left the terminal.
Lodging-schedule reads and geofence evaluation happen upstream; this module only
classifies and decides which legs a chain yields.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum

from flight_identity import MergedFlight


class ConnectionKind(Enum):
    """How a consecutive flight pair connects on the ground."""

    OVERNIGHT = "overnight"
    DIFFERENT_AIRPORT_TRANSFER = "different_airport_transfer"
    SAME_AIRPORT_LEFT = "same_airport_left"
    SAME_AIRPORT_CONNECTION = "same_airport_connection"


# Connection kinds that break the flight chain — i.e. a ground leg (or leg pair)
# exists between the two flights, so the earlier flight's arrival is a chain-final
# arrival for leg purposes and the later flight's departure is ground-reached.
_CHAIN_BREAKING = frozenset(
    {
        ConnectionKind.OVERNIGHT,
        ConnectionKind.DIFFERENT_AIRPORT_TRANSFER,
        ConnectionKind.SAME_AIRPORT_LEFT,
    }
)


@dataclass(frozen=True)
class PairContext:
    """Per-pair ground facts the classifier needs, resolved upstream.

    lodging_between — a lodging check-in falls in the gap between arrival of the
        earlier flight and departure of the later one.
    operator_left_terminal — fresh positive evidence (geofence fix outside the
        departure airport) that the operator left the terminal during the gap.
        Meaningful only for the same-airport case; ignored otherwise.
    """

    lodging_between: bool = False
    operator_left_terminal: bool = False


def classify_pair(earlier: MergedFlight, later: MergedFlight, ctx: PairContext) -> ConnectionKind:
    """Classify one consecutive flight pair. Precedence: lodging → airport → evidence."""
    if ctx.lodging_between:
        return ConnectionKind.OVERNIGHT
    if earlier.arr_airport != later.dep_airport:
        return ConnectionKind.DIFFERENT_AIRPORT_TRANSFER
    if ctx.operator_left_terminal:
        return ConnectionKind.SAME_AIRPORT_LEFT
    return ConnectionKind.SAME_AIRPORT_CONNECTION


class LegKind(Enum):
    """The ground legs a chain yields, before anchor/route/trivial resolution."""

    AIRPORT_DEPARTURE = "airport_departure"  # position_at(leave_by) → dep airport
    AIRPORT_ARRIVAL = "airport_arrival"  # arr airport → position_at(depart_after)
    AIRPORT_TRANSFER = "airport_transfer"  # arr(N) → dep(N+1), both fixed


@dataclass(frozen=True)
class PlannedLeg:
    """A ground leg the chain requires. Endpoints beyond the fixed airport(s) are
    resolved later via position_at; this records only WHICH legs exist and the
    flight(s) they attach to, with a stable identity for reconcile/dedupe.
    """

    kind: LegKind
    # The flight this leg departs from the ground toward (departure/transfer-dest)
    # and/or arrives from (arrival/transfer-origin). Transfer carries both.
    from_flight: MergedFlight | None = None
    to_flight: MergedFlight | None = None


def plan_chain_legs(chain: list[MergedFlight], contexts: list[PairContext]) -> list[PlannedLeg]:
    """Decide the ground legs for one ordered flight chain (#156 §D / C2 / W2).

    `chain` is the trip's flights in departure order; `contexts` holds the
    per-pair ground facts, so `len(contexts) == len(chain) - 1`. Returns the legs
    the chain requires — the chain-opening departure, the chain-closing arrival,
    any transfers, and the arrival/departure pairs that overnights and
    same-airport-left breaks introduce. Same-airport airside connections yield no
    leg. A single flight yields exactly a departure and an arrival.
    """
    if not chain:
        return []
    if len(contexts) != len(chain) - 1:
        raise ValueError(
            f"expected {len(chain) - 1} pair contexts for a {len(chain)}-flight chain, "
            f"got {len(contexts)}"
        )

    kinds = [classify_pair(chain[i], chain[i + 1], contexts[i]) for i in range(len(chain) - 1)]

    legs: list[PlannedLeg] = []

    # Chain-opening departure: the first flight's departure airport is always
    # reached by ground.
    legs.append(PlannedLeg(LegKind.AIRPORT_DEPARTURE, to_flight=chain[0]))

    for i, kind in enumerate(kinds):
        earlier, later = chain[i], chain[i + 1]
        if kind is ConnectionKind.SAME_AIRPORT_CONNECTION:
            continue  # airside — no ground leg either side of this seam
        if kind is ConnectionKind.DIFFERENT_AIRPORT_TRANSFER:
            legs.append(PlannedLeg(LegKind.AIRPORT_TRANSFER, from_flight=earlier, to_flight=later))
            continue
        # OVERNIGHT or SAME_AIRPORT_LEFT: the chain breaks — the earlier flight
        # gets a drive-away arrival leg and the later flight a ground-reached
        # departure leg, each resolved via position_at.
        legs.append(PlannedLeg(LegKind.AIRPORT_ARRIVAL, from_flight=earlier))
        legs.append(PlannedLeg(LegKind.AIRPORT_DEPARTURE, to_flight=later))

    # Chain-closing arrival: the last flight's arrival always gets a drive-away leg.
    legs.append(PlannedLeg(LegKind.AIRPORT_ARRIVAL, from_flight=chain[-1]))

    return legs


def is_chain_breaking(kind: ConnectionKind) -> bool:
    """Whether the pair introduces a ground leg (breaks the airside chain)."""
    return kind in _CHAIN_BREAKING

README.md

tile.json