Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
1

Embedding the Sky

from John Wiseman [alt+shift+b] in technology

Two projects at the intersection of geospatial data and AI. First, a semantic search engine for aerial imagery, fine-tuned on OpenStreetMap-tagged data. Second, WARP—a custom neural model that learns embeddings of aircraft flight trajectories, trained on roughly a million aircraft tracks. Given any flight path, WARP can instantly retrieve the most similar trajectories from history. It enables applications from OSINT and anomaly detection to safety analysis and flight planning. The same latent structure that made word2vec transformative for language now becomes available for aircraft behavior. Presented at Spec.LA on December 18, 2025. I'm going to quickly show two things tonight. The past few years I've been working mostly in geospatial, aviation, and AI, and that's what these are. One is geospatial semantic search and the other is a foundation model for aircraft. I believe they're both extremely valuable and useful. # Both projects are about embeddings. Embeddings are points inside a latent or conceptual space. Parts of that space that are conceptually similar are closer together. There are neural models that will generate an embedding of an image or text or other things. With the right model, an embedding of a photo of a golden retriever is close to the embedding of the text "golden retriever" and far from the embedding of an image of an aircraft boneyard. # I showed an early version of PIMINTO at Spec a long time ago. I've refined it a lot since then. It uses a model that's fine tuned on satellite imagery and text describing the imagery based on OpenStreetMap tags. I'm going to give a live demo of the new site, which is public. piminto.obliscence.com | Demo video # Next is WARP, which is a model I'm building to create embeddings of aircraft paths. Back to aircraft, big surprise. # I trained the model on about a million aircraft...
27th Feb 2026

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from John Wiseman

Comparison of Models Used for Aviation Briefings

I have a collection of prompts and tools that I give to LLMs to generate daily aviation briefings. The briefings are supposed to be a summary of everything interesting that’s happened in the air in the past several hours, either globally or in a region of interest. The model should contextualize aircraft activity in terms of behavioral analysis, historical patterns, geography, and current events: The fire west of Piñon Hills stood at roughly 2,700 acres and 0% containment in the latest reports, with cooler overnight conditions helping crews strengthen lines. This morning’s air picture is recon and rotary rather than Saturday’s tanker parade: N8PQ, the Aero Commander 690A, is back overhead at 8,000 ft, K-MAX N107MW is working the fire at 4,200 ft, Bell 205 N33HX is staging at Fox Field, and Tanker 104, an Erickson Aero Tanker MD-87, sits on the ramp at San Bernardino International. Writing a worthwhile briefing is a challenging task. It’s agentic, with models using 15-20 tools including querying databases, searching the web, and writing and executing scripts to process data. It also requires judgment from a model on what to include in the briefing and how to put together separate pieces of data to tell a story without overstating a conclusion. The agents filter through a large amount of data looking for interesting items, find corroborating sources, take screenshots, and edit it all into a short (less than 6000 characters) report. I’ve tried Claude Opus 4.8, Fable 5, OpenAI GPT-5.6-Sol, and Kimi K3 on this task. The tl;dr if you’re paying retail API token prices: Opus 4.8 is cheap, fast, and its output is good. Kimi K3 costs about the same as Opus 4.8 and its output is of similar quality (it’s saved by the fact-checker) but it takes 5-6x as long as Opus 4.8. Fable 5 costs 2-5x as much as Opus 4.8 or Kimi K3. I like the feel of its output best, but it needs to be reined in by the fact-checker almost as much as Kimi K3. GPT-5.6-Sol is cheap and fast and makes few mistakes, but its briefings are too short and boring. I think GPT-5.6-Sol is the smartest model available at the moment, but for this task Fable 5 does better, and Opus 4.8 does almost as well for far cheaper. Some numbers The initial prompt for the task is about 25 K - 30 K tokens, depending on whether the briefing is for the world or just Southern California. (All token calculations are done with the OpenAI tokenizer; others may be different.) Runbook Tokens briefing-runbook-common 16,516 briefing-runbook-world 13,635 briefing-runbook-socal 8,039 The custom tools to give the models access to aircraft and geospatial data, and the guides on how to best use them consume another 50 K tokens. Custom MCP Tokens MCP Description 433 Tool Descriptions (10 tools) 3533 Resources (13 documents) 45713 I’ve done six briefings with Fable 5 and two briefings with each of the other models. The following table lists time and cost for a single briefing, for each model. Time includes the total time for research, review, corrections and posting. Costs are API-equivalent estimates based on current pricing with token caching included. Model # Briefings Median time (minutes) Median cost Opus 4.8 2 24.8 $10.06 Fable 5 6 31.5 $30.33 Kimi K3 2 132.4 $10.43 GPT-5.6-Sol 2 40.0 $15.63 After a model writes the first draft of a briefing it sends it and the supporting material to an adversarial fact-checker powered by GPT-5.6-Sol. The model then rewrites the report based on the fact-checker’s findings. For each briefing I classified the fact-checker’s findings into “major”, more substantive corrections, and “minor”, softer issues. The table below shows the median number of writer tool calls and the mean number of corrections per report for each model. Model # Tool calls Major corrections Minor corrections Opus 4.8 66 0.0 4.0 Fable 5 93.5 2.5 7.0 Kimi K3 125 2.5 7.5 GPT-5.6-Sol 116 1.0 2.0 The GPT-5.6-Sol reports used separate GPT-5.6-Sol reviewer sessions, following the same adversarial process as the other models. The review step added a median $1.95 worth of GPT-5.6-Sol tokens to the cost of a report, and review costs are already included in the costs listed above. Report quality Opus 4.8 Opus 4.8 did a good job of creating rich reports with strong themes, and it showed good editorial judgment by removing weak leads not supported by further research. As an example, it might lead with a developing emergency: N969WR … was cruising at FL450 across the Oklahoma/Texas panhandle when it squawked 7700 at ~2204Z and began a continuous descent, averaging around 2,300 ft/min. It combined that lead with a large firefighting mobilisation, an EA-37B, an E-6B, allied tanker movements and forward-looking airspace notices. This was varied and interesting without feeling like a list of unrelated aircraft. The fact-checker didn’t refute any of Opus 4.8’s conclusions, and didn’t find any unsupported claims. Corrections were mostly wording changes to avoid implying unwarranted precision. Fable 5 Fable 5 produced the most detailed and ambitious reports. It was strongest when it could connect several observations into a larger story. For example, the 15 July world report described a shared GPS spoofing pattern near Smolensk: Three airliners (Turkish, Belavia, Air Serbia) each plotted flying an impossible 1.2-nm, 55-kt circle around the same fixed point southwest of Smolensk while MLAT put the real aircraft 400 km away. The 14 July SoCal report turned an anonymous military track into a strong lead: A silent visitor working W-291 — ae685e, a US military hex with no callsign, came down from Oregon overnight at FL270 and has flown a broad 21,000-ft circuit off the San Diego/northern Baja coast for hours. Fable 5 also followed stories across several days. It tracked tanker relays, airlift movements, range activity and unusual aircraft between reports. Fable 5’s reports were detailed, visual, and interesting. They also needed significant corrections, an average of 2.5 substantive and 7 minor problems per report. Its main weakness was overprecision. Codex regularly corrected: exact start and end times track boundaries aircraft counts claims about every member of a group statements that joined events across an anchor time mission interpretations that went beyond the observed movement An example of a significant error (that GPT-5.6-Sol let pass but that I found on review) was its report on a Russian Be-200 amphibious aircraft. Its published heading said: A Russian EMERCOM Be-200ChS scooping the Donbas. The report described the aircraft as working “a 300-km chain of fire sites”, but when I saw the map that seemed implausible. I asked Fable 5 about it, and eventually it posted an update: The evidence now says ‘repeated brief landings at unmapped sites, purpose unknown’. I still don’t know what that aircraft was doing, but it’s an example of Fable 5 going significantly off course. Kimi K3 Kimi K3 is really slow, and the fact-checker had to correct it more than any other model, but the quality of its (corrected) output once it was finally done was excellent–as good as Opus 4.8. KC-135 declares an emergency over the Irish Sea, home safe at Mildenhall. Fox Field staged and scattered — seven fire aircraft on the ramp on the fire squawk all morning; the 737 Fireliner and RJ-85 launched ~10am and ferried north without a drop run. There’s just no reason to use Kimi K3 since it costs as much as Opus 4.8, is about as good, but much slower. GPT-5.6-Sol GPT-5.6-Sol shows so much restraint that its briefings are boring: dry, no flavor, and not particularly interesting. LATTE18 / N650RX, a trustee-registered Global 6500, flew roughly 4 broad clockwise circuits at FL360 from 01:28 to 02:14Z, then departed southwest. The direct ADS-B track measured about 38 by 16 nautical miles. Public aviation reporting associates this tail with SNC’s Army ATHENA-S fleet; SNC confirms its 2 ATHENA aircraft support US Army airborne ISR, but SNC does not name the tails. The same offshore block appears repeatedly in the recent archive. I generally like GPT-5.6-Sol but its briefings were just too dull.

18th Jul 2026 • 1 votes

More in technology

FLIP Fluid on Flip Dots

[Hardware] Electromechanical Fluid Simulation

2 days ago • 1 votes
Is This A Joke? In The Auth Header? (F5 BIG-IP UnAuth Heap-Overflow to RCE CVE-2026-94127)

Well, well, well, well, well, well, well, well, well, well, well, well, well, well, well. We're back. Sorry. We've been watching the onslaught of vulnerabilities flood the internet. Every man, dog, and their grandmas (apparently?) are now using LLMs to find and reproduce vulnerabilities - it’

3 days ago • 1 votes
The Reason You Prohibit Things

You want less of them. That’s the reason. You may find that it’s too hard to stop people from doing the thing, literally blood, sweat, and tears trying to prosecute people, but that’s a different thing.

3 days ago
Solitaire Alone Together

Solitaire Alone Together I made a new game. It's called Solitaire Alone Together. It's Windows 98 solitaire, but you can play with everyone else on the internet. Read the full post on my blog! Here's a raw link, if you need it: https://eieio.games/blog/solitaire-alone-together

5 days ago • 1 votes
All The Ways I Broke My Website

This post is a living diary of all the times I messed up something with my website in a funny way. I value those who have the confidence to own their mistakes and share the learning with others, and so this is me doing just that! That Time I Accidentally Made a Tarpit That Time I Accidentally Made Really Large Headers That Time I Accidentally Made a Tarpit Back to Top A "tarpit" is an unofficial term used in computing to describe an intentionally slow response to a request. In these modern times many people are using tarpits as a way to combat the relentless theft of data by AI companies, although there's little to no evidence of that actually being in any way effective. I don't use tarpits, at least not intentionally, but there was that one time when I accidentally created a tarpit and trapped all visitors in it. As I've shared previously, I refuse connections from IP addresses that are blocked or belong to a blocked subnet, and I enforce this firewall during the TCP handshake. The logic here is straightforward: there's no reason to waste resources doing a TLS handshake, accepting an HTTP request, and then rejecting the connection if I already know I'm going to reject it at the earliest step. At the time, the code worked like this: the HTTP server would repeatedly call the Accept() function below expecting a new connection. I've added some comments to help explain the logic. func (l *firewallListener) Accept() (net.Conn, error) { // Accept the connection from the TCP listener. This blocks until there is a connection to accept or the listner was closed. conn, err := l.l.AcceptTCP() if err != nil { return conn, err } // Separate the IP address out from the remote address (which includes the port) ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } // Check if it's blocked, if so close the connection and return a refuseError if IsBlocked(ip, true) { conn.Close() return nil, &refuseError{} } // Otherwise return the connection on to the HTTP server return conn, nil } If the incoming connection was from a blocked IP then I'd return a refuseError. I need to use a specific error interface because the HTTP server will halt if it encounters a non-temporary error from the call to Accept(), so I need to return an error that satisfies the definition of a temporary error. I defined refuseError like this: type refuseError struct{} func (e *refuseError) Error() string { return "." } func (e *refuseError) Timeout() bool { return true } func (e *refuseError) Temporary() bool { return true } func (e *refuseError) Is(err error) bool { return err == context.DeadlineExceeded } This did accomplish the goal of rejecting connections before the TLS handshake for blocked addresses, but it had one really unintended and difficult to track down side-effect. Accepting connections is done serially, after which servers typically then process that request on a dedicated thread (or in Go's case a goroutine). This means that any delays during the accept loop will block all incoming connection. What I had missed while reviewing the code for Go's HTTP server is that when it receives a temporary error from Accept() is that while it doesn't abort, it does sleep for up to a maximum of 1 second. This sleep blocks the entire server for all incoming connections. You can see a trimmed copy of the code that does this below, with some marks I've added which I will explain. // src/net/http/server.go // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. for { // (1) rw, err := l.Accept() if err != nil { if s.shuttingDown() { return ErrServerClosed } // (2) if ne, ok := err.(net.Error); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } s.logf("http: Accept error: %v; retrying in %v", err, tempDelay) // (3) time.Sleep(tempDelay) continue } return err } connCtx := ctx if cc := s.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic("ConnContext returned nil") } } tempDelay = 0 c := s.newConn(rw) c.setState(c.rwc, StateNew, runHooks) // before Serve can return // (4) go c.serve(connCtx) } At mark 1 the server calls the Accept() function, this is the exact function that I defined above where I might return a temporary error. At mark 2 it checks if an error was returned, and if so if that error is temporary. If there was a temporary error, at mark 3 it sleeps for an increasing amount of time up-to 1 second, otherwise, at mark 4 it processes the connection on a dedicated goroutine, which allows the server to accept the next connection. I'm not entirely sure why the Go developers added this sleep delay and the change when it was introduced doesn't provide any meaningful insight. Regardless, it caused significant latency connecting to my website when a flood of rejected requests was coming in. It just goes to show how important it is to write meaningful commit messages, because you never know when somebody might come back years later wondering "why was this done?". I sure home I don't come to eat those words later. Coincidentally, you can actually see this happening if you look carefully at one of the metric graphs I shared in my first post about my server's security model: Securing My Web Infrastructure. This is the graph I shared in that blog post and while I didn't know it at the time, the fact that these request spikes all cap-out at around 60 requests per minute was not a coincidence. These requests were not being made with a limit in mind, attackers rarely ever care about things like that, instead it the accidental tarpit I had created. The downside to this was that while the malicious requests were being rate-limited, all requests were being rate-limited, up to a point of taking so long they timed out. The Fix Fixing the issue was relatively straightforward enough. Instead of returning a temporary error to the HTTP server during the accept loop, just don't return anything at all and wait for the next valid connection. func (l *firewallListener) Accept() (net.Conn, error) { for { conn, err := l.l.AcceptTCP() if err != nil { return conn, err } ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } if IsBlocked(ip, true) { conn.SetLinger(0) conn.Close() continue } return conn, nil } } Now, when the HTTP server calls Accept(), the only time it returns is with a connection from an IP that isn't blocked, or if there genuinely is an error. No more sleep delays, no more excessive timeouts. That Time I Accidentally Made Really Large Headers Back to Top For about 10 years now all major browsers have support for a security feature known as a Content Security Policy or CSP. A CSP is an HTTP header provided by the server that instructs the browser on where it can load assets from, this could be scripts, images, stylesheets, fonts, etc. The objective of using a CSP is to prevent against injected HTML that tries to load assets, such as a malicious Javascript file, from a remote source. With so much user-provided content being available online, it's very possible for this to happen without an attacker compromising the entire web server. CSP protects against that by saying "scripts can only be loaded from these domains". That's a really simplified way of looking at it, anyways. My web server supports injecting the CSP header automatically, but before I go on I need to explain a little bit about the structure of my web server. When an incoming HTTP request is accepted (having passed all firewall checks and assertions), we look at the destination host for the request. This can either be the value of the Host header or as specified during the TLS handshake. We then look at a map of hosts to apps. Apps are just an interface that accept a few methods: type App interface { Cleanup() ReloadConfig() ServeHTTP(rw http.ResponseWriter, r *http.Request) Setup(dataDir string) error Shutdown() } One of the apps is the Proxy app, which is a reverse proxy - it accepts the incoming HTTP request and then proxies it on to another host. This is a very common design, especially with increasingly complex TLS setups. Because each app is unique to a host, and different hosts have different requirements for CSP rules, the proxy app includes a CSP preset that we use to build the header value, or skip it entirely. When the proxy app was going to copy an HTTP request to the downstream host, it would build the CSP header, however there was a slight bug... func (a *App) ServeHTTP(rw http.ResponseWriter, inRequest *ht2.Request) { // --snip -- if a.CSP != nil { a.CSP.ConnectSrc += " " + inRequest.Origin } CopyHttpRequest(inRequest, outRequest, rw, CopyHttpRequestOptions{ Origin: inRequest.Origin, Csp: a.CSP, Cors: a.CORS, AddHeaders: !a.SkipHeaders, UseHTTP3: a.UseHTTP3, InsecureTLS: a.InsecureTLS, }) } I'm really unsure as to what I was doing with the line to append to the ConnectSrc, but the impact is that I'm appending to a variable that lives on the App, rather than a variable that is per-request. This meant that every time there was a request to the app, any request at all, the origin would be appended to the header value. This went on for quite a long time unnoticed and unresolved, largely because I am constantly tweaking and tinkering with my web server, after all, it's how I made having a website fun again. Each time I restarted the server process, the header value would be reset, but only for it to continue to grow and grow. Eventually, after a period of being busy with other matters, the server process stayed running for long enough that the header value grew too large and HTTP clients began to reject it. There is no defined maximum for an HTTP header value, however most HTTP clients use 100KiB, which is perfectly reasonable, and this header value would continue to grow well beyond that. Diagnosing this issue turned out to be difficult as tools like Curl would fail with errors relating to entities being too large, but stopped short of saying what specifically. I eventually used openssl s_client to send an HTTP request by hand and observed my terminal window being filled with a domain name repeated thousands of times. Looking at the commit history, it was really unclear why I added the culprit lines of code. The commit message just says "Improved CSP support". It just goes to show how important it is to write - hey look it's those words I'm now having to eat! The Fix The fix was to just delete those three lines of code. Yup, it really was that simple, and fixing this bug actually made a larger positive impact than I had expected, as it was immediately clear when I fixed the bug by looking at outbound network bytes: So much traffic was being wasted on excessive header sizes. You might look at these mistakes I've made and think "wow, Ian, these are some obvious mistakes, I never would have made them!" to which I say "good for you!" with the utmost sarcasm and disdain. I enjoy making and refining software, and making anything means making mistakes along the way. Each time I make mistakes such as the ones above, I improve my skills of investigation, diagnosing, and repair. Skills that, judging by my peers in the industry, seemingly everyone is quickly willing to throw away because a robot does it "better" than you. Header Image: "Car accident on the Ffestiniog to Bala road. Nobody was hurt" by Geoff Charles, CC BY-SA 4.0, via Wikimedia Commons.

a week ago
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in