{"slug": "our-documentation-was-lying-the-model-believed-it", "title": "Our Documentation Was Lying. The Model Believed It.", "summary": "An engineer at dailymeteo.com discovered that its fine-tuned GPT model was generating code based on API documentation that was inaccurate, causing requests to fail. The documentation claimed a variable 'slp' was available, but the API rejected it, and other claims about date ranges and timestamps were also wrong. The engineer fixed the issues by measuring actual API behavior and updating the training data.", "body_md": "There is a variable called `slp`\n\n— 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.\n\nThe request comes back `400`\n\n.\n\nIt has been coming back `400`\n\nthe whole time.\n\nWe run [dailymeteo.com](https://dailymeteo.com) — 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.\n\nLast 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.\n\nThis is the part worth stopping on, because I suspect it is extremely common.\n\nWhen 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.\n\nThe trouble is that documentation is a *claim* about a system, not the system. And nobody had checked the claim in a while.\n\nSo before touching anything, I did the boring thing: I called the service and wrote down what actually came back.\n\nThree of its claims were wrong.\n\n`slp`\n\nis an available variable\nThe data exists. There are 21,916 daily rasters sitting on disk, covering 1961 to 2020, Europe only.\n\nThe API will not serve any of them. The allow-list in the view is:\n\n```\nVARS = ['tmax', 'tmin', 'tmean', 'prcp']\n```\n\nand the check `var not in VARS`\n\nguards 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 `400`\n\n.\n\nThe best detail: the error message you get back still lists `slp`\n\namong the available variables. The message is older than the list.\n\nThe 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.\n\nBoth ends wrong.\n\n`1960`\n\nreturns *\"There's no data for date range.\"* 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.\n\nWhile measuring this I found something genuinely useful: **asking for a range outside the archive is not an error.** The API quietly clips.\n\n```\nann  1901 → 1970   returns  1961 → 1970\nann  1961 → 2035   returns  1961 → 2025\nmon  1961-01 → 2030-12   returns through 2026-07\n```\n\nThat 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.\n\nThe docs said the returned timestamp is formatted like the input date. For aggregated data, true. For long-term means, not remotely:\n\n```\nann   \"1961-1990\"          \"1991-2020\"\nmon   \"05.1961-1990\"       \"05.1991-2020\"\nday   \"25.07.1961-1990\"    \"25.07.1991-2020\"\n```\n\nIt carries the *climate period*, not a date. Any code doing `substr(timestamp, 1, 4)`\n\nto pull a year out gets nonsense. Some of ours did.\n\nLong-term means — the most semantically awkward corner of the API — had no description whatsoever. The rule, once measured, is simple: **the year inside the date selects which climate period you get.** Pass `1961`\n\n, get 1961–1990. Pass `1991`\n\n, get 1991–2020. Omit the year, get both.\n\nNobody had written that down. So across 23 calls in the training set, long-term means were invoked **seven different ways**, two of them mutually contradictory. The model wasn't learning a convention. It was learning that there isn't one.\n\nThis one I didn't expect, and it's the one I'd most like other people to check for.\n\nConsider this pair from the training set:\n\nQuestion:plotting temperatures during autumn (September 1 to November 30) in Prague\n\nCode:`from = \"1999-09-01\", to = \"1999-11-30\"`\n\nWhy 1999? No reason. The question doesn't say. Whoever wrote the example picked a year.\n\nThere were 28 examples like this. And here is why they matter beyond tidiness: **the model cannot possibly predict the answer from the question.** No amount of training reduces the error on that example, because the target contains information the input doesn't.\n\nYou 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.\n\nWorse, what the model *does* learn from them is the behaviour: **invent a year, say nothing.** In production that's a model quietly answering a different question than the one asked.\n\nThe fix cost nothing and didn't change a single choice the code makes. We just made it say so:\n\n```\ncat(\"No period was specified in the question - using 1961 to 2020.\\n\\n\")\n```\n\nSame year. Now the answer is determined by the question, plus a disclosure the model can actually learn.\n\nNine steps, but the shape is simple:\n\nThis is the single change that mattered most, and it's the cheapest one.\n\nThe earlier job ran three epochs with no validation file. Its training loss fell nicely, from 0.38 to 0.16, and everyone was happy.\n\nTraining 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.\n\nWith validation, this run:\n\n| Epoch | Training loss | Validation loss |\n|---|---|---|\n| 1 | 0.368 | 0.285 |\n| 2 | 0.133 | 0.131 |\n| 3 | 0.071 | 0.140 |\n| 4 | 0.038 | 0.152 |\n\nTraining 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.\n\nOne incidental finding: the platform's `auto`\n\nhyperparameter selection picked a learning-rate multiplier of **0.51** on the previous run and **2.0** on this one. The only meaningful difference was that our examples got longer. If you rely on `auto`\n\n, know that it can quadruple your learning rate because you added a paragraph to your system prompt.\n\nValidation loss says take epoch 2. So I built a second evaluation that asks a different question: *does the generated code actually work?* 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.\n\n| Checkpoint | Validation loss | Failures out of 29 |\n|---|---|---|\n| Epoch 2 | 0.131 |\n4 |\n| Epoch 3 | 0.140 | 1 |\n| Epoch 4 | 0.152 | 0 |\n\nExactly inverted.\n\nThe 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.\n\nEpoch 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.\n\nWe shipped epoch 4. Had we trusted the loss, we'd have shipped the one that doesn't work.\n\nWorth including because it's the mistake I'd most likely repeat.\n\nThe behavioural evaluation gave epoch 4 a perfect score on disclosure: 4 out of 4. I reported 100%.\n\nIt asked each question **once**.\n\nRe-running the same question against the deployed model several times:\n\n```\ntemperature = 1.0    discloses 3/4\ntemperature = 0      discloses 1/3\n```\n\nThe 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.\n\n(`temperature = 0`\n\nalso isn't deterministic without a seed. Three calls, three different answers.)\n\n**Your model inherits your documentation's lies.** 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.\n\n**Check whether the question determines the answer.** 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.\n\n**Ship a validation split, even a tiny one.** Twenty-nine examples were enough to reveal an overfitting turn that was completely invisible for three epochs previously.\n\n**Measure whether the output works, not whether it matches.** 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.\n\n**Sample more than once.** A percentage from one draw per question is not a measurement, it's an anecdote with a decimal point.\n\nThe 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.", "url": "https://wpnews.pro/news/our-documentation-was-lying-the-model-believed-it", "canonical_source": "https://dev.to/srdjan_poppovic/our-documentation-was-lying-the-model-believed-it-6n", "published_at": "2026-08-19 08:26:58+00:00", "updated_at": "2026-08-19 08:41:43.188413+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools", "ai-products"], "entities": ["dailymeteo.com", "GPT"], "alternates": {"html": "https://wpnews.pro/news/our-documentation-was-lying-the-model-believed-it", "markdown": "https://wpnews.pro/news/our-documentation-was-lying-the-model-believed-it.md", "text": "https://wpnews.pro/news/our-documentation-was-lying-the-model-believed-it.txt", "jsonld": "https://wpnews.pro/news/our-documentation-was-lying-the-model-believed-it.jsonld"}}