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}