Skip to content

fix(engine): bound the flowline layer at both ends - #56

Merged
prayaslashkari merged 1 commit into
fix/fused-seed-order-and-obs-joinsfrom
fix-upstream-flowline
Sep 19, 2026
Merged

prayaslashkari merged 1 commit into
fix/fused-seed-order-and-obs-joinsfrom
fix-upstream-flowline

Conversation

@prayaslashkari

@prayaslashkari prayaslashkari commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Surfaced on "What facilities are upstream from PFOS samples?" scoped to York County, ME. The discovery steps find the right facilities; this layer drew the wrong rivers around them.

The bug

GET_FLOWLINE_GEOMETRIES traced a one-sided transitive closure. It seeded from the cells of every resolved anchor, widened each by sfTouches, and followed the network to its end:

{ SELECT DISTINCT ?s2cellus WHERE { VALUES ?facilityA { ... } ... } }
?upstream_flowline rdf:type hyf:HY_FlowPath ;
      spatial:connectedTo ?s2cellus ;
      hyf:downstreamFlowPathTC ?flowline .

Nothing in it references the targets. An anchor sitting near a drainage divide pulls in the whole of the neighbouring basin.

Measured with the real answer sets (1,497 facilities, 200 sample points), the drawn layer contained:

Stream Segments Basin
Merrimack River 122 NH/MA, ~60km south
Presumpscot River 40 Casco Bay
Suncook River 37 Merrimack
Cohas Brook 24 Merrimack
Winnipesaukee River 21 Merrimack headwaters, ~130km
Powwow River 16 Merrimack

No York County sample drains from any of them. Nothing looked broken; the map just had extra rivers on it, and they were real rivers.

The fix

Intersect the closure with the flowlines that actually reach the resolved targets.

Flowlines Time
Shipped 3,271 7s
Fixed 2,516 7s
755 removed, 0 added

Zero added means the fix can only take away flowlines that were never on a path from a facility to a sample. Merrimack, Winnipesaukee and Presumpscot all go to 0.

Of the 755 removed, 684 are in a different basin and 71 lie below a sample. That second group is the one visible behaviour change: the drawn river now stops at the sample instead of running on to the sea.

Why an intersection

The direct membership test is the obvious form and does not work:

?flowline hyf:downstreamFlowPathTC? ?_flTarget .   # 31s, "Join on ?flowline"

?flowline is unbound on the left, so QLever times out. Two DISTINCT closures joined on ?flowline run in the same 7s the one-sided query took.

The reach is a bare TC, not reflexive. TC? was measured: it recovers 0 flowlines and costs 8s, because the segment a target sits on already arrives via another target upstream of it.

Bounded traces

The same intersection is applied to the distance-capped branch. At 25km it went 3,034 to 2,516, matching the unbounded set, because connectivity dominates the cap at that range. At 5km the cap still bites (2,314), so the two constraints compose rather than one masking the other. Worth noting the shipped bounded query still drew a Merrimack segment straight through its cap.

Guardrail

scripts/check-flowline-scope.mts asserts both ends are bound for all 100 shapes that draw the layer, bounded and unbounded, and runs in CI alongside the existing trace-direction check. Offline, runs in seconds.

npm run check-trace-direction (2,068 checks) and npm run check-step-labels (651 checks) still pass. The 15 lint errors on this branch are pre-existing in React components and identical before the change.

Stacked on #55

Base is fix/fused-seed-order-and-obs-joins, not development. Merge #55 first.

The two fix different bugs and touch disjoint regions, but the dependency is real for verification: without #55 the discovery step OOMs at 26s on the York question, so there are no anchor IRIs to feed this layer and the change cannot be reproduced end to end. Stacking lets a reviewer check out this branch and run the whole question.

Rebasing onto #55 surfaced one interaction worth recording. #55 reworked bindEntityInCell so that sampleObservations: false no longer drops every observation join; it now emits the lean per-filter ones. That fed straight into this PR's target-cell subselect, which re-derived the PFOS substance filter on top of an IRI list that was already the filtered answer set. Same 2,516 flowlines, 1s slower. Fixed by passing the bare block so only the cell-membership triple is emitted, which puts the query back to exactly the text measured above.

All five self-checks pass on the combined branch: flowline scope (400), query joins (265, #55's own), trace direction (2,068), step labels (651), cache key (19).

@railway-app

railway-app Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the explorer-app-pr-56 environment in sawgraph-explorer

Service Status Web Updated
sawgraph-api 🕒 Building (View Logs) Web Sep 19, 2026 at 7:56 pm UTC
sawgraph-web ✅ Success (View Logs) Web Sep 19, 2026 at 7:55 pm UTC
1 service not affected by this PR
  • sawgraph-db-dev

@railway-app
railway-app Bot temporarily deployed to sawgraph-explorer / explorer-app-pr-56 September 19, 2026 19:50 Destroyed
GET_FLOWLINE_GEOMETRIES traced a one-sided transitive closure: it seeded from
the cells of every resolved anchor, widened each by sfTouches, and followed the
network to its end. Nothing in the query referenced the targets, so an anchor
sitting near a drainage divide pulled in the whole of the neighbouring basin.

On "What facilities are upstream from PFOS samples in York County, ME" that
drew 122 segments of the Merrimack and 21 of the Winnipesaukee, ~130km away in
a basin no York sample drains from, plus the Presumpscot, the Suncook and the
Powwow. Nothing looked broken. The map just had extra rivers on it, and they
were real rivers.

The closure is now intersected with the flowlines that reach the resolved
targets. Measured live with the real answer sets (1,497 facilities, 200 sample
points): 3,271 flowlines down to 2,516, in the same 7s. 755 removed, 0 added,
so the fix can only take away flowlines that were never on a path from a
facility to a sample. Of those, 684 are in a different basin and 71 lie below
a sample, which is the one visible behaviour change: the drawn river now stops
at the sample instead of running on to the sea.

Written as an intersection of two DISTINCT closures. The direct membership
test, `?flowline hyf:downstreamFlowPathTC? ?_flTarget`, leaves ?flowline
unbound on the left and QLever times out joining on it (31s, "Join on
?flowline"). The reach is a bare TC rather than reflexive: `TC?` was measured
and recovers 0 flowlines while costing 8s.

The bounded branch gets the same intersection. At 25km it went 3,034 -> 2,516,
matching the unbounded set, because connectivity dominates the distance cap at
that range; at 5km the cap still bites (2,314), so the two constraints compose.
The shipped bounded query still drew a Merrimack segment through its cap.

scripts/check-flowline-scope.mts asserts both ends are bound for all 100 shapes
that draw the layer, bounded and unbounded, and runs in CI.
@prayaslashkari
prayaslashkari changed the base branch from development to fix/fused-seed-order-and-obs-joins September 19, 2026 19:55
@railway-app
railway-app Bot temporarily deployed to sawgraph-explorer / explorer-app-pr-56 September 19, 2026 19:55 Destroyed
@prayaslashkari
prayaslashkari merged commit 1522403 into fix/fused-seed-order-and-obs-joins Sep 19, 2026
3 of 4 checks passed

This branch was successfully deployed

No deployments
sawgraph-explorer / explorer-app-pr-56 — b7bcc73c Deployed Sep 19, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant