research brief · fed to the builders

Divination: I Ching structure and the ephemeris seed

King Wen structure and the Bayer bijection; a real ephemeris with no library; a deterministic 32-bit seed from a date.

For the builder implementing this as vanilla JS/WebGL1/Canvas2D in a single HTML file, no libraries. Two independent deliverables: (1) the King Wen hexagram structure and its bijection onto the Bayer-8 matrix already in the canon, (2) real, cite-able astronomical computation producing a reproducible 32-bit seed from a Date. Studied first: /home/claude/mn/public/preview/hexagram-grid.html and /home/claude/mn/src/canon/dither.js.


Part 1 — King Wen structure and the Bayer bijection

1.1 Trigram binary encoding (Fu Xi / Shao Yong values)

Bottom line = bit 0, yang = 1, yin = 0 (same convention hexagram-grid.html already uses). The eight trigrams:

TrigramLines (bottom→top)Value
Qian ☰ (Heaven)yang yang yang7
Dui ☱ (Lake)yang yang yin3
Li ☲ (Fire)yang yin yang5
Zhen ☳ (Thunder)yang yin yin1
Xun ☴ (Wind)yin yang yang6
Kan ☵ (Water)yin yang yin2
Gen ☶ (Mountain)yin yin yang4
Kun ☷ (Earth)yin yin yin0

A hexagram's 6-bit value = lower_trigram | (upper_trigram << 3). This is exactly what hexagram-grid.html already builds (value = ((7-row)<<3)|col), and it's why the file's own comment can say Fu Xi order is "plain binary 0..63" — the natural binary count 0→63, read as six lines bottom-up, already is a complete, non-arbitrary ordering of all 64 hexagrams. No table is needed for Fu Xi/Shao Yong order; it falls out of the encoding.

1.2 King Wen order — data, not arithmetic

King Wen order is not derivable from the binary count — it's a received sequence (traditionally attributed to King Wen of Zhou and the Duke of Zhou, ~11th c. BCE; the numbering here follows the standard Wilhelm–Baynes edition, the one nearly every English reference uses). Give the builder the trigram pair per number and let code compute the 6-bit value — hand-computing 64 decimal values invites transcription error; the pair table is the stable, checkable artifact:

// King Wen order, 1-indexed. [upper, lower] trigram letters:
// Q=Qian D=Dui L=Li Z=Zhen X=Xun K=Kan G=Gen U=Kun
const KING_WEN = [
  null, // pad so index === hexagram number
  ['Q','Q'],['U','U'],['K','Z'],['G','K'],['K','Q'],['Q','K'],['U','K'],['K','U'],
  ['X','Q'],['Q','D'],['U','Q'],['Q','U'],['Q','L'],['L','Q'],['U','G'],['Z','U'],
  ['D','Z'],['G','X'],['U','D'],['X','U'],['L','Z'],['G','L'],['G','U'],['U','Z'],
  ['Q','Z'],['G','Q'],['G','Z'],['D','X'],['K','K'],['L','L'],['D','G'],['Z','X'],
  ['Q','G'],['Z','Q'],['L','U'],['U','L'],['X','L'],['L','D'],['K','G'],['Z','K'],
  ['G','D'],['X','Z'],['D','Q'],['Q','X'],['D','U'],['U','X'],['D','K'],['K','X'],
  ['D','L'],['L','X'],['Z','Z'],['G','G'],['X','G'],['Z','D'],['Z','L'],['L','G'],
  ['X','X'],['D','D'],['X','K'],['K','D'],['X','D'],['Z','G'],['K','L'],['L','K'],
];
const TRIGRAM_VALUE = { Q:7, D:3, L:5, Z:1, X:6, K:2, G:4, U:0 };
const kingWenValue = n => {
  const [up, lo] = KING_WEN[n];
  return TRIGRAM_VALUE[lo] | (TRIGRAM_VALUE[up] << 3);
};

Verify this table against a printed Wilhelm–Baynes source before treating it as ground truth — it's reproduced from memory of a very standard reference, not derived, and a single transposed pair breaks the whole sequence silently (it'll still be a permutation of 64, just the wrong one). A cheap runtime check: kingWenValue(1) === 63 (all yang), kingWenValue(2) === 0 (all yin), kingWenValue(29) === kingWenValue(30's mirror)... see below.

1.3 Pairing structure

King Wen is organized as 32 pairs, adjacent (2k-1, 2k). Two pairing rules operate, and exactly one applies to each hexagram:

This gives a clean, checkable invariant for whichever 64 you render: for every King Wen pair (2k-1, 2k), either reverse6(v[2k-1]) === v[2k] or v[2k-1] ^ v[2k] === 0b111111. Use it as a self-test on the table in §1.2, not just decoration.

1.4 The Bayer-8 bijection — recommendation

Map hexagram → grid cell by (row = upper trigram value, col = lower trigram value), then read that cell's existing Bayer-8 threshold as the hexagram's intensity/rank. No new indexing scheme — overlay directly onto the grid hexagram-grid.html already draws.

Why this is the meaningful bijection and not just a same-cardinality coincidence: look at how bayerMatrix() is actually built (/home/claude/mn/src/canon/dither.js:21) —

M1  = [[0]]
M2n = [[ 4M+0, 4M+2 ],
       [ 4M+3, 4M+1 ]]

Three applications of this doubling take a 1×1 matrix to 8×8. Each doubling adds exactly one bit to the row address and one bit to the column address — the matrix is defined as a 3-level recursive quadrant refinement, i.e. 3 bits of row, 3 bits of column, built compositionally. A hexagram is defined the same way: 3 bits of upper trigram, 3 bits of lower trigram, and King Wen's own trigram theory already treats a hexagram as two trigrams stacked, each trigram itself built line-by-line (a hexagram is two nested 3-bit refinements). The two structures aren't merely both size-64 — they're both "two independent 3-bit addresses assembled by recursive halving," which is the actual reason Bayer-8 and the trigram square are isomorphic as data shapes, not just as counts.

So: row = upperTrigramValue(hex), col = lowerTrigramValue(hex), then bayerThreshold(8)(col, row) gives that hexagram's dither rank in [0,1) — usable directly as a per-hexagram brightness, reveal-order, or halftone-cell assignment, with zero extra lookup structure. It also means: the traditional Shao Yong "before-heaven" 8×8 square (the one Leibniz was shown) and the Bayer-8 ordered-dither matrix are the same object wearing two labels — worth stating in the piece itself, since it's the kind of thing CANON-SEED rule 6 ("hidden depth rewards inspection") wants: someone who checks the math finds it's not decoration.

Alternative rejected: mapping King-Wen-sequence-position n (1–64) directly to raster-scan Bayer cell n-1. Simpler, but throws away the actual algebraic correspondence — it would work for any two size-64 sets and teaches the viewer nothing true about either structure. Don't use it.


Part 2 — Real ephemeris, no library

All of this needs a Julian Day helper first (Meeus, Astronomical Algorithms, ch. 7 — the standard Gregorian-calendar JD formula):

function toJulianDay(date) {
  const Y0 = date.getUTCFullYear(), M0 = date.getUTCMonth() + 1;
  const d = date.getUTCDate() + (date.getUTCHours() + date.getUTCMinutes()/60
           + date.getUTCSeconds()/3600) / 24;
  let Y = Y0, M = M0;
  if (M <= 2) { Y -= 1; M += 12; }
  const A = Math.floor(Y / 100);
  const B = 2 - A + Math.floor(A / 4);
  return Math.floor(365.25 * (Y + 4716)) + Math.floor(30.6001 * (M + 1)) + d + B - 1524.5;
}
function fromJulianDay(jd) {           // inverse — JD 2440587.5 == Unix epoch
  return new Date((jd - 2440587.5) * 86400000);
}

2.1 Moon phase

Two independent methods — use the second, it's simpler and more directly astronomical:

(a) Conway's algorithm (John H. Conway & Richard Guy, The Book of Numbers, 1996) — calendar arithmetic, no JD needed, accurate to the day:

function moonAgeConway(date) {
  const y = date.getUTCFullYear(), m = date.getUTCMonth() + 1, d = date.getUTCDate();
  let r = y % 100;
  r %= 19;
  if (r > 9) r -= 19;
  r = ((r * 11) % 30) + m + d;
  if (m < 3) r += 2;
  r -= (y < 2000) ? 4 : 8.3;
  r = Math.floor(r + 0.5) % 30;
  return r < 0 ? r + 30 : r;              // 0 = new moon, ~14.8 = full
}

(b) Synodic-period method (a known new moon epoch + the mean synodic month — this is the "real" astronomical quantity, the one Conway's arithmetic is a shortcut for):

const SYNODIC_MONTH = 29.530588861;         // mean, days (IAU)
const KNOWN_NEW_MOON_JD = 2451549.5;        // 2000-01-06 18:14 UTC

function moonPhase01(date) {                // 0 = new, 0.5 = full, →1 = new again
  const days = toJulianDay(date) - KNOWN_NEW_MOON_JD;
  const cycles = days / SYNODIC_MONTH;
  return cycles - Math.floor(cycles);
}

Recommend (b): one constant, one epoch, directly legible as "phase = position in the current lunation," and it doesn't carry Conway's calendar-era fudge terms (-4/-8.3) which are a giveaway that it's curve-fit rather than derived. Both are accurate to well under a day near J2000 and drift slowly (the true synodic month varies ±0.5 day across a cycle due to orbital eccentricity) — fine for "accurate to the day," not for eclipse-grade work.

2.2 Day of year

function dayOfYear(date) {
  const start = Date.UTC(date.getUTCFullYear(), 0, 1);
  const today = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
  return Math.floor((today - start) / 86400000) + 1;   // 1..366
}

2.3 Sunrise / sunset (needed for planetary hours)

The NOAA sunrise equation, derived from Meeus — low-precision solar position, good to about a minute of time, which is what planetary-hour granularity needs:

const D2R = Math.PI / 180, R2D = 180 / Math.PI;

function sunTimes(date, lat, lon) {
  const JD = toJulianDay(date);
  const n = Math.round(JD - 2451545.0 + 0.0008);
  const Jstar = n - lon / 360;
  const M = (357.5291 + 0.98560028 * Jstar) % 360;
  const Mr = M * D2R;
  const C = 1.9148*Math.sin(Mr) + 0.0200*Math.sin(2*Mr) + 0.0003*Math.sin(3*Mr);
  const lambda = (M + C + 180 + 102.9372) % 360;
  const lambdaR = lambda * D2R;
  const Jtransit = 2451545.0 + Jstar + 0.0053*Math.sin(Mr) - 0.0069*Math.sin(2*lambdaR);
  const decl = Math.asin(Math.sin(lambdaR) * Math.sin(23.4397 * D2R));
  const latR = lat * D2R;
  const cosH = (Math.sin(-0.83*D2R) - Math.sin(latR)*Math.sin(decl))
             / (Math.cos(latR) * Math.cos(decl));
  if (cosH > 1)  return { sunrise: null, sunset: null, transit: fromJulianDay(Jtransit), polarNight: true };
  if (cosH < -1) return { sunrise: null, sunset: null, transit: fromJulianDay(Jtransit), midnightSun: true };
  const H = Math.acos(cosH) * R2D;
  return {
    sunrise: fromJulianDay(Jtransit - H/360),
    sunset:  fromJulianDay(Jtransit + H/360),
    transit: fromJulianDay(Jtransit),
  };
}

This will fail closed near the poles in summer/winter (|cosH| > 1, no sunrise/sunset that day) — the function returns null times and a flag rather than NaN dates; the caller (planetary hour) must handle it explicitly (fall back to civil midnight/noon boundaries, or just refuse to render planetary hour at that latitude and say so).

2.4 Planetary hour, Chaldean order

Daylight and night are each divided into 12 unequal "temporal hours." The ruling sequence across all 24 (day+night) hours repeats the seven classical planets in Chaldean order — Saturn, Jupiter, Mars, Sun, Venus, Mercury, Moon, ordered by decreasing apparent geocentric orbital period. Because 24 isn't a multiple of 7, the ruler of hour 1 advances by 3 positions each successive day, which is the actual mechanism behind the familiar weekday-planet correspondence (Sunday↔Sun, Monday↔Moon, …). No planetary glyphs — Departure doesn't carry ♄♃♂☉♀☿☽ — use names throughout, as below:

const CHALDEAN = ['Saturn','Jupiter','Mars','Sun','Venus','Mercury','Moon'];
const WEEKDAY_RULER = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn']; // getUTCDay(): 0=Sun

function planetaryHour(date, lat, lon) {
  const today = sunTimes(date, lat, lon);
  if (!today.sunrise) return { planet: null, reason: 'no sunrise/sunset at this latitude/date' };

  let start, end, isDay;
  if (date >= today.sunrise && date < today.sunset) {
    start = today.sunrise; end = today.sunset; isDay = true;
  } else if (date >= today.sunset) {
    const tmr = sunTimes(new Date(date.getTime() + 86400000), lat, lon);
    start = today.sunset; end = tmr.sunrise; isDay = false;
  } else {
    const yst = sunTimes(new Date(date.getTime() - 86400000), lat, lon);
    start = yst.sunset; end = today.sunrise; isDay = false;
  }
  const hourLenMs = (end - start) / 12;
  const slot = Math.min(11, Math.floor((date - start) / hourLenMs));      // 0..11
  const globalHour = isDay ? slot : slot + 12;                             // 0..23
  const dayRulerIdx = CHALDEAN.indexOf(WEEKDAY_RULER[date.getUTCDay()]);
  const planet = CHALDEAN[(dayRulerIdx + globalHour) % 7];
  return { planet, hourOfDay: globalHour + 1, isDay, hourLengthMin: hourLenMs / 60000 };
}

2.5 Lunar mansion index

28 mansions (Chinese 宿/xiù, Arabic manazil, Vedic nakshatra — pick the count that matches whichever tradition; 27 for nakshatra, 28 for the Chinese/Arabic systems), each spanning 360/28 ≈ 12.857° of ecliptic longitude. Needs the Moon's ecliptic longitude — a truncated low-precision series (Meeus, Astronomical Algorithms, ch. 47, six largest periodic terms only; full ELP2000 has hundreds and is out of scope for a no-library artifact):

function moonEclipticLongitude(date) {
  const T = (toJulianDay(date) - 2451545.0) / 36525;           // Julian centuries since J2000
  const Lp = 218.3164477 + 481267.88123421*T;                  // mean longitude
  const D  = 297.8501921 + 445267.1114034*T;                   // mean elongation from Sun
  const M  = 357.5291092 +  35999.0502909*T;                   // Sun mean anomaly
  const Mp = 134.9633964 + 477198.8675055*T;                   // Moon mean anomaly
  const F  =  93.2720950 + 483202.0175233*T;                   // argument of latitude
  const r = D2R;
  const dL = 6.288774*Math.sin(Mp*r) + 1.274027*Math.sin((2*D-Mp)*r)
           + 0.658314*Math.sin(2*D*r) + 0.213618*Math.sin(2*Mp*r)
           - 0.185116*Math.sin(M*r)  - 0.114332*Math.sin(2*F*r);
  const lon = (Lp + dL) % 360;
  return lon < 0 ? lon + 360 : lon;
}
function lunarMansionIndex(date, mansions = 28) {
  return Math.floor(moonEclipticLongitude(date) / (360 / mansions));   // 0..mansions-1
}

Six terms gives roughly ±0.3° accuracy — well inside a 12.86° mansion width, so the index is reliable even though the longitude itself isn't ephemeris-grade.


Part 3 — Deterministic 32-bit seed from a Date

Combine everything above with a well-mixed, order-sensitive hash (FNV-1a — Fowler/Noll/Vo, public-domain, one multiply/one xor per step, excellent avalanche for this scale) rather than simple addition, which would let, e.g., moon-age 5 + hour 10 collide with moon-age 10 + hour 5:

function fnv1aMix(h, x) {
  h ^= (x >>> 0);
  h = Math.imul(h, 16777619);
  return h >>> 0;
}

function deriveSeed(date, lat, lon) {
  const year      = date.getUTCFullYear();
  const doy       = dayOfYear(date);
  const moonAge   = Math.round(moonPhase01(date) * SYNODIC_MONTH);   // 0..29
  const hourInfo  = planetaryHour(date, lat, lon);
  const hourOfDay = hourInfo.planet ? hourInfo.hourOfDay : 0;         // 0..24
  const mansion   = lunarMansionIndex(date);                          // 0..27
  const hexValue  = ((doy % 64) ^ (moonAge << 1) ^ hourOfDay) & 0b111111;

  let h = 2166136261 >>> 0;                       // FNV-1a offset basis
  h = fnv1aMix(h, year);
  h = fnv1aMix(h, doy);
  h = fnv1aMix(h, moonAge);
  h = fnv1aMix(h, hourOfDay);
  h = fnv1aMix(h, mansion);
  h = fnv1aMix(h, hexValue);

  // instrumented — every step visible, per §16 evidence-not-memory
  console.table({ year, doy, moonAge, planet: hourInfo.planet, hourOfDay, mansion, hexValue,
                   seed: '0x' + h.toString(16).padStart(8, '0') });
  return h;
}

PRNG from the seed — mulberry32 (Tommy Ettinger; public domain; 4 multiplies, passes practical randomness suites, tiny) rather than Math.random(), which is not seedable and breaks the "same seed, same output" law outright:

function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
// usage: const rng = mulberry32(deriveSeed(new Date(), lat, lon)); rng() → [0,1)

The whole chain — Date → JD/calendar fields → {moonAge, planet/hour, mansion, hexValue} → FNV-1a → seed → mulberry32 stream — is pure and side-effect-free apart from console.table. Same Date + same (lat,lon) always produces the same seed, satisfying the reproducibility law directly, and the instrumentation (console.table) gives the "hidden depth rewards inspection" the canon wants for free — open devtools, see every intermediate value that produced tonight's field.


What's hard, what will go wrong