One observation can support different questions

The collector receives a vehicle position every 20 seconds, but the product does not treat that position as a fact about everything around the bus. A report can be useful for drawing a marker, for measuring progress along a bus route, or for estimating a road pace. Those uses need different checks.

The distinction matters in Davao. A bus can be stationary because passengers are boarding, not because the road is congested. A point can be stored for display while still being too strange to advance a trip. The pipeline keeps the observation, records the quality decision, and lets each downstream question apply its own evidence rules.

Two branches from the same observation

This is the boundary the architecture needs readers to see first:

The transit branch preserves service identity. It follows one vehicle on one route and direction, detects crossed stops, measures adjacent segment runs, and records stop-area dwell. The road branch is narrower. It reads good route positions with their source observations, excludes stop areas and unsuitable intervals, then asks Valhalla whether the remaining trace belongs to a continuous road path.

The road branch is optional. Without VALHALLA_URL, the road processor reports that it is disabled and does not create match runs or samples. Collection, route projection, stop visits, and segment history do not depend on that service.

Collection writes raw evidence before derived evidence

VehicleCollector is a singleton Durable Object named vehicle-collector. Its storage alarm is set to the next 20-second boundary. On each alarm it arms the following alarm before calling the upstream service, then journals source fetches, normalizes location responses, and inserts observations into D1. Manual collection calls share the same in-flight promise inside a live object instance.

The ordering after the location requests is deliberate. This is the small piece of code that prevents a projection failure from erasing the packet that exposed it:

example.ts
1const insertSummary = await insertObservations(env.DB, observations);
2storedObservations = insertSummary.inserted;
3duplicates = insertSummary.duplicates;
4rawStorageSucceeded = true;
5
6if (rawStorageSucceeded) {
7 intelligence = await updateTransitIntelligence(env.DB, observations);
8}

The actual implementation catches the D1 batch error before entering the intelligence branch. It also enqueues the road processor only when the raw insert succeeded and at least one new observation was stored. See persistAndProcessObservations and insertObservations.

The unique key is (vehicle_id, reported_at). Repeated alarm delivery therefore produces a duplicate count instead of a second raw observation. reported_at remains the vehicle's event time; collected_at records when this collector received the packet. The two values are both needed when diagnosing delayed upstream data.

Route positions carry the first quality decision

The projector chooses the canonical geometry for the observation's route ID and direction, locates the point on that line, and stores progress, segment, nearest stop, and distance from the route. A point within 200 meters is good. A farther point is still stored as off_route, but it does not advance transit intelligence.

The next check compares a new good point with the latest good point for the same vehicle, route, and direction. When the time gap is positive and no more than ten minutes, a progress change implying more than 100 km/h becomes gps_jump. Route wraps are excluded from this speed test because the trip logic treats late-to-early progress as a new run. A GPS jump remains in vehicle_route_positions; it simply does not create a passage, segment run, or stop visit.

This is why a position table is not just a cache of coordinates. It is the first place where the system records what it is willing to use for movement reasoning.

Trip, passage, and segment lineage

An open trip_run belongs to one vehicle, route, and direction. A good position starts a run when none is open. The processor continues the run only when those identities match, the gap is positive and no more than ten minutes, and the route progress has not wrapped. Otherwise it closes the old run and starts another. A closed run is completed only when its starting progress is at or before 0.25 and its last progress reaches at least 0.75. Other closed runs are abandoned.

Forward progress across an ordered stop creates a stop_passage. Its timestamp is interpolated between the previous and current report, so a passage is not automatically assigned the time of the later packet. When the preceding passage exists for the same trip, the two passages create one transit_segment_run. This gives the ETA code a measured interval from one stop to the next rather than a speed guessed from a single coordinate.

The stored relationships answer a narrower question: which derived records came from a trip or a road match?

vehicle_observations and vehicle_route_positions are keyed by vehicle and report time and are joined to road work by those values. They are inputs to the lineage, not extra trip claims. Keeping the source row and the quality-marked position makes it possible to explain why a downstream table is sparse.

Stop dwell has an uncertainty budget

The stop detector starts a candidate when the nearest stop is within 75 meters and the vehicle speed is at most 5 km/h. If the upstream speed is missing, it derives speed from route progress, but only when the prior point has the same route and direction and is no more than 60 seconds away.

One stationary packet is not a visit. The candidate needs two samples before it is written to stop_visits. With the current 20-second collection cadence, the inserted visit carries 40 seconds of uncertainty. In the test fixture, two reports at 00:00:00 and 00:00:20 produce a measured dwell of 20 seconds, uncertainty of 40 seconds, and sample count 2. That result says what the telemetry supports. It does not pretend to know the exact boarding interval between reports.

Road evidence has stricter gates

The road processor reads good route positions in batches of up to 12 points and processes no more than three vehicles per run. A trace needs four points. It is split when the route or direction changes, the timestamp gap exceeds 60 seconds, or progress backtracks by more than 250 meters. A continuous prefix is sent to Valhalla's /trace_attributes endpoint with bus costing and map_snap matching.

The match is accepted only when every returned point is matched or interpolated, has an edge and position on that edge, has no route discontinuity, and is no more than 50 meters from the input trace. Interval samples add further limits: both endpoints must be in the same route segment, more than 100 meters from a stop, and moving at a route-derived speed between 7 and 100 km/h. The matched road distance must be between 0.5 and 2 times the route distance, and edge pieces shorter than 5 meters are ignored.

The cursor makes this branch safe to run incrementally. A successful match writes its road_match_run, segment samples, and cursor in one D1 batch. A transient matcher error leaves the cursor unchanged and requests a retry after 60 seconds. A structurally unacceptable match advances past the rejected prefix without writing samples, because retrying the same poisoned evidence would only repeat the rejection. A backlog continues after one second. These are different outcomes and the processor records them separately.

Road analytics then groups recent samples by road segment and first takes each vehicle's median pace. Three distinct vehicles in the last 30 minutes and a current-to-baseline p50 ratio of at least 1.3 are required before a slowdown is returned. Five recent vehicles and at least 60 historical samples produce high road confidence; otherwise a qualifying result is moderate. A bus that supplies twenty slow samples is still one vehicle.

ETA uses the transit branch, not the road claim

The ETA code starts from the latest good route position. Stop-arrival queries accept positions no more than 120 seconds old. ETA confidence becomes low after 90 seconds. Those thresholds are intentionally separate: one controls query inclusion, the other controls the trust label.

For each remaining transit segment, the baseline selector prefers at least 30 samples from the same weekday and hour, then the same hour, then the segment across all available times. If none reaches 30, it uses a fixed 18 km/h speed and marks the segment as a fallback. Recent segment durations from at least three independent vehicles in the last 30 minutes can scale the estimate when their p50 is at least 1.3 times the historical p50. Five vehicles can preserve high confidence; three or four cap the adjusted estimate at moderate.

The ETA response carries p50, p90, confidence, and evidence counts. A fallback segment or an old position is visible in that evidence. The system can return an estimate with low confidence, or an unavailable state when there is no usable position or the stop is not on the vehicle's route. It does not use a road slowdown as a substitute for a transit segment run.

Failure boundaries are part of the design

If one location request returns HTTP 503, the collector keeps successful vehicles in the snapshot and counts the failed request. If the schedule provider fails, the collector preserves the last good snapshot rather than replacing it with a new empty one. If the raw D1 batch fails, derived transit work for that batch is skipped. If one observation fails during intelligence processing, the loop records the failure and continues with the next observation.

These boundaries explain the architecture better than a list of components. The collector protects the raw event. Transit derivation protects service identity. The road processor protects its cursor from transient failure and protects the road dataset from structurally bad matches.

How the current shape emerged

The first collector change, 01b7781, stored bus observations and handled recurring collection. 521b6db added route positions and transit events. Road matching, bounded backlog processing, and live ETA adjustment followed in separate changes. The sequence matters: road traffic was not hidden inside the original tracker, and ETA was not present in the first raw-observation design.

Current implementation anchors are intelligence.ts, road-intelligence.ts, eta.ts, and the collector migrations.