Skip to content
← Back to blog

Implementing Saju (四柱八字) in TypeScript: Calendar Math, Stem-Branch Cycles, and Solar-Term Boundaries

A technical devlog on implementing the Four Pillars (四柱八字) algorithm in TypeScript — Julian Day anchors, the 五虎遁年起月法 lookup for the month stem, 大運 direction logic, sexagenary cycle indexing, and the boundary cases that hide at solar terms.

by Jay Lee11 min readGuides
Warning: Disclaimer: Saju and astrology are cultural and historical symbol systems, not scientifically validated methods of prediction. This post documents an algorithmic implementation, not an endorsement of any predictive claim.

I wanted to see what it would actually take to implement Saju (四柱八字) from scratch. Not the interpretation layer — just the engine. Calendar math, stem-branch cycles, and the edge cases that hide at solar-term boundaries.

The thing that pulled me in was that 四柱八字 is, before anything else, a deterministic state machine. Two thousand years of accumulated convention, but at the bottom there's a clean specification: a continuous day index, a few lookup tables, modular arithmetic on coprime cycles. That part is purely an algorithm problem, and that's the part this post is about.

I'm a pharmacist by training, so this isn't my "field" in any sense. But the day I noticed that the 五行 (Wood/Fire/Earth/Metal/Water) classification used in 韓醫學 textbooks is the same five-element table that drives the Saju engine, I figured the symbol system at least deserved a careful reading. So I read it the way I'd read any other system spec — and then I implemented it.

This post documents that implementation. It is not a claim about whether the system "works" in a predictive sense.


🏗️ Architecture: Why Saju Got Its Own File

The Destiny Grid already had a Western astrology renderer. My first plan was to add an Eastern column to the same component. My AI pointed out that this would be a nightmare: the Western grid was already ~400 lines, and the Eastern logic shares almost zero abstractions with it. They use different calendars, different cycle lengths, different time anchors, different input fields.

So they're separated. SajuGridClient.tsx is its own ~1,600-line module, lazy-loaded as a dynamic import only when the user clicks the East tab. DestinyGridClient.tsx just owns the tab bar and conditionally renders one renderer or the other.

The East tab takes two extra inputs the Western tab doesn't need:

  • Birth hour (時辰) in the traditional 12-period system — needed for the Hour Pillar
  • Gender — needed for 大運 direction (順行 / 逆行)

URL sharing is straightforward: ?tab=east&hour=4&gender=F reproduces the exact view.


🧭 The Spec, Reduced to a Data Structure

Strip away the interpretive vocabulary and the input/output of the engine looks like this:

Input:  (year, month, day, hour, gender)
Output: 4 pillars × 2 chars  =  8 stem-branch tokens
        +  ordered list of 大運 ten-year periods
        +  per-year indices for the 100-year grid

Each "pillar" is a pair: one Heavenly Stem (天干) ∈ {甲乙丙丁戊己庚辛壬癸} and one Earthly Branch (地支) ∈ {子丑寅卯辰巳午未申酉戌亥}. Stems have length 10, branches have length 12, lcm(10, 12) = 60. That 60-cycle (六十甲子) is the spine of every other index in the system.

 stems  (10) ──┐
               ├──► 60-pair sexagenary cycle ──► used for year, day pillars
branches (12) ─┘                                  (and, with offset, month/hour)

Once you accept the cycle lengths, every pillar reduces to "compute an integer index into the 60-cycle." The hard part is computing the right integer.


⚙️ The Algorithm: Where the Engineering Actually Lives

1. Day Pillar (日柱) via Julian Day Number

The Day Pillar is the load-bearing one. The cycle of days never resets — not at year, not at month, not at any astronomical event. It just runs continuously.

So you need a continuous day index. Reach for Julian Day Number (JDN), the astronomical standard that counts days since 1 January 4713 BC (proleptic Julian). Then pick a known anchor where the 60-cycle index is known, and reduce modulo 60.

I used 2000-01-07 = 甲子日 (cycle index 0), which is widely cross-referenced in calendrical tables.

// Continuous day index → 60-cycle index → stem & branch
const dayIndex = ((julianDayNumber - ANCHOR_JDN) % 60 + 60) % 60;
const stem   = HEAVENLY_STEMS[dayIndex % 10];
const branch = EARTHLY_BRANCHES[dayIndex % 12];

The double-mod (% 60 + 60) % 60) is there because JavaScript's % returns a negative result for negative operands. For dates before the anchor, that single line is the entire bug-or-not-bug.

The genuinely awkward part isn't the modular arithmetic — it's converting (year, month, day) into a JDN that's right across the Julian-to-Gregorian switchover. Any naive Date arithmetic in TypeScript drifts for pre-1582 dates because of the calendar reform. I leaned on the Meeus algorithm (the same one used in astronomical software) and wrote a small test fixture against published JDN values for fixed historical dates. That step took longer than I want to admit.

2. Month Pillar (月柱) via 五虎遁年起月法

The Month Pillar's branch is the easy half: lunar month 1 starts at 寅, month 2 at 卯, and so on through the 12 branches. The branch is fully determined once you know the lunar month.

The stem is the interesting part. It depends on the year stem. The classical lookup is called 五虎遁年起月法 ("Five Tigers Pursue the Year, Find the Month Start") and it pins which stem the first month (寅月) of any given year begins on:

Year Stem Stem of 寅月 (first month)
甲 / 己
乙 / 庚
丙 / 辛
丁 / 壬
戊 / 癸

Once you have that anchor stem, each subsequent month increments the stem index by one (mod 10). The whole rule collapses to a 5-row lookup plus an increment, which is satisfying.

const FIRST_MONTH_STEM_BY_YEAR_STEM: Record<Stem, Stem> = {
: "丙", : "丙",
: "戊", : "戊",
: "庚", : "庚",
: "壬", : "壬",
: "甲", : "甲",
};
 
function monthStem(yearStem: Stem, lunarMonthIndex: number /* 0..11, 寅 = 0 */) {
  const start = FIRST_MONTH_STEM_BY_YEAR_STEM[yearStem];
  return STEMS[(STEMS.indexOf(start) + lunarMonthIndex) % 10];
}

The catch: "lunar month index" here is a solar-term-based month, not a strict lunar month. Saju months change at the 24 solar terms (節氣), specifically at the 12 odd-numbered "節" points (立春, 驚蟄, 清明, ...). 立春 is the boundary between year N's 丑月 and year N+1's 寅月. Which means a person born on 4 February in some years has a year-pillar of N-1, not N. Which means I had to compute solar terms.

That's a separate sub-problem (the position of the sun in ecliptic longitude crossing 15° boundaries), which I solved by precomputing a table of solar-term timestamps in KST and binary-searching it. Calendar math is mostly tables.

3. Hour Pillar (時柱) and Sub-day Branches

Hours map to the 12 branches in 2-hour blocks (子時 = 23:00–01:00, 丑時 = 01:00–03:00, ...). The branch part is just floor((hour + 1) / 2) % 12. The stem part follows the same kind of 5-row lookup as the month — anchored on the day stem instead of the year stem (五鼠遁日起時法). Same shape, different table.

4. 大運 Direction: A Two-Bit Decision

大運 are ten-year segments derived by walking forward or backward through the sexagenary cycle starting from the month pillar. The direction depends on two booleans: whether the year stem is yang/yin (陽/陰), and whether the subject is M/F.

                   ┌─────────────┬─────────────┐
                   │ Year Stem 陽 │ Year Stem 陰 │
   ┌──────────────┼─────────────┼─────────────┤
   │  Male        │  順行 (fwd) │  逆行 (rev) │
   │  Female      │  逆行 (rev) │  順行 (fwd) │
   └──────────────┴─────────────┴─────────────┘

That's the entire rule. Two people with identical (year, month, day, hour) but different gender get different 大運 sequences from their late twenties onward, because they walk the cycle in opposite directions. From a code perspective it's a sign flip on the index increment. From a structural perspective it's the cleanest example I know of where a deterministic system encodes a personalization axis at almost no algorithmic cost.

5. The Annual Layer: 五行 + 合沖刑 + 十二運星

Once you have the four pillars and the 大運 sequence, scoring each year in the 100-year grid is a stack of cheap lookups:

  • 五行 generation/destruction — does the year's element feed (相生) or destroy (相剋) the day stem's element? Two 5-cycle wheels.
  • 合沖刑 — branch-pair relationships (六合, 三合, 沖). All encoded as Map<BranchPair, Relation> constants.
  • 十二運星 — for a given day stem, each of the 12 branches maps to one of 12 life-phase labels. Another constant table.

Each of these is a fixed table; the runtime work is O(1) per year. The 100-year grid renders in under 5 ms in practice.


🎨 Rendering: Two Ink Cards and a Long Grid

The Western tab leans on a planetarium aesthetic. The Eastern tab needed to feel different enough that users didn't read it as "the same chart in another language." I went with an ink-manuscript palette — Crimson → Purple → Gold → Jade → Teal as a 凶 → 大吉 progression, plus an ink-bleed shadow on each card.

Three sections, top to bottom:

  1. 四柱 chart — four columns, each showing stem + branch + element + animal + polarity
  2. 大運 timeline — horizontal bars, one per 10-year segment, labeled with stem-branch + element
  3. 歲運 grid — the 100-year annual grid with the layered score per cell

Element colors follow the traditional 五方色 (Wood = green, Fire = red, Earth = yellow, Metal = white/silver, Water = deep blue), purely because they were already conventional and saved me a design decision.


🤔 The Vocabulary Crossover I Didn't Expect

The thing I didn't expect, going in, is how much the 五行 vocabulary is shared between Saju and 韓醫學 (traditional Korean medicine).

In a 韓醫學 textbook, organs are classified as Wood (liver), Fire (heart), Earth (spleen), Metal (lung), Water (kidney). Flavors are classified the same way. So are seasons, climates, and a chunk of pre-modern materia medica. Whether or not anyone subscribes to the underlying claims, the classification scheme is identical to the one driving the Saju engine. Same 5-cycle generation/destruction wheels, same yin-yang polarity, same branch-element mappings.

For me — coming from a pharmacy background that touches both modern PK/PD and the older Korean herbal corpus — that crossover was the most interesting find of the project. Not a claim about either system. Just an observation that the symbol system is shared, and the people who systematized it once were not running two different ontologies side by side.

People find meaning in symbol systems like this; that's an anthropological fact, and it's why a 2,000-year-old set of conventions is still in active use. That's an observation about humans, not about the system's predictive validity, which I take no position on.


🎯 What Surprised Me, Technically

A few things I didn't predict before I started:

  • The lookup tables are tiny. The full engine fits comfortably in a couple of hundred lines of declarative constants. The complexity isn't in the rules; it's in the calendar input.
  • Almost all the bugs live at solar-term boundaries. A correct engine has to know that 立春 in some years falls on 4 February at, say, 16:42 KST, and that someone born at 16:30 has a different year pillar than someone born at 17:00. Off-by-an-hour is the dominant failure mode.
  • Timezones eat half the implementation budget. The system assumes solar local time at a specific longitude; modern birth records are in clock time at whatever timezone the hospital used. Correcting between the two is its own subroutine and is the single biggest source of disagreement between Saju calculators online.
  • The 大運 direction rule is a beautiful piece of design. It introduces personalization with one bit of input, and from a software perspective it's the cheapest interesting feature in the engine.

The interesting part, for me, wasn't whether the system "works." It was that a 2,000-year-old symbolic apparatus encodes a complete deterministic state machine that maps cleanly onto a few dozen lines of TypeScript and a handful of constant tables. That's a non-trivial design artifact, regardless of what one believes about its interpretive layer.


🔗 Try It

The Saju tab is live in the Destiny Grid at vibed-lab.com/playground/destiny-grid. Click ☯ 四柱八字, enter your birth year/month/day; add hour and gender for the 大運 segment.

Source-of-truth caveat: the engine documents an implementation of a cultural symbol system. It is not a predictive tool. If anything in the on-screen output reads as a claim about your life, that's a UI affordance, not a fact. Treat it the way you'd treat any other generative toy.


2026.03.14

Written by

Jay Lee

Korea-Licensed Pharmacist (#68652) · Senior Researcher

Korea University, College of Pharmacy (B.S. + M.S., drug delivery systems & industrial pharmacy). Building production-grade AI tools across medicine, finance, and productivity — without a CS degree. Domain expertise first, code second.

About the author →
ShareX / TwitterLinkedIn