<?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 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>
