Skip to content

Appendix a3 — Tooling (real Rigor's CLI and the dispatch cascade)

This is a reference appendix. Since this book’s (chibirigor’s) main purpose is building a “working minimal version” by hand, at various points in the main volume we sent the real Rigor’s tool behaviors off with a one-line pointer. This is where they pointed. We lay out the difference between chibirigor’s minimal version and real Rigor with the bridge “in this book it’s naive / the real thing is like this.

The factual descriptions of Rigor written here are made consistent with the main volume’s manuscript (the order and names of the 5 stages are as in the manuscript). They are not descriptions that change this book’s code behavior.


a3-1. rigor check --explain — produce a map of fail-soft

Section titled “a3-1. rigor check --explain — produce a map of fail-soft”

A normal rigor check reports as diagnostics only problems it could prove, and stays quiet about sites it fail-softed to Dynamic[Top] (the internal notation of the main text’s minimal Dynamic = untyped; see appendix a1-1) (the real thing behind Little Part 2’s “stay quiet if you don’t know” and Part 9’s “deliberately miss”). This is an attitude for not producing false positives, but turned over, it’s also “quietly missing.

Add --explain and every fail-soft site appears as an :info diagnostic — a map of “I lost the type here” is output.

Terminal window
$ rigor check --explain app/models/user.rb
app/models/user.rb:42:7: info: fell soft to Dynamic[Top] here (RBS not found for `external_call`)
app/models/user.rb:51:3: info: fell soft to Dynamic[Top] here (param `opts` is untyped)

Its uses are these:

  • When the question “am I overlooking this bug?” arises, trace back where the type vanished with --explain’s output.
  • From the fail-soft site you reach, you can discover missing RBS, an unconfigured plugin, gaps in type annotations.

Why a “map” can be drawn — recovering the Dynamic[Top] marker

Section titled “Why a “map” can be drawn — recovering the Dynamic[Top] marker”

This listing works because the Dynamic marker of Dynamic[Top] touched on in Little Part 1 doesn’t vanish at the fail-soft site. By holding untyped not as “just a hole” but as “a Top marked with Dynamic,” “where it stayed quiet” remains as structure. That’s exactly why it can be raised into a list later with --explain.

This book (chibirigor)The real thing (Rigor)
What untyped really isType::Dynamic (just a mark)Dynamic[Top] (Top with a Dynamic marker)
The fail-soft sitejust quietly returns Dynamickeeps the site in structure, lists it with --explain
Making the silence visibleno mechanism (out of the minimal version’s scope)check --explain produces a map with :info diagnostics

chibirigor’s “stay quiet if you don’t know” prevents false positives, but --explain is a tool that makes that silence itself visible.

a3-1x. A note: chibirigor has a tiny --explain too

Section titled “a3-1x. A note: chibirigor has a tiny --explain too”

The table above marks this book as “making silence visible = no mechanism,” but we added a tiny version here too. Add check --explain and it also emits, as :info diagnostics, the sites where inference fell back to untyped (the dispatch of an unknown method):

Terminal window
$ printf 'x = mystery_call\ny = x + 2\n' > demo.rb
$ ruby exe/chibirigor check --explain demo.rb
demo.rb:1:5: info: fell to untyped here (can't look up the type of `mystery_call`)
x = mystery_call
^^^^^^^^^^^^
demo.rb:2:5: info: fell to untyped here (can't look up the type of `+`)
y = x + 2
^^^^^

What to notice is line 2. mystery_call’s type is unknown so x becomes untyped, and the + against that x can’t look up a type either and falls back to untyped — the silence propagating shows up on the map. Without --explain it just stays quiet with No type errors (since it produces no false positives). :info doesn’t touch the exit code (exit 0), so you can peek at “where the type vanished” without stopping CI.

The difference from the real thing is that real Rigor keeps Dynamic[Top]’s Dynamic marker in structure and lists every fail-soft (missing RBS, an unannotated argument, an unconfigured plugin…), whereas chibirigor picks up just one kind, the unknown dispatch (the implementation just stacks one line of provenance in the signature-is-nil branch of lib/chibirigor/dispatch.rb). The idea of mapping the silence is the same.


a3-2. Rigor’s type display — the internal precise type and the RBS-boundary conservative type

Section titled “a3-2. Rigor’s type display — the internal precise type and the RBS-boundary conservative type”

This book’s annotate shows the inferred type, one of it, as is. Real Rigor too shows types with annotate (a tool of the same name as this book’s) and editor hover, but the real thing has a two-layer setup this book lacks:

  1. The precise type inside Rigor (e.g. Constant<"FOO">).
  2. The conservative type after dropping to a coarse type at the RBS boundary (e.g. String).

chibirigor’s annotate shows only the internal type. The real thing has a double structure of “knowing precisely inside, throwing it away at the boundary,” so “inference seems to know more, yet why is the signature (RBS) so coarse” happens. The answer is this two-layer setup.

This book (chibirigor)The real thing (Rigor)
How types are shownannotate lists internal types per lineshows with annotate / hover, rounds at the boundary with erasure
The type shownone internal typetwo layers: the internal precise type / the boundary-rounded conservative type
What the discrepancy is(doesn’t happen, since there’s no boundary)the divergence between internal precise type and RBS-boundary type = erasure

And chibirigor also has dump_type as a basic feature. Tools stay narrowed to the two of check and annotate — this is not a command but an expression check recognizes. No include needed; just write dump_type(expr) and check:

Terminal window
$ printf 'x = c ? 1 : "a"\ndump_type(x)\n' > demo.rb
$ ruby exe/chibirigor check demo.rb
demo.rb:2:1: info: dump_type: 1 | "a"
dump_type(x)
^^^^^^^^^^^^

Being :info, the diagnostic doesn’t turn red (the exit code is 0), and it doesn’t get in the way emitted alongside real type errors. The mechanism is the :info diagnostic built in Little Part 9 as is — when type_of finds dump_type(expr), it records the argument’s inferred type in an :info diagnostic (which check brings out) and returns the value as is (so dump_type(x)’s type stays x’s type). The implementation is lib/chibirigor/type_of.rb; the behavior coverage is test/test_dump_type.rb.


a3-3. The 5-stage dispatch cascade — the real table lookup

Section titled “a3-3. The 5-stage dispatch cascade — the real table lookup”

Little Part 2 kept the typing of a method send to a naive single-stage table lookup (look up the METHODS table by (class, method); if found, the return type, if not, untyped). Real Rigor makes this “table lookup” a 5-stage cascade. It applies from the top stage in order, and if a stage can’t apply, falls through to the next. What each stage resolves, and what it passes to if it misses:

StageNameWhat it appliesIf it misses
constant foldingif both sides are known constants like 1 + 2, actually compute on the spot and emit the result type (3)to ②
shape dispatchoperations that directly touch the type’s structure, like a HashShape key read, solved directly from the structureto ③
RBSlook up with the RBS types that core, stdlib, and plugins provide (the real thing of this book’s hand-written METHODS table)to ④
in-source (body inference)a method not in RBS, infer the body and synthesize a return type (the real thing of Little Part 8’s return-type synthesis)to ⑤
fallbackif no stage hits, degrade to Dynamic[Top] (don’t frighten)— (stops here)

A single call is asked, from the top in order, “can this stage solve it?” and stops at the stage that solves it. A stage that can’t just quietly passes to the next — producing no false positives, and always catching at the end with Dynamic[Top], so it doesn’t stop even on an unknown call (the real thing of Little Part 2’s “don’t frighten an unknown method”).

receiver.method(args)
① constant folding ── hit ─→ the result type (e.g. 3)
│ miss
② shape dispatch ──── hit ─→ a type solved directly from the structure
│ miss
③ RBS ─────────────── hit ─→ an RBS-derived return type
│ miss
④ in-source ───────── hit ─→ a return type synthesized by body inference
│ miss
⑤ fallback ──────────────→ Dynamic[Top] (untyped)

An example where priority matters — why ③ beats ④

Section titled “An example where priority matters — why ③ beats ④”

The cascade’s order itself carries meaning. For instance, a type declared with an RBS::Extended directive beats the inference of a method body because ③ RBS hits before ④ in-source. The design judgment “prefer the declaration over the body” shows up as the ordering of the stages.

This book (chibirigor)The real thing (Rigor)
Number of dispatch stages1 stage (just look up METHODS)5 stages (① constant folding → ② shape → ③ RBS → ④ in-source → ⑤ fallback)
Table contentsa hand-written METHODS Hash③ is RBS (from core, stdlib, plugins)
Body inferenceseparately in annotate (Little Part 8)built into dispatch as ④ in-source
Handling the unknownreturns DynamicDynamic[Top] in ⑤ fallback
Declaration vs. inference priority(no distinction)expressed by stage order (③ before ④)

a3-3b. rigor trace — see the steps of inference frame by frame

Section titled “a3-3b. rigor trace — see the steps of inference frame by frame”

The tools so far (--explain, the two-layer type display, the cascade) showed inference’s answer or map. Real Rigor has one more, a tool that shows the steps of inference themselvesrigor trace. It re-runs the same inference check runs against a file, and replays the recorded inference events as a frame-by-frame terminal animation. One frame = one scene of inference, showing the moment a local enters scope (bind), the moment a branch’s types dissolve into one union (union), and the moment a method send resolves (or fail-softs to Dynamic[top]) (dispatch), highlighting the range being evaluated.

Terminal window
rigor trace lib/example.rb # step by keypress
rigor trace --delay=0.5 lib/example.rb # auto-play
rigor trace --format=json lib/example.rb # raw event stream

--verbose emits everything down to per-expression enter/result; by default it narrows to just the three “teaching points” above. The JSON event stream is stable, so it can be material for textbook figures or lecture slides.

a3-3bx. A note: chibirigor has a tiny trace too

Section titled “a3-3bx. A note: chibirigor has a tiny trace too”

This is, rare among the appendices, a section where this book’s side has a tool nearly identical to the real thing (the implementation is lib/chibirigor/tracer.rb). The parts you built by copying each Part in this book — binding into scope, Type.union’s folding, dispatch’s table lookup — flow before your eyes in running order. It’s a learning tool for confirming with your eyes the inference a reader assembled in their head (“the evaluation order should be this,” “it should become a union here”).

Let’s run it on a 3-line example — containing an assignment, a ternary, and a method call, one each:

Terminal window
$ printf 'x = 5\ny = x > 0 ? 1 : -1\nz = y + 2\n' > demo.rb
$ ruby exe/chibirigor trace demo.rb

In the terminal you advance frame by frame with Enter (q to quit). Of all 17 frames, pulling out just the key ones:

chibirigor trace ─ step 2/17
────────────────────────────────────────────────────────────────
1 x = 5
2 y = x > 0 ? 1 : -1
3 z = y + 2
────────────────────────────────────────────────────────────────
type env : x: 5
evaluating : (top level)
► bind: x ← 5 (added to type env)
chibirigor trace ─ step 5/17
type env : x: 5
evaluating : if (incl. ternary) › call to >
► dispatch: 5.>(0) → untyped (not in table → fail-soft to untyped)
chibirigor trace ─ step 7/17
evaluating : if (incl. ternary)
► union: 1 , -1 → 1 | -1
chibirigor trace ─ step 9/17
type env : x: 5 y: 1 | -1
evaluating : (top level)
► bind: y ← 1 | -1 (added to type env)
chibirigor trace ─ step 12/17
evaluating : call to +
► dispatch: 1.+(2) → 3 (constant folding)
chibirigor trace ─ step 13/17
► dispatch: -1.+(2) → 1 (constant folding)
chibirigor trace ─ step 14/17
► union: 3 , 1 → 3 | 1
chibirigor trace ─ step 15/17
► dispatch: 1 | -1.+(2) → 3 | 1 (distribute Union to members)
chibirigor trace ─ step 17/17
type env : x: 5 y: 1 | -1 z: 3 | 1
evaluating : (top level)
► bind: z ← 3 | 1 (added to type env)
── playback done (17 steps total) ──

In these 17 frames, the main volume’s parts line up in running order. x’s binding (step 2) → the ternary’s condition x > 0, being a > not in the table, fail-softs to untyped (step 5) → the two arms 1 and -1 dissolve into a union and bind to y (steps 7, 9) → y + 2, since y is 1 | -1, distributes to members and constant-folds each member (steps 12, 13), combining the results again with a union (steps 14, 15) and binding z to 3 | 1 (step 17). How the mechanisms built separately in each Part interlock on a single expression is surveyable at a glance. Add --verbose and the thinned-out per-expression enter/result come out in full too. To output JSON it’s --json; auto-play is --delay 0.5.

The mechanism is the same idea as the real thing, and it doesn’t touch the core at all. It just inserts hooks into type_of / eval_statement / Type.union / Dispatch.dispatch with Module#prepend, and when the recorder is nil (= not tracing) the hook immediately supers. So check’s and annotate’s behavior is unchanged, without touching a single line of the code you copied in the main volume (see the head comment of tracer.rb).

This book (chibirigor)The real thing (Rigor)
What it showsframe-by-frame inference events (bind / union / dispatch)the same (replays inference’s derivation)
Interference with the coreModule#prepend hooks, immediate super if the recorder is nila record→replay probe riding the same inference as check
Output formatsterminal animation / --json / --verbose / --delayterminal animation / --format=json / --verbose / --delay / --line

There are leafy differences — the real thing can narrow to a single line with --line=N, chibirigor has no line filter — but the idea of replaying and showing the steps of inference and the three event kinds are the same. Where this book’s other tools were “Rigor’s real thing → tiny version,” trace is the rare one where this book’s side lines up nearly identical to the real thing.


a3-4. Summary — a quick reference of the “naive / real” correspondence

Section titled “a3-4. Summary — a quick reference of the “naive / real” correspondence”

The four bridges of this appendix on one sheet:

MechanismTreatment in this bookThe real thing’s behaviorPointer back
rigor check --explaintiny version present (maps unknown dispatch with :info · §a3-1x)maps fail-soft sites with :info using the Dynamic[Top] marker as a clueLittle Part 9
two-layer type display (erasure)annotate shows one internal type (no boundary, so no discrepancy · §a3-2)rounds internal precise type ↔ RBS-boundary type with erasure behind annotate / hoverLittle Part 1
rigor trace filetiny version nearly identical to the real thing (frame-by-frame bind / union / dispatch · §a3-3bx)replays the steps of inference as a terminal animation (--verbose / --line / --format=json)
5-stage dispatch cascade1-stage table lookup (METHODS; no tiny version)① constant folding → ② shape → ③ RBS → ④ in-source → ⑤ fallbackLittle Part 2

Each can be read as a correspondence in which the skeleton hand-built in this book (the Dynamic marker, annotate, the METHODS table) runs, in real Rigor, as that same skeleton scaled up.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.