Most of what looks like confusion about a rule-based system is actually a missing schema. People hit a term, cannot tell whether it names an entity, a label on an entity, or a relationship between entities, and the whole mental model blurs. Documentation does not fix that, because prose has no types.
So I stopped writing the glossary as prose and wrote it as a table, then wrote assertions against the table. This post walks through the encoding and the seven structural claims worth testing. The domain is a Chinese charting system that maps a birth moment onto a fixed twelve-slot grid, but nothing below depends on the subject matter — every rule here applies to any rule-heavy engine that has to explain itself.
The closure claim, stated as a testable inequality
The first thing a vocabulary should tell you is how big it is. Not approximately — exactly.
For this system, the claim is that a chart is fully described by four layers and nothing else:
- 12 palaces (positions on the grid)
- 14 main stars (the things placed on it)
- 4 transformations (state flags applied per period)
- 2 direction anchors (which slot is the reference)
That is 32 atoms. Every other term in a 30-entry vocabulary is a modifier on one of those four layers, not a new kind of thing.
The valuable part is not the number. It is that the number is falsifiable: if a new term cannot be filed under one of the four layers, either your schema is incomplete or the term is decoration. I run this as four assertions:
assert len(PALACES) == 12 assert len(MAIN_STARS) == 14 assert len(TRANSFORMATIONS) == 4 assert len(ANCHORS) == 2 When a domain expert adds a fifth term type, this fails loudly instead of silently widening the model.
Give every term exactly one kind
The 30 glossary entries then get one kind each. Not a free-text tag — a value from a closed set, so the distribution itself becomes a signal:
{'palace': 13, 'star': 8, 'layer': 5, 'brightness': 1, 'transformation': 1, 'framework': 1, 'timing': 1} Two things fall out of that distribution immediately.
Thirteen of thirty terms are palace terms. Twelve are the palaces themselves; the thirteenth is the term for the grid of twelve. That is a schema smell worth naming: a concept naming a collection is not the same type as a concept naming a member, and if you file both as palace you will eventually write code that treats an array as an element. I keep it in the same bucket deliberately, and assert len(terms_of_kind('palace')) == 12 + 1 so the "+1" is a documented decision rather than an accident.
Seven kinds, none with zero members. An unused kind is either dead schema or a missing term. Both are worth a failing test, because a kind with no members produces no pressure to keep its invariants honest.
Brightness is an ordinal, not a set of labels
This is where most implementations quietly lose information. Star strength is a seven-grade scale from strongest to weakest. The tempting encoding is a set of strings, or worse an enum with no order — and then every comparison becomes a hand-written lookup.
Encode it as a position in an ordered list:
BRIGHTNESS = ["miao", "wang", "dedi", "liyi", "pinghe", "budedi", "xian"] def brightness_rank(grade): return BRIGHTNESS.index(grade) # raises ValueError on unknown Then the assertions are cheap and meaningful:
D1 seven grades n=7 D2 ranks are 0..6 in order [0, 1, 2, 3, 4, 5, 6] D3 rank is total and injective distinct=7 D4 unknown grade raises ValueError Two design points that matter more than they look:
-
index()raising is the feature. A dictionary with a.get(key, 0)default would silently map an unknown grade onto the strongest one. In a system where the whole output is a deterministic function of inputs, a silent default is the worst failure mode available: it produces a confident, wrong chart. - The ordinal is not a score. Nothing in the engine should multiply by brightness rank or average two of them. Ordering is the only structure the domain supports; the moment the code treats it as arithmetic, you have invented semantics.
Partition the star set, and assert disjointness
The star layer is where vocabularies rot, because groups overlap informally. Six functional groups, and the only durable claim is that they do not overlap:
C1 star groups pairwise disjoint overlaps=[] C2 named star atoms total n=30 {'main': 14, 'malefic': 6, 'romance': 4, 'literary': 2, 'assistants': 2, 'solo': 2} The pairwise check is three lines and catches the real bug class — a star added to two groups during a refactor, which then produces two different readings of the same position depending on iteration order:
overlaps = [] for i, a in enumerate(groups): for b in groups[i + 1:]: shared = set(groups[a]) & set(groups[b]) if shared: overlaps.append((a, b, sorted(shared))) assert not overlaps Note the arithmetic check in C2: the total is asserted as the sum of the group sizes, not as a hard-coded 30. My first draft of this file hard-coded the total and the assertion failed with n=30 against an expected 31 — a nice example of why the expected value should be derived, not remembered.
The reading order is a dependency graph
Interpretation order is usually documented as a list: read the anchor palace, then the triangle, then brightness, then transformations, then timing. A list is a sequence; what you actually have is a partial order. Encode the dependencies and let the test confirm a topological order exists:
PIPELINE = [ ("anchor", set()), ("triangle", {"anchor"}), ("brightness", {"anchor", "triangle"}), ("transformations", {"anchor", "brightness"}), ("timing", {"transformations"}), ] seen, ok = set(), True for stage, deps in PIPELINE: if not deps <= seen: ok = False seen.add(stage) assert ok Why bother when the list already reads fine? Because the graph catches the change you will actually make. Six months from now someone decides transformations belong before brightness — plausible, since a transformation flag is visible on the chart. With a graph, that edit fails the assertion and forces the question; with a list, it silently reorders every explanation the product produces.
The timing stage depending only on transformations is also a real modelling statement: the chart geometry is fixed at birth, and each period layer only re-selects which positions are active. If your code lets a timing layer mutate star positions, the DAG has been violated somewhere upstream.
resolve() should raise, never return None
One function, one rule:
def resolve(term): for tid, label, kind in TERMS: if tid == term: return kind, label raise KeyError("unknown term: %r" % (term,)) H1 all 30 terms resolve resolved=30 H2 unknown term raises KeyError KeyError None propagates. A None return from a resolver becomes a None in an intermediate object, then a default at the formatting layer, then a sentence in the output that quietly describes nothing. A KeyError stops at the boundary. In a system whose entire value proposition is that the same input yields the same output, the strict version is the only one that survives contact with an LLM sitting in front of it — the model cannot paper over an exception the way it can paper over a missing field.
Fingerprint the tables
The last assertion is the cheapest and the most useful:
def fingerprinted(): payload = json.dumps({...}, sort_keys=True) return hashlib.sha256(payload.encode()).hexdigest() I1 two loads fingerprint identically 4114a8435b6a4fd5... Two purposes. It pins the vocabulary — any edit to a label, an ordinal position, or a group membership changes the hash, so the change shows up in a diff people actually review. And it gives you a cheap cross-process check: if the engine and the explanation layer compute different fingerprints, they are reading different vocabularies, which is the exact bug that makes generated descriptions disagree with the chart they describe.
What this buys
The 29 assertions in the file are not tests of the domain's truth. They are tests of the encoding's consistency: cardinalities, disjointness, totality, order, determinism. That is the layer a rule-heavy product can actually guarantee, and it is the layer worth stating publicly — a claim that a vocabulary closes, that an ordinal is ordered, and that an unknown input throws, is a claim a reader can re-run.
The prose version of this glossary is at the same system's chart page if you want to see the terms against a real grid. The part that transfers to your own project is narrow and portable: write the vocabulary as a table, give every term one kind, derive your expected counts instead of remembering them, and make the unknown case loud.