Scope the Plani

5856372 · Gabriel Morell · 2026-09-03 16:36

1 files +116 -0
Message
{commit_body(@commit)}

Files changed

added PLANI.md
+116 −0
@@ -0,0 +1,116 @@
1 +# Plani
2 +
3 +A foci that moves.
4 +
5 +A [Foci](README.md) anchors a query in one place. A Plani anchors it wherever
6 +the client reporting to it currently is, and falls back to a home foci when
7 +nothing has reported for a while. The point is a vision that answers "what is
8 +near me" rather than "what is near Davis Square".
9 +
10 +This is a scope, not a description of something that exists. What is built so
11 +far is the plumbing underneath it, noted below.
12 +
13 +## Where the position lives
14 +
15 +**In a GenServer, and nowhere else.** A Plani's row in the database holds its
16 +configuration — a name, a home foci, the sources attached to it — and never a
17 +coordinate. The current position exists in the worker's memory, expires on a
18 +timer, and goes when the process does.
19 +
20 +That is a deliberate constraint rather than an implementation detail. A table
21 +of where somebody has been is a different kind of thing from a board of when
22 +the next bus is, with different obligations attached to it, and this avoids
23 +having one. It also makes the fallback obvious: when there is no position in
24 +memory there is nothing to fall back *from*, so the home foci answers.
25 +
26 +```
27 + fresh fix? ──yes──▶ the client's position
28 + Plani.anchor() ──┤
29 + └──no───▶ home foci's place (from the database)
30 +```
31 +
32 +## What already exists
33 +
34 +More than it looks, because the pieces were built for other reasons.
35 +
36 +| Piece | Where | State |
37 +| --- | --- | --- |
38 +| A client may publish to its Ankyra | `RoomHermesWeb.TopicController`, `<topic>.up.` | done |
39 +| A client reports its position, opt-in | `clients/android`, `LocationReporter` | done |
40 +| Positions arrive and are held for five minutes | `RoomSanctum.Worker.Ankyra` | done |
41 +| The trail is visible | `cfg/ankyra/:id` | done |
42 +| A client can ask for a fresh board | `<topic>.publish.`, `Pythiae.query_current_now/1` | done |
43 +
44 +And one piece of shape that matters more than any of it: **the storage layer
45 +already separates resolving an anchor from searching near a point.**
46 +
47 +```elixir
48 +def nearest_aqi_stations(source_id, foci_id, limit) # resolves a foci, then:
49 +def nearby_aqi_stations(source_id, %Geo.Point{}, limit)
50 +```
51 +
52 +A Plani is largely a matter of handing a different point to functions that
53 +already take one.
54 +
55 +## The part that is not uniform
56 +
57 +"Most of our datapoints have a latlon attached someplace" is true, but they do
58 +not have it in the same place, and — more importantly — **"the closest N" only
59 +means something for some of them.** Attaching a source to a Plani means one of
60 +three different things:
61 +
62 +**Sources that store many located things.** Nearest-N is the natural question,
63 +and the work is a spatial query.
64 +
65 +| Source | Where the location is | Ready? |
66 +| --- | --- | --- |
67 +| `gbfs` stations | `gbfs_station_information.place`, PostGIS | yes — `ST_DWithin` + `<->` ordering already used |
68 +| `gbfs` free bikes | `gbfs_free_bike_status`, PostGIS | yes |
69 +| `aqi` monitors | `airnow_hourly_observations.point`, PostGIS | yes — `nearby_aqi_stations/3` |
70 +| `gtfs` stops | `stops.stop_lat` / `stop_lon`, **plain floats** | **no** — no geometry column, no index, no nearest-stop query anywhere |
71 +
72 +GTFS is the one anybody would want first and the only one needing real work: a
73 +geometry column on `stops`, a GiST index, and backfilling it on import. It is
74 +also the largest table in the system, so the index is not optional.
75 +
76 +**Sources answered *at* a point rather than *near* one.** Weather, sunrise,
77 +pollen, the tide at the nearest station: there is one answer and it is
78 +computed for wherever you are. These already take a `foci_id` in their query
79 +config (`weather`, `ephem`, `pollen`, `calendar`, `cronos`, `icarus`), so the
80 +change is to accept a point instead — the smallest useful stage, and it needs
81 +no spatial indexing at all.
82 +
83 +**Sources with no location.** `github`, `gitlab`, `packages`, `mailbox`,
84 +`treasury`, `bourse`. A Plani should either refuse these or pass them through
85 +untouched; "the nearest CI job" is not a thing. Worth deciding explicitly
86 +rather than discovering at runtime.
87 +
88 +## Suggested order
89 +
90 +1. **The anchor.** A Plani row (name, home foci, sources) and a worker that
91 + resolves a position: freshest fix from the Ankyra worker's trail, else the
92 + home foci. Publishes nothing yet — but the resolution, the expiry and the
93 + fallback are the part with the design risk in it, and it can be watched on
94 + a page before anything depends on it.
95 +2. **Relocatable point sources.** Give the query workers that take a `foci_id`
96 + a way to take a `%Geo.Point{}`. Weather and ephem where you are. No schema
97 + changes.
98 +3. **Nearest-N for what is already indexed.** GBFS stations and bikes, AQI
99 + monitors. The queries exist; they need a limit and a caller.
100 +4. **GTFS stops.** Geometry column, GiST index, backfill on import, then
101 + nearest-stop. The biggest piece and the one worth having.
102 +
103 +## Open questions
104 +
105 +- **N or a radius?** "Five nearest" and "everything within 500m" answer
106 + differently when you are somewhere empty. GBFS queries already take a
107 + radius; nearest-N would be a second mode rather than a replacement.
108 +- **Is a Plani its own thing, or a mode on a Pythiae?** A Pythiae already has
109 + `curr_foci`. A Plani could be a foci whose place is resolved at read time,
110 + which would mean nothing else has to know about it — the smaller change, and
111 + probably the right one.
112 +- **What does a client see while it has never reported?** The home foci, which
113 + is correct but silent. A board that says which anchor it used would save
114 + somebody wondering why the times are for the wrong town.
115 +- **Which Ankyra?** A Plani follows *a* client. An Ankyra with two clients
116 + reporting has two positions and no rule for choosing between them.

Parents: 721d2fc