Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ All notable changes to EigenScript are documented here.

## [Unreleased]

### Added
- **`--step` — the eigsdap v1 CLI tape-stepper (#418).**
`eigenscript --step <tape> [source.eigs]` opens a recorded trace tape
(from `--trace`, `EIGS_TRACE`, or a `--test --trace-on-fail` failure) in
an interactive time-travel debugger: step forward AND backward over line
events, breakpoints as line filters with `c`/`rc` continue in both
directions, jump-to-line both ways, and binding reconstruction from the
tape's assignment deltas at any position. `p` shows each binding's value
*plus its observer-trajectory label* — computed by the runtime's own
#294 value-channel classifier (`observer_slot_record_value` is now
exported so the stepper feeds reconstructed histories through the same
`ObserverSlot` the language uses; a mirror implementation could drift,
this cannot) — and `t <name>` shows the running label per assignment, so
stepping back rewinds the *diagnosis*: walk `rc` backward to the exact
step where a binding flipped from `[moving]` to `[oscillating]`. Pure
tape reader (nothing executes, step-back is an index decrement); #411
version rule enforced exactly like replay (mismatch = refuse, exit 3);
CLI-only translation unit (`src/step.c`), excluded from the embed/LSP/
freestanding profiles. New suite section [42f] (16 checks,
`tests/test_step.sh`) covers stepping, label flips on back-step,
breakpoints, jumps, refusals, and driving a `--trace-on-fail` tape.
Ships with docs/DEBUGGING.md — the failure→replay→interrogate loop,
the stepper, and the temporal interrogatives in one place.

## [0.29.0] - 2026-07-10

### Added
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ LDFLAGS := -pie -Wl,-z,relro,-z,now -lm -lpthread
endif

SRC_DIR := src
SOURCES := $(SRC_DIR)/eigenscript.c $(SRC_DIR)/lexer.c $(SRC_DIR)/parser.c $(SRC_DIR)/builtins.c $(SRC_DIR)/builtins_tensor.c $(SRC_DIR)/hash.c $(SRC_DIR)/arena.c $(SRC_DIR)/state.c $(SRC_DIR)/strbuf.c $(SRC_DIR)/ext_store.c $(SRC_DIR)/fmt.c $(SRC_DIR)/lint.c $(SRC_DIR)/chunk.c $(SRC_DIR)/compiler.c $(SRC_DIR)/vm.c $(SRC_DIR)/jit.c $(SRC_DIR)/trace.c $(SRC_DIR)/eigs_embed.c $(SRC_DIR)/repl.c $(SRC_DIR)/main.c
SOURCES := $(SRC_DIR)/eigenscript.c $(SRC_DIR)/lexer.c $(SRC_DIR)/parser.c $(SRC_DIR)/builtins.c $(SRC_DIR)/builtins_tensor.c $(SRC_DIR)/hash.c $(SRC_DIR)/arena.c $(SRC_DIR)/state.c $(SRC_DIR)/strbuf.c $(SRC_DIR)/ext_store.c $(SRC_DIR)/fmt.c $(SRC_DIR)/lint.c $(SRC_DIR)/chunk.c $(SRC_DIR)/compiler.c $(SRC_DIR)/vm.c $(SRC_DIR)/jit.c $(SRC_DIR)/trace.c $(SRC_DIR)/eigs_embed.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c $(SRC_DIR)/main.c
BINARY := $(SRC_DIR)/eigenscript

# CLI-only translation units: linked into the binary, never into the
# runtime library (repl.c pulls termios/isatty — banned in the
# freestanding/embed profile, same footing as main.c).
CLI_ONLY := $(SRC_DIR)/main.c $(SRC_DIR)/repl.c
# freestanding/embed profile, same footing as main.c; step.c is the
# --step tape-stepper, stdio+isatty, same footing).
CLI_ONLY := $(SRC_DIR)/main.c $(SRC_DIR)/repl.c $(SRC_DIR)/step.c

FULL_SOURCES := $(SOURCES) $(SRC_DIR)/ext_http.c $(SRC_DIR)/ext_db.c \
$(SRC_DIR)/model_io.c $(SRC_DIR)/model_infer.c $(SRC_DIR)/model_train.c
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ Full map: **[docs/README.md](docs/README.md)**. Highlights:
- [docs/STDLIB.md](docs/STDLIB.md) — standard library guide
- [docs/DIAGNOSTICS.md](docs/DIAGNOSTICS.md) — error format and exit codes
- [docs/TRACE.md](docs/TRACE.md) — execution trace, deterministic replay, temporal interrogatives
- [docs/DEBUGGING.md](docs/DEBUGGING.md) — the debugging loop and the `--step` tape-stepper (time travel + trajectories)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — lexer → parser → bytecode VM → JIT internals
- [docs/EMBEDDING.md](docs/EMBEDDING.md) — C embedding API reference (`eigs_embed.h`)
- [examples/errors/](examples/errors/) — programs that fail on purpose,
Expand Down
25 changes: 19 additions & 6 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ VERSION=$(cat ../VERSION)
# Compiler is overridable (e.g. CC=clang ./build.sh) so CI can exercise
# more than one toolchain; defaults to gcc.
CC="${CC:-gcc}"
SOURCES="eigenscript.c lexer.c parser.c builtins.c builtins_tensor.c hash.c arena.c state.c strbuf.c ext_store.c fmt.c lint.c chunk.c compiler.c vm.c jit.c trace.c eigs_embed.c repl.c main.c"
# Anti-drift: the source list is read from the Makefile's SOURCES (the
# single source of truth), like tools/amalgamate.sh already does. build.sh
# used to carry its own copy and it silently fell behind every time a new
# translation unit landed — the CI legs that build through this script
# (install.sh, bench, the OS matrix) then failed at link while `make` was
# green (step.c was the latest instance of the class).
SOURCES=$(grep '^SOURCES :=' ../Makefile | sed 's/^SOURCES :=//; s|\$(SRC_DIR)/||g')
CLI_ONLY=$(grep '^CLI_ONLY :=' ../Makefile | sed 's/^CLI_ONLY :=//; s|\$(SRC_DIR)/||g')
if [ -z "$SOURCES" ]; then
echo "build.sh: cannot read SOURCES from ../Makefile" >&2
exit 1
fi

# macOS Intel JIT: enabled via the Mach-O TLV-aware prologue (the JIT
# now calls eigs_jit_load_eigs_current instead of inlining %fs:
Expand All @@ -22,11 +33,13 @@ JIT_FLAGS=""

if [ "$1" = "lsp" ]; then
# Language server (src/eigenlsp) — the editor-intelligence half of the
# toolchain. Links eigenlsp.c against the runtime (SOURCES minus main.c),
# minimal extensions, gcc-only like the rest of build.sh. Source list reused
# from SOURCES above so it can't drift.
LSP_SOURCES="${SOURCES/ main.c/}"
LSP_SOURCES="${LSP_SOURCES/ repl.c/} eigenlsp.c" # repl.c is CLI-only, like main.c
# toolchain. Links eigenlsp.c against the runtime (SOURCES minus the
# CLI-only units, read from the Makefile's CLI_ONLY so the drop list
# can't drift either), minimal extensions, gcc-only like the rest of
# build.sh.
LSP_SOURCES=" $SOURCES "
for u in $CLI_ONLY; do LSP_SOURCES="${LSP_SOURCES/ $u / }"; done
LSP_SOURCES="$LSP_SOURCES eigenlsp.c"
$CC -Wall -Wextra -Werror=implicit-function-declaration -O2 -fstack-protector-strong -o eigenlsp $LSP_SOURCES \
-DEIGENSCRIPT_EXT_HTTP=0 \
-DEIGENSCRIPT_EXT_MODEL=0 \
Expand Down
140 changes: 140 additions & 0 deletions docs/DEBUGGING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Debugging EigenScript

EigenScript's debugging story is built on one property: **the runtime
records its own execution**. Every nondeterministic input lands on a
trace tape ([TRACE.md](TRACE.md)), every assignment feeds a per-name
history, and every binding carries an observer trajectory. Debugging is
therefore mostly *interrogation* — of a live run, or of a recorded one —
rather than re-running and hoping.

The canonical loop:

```
failure → replay → interrogate
```

1. **Capture the failure as a tape.** `eigenscript --test
--trace-on-fail tests/` records each test; a failing test keeps its
tape and prints the exact reproducer line. Or record any run
directly: `eigenscript --trace run.tape prog.eigs` (the CLI twin of
`EIGS_TRACE=run.tape`).
2. **Replay it.** `EIGS_REPLAY=run.tape eigenscript prog.eigs` re-drives
the run with the *same* recorded nondeterminism — same random draws,
same clock reads, same file bytes. Byte-identical output, first try.
3. **Interrogate it.** Ask the run what happened — from inside the
program (temporal interrogatives), or from outside (the tape-stepper).

## The tape-stepper: `eigenscript --step`

```
$ eigenscript --step run.tape prog.eigs
tape run.tape: 68 steps, 94 assigns (5 bindings), 1 nondet records
step 1/68 line 9
(step)
```

`--step` opens a recorded tape in an interactive stepper. Nothing
executes — it is a pure *reader* over the tape's line events (`L`),
assignment deltas (`A`), and nondet returns (`N`) — so stepping
**backward is exactly as cheap as forward**: the greyed-out step-back
button incumbent debuggers can't light up is just an index decrement
here. The optional second argument is the source file, shown alongside
each stop.

| Command | Effect |
|---------|--------|
| `s [n]`, Enter | step forward (n lines) |
| `b [n]` | step backward |
| `br <line>` | set a breakpoint (a line filter); `br` lists, `del <line>` removes |
| `c` / `rc` | continue forward / **backward** to the next breakpoint |
| `j <line>` / `jb <line>` | jump to the next / previous stop at a line |
| `p [name]` | bindings at this point: value + trajectory label |
| `t <name>` | one binding's full trajectory up to this point |
| `i` | tape info and current position |
| `q` | quit |

Each stop shows the line, its source text (when the source is given),
and the events that happened during it:

```
step 11/68 line 7
| conv is conv / 2
A conv=256
A i=2
```

### Trajectories, not just values

`p` shows something no incumbent debugger has: each binding's **observer
trajectory classification** at the current position, computed by the
runtime's own `report_value` classifier (the #294 value channel — see
[OBSERVER.md](OBSERVER.md)) over the assignments seen *so far*:

```
(step) p
osc = -1 [oscillating] (31 assigns)
conv = 9.5367431640625e-07 [converged] (31 assigns)
msg = "hello" (1 assign)
```

Because the label is position-dependent, stepping backward rewinds the
*diagnosis*, not just the values — walk back 40 steps and `conv` reads
`[moving]` again: you can find the exact moment a binding settled, blew
up, or began oscillating. `t <name>` shows the same thing longitudinally,
one row per assignment with the running label.

Labels come from feeding the reconstructed numeric history through the
same `ObserverSlot` machinery the language uses at runtime, so the
stepper's `[converged]` is *by construction* what `report_value of x`
would have said at that moment. Non-numeric bindings show their value
with no label.

### Scope and honesty

- `A` records fire at **every scope** but carry the name only, so
same-named bindings from different scopes merge into one stream —
exactly like `state_at` ([TRACE.md](TRACE.md)). A function local named
`i` and a top-level `i` are one trajectory to the stepper.
- Heap values appear as the tape records them: strings quoted (and
truncated past the record budget), collections as size markers
(`<list:3>`, `<dict:2>`), functions as `<fn>`.
- The stepper enforces the #411 version rule exactly as replay does: the
tape's format and runtime version must match this binary, else it
refuses with exit 3. A viewer that guessed at a foreign encoding would
show plausible-but-wrong state — the silent divergence the header
exists to prevent.

## Temporal interrogatives (in-program)

The language itself can ask historical questions with no tape at all —
the assignment history is always on when the program contains a temporal
query (see [TRACE.md](TRACE.md) for the compile gate, and
[SYNTAX.md](SYNTAX.md) for the grammar):

- `prev of x` — the previous value of `x`
- `what is x at 42` — `x`'s value when line 42 last ran
- `when is x at 42` — how many times `x` had been assigned by then
- `where/why/how is x at 42` — the observer's verdict at that moment
- `state_at of 42` — every binding's value at line 42, as a dict

Under replay these read the *failing* run's history — the interrogatives
and the stepper are two views of the same recorded truth: use
interrogatives when the program should inspect itself, the stepper when
you want to scrub the timeline from outside.

## Choosing a tool

| Situation | Tool |
|-----------|------|
| A test fails in CI | `--test --trace-on-fail`, archive the tape, replay locally |
| A flake you can't reproduce | record with `--trace` until it fires; the tape *is* the repro |
| "When did this variable go wrong?" | `--step`, breakpoint the line, `rc` backward, watch `p`'s label flip |
| A program should react to its own history | temporal interrogatives in the source |
| Meta-circular debugging with a UI | `examples/debugger.eigs` (its own history, not the tape) |

## Roadmap (#418)

This CLI stepper is eigsdap **v1**. Blocked behind it: function-local
frames as first-class scopes (v2) and a DAP server over stdio —
`stepBack`/`reverseContinue` in VS Code with trajectory child nodes in
the variables pane (v3).
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ WASM build, no install required.
| [STDLIB.md](STDLIB.md) | The 73 standard-library modules under `lib/`. |
| [OBSERVER.md](OBSERVER.md) | Observer semantics in depth: entropy, dH, predicates, `unobserved`. |
| [TRACE.md](TRACE.md) | Temporal interrogatives, the trace tape, deterministic replay, and replay's nondeterminism boundary. |
| [DEBUGGING.md](DEBUGGING.md) | The failure→replay→interrogate loop and the `--step` tape-stepper (step back, trajectories, breakpoints). |
| [DIAGNOSTICS.md](DIAGNOSTICS.md) | Error messages, the linter, and the formatter. |

## Internals
Expand Down
7 changes: 5 additions & 2 deletions docs/TRACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ draw, the clock read, the file bytes — so a flake reproduces on the first try.

The canonical loop: **failure → replay → interrogate**. Once you are replaying
the exact run, the temporal interrogatives read its history — `prev of x`,
`state_at`, and the `eigsdap` tape-stepper (#418) — so you inspect the trajectory
that actually failed, not a fresh one that might not.
`state_at`, and the `--step` tape-stepper ([DEBUGGING.md](DEBUGGING.md)) — so
you inspect the trajectory that actually failed, not a fresh one that might
not. The stepper reads the tape directly (no replay needed): step
forward/back over its L records, reconstruct bindings from its A records,
and watch each binding's observer-trajectory label at any point.

Under `--json`, each result carries a `"tape"` field for CI to archive as an
artifact; the human form prints the replay line.
Expand Down
2 changes: 1 addition & 1 deletion src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ static double observer_slot_v_get(const ObserverSlot *s, size_t offset_back) {
* step is RELATIVE (Δv/(1+|v|)) so the thresholds carry the same meaning across
* value scales: a ±0.6 swing around 5 reads "moving" (~12% steps), the same
* swing around 1e6 is effectively settled. First value seeds last_value only. */
static void observer_slot_record_value(ObserverSlot *s, double v) {
void observer_slot_record_value(ObserverSlot *s, double v) {
if (s->v_used) {
double rel = (v - s->last_value) / (1.0 + fabs(v));
observer_slot_v_push(s, rel);
Expand Down
10 changes: 10 additions & 0 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ const char *observer_slot_report(const struct ObserverSlot *s);
/* #294 value-signal report: classify the binding's VALUE trajectory (not its
* entropy) — "oscillating"/"converged"/"stable"/"moving"/"equilibrium". */
const char *observer_slot_report_value(const struct ObserverSlot *s);
/* Fold one numeric value into the slot's #294 value channel (relative step
* Δv/(1+|v|)). Exported for the --step tape-stepper (step.c), which rebuilds
* per-binding trajectories from tape A records through the SAME classifier
* the language uses — a reimplementation there could silently drift. */
void observer_slot_record_value(struct ObserverSlot *s, double v);

/* Returns the dH at offset back from most recent (0 = most recent).
* Caller must ensure offset < observer_window_size(v). */
Expand Down Expand Up @@ -1096,6 +1101,11 @@ void handle_release(int id);
/* ---- EigenStore embedded database ---- */
void register_store_builtins(Env *env);

/* ---- Tape-stepper (#418; step.c, CLI-only) ----
* Interactive debugger over a recorded trace tape: `--step <tape> [src]`.
* Returns the process exit code (3 = version refusal, the replay rule). */
int eigenscript_step(const char *tape_path, const char *src_path);

/* ---- Formatter & Linter ---- */
int eigenscript_fmt(const char *path, int write_mode);
char* format_source_string(const char *source); /* malloc'd; caller frees */
Expand Down
20 changes: 20 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ int main(int argc, char **argv) {
" eigenscript --test [paths...] run test_*.eigs files (--json for machine-readable results)\n"
" ... --trace-on-fail record each test; a failure prints its EIGS_REPLAY reproducer\n"
" eigenscript --trace <tape> <file> run and record a replay tape (CLI twin of EIGS_TRACE)\n"
" eigenscript --step <tape> [file] interactive tape-stepper: step forward/back, bindings +\n"
" trajectories, breakpoints (docs/DEBUGGING.md)\n"
" eigenscript --pkg <cmd> [args...] package manager (add/install/update/verify; see docs)\n"
" eigenscript --version, -v print the version and exit\n"
" eigenscript --help, -h print this help and exit\n"
Expand All @@ -93,6 +95,24 @@ int main(int argc, char **argv) {
return 0;
}

/* --step: the #418 tape-stepper — a pure tape READER (nothing executes).
* Handled before trace_init for the same reason as --version: a stale
* EIGS_REPLAY in the environment is a fatal header check (#411) and must
* not take down a viewer that never replays. It does need an attached
* EigsState: the trajectory classifier reads the observer thresholds. */
if (argc >= 2 && strcmp(argv[1], "--step") == 0) {
if (argc < 3) {
fprintf(stderr, "Usage: eigenscript --step <tape> [source.eigs]\n");
return 1;
}
EigsState *step_st = eigs_state_new();
eigs_thread_attach(step_st);
int rc = eigenscript_step(argv[2], argc >= 4 ? argv[3] : NULL);
Comment thread
InauguralPhysicist marked this conversation as resolved.
Dismissed
Comment thread
InauguralPhysicist marked this conversation as resolved.
Dismissed
eigs_thread_detach();
eigs_state_destroy(step_st);
return rc;
}

trace_init();
atexit(trace_shutdown);

Expand Down
Loading
Loading