<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Srdjan Popovic</title>
    <description>The latest articles on DEV Community by Srdjan Popovic (@srdjan_poppovic).</description>
    <link>https://dev.to/srdjan_poppovic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4084590%2F3827c69d-536d-4d50-a049-580636932240.jpg</url>
      <title>DEV Community: Srdjan Popovic</title>
      <link>https://dev.to/srdjan_poppovic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/srdjan_poppovic"/>
    <language>en</language>
    <item>
      <title>Eleven Terabytes and No Raster Database</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:53:56 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/eleven-terabytes-and-no-raster-database-3bll</link>
      <guid>https://dev.to/srdjan_poppovic/eleven-terabytes-and-no-raster-database-3bll</guid>
      <description>&lt;p&gt;DailyMeteo is about 11 TB of GeoTIFFs — 1 km daily temperature and precipitation for all land on Earth, back to 1961. There is no raster database anywhere in the system. No PostGIS raster tables, no tile server in the read path for point queries, no object store. A Django app reads files off a disk.&lt;/p&gt;

&lt;p&gt;That sounds like something we haven't gotten around to fixing. It isn't. Here's the reasoning, and the places where it bit us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two machines, one job each
&lt;/h2&gt;

&lt;p&gt;The archive is produced on one machine and served from another.&lt;/p&gt;

&lt;p&gt;The production machine is a big box — a terabyte of RAM, most of it in use — that does nothing but run the interpolation. Space-time kriging over a continent is not a workload you want sharing a server with anything user-facing, because it will happily consume every core for six hours and then a request times out for reasons no one will connect to weather.&lt;/p&gt;

&lt;p&gt;The serving machine runs the API, the frontend, the database, GeoServer, the workers, and the reverse proxy, all as a Docker Swarm stack.&lt;/p&gt;

&lt;p&gt;Between them, a &lt;code&gt;rsync&lt;/code&gt; that runs every 30 minutes. Two details in it are load-bearing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It pulls, it doesn't push.&lt;/strong&gt; The serving machine reaches into the production machine and takes what's missing. Nothing runs on the production box on our behalf except a remote &lt;code&gt;rsync&lt;/code&gt; under &lt;code&gt;nice -n 19 ionice -c3&lt;/code&gt;, with a bandwidth cap. If the transfer is slow, the interpolation doesn't care. A push would have put the scheduling decision on the machine whose whole job is to not be interrupted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It transfers incomplete dates.&lt;/strong&gt; It used to only take dates where all 24 rasters existed. The effect was that when one zone failed, the perfectly good rasters for the other five stayed on the production box and the API reported the date as empty. Missing rasters are missing either way; this way, the ones that exist are at least reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the filename is the index
&lt;/h2&gt;

&lt;p&gt;A point query needs to answer: for this coordinate, this variable, and these 400 dates, which files do I open?&lt;/p&gt;

&lt;p&gt;With files on a disk and a naming convention, that's string formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{var}_day_{YYYYMMDD}_equi7{_early|_late|}.tif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Work out which continental zone the point falls in, build 400 paths, open the ones that exist. No index to keep in sync, no ingest step, no migration when a year of backfill lands, no second system that can disagree with the disk about what exists. The pipeline finishes writing a file and it is &lt;em&gt;served&lt;/em&gt;, with no further action.&lt;/p&gt;

&lt;p&gt;What that buys is worth being explicit about, because it's easy to reach for a database out of habit. There is no state anywhere that can drift from reality. A file is either on the disk or it isn't. When we needed to know exactly what we could serve, the answer was &lt;code&gt;os.scandir&lt;/code&gt; over 24 directories — about a second for 580,000 entries — and it was &lt;em&gt;the truth&lt;/em&gt;, not a cached projection of it.&lt;/p&gt;

&lt;p&gt;What it costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No query planner.&lt;/strong&gt; "Every date where the July mean exceeded X anywhere in Europe" is not a question this shape can answer. It answers "give me these pixels from these files" very well and nothing else at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directories with 24,000 files.&lt;/strong&gt; Fine on ext4, and &lt;code&gt;scandir&lt;/code&gt; doesn't care, but &lt;code&gt;ls&lt;/code&gt; in a terminal will make you wait and any tool that stats every entry becomes the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The convention is the schema, and it's enforced by nothing.&lt;/strong&gt; One file written with a different suffix is a silent data bug, not a constraint violation. This is exactly how we ended up serving duplicate values for 3,624 days — a variant appeared that the read path's ranking didn't know about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Given the access pattern — read a handful of pixels from an arbitrary set of files, never scan or join — I'd make the same call again. The mitigation for the last point isn't a database. It's that every read path goes through one function that knows the convention, and that function is the only place the convention exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file format does the work a database would
&lt;/h2&gt;

&lt;p&gt;The reason reading raw files is fast enough is Cloud Optimized GeoTIFF.&lt;/p&gt;

&lt;p&gt;Each raster is &lt;code&gt;Int16&lt;/code&gt;, LZW-compressed, internally tiled in 512×512 blocks, with five levels of overviews baked in. A European tile is 8229 × 5588 pixels and 10.2 MB on disk.&lt;/p&gt;

&lt;p&gt;The tiling is the point. To read one pixel you read one 512×512 block, not 10 MB. To draw a zoomed-out map you read an overview level that's already there, instead of downsampling the full raster. The layout means a reader can seek to the bytes it needs, and everything downstream — the point query, GeoServer's mosaics, the polygon clip — gets that for free.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Int16&lt;/code&gt; instead of a float is a factor-of-two on 11 TB, which is worth roughly five and a half terabytes of disk. Temperature stored in tenths of a degree loses nothing anyone can measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a request actually does
&lt;/h2&gt;

&lt;p&gt;A point query is Django REST Framework, and the interesting parts are all about what &lt;em&gt;not&lt;/em&gt; to do.&lt;/p&gt;

&lt;p&gt;Requests are authenticated with an API key, priced in credits, and cached in Redis — a keyed lookup of an immutable historical value is the ideal cache entry, so the TTL is a week. Bulk exports don't happen in the request at all; they're Celery tasks that write a file and email a link, because "clip 20 years of daily rasters to this polygon" and "answer within an HTTP timeout" are incompatible requirements.&lt;/p&gt;

&lt;p&gt;The polygon pricing had a nice bug in it, and it's the kind that only shows up at the edges. Cost is computed from pixels times days. Pixels came from the mask of the clipped raster — except the mask counts pixels &lt;em&gt;inside the polygon&lt;/em&gt;, and a polygon over the ocean is full of nodata pixels that are inside it and contain nothing. Draw a box over the Atlantic and you'd be charged for a full raster's worth of nothing. The fix is one line — exclude nodata as well as masked — but the shape of the mistake is general: "how much data is here" and "how much of this rectangle is here" are different questions, and the second one is easier to compute, so it's the one you accidentally write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running R in someone else's browser
&lt;/h2&gt;

&lt;p&gt;The part of the system I find most interesting isn't on the server at all.&lt;/p&gt;

&lt;p&gt;There's an R environment on the site — a console, an editor, plots, tables — and it doesn't run R on our machines. It runs webR, R compiled to WebAssembly, inside the user's browser tab. Their code fetches from our API, computes locally, and renders locally. We never see what they ran.&lt;/p&gt;

&lt;p&gt;That was a deliberate call. The alternative is an RStudio-shaped service where users execute arbitrary R on our infrastructure, and that is a sandbox problem, a resource-limits problem, a queueing problem and a security problem, forever. Moving it into the browser makes all four somebody else's — specifically, the browser's, which is very good at exactly this.&lt;/p&gt;

&lt;p&gt;The bill comes as about 45 MB of WebAssembly and R packages, downloaded before anything runs. We warm the runtime up in the background as soon as the page loads, so by the time someone has typed a question the worker is usually alive. "Usually" is doing real work in that sentence, and the first visit on a slow connection is not a great experience.&lt;/p&gt;

&lt;p&gt;The second cost is subtler. webR's default communication channel uses &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and browsers only expose that to pages that are &lt;em&gt;cross-origin isolated&lt;/em&gt; — which means serving two specific headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that &lt;code&gt;require-corp&lt;/code&gt; also blocks every cross-origin subresource that doesn't explicitly opt in. Turn it on site-wide and the map's vector tiles stop loading and half the images on the marketing pages disappear. So it's scoped to the R pages only, which means the R runtime lives on an island with different security semantics from the rest of the app, and every asset it needs has to be reachable from that island.&lt;/p&gt;

&lt;p&gt;It's worth the trouble for one reason: &lt;code&gt;SharedArrayBuffer&lt;/code&gt; is the only channel that supports interrupting a running computation. Without it, a runaway &lt;code&gt;while (TRUE)&lt;/code&gt; can only be stopped by destroying the worker and booting a fresh 45 MB one. With it, there's a stop button that actually stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two failures worth stealing
&lt;/h3&gt;

&lt;p&gt;Both of these cost more time than they should have, and both look like someone else's bug until you find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx doesn't know what &lt;code&gt;.mjs&lt;/code&gt; is.&lt;/strong&gt; The bundled &lt;code&gt;mime.types&lt;/code&gt; maps &lt;code&gt;.js&lt;/code&gt; and stops there. Every ES module served straight off disk — including both worker entry points, the map's and R's — went out as &lt;code&gt;application/octet-stream&lt;/code&gt;, and the browser refused them: &lt;em&gt;"Strict MIME type checking is enforced for module scripts."&lt;/em&gt; Every other asset was fine, so the app looked healthy while two of its major features were dead.&lt;/p&gt;

&lt;p&gt;The fix is a line in the Dockerfile. The part worth copying is the second line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s|\(application/javascript[[:space:]]\+\)js;|\1js mjs;|'&lt;/span&gt; /etc/nginx/mime.types &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'js mjs;'&lt;/span&gt; /etc/nginx/mime.types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future base image formats that entry differently, the &lt;code&gt;sed&lt;/code&gt; silently does nothing and we're back to a broken worker discovered by a user. The &lt;code&gt;grep&lt;/code&gt; turns that into a failed build. A patch applied by pattern-matching someone else's file should always be followed by an assertion that it took.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A URL nothing would accept.&lt;/strong&gt; The assistant generates R against a helper with this signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time_scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time&lt;/code&gt; takes a comma-separated list of dates, as an alternative to &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt;. Asked for six years of daily data, the model enumerated every date into &lt;code&gt;time&lt;/code&gt;. That's 2,192 dates and a &lt;strong&gt;24,222-character URL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;webR failed with &lt;code&gt;problem writing module_download template in internet module&lt;/code&gt;, which reads like a webR bug and sent me looking in the wrong place. It isn't. Our own API returns 414 above roughly 8,190 characters — Apache's default &lt;code&gt;LimitRequestLine&lt;/code&gt; — so the same generated code, copied into RStudio, would have failed too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  700 dates ( 7,810 chars) → 400   (request accepted)
1,000 dates (11,110 chars) → 414 URI Too Long
2,192 dates (24,222 chars) → 414
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tempting fix is to teach the model not to do that. We fixed the helper instead. Before building any URL, &lt;code&gt;get_data&lt;/code&gt; now normalises &lt;code&gt;time&lt;/code&gt;: a contiguous run collapses into a single &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt; request, and a list with gaps is split into batches that keep every URL under 2,000 characters. The failing case went from 24,222 characters to 165, in one request, returning exactly the same 2,192 rows as &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt; would.&lt;/p&gt;

&lt;p&gt;The signature didn't change — it's the contract the model was trained against, and it's written into its system prompt. Only the body did.&lt;/p&gt;

&lt;p&gt;That distinction is the whole point. When a language model generates code against your helpers, the helpers are the place to be defensive, because they're the part you control and the part that doesn't need retraining. Prompt changes are a request. Helper changes are a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot
&lt;/h2&gt;

&lt;p&gt;The last thing this architecture taught me is that it degrades quietly, everywhere.&lt;/p&gt;

&lt;p&gt;A missing raster isn't an error; the date is just thinner. A failed source isn't an error; the day is produced with five zones instead of six. A transfer that skips an incomplete date isn't an error; the API simply has nothing for it. Every one of those is a reasonable local decision, and stacked together they mean the system can be substantially broken while every component reports success.&lt;/p&gt;

&lt;p&gt;The number that finally made it visible was the difference between two dates: the most recent day with &lt;em&gt;any&lt;/em&gt; raster, and the most recent day with &lt;em&gt;all 24&lt;/em&gt;. The first was five days back, which is normal and looked fine. The second was six weeks back.&lt;/p&gt;

&lt;p&gt;Nothing was alerting, because nothing was subtracting.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;DailyMeteo is at &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt;. The previous post covers what the data is and why every day is computed three times.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gis</category>
      <category>django</category>
      <category>webassembly</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We Publish the Data Before It's Correct. On Purpose.</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:52:13 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/we-publish-the-data-before-its-correct-on-purpose-4j13</link>
      <guid>https://dev.to/srdjan_poppovic/we-publish-the-data-before-its-correct-on-purpose-4j13</guid>
      <description>&lt;p&gt;Someone asks for yesterday's maximum temperature in Belgrade. We don't have it. We won't have it for another four days, and when we do have it, the number will be wrong — not badly wrong, but wrong enough that we'll replace it a month later, and replace it again a year after that.&lt;/p&gt;

&lt;p&gt;That's not a bug report. That's the design.&lt;/p&gt;

&lt;p&gt;I want to explain why, because it turns out the three-pass thing is the most consequential decision in the whole system, and every other piece — the API, the storage layout, the way a chart gets drawn — bends around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data actually is
&lt;/h2&gt;

&lt;p&gt;DailyMeteo is a gridded daily climate archive. Four variables — maximum, minimum and mean temperature, and precipitation — at 1 km resolution, for all land on Earth, every day since 1 January 1961.&lt;/p&gt;

&lt;p&gt;Some numbers, because the shape of the problem is mostly numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolution&lt;/td&gt;
&lt;td&gt;1 km, daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Period&lt;/td&gt;
&lt;td&gt;1961-01-01 → present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tmax&lt;/code&gt;, &lt;code&gt;tmin&lt;/code&gt;, &lt;code&gt;tmean&lt;/code&gt;, &lt;code&gt;prcp&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continental zones&lt;/td&gt;
&lt;td&gt;6, in the Equi7 projection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rasters per day&lt;/td&gt;
&lt;td&gt;24 (6 zones × 4 variables)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One European tile&lt;/td&gt;
&lt;td&gt;8229 × 5588 px, 10.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archive&lt;/td&gt;
&lt;td&gt;~11 TB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty-four rasters per day, roughly 24,000 days. That's around 580,000 GeoTIFFs, and it's why "just put it in a database" stopped being an option early.&lt;/p&gt;

&lt;p&gt;The projection deserves a note. Equi7 splits the land surface into seven continental zones, each with its own equidistant projection, so that a kilometre is actually a kilometre wherever you are. A single global grid can't do that — it either stretches at the poles or bunches at the equator. The cost is that "the world" is six separate rasters that don't share a coordinate system, and every query has to work out which zone the point falls in before it can read a pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  How one day gets made
&lt;/h2&gt;

&lt;p&gt;Weather stations don't cover the world. They cover the parts of the world that historically built weather stations, which is a very uneven map. Turning some thousands of point observations into a continuous 1 km surface is the actual work.&lt;/p&gt;

&lt;p&gt;The pipeline pulls station observations from several archives — OGIMET, GSOD, GHCN-D, ECA&amp;amp;D, MeteoManz — because none of them alone is complete, and each has a different idea of what "yesterday" means. On a normal day that's something like 18,000 stations queried for a single variable.&lt;/p&gt;

&lt;p&gt;Then, per day and per variable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fit a trend.&lt;/strong&gt; A linear model on three covariates: elevation from a DEM, topographic wetness index, and a geometric temperature trend — a deterministic function of latitude and day of year that carries most of the seasonal signal before any interpolation happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take the residuals.&lt;/strong&gt; Observed minus trend, at each station.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Krige the residuals in space &lt;em&gt;and&lt;/em&gt; time.&lt;/strong&gt; Not just "what do the neighbouring stations say today" but "what did this neighbourhood say yesterday and the day before", which matters enormously when a station is missing for a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the two rasters back together.&lt;/strong&gt; Trend surface plus interpolated residual surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Precipitation gets a second pass, because rain isn't like temperature. Temperature always has a value; rain is mostly zero. So it's modelled twice — first whether it rained at all, then how much, given that it did. Interpolating rainfall directly gives you a light drizzle over an entire continent, which is both wrong and hard to notice.&lt;/p&gt;

&lt;p&gt;None of this is my invention. The method comes from &lt;a href="https://doi.org/10.1002/2013JD020803" rel="noopener noreferrer"&gt;Kilibarda et al. (2014)&lt;/a&gt;, where Milan Kilibarda and co-authors established spatio-temporal regression kriging for global daily temperature at 1 km, and the interpolation runs through the &lt;code&gt;meteo&lt;/code&gt; R package developed by Milan Kilibarda and Aleksandar Sekulić. What's ours is the part that turns it into something that runs every night and answers HTTP requests. It is not fast. A single day, all zones, all variables, takes hours on a machine with a terabyte of RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same day is computed three times
&lt;/h2&gt;

&lt;p&gt;Here's the tension. Station data arrives late, and it arrives incrementally.&lt;/p&gt;

&lt;p&gt;The archive that gives you a station's observation within a day or two is not the archive that eventually gives you the quality-controlled version. Some networks publish a preliminary value and revise it. Some publish nothing for weeks and then backfill a month at once. If you wait until the data is final, "today's weather" is a year old.&lt;/p&gt;

&lt;p&gt;So we don't wait. Every day gets computed three times:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;early&lt;/strong&gt; — about four days behind, from whatever stations have reported. Runs nightly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;late&lt;/strong&gt; — about four months behind, once the monthly archives have settled. Runs monthly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;final&lt;/strong&gt; — the following year, on the fully quality-controlled record. Runs yearly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each pass overwrites nothing. The three versions sit side by side on disk, distinguished by a suffix in the filename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tmax_day_20200715_equi7_early.tif
tmax_day_20200715_equi7_late.tif
tmax_day_20200715_equi7.tif        # final, no suffix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API ranks them — final beats late beats early — and serves the best one that exists for the date you asked about. Right now that means anything before 2024 comes back final, and the last few weeks come back early. The transition is invisible in the response, which is either elegant or dishonest depending on your mood; I'll come back to that.&lt;/p&gt;

&lt;p&gt;The ratio is heavily in favour of finished data. In the European maximum-temperature series: 23,010 final rasters, 851 late, 585 early. Ninety-five percent of the archive is settled. It's the leading edge that churns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Three variants of every file, ranked at read time, is a rule that lives in exactly one function. Rules like that get broken by accident.&lt;/p&gt;

&lt;p&gt;We added the &lt;code&gt;late&lt;/code&gt; stage after &lt;code&gt;early&lt;/code&gt; and &lt;code&gt;final&lt;/code&gt; were already running. Somewhere in the code that assembles a time series for a point query, the ranking knew about two variants and the directory now contained three. The unknown file didn't lose the ranking — it wasn't in the ranking at all, so it came through as a separate, additional row.&lt;/p&gt;

&lt;p&gt;The result was a chart with two values for the same date. Not a crash, not an error, not a log line. Two dots where there should be one, on 3,624 days' worth of data, sitting there for however long it took someone to look closely at a chart.&lt;/p&gt;

&lt;p&gt;The fix is three lines. The lesson isn't about the three lines. It's that "prefer the best available version" is a &lt;em&gt;domain rule&lt;/em&gt;, and if it's implemented as an incidental sort somewhere in a view, it will drift the moment the domain gains a version. It belongs in one named function that every read path goes through, and adding a variant should be a change to that function and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can actually do with it
&lt;/h2&gt;

&lt;p&gt;The data is the product, but nobody wants an 11 TB tarball. What's on top of it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point queries.&lt;/strong&gt; Click a location, get a series. The API takes a latitude, longitude, variable, and either a range or a list of dates, and returns timestamps and values. Daily, monthly or annual, aggregated or as long-term means over the two standard climate normals — 1961–1990 and 1991–2020.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /meteo/v2/pq/?var=tmax&amp;amp;agg_level=agg&amp;amp;time_scale=day
    &amp;amp;from=2020-01-01&amp;amp;to=2020-12-31&amp;amp;lat=44.81&amp;amp;lon=20.46&amp;amp;api_key=…
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Areas.&lt;/strong&gt; Draw a polygon, get the aggregate over it, or export the clipped rasters. Large exports are priced by area and period and delivered by email when they're ready, because a polygon over Asia for a decade is not a request you answer inside an HTTP timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-term means and anomalies.&lt;/strong&gt; The monthly and annual aggregates and the two climate normals are precomputed — roughly 750 GB of them — so "how does this July compare to the 1991–2020 average" is a lookup, not a computation over 30 years of daily rasters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R in the browser.&lt;/strong&gt; There's an R environment on the site that runs entirely client-side, compiled to WebAssembly. You write R, it fetches from the API and plots, and your code never touches our servers. There's an assistant next to it that turns a question in English into R code against the same helpers. That one has enough engineering in it to deserve its own post, which is the next one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddable charts,&lt;/strong&gt; because half the time what someone actually wants is a temperature curve on their own page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd rather be honest about
&lt;/h2&gt;

&lt;p&gt;Two things about this design are genuinely awkward and I don't have clean answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seam is invisible.&lt;/strong&gt; A response doesn't tell you which pass produced each value. Ask for a series spanning 2023 to now and you get final data and early data in one array, with no marker. For a chart, fine. For a paper, not fine. Exposing the processing level per value is the obvious fix and it's the kind of obvious fix that stays on the list because nothing visibly breaks without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Available" and "complete" aren't the same thing, and we conflated them.&lt;/strong&gt; A date is only fully covered when all 24 rasters exist. When one source fails — and sources fail; a station archive returned zero rows out of 18,000 for several days recently — the pipeline still produces the zones it can. So a date can have 22 of 24 rasters: present, queryable, and quietly missing two continents.&lt;/p&gt;

&lt;p&gt;We were tracking "latest date with any raster". By that measure everything looked five days behind, which is normal. By the measure that matters — latest date with all 24 — we were six weeks behind and nobody had noticed, because no counter anywhere was counting that. Now there's an internal page that shows both numbers side by side, and the gap between them is the number I look at first.&lt;/p&gt;

&lt;p&gt;That's the recurring shape of this whole system, honestly. The hard part was never the kriging. It's that a pipeline which degrades gracefully also fails quietly, and you have to go out of your way to build the thing that tells you it did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The interpolation method and the &lt;code&gt;meteo&lt;/code&gt; R package behind it are the work of Milan Kilibarda and Aleksandar Sekulić at the University of Belgrade, Faculty of Civil Engineering.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;DailyMeteo is at &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt;. Next post: how 11 TB of rasters get served without a raster database, and what happened when we tried to run R inside a browser tab.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>data</category>
      <category>gis</category>
      <category>api</category>
    </item>
    <item>
      <title>Our Documentation Was Lying. The Model Believed It.</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:26:58 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/our-documentation-was-lying-the-model-believed-it-6n</link>
      <guid>https://dev.to/srdjan_poppovic/our-documentation-was-lying-the-model-believed-it-6n</guid>
      <description>&lt;p&gt;There is a variable called &lt;code&gt;slp&lt;/code&gt; — sea-level pressure. Our API documentation lists it as available. Our error messages list it among the valid options. Our fine-tuned model, asked about air pressure over Belgrade, will happily write you fifteen lines of R to fetch it.&lt;/p&gt;

&lt;p&gt;The request comes back &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It has been coming back &lt;code&gt;400&lt;/code&gt; the whole time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is
&lt;/h2&gt;

&lt;p&gt;We run &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt; — a daily meteorological archive for Europe, gridded at 1 km, running from 1961 to roughly five days ago. There's a chat endpoint where you ask a question in plain language and get back R code that queries the archive and answers it. Behind that sits a fine-tuned GPT model, trained on a few hundred question-and-code pairs.&lt;/p&gt;

&lt;p&gt;Last week I set out to retrain it on a better dataset. I expected to spend the day on hyperparameters. I spent it finding out that the model had been taught things that were not true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The training data was built from the documentation
&lt;/h2&gt;

&lt;p&gt;This is the part worth stopping on, because I suspect it is extremely common.&lt;/p&gt;

&lt;p&gt;When you build a fine-tuning set for "write code against our API", the natural move is to sit down with the API documentation and write examples from it. That is what had happened. Each example carried a system prompt describing what the API does, a question, and the R code that answers it.&lt;/p&gt;

&lt;p&gt;The trouble is that documentation is a &lt;em&gt;claim&lt;/em&gt; about a system, not the system. And nobody had checked the claim in a while.&lt;/p&gt;

&lt;p&gt;So before touching anything, I did the boring thing: I called the service and wrote down what actually came back.&lt;/p&gt;

&lt;p&gt;Three of its claims were wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 1: &lt;code&gt;slp&lt;/code&gt; is an available variable
&lt;/h3&gt;

&lt;p&gt;The data exists. There are 21,916 daily rasters sitting on disk, covering 1961 to 2020, Europe only.&lt;/p&gt;

&lt;p&gt;The API will not serve any of them. The allow-list in the view is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VARS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmean&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the check &lt;code&gt;var not in VARS&lt;/code&gt; guards every entry point. On top of that, the continent mapping points at a newer data folder that never received the pressure rasters at all. Two separate reasons for the same &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The best detail: the error message you get back still lists &lt;code&gt;slp&lt;/code&gt; among the available variables. The message is older than the list.&lt;/p&gt;

&lt;p&gt;The model had been taught to ask for it. Two examples in the training set did exactly that. So on any question about pressure, the model produced confident, well-formed, non-functional code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 2: "data from 1960 to 2024"
&lt;/h3&gt;

&lt;p&gt;Both ends wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1960&lt;/code&gt; returns &lt;em&gt;"There's no data for date range."&lt;/em&gt; The archive starts in 1961. And the far end isn't a year at all — the archive is kept near-real-time. Daily data run to about five days ago, monthly to the previous month, annual to the last complete year. Writing a fixed end year into a system prompt guarantees it will be wrong within twelve months, silently.&lt;/p&gt;

&lt;p&gt;While measuring this I found something genuinely useful: &lt;strong&gt;asking for a range outside the archive is not an error.&lt;/strong&gt; The API quietly clips.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ann  1901 → 1970   returns  1961 → 1970
ann  1961 → 2035   returns  1961 → 2025
mon  1961-01 → 2030-12   returns through 2026-07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changes what "correct" code looks like. Generated code doesn't need to know where the archive ends — it can ask wide and read the actual extent back out of the response. That's a pattern that never goes stale. Several of our training examples had been hardcoding an end year instead, which ages badly and quietly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 3: the timestamp format
&lt;/h3&gt;

&lt;p&gt;The docs said the returned timestamp is formatted like the input date. For aggregated data, true. For long-term means, not remotely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ann   "1961-1990"          "1991-2020"
mon   "05.1961-1990"       "05.1991-2020"
day   "25.07.1961-1990"    "25.07.1991-2020"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It carries the &lt;em&gt;climate period&lt;/em&gt;, not a date. Any code doing &lt;code&gt;substr(timestamp, 1, 4)&lt;/code&gt; to pull a year out gets nonsense. Some of ours did.&lt;/p&gt;

&lt;h3&gt;
  
  
  And the thing that wasn't documented at all
&lt;/h3&gt;

&lt;p&gt;Long-term means — the most semantically awkward corner of the API — had no description whatsoever. The rule, once measured, is simple: &lt;strong&gt;the year inside the date selects which climate period you get.&lt;/strong&gt; Pass &lt;code&gt;1961&lt;/code&gt;, get 1961–1990. Pass &lt;code&gt;1991&lt;/code&gt;, get 1991–2020. Omit the year, get both.&lt;/p&gt;

&lt;p&gt;Nobody had written that down. So across 23 calls in the training set, long-term means were invoked &lt;strong&gt;seven different ways&lt;/strong&gt;, two of them mutually contradictory. The model wasn't learning a convention. It was learning that there isn't one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subtler poison: examples whose answer the question doesn't determine
&lt;/h2&gt;

&lt;p&gt;This one I didn't expect, and it's the one I'd most like other people to check for.&lt;/p&gt;

&lt;p&gt;Consider this pair from the training set:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question:&lt;/strong&gt; plotting temperatures during autumn (September 1 to November 30) in Prague&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;code&gt;from = "1999-09-01", to = "1999-11-30"&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Why 1999? No reason. The question doesn't say. Whoever wrote the example picked a year.&lt;/p&gt;

&lt;p&gt;There were 28 examples like this. And here is why they matter beyond tidiness: &lt;strong&gt;the model cannot possibly predict the answer from the question.&lt;/strong&gt; No amount of training reduces the error on that example, because the target contains information the input doesn't.&lt;/p&gt;

&lt;p&gt;You can see it in the metrics. In the previous training run, the loss spikes that survived all the way to the final epoch — steps 223, 238, 249, 253, 260, still spiking at 0.46–0.66 while everything around them sat at 0.13 — were these. They're not a hyperparameter problem. They're irreducible.&lt;/p&gt;

&lt;p&gt;Worse, what the model &lt;em&gt;does&lt;/em&gt; learn from them is the behaviour: &lt;strong&gt;invent a year, say nothing.&lt;/strong&gt; In production that's a model quietly answering a different question than the one asked.&lt;/p&gt;

&lt;p&gt;The fix cost nothing and didn't change a single choice the code makes. We just made it say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"No period was specified in the question - using 1961 to 2020.\n\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same year. Now the answer is determined by the question, plus a disclosure the model can actually learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;Nine steps, but the shape is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the API.&lt;/strong&gt; Every boundary verified by calling the service. The result is one Python file that every other script imports — a single source of truth that is not the documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit against it.&lt;/strong&gt; A script that walks each example and flags what doesn't work, what wastes model capacity, and what's cosmetic. First run: 13 examples the API rejects, 28 inventing periods, and a training set split 135-to-62 between two different code formatting styles for identical tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix what's unambiguously wrong.&lt;/strong&gt; The 13 rejects, and 23 silent period choices turned into disclosed ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize style.&lt;/strong&gt; Half the set broke calls across multiple lines, half kept them on one — same task, double the tokens. That split was costing model capacity on a question with no informational value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill the gaps.&lt;/strong&gt; There was not a single example covering questions the API &lt;em&gt;can't&lt;/em&gt; answer — wind, humidity, forecasts. The model had been improvising. Now it answers "I don't have that, here's what I do have" — still as runnable R, because the contract is that every answer executes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the explanations from the code&lt;/strong&gt;, not by hand, so they stay in sync when the code changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split off a validation set.&lt;/strong&gt; 29 examples the model never sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preflight.&lt;/strong&gt; Refuse to upload if anything above regressed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Train, then choose.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The previous run had no validation set
&lt;/h2&gt;

&lt;p&gt;This is the single change that mattered most, and it's the cheapest one.&lt;/p&gt;

&lt;p&gt;The earlier job ran three epochs with no validation file. Its training loss fell nicely, from 0.38 to 0.16, and everyone was happy.&lt;/p&gt;

&lt;p&gt;Training loss falls whether the model is learning or memorising. Without a held-out set, those two are indistinguishable. You are looking at a number that goes down in both the good case and the bad case, and concluding things about it.&lt;/p&gt;

&lt;p&gt;With validation, this run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Epoch&lt;/th&gt;
&lt;th&gt;Training loss&lt;/th&gt;
&lt;th&gt;Validation loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.368&lt;/td&gt;
&lt;td&gt;0.285&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.133&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.131&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.071&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.038&lt;/td&gt;
&lt;td&gt;0.152&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Training loss keeps falling all the way to 0.038 — near-perfect reproduction of what it was shown. Validation bottoms out in epoch 2 and turns back up. Textbook. Invisible without the held-out set.&lt;/p&gt;

&lt;p&gt;One incidental finding: the platform's &lt;code&gt;auto&lt;/code&gt; hyperparameter selection picked a learning-rate multiplier of &lt;strong&gt;0.51&lt;/strong&gt; on the previous run and &lt;strong&gt;2.0&lt;/strong&gt; on this one. The only meaningful difference was that our examples got longer. If you rely on &lt;code&gt;auto&lt;/code&gt;, know that it can quadruple your learning rate because you added a paragraph to your system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then loss and behaviour disagreed
&lt;/h2&gt;

&lt;p&gt;Validation loss says take epoch 2. So I built a second evaluation that asks a different question: &lt;em&gt;does the generated code actually work?&lt;/em&gt; Each checkpoint answers the 29 held-out questions, and we measure whether R parses it, whether the API arguments are valid, whether it invents packages that aren't installed, and whether it discloses the period it chose.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;Validation loss&lt;/th&gt;
&lt;th&gt;Failures out of 29&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.131&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 3&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 4&lt;/td&gt;
&lt;td&gt;0.152&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly inverted.&lt;/p&gt;

&lt;p&gt;The explanation is that validation loss measures token-level similarity to a reference answer. It does not measure whether code runs. As training continues, the model drifts away from the reference answer's exact wording — which raises the loss — while the code stays correct and the behaviours we deliberately added keep consolidating.&lt;/p&gt;

&lt;p&gt;Epoch 2 has the best number and cannot do the thing we trained it to do: on three of four questions with no stated period, it silently picks a year.&lt;/p&gt;

&lt;p&gt;We shipped epoch 4. Had we trusted the loss, we'd have shipped the one that doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then I got the measurement wrong too
&lt;/h2&gt;

&lt;p&gt;Worth including because it's the mistake I'd most likely repeat.&lt;/p&gt;

&lt;p&gt;The behavioural evaluation gave epoch 4 a perfect score on disclosure: 4 out of 4. I reported 100%.&lt;/p&gt;

&lt;p&gt;It asked each question &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Re-running the same question against the deployed model several times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;    &lt;span class="n"&gt;discloses&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;      &lt;span class="n"&gt;discloses&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real figure is somewhere around 50–75%, not 100%. The behaviour is genuinely learned and genuinely better than the 25% the previous model managed — but it is not reliable, and a single sample per question cannot tell you that. One sample measures what a model did once. It says nothing about what it does.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;temperature = 0&lt;/code&gt; also isn't deterministic without a seed. Three calls, three different answers.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone fine-tuning against their own API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your model inherits your documentation's lies.&lt;/strong&gt; If the training data was written from the docs, every stale claim in them is now a learned behaviour. Call the service and write down what comes back. It took an afternoon and found three errors in a prompt that had been in production for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether the question determines the answer.&lt;/strong&gt; Any example where the target contains information absent from the input is teaching the model to make things up. It also shows up as loss spikes that never come down, which is a cheap way to find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship a validation split, even a tiny one.&lt;/strong&gt; Twenty-nine examples were enough to reveal an overfitting turn that was completely invisible for three epochs previously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure whether the output works, not whether it matches.&lt;/strong&gt; Loss is a proxy. Parse rate, valid arguments, no hallucinated dependencies — those are the thing itself. When the two disagree, the proxy is not the one to trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample more than once.&lt;/strong&gt; A percentage from one draw per question is not a measurement, it's an anecdote with a decimal point.&lt;/p&gt;

&lt;p&gt;The model was maybe a fifth of the work. The rest was going back and asking the system what it actually does — which, in hindsight, is what anyone should have done before writing the documentation the model learned from.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>api</category>
      <category>datascience</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
