Clustering Workflow¶
How the LEAF DSS clustering pipeline works end-to-end - from ODK village survey to a finalised cluster consumed by the external production tool. Use this guide as the orientation document for new contributors and for partner handovers.
The workflow was designed in the IWMI requirements call on 2026-04-23 (with Faiz Alam) and the LEAF DSS Clustering Workflow deck. This guide reflects what the API actually implements; the API reference pages document each individual endpoint.
1. The big picture¶
+-----------------------------------+
SHG survey seed data ─────► | Supabase Postgres (DATABASE_URL) |
(data/villages.csv) | villages.csv source-of-truth |
│ | clusters table |
│ cluster_block_*() | cluster_villages table |
│ | cluster_generation (fingerprint)|
▼ | infrastructure table |
Greedy seed-and-grow ─────► +-----------------------------------+
(clustering.py) │
│ CSV download/upload
│ edit cycle
▼
Block coordinator / IWMI reviewer
│
│ finalize
▼
Outbound feed (production-tool API)
│
▼
External production tool
(auth, per-cluster login,
pashu-sakhi dashboards)
│
│ POST aggregated metrics
▼
/api/production-tool/dashboard/<id>
│
▼
Cluster report card on LEAF DSS UI
2. Input: village-level points¶
The clustering planner's single source of truth is data/villages.csv (committed to the repo). It is the state-wide SHG village master — roughly 21,495 village rows across all 35 Assam districts and 220 blocks — built from the consolidated SHG survey workbook by scripts/build_village_master.py. District and block names are taken verbatim from the survey (uppercased, whitespace-collapsed); the only rows dropped are those with missing or out-of-range coordinates.
| Source | When | Status |
|---|---|---|
SHG survey workbook (source_data/SHG_Assam_Consolidated_final_Jun22.xlsx, sheet Village Location Detail) |
Current | Whole-state master → data/villages.csv. Source workbook is gitignored (PII); only the derived CSV is committed. |
| ODK village survey | Production refresh | Replaces the survey once continuous collection finishes. Same columns, no API change needed. |
Required columns: district_name, block_name, gp_name, vill_name, lat, long, Dairy, Goatery, Piggery, Backyard_Poultry, Duckery, Fishery_Activity.
Each village is a single GPS point (no polygon shapefile needed - design principle from the deck). The six commodity columns are member counts: how many SHG members in that village expressed interest in each commodity. Haversine (great-circle) distance between these points is what all the spatial constraints below are measured on.
Coordinate gotcha
Villages whose survey record has no lat/long (e.g. the Udalguri "Location Status = MISSING" rows) are dropped by the coordinate filter and therefore cannot be clustered or mapped. They must be given coordinates or removed at source — regenerating clusters will not resurrect them.
Available via GET /api/villages, GET /api/villages/geojson, GET /api/villages/aggregate.
3. Map levels¶
The hierarchical map view in the dashboard switches representation as the user drills in. Showing ~21,500 points at state scale would be unreadable, so:
| Zoom | Endpoint | What you see |
|---|---|---|
| State (Assam) | GET /api/villages/aggregate?level=district |
One choropleth per district (member sums, village count). |
| District | GET /api/villages/aggregate?level=block&district=<X> |
One choropleth per block. |
| Block | GET /api/villages/geojson?block=<X> + GET /api/clusters?block=<X>&commodity=<Y> |
Village points + commodity-filtered cluster overlays. |
The block scale is where the real work happens: village dots, optionally tinted by member count, with cluster groupings drawn around them.
4. Clustering - what the algorithm does¶
The engine lives in clustering.py — pure Python, no Flask or DB dependency. It runs a greedy seed-and-grow algorithm independently per (block, commodity) pair over the six commodities Dairy, Goatery, Piggery, Backyard_Poultry, Duckery, Fishery_Activity. Government defaults come from DEFAULT_PARAMS (see §11).
ALGO_VERSION = 5
The module carries an ALGO_VERSION constant (currently 5). It is folded into the smart-refresh fingerprint (see §4a), so bumping it forces every unlocked stored scope to regenerate on its next read. Bump it whenever the clustering logic changes in a way that params and data don't already capture (v5 = LEAF-42 raising min_members_per_village to 6 + LEAF-43 dropping the village-count band).
Input filter (runs once, before any pass): villages with fewer than min_members_per_village interested members for the commodity are dropped from the candidate set. Default min_members_per_village is 6 (LEAF-42, Faiz 2026-05-21) — a village with five or fewer interested members for the commodity is treated as "unassigned", excluded from clustering, and hidden from the cluster map when that commodity is selected. The filter is per-commodity, so a village with no dairy interest but fifty goatery members still appears on the goatery view. Every pass below operates on the filtered set.
The pipeline runs five passes. A and B do the main work; C, D, E each address an edge case the earlier passes leave behind.
Pass A - Single-village clusters (pre-pass)¶
Before the greedy loop, any village whose member count for the commodity is already at or above max_cluster_members is emitted as its own one-village cluster. This bypasses both the "minimum 2 villages" rule and the upper member cap.
Why: a 274-member dairy village cannot be paired with any neighbour without busting the 150-member cap, so the greedy loop in Pass B would otherwise drop it. After this pass, large standalone villages stay in the output as singletons.
Pass B - Greedy seed-and-grow (main pass)¶
For the remaining (unassigned) villages:
- Sort big-first by member count.
- Seed the next cluster with the biggest unassigned village; greedily add nearest neighbours that fit all three constraints: ≤
max_radius_kmfrom the seed, ≤max_radius_kmmax pairwise span across the whole trial cluster, and total members ≤max_cluster_members. The village-count band that used to gate growth (was 2-4) was removed in LEAF-43; six villages that satisfy member range + 5 km span are a valid cluster. - Discard the cluster if it ends up under the member floor (<
min_cluster_members); release its villages back to the pool. A single-village cluster with members in the funding band is valid. - Repeat for the next seed.
The pairwise-span gate is what prevents a distant village from being stapled onto a cluster just because it's close to the seed — every existing member must also be within max_radius_km.
Pass C - Orphan merge (post-pass)¶
Any village still unassigned after Pass B is merged into the nearest existing cluster whose centroid is within 2 * max_radius_km — but only if every cap still holds after the merge: max_cluster_members AND the recomputed max_pairwise_km ≤ max_radius_km. (The village-count cap was removed in LEAF-43 — see Pass B above.) The earlier loose soft-cap version produced clusters that broke the 5 km span rule (e.g. BARTANGLA at 5.139 km); the current strict re-check rules that out.
The merged cluster keeps its original cluster_id, pashu_sakhi, and block_coordinator; centroid, span, and total are recomputed. Orphans that don't fit any cluster cleanly are left for Pass D rather than corrupting a good cluster.
Pass D - Provisional clusters (on by default)¶
Low-density blocks and pockets leave villages that never clear the min_cluster_members floor (KHOWANG/Dairy is the canonical example — 24 villages, no fundable cluster possible). When emit_provisional is on, each still-unassigned village seeds a provisional cluster using the same growth, radius, and size caps as Pass B, but with the member floor relaxed to provisional_min_members so isolated villages still show on the map.
Provisional clusters are flagged with provisional=True in the output. The UI renders them with a dashed amber ring + "Provisional" badge and excludes them from fundable counts, so reviewers can see what's there without confusing them with fundable clusters.
Pass E - Rebalance (off by default)¶
Optional rescue pass. When rebalance=True, a below-floor provisional group can become fundable by borrowing village(s) from an adjacent fundable cluster — but a borrow commits only if the provisional group stays inside the member + span caps AND the donor cluster stays valid (≥ min_cluster_members members) after losing the village. If the group can't reach the floor, all its tentative borrows are reverted (no churn). Only original fundable clusters may donate, and each village moves at most once — one rescue level, no cascade. Largest provisional groups are processed first; ties are broken deterministically. With LEAF-43 removing the village-count cap, Pass E mostly fires when Pass C is blocked by the member ceiling.
Default is off because it changes output across ~20% of provisional groups, which is large enough to need human review before flipping on. Bump ALGO_VERSION when enabling so smart-refresh regenerates every stored scope.
Block boundary is a hard wall - clusters never span two blocks. Each commodity is clustered independently, so one village can sit in many clusters (one per commodity it has interested members in).
Trigger: POST /api/clusters/regenerate with {block, commodity, ...overrides}. Admin-only - pass ?admin=1 or the X-Admin: 1 header. Empty overrides = government defaults (30–150 members, 5 km span, ≥6 interested members per village; no village-count band). Regenerate deletes and rebuilds the whole scope, so it wipes any CSV edits in that scope — that is why it is admin-gated.
Worth noting: a GET on /api/clusters?block=<X>&commodity=<Y> now lazily materialises clusters the first time a (block, commodity) scope is queried, so a fresh block doesn't need an explicit Regenerate to populate — subject to the smart-refresh and cadre-protection rules in the next section.
4a. Smart-refresh, cadre-edit protection & coverage refresh¶
Clusters are generated lazily and stored, then guarded so that automated regeneration can never overwrite human work. Three mechanisms cooperate.
Smart-refresh fingerprint (cluster_generation)¶
Every time a scope is generated, regenerate_clusters() writes a fingerprint into the cluster_generation table: a SHA-256 over ALGO_VERSION + effective params + the village blob (name/lat/long/members) for that (block, commodity). On a lazy read (get_or_regenerate), a scope is rebuilt only if its stored fingerprint no longer matches the current one — i.e. the algorithm version, params, or underlying village data changed. Unchanged scopes are served as-is, so routine viewing never triggers needless rebuilds.
Cadre-edit protection (the most important invariant)¶
A scope is locked — never auto-regenerated by either the lazy path or the daily sweep — when any of its clusters is:
| Flag | Set by | Meaning |
|---|---|---|
finalized |
POST /api/clusters/<id>/finalize |
Cluster published to the production-tool feed. |
locked |
CSV import; dashboard POST | A human uploaded an edited CSV, or production-tool dashboard data arrived. |
dashboard IS NOT NULL |
POST /api/production-tool/dashboard/<id> |
Cluster carries operational metrics. |
provisional |
algorithm (Pass D) | Below-floor group surfaced for review (not a lock, but flagged separately). |
scope_is_locked() returns true if any cluster in the scope is finalized or locked; the whole-state sweep additionally treats dashboard IS NOT NULL as protected. Protection is scope-level: touching one cluster in a (block, commodity) shields the entire scope. CSV import sets locked=True on the imported clusters and is commodity-safe — it deletes only the commodities present in the uploaded file. This is the concrete implementation of Faiz's rule "don't remove cadre changes".
Before any push that could regenerate clusters
A bare regenerate_clusters() (or the Regenerate button) deletes and rebuilds the whole scope, wiping edits. The safe entry points — get_or_regenerate and refresh_all_coverage — skip locked/finalized/dashboard scopes. Never wire an automated job to the bare regenerate path.
Coverage refresh (whole-state sweep)¶
Because clusters materialise lazily per block on view, the whole-state export only returns scopes someone actually opened — blocks nobody viewed contribute zero and the report undercounts (observed ~40% of the true member total). refresh_all_coverage() closes that gap by materialising every stale, unlocked (block, commodity) across all 220 blocks × 6 commodities.
| Endpoint | Purpose |
|---|---|
POST /api/clusters/refresh-all?admin=1 |
Manual trigger — the "Refresh all clusters" button on /update. Runs asynchronously, returns 202 {started: true, coverage: [...]} immediately. |
GET /api/clusters/coverage |
Per-commodity reconciliation: raw_total, assigned, unassigned, assigned_pct, blocks_with_clusters. Poll it to watch the numbers climb after a refresh. |
GET /api/clusters/pending-renames |
Blocks holding clusters under an old/renamed name; cadre_clusters counts finalized/locked/dashboard clusters that must be migrated, not dropped. |
GET /api/clusters/unassigned.csv |
Every interested village not in any cluster, so assigned + unassigned = total. |
The sweep is safe to re-run: locked scopes are skipped, fresh scopes are skipped by fingerprint (a no-op run does almost nothing), and a Postgres advisory lock ensures overlapping triggers — or the daily scheduler job that calls the same routine — never run twice at once. An "empty-but-eligible heal" guard forces a rebuild of scopes that have eligible villages but zero stored clusters despite a matching fingerprint.
4b. Worked example - KHOWANG / Goatery / Cluster 1¶
A concrete cluster from the current pilot data:
| Field | Value |
|---|---|
| Block | KHOWANG (DIBRUGARH) |
| Commodity | Goatery |
cluster_num |
1 (sequential, 1..N within the block-commodity scope) |
cluster_id |
KHOWANG-Goatery-02b1321b (stable hash; reuse in CSV round-trips) |
| Total members | 35 |
| Villages | 3 |
| Max pairwise span | 1.398 km |
The "Why this is a cluster" panel walks through each check using these numbers:
| Rule | Default range | This cluster | Outcome |
|---|---|---|---|
| Total members in funding band | 30 – 150 | 35 | ✓ |
| Max pairwise span ≤ radius cap | ≤ 5 km | 1.398 km | ✓ |
| Every village has ≥ 6 interested members (LEAF-42) | ≥ 6 | smallest is 8 | ✓ |
The 2–4 village band was removed in LEAF-43 — a six-village cluster within 5 km and inside the funding band is now valid.
Now contrast with two edge cases captured by Passes A and C:
- Pass A example. If one Goatery village in a block had 274 interested members, Pass B would have rejected it (any neighbour would push the total past 150). Pass A produces a one-village cluster with
total_members = 274,village_count = 1,max_span_km = 0. The "Why this is a cluster" panel flags the member-band check as!rather than✓and the comment in the cluster popup notes the single-village exception. - Pass C example. A 4-member Goatery village far from any seed is left over by Pass B. Pass C asks: is there a cluster centroid within
2 * 5 = 10 kmwhose member, village-count, and recomputed pairwise span all still pass after absorbing this village? If yes, append and bumptotal_membersby 4. If no, the village is handed to Pass D, which surfaces it as a provisional cluster (single-village, below the funding floor) so it still appears on the map for review.
The whole cycle is visible in the Cluster Planning workspace - pick KHOWANG, Goatery, hover a ring to see the count, click for the rules panel.
5. Edit cycle (CSV download → edit → re-upload)¶
Per Faiz's call: "we're not going to give them an interface to change it. The same way we have done our backend data upload thing." The proposed clusters from the algorithm are a starting point - block coordinators can override them based on local knowledge (a mountain between two villages, social tension, road conditions) by editing a CSV and re-uploading.
GET /api/clusters/export.csv?block=KHOWANG (whole block, all commodities)
│ row-per-village CSV. Columns:
│ cluster_code, cluster_name, cluster_num, cluster_id, commodity,
│ district_name, block_name, gp_name, vill_name, lat, long, members,
│ pashu_sakhi, block_coordinator, district_coordinator
▼
Open in Excel
┌─ split a cluster (give rows a new cluster_num)
├─ merge two clusters (give the rows the same cluster_num)
├─ move a village (change its cluster_num to the target's)
├─ drop a village (delete the row)
├─ add a brand-new village (append a row with vill_name + lat/long;
│ coordinates are accepted as-is when the name isn't in the master — LEAF-44)
├─ set cluster_name (optional display-name override for the auto cluster_code)
├─ fill in pashu_sakhi / block_coordinator / district_coordinator
└─ change member count (rare; e.g. correcting a survey error)
│
▼
POST /api/clusters/import?block=KHOWANG (raw CSV body or multipart file)
│ replaces only the commodities PRESENT in the file (commodity-safe);
│ recomputes total_members, max_span_km, centroid; sets locked=True
▼
GET /api/clusters?block=KHOWANG&commodity=Goatery (verify)
Grouping is driven by the friendly cluster_num (1, 2, P1…), not the internal cluster_id: each existing number maps back to its stored cluster, a new number mints a new cluster, and a P prefix marks the provisional tier. cluster_code is display-only and ignored on import.
Re-upload as many times as needed. Each upload is a full replace within the commodities it contains — the block's other commodities are left untouched, so re-uploading a single-commodity "view" export is safe. Import sets locked=True on the imported clusters, so they are then protected from auto-regeneration (see §4a). This matches the existing LEAF backend data-update flow — per Faiz, there is deliberately no in-app cluster editor.
6. Cluster report card¶
Once a cluster exists, GET /api/clusters/<id>/report returns the cluster plus every LEAF block-level variable for the parent block - soil, water, climate, infrastructure, people, livestock, land/agri (the same dataset that powers /api/blocks).
This mirrors slide 7 of the deck: one summary view per cluster that surfaces all relevant block context. No new variables were added - we re-use existing LEAF data and join on block_name.
7. Infrastructure overlay¶
POIs (vet centres, pharmacies, input shops, milk-collection centres) live in their own table, uploaded via CSV - same edit-via-CSV pattern as the cluster data. See the Infrastructure API page.
The interesting query is GET /api/infrastructure/nearest?cluster_id=<id>&type=vet_centre&n=3 - returns the closest POIs to a cluster's centroid with distance_km attached. Used on the cluster report card to answer "what's the nearest vet centre for this dairy cluster?"
8. Finalisation and the production-tool handshake¶
The clustering work is one half of a two-tool integration:
| Tool | Built by | Role |
|---|---|---|
| LEAF DSS | Us | Forms clusters, surfaces block context, hands the cluster definition to the production tool. |
| Production tool | Partner team (in development) | Per-cluster login, day-to-day operations (Pashu Sakhi tracking, output recording), produces aggregate dashboards. |
Once cluster edits stabilise, an admin calls POST /api/clusters/<id>/finalize on each cluster. Only finalised clusters appear in the outbound feed GET /api/production-tool/clusters - the contract the production tool consumes.
After the production tool runs for a while, it sends back aggregated metrics via POST /api/production-tool/dashboard/<id>. The payload is an arbitrary JSON blob (e.g. {"period": "2026-Q1", "eggs_produced": 12450, "meat_kg": 320.5}); LEAF DSS stores it as-is and surfaces it on the cluster report card. Schema is intentionally open - the production tool may evolve its output without coordinated releases.
Per the call, the production tool's own user-level data (which Pashu Sakhi did what for which household) stays inside the production tool. We only exchange aggregates.
9. User interface - Cluster Planning workspace¶
The clustering workflow has its own UI surface, separate from the existing block-detail dashboard.
URLs¶
| Path | When to use |
|---|---|
/<district>/<block>/clustering |
Nested under the block detail view. Reached by the "Cluster Planning" button on the block page. Shareable. |
/clustering/<block> |
Standalone - same workspace, no district prefix. Useful when you only know the block name. |
/clustering |
Standalone, defaults to the first block that has village data ingested. |
/clustering/<block>?commodity=Goatery |
Pre-selects a commodity on load. |
The "Cluster Planning" button only appears on a block detail page when that block has village data ingested (matched case-insensitively against the Block_name in the LEAF shapefile).
What you see¶
| Element | Purpose |
|---|---|
| Block boundary outline (dark blue stroke, faint teal fill) | Spatial context - the hard wall that constrains clustering. |
| Village dots (~275 in Khowang) | One per GPS point. Hover → name, member counts per commodity. Grey until a commodity is picked; then interested villages take that commodity's colour and dot size scales with members. |
| District + block dropdowns in the header | Switch context without leaving. Only districts/blocks with village data are listed; if the district has just one such block, the dropdown collapses to a static label. |
| Commodity dropdown | Pick one of the six commodities. Defaults to "Show villages only". |
| Cluster ring | Single neutral-blue containment circle centered on the cluster centroid. Radius bounds all member villages with a small padding. One colour for every cluster; identity comes from the number, not the colour. Overlaps between rings are expected. |
| Cluster numbering | Each cluster gets a sequential cluster_num (1, 2, 3...) within the (block, commodity) scope. The hash cluster_id is kept as a faint subtitle for CSV cross-reference. |
| Cluster search pill (toolbar) | Type a cluster number or hash. Live border colour for match / no-match; Enter pans, opens the popup, and flashes the ring stroke. Esc clears. |
| Centroid ✓ marker | Only on finalised clusters. Proposed clusters have no marker (less visual noise). |
| Cluster popup (on click) | Cluster number + ID, members, span, village list, status, Finalise & publish / Unfinalise button. |
| Side panel - cluster summary | Members, villages, max span, status, assigned Pashu Sakhi / block coordinator, the full village list, Other commodities in these villages (aggregated from the same village set), and Why this is a cluster with a tick or ! per rule. |
Toolbar - Map key |
Decodes the ring, village dot size, and the ✓ marker. |
Toolbar - Download block CSV / Upload CSV |
The edit cycle. Download block CSV exports the whole block (all commodities). Upload CSV replaces only the commodities present in the uploaded file — the block's other commodities are left untouched, so a partial upload is safe. |
Toolbar - Download commodity (view) |
A filtered export of just the selected commodity, for viewing/reporting. Safe to edit and re-upload too — only that commodity is updated. |
Toolbar - Regenerate |
Admin-only. Hidden by default; appears when ?admin=1 is in the URL or localStorage.leaf_admin=1. The POST endpoint itself returns 403 without the same flag. |
? button in the header |
Opens the multilingual workflow help (EN / हिं / অস). |
? button inside cluster popups |
Opens the same workflow help overlay (so the popup itself stays compact). |
Multilingual help¶
The ? overlay has tabs for English, Hindi (हिं), and Assamese (অস), with a 6-step walkthrough of the whole workspace and a yellow caveat box noting that the Hindi/Assamese strings are AI-drafted and need a native-speaker review before final rollout. Source strings live in leaf_flask/static/js/clusters.js under WORKFLOW_HELP.
Loaders¶
Async actions (cluster fetch, village load, regenerate, upload, finalise) show inline spinners in the toolbar summary line so the user gets feedback during slow Postgres round-trips.
10. Persistence and deployment¶
| Concern | Approach |
|---|---|
| Cluster store | Supabase Postgres, reached over DATABASE_URL (psycopg2, raw SQL, no ORM). Tables: clusters, cluster_villages (FK → clusters, cascading delete), cluster_generation (smart-refresh fingerprint per block+commodity), infrastructure. Schema in leaf_flask/schema.sql; flag/split changes applied via manual python run_migration.py migrations/NNN.sql. |
| Village seed | Read-only CSV at leaf_flask/data/villages.csv (committed, ~21,495 rows). Rebuilt by scripts/build_village_master.py; replaced by the ODK feed in production. |
| Connection pooling | psycopg2.pool.ThreadedConnectionPool (1-10 conns) in leaf_flask/db.py. |
| Env vars | DATABASE_URL (Supabase Postgres, IPv6) for the app; SUPABASE_URL + SUPABASE_SECRET_KEY (IPv4 REST/Storage) used only by nightly backups. Loaded from .env locally; set in the Render dashboard for production. |
| Why a DB | Render's local disk is ephemeral - file-based persistence (clusters.json) would reset on every redeploy. Postgres survives. (The service runs on the Render standard plan — no spin-down — but the disk is still not durable.) |
11. Configurable parameters¶
The cluster constraints are server-side defaults but request-time overridable via the regenerate endpoint:
These live in DEFAULT_PARAMS in clustering.py:
| Parameter | Default | Source / role |
|---|---|---|
min_members_per_village |
6 | LEAF (LEAF-42) — "<=5 excluded, >=6 kept". Per-commodity candidate filter. |
min_cluster_members |
30 | Government — fundable member floor. |
max_cluster_members |
150 | Government — member ceiling. |
max_radius_km |
5.0 | Government — max pairwise span (km) across a cluster. |
emit_provisional |
true |
Surface below-floor groups as flagged provisional clusters (Pass D) instead of dropping them. |
provisional_min_members |
1 | Relaxed member floor used only for provisional clusters. |
rebalance |
false |
Pass E rescue — off by default; changes output broadly, enable deliberately and bump ALGO_VERSION. |
The pre-LEAF-43 min_villages_per_cluster / max_villages_per_cluster band (2-4) was removed — clusters are bounded by member range + 5 km span alone.
The government numbers come from the budgeting rule: each fundable cluster is allocated a recurring grant, so the member band needs a hard floor and ceiling. Faiz expects to iterate on these before the final freeze.
12. What's not yet implemented¶
Tracked for follow-up:
- State and district choropleth UI: backend
/api/villages/aggregateis live but the top-level dashboard map doesn't paint districts/blocks by member counts yet. The Cluster Planning workspace itself is at block scale and doesn't need it. - Authentication on the production-tool endpoints: currently open. Should add an API key header before the partner team integrates live.
- Native-speaker review of Hindi / Assamese help copy: the
WORKFLOW_HELPstrings inclusters.jsare AI-drafted and flagged with a translation caveat in the help overlay. Replace with reviewed copy before final rollout. - Parameter-tuning UI: the cluster constraints (member band, radius, provisional/rebalance toggles) are tunable via the API only; the Regenerate button uses defaults. Add an inline form when users hit "0 clusters formed" (e.g. for Dairy in Khowang).
- PostGIS / earthdistance: nearest-N infrastructure query is currently in-Python haversine. Acceptable at current POI volumes; switch when the dataset grows beyond a few thousand rows or when query latency becomes a concern.
- Persistent disk on Render: cluster + POI tables are in Supabase Postgres which survives redeploys, but Render's local disk is still ephemeral - anything written to
data/is regenerated rather than persisted. Fine today; revisit if we add file uploads. - AI advisory layer: slide 9 of the deck. Out of scope for the first iteration - revisit after the cluster pipeline is live.
13. Reference implementation¶
| File | Purpose |
|---|---|
clustering.py |
Pure-Python algorithm; no Flask/DB dependency. |
villages.py |
Village seed loader + Postgres-backed cluster store. |
infrastructure.py |
POI store + nearest-N query. |
db.py |
Postgres connection pool. |
schema.sql |
Idempotent DDL - runs once against a fresh Supabase project. |
app.py |
All HTTP routes with Swagger YAML docstrings. |
templates/_cluster_planner.html |
Workspace HTML partial. Included only by clustering.html (the standalone /clustering page). The pre-LEAF-48 dashboard modal that also included this partial was removed; the planner now lives only on its own page. |
static/js/clusters.js |
All workspace logic - map setup, dropdowns, popups, CSV cycle, finalise, multilingual help. |