{"slug": "ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard", "title": "AI Agent Cost Drift: 0.35%/day Is Invisible to Your Dashboard", "summary": "A developer built drift_anchor_gate.py to detect slow AI agent cost drift that standard rolling baselines miss. Testing showed a 0.35% daily cost creep raised zero alarms over 60 days in four rolling detectors, while a frozen anchor caught it on day 9. The tool uses a frozen canary from day 0 to compare against, blocking cost growth that would otherwise go unnoticed.", "body_md": "**AI agent cost drift** is the slow growth of your input floor — system prompt, tool schemas, CLAUDE.md, MCP servers — that a rolling baseline never catches, because the baseline climbs with it. `drift_anchor_gate.py`\n\npins a frozen canary on day 0 and compares: a 0.35%/day creep raised **zero alarms in 60 days**; the anchor blocked it on **day 9**.\n\nYour fleet dashboard fires when today's average run jumps 20 percent above last week. Your context floor grows 0.35 percent a day. Those two numbers never meet. I built six 60-day worlds and ran four rolling detectors over them, each at the tightest threshold that stays quiet on a flat fleet, and the slow creep raised **zero alarms in all four, across 60 days**, while the floor went up 22.6 percent. A frozen anchor caught the same creep on **day 9**.\n\nAI disclosure:I wrote`drift_anchor_gate.py`\n\nand`make_worlds.py`\n\nwith an AI assistant and ran them myself: Python 3.13.5, offline, standard library only, no network, no keys. Every number, exit code and sha256 below is pasted from a real local run. I ran the whole demo twice from a clean`rm -rf worlds`\n\n, and the two`output.txt`\n\nfiles are byte-for-byte identical (`sha256 1772e695cb75f79d9e3f162ed4c49477a329703610cdf5ddff54cec2cc4da62a`\n\n). The series are synthetic, and I say exactly how they are generated below. The one thing that is not synthetic is the arithmetic, and it is the part that does the work.\n\n**In short:**\n\n`w`\n\nfiring at ratio `T`\n\ncannot see any uniform daily growth below `g* = T^(2/w) - 1`\n\n. For `w=7, T=1.20`\n\nthat is `diff -rq`\n\nprints nothing, `git diff`\n\nwould print nothing), the vendor quietly bumped the harness scaffold, the fleet dashboard's short-window detectors stayed silent (the long window fired once, on day 58 — 46 days late), and the anchor blocked on day 12 with the growth attributed to `vendor: +2600 B`\n\n.On July 13, [Dipankar Sarkar](https://dev.to/alex_spinov/comment/3b3eb) replied to my post on [usage logs that report zero tokens for delivered text](https://finops.spinov.online/blog/delivered-but-unbilled-stream-gate/) with a sharp correction. His argument: a provider tokenizer bump and real suppression look identical if you only watch a threshold, so the disambiguator should be population, not threshold. \"So the block condition isn't 'delta widened past X,' it's 'this stream's delta diverged from the fleet's,' per-stream anomaly against a rolling baseline of everyone else.\"\n\nHe is right, and I said so. Then I argued the thing that became this article: a rolling baseline adapts, so anything that grows slower than the adaptation window never diverges from the fleet. It walks the fleet along with it. Boiling frog. What you need is a second anchor that is frozen at a known-good date, and for a solo developer with no fleet, a canary request with deterministic input is a population of one.\n\nAnd then I ended [my own comment](https://dev.to/alex_spinov/comment/3b3hf) with this:\n\n\"To be straight about status, this is a design argument and not something I have measured. The re-tokenized floor I have run. The fleet-versus-canary discrimination I have not.\"\n\nThat was two days ago. This post is me paying that off. The domain moved (I am measuring input cost drift, not usage suppression) but the structure of the question is the same one Dipankar and I were arguing about: **can a baseline built from your own recent history see a thing that moves your own recent history?**\n\nThis part is not a discovery. It is a derivative, and it is worth doing on paper before you touch a fixture, because it tells you what the measurement is allowed to find.\n\nA rolling window compares today against a baseline built from the previous `w`\n\ndays. That baseline is centred roughly `w/2`\n\ndays back. Under uniform daily growth `g`\n\n, today's level over the baseline's level is about `(1+g)^(w/2)`\n\n. The alarm fires when that ratio exceeds the threshold `T`\n\n. Solve for `g`\n\n:\n\n```\ng* = T^(2/w) - 1\n```\n\nAny growth slower than `g*`\n\nis invisible **forever**. Not \"for a while\". The ratio the detector computes stops rising, because the baseline is climbing at the same rate as the signal. Here is what the tool prints before it has read a single byte of data:\n\n```\n  window   threshold   invisible below   compounds to, over 30 days\n  w=7      T=1.15         4.07%/day        x3.31\n  w=7      T=1.20         5.35%/day        x4.77\n  w=14     T=1.20         2.64%/day        x2.18\n  w=28     T=1.20         1.31%/day        x1.48\n  w=28     T=1.10         0.68%/day        x1.23\n```\n\nRead the right-hand column. A seven-day window at a 20 percent threshold is blind to any drift that multiplies your input cost by **4.77x over a month**. That is the prediction. The measurement is not the formula, it is the question of where real context creep lands relative to that band, and whether you can pick a `T`\n\nlow enough to close it without the alarm screaming every Tuesday.\n\nSixty days. Forty runs per day, **fixed**. I fixed the run count on purpose: variable traffic adds noise to a daily average, and noise is the thing that pushes the dashboard's threshold up. Removing it is a handicap in the dashboard's favour, and I would rather hand the dashboard its best case than be accused of rigging it.\n\nThe floor at pin time is 61,060 bytes:\n\n| field | bytes | origin |\n|---|---|---|\n`tools_json` |\n26,000 | local file |\n`system_prompt` |\n12,000 | local file |\n`instructions` (CLAUDE.md) |\n9,000 | local file |\n`harness_scaffold` |\n6,000 |\nvendor, not a local file |\n`mcp_a` , `mcp_b`\n|\n4,000 each | local files (\n|\n\n`session_id`\n\n, `timestamp`\n\nWork per run is a lognormal draw, median 18,000 bytes, sigma 0.8, multiplied by a daily job-mix factor (lognormal, sigma 0.15). So a typical run is 86,033 bytes and the floor is **71.0 percent** of it. Every world shares one noise realization and one anchor: only the floor trajectory differs. The dashboard is never handed a harder draw than the anchor gets.\n\nThe six worlds:\n\n| world | what happens |\n|---|---|\n`control` |\nnothing changes |\n`creep` |\none policy section appended to CLAUDE.md every day, a 3rd MCP server on day 14, a 4th on day 33, 12 tool schemas on day 40 |\n`slowcreep` |\none policy section every third day, one MCP server on day 33 |\n`spike` |\nsomeone pastes a 40 KB architecture doc into CLAUDE.md on day 30 |\n`harness` |\nno local file changes at all. The vendor bumps its scaffold on days 12, 31 and 48 |\n`workload` |\nthe floor never moves. Work per run doubles on day 30 |\n\nEach policy section is uniquely generated, 380 to 620 bytes, with its own heading and body. I mention that because my last cost article was dropped in review for a fixture that repeated one paragraph thirty times, which was a fair hit.\n\nGrowth rates that come out of that: `creep`\n\nruns at **+1.044 percent/day** and reaches 112,700 bytes. `slowcreep`\n\nruns at **+0.347 percent/day** and reaches 74,885 bytes, up 22.6 percent. Nobody would notice either one by looking.\n\nEvery rolling detector gets its own best deal: the tool walks a threshold ladder from 1.05 upward and picks **the lowest threshold that raises zero false alarms on the flat world**. That is the most generous calibration you can honestly give an alerting rule, because the first thing that happens to a noisy rule in real life is that somebody raises the threshold until it shuts up.\n\nHere is what came back, condensed from the real output:\n\n| world | rolling median w=7 | rolling mean w=7 | EWMA a=0.3 | rolling median w=28 | frozen anchor, tol 2% |\n|---|---|---|---|---|---|\n`control` |\n0 alarms (T=1.20) | 0 (T=1.20) | 0 (T=1.20) | 0 (T=1.15) | PASS, all 60 days |\n`creep` (1.04%/d) |\n1, on day 40\n|\n1, day 40 | 1, day 40 | 15, from day 30 | BLOCK day 3 |\n`slowcreep` (0.35%/d) |\n0 |\n0 |\n0 |\n0 |\nBLOCK day 9 |\n`harness` |\n0 |\n0 |\n0 |\n1, day 58 | BLOCK day 12 |\n`spike` |\n4, day 30 | 2, day 30 | 2, day 30 | 15, day 30 | BLOCK day 30 |\n`workload` |\n4, day 30 | 2, day 30 | 2, day 30 | 14, day 30 | PASS, all 60 days |\n\nLook at the `creep`\n\nrow for the short windows. One alarm each, and it lands on **day 40**, the day twelve tool schemas were installed. Not on the creep. On the step. The detector is doing exactly what it is built to do, which is notice a discontinuity, and the 51 KB the floor gained by walking there is invisible to it.\n\nNow the long window, `w=28`\n\nat `T=1.15`\n\n. The arithmetic said it is blind below **1.00 percent/day**. `creep`\n\nruns at 1.044 percent/day, just above the line, and the detector fires 15 times starting on day 30. `slowcreep`\n\nruns at 0.347 percent/day, below the line, and it fires **zero** times in 60 days. The formula predicted both the hit and the miss before the data existed. That is the whole argument in two rows, and it is why I put the arithmetic first.\n\nThe long window is not free, by the way. It paid for that sensitivity with 15 alarms on `spike`\n\nand 14 on `workload`\n\n.\n\nThe `harness`\n\nworld is the one I would put in front of a skeptic.\n\nNothing in the repository changes. Not one byte. The demo runs `diff -rq`\n\nbetween a day-0 snapshot and a day-59 snapshot of every local file that feeds the request:\n\n```\ndiff -rq repo_day000 repo_day059: no differences. git diff would print nothing.\n\nsha256 of every local file, day 0 vs day 59:\n  d0  063d5a8a0fe8267fc4093041701996f728662aab22b0763dd0225de767bff895  ./CLAUDE.md\n  d0  e3b98852646c31827f303b3762410a3ca42f3a51c2a2201614e21b76fae51e70  ./mcp/a.json\n  d0  07acba3d89f17bc9467bcbac3f2bcc72cfff4c07ad65694d86691a66a080f80c  ./mcp/b.json\n  d0  25cb751f2526dafd5e0f7273fe29a7abac797424f442cfff28ea82ca013bf8cf  ./system_prompt.txt\n  d0  3dc0f40d1489590ef5a98c464c11378f97e4133aa1a6fcc984fa5d386525440c  ./tools.json\n  d59 063d5a8a0fe8267fc4093041701996f728662aab22b0763dd0225de767bff895  ./CLAUDE.md\n  d59 e3b98852646c31827f303b3762410a3ca42f3a51c2a2201614e21b76fae51e70  ./mcp/a.json\n  d59 07acba3d89f17bc9467bcbac3f2bcc72cfff4c07ad65694d86691a66a080f80c  ./mcp/b.json\n  d59 25cb751f2526dafd5e0f7273fe29a7abac797424f442cfff28ea82ca013bf8cf  ./system_prompt.txt\n  d59 3dc0f40d1489590ef5a98c464c11378f97e4133aa1a6fcc984fa5d386525440c  ./tools.json\n```\n\nMeanwhile the vendor added 2,600 bytes of tool-call protocol preamble to every request on day 12, another 3,100 on day 31, another 3,400 on day 48. By day 59 the rendered request carries 14.9 percent more floor than it did at pin time, on every single call, forever.\n\nThree instruments looked at that world. `git diff`\n\nreported nothing. The rolling detectors on the usage log reported nothing (the long window eventually woke up on day 58, which is 46 days late and only after the third bump). The anchor reported this, on day 12, from the same command:\n\n``` bash\n$ drift_anchor_gate.py check worlds/anchor.json worlds/harness/canary/day012.json\nanchor     61060 B   pinned day 0   tolerance 2.0%\ntoday      63660 B   day 12           drift +4.26%\n\n  harness_scaffold      +2600 B   vendor\n  unchanged (same sha256): instructions, mcp_a, mcp_b, session_id, system_prompt, timestamp, tools_json\n\n  from local files: +0 B      from the vendor: +2600 B\n\nverdict: BLOCK  ->  exit 1   (drift +4.26% is above the 2.0% tolerance)\nexit=1\n```\n\nThe same command, the same anchor, the same day, in the control world:\n\n``` bash\n$ drift_anchor_gate.py check worlds/anchor.json worlds/control/canary/day012.json\ntoday      61060 B   day 12           drift +0.00%\nverdict: PASS   ->  exit 0   (drift +0.00% is within the 2.0% tolerance)\nexit=0\n```\n\nThat line `from local files: +0 B from the vendor: +2600 B`\n\nis the reason the anchor measures the **rendered request** and not the files you remembered to list. Your repo is not the thing you pay for. The rendered request is the thing you pay for.\n\nI would not trust this article if it did not have this section, so here it is.\n\nThe `workload`\n\nworld: the floor never changes, and on day 30 the work per run doubles (retrieval starts returning twice the chunks, users paste bigger inputs, whatever). The rolling detector fires on day 30 and it is completely correct to do so. The anchor **passes all 60 days**, because the frozen canary task is not doing more work. Its input really did not drift. The anchor is blind to the entire class of cost growth that lives in the workload ([the context tax, where every step re-bills the whole transcript](https://finops.spinov.online/blog/context-tax-measure-transcript-rebill/)), and that class is real and often bigger than the floor.\n\nNow put `spike`\n\nand `workload`\n\nside by side in that results table. Look at the rolling columns. Same detector, same day 30, near-identical alarm counts (4, 2, 2 on the short windows, 15 vs 14 on the long one). From inside the usage log those two worlds are **indistinguishable**. One of them is a 40 KB doc pasted into an instruction file that you will now pay for on every call until someone deletes it. The other is your product doing more work, which is what you wanted. The dashboard raises the identical alarm for both.\n\nThe anchor tells them apart: BLOCK on the paste, PASS on the workload. That is not the anchor being more sensitive. It is the anchor answering a different, narrower question, and answering it cleanly.\n\nSo the honest summary is a division of labour, not a replacement. Keep the rolling detector. It catches the paste, the step, and the workload change. Add the anchor. It catches the year.\n\nThere is one parameter that can flip the whole result, so I swept it and I am publishing the sweep: **how much of a typical request is floor?**\n\nThe noise in a fleet average comes from the **work**, not from the floor. The floor is the same on every call. So the less work your runs carry, the quieter your fleet average is, the lower a threshold you can run without false alarms, and the closer your dashboard gets to being a canary all by itself. Here is the sweep on the `slowcreep`\n\nworld, with the `w=7`\n\ndetector recalibrated to its own zero-false-alarm threshold at each work level:\n\n```\n  work_median  floor share   w=7 clean T   blind below   alarms on slowcreep   first\n  wm002000      95.8%       T=1.05          1.40%/day    4 <- sees the creep  33\n  wm005000      89.8%       T=1.05          1.40%/day    6 <- sees the creep  12\n  wm010000      81.8%       T=1.15          4.07%/day    0                    -\n  wm018000      71.7%       T=1.15          4.07%/day    0                    -\n  wm030000      59.2%       T=2.00         21.90%/day    0                    -\n  wm060000      41.7%       T=2.00         21.90%/day    0                    -\n```\n\nAt a floor share around 90 percent and above, **the dashboard sees the creep and this article's headline does not apply to you**. The flip in my run happens between 81.8 percent (blind) and 89.8 percent (sees). If your agent's requests are almost entirely system prompt and tool schemas with a tiny user turn on top, you already own a canary. You did not have to freeze anything.\n\nFor everyone else, notice what happens at the bottom of that table. At a 42 percent floor share, the ladder had to climb to `T=2.00`\n\nto stay quiet on a flat fleet. A threshold of 2.00 means \"wake me when input cost doubles day over day\", and its blind band is 21.9 percent a day. Nobody ships that alert. That is the mechanism in one line: **the noisier your work, the higher your threshold must go, and the wider the door you leave open for drift.**\n\nI swept the day-to-day noise too, sigma from 0.05 to 0.25. At sigma 0.05, a very quiet fleet, the `w=7`\n\ndetector does catch one alarm on `slowcreep`\n\n, on day 36 (27 days after the anchor). At every other noise level: zero.\n\nThe tool counts bytes. That is deliberate, and it is also the answer to the first objection I would raise if I were reading this.\n\nDrift is a **ratio**, and a ratio survives any linear rescale: `(a*x2) / (a*x1) = x2 / x1`\n\n. Whatever your bytes-per-token constant is, whatever your price per token is, it cancels. So I do not need a tokenizer to say the floor grew 22.6 percent, and I refuse to multiply by a made-up constant to make the number look like money.\n\nCaching is the interesting version of this objection. A stable prefix is cacheable, and a cache read can cost about a tenth of a fresh write, so caching **changes the level** of what you pay by a large factor. It does not change the ratio, so it does not change the verdict. The floor still grew 22.6 percent, you are still paying the drift, and you are paying it on every call. There is a nastier detail underneath: editing an instruction file **invalidates the prefix**, so on the day someone appends a policy section you pay a full cache write on top ([the cache-break detector](https://finops.spinov.online/blog/cache-break-detector/) is about exactly that failure). Slow creep is not one cheap event. It is a cheap event that also throws away your cache, repeatedly.\n\nIf you want dollars, multiply. Just do not let the multiplication happen before the comparison.\n\nTwo of my own posts are close enough to this one that I owe you a direct answer.\n\n[Sliding-Window Spend Guard](https://finops.spinov.online/blog/sliding-window-spend-guard/) also uses a window. The difference is where the threshold comes from: there, the cap is **absolute** (\"$X per window, refuse the next call\"), and the window is only a way to aggregate. Here, the whole subject is a threshold derived **from your own history**, which is the thing that fails. An absolute cap is immune to this failure mode by construction. It is also the reason the window guard is a good design and a fleet-relative alert is a trap: one of them has a baseline that cannot be moved by the drift it is supposed to catch.\n\n[MCP Tool Pin Verify](https://finops.spinov.online/blog/mcp-tool-pin-verify/) also pins a hash. The difference is what the pin is for: there it protects the **semantic integrity** of a tool description against a malicious rug-pull. Here the pin protects a **level**, and the adversary is not an attacker, it is your own team, being helpful, one policy section at a time. Same mechanism, different failure. Pinning tool manifests does not tell you your floor grew, and this gate does not tell you a tool description turned hostile. Run both.\n\nBoth belong to the same family: [a pre-execution gate](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) that decides before the money is spent instead of a chart that explains the money after.\n\nA cost gate has exactly one bug it must never have: reporting \"all clear\" because the artifact it was supposed to measure went missing. My previous gate had that bug (a zero-byte baseline made a division produce `0.0`\n\n, which was below the threshold, which was a pass, on 2.25 million tokens). It was caught in review and the article was dropped. Fair.\n\nSo this one has three exits and a rule:\n\n`0 PASS`\n\ndrift within tolerance, canary frozen, every declared field present and non-empty.`1 BLOCK`\n\ndrift above tolerance.`2 STRUCTURAL`\n\nthe input is unusable.**There is no path where a missing, emptied or truncated artifact returns 0.** (A record that faithfully reports a genuinely *smaller* request is a PASS — that is the whole point of measuring level, not a fail-open. What cannot happen is the measurement vanishing, or a field dropping out, and being scored as \"no drift.\") Nine degenerate inputs, all from the real run:\n\n```\n--- dropped_field ---\nSTRUCTURAL: declared field missing from today's record: tools_json\nSTRUCTURAL: a bytes-only check would have called this -42.6% drift and passed it\nverdict: STRUCTURAL  ->  exit 2   (fail-closed: unusable input is never a pass)\nexit=2\n\n--- zero_byte_field ---\nSTRUCTURAL: declared field rendered zero bytes: mcp_b\nexit=2\n\n--- unknown_field ---\nSTRUCTURAL: undeclared field in today's record: mcp_c\nSTRUCTURAL: you do not know everything your harness is sending; re-pin deliberately\nexit=2\n\n--- task_changed ---\nSTRUCTURAL: canary task changed: task_sha256 c81db92e6a9f... != pinned 7439b8b0addf...\nSTRUCTURAL: a canary that drifts is not a canary\nexit=2\n\n--- volatile_grew ---\nSTRUCTURAL: volatile field session_id changed length: 36 B pinned, 96 B today\nSTRUCTURAL: a volatile field with a moving length is not volatile, it is drift in disguise\nexit=2\n```\n\nPlus `missing`\n\n, `empty`\n\n, `truncated`\n\n, and `inconsistent_total`\n\n(the parts do not sum to the stated total). Nine cases, nine exit 2s. The `dropped_field`\n\nline is the one to stare at: **a naive bytes-only comparison reads that record as 42.6 percent cheaper than the anchor and passes it.** That is the fail-open shape, and it is why the manifest is a manifest and not a number.\n\nAnd on the typical scenario, the one this tool exists for, it fails closed in the direction that costs someone a conversation: `slowcreep`\n\nday 8 passes at +1.80 percent, day 9 blocks at +2.79 percent, exit 1, attribution `instructions +1706 B local:CLAUDE.md`\n\n. Your CI goes red because somebody added four paragraphs of rules over nine days. That is the intended behaviour, and if it annoys you, re-pin the anchor. Deliberately. In a commit. With your name on it.\n\nIt is 1,726 bytes and there is nothing clever in it:\n\n```\n{\n  \"canary_id\": \"floor-canary-v1\",\n  \"pinned_at\": \"day 0\",\n  \"tolerance_pct\": 2.0,\n  \"task_sha256\": \"7439b8b0addf5009c942bb2f1c42c0c4e3489b13990957f8a47253aaa429a8c8\",\n  \"rendered_bytes\": 61060,\n  \"manifest\": {\n    \"tools_json\":       {\"bytes\": 26000, \"sha256\": \"3dc0f40d...\", \"origin\": \"local:tools.json\",       \"volatile\": false},\n    \"system_prompt\":    {\"bytes\": 12000, \"sha256\": \"25cb751f...\", \"origin\": \"local:system_prompt.txt\",\"volatile\": false},\n    \"instructions\":     {\"bytes\":  9000, \"sha256\": \"063d5a8a...\", \"origin\": \"local:CLAUDE.md\",        \"volatile\": false},\n    \"harness_scaffold\": {\"bytes\":  6000, \"sha256\": \"39b235eb...\", \"origin\": \"vendor\",                 \"volatile\": false},\n    \"mcp_a\":            {\"bytes\":  4000, \"sha256\": \"e3b98852...\", \"origin\": \"local:mcp/a.json\",       \"volatile\": false},\n    \"mcp_b\":            {\"bytes\":  4000, \"sha256\": \"07acba3d...\", \"origin\": \"local:mcp/b.json\",       \"volatile\": false},\n    \"session_id\":       {\"bytes\":    36, \"sha256\": \"96311dd8...\", \"origin\": \"runtime\",                \"volatile\": true},\n    \"timestamp\":        {\"bytes\":    24, \"sha256\": \"2956a7fc...\", \"origin\": \"runtime\",                \"volatile\": true}\n  }\n}\n```\n\n(Hashes truncated here for width. They are full-length in the file.) One detail worth pausing on: `instructions`\n\nis pinned at `063d5a8a...`\n\n, and that is the same digest `shasum`\n\nprinted for `CLAUDE.md`\n\non day 59 of the harness world. The file is provably untouched, and the request still grew.\n\nToday's record is the same shape: per-field byte counts and digests for the rendered request. Byte counts and hashes, not payloads, which is the shape a privacy-conscious request log already has. If your harness can dump the request it is about to send, you can produce this. If it cannot, that is a finding.\n\nThree files. All of it, because a reproducibility claim you cannot check is just a claim.\n\n`drift_anchor_gate.py`\n\n:\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"drift_anchor_gate.py - offline, keyless, zero-network, read-only, stdlib-only (Python 3.13).\n\nOne question: has the INPUT COST of a known-good run gone up since a known-good day?\n\nA rolling baseline (median / mean / EWMA over your fleet) answers that question by\ncomparing today against your own recent history. It is a detector of SPEED. The\ninvoice is charged on LEVEL. This tool answers the same question against a baseline\nthat does not move: an anchor pinned on day 0 to a FROZEN canary task, so the only\nthing that can change between then and now is what your harness wraps around it.\n\n  check      one day against the anchor. Exit 0 PASS / 1 BLOCK / 2 STRUCTURAL.\n  replay     N days, anchor and rolling detectors side by side on the same log.\n  sweep      the two parameters that could flip the result: floor share, day noise.\n  blindband  pure arithmetic, no data: the growth rate a rolling window cannot see.\n\nExit contract (fail-closed):\n  0 PASS        drift within tolerance, canary frozen, every declared field present and non-empty\n  1 BLOCK       drift above tolerance\n  2 STRUCTURAL  input unusable: unreadable, empty, truncated, a declared field missing or\n                rendered zero bytes, an undeclared field present, the canary task changed,\n                a volatile field that changed length, a record whose parts do not sum to its total.\n\nThere is no path where a missing, emptied or truncated artifact returns 0, and a\ndropped or zeroed field fails closed too. A gate that answers \"no drift detected\"\nbecause the artifact vanished is worse than no gate.\n\nThis tool never writes, never opens a socket, never imports anything outside the stdlib.\n\"\"\"\n\nimport hashlib\nimport json\nimport os\nimport statistics\nimport sys\n\nLADDER = [1.05, 1.10, 1.15, 1.20, 1.25, 1.30, 1.40, 1.50, 2.00]\nFAMILIES = [(\"rolling median w=7\", \"median\", 7),\n            (\"rolling mean   w=7\", \"mean\", 7),\n            (\"EWMA alpha=0.3\", \"ewma\", 7),\n            (\"rolling median w=28\", \"median\", 28)]\nALPHA = 0.3\n\n# ---------------------------------------------------------------- the gate\n\ndef load_json(path, what):\n    if not os.path.exists(path):\n        die(\"%s not found: %s\" % (what, path))\n    if os.path.getsize(path) == 0:\n        die(\"%s is zero bytes: %s\" % (what, path))\n    try:\n        with open(path) as fh:\n            return json.load(fh)\n    except (ValueError, OSError) as exc:\n        die(\"%s is not readable JSON: %s (%s)\" % (what, path, exc.__class__.__name__))\n\ndef die(msg):\n    print(\"STRUCTURAL: %s\" % msg)\n    print(\"verdict: STRUCTURAL  ->  exit 2   (fail-closed: unusable input is never a pass)\")\n    sys.exit(2)\n\ndef check(anchor, today, quiet=False):\n    \"\"\"Return (verdict, drift_pct, lines). verdict in PASS / BLOCK / STRUCTURAL.\"\"\"\n    out = []\n    man = anchor[\"manifest\"]\n    fields = today.get(\"fields\")\n    if not isinstance(fields, dict) or not fields:\n        return \"STRUCTURAL\", 0.0, [\"no fields in today's record\"]\n    if today.get(\"canary_id\") != anchor[\"canary_id\"]:\n        return \"STRUCTURAL\", 0.0, [\"canary_id mismatch: %r vs pinned %r\"\n                                   % (today.get(\"canary_id\"), anchor[\"canary_id\"])]\n    if today.get(\"task_sha256\") != anchor[\"task_sha256\"]:\n        return \"STRUCTURAL\", 0.0, [\"canary task changed: task_sha256 %s... != pinned %s...\"\n                                   % (str(today.get(\"task_sha256\"))[:12], anchor[\"task_sha256\"][:12]),\n                                   \"a canary that drifts is not a canary\"]\n\n    missing = [k for k in man if k not in fields]\n    unknown = [k for k in fields if k not in man]\n    empty = [k for k, v in fields.items() if v.get(\"bytes\", 0) <= 0]\n    if missing:\n        naive = sum(v[\"bytes\"] for v in fields.values()) / anchor[\"rendered_bytes\"] - 1\n        return \"STRUCTURAL\", 0.0, [\n            \"declared field missing from today's record: %s\" % \", \".join(sorted(missing)),\n            \"a bytes-only check would have called this %+.1f%% drift and passed it\" % (naive * 100)]\n    if unknown:\n        return \"STRUCTURAL\", 0.0, [\n            \"undeclared field in today's record: %s\" % \", \".join(sorted(unknown)),\n            \"you do not know everything your harness is sending; re-pin deliberately\"]\n    if empty:\n        return \"STRUCTURAL\", 0.0, [\"declared field rendered zero bytes: %s\" % \", \".join(sorted(empty))]\n\n    for k, v in man.items():\n        if v.get(\"volatile\") and fields[k][\"bytes\"] != v[\"bytes\"]:\n            return \"STRUCTURAL\", 0.0, [\n                \"volatile field %s changed length: %d B pinned, %d B today\"\n                % (k, v[\"bytes\"], fields[k][\"bytes\"]),\n                \"a volatile field with a moving length is not volatile, it is drift in disguise\"]\n\n    total = sum(v[\"bytes\"] for v in fields.values())\n    if total != today.get(\"rendered_bytes\"):\n        return \"STRUCTURAL\", 0.0, [\"record does not sum: fields %d B, rendered_bytes %d B\"\n                                   % (total, today.get(\"rendered_bytes\"))]\n\n    drift = total / anchor[\"rendered_bytes\"] - 1\n    tol = anchor[\"tolerance_pct\"] / 100.0\n    deltas = sorted(((fields[k][\"bytes\"] - man[k][\"bytes\"], k) for k in man),\n                    key=lambda t: -abs(t[0]))\n    local = sum(d for d, k in deltas if man[k][\"origin\"].startswith(\"local\"))\n    vendor = sum(d for d, k in deltas if not man[k][\"origin\"].startswith(\"local\"))\n\n    if not quiet:\n        out.append(\"anchor  %8d B   pinned %s   tolerance %.1f%%\"\n                   % (anchor[\"rendered_bytes\"], anchor[\"pinned_at\"], anchor[\"tolerance_pct\"]))\n        out.append(\"today   %8d B   day %-3s          drift %+.2f%%\"\n                   % (total, today.get(\"day\", \"?\"), drift * 100))\n        out.append(\"\")\n        for d, k in deltas:\n            if d:\n                out.append(\"  %-18s %+8d B   %s\" % (k, d, man[k][\"origin\"]))\n        same = [k for d, k in deltas if d == 0 and fields[k][\"sha256\"] == man[k][\"sha256\"]]\n        if same:\n            out.append(\"  unchanged (same sha256): %s\" % \", \".join(sorted(same)))\n        out.append(\"\")\n        out.append(\"  from local files: %+d B      from the vendor: %+d B\" % (local, vendor))\n\n    verdict = \"BLOCK\" if drift > tol else \"PASS\"\n    return verdict, drift * 100, out\n\ndef cmd_check(args):\n    anchor = load_json(args[0], \"anchor\")\n    today = load_json(args[1], \"today's record\")\n    verdict, drift, lines = check(anchor, today)\n    if verdict == \"STRUCTURAL\":\n        for ln in lines:\n            print(\"STRUCTURAL: %s\" % ln)\n        print(\"verdict: STRUCTURAL  ->  exit 2   (fail-closed: unusable input is never a pass)\")\n        return 2\n    body = \"\\n\".join(lines)\n    print(body)\n    print(\"\")\n    if verdict == \"BLOCK\":\n        print(\"verdict: BLOCK  ->  exit 1   (drift %+.2f%% is above the %.1f%% tolerance)\"\n              % (drift, anchor[\"tolerance_pct\"]))\n    else:\n        print(\"verdict: PASS   ->  exit 0   (drift %+.2f%% is within the %.1f%% tolerance)\"\n              % (drift, anchor[\"tolerance_pct\"]))\n    print(\"report-sha256: %s\" % hashlib.sha256(body.encode()).hexdigest())\n    return 1 if verdict == \"BLOCK\" else 0\n\n# ---------------------------------------------------------- the dashboard\n\ndef daily_means(path):\n    if not os.path.exists(path) or os.path.getsize(path) == 0:\n        die(\"usage log missing or empty: %s\" % path)\n    days = {}\n    with open(path) as fh:\n        for line in fh:\n            r = json.loads(line)\n            days.setdefault(r[\"day\"], []).append(r[\"input_bytes\"])\n    if not days:\n        die(\"usage log has no records: %s\" % path)\n    return [statistics.mean(days[d]) for d in sorted(days)]\n\ndef alarms(series, kind, w, t):\n    \"\"\"Days on which today's mean exceeds the baseline built from the PREVIOUS w days by factor t.\"\"\"\n    hits = []\n    ew = None\n    for i, x in enumerate(series):\n        if i >= w:\n            if kind == \"median\":\n                base = statistics.median(series[i - w:i])\n            elif kind == \"mean\":\n                base = statistics.mean(series[i - w:i])\n            else:\n                base = ew\n            if base and x / base > t:\n                hits.append(i)\n        if kind == \"ewma\":\n            ew = x if ew is None else ALPHA * x + (1 - ALPHA) * ew\n    return hits\n\ndef calibrate(control, kind, w):\n    \"\"\"The lowest threshold on the ladder that raises zero false alarms on a flat world.\"\"\"\n    for t in LADDER:\n        if not alarms(control, kind, w, t):\n            return t\n    return None\n\ndef blind_band(t, w):\n    \"\"\"Uniform daily growth g is invisible while (1+g)^(w/2) <= T. Arithmetic, not a finding.\"\"\"\n    return t ** (2.0 / w) - 1.0\n\ndef cmd_blindband(_args):\n    print(\"blind band of a rolling window: it compares today against a baseline centred\")\n    print(\"about w/2 days back, so a uniform daily growth g only trips it when (1+g)^(w/2) > T.\")\n    print(\"solve for g:   g* = T^(2/w) - 1     any growth slower than g* is invisible forever.\")\n    print(\"\")\n    print(\"  window   threshold   invisible below   compounds to, over 30 days\")\n    for w, t in ((7, 1.15), (7, 1.20), (14, 1.20), (28, 1.20), (28, 1.10)):\n        g = blind_band(t, w)\n        print(\"  w=%-4d   T=%.2f       %6.2f%%/day        x%.2f\" % (w, t, g * 100, (1 + g) ** 30))\n    print(\"\")\n    print(\"this is arithmetic. it is true before you collect a single byte of data.\")\n    print(\"the measurement is where real context creep lands relative to that band.\")\n    return 0\n\ndef cmd_replay(args):\n    world, ctrl = args[0], args[1]\n    anchor = load_json(os.path.join(os.path.dirname(world), \"anchor.json\"), \"anchor\")\n    events = load_json(os.path.join(world, \"events.json\"), \"events\")\n    series = daily_means(os.path.join(world, \"usage.jsonl\"))\n    control = daily_means(os.path.join(ctrl, \"usage.jsonl\"))\n\n    print(\"world: %s   days: %d   runs/day: fixed\" % (os.path.basename(world), len(series)))\n    floor0 = anchor[\"rendered_bytes\"]\n    typical = statistics.mean(control)   # a flat fleet, so this is the work profile, not the drift\n    print(\"floor at pin time %d B   typical run %d B   floor share %.1f%%\"\n          % (floor0, round(typical), 100.0 * floor0 / typical))\n    print(\"\")\n\n    print(\"  detector              threshold   alarms   first   on the day of\")\n    for name, kind, w in FAMILIES:\n        t = calibrate(control, kind, w)\n        if t is None:\n            print(\"  %-20s   none clean on a flat world\" % name)\n            continue\n        hits = alarms(series, kind, w, t)\n        first = hits[0] if hits else None\n        ev = events.get(str(first), \"nothing: this one is about the creep\") if first is not None else \"-\"\n        print(\"  %-20s   T=%.2f       %2d      %-5s   %s\"\n              % (name, t, len(hits), first if first is not None else \"-\", ev))\n        g = blind_band(t, w)\n        print(\"  %-20s   blind below %.2f%%/day (x%.2f over 30 days)\" % (\"\", g * 100, (1 + g) ** 30))\n\n    first_block, first_struct, drift_at, final = None, None, 0.0, floor0\n    for day in range(len(series)):\n        rec = load_json(os.path.join(world, \"canary\", \"day%03d.json\" % day), \"record\")\n        verdict, drift, _ = check(anchor, rec, quiet=True)\n        if verdict == \"BLOCK\" and first_block is None:\n            first_block, drift_at = day, drift\n        if verdict == \"STRUCTURAL\" and first_struct is None:\n            first_struct = day\n        final = rec[\"rendered_bytes\"]\n    print(\"\")\n    if first_block is None and first_struct is None:\n        print(\"  frozen anchor tol=%.1f%%     PASS on all %d days: the frozen input never drifted\"\n              % (anchor[\"tolerance_pct\"], len(series)))\n    if first_block is not None:\n        print(\"  frozen anchor tol=%.1f%%     BLOCK on day %d (drift %+.2f%%)  -> exit 1\"\n              % (anchor[\"tolerance_pct\"], first_block, drift_at))\n    if first_struct is not None:\n        print(\"  frozen anchor tol=%.1f%%     STRUCTURAL on day %d (%s) -> exit 2\"\n              % (anchor[\"tolerance_pct\"], first_struct,\n                 events.get(str(first_struct), \"the set of fields changed\")))\n    print(\"  floor on the last day %d B (%+.1f%% against the anchor)\"\n          % (final, (final / floor0 - 1) * 100))\n    return 1 if first_block is not None else (2 if first_struct is not None else 0)\n\ndef cmd_sweep(args):\n    root, label = args[0].rstrip(\"/\"), args[1]\n    if not os.path.isdir(root):\n        die(\"sweep root not found: %s\" % root)\n    anchor = load_json(os.path.join(os.path.dirname(root), \"anchor.json\"), \"anchor\")\n    print(\"  %-10s  floor share   w=7 clean T   blind below   alarms on slowcreep   first\" % label)\n    for name in sorted(os.listdir(root)):\n        d = os.path.join(root, name)\n        ctrl = daily_means(os.path.join(d, \"control\", \"usage.jsonl\"))\n        slow = daily_means(os.path.join(d, \"slowcreep\", \"usage.jsonl\"))\n        t = calibrate(ctrl, \"median\", 7)\n        hits = alarms(slow, \"median\", 7, t)\n        share = 100.0 * anchor[\"rendered_bytes\"] / statistics.mean(ctrl)\n        g = blind_band(t, 7) * 100\n        print(\"  %-10s  %6.1f%%       T=%.2f        %6.2f%%/day   %2d %-18s %s\"\n              % (name, share, t, g, len(hits),\n                 \"<- sees the creep\" if hits else \"\", hits[0] if hits else \"-\"))\n    print(\"\")\n    print(\"  neither parameter reaches the frozen anchor. It reads a deterministic input, so it\")\n    print(\"  carries no work and no day-to-day noise: floor share 100%%, tolerance %.1f%%, same\"\n          % anchor[\"tolerance_pct\"])\n    print(\"  BLOCK day in every row above. The rows only move the DASHBOARD.\")\n    return 0\n\nUSAGE = \"\"\"usage:\n  drift_anchor_gate.py check     <anchor.json> <today.json>\n  drift_anchor_gate.py replay    <world_dir> <control_dir>\n  drift_anchor_gate.py sweep     <sweep_root> <label>\n  drift_anchor_gate.py blindband\n\"\"\"\n\nif __name__ == \"__main__\":\n    cmds = {\"check\": cmd_check, \"replay\": cmd_replay, \"sweep\": cmd_sweep, \"blindband\": cmd_blindband}\n    if len(sys.argv) < 2 or sys.argv[1] not in cmds:\n        sys.stdout.write(USAGE)\n        sys.exit(2)\n    sys.exit(cmds[sys.argv[1]](sys.argv[2:]))\n```\n\n`make_worlds.py`\n\n, the fixture builder. Seeded, and the only thing here that writes anything:\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"make_worlds.py - build the fixtures for drift_anchor_gate.py.\n\nOffline, keyless, zero-network, stdlib-only, seeded. This is the only script here\nthat writes anything. The gate itself never writes.\n\nSix worlds share ONE anchor (day 0 is byte-identical everywhere) and ONE noise\nrealization per (work_median, sigma_day) pair. Only the floor trajectory differs\nbetween worlds, so the dashboard is never handed a harder noise draw than the\nanchor. Runs per day are FIXED at 40 on purpose: that removes run-count noise,\nwhich is a handicap in the dashboard's favour.\n\"\"\"\n\nimport hashlib\nimport json\nimport os\nimport random\nimport shutil\n\nHERE = os.path.dirname(os.path.abspath(__file__))\nOUT = os.path.join(HERE, \"worlds\")\n\nDAYS = 60\nRUNS_PER_DAY = 40\nBASE_SEED = 20260715\nWORK_MEDIAN = 18_000      # bytes of real per-run work: the user turn, retrieved chunks, history\nWORK_SIGMA = 0.8          # lognormal spread of work across runs\nSIGMA_DAY = 0.15          # lognormal spread of the daily job mix\nTOL_PCT = 2.0\n\nTASK = (\"canary task: read the pinned fixture file, return its word count as JSON. \"\n        \"do not call tools. do not browse. answer in one line.\")\n\n# Floor at pin time, in bytes, by field.\nFLOOR0 = {\n    \"harness_scaffold\": 6_000,   # vendor-injected: tool-call protocol preamble + safety wrapper\n    \"system_prompt\": 12_000,\n    \"tools_json\": 26_000,\n    \"instructions\": 9_000,       # CLAUDE.md / AGENTS.md\n    \"mcp_a\": 4_000,\n    \"mcp_b\": 4_000,\n}\nVOLATILE = {\"session_id\": 36, \"timestamp\": 24}\nORIGIN = {\n    \"harness_scaffold\": \"vendor\",\n    \"system_prompt\": \"local:system_prompt.txt\",\n    \"tools_json\": \"local:tools.json\",\n    \"instructions\": \"local:CLAUDE.md\",\n    \"mcp_a\": \"local:mcp/a.json\",\n    \"mcp_b\": \"local:mcp/b.json\",\n    \"mcp_c\": \"local:mcp/c.json\",\n    \"mcp_d\": \"local:mcp/d.json\",\n    \"session_id\": \"runtime\",\n    \"timestamp\": \"runtime\",\n}\n\nWORDS = (\"agent budget cache canary context deploy drift enforce escalate fixture gate harness \"\n         \"ingest invariant ledger manifest observe payload pin policy prompt quota render replay \"\n         \"retry rollback runbook schema scope session snapshot spend threshold token trace vendor \"\n         \"window workspace approve reject timeout latency shard cursor batch\").split()\n\nSUBJECTS = [\"retry budget\", \"tool allowlist\", \"pii redaction\", \"escalation path\", \"commit policy\",\n            \"sandbox scope\", \"timeout ladder\", \"cache warmup\", \"rollback drill\", \"on-call handoff\",\n            \"schema review\", \"cost ceiling\", \"log retention\", \"vendor swap\", \"eval cadence\",\n            \"prompt review\", \"secret handling\", \"shell allowlist\", \"diff size cap\", \"branch policy\"]\n\ndef text_of(rng, n, tag):\n    \"\"\"Deterministic ASCII text of exactly n bytes.\"\"\"\n    if n <= 0:\n        return \"\"\n    parts = [tag, \":\"]\n    size = len(tag) + 1\n    while size < n:\n        w = rng.choice(WORDS)\n        parts.append(\" \" + w)\n        size += len(w) + 1\n    s = \"\".join(parts)[:n]\n    return s + \" \" * (n - len(s))\n\ndef rule_section(rng, idx):\n    \"\"\"One appended policy section. Unique subject, unique body, 380..620 bytes.\"\"\"\n    n = rng.randint(380, 620)\n    head = \"\\n\\n## Rule %03d: %s\\n\" % (idx, SUBJECTS[idx % len(SUBJECTS)])\n    return head + text_of(rng, n - len(head), \"rationale-%03d\" % idx)\n\n# ---- static content, generated once so it is stable across days and worlds ----\n_c = random.Random(BASE_SEED ^ 0xF100)\nBASE = {k: text_of(_c, v, k) for k, v in FLOOR0.items()}\n_s = random.Random(BASE_SEED ^ 0x5EC7)\nSECTIONS = [rule_section(_s, i) for i in range(70)]\nEXTRA_TOOLS = \"\".join(text_of(_c, 1_150, \"tool-schema-%02d\" % i) for i in range(12))  # 13,800 B\nMCP_C = text_of(_c, 4_200, \"mcp_c\")\nMCP_D = text_of(_c, 3_800, \"mcp_d\")\nSPIKE_DOC = text_of(_c, 40_000, \"pasted-architecture-doc\")\nBUMP = {12: text_of(_c, 2_600, \"scaffold-bump-1\"),\n        31: text_of(_c, 3_100, \"scaffold-bump-2\"),\n        48: text_of(_c, 3_400, \"scaffold-bump-3\")}\n\nEVENTS = {\n    \"control\": {},\n    \"creep\": {14: \"mcp: +3rd server\", 33: \"mcp: +4th server\", 40: \"tools: +12 schemas\"},\n    \"slowcreep\": {33: \"mcp: +3rd server\"},\n    \"spike\": {30: \"instructions: +40 KB doc pasted\"},\n    \"harness\": {12: \"vendor: scaffold bump 1\", 31: \"vendor: scaffold bump 2\", 48: \"vendor: scaffold bump 3\"},\n    \"workload\": {30: \"work per run doubles (floor untouched)\"},\n}\nWORLDS = list(EVENTS)\n\ndef render_floor(world, day):\n    \"\"\"Field -> content string for this world on this day. Day 0 is identical in every world.\"\"\"\n    f = dict(BASE)\n    if world == \"creep\":\n        n = day                                  # one policy section per day\n    elif world == \"slowcreep\":\n        n = day // 3                             # one policy section every third day\n    else:\n        n = 0\n    if n:\n        f[\"instructions\"] = BASE[\"instructions\"] + \"\".join(SECTIONS[:n])\n    if world == \"creep\":\n        if day >= 40:\n            f[\"tools_json\"] = BASE[\"tools_json\"] + EXTRA_TOOLS\n        if day >= 14:\n            f[\"mcp_c\"] = MCP_C\n        if day >= 33:\n            f[\"mcp_d\"] = MCP_D\n    if world == \"slowcreep\" and day >= 33:\n        f[\"mcp_c\"] = MCP_C\n    if world == \"spike\" and day >= 30:\n        f[\"instructions\"] = f[\"instructions\"] + SPIKE_DOC\n    if world == \"harness\":\n        add = \"\".join(v for d, v in sorted(BUMP.items()) if day >= d)\n        f[\"harness_scaffold\"] = BASE[\"harness_scaffold\"] + add\n    return f\n\ndef work_scale(world, day):\n    \"\"\"The only world that changes the WORK per run rather than the floor.\"\"\"\n    return 2.0 if (world == \"workload\" and day >= 30) else 1.0\n\ndef sha(s):\n    return hashlib.sha256(s.encode(\"utf-8\")).hexdigest()\n\ndef dump_fields(fields):\n    \"\"\"Rendered-request record: per-field byte count + sha256. The shape of a privacy-safe request log.\"\"\"\n    rec = {k: {\"bytes\": len(v), \"sha256\": sha(v)} for k, v in fields.items()}\n    for k, n in VOLATILE.items():\n        rec[k] = {\"bytes\": n, \"sha256\": sha(k * n)}\n    return rec\n\ndef total_bytes(fields):\n    return sum(len(v) for v in fields.values()) + sum(VOLATILE.values())\n\ndef noise_matrix(work_median, sigma_day, seed):\n    \"\"\"Per-run work bytes. Identical across worlds for a given (work_median, sigma_day).\"\"\"\n    rng = random.Random(seed)\n    rows = []\n    for _ in range(DAYS):\n        dm = rng.lognormvariate(0.0, sigma_day)\n        rows.append([max(1, int(work_median * rng.lognormvariate(0.0, WORK_SIGMA) * dm))\n                     for _ in range(RUNS_PER_DAY)])\n    return rows\n\ndef write_usage(path, world, noise):\n    \"\"\"usage.jsonl: one line per run, the log a fleet dashboard already has.\"\"\"\n    with open(path, \"w\") as fh:\n        for day in range(DAYS):\n            floor = total_bytes(render_floor(world, day))\n            k = work_scale(world, day)\n            for run in range(RUNS_PER_DAY):\n                fh.write(json.dumps({\"day\": day, \"run\": run,\n                                     \"input_bytes\": floor + int(noise[day][run] * k)}) + \"\\n\")\n\ndef main():\n    if os.path.isdir(OUT):\n        shutil.rmtree(OUT)\n    os.makedirs(OUT)\n\n    day0 = render_floor(\"control\", 0)\n    anchor = {\n        \"canary_id\": \"floor-canary-v1\",\n        \"pinned_at\": \"day 0\",\n        \"tolerance_pct\": TOL_PCT,\n        \"task_sha256\": sha(TASK),\n        \"rendered_bytes\": total_bytes(day0),\n        \"manifest\": {},\n    }\n    for k, rec in dump_fields(day0).items():\n        anchor[\"manifest\"][k] = {\"bytes\": rec[\"bytes\"], \"sha256\": rec[\"sha256\"],\n                                 \"origin\": ORIGIN[k], \"volatile\": k in VOLATILE}\n    with open(os.path.join(OUT, \"anchor.json\"), \"w\") as fh:\n        json.dump(anchor, fh, indent=2, sort_keys=True)\n\n    noise = noise_matrix(WORK_MEDIAN, SIGMA_DAY, BASE_SEED)\n\n    for world in WORLDS:\n        wd = os.path.join(OUT, world)\n        os.makedirs(os.path.join(wd, \"canary\"))\n        for day in range(DAYS):\n            fields = render_floor(world, day)\n            rec = {\"canary_id\": \"floor-canary-v1\", \"day\": day, \"task_sha256\": sha(TASK),\n                   \"rendered_bytes\": total_bytes(fields), \"fields\": dump_fields(fields)}\n            with open(os.path.join(wd, \"canary\", \"day%03d.json\" % day), \"w\") as fh:\n                json.dump(rec, fh, indent=2, sort_keys=True)\n        write_usage(os.path.join(wd, \"usage.jsonl\"), world, noise)\n        with open(os.path.join(wd, \"events.json\"), \"w\") as fh:\n            json.dump({str(k): v for k, v in EVENTS[world].items()}, fh, indent=2, sort_keys=True)\n\n    # Repo snapshots for the harness world: the local files a `git diff` would look at.\n    for day in (0, 59):\n        d = os.path.join(OUT, \"harness\", \"repo_day%03d\" % day)\n        os.makedirs(os.path.join(d, \"mcp\"))\n        f = render_floor(\"harness\", day)\n        open(os.path.join(d, \"system_prompt.txt\"), \"w\").write(f[\"system_prompt\"])\n        open(os.path.join(d, \"CLAUDE.md\"), \"w\").write(f[\"instructions\"])\n        open(os.path.join(d, \"tools.json\"), \"w\").write(f[\"tools_json\"])\n        open(os.path.join(d, \"mcp\", \"a.json\"), \"w\").write(f[\"mcp_a\"])\n        open(os.path.join(d, \"mcp\", \"b.json\"), \"w\").write(f[\"mcp_b\"])\n\n    # Degenerate inputs. Every one of these must fail closed.\n    dg = os.path.join(OUT, \"degenerate\")\n    os.makedirs(dg)\n    base = json.load(open(os.path.join(OUT, \"control\", \"canary\", \"day000.json\")))\n\n    def variant(name, mutate):\n        r = json.loads(json.dumps(base))\n        mutate(r)\n        with open(os.path.join(dg, name + \".json\"), \"w\") as fh:\n            json.dump(r, fh, indent=2, sort_keys=True)\n\n    def drop_tools(r):\n        r[\"fields\"].pop(\"tools_json\")\n        r[\"rendered_bytes\"] = sum(v[\"bytes\"] for v in r[\"fields\"].values())\n\n    def zero_mcp(r):\n        r[\"fields\"][\"mcp_b\"] = {\"bytes\": 0, \"sha256\": sha(\"\")}\n        r[\"rendered_bytes\"] = sum(v[\"bytes\"] for v in r[\"fields\"].values())\n\n    def unknown_field(r):\n        r[\"fields\"][\"mcp_c\"] = {\"bytes\": 4_200, \"sha256\": sha(MCP_C)}\n        r[\"rendered_bytes\"] = sum(v[\"bytes\"] for v in r[\"fields\"].values())\n\n    def task_changed(r):\n        r[\"task_sha256\"] = sha(TASK + \" and also list the files you can see\")\n\n    def volatile_grew(r):\n        r[\"fields\"][\"session_id\"] = {\"bytes\": 96, \"sha256\": sha(\"x\" * 96)}\n        r[\"rendered_bytes\"] = sum(v[\"bytes\"] for v in r[\"fields\"].values())\n\n    def bad_sum(r):\n        r[\"rendered_bytes\"] = 61_060 - 20_000\n\n    variant(\"dropped_field\", drop_tools)\n    variant(\"zero_byte_field\", zero_mcp)\n    variant(\"unknown_field\", unknown_field)\n    variant(\"task_changed\", task_changed)\n    variant(\"volatile_grew\", volatile_grew)\n    variant(\"inconsistent_total\", bad_sum)\n    open(os.path.join(dg, \"empty.json\"), \"w\").close()\n    open(os.path.join(dg, \"truncated.json\"), \"w\").write('{\"canary_id\": \"floor-canary-v1\", \"day\": 0,')\n\n    # Sweeps: the two parameters that could flip the result. control + slowcreep share a seed.\n    for wm in (2_000, 5_000, 10_000, 18_000, 30_000, 60_000):\n        seed = BASE_SEED + wm + 15 * 1_000_003\n        nz = noise_matrix(wm, SIGMA_DAY, seed)\n        for world in (\"control\", \"slowcreep\"):\n            d = os.path.join(OUT, \"sweep_floorshare\", \"wm%06d\" % wm, world)\n            os.makedirs(d)\n            write_usage(os.path.join(d, \"usage.jsonl\"), world, nz)\n            shutil.copy(os.path.join(OUT, world, \"events.json\"), os.path.join(d, \"events.json\"))\n\n    for sig in (0.05, 0.10, 0.15, 0.20, 0.25):\n        seed = BASE_SEED + WORK_MEDIAN + int(sig * 100) * 1_000_003\n        nz = noise_matrix(WORK_MEDIAN, sig, seed)\n        for world in (\"control\", \"slowcreep\"):\n            d = os.path.join(OUT, \"sweep_sigma\", \"s%03d\" % int(sig * 100), world)\n            os.makedirs(d)\n            write_usage(os.path.join(d, \"usage.jsonl\"), world, nz)\n            shutil.copy(os.path.join(OUT, world, \"events.json\"), os.path.join(d, \"events.json\"))\n\n    print(\"worlds built: %s\" % \", \".join(WORLDS))\n    print(\"anchor rendered_bytes: %d B  tolerance: %.1f%%\" % (anchor[\"rendered_bytes\"], TOL_PCT))\n    for world in WORLDS:\n        a, b = total_bytes(render_floor(world, 0)), total_bytes(render_floor(world, 59))\n        rate = ((b / a) ** (1 / 59) - 1) * 100 if b != a else 0.0\n        print(\"  %-10s floor day0 %7d B  ->  day59 %7d B   (x%.2f, %+.3f%%/day)\"\n              % (world, a, b, b / a, rate))\n\nif __name__ == \"__main__\":\n    main()\n```\n\n`run_demo.sh`\n\n:\n\n``` bash\n#!/usr/bin/env bash\n# run_demo.sh - rebuild the fixtures from scratch and run every claim in the post.\n# Offline, keyless, no network, stdlib only. Nothing here reads a clock, so stdout is\n# byte-for-byte reproducible: run it twice, diff the two outputs, they are identical.\nset -u\ncd \"$(dirname \"$0\")\"\n\nhr() { printf '\\n%s\\n%s\\n' \"$1\" \"$(printf '=%.0s' $(seq 1 ${#1}))\"; }\n\nhr \"0. THE PREDICTION (arithmetic, before any data exists)\"\npython3 drift_anchor_gate.py blindband\n\nhr \"1. BUILD THE WORLDS (seeded, deterministic, no network)\"\npython3 make_worlds.py\n\nhr \"2. KILLER DEMO: the harness world. Every local file is byte-identical to day 0.\"\ndiff -rq worlds/harness/repo_day000 worlds/harness/repo_day059 \\\n  && echo \"diff -rq repo_day000 repo_day059: no differences. git diff would print nothing.\"\necho \"\"\necho \"sha256 of every local file, day 0 vs day 59:\"\n( cd worlds/harness/repo_day000 && find . -type f | sort | xargs shasum -a 256 ) | sed 's/^/  d0  /'\n( cd worlds/harness/repo_day059 && find . -type f | sort | xargs shasum -a 256 ) | sed 's/^/  d59 /'\n\necho \"\"\necho \"--- same anchor, same day 12, two worlds ---\"\necho \"\"\necho \"\\$ drift_anchor_gate.py check worlds/anchor.json worlds/control/canary/day012.json\"\npython3 drift_anchor_gate.py check worlds/anchor.json worlds/control/canary/day012.json\necho \"exit=$?\"\necho \"\"\necho \"\\$ drift_anchor_gate.py check worlds/anchor.json worlds/harness/canary/day012.json\"\npython3 drift_anchor_gate.py check worlds/anchor.json worlds/harness/canary/day012.json\necho \"exit=$?\"\n\nhr \"3. SIX WORLDS, ONE ANCHOR, ONE LOG. Each rolling detector gets its own best threshold.\"\nfor w in control creep slowcreep harness spike workload; do\n  echo \"\"\n  python3 drift_anchor_gate.py replay \"worlds/$w\" worlds/control\n  echo \"  replay exit=$?\"\ndone\n\nhr \"4. THE TYPICAL SCENARIO, AT THE BOUNDARY. The gate must fail CLOSED on slow creep.\"\necho \"\"\necho \"\\$ drift_anchor_gate.py check worlds/anchor.json worlds/slowcreep/canary/day008.json\"\npython3 drift_anchor_gate.py check worlds/anchor.json worlds/slowcreep/canary/day008.json\necho \"exit=$?\"\necho \"\"\necho \"\\$ drift_anchor_gate.py check worlds/anchor.json worlds/slowcreep/canary/day009.json\"\npython3 drift_anchor_gate.py check worlds/anchor.json worlds/slowcreep/canary/day009.json\necho \"exit=$?\"\n\nhr \"5. DEGENERATE INPUTS: every one of these must fail closed.\"\nfor f in missing empty truncated dropped_field zero_byte_field unknown_field task_changed volatile_grew inconsistent_total; do\n  echo \"\"\n  echo \"--- $f ---\"\n  python3 drift_anchor_gate.py check worlds/anchor.json \"worlds/degenerate/$f.json\"\n  echo \"exit=$?\"\ndone\necho \"\"\necho \"--- clean day 0 (the control: it must still pass) ---\"\npython3 drift_anchor_gate.py check worlds/anchor.json worlds/control/canary/day000.json\necho \"exit=$?\"\n\nhr \"6. SWEEP A: the one parameter that flips the result. How much of the request is floor?\"\npython3 drift_anchor_gate.py sweep worlds/sweep_floorshare work_median\n\nhr \"7. SWEEP B: day-to-day noise. Does the dashboard wake up at any of these?\"\npython3 drift_anchor_gate.py sweep worlds/sweep_sigma sigma_day\n```\n\nRun it: `bash run_demo.sh > output.txt`\n\n. It takes a few seconds, touches no network, needs no keys, and produces the same bytes every time. Mine hashes to `1772e695cb75f79d9e3f162ed4c49477a329703610cdf5ddff54cec2cc4da62a`\n\n.\n\n`usage.jsonl`\n\nand your real request dumps.Dump the rendered request for one frozen, deterministic task. Save the byte counts and hashes as an anchor with today's date. Add one CI step that re-renders that same task and compares. Set the tolerance somewhere near 2 percent, because a deterministic input has no noise to hide in, and let it fail the build.\n\nThen keep your dashboard exactly as it is. It is watching for the paste and the traffic step, and it is good at that. It was never watching for the year.\n\nOne thing I have not settled, and I would genuinely like a second opinion: **the re-pin.** Every legitimate change (a new MCP server, a needed rule) forces a re-pin, and a re-pin resets the baseline. Do it a dozen times and you have reinvented a rolling baseline with extra steps, just with human latency in the loop. My current answer is that the re-pin has to carry the cumulative drift since the **original** pin, so the number you are approving is \"the floor is now 47 percent above where it was in March\", not \"plus 4 percent since last week\". I have not built that yet. If you have run a pinned baseline in production for longer than a quarter, I want to know how you kept the pin honest.\n\n*I write about pre-execution control for AI agents: gates that decide before the money moves, not charts that explain it afterwards. Every post ships a tool you can actually run, offline, with no keys. Follow along if that is your kind of thing, and if your context floor has a story, put it in the comments. I am especially interested in anyone who has watched a vendor bump a harness without telling them.*", "url": "https://wpnews.pro/news/ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard", "canonical_source": "https://dev.to/alex_spinov/ai-agent-cost-drift-035day-is-invisible-to-your-dashboard-1734", "published_at": "2026-07-15 00:47:52+00:00", "updated_at": "2026-07-15 01:27:25.073221+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["Dipankar Sarkar", "drift_anchor_gate.py", "make_worlds.py"], "alternates": {"html": "https://wpnews.pro/news/ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard", "markdown": "https://wpnews.pro/news/ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard.md", "text": "https://wpnews.pro/news/ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard.txt", "jsonld": "https://wpnews.pro/news/ai-agent-cost-drift-0-35-day-is-invisible-to-your-dashboard.jsonld"}}