Skip to content

feat(engine): streams as an answer type + distance-bounded flow tracing - #32

Open
prayaslashkari wants to merge 8 commits into
developmentfrom
feat/stream-distance-tracing
Open

feat(engine): streams as an answer type + distance-bounded flow tracing#32
prayaslashkari wants to merge 8 commits into
developmentfrom
feat/stream-distance-tracing

Conversation

@prayaslashkari

@prayaslashkari prayaslashkari commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Recreates UC1-CQ2c from David's notebooks: "What streams are downstream at most N km from facilities of industry X?"


1. What this adds

Two capabilities the query builder didn't have:

Streams are now an answer, not just scenery. Flowlines only ever appeared as a decorative layer traced from the anchors — you couldn't ask for them. (waterBodies is hyf:HY_WaterBody: lakes and ponds, which the notebook explicitly swaps out for flowlines.) Streams are now selectable in either block, with a stream-type filter.

Downstream/upstream can be distance-limited. Traces were the full transitive closure — they ran to the ocean and returned half the state. There's now a Within N km of flow dropdown (5/10/30/50/100 km), plus the notebook's "+1" behaviour: the trace extends one segment past the cutoff so a flowpath ends at a river feature rather than mid-channel.

What this opens up for researchers

  • Sampling prioritisation. "Which stream segments are within 30 km downstream of an airport or landfill?" — a shortlist you can send a field crew, instead of a statewide guess. Cross it with existing sample coverage to find the gaps.
  • Source screening, in reverse. Point at a contaminated stream, flip to upstream, get the facilities plausibly implicated. A hypothesis set narrow enough to investigate.
  • Sensitivity testing. The cutoff is a scientific judgement, not a fact. Sweeping 5 → 100 km shows whether a finding is robust or an artifact of the threshold — the sort of check that's tedious in a notebook and trivial here.
  • Distance-attributed results. Every stream carries its flow distance from the source, so results can be ranked by proximity rather than treated as a flat set.

One caveat worth repeating in any write-up: this is connectivity, not fate-and-transport. It says water flows from here to there within N km. It says nothing about whether contamination arrives, or at what concentration — no discharge volumes, no dilution, no groundwater. It's a screening layer.


2. How it's implemented

'streams' joins EntityType with FTYPE filtering, IRI hydration and map wiring; maxDistanceKm joins SpatialRelationship. When unset, the emitted SPARQL is byte-identical to today's — I diffed generated queries for three existing question shapes to confirm nothing moved.

The distance cutoff. Sum the segment lengths between seed and candidate, then filter on the total. The seed block is duplicated inside the subquery — without it the SUM runs over the entire national flowline graph:

{
  SELECT DISTINCT ?upstream_flowline ?ds_flowline WHERE {
    {
      SELECT ?upstream_flowline ?_flEnd (SUM(?_flLen) AS ?_plen) WHERE {
        {
          SELECT ?upstream_flowline ?_flMid ?_flEnd WHERE {
            { SELECT DISTINCT ?upstream_flowline WHERE { ...anchor seed... } }
            ?upstream_flowline hyf:downstreamFlowPathTC ?_flMid .
            ?_flMid hyf:downstreamFlowPathTC ?_flEnd .
          }
        }
        ?_flMid nhdplusv2:hasFlowPathLength/qudt:quantityValue/qudt:numericValue ?_flLen .
      } GROUP BY ?upstream_flowline ?_flEnd
    }
    FILTER (xsd:float(?_plen) < xsd:float(30))
    ?_flEnd hyf:downstreamFlowPath? ?ds_flowline .   # the "+1"
  }
}

The "+1" is that last line — a zero-or-one path, giving the endpoint and its immediate neighbour in one triple. A UNION says the same thing, but QLever (which the notebooks run against) returns unbound results for MIN() over a UNION-bound variable; I isolated it on a two-row case with no data involved. The path form works on both hosts. Same reason for ?a = ?b over sameTerm() — QLever hasn't implemented sameTerm.

Streams as the target get BIND(?ds_flowline AS ?streamC) instead of the usual ?s2target hop, which would otherwise match any flowline sharing an S2 cell with the answer. The supporting stream layer is skipped in that case, so flowlines aren't drawn twice.

Flow distance for popups uses MIN() per flowline (shortest qualifying path). Fringe segments can't inherit their parent's distance — that would report a sub-threshold number for a flowline outside the threshold — so they carry the parent's path plus the parent's own length.


3. How this differs from the notebook

Notebook Here Why
schema1:address on facilities required OPTIONAL Only 13 of 144 NH airport facilities have it. Required, it silently drops 91% of the anchor set.
S2 seeding facility's own cell own cell + 8 neighbours Existing app-wide convention for every spatial relation.
Region binding facility → county → state S2 cell → state How the app already scopes regions; catches 147 facilities vs 144.
Per-stream distance one value per (seed, endpoint) pair MIN() across seeds Each stream needs one number for its popup.

The first row is a bug, and it's the whole story of the count difference — it's why the notebook's map shows about five facilities for a state with 144:

notebook as written                      162 flowlines   (18 seeds, ~13 facilities)
notebook, address requirement removed  1,547            (228 seeds, 144 facilities)
ours, neighbour expansion off          1,605            ⊇ the 1,547, nothing missing
ours as shipped                        2,784

The other three rows are deliberate, and none is new to this PR.

Not carried over: the notebook's navy/blue seed-vs-downstream colour split, its S2-cell overlay, and street address in the facility popup (we have name, industry and an EPA FRS link). All cosmetic.

Also worth knowing: downstreamFlowPathTC in this KG doesn't reach the coast. Unbounded gives 3,490 streams vs 2,784 at 30 km — a narrower closure than the notebook's parameters imply.


4. Verification

scripts/flow-distance-check.mjs runs the real planner against the live endpoints and asserts the step plan, the superset relation, that the bound actually excludes flowlines, and that every hydrated stream has drawable geometry. Plain .mjs following scripts/contrast-check.mjs; no test framework added.

Measured (NH, NAICS 488119):

cutoff streams fringe added
unbounded 3,490
30 km 2,784 +1.0%
10 km 2,096 +3.8%
5 km 1,669 +8.9%

Flowline distances at a 30 km cutoff span 0.01–32.23 km — the over-threshold value is the fringe honestly reporting itself as past the boundary.

The bound also makes previously impossible questions possible: "samples within 30 km downstream of NH airports" returns 27 sample points in ~15 s, where the unbounded form times out against the gateway.

npm run build clean. npm run lint shows the same 15 pre-existing errors as development — none new.

⚠️ FRINK is currently returning 503 on all five endpoints, so the committed check script hasn't been re-run against it since the "+1" commit. The numbers above were verified against apps.okn.us, which I'd earlier confirmed returns byte-identical result sets for these queries. Please re-run node scripts/flow-distance-check.mjs before merging, once FRINK is back.

To try it

Block A = Streams / Flowlines · Relationship = Downstream of + Within 30 km of flow · Block C = Facilities, industry 488119, region New Hampshire.

Put the region on Block C, not Block A. On Block A the anchor becomes every 488119 facility in the country (11,010 across 10,703 S2 cells) and the trace takes minutes — correct, since a Maine stream can be fed from New Hampshire or Quebec, but rarely what you want.

Recreates UC1-CQ2c ("what streams are downstream at most N km from
facilities of industry X?") from David's notebook, which needed two
things the query builder could not express.

Streams as an answerable entity. Flowlines previously reached the map
only as a decorative layer traced from the anchors; hyf:HY_FlowPath was
not selectable in either block. Adds 'streams' to EntityType with an
FTYPE filter, IRI hydration, and map wiring. When the target is a
flowline the ?s2target hop is replaced with a direct bind — that hop
would otherwise match any flowline sharing a cell with the answer — and
the supporting stream layer is skipped so flowlines aren't drawn twice.

Cumulative distance cutoff. downstream/upstream traces were the full
transitive closure. Adds an optional maxDistanceKm that sums
nhdplusv2:hasFlowPathLength over the segments between seed and
candidate and filters on the total. Unset, the emitted SPARQL is
byte-identical to before, so existing questions are unaffected.

The bound is not just a filter: "samples within 30 km downstream of NH
airports" returns 27 sample points in ~15s, where the unbounded form
times out against the federation gateway.

Two things worth knowing for review:

The notebook and this implementation do not agree, and the notebook is
wrong. Its outer block re-joins facilities on schema1:address as a
required triple, a predicate only 13 of 144 NH airport facilities carry,
so it silently discards 91% of its own anchor set. Drop that triple and
it returns 1,547 flowlines where we return 1,605 on the same seeding —
we are a superset. Address stays OPTIONAL here.

Neighbour-cell expansion is kept, per discussion: we seed from the
facility's S2 cell and its 8 neighbours where the notebook uses the
facility's own cell only. That is the app-wide convention and accounts
for the rest of the difference (2,757 vs 1,605 for NH airports at 30km).

scripts/flow-distance-check.mjs runs the real planner against the live
endpoints and asserts the step plan, the superset relation, that the
bound actually excludes flowlines, and that every hydrated stream has
drawable geometry.
@railway-app

railway-app Bot commented Aug 25, 2026

Copy link
Copy Markdown

This PR was not deployed automatically as @prayaslashkari does not have access to the Railway project.

In order to get automatic PR deploys, please add @prayaslashkari to your workspace on Railway.

The "+1" from David's second UC1-CQ2c notebook. A distance budget runs
out at whatever segment happens to fit, which is an artifact of how
NHDPlus split the river rather than a real feature — the drawn path
stops mid-channel. Extending one segment past the boundary means the
flowpath visibly crosses the threshold instead of ending at it, which
is the notebook's stated intent: the total may deliberately exceed the
limit.

Always on when a cutoff is set. A checkbox for a 1%-at-30km difference
is a control nobody would understand, and "within 30 km" already reads
as approximate.

Implemented as a zero-or-one property path, which yields the endpoint
and its immediate neighbour in one triple. A UNION says the same thing,
but QLever — which the notebooks run against — returns unbound results
for MIN() over a variable bound inside a UNION, verified on a two-row
test case with no data involved. The path form works on both hosts.
Same reason `?a = ?b` rather than sameTerm(): QLever has not
implemented sameTerm.

Fringe segments cannot inherit their parent's distance, since that
would report a sub-threshold number for a flowline outside the
threshold. They carry the parent's path plus the parent's own length —
the distance to where the fringe segment begins, which never
understates. A flowline reachable as both a valid endpoint and a fringe
keeps the smaller value, since MIN runs over both.

Effect scales inversely with the cutoff, as the fringe is a larger
share of a smaller answer (NH airports):

    5 km    1,533 -> 1,669   (+8.9%)
   10 km    2,019 -> 2,096   (+3.8%)
   30 km    2,757 -> 2,784   (+1.0%)

Verified against apps.okn.us: 2,784 at 30 km, 1,669 at 5 km, 3,490
unbounded (unchanged), and flowline distances spanning 0.01–32.23 km
with the fringe correctly reporting past the threshold. FRINK is
returning 503 across all five endpoints right now, so the committed
check script has not been re-run against it — the two hosts were
verified to return identical result sets earlier in this work.
@prayaslashkari

Copy link
Copy Markdown
Collaborator Author

Added the "+1" hop from David's second notebook (0ff2f39).

A distance budget runs out at whatever segment happens to fit, which is an artifact of how NHDPlus split the river rather than a real feature — without this the drawn path stops mid-channel. Extending one segment past the boundary means the flowpath visibly crosses the threshold, which is the notebook's stated intent: the total may deliberately exceed the limit.

Always on when a cutoff is set. A checkbox for a 1%-at-30km difference is a control nobody would understand, and "within 30 km" already reads as approximate. Easy to make it a toggle if you disagree.

Effect scales inversely with the cutoff

The fringe is a larger share of a smaller answer set (NH airports, NAICS 488119):

cutoff without +1 with +1 added
5 km 1,533 1,669 +8.9%
10 km 2,019 2,096 +3.8%
30 km 2,757 2,784 +1.0%

At 30 km it's noise. At 5 km it's a tenth of the result, so it matters most exactly where a researcher is being most selective.

Note this also changes entity answers, not just the drawing — with streams as Block A the fringe is cosmetic, but for "samples downstream of X" it admits sample points sitting on the fringe segment, past the stated distance. That's the notebook's semantics, and it's why the popup number matters (below).

Two implementation notes worth review

Zero-or-one property path, not a UNION. ?end hyf:downstreamFlowPath? ?answer yields the endpoint and its immediate neighbour in one triple. A UNION expresses the same thing, but QLever — which David's notebooks run against — returns unbound results for MIN() over a variable bound inside a UNION. Verified on a two-row test case with no data involved:

SELECT ?k (MIN(?v) AS ?minv) WHERE {
  VALUES (?k ?a ?b) { ("x" 1.0 2.0) ("y" 5.0 3.0) }
  { BIND(?a AS ?v) } UNION { BIND(xsd:float(?a) + xsd:float(?b) AS ?v) }
} GROUP BY ?k

→ QLever returns ?k bound, ?minv missing. The path form works on both hosts. Same reason for ?a = ?b over sameTerm() — QLever hasn't implemented sameTerm.

Fringe distance. Fringe segments can't inherit their parent's path_length, since that reports a sub-threshold number for a flowline outside the threshold. They carry the parent's path plus the parent's own length — the distance to where the fringe segment begins, which never understates. A flowline reachable as both a valid endpoint and a fringe keeps the smaller value, since MIN runs over both.

Verification

FRINK is returning 503 across all five endpoints as I write this, so scripts/flow-distance-check.mjs hasn't been re-run against it. I verified against apps.okn.us instead — the two hosts were confirmed to return byte-identical result sets earlier in this work:

streams @ unbounded: 3490      (unchanged)
streams @ 5km:       1669
streams @ 30km:      2784

flowline distances (5 facilities, 30 km): 113 rows, 113 with path_length
  within cutoff (<30km): 112
  fringe (>=30km):         1
  range: 0.01 - 32.23 km

That 32.23 km fringe value is the check that matters — it's a flowline past the boundary honestly reporting a distance past the boundary.

npm run build clean, npm run lint still the same 15 pre-existing errors. Worth re-running the check script against FRINK before merge, once it's back.

FRINK retired https://frink.apps.renci.org/<kg>/sparql; all five knowledge
graphs now return 503 there and are served from https://apps.okn.us/<kg>/sparql
instead. Reported by David Kedrowski, who hit the same break in his notebooks.

Verified all five endpoints live on the new host (200, CORS *), and replayed
the real pipeline through planPipeline/executePipeline against them: 7 of 8
prebuilt queries succeed and all discovery queries return live data rather
than falling back to hardcoded constants.

The Indiana downstream prebuilt still fails, but with a QLever memory-limit
error ("Tried to allocate 819.2 MB, but only 743.3 MB were available"), not a
routing failure. Tracked separately.
fix(endpoints): move SPARQL endpoints from FRINK to apps.okn.us
The app deploys on Railway from main, but the repo still carried a Render
blueprint from the publish workflow and no mention of Railway anywhere. The
stale file was actively misleading about where production runs.

Removes render.yaml, documents the real deploy target in the README, and adds
the W35 changelog covering this and the FRINK endpoint migration.
chore: drop stale render.yaml, document Railway as the deploy target
@prayaslashkari prayaslashkari changed the title feat(engine): streams entity type and distance-bounded flow tracing feat(engine): streams as an answer type + distance-bounded flow tracing Aug 28, 2026
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