Notes

Notes

Short pieces on the decisions behind the work: the thresholds I had to pick, the things that failed, and the numbers that mean less than they look.

The words for water: reading Canada's landscape vocabulary out of 365,395 place names

Natural Resources Canada publishes the whole Canadian Geographical Names Database as a weekly CSV. It holds 365,395 officially approved names, and 224,924 of them, 61.6%, are attached to water features. Before Canada is a country of mountains or prairie or cities, it is, in its own naming records, a country of lakes and brooks and coves.

Northrop Frye thought the Canadian imagination was less troubled by “Who am I?” than by “Where is here?”. The names file is one long, extremely literal attempt at an answer. What interested me is that the answer is given in different words depending on where you stand, and that the file is big enough to draw the boundaries between those words.

The field that looks like the vocabulary is not the vocabulary

The CSV has a Generic Term column, which looks like exactly what I wanted. It is not. It is a translation of what I wanted.

Lac Saint-Jean is filed under generic term Lake. So are the other 64,000 lacs. The column normalises every name to a controlled English vocabulary, so querying it tells you how many bodies of standing water Canada has and nothing at all about what anybody calls them. The one column that appears to hold the words is the one column with the words taken out.

The words are still there, in the name strings, so they have to be read back out. That needs a rule about where a generic sits, and the rule is a property of the language rather than of the word: English puts the generic last, in Otter Brook, and French puts it first, in Ruisseau de la Loutre. So I scan the opening tokens for a French generic, and fall back to the final token for an English one. French wins the tie, which is what makes Baie du Grand Ruisseau read as a bay rather than as a stream.

That rule classifies 222,549 names, 60.9% of the database, into 68 generic words. It can be checked, because CGNDB’s own Generic Term is an independent opinion about the same feature. Agreement is 98.5% for lake, 98.8% for creek, 98.2% for brook, 97.0% for lac. It is 71.8% for pool and 60.5% for barasway, which are exactly the rare words this note is most interested in, so those two should be read as indicative rather than as counts.

There are more lacs than lakes

64,661 lacs against 58,923 lakes. The most Canadian noun in English is outnumbered in Canada’s own gazetteer by its French equivalent, by 5,738, and almost the entire margin sits in one province: 99% of lacs are in Quebec.

The reason is not that Quebec has more lakes than the rest of the country. It is that Quebec’s lakes have been surveyed and formally named at a density the Shield country of Ontario and Manitoba has not matched, and the effect is large enough to flip a national total. This is worth holding onto, because it is the standing objection to everything below: a word map made from a gazetteer is always partly a map of who did the surveying.

The brook line

Two words for a small watercourse, drawn from the same English:

brookcreek
total7,79523,020
Atlantic provinces97.3%0.0%
British Columbia1%47%

Not “mostly”, not “predominantly”. Of 23,020 creeks in Canada, the four Atlantic provinces hold none. The two words divide the country between them and the division is essentially total.

Use the head-to-head buttons to see it, and the filter to go looking for a particular word.

Cells are A5 pentagons at resolution 6, roughly 8,300 km² each, which is about the coarsest grain that still separates Cape Breton from the mainland. I have used H3 for this kind of aggregation before; A5’s cells are equal-area by construction, which matters more than usual over a country spanning 42 degrees of latitude, where hexagon area quietly drifts as you go north. Colour runs by decile, because the counts are heavily right-skewed and a linear ramp would leave nine tenths of the range unused.

pond is the other half of the same story. 82% of Canada’s 8,127 ponds are in Newfoundland and Labrador, where a pond is not a small thing: Newfoundland uses the word for bodies of water that Ontario would call lakes without hesitating. The word is doing different work, not describing a different size.

Words that belong to exactly one place

Sorting by how concentrated a word is, rather than how common, turns up the short list of generics that never travelled:

wordnameswhere
steady163Newfoundland, 100%
barasway61Newfoundland, 100%
fosse1,282Quebec, 100%
sunker40Newfoundland, 98%
tickle252Newfoundland, 96%
pool334Nova Scotia, 82%
stream164New Brunswick, 76%
drain472Ontario 61%, Manitoba 39%

The federal glossary of generics defines a steady as “still water of a brook or river, not apparently affected by stream flow”, and gives its French equivalent as fosse, “a part of a stream that is deeper and less fast-flowing than the surrounding waters”. Two words for the same stretch of quiet river. 163 of them in Newfoundland, 1,282 in Quebec, and no overlap whatsoever. The steady / fosse button on the map is the whole bilingual question in one picture: not a border, but two vocabularies that each stop dead where the other begins.

drain is the flat opposite and my favourite entry in the table. It is not folklore, it is drainage law: a ditched watercourse under Ontario’s and Manitoba’s municipal drainage regimes, formal enough to be named and gazetted. Two provinces have a legal category the others lack, and it shows up in the national names file as a word.

ToponymyCanadaOpen dataA5

Liminal Toronto: measuring threshold space as divergence from its neighbours

Victor Turner described liminal space as the threshold between one social state and another: neither here nor there, undefined, and generative because of it. Urban geographers have applied the term to physical space, to railway lands and waterfronts and former industrial margins, but almost always qualitatively. This is a working note on whether you can read it from data instead.

A POI dataset is a record of declared urban function. Each point says: this place is a café, a warehouse, a clinic. Zones with a settled character cluster on a few categories. Zones in transition resist that clustering. The obvious move is Shannon entropy over the category mix, and that is where I started. Two things went wrong, and both were more interesting than the original idea.

Forty-three percent of the data is benches

The first version used OpenStreetMap’s top-level keys as categories: amenity, shop, leisure, office. That is a tempting shortcut and it is wrong, because those keys are not a functional taxonomy. amenity accounted for 68% of all 74,314 nodes in the Toronto extract, and it covers restaurants, schools, benches and waste baskets equally.

Sorting by tag value shows what is actually in there:

OSM tagnodes
amenity=bench8,615
amenity=bicycle_parking5,120
amenity=waste_basket4,524
amenity=restaurant4,168
amenity=parking_space3,098
amenity=post_box2,439

There are more benches in Toronto’s OSM extract than restaurants. Street fittings total 43% of every point in the file. A cell containing a bench, a bin and a bike rack was scoring as maximally mixed, which is to say the map was substantially a picture of where sidewalks have been surveyed.

The fix is a functional crosswalk: 18 Tier-1 categories fed by explicit tag rules, the same approach AnythingPOI uses to reconcile OSM and Overture vocabularies. Street furniture is excluded outright, and shop=vacant (865 nodes) is held aside as its own signal, since an empty unit is evidence about transition rather than a category of use. What remains is 41,517 functional POIs with a mix that means something:

Tier-1 categoryshare
Food & Beverage27.3%
Retail21.6%
Personal Care & Services10.4%
Transportation9.0%
Healthcare7.0%

Mean entropy went from 0.36 bits to 1.88. The signal had been there the whole time, buried under municipal furniture.

Mixedness is not betweenness

The second problem was conceptual, and it is the one worth writing up.

Entropy measures how internally jumbled a cell is. Turner’s liminality is not that. It is the condition of being between two states, of belonging to neither of the things on either side. A cell that is half café and half warehouse is mixed. A cell sitting on the seam where a café district becomes a warehouse district is liminal. Those are different cells, and only the second one is what the theory is about.

So the better measure compares a cell to its surroundings rather than to itself: the mean Jensen-Shannon divergence between a cell’s category distribution and those of its six H3 neighbours. JSD is symmetric, bounded in [0, 1] with log base 2, and stays finite when a category is missing from one side, which matters on sparse cells where KL divergence would blow up.

Use the toggle to compare them. Mixedness is patchy and tracks commercial intensity. Betweenness picks out lines.

Two floors keep noise out of the reading. Cells need at least five functional POIs to appear, as subjects and as neighbours both: below that, entropy is a coin flip, and 37% of cells at this resolution hold exactly one POI. That cuts 14,246 cells to 2,491. Betweenness then needs at least two qualifying neighbours, because a cell compared against a single neighbour hits the maximum whenever the two happen to share no category, which puts noise straight into the top decile. That leaves 1,553 cells and drops spurious maximums from 2.2% to 0.7%. The honest reading of those numbers is that most of Toronto is not mapped densely enough to support the claim at all.

Colour runs by decile rather than linearly, because both measures are right-skewed and a linear ramp leaves most of the range unused. That was the bug in my first attempt: it normalised to 3 bits when nothing in the city exceeded 2.25, so the map rendered almost entirely yellow.

What it does not do

Coverage is the standing objection and it is not fully answered. A zone can look uniform because it genuinely is, or because nobody has mapped it, and divergence-from-neighbours inherits that ambiguity from the underlying data. The n≥5 floor and the excluded-furniture count in each popup are partial controls, not a solution. Conflating with Overture Maps would give an independent second opinion on coverage, which is the obvious next step.

Every measure here is also computed on a snapshot. OSM is live, and a liminality reading that cannot see time is describing the outcome of a transition without seeing the transition.

Where this goes

The temporal version is the real paper. OSM’s edit history records when each node appeared and how it was first tagged, so the category mix of a cell can be reconstructed for any past date. Betweenness should then trace the arc of redevelopment directly: a seam that moves across the map as one district’s character advances into another. Exhibition Place and the Portlands have run from industrial to fairground to cultural to residential-in-waiting, and the edit record should show those phases as a moving front rather than a static score.

The comparison case is a planned waterfront, somewhere designed as a single-use public amenity from the start. If liminality is a product of unresolved land-use succession rather than of water, a planned shore should show a low, flat divergence profile where Toronto shows a ridge.

And the null model is what would make any of it defensible: reshuffle category labels across cells while holding each cell’s POI count fixed, then ask which cells are more divergent from their neighbours than that chance arrangement allows. Without it, every claim on this page is an eyeball.

anythingpoiLiminalityOpenStreetMapTorontoUrban geographyH3

General capability barely predicts spatial reasoning in LLMs

I took the eleven top models on the Vellum leaderboard and asked them 46 spatial questions with machine-computed answers: GeoJSON axis order, great circle distance and bearing, UTM zones including the Norway and Svalbard exceptions, RFC 7946 winding order, and point-in-polygon on a concave ring. One run, temperature 0, through OpenRouter.

The correlation between general capability and spatial score is r = 0.19.

ModelScoreAnsweredOf answeredMean tokensVellum
GPT-5.6 Sol97.697.6100.071147.2
Claude Opus 595.7100.095.753264.7
Claude Fable 591.3100.091.3415
Claude Sonnet 591.391.3100.084457.4
Claude Opus 4.869.6100.069.63757.9
DeepSeek V4 Flash69.6100.069.616651.6
GLM 5.263.069.690.6153054.7
DeepSeek V4 Pro56.560.992.9210448.2
Kimi K345.767.467.7171556.0
Kimi K2.628.330.492.9312854.0
Gemini 3.1 Pro26.1100.026.1195

The lowest-ranked model on general capability came first. The highest-ranked came second. Below that the ordering scrambles entirely.

Three columns matter more than the score. Answered is the share that produced anything within a 4,000-token budget. Of answered is accuracy on those. Mean tokens shows why they differ: Kimi K2.6 spends 3,128 tokens per question and answers 30% of them, but is right 93% of the time when it finishes. It is not bad at geodesy, it runs out of room.

Reporting those as one number is a trap I fell into first time round. Capped at 200 tokens, ten of eleven models scored zero on distance. They had not failed, they had been truncated, and an empty response was being scored as a wrong answer. The resulting leaderboard ranked models by terseness and looked entirely plausible.

Axis order is near-solved: nine of eleven score 100. Bearing is not: four score zero. Nobody should be computing geodesy in a language model anyway, but plenty of agent frameworks now do. It is also why I assess reading code.

46 tasks, one run, $5.20. Ground truth computed, not written. Single run, so no variance estimate.

BenchmarksGeoAILLM

The curse of the .science domain: why this site moved to jameswil.com

This site used to live at jwilliams.science. It now lives at jameswil.com, because a domain that some readers cannot open is not a domain.

The reason is the TLD, not the site. .science was cheap at launch and got used accordingly, so it carries a poor reputation with the blocklists that corporate proxies, university filters and mail gateways buy in wholesale. Those lists work at the TLD level. Nothing on my site was ever assessed. Readers on several Canadian networks simply got a block page, and a CV link that returns a block page is worse than no link.

Mail is the sharper edge. A .science address in a From header is enough to score a message into a junk folder before anything else about it is read, which matters when the message is an application.

The lesson is dull and worth stating: a novelty TLD is not a saving, it is a deliverability decision made once and paid for repeatedly. Old links redirect, and the five references to the old domain still sitting in project pages are next.

WebDomainsMeta

POI conflation thresholds: why a 2% cross-source match rate is the honest answer

Two POIs sit 30 m apart with similar names. Same shop, or two shops? Every conflation pipeline answers that question and almost none of them publish the answer.

AnythingPOI, which fuses OpenStreetMap and Overture Maps into 22.7M points across six countries, uses three gates, all of which must hold: 50 m Haversine, agreement on Tier-1 category, and Jaro-Winkler name similarity of 0.85 on normalised names. Candidates are blocked first by H3 at resolution 11, which is the same parameter choice in a different costume. Across six countries the gates match 1.4% to 3.2% of records.

The number looks like failure. Loosening it is trivial: take the gate to 150 m, drop the category check, and the merged count climbs to something that reads well in a table. What you have actually done is fuse the barber and the newsagent that share a parade and a family name, and you cannot tell which of your merges are real.

A cross-source match is worth something in the confidence score precisely because it is rare. It means two independently built datasets agree. Inflate the rate and you destroy the signal that made the field worth adding.

Recall is what this costs. Transliterated names go unmatched, and so does a shopping centre whose centroid sits 200 m from its entrance. That is a deliberate trade, and it belongs in the documentation rather than in a footnote.

anythingpoiConflationOpen dataOpenStreetMap

Micro visualisations for spatial data: draw the geometry, not the number

A table of metres cannot say what a neighbourhood is shaped like. The k-ring that overshoots 400 m reaches furthest along six axes, so a shop 390 m away is inside or outside depending on its bearing. Inline SVG draws that at the size of the text, inside the sentence making the claim. Resolution 9 gives you for a 400 m walk, and resolution 11 gives you . Same dashed 400 m target, same 15 px, and only the second is the neighbourhood you asked for. Overshoot falls from 514% to nothing.

At 64 px there is room for the boundary. Real H3 disks at 51.48 degrees north, each scaled to its own footprint:

ResFootprintReachOvershootCells
72,457 m+514%7
8922 m+130%7
9702 m+75%19
10527 m+32%61
11401 m+0%217
12414 m+3%1,519

Read the dashed circle, not the outline. At resolution 7 the target is a speck inside the one cell you were given. By 11 it nearly fills a footprint that has stopped being a hexagon, which is what 217 cells bought.

Each is under a kilobyte, needs no charting library, and has fixed width and height, so it cannot shift the layout. currentColor serves both themes from one file. Best of all, the script that computes the table emits the glyphs, so the picture cannot drift from the numbers beside it.

A figure does not have to be a page wide to be a figure.

walkgridDatavizSVGH3

Choosing an H3 resolution for neighbourhood analysis: your k-ring is not the radius you asked for

Ask for a 400 m walkable neighbourhood, the standard five-minute walk, and H3 hands you something else. At resolution 9 you get 702 m. That is not a rounding error, it is a neighbourhood 76% too big.

The cause is that k is an integer and the grid step is fixed. A disk grows in whole rings, so its radius is wherever the grid happens to land, never the number you asked for. Coarse resolutions land badly.

Bar chart of the radius actually reached when asking H3 for a 400 metre neighbourhood at 51.48 degrees north. Resolution 7 reaches 2,457 m using 7 cells; resolution 8, 922 m using 7 cells; resolution 9, 702 m using 19 cells; resolution 10, 527 m using 61 cells; resolution 11, 401 m using 217 cells; resolution 12, 414 m using 1,519 cells.

Grown rather than estimated, at 51.48°N:

k = 1
while disk_reach(cell, k) < 400:
    k += 1

Resolution 9 reaches 702 m for 19 cells, resolution 10 reaches 527 m for 61, and only resolution 11, the resolution AnythingPOI blocks on, lands at 401 m, for 217. Measuring the disk a second way, as the radius of the circle of equal area, agrees: 769 m, 521 m, 371 m.

So each step costs roughly 3.5× the cells, and what it buys is boundary accuracy. It is not buying finer cells, which mostly encode precision your input data does not have.

Pick the resolution from the error you will accept on the boundary, then check the cell count you can afford. Choosing on cell size alone is how a 400 m study quietly becomes a 700 m one.

Cells are not equal-area, so these are numbers for 51°N. Rerun them at yours.

walkgridH3GISSpatial

Teaching web development when writing the code is the cheap part

A first-year student can now produce a working CRUD app in an afternoon. Assessing the artefact stopped being informative the moment that became true, because the artefact no longer evidences anything about the student.

What has not become cheap is diagnosis. Generated code fails in ordinary ways: a fetch that never handles the rejected promise, an auth check enforced in the client and nowhere else, an N+1 query that is invisible at 20 rows and fatal at 20,000. A student who cannot read the code cannot see any of it, and the model will not volunteer it. It is the same failure I measure elsewhere: confident output, no signal about whether it is right.

So the marks moved. On CMU422 and CMU529 the submission is still an application, but it arrives as a repository: commit history, a README that justifies the dependencies and the architecture, and a short written reflection on what broke, what the student changed their mind about, and what they still do not understand. A repo you cannot explain is a repo you did not build.

This is not a restriction on tool use, and policing that would fail anyway. It is a change in what counts as the work. Reading code critically was always the harder skill and we could previously get away with assuming it followed from writing enough of it. It does not follow any more, so it has to be taught directly.

TeachingWeb developmentLLM