diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e8f4e..b0fcd95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added +- **`--step` — the eigsdap v1 CLI tape-stepper (#418).** + `eigenscript --step [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 ` 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 diff --git a/Makefile b/Makefile index da18cb3..acf0ca3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f63e546..0a3f45c 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/build.sh b/build.sh index b762997..45945d0 100755 --- a/build.sh +++ b/build.sh @@ -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: @@ -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 \ diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md new file mode 100644 index 0000000..bfe715c --- /dev/null +++ b/docs/DEBUGGING.md @@ -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 ` | set a breakpoint (a line filter); `br` lists, `del ` removes | +| `c` / `rc` | continue forward / **backward** to the next breakpoint | +| `j ` / `jb ` | jump to the next / previous stop at a line | +| `p [name]` | bindings at this point: value + trajectory label | +| `t ` | 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 ` 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 + (``, ``), functions as ``. +- 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). diff --git a/docs/README.md b/docs/README.md index c65fe39..fae4905 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/TRACE.md b/docs/TRACE.md index aebe039..7dce367 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -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. diff --git a/src/eigenscript.c b/src/eigenscript.c index 1fc7b37..4aa3b6d 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -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); diff --git a/src/eigenscript.h b/src/eigenscript.h index 9d1be7a..9929bf5 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -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). */ @@ -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 [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 */ diff --git a/src/main.c b/src/main.c index c2c7bea..3b23d4d 100644 --- a/src/main.c +++ b/src/main.c @@ -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 run and record a replay tape (CLI twin of EIGS_TRACE)\n" + " eigenscript --step [file] interactive tape-stepper: step forward/back, bindings +\n" + " trajectories, breakpoints (docs/DEBUGGING.md)\n" " eigenscript --pkg [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" @@ -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 [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); + eigs_thread_detach(); + eigs_state_destroy(step_st); + return rc; + } + trace_init(); atexit(trace_shutdown); diff --git a/src/step.c b/src/step.c new file mode 100644 index 0000000..0841b8c --- /dev/null +++ b/src/step.c @@ -0,0 +1,499 @@ +/* ================================================================ + * EigenScript tape-stepper — `eigenscript --step [source]` + * ================================================================ + * The #418 eigsdap v1 surface: an interactive CLI debugger over a + * recorded trace tape (EIGS_TRACE / --trace / --test --trace-on-fail). + * Pure reader — the tape is never executed, so stepping BACKWARD is + * exactly as cheap as forward: position is an index into the tape's + * L records, and bindings at any position are a fold of the A records + * before it. + * + * Trajectory labels come from the runtime's own #294 value-channel + * classifier (observer_slot_record_value + observer_slot_report_value): + * the stepper feeds each binding's reconstructed numeric history through + * a real ObserverSlot, so a label here is BY CONSTRUCTION what + * `report_value of x` would have said at that moment — a mirror + * implementation could drift; this cannot. + * + * Version policy (#411): same rule as replay — the tape names its + * format and runtime on line 1 and both must match this binary + * exactly, else refuse with exit 3. Version-and-reject, never migrate. + * A tape viewer that guessed at a foreign encoding would show + * plausible-but-wrong state, the same silent divergence the header + * exists to prevent. + * + * CLI-only (stdio + isatty): listed in the Makefile's CLI_ONLY set, + * excluded from the embed/LSP/freestanding builds like main.c/repl.c. + */ + +#include "eigenscript.h" +#include "trace.h" + +#include +#include +#include +#include + +#ifndef EIGENSCRIPT_VERSION +#define EIGENSCRIPT_VERSION "dev" +#endif + +/* ---- tape model ------------------------------------------------ */ + +typedef struct { + char kind; /* 'L', 'A', 'N', 'V' */ + int line; /* L: source line */ + int step; /* index of the L record this event belongs to + * (events before the first L clamp to 0) */ + const char *name; /* A/N: binding / builtin name (into tape buf) */ + const char *value; /* A/N: serialized value (into tape buf) */ +} StepRec; + +typedef struct { + int rec; /* index into recs[] */ + int step; /* visible from this position on */ + const char *value; + double num; + int is_num; +} Assign; + +typedef struct { + const char *name; + Assign *a; + int n, cap; +} NameHist; + +typedef struct { + char *tape; /* whole tape file, lines NUL-split in place */ + StepRec *recs; + int nrecs; + int *steps; /* rec index of each L record */ + int nsteps; + NameHist *names; + int nnames, namecap; + char **src; /* optional source lines (1-based view) */ + int nsrc; + char *srcbuf; +} Tape; + +/* ---- helpers ---------------------------------------------------- */ + +static char *read_whole_file(const char *path, long *out_len) { + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + if (len < 0) { fclose(f); return NULL; } + char *buf = malloc((size_t)len + 1); + if (!buf) { fclose(f); return NULL; } + if (len > 0 && fread(buf, 1, (size_t)len, f) != (size_t)len) { + free(buf); fclose(f); return NULL; + } + fclose(f); + buf[len] = '\0'; + if (out_len) *out_len = len; + return buf; +} + +static NameHist *hist_for(Tape *t, const char *name, int create) { + for (int i = 0; i < t->nnames; i++) + if (strcmp(t->names[i].name, name) == 0) return &t->names[i]; + if (!create) return NULL; + if (t->nnames == t->namecap) { + int nc = t->namecap ? t->namecap * 2 : 16; + NameHist *nn = realloc(t->names, (size_t)nc * sizeof(NameHist)); + if (!nn) return NULL; + t->names = nn; + t->namecap = nc; + } + NameHist *h = &t->names[t->nnames++]; + h->name = name; + h->a = NULL; + h->n = h->cap = 0; + return h; +} + +static int hist_push(NameHist *h, Assign a) { + if (h->n == h->cap) { + int nc = h->cap ? h->cap * 2 : 8; + Assign *na = realloc(h->a, (size_t)nc * sizeof(Assign)); + if (!na) return 0; + h->a = na; + h->cap = nc; + } + h->a[h->n++] = a; + return 1; +} + +/* #411 header check — the replay rule, with "step" wording. */ +static int vline_ok(const char *p) { + if (p[0] != 'V' || p[1] != ' ') { + if (p[0] == 'V') + fprintf(stderr, "step: malformed tape version header '%s'; " + "refusing to step (docs/TRACE.md)\n", p); + else + fprintf(stderr, "step: tape has no version header — recorded by " + "a pre-versioning EigenScript or not a tape; refusing " + "to step (docs/TRACE.md)\n"); + return 0; + } + char *end = NULL; + long fmt = strtol(p + 2, &end, 10); + if (end == p + 2 || *end != ' ') { + fprintf(stderr, "step: malformed tape version header '%s'; " + "refusing to step (docs/TRACE.md)\n", p); + return 0; + } + if (fmt != TRACE_FORMAT_VERSION) { + fprintf(stderr, "step: tape format v%ld, this binary reads v%d — " + "refusing to step; re-record on this version " + "(docs/TRACE.md)\n", fmt, TRACE_FORMAT_VERSION); + return 0; + } + if (strcmp(end + 1, EIGENSCRIPT_VERSION) != 0) { + fprintf(stderr, "step: tape recorded on EigenScript %s, this binary " + "is %s — refusing to step; a tape is valid only for the " + "version that recorded it (docs/TRACE.md)\n", + end + 1, EIGENSCRIPT_VERSION); + return 0; + } + return 1; +} + +/* Parse the NUL-split tape buffer into recs/steps/name histories. + * Returns 0 on version refusal (caller exits 3), 1 otherwise. */ +static int tape_parse(Tape *t, long len) { + /* count lines for one exact allocation */ + int nlines = 0; + for (long i = 0; i < len; i++) + if (t->tape[i] == '\n') nlines++; + if (len > 0 && t->tape[len - 1] != '\n') nlines++; + t->recs = calloc(nlines ? (size_t)nlines : 1, sizeof(StepRec)); + t->steps = calloc(nlines ? (size_t)nlines : 1, sizeof(int)); + if (!t->recs || !t->steps) return 0; + + int first = 1; + char *p = t->tape, *end = t->tape + len; + while (p < end) { + char *nl = memchr(p, '\n', (size_t)(end - p)); + if (nl) *nl = '\0'; + if (first) { + if (!vline_ok(p)) return 0; + first = 0; + } + StepRec r = {0}; + r.kind = p[0]; + r.step = t->nsteps > 0 ? t->nsteps - 1 : 0; + switch (p[0]) { + case 'V': + if (!vline_ok(p)) return 0; /* mid-stream session header */ + break; + case 'L': + r.line = atoi(p + 2); + t->steps[t->nsteps] = t->nrecs; + r.step = t->nsteps; + t->nsteps++; + break; + case 'A': case 'N': { + char *eq = strchr(p + 2, '='); + if (!eq) { r.kind = 0; break; } /* torn record: skip */ + *eq = '\0'; + r.name = p + 2; + r.value = eq + 1; + if (r.kind == 'A') { + NameHist *h = hist_for(t, r.name, 1); + if (h) { + Assign a; + a.rec = t->nrecs; + a.step = r.step; + a.value = r.value; + char *ne = NULL; + a.num = strtod(r.value, &ne); + a.is_num = (ne != r.value && *ne == '\0'); + hist_push(h, a); + } + } + break; + } + default: + r.kind = 0; /* unknown: skip */ + break; + } + if (r.kind) t->recs[t->nrecs++] = r; + p = nl ? nl + 1 : end; + } + return 1; +} + +static void load_source(Tape *t, const char *path) { + long len = 0; + t->srcbuf = read_whole_file(path, &len); + if (!t->srcbuf) { + fprintf(stderr, "step: cannot read source '%s' (continuing without " + "source display)\n", path); + return; + } + int nlines = 1; + for (long i = 0; i < len; i++) + if (t->srcbuf[i] == '\n') nlines++; + t->src = calloc((size_t)nlines + 1, sizeof(char *)); + if (!t->src) return; + char *p = t->srcbuf, *end = t->srcbuf + len; + while (p < end && t->nsrc < nlines) { + t->src[t->nsrc++] = p; + char *nl = memchr(p, '\n', (size_t)(end - p)); + if (!nl) break; + *nl = '\0'; + p = nl + 1; + } +} + +/* ---- trajectory classification ---------------------------------- */ + +/* Feed name's numeric assigns visible at `pos` through a real + * ObserverSlot and return the runtime's own label — NULL when the + * binding has no numeric trajectory yet. */ +static const char *classify_at(const NameHist *h, int pos, int *out_numeric) { + ObserverSlot s; + memset(&s, 0, sizeof s); + int fed = 0; + for (int i = 0; i < h->n && h->a[i].step <= pos; i++) { + if (!h->a[i].is_num) continue; + observer_slot_record_value(&s, h->a[i].num); + fed++; + } + const char *label = fed ? observer_slot_report_value(&s) : NULL; + free(s.v_window); + free(s.dh_window); + if (out_numeric) *out_numeric = fed; + return label; +} + +/* Latest assign of `h` visible at `pos`, or NULL. */ +static const Assign *latest_at(const NameHist *h, int pos) { + const Assign *last = NULL; + for (int i = 0; i < h->n && h->a[i].step <= pos; i++) last = &h->a[i]; + return last; +} + +/* ---- display ----------------------------------------------------- */ + +static void show_stop(const Tape *t, int pos) { + int rec = t->steps[pos]; + int line = t->recs[rec].line; + printf("step %d/%d line %d\n", pos + 1, t->nsteps, line); + if (t->src && line >= 1 && line <= t->nsrc) + printf(" | %s\n", t->src[line - 1]); + /* events that happened during this step (its A/N records) */ + int bound = (pos + 1 < t->nsteps) ? t->steps[pos + 1] : t->nrecs; + for (int i = rec + 1; i < bound; i++) { + const StepRec *r = &t->recs[i]; + if (r->kind == 'A') printf(" A %s=%s\n", r->name, r->value); + if (r->kind == 'N') printf(" N %s=%s\n", r->name, r->value); + } +} + +static void show_bindings(const Tape *t, int pos, const char *only) { + int shown = 0; + for (int i = 0; i < t->nnames; i++) { + const NameHist *h = &t->names[i]; + if (only && strcmp(h->name, only) != 0) continue; + const Assign *last = latest_at(h, pos); + if (!last) continue; /* not yet assigned here */ + int count = 0; + for (int k = 0; k < h->n && h->a[k].step <= pos; k++) count++; + const char *label = classify_at(h, pos, NULL); + printf("%s = %s", h->name, last->value); + if (label) printf(" [%s]", label); + printf(" (%d assign%s)\n", count, count == 1 ? "" : "s"); + shown++; + } + if (!shown) { + if (only) printf("no binding '%s' at this point\n", only); + else printf("no bindings yet\n"); + } +} + +static void show_trajectory(const Tape *t, int pos, const char *name) { + const NameHist *h = hist_for((Tape *)t, name, 0); + if (!h || !latest_at(h, pos)) { + printf("no binding '%s' at this point\n", name); + return; + } + int total = 0; + for (int i = 0; i < h->n && h->a[i].step <= pos; i++) total++; + printf("%s: %d assign%s\n", name, total, total == 1 ? "" : "s"); + /* One slot fed incrementally: the label after the k-th numeric value + * is exactly what report_value would have said at that moment. */ + ObserverSlot s; + memset(&s, 0, sizeof s); + int fed = 0; + const int SHOW = 20; /* print at most the last SHOW entries */ + int start = total > SHOW ? total - SHOW : 0; + if (start > 0) printf(" … %d earlier assign(s) elided\n", start); + for (int i = 0, k = 0; i < h->n && h->a[i].step <= pos; i++, k++) { + const char *label = NULL; + if (h->a[i].is_num) { + observer_slot_record_value(&s, h->a[i].num); + fed++; + label = observer_slot_report_value(&s); + } + if (k < start) continue; + printf(" #%-3d line %-5d %s", k + 1, + t->recs[t->steps[h->a[i].step]].line, h->a[i].value); + if (label) printf(" [%s]", label); + printf("\n"); + } + free(s.v_window); + free(s.dh_window); +} + +static void show_help(void) { + printf( + "commands:\n" + " s [n], step forward (n lines)\n" + " b [n] step backward\n" + " br set breakpoint; br lists; del removes\n" + " c / rc continue forward / backward to a breakpoint\n" + " j / jb jump to next / previous stop at \n" + " p [name] bindings here (value + trajectory label)\n" + " t a binding's trajectory up to here\n" + " i tape info\n" + " q quit\n"); +} + +/* ---- the stepper ------------------------------------------------- */ + +#define MAX_BP 64 + +int eigenscript_step(const char *tape_path, const char *src_path) { + Tape t; + memset(&t, 0, sizeof t); + + long len = 0; + t.tape = read_whole_file(tape_path, &len); + if (!t.tape) { + fprintf(stderr, "step: cannot read tape '%s'\n", tape_path); + return 1; + } + if (len == 0) { + fprintf(stderr, "step: empty tape — refusing to step " + "(docs/TRACE.md)\n"); + free(t.tape); + return 3; + } + if (!tape_parse(&t, len)) { + /* vline_ok printed the reason; calloc failure has errno unset but + * the distinction doesn't matter to the caller: no session. */ + free(t.tape); free(t.recs); free(t.steps); + for (int i = 0; i < t.nnames; i++) free(t.names[i].a); + free(t.names); + return 3; + } + if (t.nsteps == 0) { + fprintf(stderr, "step: tape has no line events (L records) — " + "nothing to step\n"); + free(t.tape); free(t.recs); free(t.steps); + for (int i = 0; i < t.nnames; i++) free(t.names[i].a); + free(t.names); + return 1; + } + if (src_path) load_source(&t, src_path); + + int nA = 0, nN = 0; + for (int i = 0; i < t.nrecs; i++) { + if (t.recs[i].kind == 'A') nA++; + if (t.recs[i].kind == 'N') nN++; + } + printf("tape %s: %d steps, %d assigns (%d bindings), %d nondet records\n", + tape_path, t.nsteps, nA, t.nnames, nN); + + int pos = 0; + int bp[MAX_BP], nbp = 0; + int tty = isatty(fileno(stdin)); + char buf[512]; + + show_stop(&t, pos); + for (;;) { + if (tty) { printf("(step) "); fflush(stdout); } + if (!fgets(buf, sizeof buf, stdin)) break; /* EOF = quit */ + buf[strcspn(buf, "\n")] = '\0'; + char *cmd = strtok(buf, " \t"); + char *arg = strtok(NULL, " \t"); + + if (!cmd || strcmp(cmd, "s") == 0) { /* step forward */ + int n = arg ? atoi(arg) : 1; + if (n < 1) n = 1; + if (pos + 1 >= t.nsteps) { printf("at end of tape\n"); continue; } + pos = (pos + n < t.nsteps) ? pos + n : t.nsteps - 1; + show_stop(&t, pos); + } else if (strcmp(cmd, "b") == 0) { /* step back */ + int n = arg ? atoi(arg) : 1; + if (n < 1) n = 1; + if (pos == 0) { printf("at start of tape\n"); continue; } + pos = (pos - n > 0) ? pos - n : 0; + show_stop(&t, pos); + } else if (strcmp(cmd, "c") == 0 || strcmp(cmd, "rc") == 0) { + int dir = (cmd[0] == 'c') ? 1 : -1; + int q = pos, hit = 0; + while (q + dir >= 0 && q + dir < t.nsteps) { + q += dir; + int line = t.recs[t.steps[q]].line; + for (int k = 0; k < nbp; k++) + if (bp[k] == line) { hit = 1; break; } + if (hit) break; + } + if (!hit) printf(dir > 0 ? "no breakpoint hit — at end of tape\n" + : "no breakpoint hit — at start of tape\n"); + pos = q; + show_stop(&t, pos); + } else if (strcmp(cmd, "br") == 0) { + if (!arg) { + if (!nbp) printf("no breakpoints\n"); + for (int k = 0; k < nbp; k++) printf("breakpoint at line %d\n", bp[k]); + } else if (nbp < MAX_BP) { + bp[nbp++] = atoi(arg); + printf("breakpoint at line %d\n", bp[nbp - 1]); + } else printf("breakpoint table full (%d)\n", MAX_BP); + } else if (strcmp(cmd, "del") == 0 && arg) { + int line = atoi(arg), found = 0; + for (int k = 0; k < nbp; k++) + if (bp[k] == line) { bp[k] = bp[--nbp]; found = 1; break; } + printf(found ? "deleted breakpoint at line %d\n" + : "no breakpoint at line %d\n", line); + } else if ((strcmp(cmd, "j") == 0 || strcmp(cmd, "jb") == 0) && arg) { + int line = atoi(arg); + int dir = (cmd[1] == '\0') ? 1 : -1; + int q = pos, hit = 0; + while (q + dir >= 0 && q + dir < t.nsteps) { + q += dir; + if (t.recs[t.steps[q]].line == line) { hit = 1; break; } + } + if (!hit) { printf("no %s occurrence of line %d\n", + dir > 0 ? "later" : "earlier", line); continue; } + pos = q; + show_stop(&t, pos); + } else if (strcmp(cmd, "p") == 0) { + show_bindings(&t, pos, arg); + } else if (strcmp(cmd, "t") == 0 && arg) { + show_trajectory(&t, pos, arg); + } else if (strcmp(cmd, "i") == 0) { + printf("tape %s: %d steps, %d assigns (%d bindings), %d nondet " + "records; at step %d/%d\n", + tape_path, t.nsteps, nA, t.nnames, nN, pos + 1, t.nsteps); + } else if (strcmp(cmd, "h") == 0 || strcmp(cmd, "help") == 0) { + show_help(); + } else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0) { + break; + } else { + printf("unknown command '%s' (h for help)\n", cmd); + } + } + + free(t.tape); free(t.recs); free(t.steps); + for (int i = 0; i < t.nnames; i++) free(t.names[i].a); + free(t.names); + free(t.src); free(t.srcbuf); + return 0; +} diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index df50584..5cf455d 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1365,6 +1365,23 @@ else fi echo "" +# [42f] Tape stepper (#418 eigsdap v1: --step forward/back, bindings + +# trajectory labels, breakpoints, jumps, #411 version refusals) +echo "[42f] Tape Stepper (16 checks)" +ST_OUTPUT=$(bash "$TESTS_DIR/test_step.sh" 2>&1) +ST_PASS=$(echo "$ST_OUTPUT" | grep -c "PASS:" || true) +ST_FAIL=$(echo "$ST_OUTPUT" | grep -c "FAIL:" || true) +TOTAL=$((TOTAL + ST_PASS + ST_FAIL)) +PASS=$((PASS + ST_PASS)) +FAIL=$((FAIL + ST_FAIL)) +if [ "$ST_FAIL" -gt 0 ]; then + echo " FAIL: $ST_FAIL stepper check(s) failed" + echo "$ST_OUTPUT" | grep "FAIL:" | head -5 +else + echo " PASS: all $ST_PASS stepper checks" +fi +echo "" + # [42c] REPL (#392): piped transcript byte-exact + pty-driven line editor echo "[42c] REPL editor & piped transcript (16 checks)" RE_OUTPUT=$(bash "$TESTS_DIR/test_repl.sh" 2>&1) diff --git a/tests/test_step.sh b/tests/test_step.sh new file mode 100644 index 0000000..c8529e6 --- /dev/null +++ b/tests/test_step.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# Tape-stepper tests (#418 eigsdap v1: `eigenscript --step [src]`). +# Records a tape, then drives the stepper over it with scripted stdin: +# stepping forward/back, binding reconstruction, trajectory labels (the +# runtime's own #294 value-channel classifier), breakpoints, jumps, the +# #411 version refusals, and the --test --trace-on-fail acceptance path. +# +# Run directly or from run_all_tests.sh. Prints PASS:/FAIL: lines and a +# summary. Exit code: 0 if all pass, 1 if any fail. + +set -u +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC_DIR="$(cd "$TESTS_DIR/.." && pwd)/src" +EIGS="$SRC_DIR/eigenscript" + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d -t eigs_step.XXXXXX) +trap 'rm -rf "$TMPDIR"' EXIT + +ok() { echo " PASS: $1"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $1${2:+ ($2)}"; FAIL=$((FAIL+1)); } + +if [ ! -x "$EIGS" ]; then + echo " FAIL: eigenscript binary not found at $EIGS" + echo "STEP: 0 passed, 1 failed" + exit 1 +fi + +# ---- fixture: an oscillator, a halving (converging) binding, a string, +# and one nondet call. 30 halvings of 1024 push the relative step below +# dh_zero (0.001) with a full 10-wide window to spare -> [converged]; +# the +1/-1 alternation sign-flips every step -> [oscillating]. +FIX="$TMPDIR/fix.eigs" +cat > "$FIX" <<'EOF' +osc is 1 +conv is 1024 +msg is "hello" +seed is random of null +for i in range of 30: + osc is 0 - osc + conv is conv / 2 +print of conv +EOF + +TAPE="$TMPDIR/fix.tape" +"$EIGS" --trace "$TAPE" "$FIX" > /dev/null 2>&1 +if [ -s "$TAPE" ]; then + ok "fixture records a tape" +else + fail "fixture records a tape" + echo "STEP: $PASS passed, $((FAIL)) failed" + exit 1 +fi + +drive() { # drive — one stepper session, commands as lines + printf '%s\n' "$@" | "$EIGS" --step "$TAPE" "$FIX" 2>&1 +} + +# ---- 1. loads and announces the tape, shows the first stop +OUT=$(drive q) +echo "$OUT" | grep -q "steps, .* assigns" && echo "$OUT" | grep -q "^step 1/" \ + && ok "loads tape and shows first stop" \ + || fail "loads tape and shows first stop" "$(echo "$OUT" | head -2)" + +# ---- 2. forward stepping walks the L records and shows source + events +OUT=$(drive s s q) +echo "$OUT" | grep -q "step 3/.*line 2" \ + && echo "$OUT" | grep -q "| conv is 1024" \ + && echo "$OUT" | grep -q "A conv=1024" \ + && ok "forward step shows line, source text, assignment events" \ + || fail "forward step shows line, source text, assignment events" + +# ---- 3. nondet events surface at their step +OUT=$(drive "s 4" q) +echo "$OUT" | grep -q "N random=" \ + && ok "nondet record shown at its step" \ + || fail "nondet record shown at its step" + +# ---- 4. bindings at end of tape: values + trajectory labels from the +# runtime's own classifier +OUT=$(drive "s 200" s p q) +echo "$OUT" | grep -q "at end of tape" \ + && ok "stepping past the end reports end of tape" \ + || fail "stepping past the end reports end of tape" +echo "$OUT" | grep -Eq "^conv = .*\[converged\]" \ + && echo "$OUT" | grep -Eq "^osc = (-)?1 \[oscillating\]" \ + && ok "end-of-tape labels: conv converged, osc oscillating" \ + || fail "end-of-tape labels: conv converged, osc oscillating" +echo "$OUT" | grep -Eq '^msg = "hello" \(1 assign\)' \ + && ok "non-numeric binding shows value, no trajectory label" \ + || fail "non-numeric binding shows value, no trajectory label" + +# ---- 5. THE ACCEPTANCE CHECK (#418): step back and watch the label flip. +# At the end conv is [converged]; 40 steps earlier it is still +# mid-descent -> [moving]. Same binding, same tape, different moment. +OUT=$(drive "s 200" "b 40" p q) +echo "$OUT" | grep -Eq "^conv = .*\[moving\]" \ + && ok "stepping back flips conv's label converged -> moving" \ + || fail "stepping back flips conv's label converged -> moving" + +# ---- 6. breakpoints: br + c stops only on that line; rc goes back +OUT=$(drive "br 7" c c rc q) +HITS=$(echo "$OUT" | grep -c "line 7$") +[ "$HITS" -ge 3 ] \ + && ok "breakpoint continue/reverse-continue stop on line 7" \ + || fail "breakpoint continue/reverse-continue stop on line 7" "hits=$HITS" + +# ---- 7. jump to line, both directions +OUT=$(drive "j 8" "jb 1" q) +echo "$OUT" | grep -q "line 8" && echo "$OUT" | grep -q "line 1" \ + && ok "jump forward and backward to a line" \ + || fail "jump forward and backward to a line" + +# ---- 8. trajectory view: per-assign running labels +OUT=$(drive "s 200" "t conv" q) +echo "$OUT" | grep -q "^conv: 31 assigns" \ + && echo "$OUT" | grep -q "earlier assign(s) elided" \ + && echo "$OUT" | grep -Eq "\[moving\]" \ + && echo "$OUT" | grep -Eq "\[converged\]" \ + && ok "trajectory view shows running classification" \ + || fail "trajectory view shows running classification" + +# ---- 9. #411 version refusals, the replay rule verbatim: exit 3 +sed '1s/.*/V 1 0.0.0-other/' "$TAPE" > "$TMPDIR/rt.tape" +echo q | "$EIGS" --step "$TMPDIR/rt.tape" > /dev/null 2>"$TMPDIR/rt.err" +[ $? -eq 3 ] && grep -q "refusing to step" "$TMPDIR/rt.err" \ + && ok "runtime-version mismatch refused with exit 3" \ + || fail "runtime-version mismatch refused with exit 3" + +sed '1s/.*/V 99 0.29.0/' "$TAPE" > "$TMPDIR/fmt.tape" +echo q | "$EIGS" --step "$TMPDIR/fmt.tape" > /dev/null 2>"$TMPDIR/fmt.err" +[ $? -eq 3 ] && grep -q "tape format v99" "$TMPDIR/fmt.err" \ + && ok "format-version mismatch refused with exit 3" \ + || fail "format-version mismatch refused with exit 3" + +: > "$TMPDIR/empty.tape" +echo q | "$EIGS" --step "$TMPDIR/empty.tape" > /dev/null 2>&1 +[ $? -eq 3 ] \ + && ok "empty tape refused with exit 3" \ + || fail "empty tape refused with exit 3" + +# ---- 10. acceptance: a --test --trace-on-fail tape drives the stepper +FAILDIR="$TMPDIR/suite" +mkdir -p "$FAILDIR" +cat > "$FAILDIR/test_boom.eigs" <<'EOF' +x is 2 +for i in range of 6: + x is x * x +exit of 1 +EOF +TR_OUT=$("$EIGS" --test --trace-on-fail "$FAILDIR" 2>&1) +FAIL_TAPE=$(echo "$TR_OUT" | grep -o 'EIGS_REPLAY=[^ ]*' | head -1 | cut -d= -f2) +if [ -n "$FAIL_TAPE" ] && [ -f "$FAIL_TAPE" ]; then + OUT=$(printf 's 200\np x\nq\n' | "$EIGS" --step "$FAIL_TAPE" 2>&1) + echo "$OUT" | grep -Eq "^x = .*\[diverging|^x = .*\[moving" \ + && ok "--trace-on-fail tape steps and classifies the culprit" \ + || fail "--trace-on-fail tape steps and classifies the culprit" \ + "$(echo "$OUT" | grep '^x' | head -1)" + rm -f "$FAIL_TAPE" +else + fail "--trace-on-fail tape steps and classifies the culprit" "no tape path in runner output" +fi + +# ---- 11. EOF on stdin is a clean quit (exit 0) +printf '' | "$EIGS" --step "$TAPE" > /dev/null 2>&1 +[ $? -eq 0 ] \ + && ok "EOF quits cleanly" \ + || fail "EOF quits cleanly" + +echo "STEP: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/tools/amalgamate.sh b/tools/amalgamate.sh index 1878841..22071ab 100755 --- a/tools/amalgamate.sh +++ b/tools/amalgamate.sh @@ -14,9 +14,13 @@ OUT="${1:-build}" mkdir -p "$OUT" # Read SOURCES from the Makefile, expand $(SRC_DIR)=src, drop the CLI-only -# units (main.c + repl.c — repl.c pulls termios, banned in the embed profile). +# units — read from the Makefile's CLI_ONLY (main.c, repl.c, step.c — they +# pull termios/isatty/stdio, banned in the embed profile) so the drop list +# is the same single source of truth as the source list. +CLI_ONLY=$(grep '^CLI_ONLY :=' Makefile | sed 's/^CLI_ONLY :=//; s/\$(SRC_DIR)/src/g') SOURCES=$(grep '^SOURCES :=' Makefile | sed 's/^SOURCES :=//; s/\$(SRC_DIR)/src/g' \ - | tr ' ' '\n' | grep '\.c$' | grep -v '/main\.c$' | grep -v '/repl\.c$') + | tr ' ' '\n' | grep '\.c$') +for u in $CLI_ONLY; do SOURCES=$(printf '%s\n' "$SOURCES" | grep -v "^$u\$"); done python3 - "$OUT/eigenscript_all.c" $SOURCES <<'PY' import re, sys, os