<?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: Shan Liu</title>
    <description>The latest articles on DEV Community by Shan Liu (@shanni).</description>
    <link>https://dev.to/shanni</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%2F4063145%2F4dd9b73b-3a1a-46b4-8a59-63ad2fe44bd1.png</url>
      <title>DEV Community: Shan Liu</title>
      <link>https://dev.to/shanni</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shanni"/>
    <language>en</language>
    <item>
      <title>Your access log already knows whether ChatGPT is citing you</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Sat, 12 Sep 2026 22:00:19 +0000</pubDate>
      <link>https://dev.to/shanni/your-access-log-already-knows-whether-chatgpt-is-citing-you-20gp</link>
      <guid>https://dev.to/shanni/your-access-log-already-knows-whether-chatgpt-is-citing-you-20gp</guid>
      <description>&lt;p&gt;There's a lot of guessing about whether AI systems are citing your site. Analytics won't tell you — most of these referrals arrive with no referrer at all.&lt;/p&gt;

&lt;p&gt;Your access log will, and it's more specific than people realize: &lt;strong&gt;two different classes of user-agent mean two completely different things&lt;/strong&gt;, and only one of them is evidence that you were actually cited.&lt;/p&gt;

&lt;p&gt;I've been logging this on a small site since launch. Here's the mechanism, the distinction that makes it useful, and the trap that will make you over-count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that matters
&lt;/h2&gt;

&lt;p&gt;Group the AI user-agents you see into two buckets:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 1 — crawlers.&lt;/strong&gt; &lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;ClaudeBot&lt;/code&gt;, &lt;code&gt;PerplexityBot&lt;/code&gt;, &lt;code&gt;OAI-SearchBot&lt;/code&gt;, &lt;code&gt;Claude-SearchBot&lt;/code&gt;, &lt;code&gt;Applebot-Extended&lt;/code&gt;, &lt;code&gt;CCBot&lt;/code&gt;. These are building or refreshing an index. A hit here means &lt;em&gt;you're in a crawl queue&lt;/em&gt;. That's necessary, it's not an outcome, and it says nothing about whether anyone ever saw your content in an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket 2 — user-triggered fetches.&lt;/strong&gt; &lt;code&gt;ChatGPT-User&lt;/code&gt;, &lt;code&gt;Perplexity-User&lt;/code&gt;, &lt;code&gt;Claude-User&lt;/code&gt;. Different job entirely: a &lt;strong&gt;person&lt;/strong&gt; was in a conversation, and the assistant fetched your page to answer them — either browsing live or following a citation the user clicked.&lt;/p&gt;

&lt;p&gt;That second bucket is the closest thing to direct evidence that your page participated in an answer. Naming convention across vendors is conveniently consistent: &lt;code&gt;-Bot&lt;/code&gt; and &lt;code&gt;-SearchBot&lt;/code&gt; suffixes are infrastructure, &lt;code&gt;-User&lt;/code&gt; suffix means a human is on the other end.&lt;/p&gt;

&lt;p&gt;Most write-ups on this topic lump all of these into one "AI traffic" number, which throws away the only distinction you actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing it without a log pipeline
&lt;/h2&gt;

&lt;p&gt;My stack has no nginx in front of the app, so there's no access log on disk to grep. If you're in the same position, you don't need to add one — the edge proxy can log the hit itself.&lt;/p&gt;

&lt;p&gt;The whole mechanism is a regex and a &lt;code&gt;console.log&lt;/code&gt; in the request path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_BOT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|PerplexityBot|Perplexity-User|Google-Extended|Applebot-Extended/i&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AI_BOT_RE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[ai-bot] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then whatever collects your stdout is your dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# which agents, how often&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; archive.log | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;

&lt;span class="c"&gt;# which pages they took&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3, $5}'&lt;/span&gt; archive.log | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;

&lt;span class="c"&gt;# the evidence bucket — usually few enough to read line by line&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'ChatGPT-User|Perplexity-User|Claude-User'&lt;/span&gt; archive.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth doing before you trust the numbers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Archive out of the log buffer.&lt;/strong&gt; Process managers rotate, restart, and flush. A &lt;code&gt;-User&lt;/code&gt; hit may appear exactly once, ever — if it lands in a buffer that gets cleared, it's gone and you'll never know it happened. A small script on a periodic cron that appends new lines into an append-only file, deduped, is enough. Make it idempotent so re-running is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what your matcher excludes.&lt;/strong&gt; Mine initially skipped &lt;code&gt;llms.txt&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt;, and &lt;code&gt;sitemap.xml&lt;/code&gt;, which are precisely the files an AI crawler hits first. Those counts read as zero for two days and the zero was an artifact. If your proxy has a path matcher, audit it against the paths bots actually want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: fake AI user-agents
&lt;/h2&gt;

&lt;p&gt;Here's the part I hadn't seen written up anywhere, and it will corrupt your data if you don't handle it.&lt;/p&gt;

&lt;p&gt;Some of your "AI crawler" traffic is a vulnerability scanner wearing a costume. One 83-second burst on my site: &lt;strong&gt;60 requests rotating through 6 different AI user-agent strings&lt;/strong&gt;, hitting paths like &lt;code&gt;/aws-credentials&lt;/code&gt; and &lt;code&gt;/id_rsa&lt;/code&gt;, plus query strings carrying shell-injection probes.&lt;/p&gt;

&lt;p&gt;Spoofing a user-agent is trivial, and AI crawler strings are a good disguise precisely because everyone has decided to allow them.&lt;/p&gt;

&lt;p&gt;If you count those as AI interest, you'll conclude the robots love you. Tag them instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SCANNER_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/(?:&lt;/span&gt;&lt;span class="sr"&gt;etc&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;passwd|proc&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;self|cgi-bin&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;|id_&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;rsa|dsa|ed25519&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|private-key|credentials|secrets|api-keys|wp-&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;admin|login&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|phpmyadmin|xmlrpc&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;](?:&lt;/span&gt;&lt;span class="sr"&gt;cmd|exec|command&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;%3B|%60|%7C|;|`|&lt;/span&gt;&lt;span class="se"&gt;\|)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SCANNER_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai-bot-scan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai-bot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices there. &lt;strong&gt;Blacklist suspicious paths rather than whitelisting your real ones&lt;/strong&gt; — if a blacklist misses something you get a few junk lines, but if a whitelist misses a page you just shipped, it misclassifies a genuine citation as a scan. One failure mode is noise; the other is silently destroying your only copy of a signal that occurs once.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;keep the scan lines&lt;/strong&gt;, just tagged differently. They're still useful for security review; they just don't belong in the citation count.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five weeks later, the trap was most of my data
&lt;/h3&gt;

&lt;p&gt;I wrote the section above from a single 83-second burst. Then I left the log running and came back on 2026-09-12 — 3,392 lines, five and a half weeks. The naive count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User-agent&lt;/th&gt;
&lt;th&gt;Raw hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;1,462&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;872&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;385&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;177&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-User&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-SearchBot&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity-User&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;1,487 &lt;code&gt;*-User&lt;/code&gt; hits.&lt;/strong&gt; Read straight off the log, that says a human-driven assistant fetched my pages fifteen hundred times. It says nothing of the kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roughly 1,330 of those — about 89% — are one machine on a timer.&lt;/strong&gt; The tell is in the path distribution: &lt;code&gt;/&lt;/code&gt; 665 times and &lt;code&gt;/en&lt;/code&gt; 664 times — near-perfectly paired, and together dwarfing every real page on the site by two orders of magnitude. Pulling the timestamps for the root hits alone: 661 of them since 2026-08-11, &lt;strong&gt;median gap 49 minutes&lt;/strong&gt;, 84% of gaps falling in a 25–95 minute band, with the &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/en&lt;/code&gt; requests fired 0.1–0.7 seconds apart. People clicking citations in conversations do not arrive on a metronome, in pairs, on two URLs.&lt;/p&gt;

&lt;p&gt;The rest of the costume is more familiar. Scanner probes wearing &lt;code&gt;*-User&lt;/code&gt; strings went after cloud credentials across all three major providers, plus the usual file-read attempts — the AWS link-local metadata address for IAM info, the Azure OAuth token endpoint, the GCP service-account token path, &lt;code&gt;..%2F..%2F.env&lt;/code&gt;, and &lt;code&gt;file:///proc/self/environ&lt;/code&gt;. &lt;code&gt;Claude-User&lt;/code&gt; asked for &lt;code&gt;/Dockerfile&lt;/code&gt; and &lt;code&gt;/metrics&lt;/code&gt;. &lt;code&gt;Perplexity-User&lt;/code&gt; asked for &lt;code&gt;/log-viewer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Strip the metronome, the scanner paths, &lt;code&gt;robots.txt&lt;/code&gt; and the login probes, and what remains is &lt;strong&gt;on the order of 80 &lt;code&gt;*-User&lt;/code&gt; hits that actually landed on content&lt;/strong&gt; — spread across the true-solar-time explainer, the day-master and how-to-calculate pages, the method page in both languages, the case studies, and the conformance test.&lt;/p&gt;

&lt;p&gt;Eighty is a real number and a much better one. It is also &lt;strong&gt;about 5% of the raw count&lt;/strong&gt;. That is the size of the error you inherit by trusting the user-agent string: not a rounding difference, a factor of nineteen — and it points the wrong way, flattering you. Had I shipped a dashboard in August that plotted &lt;code&gt;*-User&lt;/code&gt; hits without filtering, it would have shown a beautiful curve, and every point on it would have been a lie.&lt;/p&gt;

&lt;p&gt;The lesson isn't that logs don't work. It's that &lt;strong&gt;the filtering is the measurement.&lt;/strong&gt; The regex that finds AI user-agents is the easy half; the half that decides which of those hits are real is where the signal actually gets made.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually looked like
&lt;/h2&gt;

&lt;p&gt;Small numbers, and I'm going to keep them small rather than dress them up. Three readings from one site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;th&gt;GPTBot&lt;/th&gt;
&lt;th&gt;ClaudeBot&lt;/th&gt;
&lt;th&gt;PerplexityBot&lt;/th&gt;
&lt;th&gt;&lt;code&gt;*-User&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pre-launch baseline (2026-08-03)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Launch day, first proxy log&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two days later (2026-08-05)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things showed up in that data that I would not have gotten any other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crawlers behave differently from each other.&lt;/strong&gt; On launch day one crawler hit all ten new pages exactly once each, no repeats — walking a list. Another arrived two days later and only touched the homepage and one section, which reads like discovery rather than indexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first &lt;code&gt;-User&lt;/code&gt; hits are legible individually.&lt;/strong&gt; With two of them, you read the lines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-04 15:47&lt;/td&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/zh/method&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-05 04:29&lt;/td&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/en/method&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both landed on the methodology page — the one documenting how the product computes what it computes. Neither hit a content page. With n=2 I'm not going to build a theory on it, but it's a specific, checkable observation about &lt;em&gt;which&lt;/em&gt; page an answer engine went to get facts from, and it pointed me at something I'd have deprioritized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero is information, if you took the baseline first.&lt;/strong&gt; The pre-launch row exists because I recorded it before shipping. Without it I couldn't distinguish "no one is citing us" from "we've always looked like this."&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spoofable, and not conservatively so.&lt;/strong&gt; Everything above is a self-reported header. The scanner section and the metronome are the proof. It's tempting to assume spoofing means you're under-counting — the five-week data says the opposite: the raw number was ~19x the filtered one, inflated by traffic pretending to be an assistant. An unfiltered count is not a floor. It isn't a ceiling either. It's just not a measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;-User&lt;/code&gt; hit is not a citation you can quote.&lt;/strong&gt; It means a page was fetched to serve a conversation. You don't get the question, the answer, or whether you were quoted or contradicted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small n stays small — and big n is usually small wearing a costume.&lt;/strong&gt; Two hits is two hits. Fifteen hundred hits was eighty hits and a monitoring bot. Neither number gets to become a trend just because it's convenient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your CDN may absorb it.&lt;/strong&gt; If a cached response never reaches your app, your proxy never logs it. Worth checking whether your edge is answering on your behalf.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Because the alternative is inferring your way to a conclusion you like. The AEO advice market is full of tactics with no measurement attached, and most of them can't be evaluated by the person selling them either.&lt;/p&gt;

&lt;p&gt;This costs one regex, one &lt;code&gt;console.log&lt;/code&gt;, and a cron job. It won't tell you what the model said about you. It will tell you, with dates, whether anything on the other side ever came to your server to find out — and which page it wanted.&lt;/p&gt;

&lt;p&gt;If you set it up, take the baseline reading &lt;em&gt;before&lt;/em&gt; your next launch. That's the row you can't reconstruct later.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The page those first two fetches went for is the &lt;a href="https://auspiceoracle.com/en/method?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=devto-proving-ai-citations" rel="noopener noreferrer"&gt;method page&lt;/a&gt; — the computation, the constants, and where the numbers come from. The product is &lt;a href="https://auspiceoracle.com/en?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=devto-proving-ai-citations" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>seo</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Spent Three Weeks Doing SEO Wrong. One Missing Function Was the Whole Problem.</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Sun, 30 Aug 2026 23:29:15 +0000</pubDate>
      <link>https://dev.to/shanni/i-spent-three-weeks-doing-seo-wrong-one-missing-function-was-the-whole-problem-ab9</link>
      <guid>https://dev.to/shanni/i-spent-three-weeks-doing-seo-wrong-one-missing-function-was-the-whole-problem-ab9</guid>
      <description>&lt;p&gt;Everyone tells you SEO is a long game. Publish, wait, compound. That advice is true and it almost ruined my launch, because it gave me a comfortable explanation for a problem that wasn't slow — it was broken.&lt;/p&gt;

&lt;p&gt;Here's what actually happened, and the growth lesson I'd hand to anyone launching something new.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I launched. I wrote real pages — long ones, researched, not filler. I submitted a sitemap. Google accepted it. Status: Success.&lt;/p&gt;

&lt;p&gt;Then nothing. Weeks of nothing.&lt;/p&gt;

&lt;p&gt;Search Console showed my most important page — the actual product page — as &lt;strong&gt;"URL is unknown to Google."&lt;/strong&gt; Not "low quality." Not "crawled, not indexed." &lt;em&gt;Unknown.&lt;/em&gt; As if it didn't exist.&lt;/p&gt;

&lt;p&gt;The comfortable story was right there: new site, no authority, be patient. I almost took it. That's the trap. &lt;strong&gt;"SEO takes time" is the most expensive excuse in growth, because it's indistinguishable from "your site is silently broken."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cause
&lt;/h2&gt;

&lt;p&gt;I stopped guessing and inspected every URL individually.&lt;/p&gt;

&lt;p&gt;Three of my content pages came back &lt;strong&gt;"Duplicate, Google chose different canonical."&lt;/strong&gt; Google had decided my pages were copies of something else. My product page wasn't even that — it was invisible.&lt;/p&gt;

&lt;p&gt;The cause was one missing function.&lt;/p&gt;

&lt;p&gt;My framework generates page metadata per route. The product page didn't define any. So it silently inherited the parent layout's — including the canonical tag. Every visitor and every crawler that hit that page was handed a tag that said, in effect: &lt;em&gt;this page is a duplicate of the homepage, don't index it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had spent weeks writing content for a page that was &lt;strong&gt;telling Google not to index it.&lt;/strong&gt; No error. No warning. Valid HTML. Perfect Lighthouse score. A sitemap reporting Success.&lt;/p&gt;

&lt;p&gt;That's the part worth internalizing. The bug didn't look like a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I fixed, and how I proved it
&lt;/h2&gt;

&lt;p&gt;I found three more problems in the same audit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirect chains.&lt;/strong&gt; &lt;code&gt;www&lt;/code&gt; → apex, &lt;code&gt;http&lt;/code&gt; → &lt;code&gt;https&lt;/code&gt;, trailing slash, and a legacy &lt;code&gt;?lang=&lt;/code&gt; parameter each triggered its own redirect. Chained together, a crawler hit four hops before reaching content. Crawlers give you a budget. I was spending it on hops. I collapsed all four into exactly &lt;strong&gt;one&lt;/strong&gt; 301.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language sniffing.&lt;/strong&gt; I routed visitors by browser language. Elegant for humans, poison for crawlers — Googlebot crawls from one place, so it only ever saw one version of my site. I removed the sniffing entirely and routed on path alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-language duplication.&lt;/strong&gt; My English pages echoed fragments of the other language. Enough for Google to read them as near-duplicates of each other.&lt;/p&gt;

&lt;p&gt;Then — and this is the part most people skip — &lt;strong&gt;I verified every single fix against production.&lt;/strong&gt; Not locally. Not in staging. Against the live site, from outside.&lt;/p&gt;

&lt;p&gt;Every hostname variant, every scheme, every trailing-slash and parameter combination: exactly one hop. Every sitemap URL: zero redirects, HTTP 200. I checked those same invariants again today, weeks later, and they still hold. A fix you haven't verified from the outside isn't a fix. It's a hypothesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The product page went from &lt;strong&gt;"unknown to Google"&lt;/strong&gt; to indexed, with Google selecting the correct canonical — itself. The duplicate verdicts cleared. Impressions started flowing within days.&lt;/p&gt;

&lt;p&gt;Then the part I didn't plan.&lt;/p&gt;

&lt;p&gt;I'd been treating classic SEO as the goal. But my first confirmed &lt;strong&gt;AI citation&lt;/strong&gt; — an assistant pulling my page as a source — didn't land on a product page or a landing page. It landed on a &lt;strong&gt;concept page&lt;/strong&gt;. A page that just explains an idea, patiently, with no sales motion in it.&lt;/p&gt;

&lt;p&gt;That reframed my whole content strategy. Assistants don't cite your pitch. They cite your explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things I'd tell you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Rule out broken before you accept slow.&lt;/strong&gt; If a page isn't indexed after a few weeks, inspect that exact URL before writing another word. "Unknown to Google" is a bug report, not a waiting period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read what your pages actually ship.&lt;/strong&gt; Not what your framework should generate — what it does. &lt;code&gt;curl&lt;/code&gt; your own page and look at the canonical tag. Mine was lying about my most important page for weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Count your redirect hops.&lt;/strong&gt; From every variant: www, non-www, http, https, with and without a trailing slash. Each one should reach the destination in a single hop. This is a fifteen-minute audit that most sites fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Verify from the outside, and re-verify later.&lt;/strong&gt; Production is the only environment that counts, and a fix that held in July can quietly regress in August.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Start before you feel ready.&lt;/strong&gt; The pages I published in week one outrank the pages I published last week. Same effort. Different age. Indexing age is the one growth input you cannot buy, borrow, or accelerate later — you can only start earlier. &lt;strong&gt;Publishing on day one is a decision that pays out in month six.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;The growth hack isn't a trick. It's this: &lt;strong&gt;when something isn't working, the most likely explanation is not the most comfortable one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"SEO takes time" let me feel patient for three weeks while my site quietly told Google to ignore it. The moment I replaced that story with a URL inspection, the problem took an afternoon.&lt;/p&gt;

&lt;p&gt;Patience is a strategy for things that are working. Diagnosis is what you owe everything else.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>startup</category>
      <category>growth</category>
    </item>
    <item>
      <title>I benchmarked 8 LLMs for a niche production app. The flagship cost 5.8x more - and lost.</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Sat, 29 Aug 2026 01:48:02 +0000</pubDate>
      <link>https://dev.to/shanni/i-benchmarked-8-llms-for-a-niche-production-app-the-flagship-was-16x-the-cost-for-nothing-246e</link>
      <guid>https://dev.to/shanni/i-benchmarked-8-llms-for-a-niche-production-app-the-flagship-was-16x-the-cost-for-nothing-246e</guid>
      <description>&lt;p&gt;My app generates personalized readings for BaZi — Chinese "Four Pillars" birth charts. Every reading is an LLM call, every call costs money, and the domain is full of trap terminology that models love to botch. So before launch I benchmarked every candidate model on my actual workload, and then built the routing layer around what the benchmark found.&lt;/p&gt;

&lt;p&gt;The results generalize to any "LLM in a niche domain" app, so here they are — including the part where the most expensive model lost to one costing 5.8× less.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good" means in a niche domain
&lt;/h2&gt;

&lt;p&gt;Generic benchmarks were useless to me. My acceptance criteria were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Domain accuracy&lt;/strong&gt;: 甲 is &lt;em&gt;Yang Wood&lt;/em&gt;. A model that renders it "Yin Wood" in English output is not 5% wrong, it's categorically wrong — the way a compiler that flips one bit is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No invented jargon&lt;/strong&gt;: the system has a closed vocabulary (the Ten Gods, fixed star names). A model that confidently introduces terms my engine never computed is a liability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per reading&lt;/strong&gt;, because a free tier exists and every free reading is marketing spend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Running my own corpus through the candidates produced findings no leaderboard would have surfaced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The flagship preview won't let you turn thinking off.&lt;/strong&gt; Hybrid reasoning models "think" by default, and the preview snapshot rejects the flag outright (&lt;code&gt;400 InvalidParameter: The value of the enable_thinking parameter is restricted to True&lt;/code&gt;), so you pay for the inner monologue whether you want it or not. On one streamed request that was 286 reasoning events before the first character of the actual answer: 2.1s to the first reasoning token, 10.5s to the first character a user can read. On list price the flagship already costs &lt;strong&gt;5.8× the mid-tier model&lt;/strong&gt; per call; the reasoning tokens bill on top of that, for prose I could not tell apart. Excluded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three mid-tier models flunked domain accuracy&lt;/strong&gt; — English chart output with elements flipped (Yang Wood → "Yin Wood" class of errors). Excluded regardless of price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "character roleplay" fine-tunes hallucinated worst of all&lt;/strong&gt; — invented relationships between the Ten Gods that don't exist in the system. The models &lt;em&gt;optimized for persona&lt;/em&gt; were the least safe choice for a persona product. Excluded.&lt;/li&gt;
&lt;li&gt;Two well-known open-weight models had quietly been &lt;strong&gt;delisted from the provider's international endpoint&lt;/strong&gt; between planning and testing. A model choice is a dependency with an EOL you don't control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What survived: a cheap-and-accurate small model for the free tier, and a mid-tier model for paid — with the surprise that the mid-tier's &lt;em&gt;previous&lt;/em&gt; generation was equally accurate at lower cost, which is exactly what you want in a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Price is data: keep it next to the routing
&lt;/h2&gt;

&lt;p&gt;The eval's outputs — which models are allowed, in what order, at what price — live in one file. A &lt;code&gt;Route&lt;/code&gt; is a provider (endpoint + key) plus a model plus that model's list price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;small-fast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// USD per 1M tokens, in/out&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mid-plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mid-plus-prev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flagship&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_CHAINS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;free&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;small-fast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;legacy-plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mid-plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mid-plus-prev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flagship&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tier gets an &lt;strong&gt;ordered fallback chain&lt;/strong&gt;: the head is the workhorse, the tail is who serves the request when the workhorse can't. If a backup API key is configured, the chain ends with &lt;em&gt;the primary model on the backup account&lt;/em&gt; — because when your account balance dies, every model on it dies together, and only a different key helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  "The model failed" is three different problems
&lt;/h2&gt;

&lt;p&gt;The subtle part of fallback chains isn't trying the next model — it's knowing &lt;em&gt;when&lt;/em&gt; the next model helps at all. Every failure gets classified into one of three moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fatal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;LLMHttpError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fatal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// new model won't fix your key&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;                       &lt;span class="c1"&gt;// transient, same route&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/RateQuota|rate limit/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/model|not.&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;found|InvalidParameter/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fatal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;// network-layer: ECONNRESET, DNS, timeout&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that bites people: &lt;strong&gt;429 is two different errors wearing one status code.&lt;/strong&gt; Rate-limit throttling is transient — back off and retry the &lt;em&gt;same&lt;/em&gt; model. Quota/allocation exhaustion is not — retrying the same model just burns time; skip to the next route. You can only tell them apart by sniffing the response body, and the distinction is provider-specific. Learn your provider's error taxonomy; it's load-bearing.&lt;/p&gt;

&lt;p&gt;When the whole chain is exhausted, the app returns placeholder text with an &lt;code&gt;ok: false&lt;/code&gt; flag — and the flag exists because of a real trap: &lt;strong&gt;never persist a fallback stub.&lt;/strong&gt; A paid user whose reading gets cached as "(placeholder)" sees that placeholder on every revisit, forever, and the system never retries because a cached reading exists. &lt;code&gt;ok&lt;/code&gt; gates the database write; failures stay ephemeral and self-heal on the next request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost telemetry that survives fallback
&lt;/h2&gt;

&lt;p&gt;Every business action (one reading = up to 7 parallel calls) emits a usage event, and each call's cost is computed against &lt;strong&gt;the model that actually served it&lt;/strong&gt;, not the one you intended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;primaryModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fellBack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;served&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;intended&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llm_usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;servedModels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;primary_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fell_back&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fellBack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fell_back: true&lt;/code&gt; is the alert condition — it means your workhorse is degraded and your margins quietly changed. With this wiring, real numbers per call (~3.7k in / 0.4k out): &lt;strong&gt;$0.0021&lt;/strong&gt; on the paid-tier model, &lt;strong&gt;$0.0005&lt;/strong&gt; on the free-tier one — so a two-call free reading lands near &lt;strong&gt;$0.001&lt;/strong&gt;. Those aren't estimates; they're what the meter read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark on your own corpus.&lt;/strong&gt; Leaderboards can't see that your domain has a closed vocabulary, and "most capable" models can be your worst performers on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enable_thinking&lt;/code&gt; (or your provider's equivalent) is the biggest single cost lever&lt;/strong&gt; on hybrid reasoning models — and verify each snapshot actually honors it. A preview build that &lt;em&gt;rejects&lt;/em&gt; the flag bills you for reasoning tokens on every call, on top of an already higher list price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify failures before you retry.&lt;/strong&gt; Same-model retry, next-model failover, and give-up-now are different errors sharing status codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End the chain with a different account, not a different model.&lt;/strong&gt; Balance exhaustion kills models in bulk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never persist fallback output.&lt;/strong&gt; Gate the cache write on "this is real content."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit cost per actual served model&lt;/strong&gt; with a &lt;code&gt;fell_back&lt;/code&gt; flag. Silent fallback is silent margin change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app all this serves is &lt;a href="https://auspiceoracle.com/en" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt; — a bilingual BaZi calculator where a deterministic engine computes the chart and the LLM is only allowed to phrase it. That constraint is its own article (next in the series).&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your birth time is lying to you: a time-zone rabbit hole in a Chinese astrology calculator</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 18:09:26 +0000</pubDate>
      <link>https://dev.to/shanni/your-birth-time-is-lying-to-you-a-time-zone-rabbit-hole-in-a-chinese-astrology-calculator-46on</link>
      <guid>https://dev.to/shanni/your-birth-time-is-lying-to-you-a-time-zone-rabbit-hole-in-a-chinese-astrology-calculator-46on</guid>
      <description>&lt;p&gt;I built a calculator for BaZi — Chinese "Four Pillars" birth charts. Whatever you think of the interpretive tradition (and I'll get to that), the &lt;em&gt;input&lt;/em&gt; math turned out to be a genuinely deep time-zone problem, and that's what this post is about. If you've ever thought "time zones, how hard can it be" — this is a tour of exactly how hard, with working TypeScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;BaZi divides the day into twelve two-hour "branches", so your birth &lt;em&gt;hour&lt;/em&gt; is one of the chart's four pillars. Get the hour wrong and you get a different chart — not slightly different, categorically different.&lt;/p&gt;

&lt;p&gt;Every calculator I could find feeds the system the wall-clock time from your birth certificate. But the tradition predates time zones by about two thousand years; it obviously means &lt;em&gt;solar&lt;/em&gt; time — where the sun actually was over your birthplace. Clock time and solar time differ by more than most people think, and the difference decomposes into exactly three parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Daylight saving time — and it's historical.&lt;/strong&gt; You need the DST rules in force &lt;em&gt;on the birth date&lt;/em&gt;, not today's. China ran a now-forgotten DST experiment from 1986–91; Harbin kept its own zone before 1949. If you were born in Beijing in July 1988, your certificate is an hour ahead of standard time and no modern-day lookup will tell you that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Longitude.&lt;/strong&gt; Solar time shifts 4 minutes per degree from your zone's standard meridian. China spans five geographic zones but uses one clock — born in Ürümqi, your clock runs about two hours ahead of the sun. It's not just a China quirk: Vancouver sits at 123°W in a zone whose meridian is 120°W, so that's another 12 minutes, everywhere, always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The equation of time.&lt;/strong&gt; The sun itself runs up to ±16 minutes fast or slow over the year, thanks to orbital eccentricity and axial tilt. NOAA publishes an approximation that's accurate to under a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Equation of time (minutes), NOAA approximation */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;equationOfTimeMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dayOfYear&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dayOfYear&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;81&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;364&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;9.87&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;7.53&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stack all three and a July birth in Vancouver needs ~78 minutes of correction. That's easily a different hour branch — a different chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting historical offsets without shipping a tz database
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me: you don't need to bundle tz data. Node's &lt;code&gt;Intl&lt;/code&gt; is backed by ICU, which ships the full IANA tzdb — including the historical oddities. The trick is that &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; will happily &lt;em&gt;format&lt;/em&gt; a UTC instant in any zone, and from the formatted parts you can recover the offset:&lt;/p&gt;

&lt;p&gt;The recipe, in words: format the UTC instant into the target zone with &lt;code&gt;Intl.DateTimeFormat.formatToParts()&lt;/code&gt;, then re-read those wall-clock fields &lt;em&gt;as if&lt;/em&gt; they were UTC. The gap between that and the real instant is the zone's offset at that moment — historical rules included, because ICU carries them.&lt;/p&gt;

&lt;p&gt;What it gets you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tzOffset('Asia/Shanghai', 1988-07-01)  →  +540 min  (+9h — the forgotten DST)
tzOffset('Asia/Shanghai', 2001-11-03)  →  +480 min  (+8h — normal)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That +9 is the whole point: a 1988 Shanghai birth certificate is an hour ahead of standard time, and ICU knows it without you shipping a byte of tz data.&lt;/p&gt;

&lt;p&gt;Three things bit me getting there, and they're the difference between a snippet and something you run a few hundred thousand times a day: &lt;code&gt;hourCycle: 'h23'&lt;/code&gt; is load-bearing (some runtimes hand you hour 24 for midnight, and &lt;code&gt;Date.UTC&lt;/code&gt; cheerfully rolls that into the next day), a fresh &lt;code&gt;DateTimeFormat&lt;/code&gt; per call is the most expensive thing in the whole pipeline, and zones ICU doesn't recognize need a fallback rather than a throw.&lt;/p&gt;

&lt;p&gt;Going the other way — wall time to UTC — has the classic chicken-and-egg problem (you need the offset to compute the instant, but the offset depends on the instant). Two fixed-point iterations settle it everywhere except inside the one-hour DST gap, where no exact answer exists anyway.&lt;/p&gt;

&lt;p&gt;There's a subtler one hiding in "was DST active?". JavaScript has no &lt;code&gt;isdst&lt;/code&gt; API, so I sample the zone's offset on Jan 1, Jul 1, and the birth instant, and take the &lt;em&gt;minimum&lt;/em&gt; as the standard offset — DST always moves clocks forward, so the minimum is standard time in both hemispheres. Sampling the birth instant too matters because of Morocco, which observes &lt;em&gt;negative&lt;/em&gt; DST during Ramadan; without it, the heuristic reports a +60-minute DST that never happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two edge cases I didn't see coming
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The date line.&lt;/strong&gt; The Chatham Islands sit at 176.5°W and use UTC+12:45. Do the naive thing — longitude × 4 minutes from Greenwich — and the computed local mean solar time lands a full &lt;em&gt;day&lt;/em&gt; off. In a birth chart that silently corrupts the day pillar, which is the pillar the whole reading hangs on.&lt;/p&gt;

&lt;p&gt;The fix is to normalize into the ±180° window centered on &lt;strong&gt;the zone's standard meridian&lt;/strong&gt;, not the one centered on Greenwich — the Greenwich version is what you get for free, and it's what silently breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chatham Islands: longitude -176.5°, zone UTC+12:45 (meridian 183.75°)

  naive, normalized against Greenwich → -176.5°  → mean solar time off by ~24h
  normalized against the meridian     → +183.5°  → correct
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same input, and the difference is a whole day in the day pillar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rounding that has to add up.&lt;/strong&gt; The UI shows the three components as an addition table: DST + longitude + equation of time = total. Round each part independently and the table stops summing — off-by-one minutes that make the whole thing look broken. So the rounded parts are forced to sum exactly to the rounded total, with the residual assigned to whichever part had the largest rounding error. A tiny thing, but "the math visibly doesn't add up" is not a good look for a calculator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown birth hour: compute all twelve
&lt;/h2&gt;

&lt;p&gt;Most calculators, when you don't know your birth hour, silently default to noon or midnight — producing a confident chart of a person who doesn't exist. But there are only twelve possible hour branches, and the chart function is pure. So: compute all twelve charts (~1ms), intersect the results, show blanks where they disagree. On a 184-sample test set, 52% of charts still have a unique strength verdict with &lt;em&gt;no hour information at all&lt;/em&gt; — which means half the time we can give a real answer instead of a fabricated one.&lt;/p&gt;

&lt;p&gt;And when the corrected time lands within 8 minutes of a two-hour boundary, we flag it and suggest comparing both charts, instead of pretending to a certainty the input data can't support.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But isn't this astrology?"
&lt;/h2&gt;

&lt;p&gt;The interpretive layer is a cultural system — take it or leave it. The computational layer is not: calendar conversion, historical time-zone resolution, solar position, and the sexagenary cycle all have objectively right and wrong answers, and most tools get them wrong. That's the part worth engineering carefully, and honestly it's the same rigor any birth-time-sensitive system (astronomy tooling, historical databases) deserves.&lt;/p&gt;

&lt;p&gt;The stack: Next.js, &lt;a href="https://github.com/6tail/lunar-typescript" rel="noopener noreferrer"&gt;lunar-typescript&lt;/a&gt; for the sexagenary calendar, &lt;code&gt;Intl&lt;/code&gt;/ICU for time zones. No external API calls for the chart itself. We also publish our nayin translation table as open data (CC BY 4.0): &lt;a href="https://github.com/Shann5/bazi-nayin" rel="noopener noreferrer"&gt;github.com/Shann5/bazi-nayin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The calculator is free, no signup, English and Chinese: &lt;a href="https://auspiceoracle.com/en" rel="noopener noreferrer"&gt;auspiceoracle.com/en&lt;/a&gt;. The full write-up of the solar-time correction, with a city-by-city table, lives at &lt;a href="https://auspiceoracle.com/en/content/true-solar-time" rel="noopener noreferrer"&gt;auspiceoracle.com/en/content/true-solar-time&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any of the time handling in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>timezones</category>
      <category>astronomy</category>
    </item>
    <item>
      <title>Your LLM Skill can't do astronomy: why packaged divination Skills compute the wrong answer</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Mon, 17 Aug 2026 07:01:19 +0000</pubDate>
      <link>https://dev.to/shanni/your-llm-skill-cant-do-astronomy-why-packaged-divination-skills-compute-the-wrong-answer-47nl</link>
      <guid>https://dev.to/shanni/your-llm-skill-cant-do-astronomy-why-packaged-divination-skills-compute-the-wrong-answer-47nl</guid>
      <description>&lt;p&gt;There's been a wave of Chinese-divination Skills on GitHub this year. The biggest BaZi (Four Pillars astrology) one has ~2.5k stars — interactive chart casting against nine classical texts, plus MBTI mapping, compatibility, and elemental remedies. Another has ~800 stars and leads with engineered hallucination prevention: fixed casting steps, a structured knowledge base, external scripts. Below those: a ~450-star everything-bundle covering a dozen systems, a ~330-star one that converts chart output into AI-readable structured prompts and ships an API and an MCP server, and a ~270-star offline runtime that computes locally and exposes results to the model.&lt;/p&gt;

&lt;p&gt;I've spent a year writing a BaZi engine. I've read through these projects, and to be fair: there are people doing serious work here. The one that treats anti-hallucination as goal #1 has the right instinct. The one that pushes computation into a local runtime has the right instinct too.&lt;/p&gt;

&lt;p&gt;But almost all of them are inaccurate in a place nobody looks — not in the interpretation, in the arithmetic that happens before it.&lt;/p&gt;

&lt;p&gt;That's backwards from how people assume this works. The casting step looks like the easy mechanical part and interpretation looks like the hard part. It's the reverse. Interpretation has no ground truth, so anything reads as plausible. Casting has exactly one right answer — and most implementations get it wrong.&lt;/p&gt;

&lt;p&gt;Everything below generalizes past this domain. The pattern is: an LLM asked to do a lookup or a calculation will produce something that looks like the answer, and if your domain has no error signal, you will never find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: the year boundary is a timestamp, not a date
&lt;/h2&gt;

&lt;p&gt;Here's a run of my engine. Shanghai, February 3–5 1990, only the clock time changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1990-02-03 23:00 → 己巳 丁丑 己亥 乙亥
1990-02-04 10:00 → 己巳 丁丑 庚子 辛巳
1990-02-04 12:00 → 庚午 戊寅 庚子 壬午
1990-02-04 14:00 → 庚午 戊寅 庚子 癸未
1990-02-05 01:00 → 庚午 戊寅 辛丑 戊子
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the three rows for Feb 4. At 10:00 the first two pillars are 己巳 丁丑. At 12:00 they're 庚午 戊寅. The year and month pillars both changed in the middle of a single day.&lt;/p&gt;

&lt;p&gt;Because the BaZi year doesn't turn at midnight on Jan 1, or at Lunar New Year. It turns at Lichun — the solar term that begins when the sun reaches 315° of ecliptic longitude. That's an instant, precise to the minute, different every year. Two people born the same calendar day, one in the morning and one after lunch, get different year and month pillars. Not slightly different — 4 of the 8 characters differ.&lt;/p&gt;

&lt;p&gt;So: how does a Skill know what time Lichun occurred in 1990?&lt;/p&gt;

&lt;p&gt;There are exactly two ways. Either it ships a table of every solar term for every year to minute precision (1900–2100 × 24 terms ≈ 48,000 timestamps), or it computes the astronomy at runtime. Most of these Skills do neither — they encode solar term dates in a Markdown knowledge base. Day granularity. Which means for everyone born on the boundary day, the year pillar is either entirely right or entirely wrong depending on which half the model guessed.&lt;/p&gt;

&lt;p&gt;This is not an interpretive style difference. A quarter of the chart is simply incorrect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: the clock on the wall is not the sun
&lt;/h2&gt;

&lt;p&gt;Same wall-clock time, same national timezone, two cities. June 15 1992, 07:10 Beijing time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Shanghai  121.5°E : solar correction   +6 min → 壬申 丙午 壬戌 甲辰
Ürümqi     87.6°E : solar correction −130 min → 壬申 丙午 壬戌 壬寅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different hour pillar. 甲辰 vs 壬寅 — both characters change.&lt;/p&gt;

&lt;p&gt;China spans roughly four geographic time zones (73°E to 135°E) and runs on one official clock. BaZi uses the sun where you were born. At 07:10 on the Ürümqi clock, local solar time is barely past 05:00 — the sun is only just up. That's the 寅 hour, not the 辰 hour. Two full branches apart.&lt;/p&gt;

&lt;p&gt;Computing this correction needs three quantities: the birth longitude, the UTC offset in effect at that place on that date, and the equation of time — the ±15-minute daily discrepancy between true and mean solar time caused by Earth's elliptical orbit and axial tilt. That last one is a trigonometric series, evaluated per day-of-year.&lt;/p&gt;

&lt;p&gt;Can a model do this in-context? Realistically, no. Asking it to evaluate the equation of time is asking for a plausible number. Asking it to recall longitude fails differently: the user says "I was born in Ürümqi," the model supplies an approximate longitude, and one degree of error is four minutes — which near an hour boundary is enough to flip the pillar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: the hour that never existed
&lt;/h2&gt;

&lt;p&gt;This is the one I find most instructive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1988-07-01 08:00 Shanghai: total correction −58 min
  decomposed: DST −60, longitude +6, equation of time −4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8 AM on the clock in Shanghai in July 1988 was really 7:02, because China observed daylight saving time from 1986 to 1991. Almost nobody remembers this. But people born in those six years are in their thirties now — squarely the demographic that goes looking for a reading. Their birth certificates record a clock that had been moved forward an hour. An hour is enough to cross an entire hour-branch, and at the right time of night, the day pillar too.&lt;/p&gt;

&lt;p&gt;It gets worse at the transition itself. On the spring-forward date, local 02:00 jumps straight to 03:00 — that hour never existed locally. If a birth record says 1988-04-17 02:30, that timestamp is not a real instant.&lt;/p&gt;

&lt;p&gt;My own engine got this wrong first. The original implementation reverse-solved the UTC instant by fixed-point iteration, which doesn't converge for a nonexistent input — but it returned a result anyway. Consequence: 02:59 and 03:00 mapped to the same real instant while producing different hour pillars, and their reported corrections differed by a full 60 minutes. It's now pinned by a test that walks the gap across all six Chinese DST years, plus the minutes on either side of it.&lt;/p&gt;

&lt;p&gt;I bring this up not to show off the fix but to make the central point: none of these errors throw. The system quietly hands you eight tidy characters. Nothing about the output looks degraded. Feed that chart to any competent LLM and it will fluently explain what those characters mean about your life — and the better the prose, the more thoroughly wrong the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why everyone thinks it works anyway
&lt;/h2&gt;

&lt;p&gt;Because this domain has no verification loop.&lt;/p&gt;

&lt;p&gt;You enter a birthday, you get a paragraph, and it feels like it fits. That feeling has three sources: the Barnum effect (sufficiently general descriptions fit everyone), the model's genuine fluency (it was trained to generate text you find apt), and your own cooperation (people unconsciously supply confirming evidence for a reading they've already heard).&lt;/p&gt;

&lt;p&gt;Together those are more than enough to make a system with a completely incorrect chart read as accurate.&lt;/p&gt;

&lt;p&gt;This is the uncomfortable part of working in this space. In most domains a miscalculation produces feedback — the build breaks, the ledger doesn't balance, the bridge falls down. Here there's nothing. The user won't know, the model won't know, and the Skill author won't know either. Unless someone writes a test.&lt;/p&gt;

&lt;p&gt;So there's really one question I use to judge whether a divination project is serious: does it have failing test cases for the chart casting? Not tests that assert it runs — tests that assert this input must be rejected, or must return A and never B. The anti-hallucination project mentioned earlier is pointed the right way with its external scripts and fixed steps; so is the local-runtime one. But between "pointed the right way" and "computes correctly" sit three separate mountains: the solar term ephemeris, the equation of time, and the historical timezone database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug is the division of labor
&lt;/h2&gt;

&lt;p&gt;To be clear, Skills as a format aren't the problem. The task split is.&lt;/p&gt;

&lt;p&gt;Chart casting is pure computation. Given an instant and a place, the eight characters are uniquely determined; there is zero interpretive latitude. That belongs in code: a pure function, unit tested, identical every run.&lt;/p&gt;

&lt;p&gt;Interpretation is language. The same chart can be read gently or bluntly, weighted toward career or temperament. Models are genuinely good at that — better than I am.&lt;/p&gt;

&lt;p&gt;The failure is handing the first job to the model too. An LLM's reliability at table lookup and its reliability at prose are not remotely the same number. Ask it to recall 48,000 solar term timestamps and it will hand you something that looks a great deal like one.&lt;/p&gt;

&lt;p&gt;My rule, which I'd apply to any LLM product that must not invent things:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The engine decides what is said. The model decides only how to say it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Eight characters, elemental strengths, favorable elements, luck cycles — all computed in TypeScript, unit tested, with the constants published on a public method page. The model receives a block of facts and one directive: cite only what's given. It's a translator with a persona, not an oracle.&lt;/p&gt;

&lt;p&gt;There's a second benefit that's easy to miss: once the engine owns the facts, it can also own the uncertainty. When a user doesn't know their birth hour, I don't pick a default and hope. There are exactly 12 possibilities, and computeChart is a pure function, so I compute all 12 and intersect them — only conclusions that hold in every candidate reach the prompt; the rest are left blank, explicitly. When a corrected time lands within a few minutes of an hour boundary, I cast the chart on the other side too and show precisely what changes and what doesn't.&lt;/p&gt;

&lt;p&gt;A stated hole beats a silent one. Leave the slot empty and the model backfills it, convincingly. Mark it and instruct it, and the model routes around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually check
&lt;/h2&gt;

&lt;p&gt;If you're using one of these Skills, three questions you can verify yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Born on a solar-term boundary day — does it know the exact time that term began that year?&lt;/li&gt;
&lt;li&gt;Born far from your timezone's meridian — does it correct for birth longitude? Born 1986–1991 in China (or any DST region) — does it subtract the offset?&lt;/li&gt;
&lt;li&gt;Don't know your birth hour — does it leave it blank, or quietly assign one?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it can't answer one of the three, the chart has a meaningful chance of being wrong. It will read exactly as convincing either way.&lt;/p&gt;

&lt;p&gt;If you're writing one, the advice is a single line: pull the casting out into code, and give it tests that can fail. That last clause matters. I once deleted a validator I'd shipped months earlier because a code review revealed both of its loops asked whether a set contained elements drawn from that same set — always true, never able to fire. It had caught zero violations and structurally could not catch any. Meanwhile it appeared in every architecture discussion as "we validate that," so everyone stopped thinking about it. A guardrail that cannot fire is worse than no guardrail, and an assertion that has never gone red is a hypothesis, not a gate.&lt;/p&gt;

&lt;p&gt;Get the arithmetic right first. Then hand the prose to the model — it really is better at that part than you are.&lt;/p&gt;




&lt;p&gt;Every chart above is a live run of the engine behind &lt;a href="https://auspiceoracle.com" rel="noopener noreferrer"&gt;Auspice Oracle&lt;/a&gt;. The handling of solar term instants, true solar time, and historical DST is documented on the &lt;a href="https://auspiceoracle.com/en/method" rel="noopener noreferrer"&gt;method page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Should I replace my marketing intern with AI...</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:55:06 +0000</pubDate>
      <link>https://dev.to/shanni/should-i-replace-my-marketing-intern-with-ai-4e2n</link>
      <guid>https://dev.to/shanni/should-i-replace-my-marketing-intern-with-ai-4e2n</guid>
      <description></description>
      <category>ai</category>
      <category>marketing</category>
      <category>productivity</category>
      <category>startup</category>
    </item>
    <item>
      <title>The translation model that cost 15 more was also the most confidently wrong</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Wed, 12 Aug 2026 05:14:52 +0000</pubDate>
      <link>https://dev.to/shanni/the-translation-model-that-cost-15x-more-was-also-the-most-confidently-wrong-10m7</link>
      <guid>https://dev.to/shanni/the-translation-model-that-cost-15x-more-was-also-the-most-confidently-wrong-10m7</guid>
      <description>&lt;p&gt;My app serves the same content in Chinese and English: personalized readings for Chinese birth charts, generated by an LLM. The readings are cached; when a user flips language, regenerating from scratch costs ~3 seconds and produces a different reading — which is exactly what you don't want, because now your two languages disagree about the user's life. The fix is obvious: translate the cached reading instead of regenerating. Language switch went from 2.8s to ~60ms once cached, and both languages finally say the same thing.&lt;/p&gt;

&lt;p&gt;But "just translate it" walked straight into the domain-terminology buzzsaw, and the eval I ran to pick a model produced my favorite result of the year: the flagship translation model, at 15× the price, was the most dangerously wrong of the lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline eval: fluent nonsense scales with price
&lt;/h2&gt;

&lt;p&gt;The domain (BaZi astrology) has a closed set of technical terms with established English renderings. I ran four sizes of a dedicated translation model family over my own corpus, no glossary, and scored the terms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Correct EN&lt;/th&gt;
&lt;th&gt;flash (cheap)&lt;/th&gt;
&lt;th&gt;plus (flagship, 15×)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;七杀&lt;/td&gt;
&lt;td&gt;Seven Killings&lt;/td&gt;
&lt;td&gt;Seven Kill*ers* ✗&lt;/td&gt;
&lt;td&gt;Seven Killers ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;印星&lt;/td&gt;
&lt;td&gt;Resource star&lt;/td&gt;
&lt;td&gt;Inheritance Star ✗&lt;/td&gt;
&lt;td&gt;"Hidden Stems of the Mind" ✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;比劫&lt;/td&gt;
&lt;td&gt;Companion star&lt;/td&gt;
&lt;td&gt;Peer &amp;amp; Robbery Stars ✗&lt;/td&gt;
&lt;td&gt;"Rat–Ox combinations" ✗✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;食神&lt;/td&gt;
&lt;td&gt;Eating God&lt;/td&gt;
&lt;td&gt;Food God ✗&lt;/td&gt;
&lt;td&gt;Food God ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every model missed terms — expected; the terms are jargon. The pattern that wasn't expected: the flagship's misses weren't near-misses, they were inventions. "Hidden Stems of the Mind" is not a bad translation of 印星 (a chart-analysis category); it's a hallucinated concept delivered with total fluency. The cheap model's "Inheritance Star" is wrong but recognizably adjacent. Paying more bought more fluent wrong answers — a bigger model has more capacity to confabulate plausibly, and in translation, plausible-but-wrong is strictly worse than clunky-but-wrong because nobody catches it in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The glossary is the product
&lt;/h2&gt;

&lt;p&gt;The provider's API takes a terms list — source term, forced target rendering — injected at translation time (the docs call it term intervention; most serious MT APIs have an equivalent). With ~15 domain terms pinned:&lt;/p&gt;

&lt;p&gt;All four models became term-perfect. Every one. The entire quality race collapsed into prose style, latency, and price:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Terms (with glossary)&lt;/th&gt;
&lt;th&gt;Prose&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Cost per domain snippet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;flash&lt;/td&gt;
&lt;td&gt;perfect&lt;/td&gt;
&lt;td&gt;most natural&lt;/td&gt;
&lt;td&gt;~740ms&lt;/td&gt;
&lt;td&gt;$0.00015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lite&lt;/td&gt;
&lt;td&gt;perfect&lt;/td&gt;
&lt;td&gt;slightly flatter&lt;/td&gt;
&lt;td&gt;~610ms&lt;/td&gt;
&lt;td&gt;$0.00011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plus&lt;/td&gt;
&lt;td&gt;perfect&lt;/td&gt;
&lt;td&gt;marginally more idiomatic&lt;/td&gt;
&lt;td&gt;~1100ms&lt;/td&gt;
&lt;td&gt;$0.0022&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the model decision — the thing I'd expected to agonize over — was over. The cheap-fast one wins on quality-per-dollar; the flagship's "marginal polish" is not worth 15×. All the real engineering turned out to be in what goes into the glossary. Three findings, each from a production incident or a near-miss:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Don't glossary everything you could glossary
&lt;/h3&gt;

&lt;p&gt;My first instinct: pin all the domain vocabulary, including the sexagenary "pillar" characters (庚 → Yang Metal, 午 → Horse Fire). The output was technically correct and read like a parts catalog: "Yang Metal seated atop Horse Fire." The unglossed baseline had written "Geng seated atop Wu Fire" — pinyin, the way English-language practitioners actually write. The glossary is for terms where deviation is an error (the analysis vocabulary). Terms where the natural rendering varies by register (name-like characters) read better left to the model, with the fixed mapping reserved for UI labels. Scope the forced list to what must never drift; let prose be prose.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A glossary is directional
&lt;/h3&gt;

&lt;p&gt;Reusing the zh→en term table for the en→zh direction doesn't fail loudly — it fails by leaving English terms embedded in Chinese output. Real production sentence: 「你的Day Master是Rén」. The table has to flip per direction, so the lookup is translationTerms(targetLang), not a constant.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Reverse-mapping pinyin is a minefield of English collisions
&lt;/h3&gt;

&lt;p&gt;Going back into Chinese, the pinyin names in English prose (Rén, Shēn) must become characters again (壬, 申). I generate that map from the engine's own data tables — but only the tone-marked forms are included. The bare romanizations collide with ordinary English: You is a branch name (酉), Yin is one (寅), Wu is two different ones (戊/午). An unfiltered map happily rewrites every English "you" into 酉. Diacritics, which I'd initially treated as typographic fussiness, turned out to be the only thing making the mapping injective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical constraints worth knowing
&lt;/h2&gt;

&lt;p&gt;Dedicated MT endpoints are shaped differently from chat models, and the shape matters for cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Single-turn, no system prompt. You send source text; the glossary rides in a config field that isn't billed as prompt tokens. A chat model doing the same job re-bills you the instruction preamble on every call.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hard input cap (8,192 tokens here) — long documents need a chunker.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Measured production numbers: a full reading section zh→en in ~2.1s at $0.0003; short strings at $0.00008. Output tokens dominate MT cost, so the output-price column of the pricing table is the one to sort by.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The chain still ends in a fallback: flash → lite → regenerate in the target language if the MT service is down. Degraded consistency beats an error page.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;In a domain with fixed terminology, eval the glossary mechanism, not just the models. It flattened a 15× price range into a tie.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Model size doesn't fix domain terms — it upgrades the failure mode from clunky-wrong to fluent-wrong. Fluent-wrong is worse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Scope the glossary to must-never-drift terms; over-pinning makes prose robotic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Glossaries and transliteration maps are directional; test the round trip, not just the forward pass.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Translating cached content instead of regenerating buys you speed and cross-language consistency — the second one is the sleeper benefit.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bilingual app in question: &lt;a href="https://auspiceoracle.com/en/chart" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt;. The eval harness (corpus, runner, full transcripts) lives in the repo and reruns with one command — evals you can't rerun are anecdotes.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>i18n</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>I audited a 5.5 GB AI training dataset by downloading 0.8% of it</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:06:58 +0000</pubDate>
      <link>https://dev.to/shanni/i-audited-a-55-gb-ai-training-dataset-by-downloading-08-of-it-bf9</link>
      <guid>https://dev.to/shanni/i-audited-a-55-gb-ai-training-dataset-by-downloading-08-of-it-bf9</guid>
      <description>&lt;p&gt;Two months ago a repository turned up in my corner of the internet: 3,358 stars, 744 forks, an MIT license, and a release advertised as 518,400 training samples — 5.5 GB, split across three zip parts. My corner is Chinese astrology software, a domain with almost no machine-readable data, so a corpus that size was either the most useful thing published in years or it was nothing at all. I wanted to know which.&lt;/p&gt;

&lt;p&gt;Then I looked at the sample count for one second longer. &lt;strong&gt;518,400 = 60 × 12 × 30 × 12 × 2.&lt;/strong&gt; Sixty years, twelve months, thirty days, twelve two-hour periods, two genders. That isn't a tally of things somebody observed. That's the size of a nested loop.&lt;/p&gt;

&lt;p&gt;Which is a wonderful reason to be curious and a terrible reason to conclude anything. So I went and read the archive — 48 MB of it, over HTTP range requests, never touching the other 5.83 GB. The trip turned out to be more interesting than the destination, and the method is the part that travels well beyond my strange little niche.&lt;/p&gt;

&lt;p&gt;So: here's how you read a multi-gigabyte release for the price of a few photos, and then the four things it told me.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the trick: reading a 5.5 GB release without downloading it
&lt;/h2&gt;

&lt;p&gt;Three facts make this work, and they're pleasant ones to know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A split zip made with split is plain concatenation.&lt;/strong&gt; The parts aren't independent archives. Part 1 opens with the first local file header, and the byte offsets in the central directory are absolute across the whole set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The central directory sits at the end&lt;/strong&gt; and lists every entry: name, uncompressed size, compressed size, local-header offset. Fetch the last couple of megabytes and you're holding the table of contents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub release assets honour Range requests.&lt;/strong&gt; Once you know an entry's offset and size, you can ask for exactly that entry and nothing else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There was a bonus in this particular archive. It stores its .jsonl.gz shards with method 0 — stored, not deflated — because they had already been gzipped. Every entry is independently addressable, and the small text files at the tail sit there uncompressed, so a plain curl -r hands you readable source code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. the table of contents: last 3 MB of the last part&lt;/span&gt;
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 1890639808-1893639807 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".../ziwei-samples-v3-part3.zip.003"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; p3.tail
&lt;span class="c"&gt;# then find PK\x05\x06 (EOCD), walk the PK\x01\x02 entries,&lt;/span&gt;
&lt;span class="c"&gt;# and honour the ZIP64 extra field — cdoff came back as 0xFFFFFFFF&lt;/span&gt;

&lt;span class="c"&gt;# 2. one 8 MB data shard, at the offset the directory gave me&lt;/span&gt;
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 48646942-56753200 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".../ziwei-samples-v3-part1.zip.001"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; feb.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;917 entries came back. 781 of them are the data everybody downloaded it for. The other 136 are where the story turned out to be.&lt;/p&gt;

&lt;p&gt;Total fetched: &lt;strong&gt;47,897,905 bytes — 0.81% of the release.&lt;/strong&gt; Full script in the appendix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the release contains the file the repo says it held back
&lt;/h2&gt;

&lt;p&gt;The public repo ships lib/ziwei/db-analysis.ts as a 2,124-byte stub, and its header comment is refreshingly direct about why (my translation):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The analysis content library is not part of the open-source scope. The full online version contains detailed readings for 14 major stars × 13 palace contexts — core content, not open-sourced along with the chart engine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And at the bottom: &lt;code&gt;export const STAR_DB: Record&amp;lt;string, unknown&amp;gt; = {};&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The same path inside the release is &lt;strong&gt;377,157 bytes&lt;/strong&gt;, with STAR_DB populated starting at line 161: 524 string literals, 145,580 characters of hand-written Chinese prose. The library that was held back from git went out in the release anyway — just parked 5.87 GB deep inside a three-part split zip, where nobody was looking.&lt;/p&gt;

&lt;p&gt;That's the first and most portable lesson, and it has nothing to do with astrology: &lt;strong&gt;redaction leaks through large binary artifacts.&lt;/strong&gt; A file you carefully stubbed in git will cheerfully ride along in the release tarball, the Docker layer, the model checkpoint, the training-data dump. The bigger the artifact, the less likely anyone notices — which is exactly backwards from the way the risk actually runs.&lt;/p&gt;

&lt;p&gt;Finding the source also settled what the dataset is. Across the whole lib/ziwei/ directory there are 216,172 characters of TypeScript, 183,182 of them inside string literals. The generator is five nested loops over that grid, longitude hardcoded to 120, and — outside the audit script's own sampler — &lt;strong&gt;no call to Math.random anywhere.&lt;/strong&gt; The output is a pure function of a five-integer key.&lt;/p&gt;

&lt;p&gt;Which makes the release a memo table. Uncompressed it comes to roughly 32.9 GB (I measured one shard at 45,649,978 bytes, and there are 720 of them), holding something like &lt;strong&gt;1.09 × 10¹⁰ characters&lt;/strong&gt; of generated prose, all of it assembled from 183 K characters of source — source that ships in the same zip, next to the npm run full command that regenerates the whole thing.&lt;/p&gt;

&lt;p&gt;Measured reuse, on the 3,600 samples I pulled (0.69% of the corpus): 46,800 emitted topic blocks, &lt;strong&gt;16,359 distinct&lt;/strong&gt; — and each of those is a permutation of the same literals. Fine-tuning on this doesn't teach a model a domain. It pays a GPU bill to compress a template engine into weights, badly, when you could simply call the engine.&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;Release size&lt;/td&gt;
&lt;td&gt;5.5 GB (3 parts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uncompressed&lt;/td&gt;
&lt;td&gt;~32.9 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated prose&lt;/td&gt;
&lt;td&gt;~1.09 × 10¹⁰ chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-written source it came from&lt;/td&gt;
&lt;td&gt;183,182 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Math.random in the generator&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Finding 2: 2,520 of the samples are dates that never happened
&lt;/h2&gt;

&lt;p&gt;days: range(1, 30) — thirty days for every month, and only ever thirty.&lt;/p&gt;

&lt;p&gt;That has two consequences. Every 31st is missing: seven months × 60 years × 12 hours × 2 genders = &lt;strong&gt;10,080 real birthdays with no row at all.&lt;/strong&gt; And February gets days 29 and 30 regardless of the year, which leaves &lt;strong&gt;2,520 rows keyed to dates that never existed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I couldn't resist pulling February 1962, a non-leap year, to see what the generator does with an impossible input. It doesn't fail. It rolls straight past the end of the month and keeps counting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gregorian input&lt;/th&gt;
&lt;th&gt;lunar day the engine assigned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1962-02-27&lt;/td&gt;
&lt;td&gt;month 1, day 23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1962-02-28&lt;/td&gt;
&lt;td&gt;month 1, day 24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;1962-02-29&lt;/strong&gt; (not a date)&lt;/td&gt;
&lt;td&gt;month 1, day 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;1962-02-30&lt;/strong&gt; (not a date)&lt;/td&gt;
&lt;td&gt;month 1, day 26&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the sample labelled 1962-02-30 is a complete, confident, 63 KB chart — for a person born on March 2nd. Nothing anywhere in the pipeline noticed.&lt;/p&gt;

&lt;p&gt;Two more shape quirks live in the same grid. The year axis is range(1924, 1983), with a source comment explaining that "60 years covers a full sexagenary cycle" — &lt;strong&gt;the window was chosen to make the number come out round, not to cover anybody's users.&lt;/strong&gt; Nobody born after 1983 is in here, which in 2026 makes it a corpus exclusively about people aged 43 and up. And longitude is 120 on every single row, which stings a little: in a system where the birth hour is the highest-entropy input, the one variable most worth varying got pinned to a constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: "518,400 / 518,400 validated" turns out to be a substring check
&lt;/h2&gt;

&lt;p&gt;The release ships its own validation and audit logs, and they look great. Zero failures, zero warnings, lines like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;health contains 「liver/kidney/spleen-stomach」:  518400/518400 (100.00%)
health contains 「子午流注」 and 「經絡」:        518400/518400 (100.00%)
female health contains 「gynaecology/menses/pregnancy」: 259200/259200 (100.00%)
male/female wealth topic pairs differ:           259200/259200 (100.00%)
failures: 0     warnings: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those is String.includes. They establish that a template fired. They cannot tell present apart from correct — and here that distinction is load-bearing, because five lines earlier in the very same log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;daXians[0] contains siHua:        0/518400 (0.00%)
any daXian contains siHua:        0/518400 (0.00%)
daXians[0] contains stemIndex:    0/518400 (0.00%)
samples containing palace.selfSihua: 0/518400 (0.00%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spec handed to the generating agent — README-CODEX.md, also in the archive — calls one of those fields "the single easiest thing to get wrong" and makes it hard requirement #6, with a post-generation checklist that says to verify it. I went and checked the shipped records directly: each daXians entry has exactly four keys — startAge, endAge, palaceBranch, palaceName. The field the spec built itself around simply isn't there.&lt;/p&gt;

&lt;p&gt;The packaging manifest, meanwhile, lists this in English under the heading &lt;strong&gt;"Verified results"&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verified results:
- Total samples: 518,400
- Validation failures: 0
- daXians[].siHua: absent
- daXians[].stemIndex: absent
- palace.selfSihua: absent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An earlier audit report, still sitting in the box, shows those same fields populated with real values across 20 sampled rows. So somewhere between that run and the shipped one, the three most important computed fields went missing, and the validator scored their absence as a pass. The scorecard file then awards &lt;strong&gt;10/10&lt;/strong&gt; to the dimension "hard-constraint implementation and audit," on the stated basis that verification commands exist for each constraint.&lt;/p&gt;

&lt;p&gt;One more from this log, because it's the detail I keep thinking about. The audit counts how often each of twelve canned warning phrases appears, and three of them land at exactly 518,400 — including the sentence "if the fortune palace is afflicted and the spouse palace shows no separation in life, then it must be separation by death." That line is meant to be conditional on a configuration most charts don't have. It's in 100% of the corpus. A model trained on this learns to tell everybody they'll be widowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: a file in the archive audits the citations, and it's brutally honest
&lt;/h2&gt;

&lt;p&gt;Tucked into the release is corpus/annotations.json — a review of every quotation in the analysis library attributed to a named teacher, a physician-lecturer who died in 2012. Here's its own summary, in its own categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;status&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;th&gt;the file's definition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;verified&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;appears in the lecture transcripts, source checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;traditional&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;generic classical maxim, attributed to him but not his&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;suspect&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;source doubtful, possibly another school or later invention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fabricated&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"highly likely invented by a previous author or by me on the spot"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;methodology&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;concept correct, not a quotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;19% of the quoted attributions are traced.&lt;/strong&gt; The notes are first-person and completely unsparing — "this line I made up, should be changed to 'the Northern school holds…'"; "'precise month-derivation method' is a name I invented, not his term"; "⚠️ major error: his illness readings key on the palace branch, not the star element — this whole mapping deviates from the system."&lt;/p&gt;

&lt;p&gt;There's also a top_priority_fixes array. One P1 item is the fortune-palace sentence I quoted above, flagged as probably not his words and in need of a rewrite. It is the sentence that appears in 518,400 of 518,400 samples. &lt;strong&gt;The fix list shipped inside the artifact it was supposed to gate.&lt;/strong&gt; (The generated prose also attributes it, and dozens of other lines, using a misspelling of the teacher's name.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here's the part that makes this worth writing up rather than piling on.&lt;/strong&gt; That annotations file is more provenance diligence than almost any repo in this space has, and the maintainer wrote it themselves, about their own text, before shipping. Sitting next to it is a rights policy that sorts collected material into four tiers, forbids storing full text without a license, and forbids rights_status=unknown material from entering trainable corpora. The source registry reads: 211 sources, &lt;strong&gt;1&lt;/strong&gt; cleared for full text, 167 needing a license, 15 prohibited outright. Somebody thought hard about all of this.&lt;/p&gt;

&lt;p&gt;So the failure isn't missing diligence. It's that &lt;strong&gt;the diligence was never wired to a gate.&lt;/strong&gt; A file listing six fabricated citations doesn't block a release. A policy saying "unknown rights don't enter training corpora" doesn't stop a corpus derived from that material from going out under MIT. A validator that greps for a string can't fail a build for being wrong. Every artifact of trust in that box was produced by the same pipeline it was meant to check — the agent generated the data, ran the audit, and reported success, and each of those three steps was scored on whether it completed.&lt;/p&gt;

&lt;p&gt;That's the 2026 failure mode, and I don't think it's rare. Not slop. &lt;strong&gt;Slop with a passing test suite.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three checks I run now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Factor the sample count.&lt;/strong&gt; If N decomposes into small round factors, you're looking at a grid — coverage of a key space rather than observations of anything. Ask what the unit of observation is. If the answer is "a possible input," there's no signal to learn, and no outcome, adjudication, or human in the loop to check a claim against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the generator before you look at the data.&lt;/strong&gt; If it ships — and it very often does, in the tail of the archive — the generator &lt;em&gt;is&lt;/em&gt; the dataset, and it's five orders of magnitude smaller. Then check it for randomness. No randomness means the release is a memo table of a pure function, and you should just call the function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the validator, not the validation.&lt;/strong&gt; grep for what it actually asserts. Substring presence, field presence, and file counts are shape checks; they pass with equal enthusiasm on correct output and inverted output. A green log tells you nothing until you've seen the predicate. Mine assert relations between computed values, for what that's worth, and I still don't trust them as far as I'd like.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A small footnote on why any of this needed doing. The download counts on that release: 10,814 for part 1, 7,506 for part 2, 7,989 for part 3, and 1,250 for the checksum file. About 30% of the people who started never finished, and roughly one in ten verified what they got. A 5.5 GB three-part download is its own kind of moat — nobody reads what they can't open, and 3,358 stars measured the appeal of the claim rather than the contents.&lt;/p&gt;

&lt;p&gt;The audit cost 48 MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I was poking at it at all&lt;/strong&gt;: I build software in the neighbouring system — BaZi rather than Zi Wei Dou Shu (&lt;a href="https://auspiceoracle.com/en" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt;) — so this corpus landed on my desk as something I might use. I've deliberately left the repo, the tag, and the file paths out of the prose, because the point here is the method rather than the maintainer. I'm also not drawing any licensing conclusions: I'm not a lawyer, and every count I've quoted is the repo's own. Everything else reproduces in about two minutes with the script below, and I'd much rather be corrected than believed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;REL&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://github.com/{owner}/{repo}/releases/download/{tag}/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PART&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;...part1.zip.001&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;...part2.zip.002&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;...part3.zip.003&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1992294400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1992294400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1893639808&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# from the releases API
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;grab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                      &lt;span class="c1"&gt;# HTTP range fetch
&lt;/span&gt;    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PART&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="n"&gt;headers&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;Range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# 1. central directory: tail of the last part.
#    find PK\x05\x06 for the EOCD, then walk PK\x01\x02 entries.
#    cdoff == 0xFFFFFFFF means ZIP64 -&amp;gt; read the real values from
#    extra field header id 0x0001 (usize, csize, local-header offset).
&lt;/span&gt;&lt;span class="nf"&gt;grab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SIZE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SIZE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p3.tail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2. any entry, given its absolute offset from the directory:
#    absolute -&amp;gt; (part, local offset) via the cumulative sizes,
#    then re-read the local header to skip name+extra, and the
#    remaining csize bytes are the stored member, byte for byte.
#    Entries with method == 0 (stored) need no decompression —
#    the .jsonl.gz shards drop straight into gzip.open().
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to take away, none of them about astrology. Redaction leaks through big artifacts. Grids aren't data. And a green audit log is a claim like any other — it's worth asking what it measured.&lt;/p&gt;

&lt;p&gt;All numbers measured 2026-08-05 against the release as published.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>datasets</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The LLM in my app is not allowed to decide anything</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 21:46:55 +0000</pubDate>
      <link>https://dev.to/shanni/the-llm-in-my-app-is-not-allowed-to-decide-anything-39n0</link>
      <guid>https://dev.to/shanni/the-llm-in-my-app-is-not-allowed-to-decide-anything-39n0</guid>
      <description>&lt;p&gt;I build software in the single worst domain for LLM truthfulness: fortune-telling. A BaZi (Chinese Four-Pillars astrology) reading app, where the model's job is to sound like a wise master — and where user reviews of competing AI products converge on one complaint: "pure nonsense." An LLM asked to "read a birth chart" will hallucinate chart elements that aren't there, invent rules that don't exist in the tradition, and deliver it all in a voice of total confidence. In a domain with zero external ground truth to check against, users can't tell — until two readings of the same chart contradict each other.&lt;/p&gt;

&lt;p&gt;Whatever you think of the domain (I wrote about its &lt;a href="https://auspiceoracle.com/en/content/true-solar-time" rel="noopener noreferrer"&gt;genuinely hard timezone math&lt;/a&gt; earlier), the engineering answer is portable to any LLM product that must not make things up. It's one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The deterministic engine decides what is said. The LLM decides only how to say it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The chart, the element strengths, the favorable-element analysis, every derived fact — computed by a rules engine in TypeScript, unit-tested, &lt;a href="https://auspiceoracle.com/en/method" rel="noopener noreferrer"&gt;published constants and all&lt;/a&gt;. The model receives those facts as a compact block and a directive: cite only what's given. It's a translator with a persona, not an oracle.&lt;/p&gt;

&lt;p&gt;That's the easy 80%. The interesting engineering is in three places where the rule almost broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The hard case: a hole in the input
&lt;/h2&gt;

&lt;p&gt;Many users don't know their birth hour — and the hour is one of the four pillars. The naive options are both bad: refuse the user, or let the model improvise around the gap. Guess which one a model does if you just omit the hour: it fills the hole. Silently. With a specific, plausible, invented pillar.&lt;/p&gt;

&lt;p&gt;The fix is to make the engine handle the uncertainty, deterministically. Unknown hour → there are exactly 12 possible charts. Compute all twelve, then take the intersection: only facts that hold in every candidate chart survive into the prompt. Element strength agrees across all 12? State it. It splits 7/5? Then the prompt says, verbatim: "strength undetermined (7 of 12 candidates lean strong) — you may not build on this."&lt;/p&gt;

&lt;p&gt;And crucially, the hole itself is made explicit rather than omitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chart: 己未 丙寅 庚午 ▢   (hour pillar unknown — 12 candidate
charts computed, only facts true in all of them are listed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ▢ earned its place. A stated hole beats a silent one: leave the slot empty and the model backfills it; mark it and instruct ("do not mention the hour pillar; do not discuss the life areas it governs; inventing one is lying") and the model routes around it. The prompt even tells the model how to end gracefully — one sentence noting what more could be seen if the user learns their birth time. Uncertainty became a product feature instead of a hallucination site.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The second gate: validate the output like you don't trust the first gate
&lt;/h2&gt;

&lt;p&gt;Prompts are policy, not enforcement. So generated text passes through a validator before it's stored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invented-pillar detection. For unknown-hour charts, scan the output for any of the 12 candidate hour pillars. The trick is matching the two-character stem-branch pair, never single characters — 子 alone appears inside the ordinary word 孩子 ("child"), 金 inside 资金 ("funds"). Pair matching has essentially no false positives; single-char matching would flag every other sentence.&lt;/li&gt;
&lt;li&gt;Forbidden-pattern scan. Regex list of fatalistic/fear-mongering constructions ("will surely divorce", "short-lived", "incurable") — the domain's dark patterns, encoded. This isn't just taste: every platform policy that governs this vertical (search quality guidelines, ad policies, payment processors) draws its allow/ban line at concrete doom claims vs. interpretive reflection. The regex list is the compliance boundary as code.&lt;/li&gt;
&lt;li&gt;Closed-vocabulary check. A list of star/deity terms the engine never computes; if one appears in the output, the model imported folklore from its training data. Flag for review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The confession: my guardrail was dead code and I didn't notice
&lt;/h2&gt;

&lt;p&gt;The validator originally had a third, stronger check: a whitelist assertion that every stem, branch, and "ten god" term in the output came from the chart JSON. Code-reviewing it months later, I found both of its loops were asking whether a set contained items taken from that same set — a condition that is always true, wired to a check that could therefore never fire. Two supporting arrays were never read at all. It had caught zero violations, ever, and couldn't.&lt;/p&gt;

&lt;p&gt;I deleted it and wrote a comment explaining why, including what a real version would need to solve (the same single-character collision problem as above). Two lessons I now apply everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A guardrail that cannot fire is worse than no guardrail — it shows up in every architecture diagram and code review as "we validate that," and everyone stops thinking about it.&lt;/li&gt;
&lt;li&gt;Test your validators the way you test code: with inputs that must fail. A validation function with no failing test case is a hypothesis, not a gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus fight: prompt-language gravity
&lt;/h2&gt;

&lt;p&gt;The persona and all instructions are written in Chinese; the app also serves English readings. A one-line "respond in English" does not survive contact with a 2,000-character Chinese prompt: the model would ship hybrid sentences into production — actual example: "Your盘的里，其实事业和财这两条线比性格更有讲头" — lifted straight from a Chinese example sentence inside the persona. The fix that held: an explicit paragraph stating that every quoted sentence above is a tone demonstration only, must not be copied or translated, and that the output may contain no Chinese characters except glossed pinyin. When your prompt is bilingual, the language directive has to out-shout the entire rest of the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother, beyond truthfulness
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cost. The model writes 300 words of styled prose per section instead of "reasoning" about the chart. No chain-of-thought needed — thinking mode is off, calls are ~1.5s and fractions of a cent.&lt;/li&gt;
&lt;li&gt;Consistency. Two users with the same chart get stylistic variation on the same facts, not two different fates. Re-reads don't contradict.&lt;/li&gt;
&lt;li&gt;The line is auditable. When a user asks "why does it say that?", there's an engine fact to point to — the same one published on the site's method page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is old, honestly — it's a compiler emitting facts and a pretty-printer rendering them. The only new part is that the pretty-printer went to art school and will invent facts if you let it. Don't let it: compute the truth, mark the holes, validate the output, and test that your validators can actually fail.&lt;/p&gt;

&lt;p&gt;The app: &lt;a href="https://auspiceoracle.com/en/chart" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt; — the engine's scoring constants are public on the &lt;a href="https://auspiceoracle.com/en/method" rel="noopener noreferrer"&gt;method page&lt;/a&gt;, which is the same "show your work" rule applied to marketing.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I spent a month doing AEO for a tiny niche site. Most of the advice was wrong.</title>
      <dc:creator>Shan Liu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 21:35:23 +0000</pubDate>
      <link>https://dev.to/shanni/i-spent-a-month-doing-aeo-for-a-tiny-niche-site-most-of-the-advice-was-wrong-20ad</link>
      <guid>https://dev.to/shanni/i-spent-a-month-doing-aeo-for-a-tiny-niche-site-most-of-the-advice-was-wrong-20ad</guid>
      <description>&lt;p&gt;"Answer Engine Optimization" is the new gold rush: get your site cited by ChatGPT, Perplexity, and Google's AI Overviews. There is an entire cottage industry selling advice on how — add schema markup, publish more pages, buy a tool.&lt;/p&gt;

&lt;p&gt;I run a small bilingual Chinese-astrology calculator (&lt;a href="https://auspiceoracle.com" rel="noopener noreferrer"&gt;auspiceoracle.com&lt;/a&gt;). It's about as niche and low-authority as a site gets, which makes it a decent lab rat: zero brand signal, zero backlinks, nothing to confound the measurement. Before writing a single content page I did two things most AEO advice skips — I read the actual studies, and I set up measurement before launch. Here's what survived contact with the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: schema markup is not an AEO lever
&lt;/h2&gt;

&lt;p&gt;This one hurt, because adding JSON-LD is the single most-repeated piece of AEO advice.&lt;/p&gt;

&lt;p&gt;The best evidence available is an Ahrefs difference-in-differences study: 1,885 pages that added JSON-LD, each matched to 3 control URLs on other domains at similar pre-period citation levels, 30-day windows, four statistical approaches. Result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Citation change after adding JSON-LD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google AI Overviews&lt;/td&gt;
&lt;td&gt;−4.6% (small but significant decline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google AI Mode&lt;/td&gt;
&lt;td&gt;+2.4% — indistinguishable from zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT&lt;/td&gt;
&lt;td&gt;+2.2% — indistinguishable from zero&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The much-quoted counter-stat ("AI-cited pages are 3× more likely to have JSON-LD") is confounding, and Ahrefs says so themselves: schema lives on better-maintained sites. Four independent mechanism studies agree on why — when LLMs fetch a live page they extract visible HTML and ignore the structured-data layer. One test planted facts that existed only in FAQ schema; no platform used them. Another fed models deliberately invalid schema and they happily extracted from it — the script block is being read as plain text.&lt;/p&gt;

&lt;p&gt;What I kept: an extractable, plain-language definition in the first two visible sentences of every content page. That's the thing the machines actually read. Schema stays on the pages as cheap rich-result table stakes, but I budget zero AEO effort against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: page volume is a weak signal, and you can't shortcut brand
&lt;/h2&gt;

&lt;p&gt;The other standard advice is programmatic content: generate hundreds of pages, win on surface area. Ahrefs' correlation study across 75,000 brands ranks the signals that track AI visibility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Spearman ρ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YouTube mentions&lt;/td&gt;
&lt;td&gt;~0.74&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branded web mentions&lt;/td&gt;
&lt;td&gt;0.66–0.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branded search volume&lt;/td&gt;
&lt;td&gt;0.35–0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain Rating&lt;/td&gt;
&lt;td&gt;0.27–0.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number of site pages&lt;/td&gt;
&lt;td&gt;~0.19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backlinks&lt;/td&gt;
&lt;td&gt;~0.18–0.23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the fine print, though, before acting on any row: the sample is filtered to DR&amp;gt;40 brands, the correlations are zero-order (nobody partialled out brand size), and there's no independent replication. The honest inference isn't "make YouTube videos" — it's that AI visibility tracks composite brand prominence, which a new site does not have and cannot fake with page count. For a small site, both the vendor pitch ("more pages!") and the counter-pitch ("pages don't matter!") are extrapolations from a population you're not in. The studies literally sampled pages that already had 100+ AI citations. Yours have zero. Nobody has published data about you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: don't out-define Wikipedia; map the entity gaps
&lt;/h2&gt;

&lt;p&gt;My original plan assumed the English terminology in my niche was unclaimed. It wasn't — Wikipedia holds the head term with an actively-growing article, and LLMs demonstrably over-index on encyclopedic sources. Any page whose job is to out-define Wikipedia is dead on arrival.&lt;/p&gt;

&lt;p&gt;But the MediaWiki API tells you something more useful than "Wikipedia exists": which sub-concepts have no article and no redirect. In my niche, a half-dozen core glossary terms return missing — definitionally seated at the head, structurally scattered below. That gap map, not keyword volume, became the content plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody sells: measurement
&lt;/h2&gt;

&lt;p&gt;AI crawlers have no submission channel. You can't ping GPTBot. Zero crawls means "not discovered yet," not "misconfigured." The only proactive lever is indirect: Bing's index feeds OpenAI's and Copilot's retrieval, so IndexNow (one key file + one POST per publish) is the single highest-leverage submission you can make. Everything else is external links doing discovery work.&lt;/p&gt;

&lt;p&gt;My production box runs Next.js behind a tunnel with no nginx, so there were no access logs to mine. The fix was one line in the middleware — match the AI user-agents, &lt;code&gt;console.log&lt;/code&gt; a line, and the process manager's logs become the dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# UA × hit count, from pm2 logs&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s1"&gt;'[ai-bot]'&lt;/span&gt; ~/.pm2/logs/app-out&lt;span class="k"&gt;*&lt;/span&gt;.log | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two distinctions matter when you read those logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPTBot / ClaudeBot / PerplexityBot&lt;/strong&gt; = your page entered a crawl queue. Necessary, not sufficient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT-User / Perplexity-User / Claude-User&lt;/strong&gt; = a human saw your site cited in an answer and the assistant fetched the page for them. This is the metric. Everything else is leading-indicator noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one thing you cannot retrofit: a baseline. Before the pages went live I ran a fixed panel of six prompts through ChatGPT, Perplexity, and Google (18 cells), recorded who got cited, and archived screenshots. All 18 cells: zero citations of us, as expected. The discipline is the same panel every month, questions never edited — change the questions and you've changed the ruler. Without the pre-launch zero row, any future citation could be "maybe we already had that."&lt;/p&gt;

&lt;p&gt;Early returns, for honesty's sake: on launch day one crawler (ClaudeBot) fetched all ten new pages exactly once each, like it was walking a checklist. The others: zero. Citations: zero. This is a 90-day experiment, not a success story — which is exactly why the baseline row matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contrarian call: let the training crawlers in
&lt;/h2&gt;

&lt;p&gt;Standard advice for content sites is to allow retrieval bots but block training crawlers (CCBot, GPTBot-as-trainer, Google-Extended). I did the opposite — explicit allow for everything.&lt;/p&gt;

&lt;p&gt;The reasoning is cold-start economics. Nobody's model "knows" my site's terminology or that it exists. Being ingested into training data is how that changes, and the lag is a full model generation — a cost you pay now for visibility later. Blocking training crawlers protects content whose value is exclusivity; a new site has none. I wrote down the reversal condition (if content gets scraped-and-republished at scale, or citations stabilize, revisit), which keeps it a decision instead of a default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write the definition in the first two visible sentences. Skip the schema debate entirely.&lt;/li&gt;
&lt;li&gt;Map entity gaps with the MediaWiki API before writing anything.&lt;/li&gt;
&lt;li&gt;Set up IndexNow; accept that everything else is discovery-by-links.&lt;/li&gt;
&lt;li&gt;Log AI user-agents at the edge, and learn the &lt;code&gt;-Bot&lt;/code&gt; vs &lt;code&gt;-User&lt;/code&gt; distinction.&lt;/li&gt;
&lt;li&gt;Record a citation baseline before launch. Same prompts, monthly, forever.&lt;/li&gt;
&lt;li&gt;Treat every AEO study as data about someone else's population until your own logs say otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The meta-lesson: AEO in 2026 is where SEO was in 2003 — long on vendors, short on mechanisms. The mechanisms are checkable. Check them.&lt;/p&gt;

&lt;p&gt;The site in question, if you want to see the "extractable first two sentences" pattern live: &lt;a href="https://auspiceoracle.com/en/method" rel="noopener noreferrer"&gt;how the engine works&lt;/a&gt;, and the &lt;a href="https://auspiceoracle.com/en/content/true-solar-time" rel="noopener noreferrer"&gt;true solar time deep-dive&lt;/a&gt; that became the first post in this series.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
      <category>marketing</category>
    </item>
  </channel>
</rss>
