DEV Community

taurus yao
taurus yao

Posted on

The Opposite Slot Comes Free: Modelling a Paired Reading Instead of a Single Lookup

The Opposite Slot Comes Free: Modelling a Paired Reading Instead of a Single Lookup

Every rule-heavy domain has the same shape. A small set of conventions gets applied in a fixed order, the output is a structured record, and then a human reads that record and produces prose. The prose gets all the attention. The substrate is where the engineering actually lives.

I want to use one slot out of a twelve-slot chart as a worked example, because that slot has a property most of the other slots do not: it is fully determined by another slot.

In Zi Wei Dou Shu the slot that describes a person's life outside home ground — travel, relocation, unfamiliar places, public exposure — sits directly opposite the slot that describes the person themselves. That single geometric fact is worth more than it looks. It turns a lookup into a comparison, and comparisons are testable in ways that lookups are not.

Nothing below requires any astrology background. No forecasts, no verdicts. The subject is only the data model.

1. The slot is a congruence, not an entry in a table

The twelve slots have a fixed label order, identical for every chart. Only the rotation is personal.

LABELS = [
    "Life", "Siblings", "Spouse", "Children",
    "Wealth", "Health", "Travel", "Friends",
    "Career", "Property", "Fortune", "Parents",
]

def opposite(i: int) -> int:
    return (i + 6) % 12

assert len(LABELS) == 12 and len(set(LABELS)) == 12
assert LABELS[6] == "Travel"
assert opposite(0) == 6                                    # life at 0 -> travel at 6
assert all(opposite(opposite(i)) == i for i in range(12))   # involution
assert all(opposite(i) != i for i in range(12))             # no fixed points
Enter fullscreen mode Exit fullscreen mode

Three properties fall out of that, and all three are worth exploiting:

  1. One index determines the other. Store the rotation once, derive the pair. If the renderer and the reading engine each compute the slot independently, they can disagree, and the disagreement will look like a content bug when it is an indexing bug. Deriving both from one number makes that category of bug unrepresentable — the same argument as any single-source-of-truth change.
  2. The relation is its own inverse. opposite(opposite(x)) == x is a free property test. It catches off-by-six errors immediately, which is the most common arithmetic mistake in a ring of this size.
  3. No fixed points. No slot is its own mirror. That is not decoration: it means the pair always carries two distinct pieces of data, so a "paired" reading can never silently collapse into a single-record reading.

2. What a record actually holds

Four fields per slot, and only one of them is really interesting.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Palace:
    index: int                                                  # derived, never authored
    stars: tuple = ()                                           # zero, one or two main stars
    brightness: dict = field(default_factory=dict)              # star -> label, not a number
    transforms: dict = field(default_factory=dict)              # star -> transformation kind
Enter fullscreen mode Exit fullscreen mode

The star set is fixed and small — fourteen of them, drawn from a closed vocabulary. A slot may hold zero, one or two. The empty case is the one that carries design weight, because the convention is that an empty slot is read by borrowing the star of the slot opposite it.

That is the point where the geometry stops being decorative and becomes load-bearing: one slot's emptiness promotes the other slot's content to primary input. If you build the pair relation as a rendering convenience, an empty slot is an edge case. If you build it as part of the model, an empty slot is just a resolution rule.

def effective_stars(palace, chart):
    """A slot with no star borrows the opposite slot's star. Do not recurse."""
    if palace.stars:
        return palace.stars
    return chart[opposite(palace.index)].stars
Enter fullscreen mode Exit fullscreen mode

The comment is the whole lesson. If both slots in a pair are empty (it happens), a naive borrow recurses and you either loop forever or invent a star to break the cycle. One level of borrow, then stop, and let the caller see an empty result — the empty result is real information, not a missing value.

3. Brightness is a sign, not an adjective

Here is the modelling decision that matters most, and the one most implementations get wrong.

Each main star in each slot carries a brightness rank. Rank is not a qualifier that makes the sentence "nicer". It flips polarity. The same star in the same slot describes outward flourish at the top of the scale and drain at the bottom. Model it as an adjective and your prose templates will contradict each other; model it as a sign and the contradiction becomes a branch you can test.

The trap: published tables disagree on how many rungs the scale has — five in some, six or seven in others. So do not store an integer. Store the label, and map label to band through a versioned table:

# A modelling choice, not a transcription of any one published table.
# Different tables use different rung counts, which is exactly why the
# integer must live in config and never in a stored record.
BRIGHTNESS_BANDS = {
    "table-v1": {
        "brightest": +1, "bright": +1, "favourable": +1,
        "flat": 0,
        "strained": -1, "dim": -1, "darkest": -1,
    },
}

def polarity(brightness: str, table: str = "table-v1") -> int:
    return BRIGHTNESS_BANDS[table][brightness]   # KeyError is the desired failure
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices in five lines:

  • A missing label raises instead of defaulting to zero. A silent 0 would render as a neutral reading and nobody would ever notice the record was incomplete. Fail closed.
  • The table is a parameter with a name. When you upgrade to a table with a different rung count, the migration is a diff of a config file, not a scan of stored records hunting for integers that used to mean something else.

4. The pair read is two projections of one record

Given the slot and its mirror, the useful output is not one sentence but two, produced from the same fields:

def read_pair(chart, life_index, table="table-v1"):
    inner = chart[life_index]
    outer = chart[opposite(life_index)]

    def describe(p):
        return {
            "slot": LABELS[p.index],
            "stars": effective_stars(p, chart),
            "polarity": [polarity(p.brightness[s], table)
                         for s in effective_stars(p, chart) if s in p.brightness],
        }

    return {"inside": describe(inner), "outside": describe(outer)}
Enter fullscreen mode Exit fullscreen mode

The value of returning both is that the prose layer is forced to talk about the relationship — which side is heavier, which side is carrying the borrowed star — rather than emitting a verdict about a single slot. Structural constraint producing better prose, which is usually the opposite of how these systems get built.

5. Time layers are a cascade, not a merge

A chart is read at three time scales: natal, ten-year period, single year. Activation markers (the transformation set) can appear at any of the three, in the same slot. Two questions have to be answered explicitly, and published conventions do not agree:

  • When an inner layer marks a star that an outer layer already marked, does it replace or coexist?
  • Do you read the union of all marks, or the most specific one?

Do not resolve this inside a rendering function. Keep the ambiguity in the data:

LAYERS = ("natal", "decade", "year")

def marks_at(chart, index, layers=LAYERS):
    out = {}
    for layer in layers:
        for star, kind in chart[layer][index].transforms.items():
            out.setdefault(star, []).append((layer, kind))
    return out
Enter fullscreen mode Exit fullscreen mode

Returning a list rather than a scalar means the precedence question stays visible to whoever writes the prose, and stays testable. If you fold it to one value early, you have made a convention decision in a place where nobody will look for it.

6. Three tiers, and only the first one is really yours

The most useful thing I can say about this domain is a triage:

Tier Content Status
1 Slot indices, the pair relation, the involution Arithmetic. Assert it.
2 Star placement, brightness labels, transformation assignment A table lookup. Only as good as the named table version.
3 The sentences Interpretation. Not checkable.

Tier 1 is a handful of lines and a handful of assertions, as above. It is deterministic, fast, and it either passes or it does not.

Tier 2 is checkable against a version — which is the only meaningful sense of "checkable" for reference data. The numbers cannot be better than the table they came from, so the table gets a name and a hash, and upgrades get diffed.

Tier 3 is not checkable, and pretending otherwise is how these products rot. Whatever you conclude about whether a top-rank star in the outer slot means recognition or burnout is a judgement, in the same way that any interpretation of a structured record is a judgement.

The consequence is a rule: tier 3 must never back-propagate into tiers 1 and 2. If a bad sentence gets "fixed" by editing a reference table, an arithmetic error has been laundered into a data file and you have lost the ability to diff it.

7. Why this is worth the afternoon

Rule-heavy products fail quietly in one specific way: the deterministic layer drifts, and the prose layer keeps producing confident paragraphs on top of it. A prose error is loud — the sentence is nonsense, someone notices. An arithmetic error is silent, because the output is still fluent.

A small assertion suite on the structural layer is the cheapest instrument available. It does not make the readings better. It makes the machinery honest, which is the part that can actually be verified.

The narrow claim of this post, restated: within this chart, the outer slot is an index congruence off the inner slot, an empty slot resolves by one non-recursive borrow, brightness is an ordinal whose polarity band lives in versioned config, and time layers stay un-merged on purpose. That is checkable. The rest is reading.

If you want to see the same structure rendered from real birth data rather than from my toy dicts, the chart generator is here: https://askziwei.com/?utm_source=devto

Top comments (0)