Skip to main content

brink_runtime/
error.rs

1//! Runtime error types.
2
3use alloc::string::String;
4
5use brink_format::{DecodeError, DefinitionId};
6
7/// Why execution ran out of content — the call-stack shape C#'s
8/// `Story.Continue()` inspects to pick one of four messages (`Story.cs`,
9/// the `AddError` calls guarding the "ran out of content" branch: it checks
10/// `callStack.CanPop(PushPopType.Tunnel)`, then `.CanPop(PushPopType.Function)`,
11/// then `!callStack.canPop`, with a final backstop for none of the above).
12///
13/// Classified the moment a frame's content is discovered exhausted
14/// (`vm::handle_frame_exhaustion`, the same instant C# reads
15/// `callStack.CanPop`), but only *stashed* on `Flow` — for the *next*
16/// `continue_single` call to raise as the deferred fault (issue #1574 ruled
17/// the deferred timing stays — this only changes which cause is attached,
18/// not *when* the fault fires) — on the paths where this exhaustion is
19/// itself the terminal one (`vm::Stepped::Done`). A frame whose exhaustion
20/// instead resumes execution (a completed thread with a parent to fall back
21/// to, a popped frame with content still below it) never writes its cause:
22/// otherwise a transient exhaustion elsewhere on the same flow (e.g. a
23/// `Story::call_function` boundary evaluating a function that calls a void
24/// helper) would clobber a cause an earlier, still-pending exhaustion had
25/// already recorded, and a later, unrelated `Done` would read it stale. It
26/// has to be classified that early rather than read fresh at fault time:
27/// unlike C#, this runtime's own exhaustion recovery always pops the
28/// exhausted frame (even a Tunnel with nothing pending), so by the time the
29/// deferred fault fires the frame that triggered it is usually long gone
30/// from the call stack.
31///
32/// In practice, only [`Plain`](Self::Plain) is reachable through any story
33/// today: a Tunnel or Function frame's exhaustion classifies correctly at
34/// the instant it happens, but this runtime's frame-popping (unlike C#'s)
35/// keeps unwinding past it instead of stopping there — cascading all the
36/// way to the root frame's own exhaustion, which is the one that actually
37/// produces the terminal `Done` and gets its `Plain` cause stashed, before
38/// the fault ever surfaces. Making the other three arms reachable needs a
39/// separate, deliberate fix to that popping behavior — tracked in #2005.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, thiserror::Error)]
41pub enum RanOutOfContentCause {
42    /// The top call-stack frame is a tunnel (`->t->`) — content ran out
43    /// mid-tunnel with no `->->` to return. Mirrors
44    /// `callStack.CanPop(PushPopType.Tunnel)`.
45    #[error("unexpectedly reached end of content. Do you need a '->->' to return from a tunnel?")]
46    Tunnel,
47    /// The top call-stack frame is a function call — content ran out
48    /// mid-function with no `~ return`. Mirrors
49    /// `callStack.CanPop(PushPopType.Function)`.
50    #[error("unexpectedly reached end of content. Do you need a '~ return'?")]
51    Function,
52    /// The call stack can't pop at all (only the root frame remains) — the
53    /// plain "story fell off the end" case, and the default when nothing
54    /// more specific was ever recorded. Mirrors `!callStack.canPop`.
55    #[default]
56    #[error("ran out of content. Do you need a '-> DONE' or '-> END'?")]
57    Plain,
58    /// The call stack can still pop, but the exhausted frame that produced
59    /// the terminal `Done` is neither a tunnel nor a function — e.g. a
60    /// `Thread` boundary with no parent thread left to fall back to, or a
61    /// `FunctionEvalFromGame` boundary that is itself the last frame
62    /// standing. C#'s backstop for a call-stack shape well-formed compiler
63    /// output should never produce; an *ordinary* `<- thread` completing
64    /// (a `Thread` frame exhausting with a parent thread still waiting)
65    /// never reaches this arm — that path resumes via `ThreadCompleted` and
66    /// records no cause at all.
67    #[error("unexpectedly reached end of content for unknown reason. Please debug compiler!")]
68    Unknown,
69}
70
71/// Errors that can occur during story linking or execution.
72#[derive(Debug, Clone, PartialEq, thiserror::Error)]
73pub enum RuntimeError {
74    #[error("bytecode decode error: {0}")]
75    Decode(#[from] DecodeError),
76
77    #[error("unresolved definition: {0}")]
78    UnresolvedDefinition(DefinitionId),
79
80    #[error("no root container found")]
81    NoRootContainer,
82
83    #[error("value stack underflow")]
84    StackUnderflow,
85
86    #[error("call stack underflow")]
87    CallStackUnderflow,
88
89    #[error("container stack underflow")]
90    ContainerStackUnderflow,
91
92    #[error("invalid choice index: {index} (available: {available})")]
93    InvalidChoiceIndex { index: usize, available: usize },
94
95    #[error("not waiting for choice")]
96    NotWaitingForChoice,
97
98    #[error("story has ended")]
99    StoryEnded,
100
101    #[error("unresolved global: {0}")]
102    UnresolvedGlobal(DefinitionId),
103
104    #[error("type error: {0}")]
105    TypeError(String),
106
107    #[error("division by zero")]
108    DivisionByZero,
109
110    #[error("unimplemented opcode: {0}")]
111    Unimplemented(String),
112
113    #[error("unresolved external function call: {0}")]
114    UnresolvedExternalCall(DefinitionId),
115
116    #[error("output capture underflow (no checkpoint)")]
117    CaptureUnderflow,
118
119    #[error("unknown flow: {0}")]
120    UnknownFlow(String),
121
122    #[error("flow already exists: {0}")]
123    FlowAlreadyExists(String),
124
125    #[error("{0}")]
126    RanOutOfContent(RanOutOfContentCause),
127
128    #[error("step limit exceeded ({0} steps)")]
129    StepLimitExceeded(u64),
130
131    #[error("line limit exceeded ({0} lines in a single turn)")]
132    LineLimitExceeded(usize),
133
134    #[error("locale checksum mismatch: expected {expected:#010x}, got {actual:#010x}")]
135    LocaleChecksumMismatch { expected: u32, actual: u32 },
136
137    #[error("locale scope not in base program: {0}")]
138    LocaleScopeNotInBase(DefinitionId),
139
140    #[error("locale missing scope required by strict mode: {0}")]
141    LocaleScopeMissing(DefinitionId),
142
143    #[error(
144        "function evaluation yielded (a function called from the engine cannot present choices or end the story)"
145    )]
146    FunctionYielded,
147
148    #[error("no function evaluation in progress")]
149    NotEvaluatingFunction,
150
151    #[error("a function evaluation is already in progress on this flow")]
152    AlreadyEvaluatingFunction,
153
154    /// `call_function` was given a name that resolves to no function/knot.
155    #[error("function not found: {0}")]
156    FunctionNotFound(String),
157
158    /// A function evaluated via the synchronous `call_function` path called an
159    /// external whose handler deferred (`Pending`) — it can't be resolved in a
160    /// one-shot synchronous call.
161    #[error("external '{0}' is async; cannot resolve during a synchronous call_function")]
162    AsyncExternalInCall(String),
163
164    /// `choose_path_string` was given a path that resolves to no knot,
165    /// stitch, or label.
166    #[error("no knot or stitch found at path '{0}'")]
167    UnknownPath(String),
168
169    /// `choose_path_string` was called while the flow is parked on an
170    /// unresolved external call. A pending host call cannot be silently
171    /// abandoned — resolve it (or reset the story) before jumping.
172    #[error(
173        "cannot jump to '{path}': the flow is parked on unresolved external '{external}' — \
174         resolve it before jumping"
175    )]
176    JumpWhileAwaitingExternal { path: String, external: String },
177
178    /// A host-directed entry (`choose_path_string_with_args`) — or a
179    /// `call_function` — was given the wrong number of arguments for the
180    /// target's declared parameters.
181    #[error("'{target}' expects {expected} argument(s), got {got}")]
182    ArgCountMismatch {
183        /// The knot/stitch/function path or name.
184        target: String,
185        /// Declared parameter count.
186        expected: u8,
187        /// Arguments the host supplied.
188        got: usize,
189    },
190
191    /// A host **semantic** access (variable get/set, entry lookup, function
192    /// eval) targeted a `#@private` definition while visibility enforcement
193    /// was on (M-2b, `docs/modules-spec.md` §4 boundary rule 2). The host is
194    /// outside every module. Dev tooling (play-from-here) opts out via
195    /// [`Story::set_visibility_enforcement`](crate::Story::set_visibility_enforcement).
196    /// Persistence (save/load/journal/replay) is unaffected — it never routes
197    /// through the enforced surface.
198    #[error(
199        "'{name}' is #@private and cannot be accessed by the host \
200         (dev tooling may override visibility enforcement)"
201    )]
202    PrivateAccess {
203        /// The private definition's name or path, as the host supplied it.
204        name: String,
205    },
206
207    // ── T1b collections (docs/value-model-spec.md §11c) ──────────────
208    //
209    // Out-of-bounds/missing-key reads and writes are turn-terminating
210    // runtime faults — total operations with no silent growth on
211    // write-past-end (`docs/t1b-surface-spec.md` §4). Propagating as
212    // `RuntimeError` (rather than a special in-band value) is exactly what
213    // "turn-terminating" already means in this VM: it unwinds `step()`,
214    // ending the current turn, the same mechanism `DivisionByZero` uses.
215    /// Array index read/write out of bounds (`0 <= index < len` required).
216    #[error("array index {index} out of bounds (len {len})")]
217    IndexOutOfBounds { index: i32, len: usize },
218    /// Map key *read* (`m[k]`, `MapGet`) on a key that isn't present, or a
219    /// path-projection *write* through a `ref` whose final segment key
220    /// isn't present (`docs/t1e-spec.md` §4). Indexed *assignment*
221    /// (`m[k] = v` via the `IndexSet` opcode) no longer raises this fault on
222    /// a missing key — it inserts instead (JS/Python semantics, issue #856,
223    /// ruled 2026-07-15).
224    #[error("map has no key {key}")]
225    MapKeyNotFound { key: String },
226    /// `a[i]`/`a[i] = v`/`m[k]`/`m[k] = v` where `a`/`m` isn't an
227    /// `Array`/`Map`.
228    #[error("cannot index into a {0} value")]
229    NotIndexable(&'static str),
230    /// Array index expression didn't evaluate to an `Int`.
231    #[error("array index must be an int, got {0}")]
232    InvalidArrayIndex(&'static str),
233    /// Map key expression evaluated to a type outside the ratified key
234    /// domain (int/string/bool — value-model-spec §4).
235    #[error("map key must be int, string, or bool, got {0}")]
236    InvalidMapKeyType(&'static str),
237    /// `PushLiteral(idx)` referenced an index outside the literal pool —
238    /// malformed bytecode, not an author-triggerable condition.
239    #[error("literal pool index {0} out of range")]
240    InvalidLiteralIndex(u32),
241    /// A `NameId` (container/address-path name) referenced an index outside
242    /// `StoryData::name_table` — malformed bytecode, not an
243    /// author-triggerable condition. Caught at link time, before any of the
244    /// name is used to build path lookup tables.
245    #[error("name id {0} out of range")]
246    InvalidNameId(u16),
247
248    // ── TM-4 records (docs/typed-mode-spec.md §6 / value-model-spec §11c) ──
249    /// `RecordNew(shape_id)` referenced a shape id outside the compiled
250    /// `StructShapes` table — malformed bytecode.
251    #[error("struct shape id {0} out of range")]
252    InvalidShapeId(u32),
253    /// `RecordGetDyn`/`RecordSetDyn` on a value that isn't a `Record`.
254    #[error("cannot access a field on a {0} value")]
255    NotARecord(&'static str),
256    /// `RecordGetDyn`/`RecordSetDyn` named a field the record's shape
257    /// doesn't declare — a compile-time typo under strict mode (surfaced as
258    /// a diagnostic there) or a genuine dynamic mismatch under gradual mode,
259    /// both turn-terminating at runtime (spec §11c pattern).
260    #[error("struct has no field {0:?}")]
261    RecordFieldNotFound(String),
262    /// `RecordGet(offset)`/`RecordSet(offset)` (TM-4c static-offset field
263    /// ops) with an offset outside the popped record's own field vector.
264    /// These ops never re-check the record's shape (that's the payoff over
265    /// `RecordGetDyn`/`RecordSetDyn`) — only the field count is verified, so
266    /// this is the sole fault this pair can produce, malformed bytecode or
267    /// otherwise.
268    #[error("struct field offset {offset} out of range (record has {len} fields)")]
269    RecordFieldOffsetOutOfRange { offset: u16, len: usize },
270
271    // ── TM-3 completion: conversion intrinsics (docs/typed-mode-spec.md
272    // §4, maintainer ruling 2026-07-13, issue #659) ──────────────────────
273    /// `int(x)`/`float(x)` where `x` is a `String` that fails to parse as
274    /// the target numeric type. Turn-terminating fault — no
275    /// zero-defaulting, no silent garbage (ruling 1: "Parse failure is a
276    /// turn-terminating fault... like a missing map key"). Unlike this,
277    /// the classic uppercase `INT()`/`FLOAT()` builtins keep their
278    /// pre-existing silent-0-on-string-parse-failure legacy behavior
279    /// (`value_ops::cast_to_int`/`cast_to_float`) untouched within their own
280    /// `Int`/`Float`/`Bool`/`String` domain — oracle-byte-identical, a
281    /// distinct code path. Outside that domain (divert targets, pointers,
282    /// collections, records, function/handle/projection values), the
283    /// uppercase builtins now raise [`InvalidConversionDomain`](Self::InvalidConversionDomain)
284    /// too (issue #955) instead of the wildcard-fold-to-zero they used to —
285    /// those variants were never oracle-reachable through `INT()`/`FLOAT()`.
286    #[error("cannot parse {input:?} as {target}")]
287    ConversionParseFailure { target: &'static str, input: String },
288    /// `int(x)`/`float(x)` where `x` is outside the permissive
289    /// numeric+bool domain (divert targets, LIST values, arrays, maps,
290    /// records) — compile error under `types = strict` (`brink-analyzer`'s
291    /// intrinsic typing/domain check), turn-terminating fault under
292    /// `types = gradual` (ruling 2). Also raised by the classic uppercase
293    /// `INT()`/`FLOAT()` builtins (`value_ops::cast_to_int`/`cast_to_float`)
294    /// for the same reason, with an uppercase `target` label (issue #955) —
295    /// no spec (`value-model-spec.md`, `t1c`/`t1d`/`t1e-spec.md`) rules a
296    /// conversion for those variants, so faulting is the conservative
297    /// default rather than the old silent zero.
298    #[error("cannot convert a {got} value to {target}")]
299    InvalidConversionDomain {
300        target: &'static str,
301        got: &'static str,
302    },
303
304    // ── T1c function values (docs/t1c-spec.md §3/§6, issue #700) ──────────
305    /// `call(f, …)` / a direct `f(…)` where the callee value is not a
306    /// function value (nor a divert target). Gradual-mode dispatch fault —
307    /// "no silent garbage" (spec §3, value-model-spec §11c).
308    #[error("cannot call a {0} value as a function")]
309    NotCallable(&'static str),
310    /// Calling a function value with the wrong number of arguments: the bound
311    /// prefix plus the supplied args must exactly equal the target's declared
312    /// arity (spec §3). Turn-terminating in gradual mode; strict mode catches
313    /// it at compile time (spec §4).
314    #[error(
315        "function value expects {expected} argument(s), got {got} (bound {bound} + supplied {supplied})"
316    )]
317    FunctionValueArity {
318        expected: usize,
319        got: usize,
320        bound: usize,
321        supplied: usize,
322    },
323    /// A rehydrated function value's bound env no longer matches the current
324    /// signature — a param was renamed, reordered, or re-moded across a
325    /// recompile (spec §6). A defined fault, never a silent misbinding.
326    #[error("function value no longer matches its target's signature: {0}")]
327    FunctionValueRehydrationMismatch(String),
328    /// Invoking a function value that `ref`-binds a flow-private (`#@local`)
329    /// cell (spec §3). T1c ships this fault instead of creating-flow identity
330    /// (#597): a `#@local`-`ref` binding can only be dereferenced safely from
331    /// its creating flow, and no creating-flow identity is tracked yet, so the
332    /// invocation faults rather than risk a silent cross-flow misbinding. The
333    /// payload is the bound cell's name.
334    #[error(
335        "function value ref-binds flow-private cell `{0}`; cross-flow invocation is a fault in T1c (see #597)"
336    )]
337    FunctionValueCrossFlowLocal(String),
338
339    // ── T1e path projections (docs/t1e-spec.md §1(2)/§3) ──────────────────
340    /// A live path projection's snapshot segments no longer resolve against
341    /// the root cell's *current* value at read or write time: a shrunk
342    /// array, a removed map key, or a struct field dropped by recompile.
343    /// The single ratified turn-terminating fault for every path-invalidation
344    /// cause (spec §1(2): "a defined turn-terminating runtime fault — not a
345    /// clamp, not UB"). The payload carries the underlying cause (an
346    /// `IndexOutOfBounds`/`MapKeyNotFound`/`RecordFieldNotFound`-shaped
347    /// message, or a root-resolution failure).
348    #[error("projection invalidated: {0}")]
349    ProjectionInvalidated(String),
350
351    // ── Stdlib slice 1 completion: `char_at` (`docs/t1b-surface-spec.md`
352    // §5, issue #857) ──────────────────────────────────────────────────────
353    /// `char_at(s, i)`'s index expression didn't evaluate to an `Int`.
354    #[error("char_at index must be an int, got {0}")]
355    CharAtIndexNotInt(&'static str),
356    /// `char_at(s, i)` where `i` is outside `[0, char_count)` — chars
357    /// (Unicode scalar values), not UTF-8 bytes (the issue's "author
358    /// sanity" ruling), so `len` is `s.chars().count()`, never
359    /// `s.len()`. Turn-terminating fault, no silent empty/clamped result
360    /// (value-model-spec §11c) — matches `IndexOutOfBounds`'s posture for
361    /// arrays.
362    #[error("char_at index {index} out of bounds ({len} chars)")]
363    CharAtOutOfBounds { index: i32, len: usize },
364
365    // ── NS-A1 Option[T] + the ruled stdlib flips (`docs/stdlib-spec.md`
366    // §§3-5) ──────────────────────────────────────────────────────────────
367    /// A stdlib verb was handed a container/argument of the wrong runtime
368    /// type — `find` on a non-string, `min`/`first`/`pop` on a non-array,
369    /// `get`/`contains_value`/`clear` on a non-map. A malformed *question*
370    /// is a bug (the ruled fault-vs-absence doctrine), so this is a
371    /// turn-terminating fault, never a `none`.
372    #[error("`{verb}` expects {expected}, got {found}")]
373    StdlibWrongType {
374        verb: &'static str,
375        expected: &'static str,
376        found: &'static str,
377    },
378    /// `min`/`max` reached an element outside the currently-orderable set
379    /// (int/float/bool/string, homogeneous per the §4b roster), or a
380    /// cross-type pair (int vs string). Turn-terminating fault — an
381    /// unorderable extremum question is malformed, not absent.
382    #[error("`{verb}` cannot order element of type {found}")]
383    NotOrderable {
384        verb: &'static str,
385        found: &'static str,
386    },
387
388    // ── NS-A4: the ordering doctrine (`docs/stdlib-spec.md` §4b, issue
389    // #1110) ──────────────────────────────────────────────────────────────
390    /// DEV mode only: an ordering verb (`sort`/`sorted`/`min`/`max`; A7
391    /// adds `heap_push`) reached a float NaN comparand. NaN flows freely
392    /// through arithmetic — ordering contexts are where it stops: in dev
393    /// mode the upstream bug surfaces at its first ordering consumption as
394    /// this turn-terminating fault. PROD mode instead places NaN by the
395    /// pinned non-fabricating total order (`-0 == +0` ties, NaN greatest,
396    /// NaN-vs-NaN ties) and keeps moving — the mode changes WHERE execution
397    /// stops, never WHAT values are fabricated. `sort_by`/`sorted_by`
398    /// deliberately do NOT raise this (F14: the comparator owns the order).
399    #[error(
400        "`{verb}` reached a NaN comparand — NaN cannot be ordered (dev-mode fault; prod mode \
401         places NaN by the pinned total order)"
402    )]
403    UnorderedComparand { verb: &'static str },
404    /// `sort_by`/`sorted_by` was handed a comparator that is not a function
405    /// value (`FnRef`/`Closure`). Malformed question — turn-terminating.
406    #[error("`{verb}` comparator must be a function value `fn(T, T): int`, got {found}")]
407    ComparatorNotAFunction {
408        verb: &'static str,
409        found: &'static str,
410    },
411    /// A `sort_by`/`sorted_by` comparator returned something other than an
412    /// int (F0's ruled shape: negative = less, zero = tie, positive =
413    /// greater). Turn-terminating — a silent coercion here would scramble
414    /// the order.
415    #[error(
416        "`{verb}` comparator must return an int (negative = less, zero = tie, positive = \
417         greater), got {found}"
418    )]
419    ComparatorReturnType {
420        verb: &'static str,
421        found: &'static str,
422    },
423    /// A fn-value verb (`map`/`filter`/`fold`/`filter_map`/`each`/
424    /// `map_each` — `docs/stdlib-spec.md` §4, issue #1679) was handed a
425    /// callback that is not a function value (`FnRef`/`Closure`). Malformed
426    /// question — turn-terminating, pure or effectful alike. Distinct from
427    /// [`ComparatorNotAFunction`](Self::ComparatorNotAFunction) because each
428    /// verb names its own expected shape.
429    #[error("`{verb}` callback must be a function value {expected}, got {found}")]
430    CallbackNotAFunction {
431        verb: &'static str,
432        /// The callback's declared shape, already back-quoted for the
433        /// message (e.g. `` `fn(T): bool` ``).
434        expected: &'static str,
435        found: &'static str,
436    },
437    /// A fn-value verb's callback returned a value of the wrong shape —
438    /// `filter`, whose predicate must return a bool, and `filter_map`,
439    /// whose Option-mapper must return an `Option`. Coercing truthiness or
440    /// unwrapping a non-Option here would silently change which elements
441    /// survive, so this is turn-terminating.
442    #[error("`{verb}` callback must return {expected}, got {found}")]
443    CallbackReturnType {
444        verb: &'static str,
445        expected: &'static str,
446        found: &'static str,
447    },
448    /// A `sort_by`/`sorted_by` comparator, or a fn-value verb's callback
449    /// (issue #1679, pure quartet and effectful pair alike), broke a
450    /// contract the VM can observe: it presented a choice, reached
451    /// `-> DONE`/`-> END`, called an external function, exceeded the
452    /// nested-evaluation step budget, or recursed past the nesting depth
453    /// limit. These four are architectural — no handler exists mid-opcode —
454    /// so they fire for `each`/`map_each` exactly as for the pure quartet;
455    /// being effectful widens what a callback may *do*, not what the VM can
456    /// honor mid-op. The checker enforces the pure·silent half of the
457    /// contract statically where the callee's origin is provable (E119,
458    /// pure quartet only); this fault is the gradual-mode runtime residual
459    /// either way. `role` names the shape the *author* wrote —
460    /// `"comparator"` for `sort_by`/`sorted_by`, `"callback"` for every
461    /// fn-value verb (`callback_role`) — so a `map`/`each`/… author is
462    /// never told they wrote a bad comparator.
463    #[error("`{verb}` {role} {what} — {role}s must be pure, silent functions")]
464    ComparatorEscaped {
465        verb: &'static str,
466        role: &'static str,
467        what: &'static str,
468    },
469    /// DEV mode only (F34, ruled 2026-07-19): a `sort_by`/`sorted_by`
470    /// comparator, or a **pure** fn-value verb's callback
471    /// (`map`/`filter`/`fold`/`filter_map`, issue #1679), performed a
472    /// world-write mid-evaluation — assigned a global (directly, or through
473    /// a `ref`-parameter pointer / path projection) or advanced the RNG
474    /// cell (a draw IS a write: a random comparator/callback is exactly the
475    /// non-determinism the pure·silent contract bans). PROD mode skips the
476    /// check entirely and the write executes — defined and deterministic,
477    /// because the stable merge-sort's comparison sequence is fixed and the
478    /// fn-value verbs walk their array in iteration order (the mode changes
479    /// WHERE execution stops, never WHAT is produced). Visit-count
480    /// increments from the callee's own invocation are NOT world-writes —
481    /// they are the ruled in-story dispatch semantics and stay exempt.
482    /// Reads are not guarded at runtime (E119's static bound owns the read
483    /// posture, where E119 gates at all). `role` is the same author-facing
484    /// noun as [`ComparatorEscaped`](Self::ComparatorEscaped); like it,
485    /// this is the gradual-mode runtime residual of the E119 gate. **Never
486    /// fires for the effectful pair** (`each`/`map_each`, issue #1679 slice
487    /// 2, in either mode): world-writes are exactly what they exist to
488    /// permit — see `vm::guard_comparator_write`.
489    #[error(
490        "`{verb}` {role} {what} — {role}s must be pure, silent functions (dev-mode fault; prod \
491         mode executes the write)"
492    )]
493    ComparatorWroteState {
494        verb: &'static str,
495        role: &'static str,
496        what: &'static str,
497    },
498
499    // ── F27: Option has no truthiness (`docs/stdlib-spec.md` §1.6, ruled
500    // 2026-07-19, issue #1120) ────────────────────────────────────────────
501    /// A `Value::OptionVal` reached the VM's truthiness evaluation (`GotoIf`,
502    /// `JumpIfFalse`, `Not`, a choice condition). Option has **no**
503    /// truthiness — truthiness is a quiet coercion of exactly the kind
504    /// `Option[T] ≠ T` exists to ban — so this is the gradual-mode
505    /// turn-terminating fault; `types = strict` reports the same condition
506    /// statically (E116). Authors write `== none` / `== some(x)`, or the
507    /// `as`-binding (B1b, issue #1475 — see [`Self::AsBindingNotOption`],
508    /// its own fault). Supersedes NS-A1's shipped falsy-none behavior.
509    #[error("an Option has no truthiness — test `== none` / `== some(x)` explicitly")]
510    OptionTruthiness,
511
512    // ── B1b: the `as` binding (`docs/decision-log.md` 2026-07-26, issue
513    // #1475) ─────────────────────────────────────────────────────────────
514    /// `Opcode::OptionBind` received a non-`Option` operand — `if EXPR as
515    /// name { … }` where `EXPR` does not evaluate to an `Option[T]`. The
516    /// binding's whole job is to unwrap `Option[T]` to `T`, so there is
517    /// nothing to bind. This is the gradual-mode residual of the checker's
518    /// strict-mode `E147` (the same statically/dynamically paired posture
519    /// [`Self::OptionTruthiness`] has with `E116`); on the native surface,
520    /// which is strict-only, `E147` catches every statically classifiable
521    /// case first and this fault is the backstop for the rest.
522    #[error("the `as` binding requires an Option, got {found}")]
523    AsBindingNotOption {
524        /// The offending operand's runtime type name (`vm::value_type_name`).
525        found: &'static str,
526    },
527
528    // ── NS-A5: the inhabited-range refinement (`docs/stdlib-spec.md` §7,
529    // F8 ruled 2026-07-19) ────────────────────────────────────────────────
530    /// `int(range)` reached an **empty** range at runtime — the F8 gradual-
531    /// mode residual, and THE template for every future value refinement:
532    /// under gradual typing the refinement check is inert at compile time
533    /// and this turn-terminating fault is what remains; under `types =
534    /// strict` the same condition is unrepresentable (the checker demands
535    /// `NonEmptyRange` evidence — a provably-inhabited literal or a
536    /// `non_empty(r)` unwrap — and reports E117 statically). A draw from
537    /// nothing is a malformed question, never an absence, so this is a
538    /// fault and not a `none` (the ruled fault-vs-absence doctrine;
539    /// contrast `pick(0..0)`, which IS absence and returns `none`).
540    #[error("`int` cannot draw from the empty range {range} — validate with `non_empty(r)` first")]
541    EmptyRangeDraw {
542        /// The written form of the offending range (`0..0`, `5..=2`, …).
543        range: String,
544    },
545
546    // ── NS-A7: Weighted[T] evidence-by-construction (`docs/stdlib-spec.md`
547    // §8, issue #1113) ────────────────────────────────────────────────────
548    /// `weighted(…)` reached a **computed** weight that is not a positive
549    /// int at construction time — the E078-style split's runtime half: a
550    /// weight the checker could classify statically is the E120 compile
551    /// error; a computed weight that turns out zero/negative/non-int is
552    /// this turn-terminating construction fault. Construction is the
553    /// validator (the §7 parse-don't-validate shape), so `roll` over any
554    /// table that exists is total.
555    #[error(
556        "`weighted` requires positive int weights, got {found} — construction refuses empty/zero/negative-weight tables"
557    )]
558    WeightedBadWeight {
559        /// Display form of the offending weight value (`0`, `-3`, `1.5`, a
560        /// type name for non-numerics).
561        found: String,
562    },
563    /// The `weighted_new` op received a malformed pair row (empty, or an
564    /// odd flattened length). Unreachable through the compiler — the E120
565    /// gate refuses empty/odd construction shapes statically — so this
566    /// guards hand-crafted or corrupt bytecode only (the malformed-bytecode
567    /// robustness discipline, never a panic).
568    #[error("`weighted` construction received {detail}")]
569    WeightedMalformedTable { detail: &'static str },
570}