Forty Cells: Encoding a Fixed Lookup Table So the Output Can Be Audited

Rule-heavy systems keep making the same design mistake in opposite directions. Sometimes every rule is a formula, and the code turns into an unreadable thicket of conditionals. Sometimes everything is a table, including the parts that are genuinely arithmetic, and nobody can tell which cell is the one that changed.

The useful discipline is to decide, per layer, whether it is a formula or a table — and then hold it to the invariant that layer admits. This post works through one lookup layer of a Chinese astrology chart, but the shape is generic: a small input domain, four output slots per input, fifteen symbols in total. It is a good specimen precisely because the numbers are small enough to check by hand and rigid enough that the checks have teeth.

No forecasts, no verdicts. The subject is the data layer.

The layer, stated exactly

A chart of this kind is keyed to a ten-value cycle. Call the ten values stems. For each stem, exactly four of the chart's symbols are marked as changed, one in each of four flavours: Lu, Quan, Ke, Ji. The four flavours are fixed in order and meaning — arriving resources, authority, standing, friction — and they never change position relative to each other.

So the layer is a function:

stem (10 values) -> (Lu, Quan, Ke, Ji) -- four symbols 

Ten inputs, four outputs each: forty cells, drawn from a pool that turns out to be smaller than forty. That is the whole layer. Everything downstream — which part of the chart a change lands on — comes from where the selected symbol sits, which is a different layer entirely, keyed to a different computation.

Which is the first design point worth naming: the table answers "what changed", never "what it means". Keeping those two questions in separate modules is what makes the output auditable at all. A layer that both selects and interprets has no invariant you can test.

Encode it as data, with a source per row

The natural implementation is a dictionary of four-tuples, one row per stem, each row carrying the name of the rule-table entry it was read from:

STEMS = ["Jia", "Yi", "Bing", "Ding", "Wu", "Ji", "Geng", "Xin", "Ren", "Gui"] TRANSFORMS = ["Lu", "Quan", "Ke", "Ji"] SI_HUA = { # stem -> (Lu, Quan, Ke, Ji)  "Jia": ("Lian Zhen", "Po Jun", "Wu Qu", "Tai Yang"), "Yi": ("Tian Ji", "Tian Liang","Zi Wei", "Tai Yin"), "Bing": ("Tian Tong", "Tian Ji", "Wen Chang","Lian Zhen"), "Ding": ("Tai Yin", "Tian Tong", "Tian Ji", "Ju Men"), "Wu": ("Tan Lang", "Tai Yin", "You Bi", "Tian Ji"), "Ji": ("Wu Qu", "Tan Lang", "Tian Liang","Wen Qu"), "Geng": ("Tai Yang", "Wu Qu", "Tai Yin", "Tian Tong"), "Xin": ("Ju Men", "Tai Yang", "Wen Qu", "Wen Chang"), "Ren": ("Tian Liang","Zi Wei", "Zuo Fu", "Wu Qu"), "Gui": ("Po Jun", "Ju Men", "Tai Yin", "Tan Lang"), } 

Two things to notice before any checking happens. First, the values are fifteen distinct symbols across forty cells, so some symbols recur — one appears five times. Second, the table is total: all ten inputs have a row. Both facts are free invariants, and both catch real bugs.

Five assertions that come from the structure

The point of writing it as data is that the invariants are then assertions, not prose. Run against the table above, these are the checks and the actual numbers:

1. Totality. Ten stems, four cells each, no row of the wrong width. A missing row silently shifts behaviour for one input out of ten — a one-in-ten bug that survives casual testing, because nine inputs look fine.

2. Row distinctness. The four cells in a row are always four different symbols. This is not decoration: it encodes the rule that a single input cannot mark the same symbol twice. A duplicate in a row means either transcription error or a genuinely different convention set — and both need a human, not a fallback.

3. Bounded alphabet. The forty cells draw on exactly fifteen distinct symbols, and the whole symbol set of the chart is larger than that. So there is a fixed subset that can ever be selected, and a complement that never is. That gives you a cheap negative test: any symbol outside the fifteen appearing in this layer's output is a bug, not a reading.

4. Multiplicity distribution. Count how often each symbol is selected and the shape is informative: one symbol at 5, two at 4, five at 3, five at 2, two at 1. Summed, 5*1 + 4*2 + 3*5 + 2*5 + 1*2 = 40. That arithmetic identity is a stronger check than any single row, because it fails if any cell is wrong anywhere in the table, and it passes only for a globally consistent transcription.

5. Purity and error on unknown input. The same input returns the same tuple, always — no mutable state, no cache with a stale key. And an unknown input must raise, not return an empty tuple:

try: SI_HUA["Zzz"] # must raise except KeyError: pass 

That last one looks trivial and is the most common way a deterministic layer stops being reproducible. A defensive return () or return (None,)*4 turns "I don't have this row" into "this chart has no changes", which is a different and plausible-looking answer. The failure gets published to a user as a reading instead of surfacing as an error. In a layer whose entire value is that the same input yields the same output, silent defaults are worse than crashes.

Where the table legitimately refuses to be a formula

The temptation, once the data is in a dictionary, is to compress it: derive the rows from a rule, or generate them from a smaller table. Resist that for this layer, and the reason is a real one rather than taste.

Convention sets disagree on some of these rows. Not the arithmetic — the assignment. Two implementations can both be internally consistent and still disagree on a couple of the ten rows, and when two published charts differ on those inputs, the disagreement is upstream, in which table you use, not downstream, in either implementation's code. If you hard-code the rows as if branches, that disagreement becomes invisible: it lives in the source of one program and in nobody's data. If the rows are data with a version and a per-row provenance field, the disagreement is a diff you can print, and a chart's output can be checked against the same table that produced it.

So: arithmetic belongs in code; assignments belong in data. The corollary is that the data needs a version string, and results should carry the version that produced them. Otherwise you cannot tell "this reading is wrong" from "this reading was produced by the older table" three months later.

The same function, called with different inputs

One more structural point, because it changes the API rather than the table. Time layers in this system (birth, decade, year) do not have their own transformation logic. They supply different stems to the same pure function. Which means the correct output type is a list of assignments, each tagged with the stem and layer that produced it — not one merged record.

Merging is where most implementations lose auditability. If three layers are folded into a single "current transformations" dictionary, two of them can collide on the same slot and one overwrites the other, and the resulting record is no longer a function of any single input. Keep them separate, and every cell in the output can be recomputed from one stem, by hand, from the table above.

What the check run looks like

PASS totality: 10 stems x 4 slots :: 10 stems, cell counts [4] PASS row distinctness :: rows with a repeat: none PASS key set == stem list :: True PASS total cells == 40 :: 40 PASS distinct stars == 15 :: 15 PASS multiplicity distribution :: {5: 1, 4: 2, 3: 5, 2: 5, 1: 2} PASS multiplicity sums to 40 :: 40 PASS heaviest star appears 5 times :: [('Tai Yin', 5), ('Wu Qu', 4), ('Tian Ji', 4)] PASS purity: repeat lookup identical :: ('Tai Yang', 'Wu Qu', 'Tai Yin', 'Tian Tong') PASS star names normalised :: 15 PASS unknown stem raises KeyError :: True VERDICT: PASS 

Every line above is a property of the structure, not of any interpretation. That is the test any rule-heavy layer should be able to pass: the numbers are checkable by someone who knows nothing about the domain's meaning, and they fail loudly when a single cell is transcribed wrong.

The general shape

Strip the domain away and the pattern is:

  • Small finite input domain, fixed-width output. Write it as data, one row per input, complete.
  • Assert the invariants the structure gives you for free: totality, row distinctness, alphabet bound, global count identity, purity, raise-on-unknown.
  • Decide explicitly which layers are conventions and externalise those as versioned data with provenance. Never as branches.
  • Keep layers separate in the output type so any cell can be recomputed from one input.

The reason to bother is not elegance. It is that a reading built on a table can be re-derived from the same table by a third party, and a reading built on improvisation cannot be checked against anything — including itself, one month later.

If you want to see the layer sitting inside an actual chart rather than in isolation, the full mechanism is written up here, with the same rule table: https://askziwei.com/?utm_source=devto