Architecting Zero-Hallucination Divination Engines: Deterministic Rust State Machines Meet LLM Semantic Grounding
LLMs that plot charts directly get changing lines and star positions wrong, because token sampling cannot preserve discrete invariants. This case study reads InsightApp's production design: a pure Rust kernel emits a JSON fact graph and the LLM only writes the language. It covers real data structures, coin-method probabilities, a bounded flying-star graph walk, and three gaps still open.
Disclosure: InsightApp and sns.style are run by the same operator. Every code reference below comes from the InsightApp backend at commit `22cccf4`, with file paths for traceability; the repository is not public. This is a case study, not an independent review. The full-length version of this feature is the Chinese edition; this is the English summary.
Ask a large language model to cast an I Ching hexagram or plot a Zi Wei Dou Shu chart and then interpret it, and the result usually reads well but fails a check. A changing line is wrong, a main star sits in two palaces at once, or the "Ji" transformation lands on a star that is not on the chart. The model is not stupid. The task has the wrong shape for it. Chart plotting is discrete combinatorics. Interpretation is natural language. One sampler should not do both.
This feature walks through a design that is in production at InsightApp's I Ching and Zi Wei engine: a deterministic Rust kernel computes the chart and emits a structured JSON fact graph, and the LLM only turns that graph into language. The "zero hallucination" in the title has a precise scope. It holds for the chart layer. It does not hold for the prose layer, and we say exactly where it stops.
Why autoregressive sampling breaks discrete invariants
A Zi Wei chart is a chain of exact steps. In InsightApp, the Life Palace is `mod12(lunar_month + 1 - hour)`, the Body Palace is `(lunar_month + 1 + hour) % 12`, the element bureau comes from a 5×6 lookup `NA_YIN_JU[gan / 2][ming_gong_idx / 2]`, and the Zi Wei star position follows from the bureau and the lunar day (`backend/src/ziwei/calc.rs`). The chain must preserve invariants: each of the 14 main stars appears exactly once across 12 palaces; each heavenly stem maps to exactly four transformations (Lu, Quan, Ke, Ji); Tian Fu is fixed by Zi Wei as `mod12(4 - zi_wei)`.
A token sampler has no palace array and no mechanism that forces step 9 to reuse the exact value from step 3. Intermediate results live only as text, every step carries some error probability, and errors propagate. The model also never checks invariants: it can say "Zi Wei is in the Life Palace" and, three paragraphs later, "Zi Wei is in the Travel Palace". We have not measured an error rate for LLM-only chart plotting. That paired experiment is worth running; until then, this is a structural argument, not a benchmark.
The principle: kernel for facts, model for language
birth data / cast request
|
v
Rust kernel: full_chart(), feixing::compute(),
patterns::detect(), ZhouyiEngine (pure, no I/O)
| ChartResult / Reading (JSON fact graph)
v
KB retrieval: kb::search + classical-text tags
|
v
LLM via OpenRouter (default gemini-2.0-flash-001, SSE)
The model sits at the bottom. It reads facts but never produces them. Temperature is 0.7 (`backend/src/llm/client.rs`) on purpose: wording should vary, the chart should not.
I Ching: randomness in exactly one place
`backend/src/agent/hexagram.rs` models the three-coin method. Each coin is 2 or 3 with probability 1/2, so the sum is 6 plus a Binomial(3, 1/2) count: P(6) = 1/8, P(7) = 3/8, P(8) = 3/8, P(9) = 1/8. From the code alone: each line is yang with probability 1/2, changing with probability 1/4, a hexagram has 1.5 changing lines on average, and the chance of no changing line is (3/4)^6, about 17.8%. After the throw, the primary and derived hexagrams are both looked up by line pattern, and `ZhouyiEngine::build_reading` returns a `Reading` with `primary`, `derived`, `changing_indices` and `changing_yao_ci`. If a lookup fails, the engine returns an `EngineError` instead of a plausible default. That is fail-closed. Try the 64-hexagram lookup, the complete 64-hexagram guide and the changing-lines explainer.
Zi Wei: a pure pipeline
`ziwei::full_chart(&ChartRequest) -> ChartResult` (`backend/src/ziwei/chart.rs`) has no I/O and no randomness. It resolves leap months, places palaces, stems, the bureau, the 14 main stars, auxiliary and malefic stars, the four transformations and the decade periods, then detects patterns and computes flying-star data. The fields `patterns` and `feixing` on `ChartResult` are marked `skip_deserializing`: derived facts can only come from the server. The leap-month rule was added, per a code comment, after cross-checking against the open-source iztro library on 505 random plus 5 fixed samples, with zero diffs afterwards. That is what a deterministic kernel buys you: you can diff it sample by sample against an independent implementation.
True solar time is honest to state precisely: it is corrected in the frontend (`frontend/src/lib/ziwei/trueSolarTime.ts`), not in Rust. The kernel receives an already-converted hour index.
Flying stars: a bounded graph walk
`backend/src/ziwei/feixing.rs` computes a `FeixingDetail` with per-decade transformations, an optional annual layer, self-transformations, and a `HuajiChain`. The chain starts from the natal Ji star, finds its palace, uses that palace's stem to find the next Ji star, and repeats. It stops at depth `HUAJI_MAX_DEPTH = 4`, on a revisit (flagged `is_loop`), or when the stem stops changing. A few dozen lines of code, fully reproducible. See the flying-star page.
Where "zero hallucination" stops
Reading the code, we found three gaps. First, both interpretation endpoints trust the client: the I Ching handler takes hexagram text from the request, and the Zi Wei handler takes a `chart_summary` string from the frontend, with no server-side recomputation (`backend/src/handlers/interpret.rs`, `backend/src/handlers/ziwei.rs`). The fix is to accept birth data only and recompute, or to sign server-generated graphs. Second, the annual stem uses `(year - 4) mod 10`, which the code itself notes is off by a day or two around Lichun. Third, nothing checks the model's output against the chart after generation; a deterministic validator that extracts star-palace claims and compares them with `ChartResult` would close that loop.
Long-tail pages from the same tables
`backend/src/seo.rs` defines `STARS_14` and `PALACES_12`, which generate 168 star-in-palace pages such as Zi Wei in the Life Palace, Tan Lang in the Spouse Palace and Wu Qu in the Wealth Palace. For background, read how to read a Zi Wei Dou Shu chart or browse the InsightApp blog.
The takeaway generalises to any rule-bound domain: list the invariants first, make the kernel pure, isolate randomness, let the server own derived facts, diff against an independent implementation, fail closed on lookup errors, document known approximations, and let the model read facts rather than compute them.
Sources
FAQ
Why not let an LLM plot a Zi Wei chart directly?
Plotting is a chain of modular arithmetic and table lookups that must preserve invariants such as each of 14 main stars appearing once. Token sampling has no mechanism to reuse exact intermediate values or check invariants. We did not measure an error rate; this is a structural argument.
What does zero hallucination mean here?
Only the chart layer: the Rust kernel is a pure function, so the same input always yields the same chart and can be diffed against independent implementations such as iztro. The LLM prose can still be wrong, and nothing checks it against the chart after generation.
What gaps remain in InsightApp's implementation?
Interpretation endpoints trust the chart sent by the client; the annual stem uses a solar-year approximation that is off near Lichun; there is no post-generation validator. True solar time is corrected in the frontend, not in Rust.