The engine is two programs separated by an artifact boundary. A build step turns curated bus and jeepney inputs into a deterministic planner image. A browser worker verifies and mounts that image, adds query-specific access edges, searches it, and turns the winning paths into rider instructions.

That shape was not the starting point. The first planner work lived closer to the product surface and searched route bundles directly. The multimodal work added service identity, direction, transfers, and walking. By the time the compiled image was introduced in commit 89cad3b, rebuilding that static network at query time had become the wrong place to spend work. Commit 8366918 then moved planning into a dedicated web worker. The later externalization commit ff57ec5 removed the generated binary from the application change set and made the image a public, content-addressed asset.

The useful way to read the engine is to follow one request through those two phases.

Build time and query time

The request crosses a fixed boundary: publication prepares static topology, while the query adds only the two endpoint-specific overlays before search.

Build time owns facts that are shared by every query: service IDs, public route IDs, mode, direction, geometry, bus stop identity, graph nodes, ride edges, transfer edges, and the service-area rings. Query time owns the two user coordinates, enabled mode, projections, access and egress edges, and the final policy decision.

This boundary keeps a request from rebuilding static topology. It also means the image is now a compatibility boundary. The compiler, image reader, and worker must agree on the format version, tick size, coordinate scale, section set, and integrity hash.

The current compiler expresses the build order directly. The following is a partial excerpt from the function:

example.ts
1 const normalizedServices = normalizeServices(input)
2 const coordinates = buildCoordinates(input, normalizedServices)
3 const coordinateOrdinals = buildCoordinateOrdinals(coordinates)
4 const stops = buildStops(input, coordinateOrdinals)
5 const services = buildServices(normalizedServices)
6 const nodes = buildNodes(services, stops, coordinateOrdinals)
7 const ride = buildRideEdgesAndSegments(services, nodes, coordinateOrdinals)
8 const transferEdges = createTransferEdges(nodes, services, coordinates)
9 const edges = compileEdges([...ride.edges, ...transferEdges])
10 const segments = compileSegments(ride.segments, edges)

Source: planner-compiler.ts. This is not a generic serialization pass. It normalizes services before it assigns coordinate ordinals, then builds ride and transfer edges before it writes sections. The order gives later validation a stable representation to inspect.

What a graph node means

A node is not “a point close to a route.” It belongs to a directed service and carries a coordinate. Bus nodes also carry an official stop ordinal. A service ID includes the direction-specific identity used by the graph; a public route ID is the rider-facing route identifier. Several directed services can therefore share a public route ID without becoming one undirected service.

Bus services come from ordered stop IDs and route geometry. The image builder maps each stop to an increasing geometry index. A non-increasing sequence fails the build. Jeepney services come from explicit direction data. Forward and reverse become separate services only when the source says they exist. Reversing a geometry is not treated as proof that the vehicle operates in that direction.

Ride edges follow a service. Same-mode transfer edges join nodes with a shared transfer key. Bus-to-jeepney edges are generated only for an official bus stop and a jeepney node within 400 metres. Query overlay edges connect the origin and destination to projected service positions. The base graph never needs to know the rider's current pin.

The transfer edge is where the data model meets the search state. A candidate either continues on a directed service, changes services through a typed transfer, or stops because the transfer rule does not allow an edge.

The current distance guard is small but consequential:

example.ts
1 const walkMm = coordinateDistanceMm(
2 jeepCoordinate,
3 coordinateForNode(busNode, coordinates)
4 )
5 if (walkMm > TRANSFER_MAX_MM) continue
6 edges.push(
7 {
8 fromNode: jeepNode.ordinal,

Source: planner-compile-edges.ts. The compiler does not add a transfer edge and ask the search to decide whether 401 metres is acceptable. It removes that edge before the reverse search sees it.

Why search runs backwards

The current search starts with a zero-cost label at the destination and follows incoming edges through the reverse CSR adjacency. That gives every reached state a suffix leading to the destination. When an origin access edge is examined, the search already knows the cost and path that follows it.

There are two states per base node. The state ID is nodeId * 2 + transferCount, so the second state represents one transfer already used. A transfer is accepted only when the suffix already begins with a ride, and the next transfer count cannot reach the state count. This prevents a transfer-only chain and limits an itinerary to two ride groups. The following is a partial excerpt from the search initialization:

example.ts
1 const statesPerNode = 2
2 const stateId = (nodeId: number, transferCount: number): number =>
3 nodeId * statesPerNode + transferCount
4 const destinationStateId = stateId(destinationNodeId, 0)
5 labels.scoreTicks[destinationStateId] = 0
6 labels.version[destinationStateId] = 1
7
8 const queueCapacity =
9 statesPerNode *
10 (graph.edgeFromNodeIds.length + graph.overlay.arcs.length + 1)

Source: planner-search.ts. The queue is packed, but the search is not allocation-free. createLabels allocates Float64Array and Uint32Array fields for every search state. The candidate builder and path materializer also allocate result structures.

The label comparison keeps more information than score alone. It compares score, total walking, longest walking leg, transfer walking, transfer count, ride distance, and path identity. That makes the search deterministic when scores tie. It does not make the whole planner a proof of globally Pareto-optimal trips. Candidate selection still applies product policy after search.

Selection is a second algorithm

Search produces possible candidates. Selection checks the candidate's access and egress, groups consecutive edges into rides, and rejects sequences that a rider should not receive. The current rules allow one or two rides, reject a repeated service ID, reject a transfer walk above 400 metres, and enforce the 1,500-metre per-end and 2,000-metre combined access caps.

After filtering, the selector keeps the best candidate for each first service. It removes same-mode near duplicates when both endpoint pairs are within 100 metres and the scores differ by no more than 600 ticks, or two minutes. It returns up to four candidates. In both mode, a surviving candidate that uses a jeepney receives priority over a bus-only candidate before the normal ranking tuple is applied.

That distinction explains the Buhangin case. A reverse search can find a path to Sampaguita. It cannot, from graph cost alone, decide that 4.7 kilometres of access walking is bad advice. The selection layer is where that product judgment becomes an executable rule.

Mounting the image

The image sections are readable through DataView while the reader validates the directory, widths, counts, offsets, and appended digest. Mounting then decodes strings and copies numeric values into typed arrays used by the planner.

The excerpt is partial; it stops while the remaining image sections are being read.

example.ts
1export function mountPlannerImage(
2 image: Uint8Array,
3 verifiedSha256: string
4): MountedPlannerImage {
5 const header = readPlannerImageHeader(image)
6 const sections = readSections(image)
7 const sha256 = plannerImageHashParts(image).expectedSha256
8 if (verifiedSha256 !== sha256) {
9 throw new Error("planner image SHA-256 does not match")
10 }
11 const strings = decodeStrings(

Source: planner-mount.ts. The phrase “mounted typed arrays” should not be read as “all runtime state is a view over the downloaded bytes.” uint32Section creates a new Uint32Array and copies each value. Coordinates, nodes, edges, labels, and string values are also represented in runtime-owned arrays or JavaScript strings. The browser transfers the initial ArrayBuffer to the worker, which avoids cloning that message, but it does not remove the mount and query allocations.

The query path in planMountedTrip then:

  1. checks the 25-metre same-place boundary;
  2. checks both points against the compiled service area;
  3. enables the requested mode;
  4. projects endpoints and adds coverage candidates;
  5. builds the overlay and copies base node coordinates for the search graph;
  6. searches the reverse graph;
  7. filters and ranks candidates;
  8. materializes the result or returns noConnectingRoute.

The list is short because these are distinct responsibilities. It is not a promise that each step is cheap or independent of every other step.

Evidence and limits

The current checked-out manifest reports a 26,851,432-byte image. That value belongs to the generated snapshot and can change when source data changes. The build checks determinism by compiling twice and comparing both the SHA-256 and every byte. The web client checks the manifest byte count and digest before it sends the buffer to the worker. The worker verifies the image again before mounting it.

The browser path currently passes an empty liveBusArrivals array. The engine therefore plans from static topology and fixed mode-specific waits in that path. The current code also measures access using coordinate distance rather than a pedestrian street graph. These are deliberate boundaries, but they are not claims that static topology is complete or that a straight line is a walk route.

For the exact image layout and publication steps, read Planner image compilation. For the request lifecycle and update failure cases, read Offline cache and worker lifecycle. For the reasons behind the two main policy changes, read the absurd-route postmortem and the server-to-client pivot.