<?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: Hyunbin Kim</title>
    <description>The latest articles on DEV Community by Hyunbin Kim (@beachcombers).</description>
    <link>https://dev.to/beachcombers</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%2F4094013%2F2b82e6af-19d7-4040-84fe-3722caa4aed3.jpg</url>
      <title>DEV Community: Hyunbin Kim</title>
      <link>https://dev.to/beachcombers</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/beachcombers"/>
    <language>en</language>
    <item>
      <title>A calendar library returned the same answer for every year — and my tests agreed with it</title>
      <dc:creator>Hyunbin Kim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:04:49 +0000</pubDate>
      <link>https://dev.to/beachcombers/a-calendar-library-returned-the-same-answer-for-every-year-and-my-tests-agreed-with-it-1j93</link>
      <guid>https://dev.to/beachcombers/a-calendar-library-returned-the-same-answer-for-every-year-and-my-tests-agreed-with-it-1j93</guid>
      <description>&lt;p&gt;I build a Korean saju (BaZi) service. The whole pitch is that the numbers are computed deterministically and only the prose is written by a model, so the calculation layer is the one part that is not allowed to be vaguely right.&lt;/p&gt;

&lt;p&gt;Two days ago I found out it had been wrong for five years of birthdays, and that my test suite had been cheerfully confirming it the entire time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the function was supposed to do
&lt;/h2&gt;

&lt;p&gt;A saju chart's year and month pillars do not change on January 1st. They change at solar terms — the 24 points where the sun reaches a fixed ecliptic longitude. The year pillar turns at 입춘 (315°), which lands somewhere around February 4th, at a specific &lt;em&gt;minute&lt;/em&gt; that differs every year, because the Earth's orbit does not care about our calendar.&lt;/p&gt;

&lt;p&gt;So if you are born on February 4th, whether your chart says 癸卯 or 甲辰 depends on what time of day you were born, compared against an astronomical instant.&lt;/p&gt;

&lt;p&gt;I was getting those instants from a calendar library:&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;terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSolarTermsByYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;An agent working on unrelated copy flagged a contradiction: a FAQ page claimed minute-level solar term precision, while a comment in the engine said the data only covered 2020–2030. I went to check which one was lying.&lt;/p&gt;

&lt;p&gt;I dumped 입춘 for eleven consecutive years and diffed it against an independent astronomical computation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        library          computed (KST)
2024    Feb 4, 05:02     Feb 4, 17:26
2025    Feb 4, 05:02     Feb 3, 23:10
2026    Feb 4, 05:02     Feb 4, 05:01
2027    Feb 4, 05:02     Feb 4, 10:46
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven years, one value. The function took a &lt;code&gt;year&lt;/code&gt; argument and ignored it. It happened to be right for 2026 — presumably whenever the table was generated — and wrong for everything else, sometimes by twelve hours, sometimes landing on the wrong &lt;em&gt;day&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Twelve hours is not a rounding error here. It is the difference between two different charts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2024-02-04 06:00  →  癸卯 乙丑   (before 17:26)
2024-02-04 18:00  →  甲辰 丙寅   (after)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same date. Opposite answer. Anyone born on a solar term day between 2020 and 2030 — except 2026 — could have been handed the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the part that actually bothered me
&lt;/h2&gt;

&lt;p&gt;I have a golden test suite. Solar term boundaries are the first thing it locks. It was green the whole time.&lt;/p&gt;

&lt;p&gt;Here is the shape of what it asserted:&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="c1"&gt;// probes chosen around the "known" boundary of 05:02&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chartFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-02-04&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;04:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;癸卯&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chartFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-02-04&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;05:03&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;甲辰&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those passed. Of course they did. The engine read the boundary from the library, and the fixture values were written by reading the boundary from the library. &lt;strong&gt;My test and my code shared a single source of truth, so the test could only ever confirm the library's opinion — never reality.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the bug class, and it has nothing to do with calendars. A test is only an oracle if it knows something the code does not. The moment your expected values are derived from the same dependency the code under test calls, the assertion degrades into a change detector: it will tell you when behavior &lt;em&gt;changes&lt;/em&gt;, and it will never tell you the behavior was wrong to begin with.&lt;/p&gt;

&lt;p&gt;The second failure was subtler. Every probe I had chosen — 04:00, 05:01, 05:03 — sat on the same side of the &lt;em&gt;real&lt;/em&gt; boundary at 17:26. So even a test written against real-world truth would have passed with the wrong table, because my examples all fell into one bucket. Example-based tests cannot see a parameter being ignored unless the examples straddle something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that would have caught it
&lt;/h2&gt;

&lt;p&gt;One property, no domain knowledge required:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;different years produce different solar term instants&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="o"&gt;=&amp;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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2020&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2030&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;solarTermsOfYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;입춘&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="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hour&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minute&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="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// the old table produced 1&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asserts a &lt;em&gt;relationship&lt;/em&gt; rather than a value: this function's output must vary with its argument. It would have failed on day one, in a single line, without me knowing a single thing about astronomy.&lt;/p&gt;

&lt;p&gt;I now think of this as the cheapest test you can write against any parameterized lookup — tables, caches, config resolvers, i18n bundles, feature flags, per-tenant settings. Anything with the shape &lt;code&gt;f(key) =&amp;gt; data&lt;/code&gt; deserves one assertion that says &lt;em&gt;f actually reads key&lt;/em&gt;. It is the class of bug that produces confident, plausible, uniformly wrong output, which is the worst kind to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I stopped looking the values up and started computing them. &lt;code&gt;astronomy-engine&lt;/code&gt; was already a dependency for something else, and solar terms are just a root-find on solar longitude:&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="c1"&gt;// 입춘 = the moment the sun reaches 315° ecliptic longitude.&lt;/span&gt;
&lt;span class="c1"&gt;// Search from 15 days before the nominal date, over a 30-day window:&lt;/span&gt;
&lt;span class="c1"&gt;// wider than the ~15-day gap between terms, narrower than the next crossing.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MakeTime&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;15&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SearchSunLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-four terms per year, 1900–2050, cached per year, returned as both wall-clock and absolute instant. The library stayed for what it is good at — sexagenary cycle and lunar/solar date conversion — and lost the job it was silently failing.&lt;/p&gt;

&lt;p&gt;Then the golden test got re-anchored to the real boundary, with probes at 17:25 and 17:28 instead of on one side of a fiction, plus the variance property above.&lt;/p&gt;

&lt;p&gt;Three things I would do differently, and will next time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cross-check a dependency against an independent implementation before trusting it as an oracle.&lt;/strong&gt; Not continuously — once, at adoption, on a spread of inputs. It took twenty minutes and would have saved five years of charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose probes that straddle the boundary you are testing, computed from the truth, not from the code.&lt;/strong&gt; If your fixtures come out of the system under test, you have written a snapshot, not a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert variance on anything parameterized.&lt;/strong&gt; One line. Do it before the interesting assertions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Housekeeping, since someone will ask
&lt;/h2&gt;

&lt;p&gt;Charts already generated are stored as payloads and are not silently recomputed — a saved reading stays what it was when it was issued, and we regenerate on request if a birth date falls in the affected window. Rewriting people's charts underneath them without telling them seemed worse than the bug.&lt;/p&gt;

&lt;p&gt;The calendar engine is open source as &lt;a href="https://github.com/bunhine0452/k-saju" rel="noopener noreferrer"&gt;&lt;code&gt;k-saju&lt;/code&gt;&lt;/a&gt; (MIT, TypeScript). The astronomical version is published — &lt;a href="https://github.com/bunhine0452/k-saju/releases/tag/v0.1.3" rel="noopener noreferrer"&gt;v0.1.3&lt;/a&gt; and up; 0.1.0 has the stale table, so upgrade if you installed it early. The corrected engine is live in the product at &lt;a href="https://www.ioreum.com/en" rel="noopener noreferrer"&gt;ioreum.com/en&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I am deliberately not naming the library. I have not filed an issue yet, and honestly the interesting part is not that a table went stale. It is that I built a test suite around it that was structurally incapable of noticing.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Rendering CJK-heavy share cards with satori (and the three things that broke)</title>
      <dc:creator>Hyunbin Kim</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:01:40 +0000</pubDate>
      <link>https://dev.to/beachcombers/rendering-cjk-heavy-share-cards-with-satori-and-the-three-things-that-broke-5amm</link>
      <guid>https://dev.to/beachcombers/rendering-cjk-heavy-share-cards-with-satori-and-the-three-things-that-broke-5amm</guid>
      <description>&lt;p&gt;I run a small Korean saju (Four Pillars) reading service. Every reading ends with a share card — a PNG with the person's day pillar in Chinese characters, a hand-drawn animal, and a one-line epithet in Korean or English. We also generate 60 Pinterest pins and Open Graph images for a few hundred dictionary pages the same way.&lt;/p&gt;

&lt;p&gt;All of it is rendered with &lt;a href="https://github.com/vercel/satori" rel="noopener noreferrer"&gt;satori&lt;/a&gt; through Next.js's &lt;code&gt;ImageResponse&lt;/code&gt;. Satori is wonderful: you write JSX, you get a PNG, no headless browser. It is also a very specific subset of HTML/CSS, and CJK text hits the edges of that subset faster than Latin text does. Here is what broke, in the order it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your font does not have the glyph, and satori will not tell you
&lt;/h2&gt;

&lt;p&gt;The brand font is a Korean handwriting face. It covers Hangul and Latin. It does &lt;strong&gt;not&lt;/strong&gt; cover the hanja (Chinese characters) that a saju chart is made of — &lt;code&gt;甲子&lt;/code&gt;, &lt;code&gt;丙寅&lt;/code&gt;, and so on. Rendered with only that font, every pillar came out as tofu boxes.&lt;/p&gt;

&lt;p&gt;Satori resolves glyphs per character across the fonts you pass, in order. So the fix is not "find one font with everything" — it is "pass a fallback set, most specific first":&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="c1"&gt;// og-fonts.ts — module-cached; the TTFs are 2 MB each and this runs per request&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;son&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;han&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadHandFonts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;son&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;han&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/assets/fonts/OreumSon.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c1"&gt;// Hangul + Latin&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/assets/fonts/OreumHan.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c1"&gt;// hanja&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;son&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;han&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handFontOptions&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;son&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;han&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadHandFonts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OreumSon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;son&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OreumHan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;han&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&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;and in the element tree, &lt;code&gt;fontFamily: 'OreumSon, OreumHan'&lt;/code&gt;. Hangul and Latin come from the first face, hanja fall through to the second. This is exactly the &lt;code&gt;unicode-range&lt;/code&gt; trick you would do in CSS &lt;code&gt;@font-face&lt;/code&gt; — satori just makes you do it with the font array.&lt;/p&gt;

&lt;p&gt;Two things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Satori wants TTF/OTF, not woff2.&lt;/strong&gt; Our web fonts are woff2 for the browser; we keep TTF copies in &lt;code&gt;src/assets/fonts&lt;/code&gt; just for image routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a literal &lt;code&gt;path.join(process.cwd(), '...')&lt;/code&gt;.&lt;/strong&gt; Vercel's output file tracing follows the literal string and bundles the font with the function. Build the path dynamically and the file is not there at runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. There is no &lt;code&gt;mask-image&lt;/code&gt;, and there is no CSS file
&lt;/h2&gt;

&lt;p&gt;Our light/dark theme in the browser is done with icon masks: an SVG alpha mask, colored with &lt;code&gt;background-color&lt;/code&gt;, so one asset serves both themes. Satori does not support &lt;code&gt;mask-image&lt;/code&gt; (or &lt;code&gt;filter&lt;/code&gt;, or &lt;code&gt;backdrop-filter&lt;/code&gt;). It also does not read your stylesheet — every value must be inline in the JSX.&lt;/p&gt;

&lt;p&gt;So the image routes do not share the site's CSS at all. They carry a tiny token mirror 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="c1"&gt;// tokens mirrored from oreum.css — light theme only&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fdfbf3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#221d16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;seal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#b1402b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;faded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#8a8166&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(34, 29, 22, 0.5)&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="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the cards are &lt;strong&gt;light-only&lt;/strong&gt;. Partly because masks are unavailable, partly because the places these images land (Pinterest feeds, link previews) are light backgrounds anyway. Accept the constraint; do not fight it with tricks.&lt;/p&gt;

&lt;p&gt;For the animal art, we pre-render each of the twelve zodiac animals as a PNG with ink strokes baked in and inline it as a data URI:&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;artCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadAnimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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="nb"&gt;Promise&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="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;artCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/assets/card-animals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="nx"&gt;artCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`data:image/png;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;artCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the pin must still render without the art&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;artCache&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="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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;Note the &lt;code&gt;null&lt;/code&gt; branch. An image route that 500s because an optional asset is missing takes your whole Open Graph preview down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Every multi-child &lt;code&gt;div&lt;/code&gt; needs &lt;code&gt;display: flex&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one is documented, and I still hit it three times. Satori's layout engine is Yoga; a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; with more than one child must declare &lt;code&gt;display: 'flex'&lt;/code&gt; or you get a runtime error, not a fallback. It is easy to forget on the &lt;em&gt;inner&lt;/em&gt; wrappers — the outer card is obviously flex, the little row that holds "element · stage · seated god" is not obviously anything, and that is the one that throws.&lt;/p&gt;

&lt;p&gt;My rule now: every &lt;code&gt;div&lt;/code&gt; in an image route gets &lt;code&gt;display: 'flex'&lt;/code&gt; unless it has exactly one text child. &lt;code&gt;span&lt;/code&gt; is for text only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the CJK detail actually changes
&lt;/h2&gt;

&lt;p&gt;The hanja are the payload. A day pillar &lt;em&gt;is&lt;/em&gt; two characters — showing them big, in the correct face, is the entire point of the card. If they render as boxes the card is decoration; if they render in a calligraphic face next to a handwriting Latin face, it reads as one object. The font fallback array is a five-line change and it is the difference between the two.&lt;/p&gt;

&lt;p&gt;Sizes, for reference: the 2:3 Pinterest pin is 1000×1500 with the pillar at 116px and the hanja block at 210px; the 1200×630 OG card puts the two characters at 150px on the left and the title at 72px. Both come out around 50–160 KB as PNG.&lt;/p&gt;

&lt;p&gt;The rendering code runs on a deterministic table — every one of the 60 pins is computed from the same data the dictionary pages use, so the pin can't disagree with the page. That part is not a satori trick; it is just refusing to hand-write sixty descriptions.&lt;/p&gt;




&lt;p&gt;If you want to see the output: the 60 day-pillar pins live at &lt;a href="https://www.ioreum.com/en/day-master" rel="noopener noreferrer"&gt;&lt;code&gt;ioreum.com/en/day-master&lt;/code&gt;&lt;/a&gt;, and the calendar math underneath is open source as &lt;a href="https://github.com/bunhine0452/k-saju" rel="noopener noreferrer"&gt;&lt;code&gt;k-saju&lt;/code&gt;&lt;/a&gt; (MIT, TypeScript).&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Your LLM cannot compute a birth chart (and it won't tell you)</title>
      <dc:creator>Hyunbin Kim</dc:creator>
      <pubDate>Sun, 30 Aug 2026 03:40:10 +0000</pubDate>
      <link>https://dev.to/beachcombers/your-llm-cannot-compute-a-birth-chart-and-it-wont-tell-you-5gma</link>
      <guid>https://dev.to/beachcombers/your-llm-cannot-compute-a-birth-chart-and-it-wont-tell-you-5gma</guid>
      <description>&lt;p&gt;There is a genre of prompt going around: paste your birth date and time into a chatbot and ask for your Korean saju (or Chinese BaZi) chart — the four pillars, the day master, the whole thing. The answers read beautifully. They are also, structurally, guesses.&lt;/p&gt;

&lt;p&gt;I run a saju reading service, so I care about this for selfish reasons. But the failure mode is interesting on its own, because it is the quiet kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a chart actually requires
&lt;/h2&gt;

&lt;p&gt;A Four Pillars chart is not text. It is the output of a calendar function:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Map the birth instant onto the &lt;strong&gt;sexagenary cycle&lt;/strong&gt; — a 60-step cycle of stem–branch pairs that has been ticking continuously for centuries. The day pillar is literally "which of the 60 is today", counted from a known epoch.&lt;/li&gt;
&lt;li&gt;Decide the &lt;strong&gt;year and month pillars&lt;/strong&gt; from the 24 solar terms, which are astronomical instants (the sun reaching a given ecliptic longitude), not calendar dates. The year does not turn on January 1 or on lunar new year; it turns at 입춘 (ipchun) — in 2024 that was February 4, &lt;strong&gt;16:27 KST&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Correct the &lt;strong&gt;hour pillar&lt;/strong&gt; for true solar time — Korea keeps time on 135°E, Seoul sits near 127°E, so civil noon and solar noon differ by ~32 minutes before you add the equation of time.&lt;/li&gt;
&lt;li&gt;Apply the &lt;strong&gt;late-night hour convention&lt;/strong&gt; (23:00 belongs to the next day for the hour stem, under one common school).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every one of these is exact arithmetic on instants. None of it is something you can approximate from having read a lot of astrology text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a language model gets it wrong in a specific way
&lt;/h2&gt;

&lt;p&gt;A general LLM does not run a sexagenary counter. It predicts what a chart &lt;em&gt;usually looks like&lt;/em&gt; for a date that &lt;em&gt;looks like&lt;/em&gt; yours. That works surprisingly often for the easy 95% of dates — the model has seen enough tables. It breaks at the boundaries, which are exactly the cases where a chart is decided:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;born on the morning of ipchun → it hands you the new year's pillar when you are still in the old one;&lt;/li&gt;
&lt;li&gt;born at 23:31 → it does not know which school's convention it silently picked;&lt;/li&gt;
&lt;li&gt;born abroad → it compares your local date to a Korean solar-term date, when the term is a single global instant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here is the part that matters: &lt;strong&gt;it will not say "I'm not sure."&lt;/strong&gt; The output has the same confident register whether the day pillar is right or off by one. There is no error term. A calculator that is wrong looks exactly like a calculator that is right, until you check it against an almanac.&lt;/p&gt;

&lt;p&gt;You can test this yourself. Ask any chatbot for the four pillars of &lt;code&gt;2024-02-04 04:00, Seoul&lt;/code&gt;, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 2024-02-04 04:00
&lt;span class="c"&gt;# year 癸卯 — still the old year pillar, because 04:00 &amp;lt; 16:27&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then try &lt;code&gt;2000-05-15 23:31&lt;/code&gt; and &lt;code&gt;2000-05-05 09:30 --lon 124.7&lt;/code&gt;. The boundary cases are where the two answers diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture that follows from this
&lt;/h2&gt;

&lt;p&gt;If the numbers must be exact and the prose can be fluent, the two jobs should not live in the same component. Our split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;deterministic engine&lt;/strong&gt; computes the chart — solar terms stored as instants, sexagenary counting, true-solar-time correction, the hour convention written down rather than implied. It is a pure TypeScript module with golden tests for every boundary above. The calendar core is open source as &lt;a href="https://github.com/bunhine0452/k-saju" rel="noopener noreferrer"&gt;&lt;code&gt;k-saju&lt;/code&gt;&lt;/a&gt; (MIT).&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;LLM writes the reading&lt;/strong&gt; — and only the reading. It receives the computed facts as input and produces prose. It is not allowed to introduce a pillar, a stem, or a number that is not in its input; a sanitizer drops any section that does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model is the writer, not the calculator. That sentence is the whole design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same split, exposed as tools
&lt;/h2&gt;

&lt;p&gt;Since chat assistants are where people are asking these questions, we put the calculator where the questions are: an MCP server (&lt;code&gt;ioreum.com/api/mcp&lt;/code&gt;) that ChatGPT and Claude can call as tools. Four of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tool&lt;/th&gt;
&lt;th&gt;returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculate_saju&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the four pillars, elements, day master, hidden stems — computed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;luck_pillars&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the ten-year cycles with start ages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check_zodiac_year&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;which zodiac year a date falls in, with the ipchun boundary handled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;day_master_character&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reference entry for any of the sixty day pillars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server instructions are explicit that the tools return calculated facts and that written interpretation lives elsewhere — so the assistant has no reason to invent a reading in the chart's voice. Setup is one URL; details at &lt;a href="https://www.ioreum.com/en/connector" rel="noopener noreferrer"&gt;&lt;code&gt;ioreum.com/en/connector&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;I am not claiming the &lt;em&gt;interpretation&lt;/em&gt; of a chart is verifiable. It is not; it is a tradition, and reasonable schools disagree. The claim is narrower: the &lt;strong&gt;calculation&lt;/strong&gt; is verifiable, so it should be done by something that can be verified — and a language model is the one component in the stack that cannot be.&lt;/p&gt;

&lt;p&gt;If you build anything on top of traditional calendars — Chinese, Korean, Hindu panchang, anything with astronomical boundaries — the same rule applies. Compute first. Let the model write.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why every BaZi calculator disagrees with the almanac</title>
      <dc:creator>Hyunbin Kim</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:02:44 +0000</pubDate>
      <link>https://dev.to/beachcombers/why-every-bazi-calculator-disagrees-with-the-almanac-19nf</link>
      <guid>https://dev.to/beachcombers/why-every-bazi-calculator-disagrees-with-the-almanac-19nf</guid>
      <description>&lt;p&gt;Every Four Pillars calculator — saju in Korea, BaZi in China — agrees on the easy 95% of the job. Feed it a birth date and it maps that instant onto a traditional calendar: four pillars, each a heavenly stem paired with an earthly branch.&lt;/p&gt;

&lt;p&gt;The remaining 5% is boundaries. And at the boundaries, nearly all of them quietly disagree with the printed almanac they claim to reproduce.&lt;/p&gt;

&lt;p&gt;I maintain a saju reading service, and getting these four cases right was most of the actual engineering. Here they are, with the failing inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A solar term is an instant, not a date
&lt;/h2&gt;

&lt;p&gt;The year pillar does not turn on January 1, and not on lunar new year either. It turns at 입춘 (ipchun, "start of spring") — one of the 24 solar terms, defined by the sun's apparent longitude. In 2024 that moment was &lt;strong&gt;February 4, 17:27 KST&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A calculator that applies solar terms at &lt;em&gt;day&lt;/em&gt; granularity says "February 4 → new year pillar" and hands the wrong year to everyone born that morning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 2024-02-04 04:00
&lt;span class="c"&gt;# year 癸卯 — still the old year pillar, because 04:00 &amp;lt; 16:27&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is unglamorous: store term boundaries as instants and compare instants. The subtlety is that this correction applies to the &lt;strong&gt;year and month&lt;/strong&gt; pillars only — the day pillar runs on its own sexagenary count and must not be touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The 23:00 hour belongs to two days at once
&lt;/h2&gt;

&lt;p&gt;Traditional practice starts the day at 23:00, not midnight — the hour of the Rat (자시). So for a birth at 23:31, there are two defensible answers about which day's stem the hour pillar derives from, and schools split on it.&lt;/p&gt;

&lt;p&gt;The convention this engine declares: the &lt;strong&gt;day pillar keeps clock midnight&lt;/strong&gt;, while the &lt;strong&gt;hour stem takes the next day's stem&lt;/strong&gt; (the 야자시 rule).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 2000-05-15 23:31
&lt;span class="c"&gt;# day 癸酉, hour 甲子&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am not claiming this is the One True Rule. I am claiming it should be written down. Most tools pick a side in silence, which is how two calculators give one person two charts and neither can explain why.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The clock is not the sun
&lt;/h2&gt;

&lt;p&gt;Korea keeps time on the 135°E meridian. Seoul sits near 127°E. That is about &lt;strong&gt;32 minutes&lt;/strong&gt; of difference between civil noon and solar noon — before you add the equation of time, which swings solar noon by up to ~16 minutes across the year.&lt;/p&gt;

&lt;p&gt;Hour pillars are two-hour buckets. A half-hour error puts a birth in the wrong bucket often enough to matter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 2000-05-05 09:30 &lt;span class="nt"&gt;--lon&lt;/span&gt; 124.7   &lt;span class="c"&gt;# hour 丙辰&lt;/span&gt;
npx k-saju 2000-05-05 09:30               &lt;span class="c"&gt;# hour 丁巳  (Seoul default)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same wall clock, different hour pillar, purely from longitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Foreign births need absolute time
&lt;/h2&gt;

&lt;p&gt;This one bites hardest. A solar term is an astronomical instant, global. If someone is born in New York on February 3 at 16:00, that is already February 4 in Korea — and possibly already past the term.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 2024-02-03 16:00 &lt;span class="nt"&gt;--place&lt;/span&gt; new-york
&lt;span class="c"&gt;# year 甲辰 — the new year pillar, on a local date of "Feb 3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare local &lt;em&gt;dates&lt;/em&gt; against KST term &lt;em&gt;dates&lt;/em&gt; and you get this backwards. Convert both to absolute time and compare instants, and it falls out correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing claims instead of asserting them
&lt;/h2&gt;

&lt;p&gt;All four examples above are golden tests. The README table's example commands &lt;em&gt;are&lt;/em&gt; the test inputs, so a claim that drifts from the code fails CI rather than sitting there being wrong.&lt;/p&gt;

&lt;p&gt;That matters more than usual in this domain, because "accuracy" claims here normally hide two things: which school's conventions were chosen, and where the dataset runs out. Mine, stated openly: minute-exact term instants cover 2020–2030 (dataset range) and degrade to day granularity outside it; luck-pillar start ages use day-granular boundaries and can differ from a paper almanac by ±1 year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is deliberately missing
&lt;/h2&gt;

&lt;p&gt;Interpretation. The library turns a birth instant into symbolic coordinates and stops.&lt;/p&gt;

&lt;p&gt;I think that split is the honest one. The calendar math is verifiable — you can check it against an almanac and I can hand you a test suite. What those eight characters &lt;em&gt;mean&lt;/em&gt; is not verifiable, and blending the two is how this whole genre earned its reputation. (My commercial product, &lt;a href="https://www.ioreum.com/en" rel="noopener noreferrer"&gt;ioreum&lt;/a&gt;, does write readings — with an LLM that is structurally forbidden from touching a number.)&lt;/p&gt;

&lt;p&gt;The engine is MIT: &lt;strong&gt;&lt;a href="https://github.com/bunhine0452/k-saju" rel="noopener noreferrer"&gt;https://github.com/bunhine0452/k-saju&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx k-saju 1995-03-16 07:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have ever tried to reconcile two calculators at 2 a.m., issue #1 — extending minute-exact solar terms to 1900–2050 astronomically — is open and genuinely fun.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
