Chapter 19 — The Tape, and Errors as Data

The final piece of Ingle’s “knowing you’re right” story is observability: the language is built so that what your program does, and what the compiler thinks of it, are available as structured data — not just text for a human to squint at.

The execution tape

Run any program with --tape (or --emit=trace) and Ingle writes a tape: one JSON object per executed instruction, in order, recording the function, the instruction offset, the opcode, the source line, and a snapshot of the value stack at that moment.

inglec --tape program.ig
{"fn":"main","ip":0,"op":"CONST","line":6,"stack":[]}
{"fn":"main","ip":2,"op":"CALL","line":6,"stack":[3]}
{"fn":"dbl","ip":0,"op":"GET_LOCAL","line":4,"stack":[3]}
{"fn":"dbl","ip":2,"op":"GET_LOCAL","line":4,"stack":[3,3]}

It’s observer-only — recording a tape never changes how the program runs — and it costs essentially nothing when you don’t ask for it. You can read it yourself to follow a run step-by-step, or hand it to a tool (or an AI) to debug a run as data. When a contract is violated, a structured contract_violation event lands on this same tape, so a spec failure isn’t a dead end — it’s a record of exactly what happened, on what values.

Errors as data

Compile errors get the same treatment. By default they’re written for humans, in your program’s own terms, with a fix suggestion — you’ve seen plenty in this book. But add --diagnostics=json and every error comes out as a JSON object instead:

inglec --emit=run --diagnostics=json prog.ig
{"severity":"error","file":"prog.ig","line":5,"col":16,"message":"use of 'a' after it was moved","near":null,"help":"a move transfers ownership; pass it without `move` to borrow it instead, or make a copy before the move","note":{"line":4,"col":21,"message":"value moved here"}}

Same information as the friendly text version — file, line, column, message, a help fix, and a secondary note pointing at the related location — but as a structure a program can parse and act on without scraping strings.

A run-time fault, as data

Run-time failures get the same treatment. When an Err reaches main unhandled — or a contract or refinement is violated — Ingle stops with a structured fault that carries the offending value as data and pins it to a true file:line:col:

struct IoErr { code: int  path: string }

fn load() -> Result<int, IoErr> {
    return Err(IoErr { code: 5, path: "/etc/data" })
}

fn main() -> Result<int, IoErr> {
    let bytes = load()?
    return Ok(bytes)
}
error[unhandled_error]: an Err returned by main was never handled
  --> prog.ig (in main)
  why:    a Result that reaches main must be handled (match its Err), not left to propagate out
  values: error = IoErr { code: 5, path: "/etc/data" }
  hint:   match the Result and handle the Err arm (or have main do something with the error)

The values: line is the error’s payload, walked field by field and rendered as data — not an opaque blob but the IoErr you actually returned, nested strings quoted. A scalar Err like Err("disk offline") prints just as plainly. Whoever reads the fault — a person or a model — gets the real error to act on, not a pointer to chase.

Why would a language make its errors and traces machine-readable? Because Ingle is built LLM-first — for a world where a lot of code is written and run by models — and a model works better with “here is precisely what’s wrong, as data, and here’s the fix” than with prose it has to parse. Everything in this part — contracts as specs, fuzzing into counterexamples, deterministic replay, the tape, JSON diagnostics — serves the same structured feedback loop, one a machine or a human can close on its own.

Fireside trivia. Ingle’s tape is emitted from inside the VM’s instruction dispatch, so it grows automatically with every new opcode — the trace can’t fall behind the language, because emitting it is part of how the language runs. Observability that’s built in rather than bolted on tends to look like paranoia right up until the first time it saves your afternoon.