ModuLisp/test/signal_engine/test_resource_reclaim.cpp

599 lines
24 KiB
C++
Raw Normal View History

// Resource-reclamation regression tests (release audit F3/F4/F5/F8).
//
// Live coding means recompiling the same programs over and over. These tests
// lock in the invariant that recompiles RECLAIM (or reuse) every fixed-pool
// resource they consume — state slots, data tables, graph nodes, and the
// source arena — so that ordinary performance workflows can never exhaust a
// pool and silently kill or revert an output:
//
// F3: anonymous stateful UGens must reuse state slots across recompiles
// (structural identity via the StateResourceRegistry), and vector
// literals must reuse data tables (content-interning in CellStore).
// F4: on_cell_changed must gc unreachable nodes like every sibling
// recompile path, or live cell edits exhaust the node pool.
// F5: source-arena exhaustion must fail the eval with an explicit
// diagnostic instead of installing a graph whose stored source text is
// stale (which silently reverts the output on the next recompile).
// F8: on_cell_changed must refresh the output's dependency list, or an
// output stops reacting to cells introduced by a redefinition.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include <cstdio>
#include <cstring>
#include <string>
using namespace sig;
namespace {
struct ReclaimHarness {
SignalEngine engine;
ReclaimHarness() { engine.init_defaults(120.0, 4); }
EvalResult eval(const std::string& code) {
return eval_cold(code.c_str(), (uint32_t)code.size(), engine);
}
void eval_ok(const std::string& code) {
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
// Execute one sample at t=0 and return the named output's value.
double sample(const char* output_name) {
double cell_values[MAX_CELLS];
engine.cells.snapshot_values(cell_values, MAX_CELLS);
double hw_inputs[32] = {};
double outputs[MAX_OUTPUTS] = {};
double workspace[MAX_TOTAL_NODES] = {};
ExecutionContext ctx;
ctx.t = 0.0;
ctx.dt = 0.0;
ctx.cell_values = cell_values;
ctx.hw_inputs = hw_inputs;
ctx.data_pool = engine.cells.data_pool;
ctx.data_offsets = engine.cells.data_offsets;
ctx.data_lengths = engine.cells.data_lengths;
ctx.prev_outputs = engine.pool.prev_output_values;
ctx.output_values = outputs;
ctx.workspace = workspace;
execute_all_outputs(engine.pool, ctx);
SymbolID sym = internSymbol(output_name);
uint16_t idx = GraphBuilder::resolve_output_index(sym);
REQUIRE(idx != NODE_NONE);
return outputs[idx];
}
};
} // namespace
// ============================================================================
// F3: anonymous stateful UGens reuse state slots across recompiles
// ============================================================================
TEST_CASE("Reclaim: 100 re-evals of an anonymous lfo keep state slots bounded",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (lfo 2))");
uint16_t slots_after_first = h.engine.pool.state_slot_count;
REQUIRE(slots_after_first >= 1);
for (int i = 0; i < 100; i++) {
EvalResult r = h.eval("(a1 (lfo 2))");
INFO("iteration " << i);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
// Recompiling the identical program must resolve to the SAME slots via
// the registry's structural key — not allocate a fresh slot per eval.
REQUIRE(h.engine.pool.state_slot_count == slots_after_first);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: anonymous state value survives re-eval of identical source",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count >= 1);
// Simulate accumulated phase, then recompile the same program.
h.engine.pool.state_values[0] = 0.625;
h.eval_ok("(a1 (phasor 1))");
// Structural identity: the recompiled graph reads the same slot and the
// accumulated value is preserved (init values are hints, not resets).
REQUIRE(h.engine.pool.state_values[0] == Approx(0.625));
}
TEST_CASE("Reclaim: distinct anonymous UGens across outputs get distinct slots",
"[reclaim][state_slots]") {
ReclaimHarness h;
h.eval_ok("(a1 (phasor 1))");
uint16_t after_a1 = h.engine.pool.state_slot_count;
h.eval_ok("(a2 (phasor 1))");
uint16_t after_a2 = h.engine.pool.state_slot_count;
// Different output => different structural context => independent state.
REQUIRE(after_a2 > after_a1);
// But re-evaluating either output stays bounded.
h.eval_ok("(a1 (phasor 1))");
h.eval_ok("(a2 (phasor 1))");
REQUIRE(h.engine.pool.state_slot_count == after_a2);
}
// ============================================================================
// F3 + F4: cell sweeps through on_cell_changed keep tables and nodes bounded
// ============================================================================
TEST_CASE("Reclaim: 300-step cell sweep keeps data tables and nodes bounded",
"[reclaim][tables][nodes]") {
ReclaimHarness h;
h.eval_ok("(define off 0)");
h.eval_ok("(a1 (+ off (step [1 2 3] beat)))");
uint8_t tables_after_first = h.engine.cells.data_table_count;
uint16_t nodes_after_first = h.engine.pool.node_count;
REQUIRE(tables_after_first >= 1);
for (int i = 1; i <= 300; i++) {
char buf[64];
snprintf(buf, sizeof(buf), "(define off %d)", i);
h.eval_ok(buf);
// Every dependent recompile must reuse the [1 2 3] table
// (content-interning) instead of appending a duplicate.
REQUIRE(h.engine.cells.data_table_count == tables_after_first);
// And on_cell_changed must gc the orphaned old graph. The bound is
// loose (the changed constant makes node counts wobble by a node or
// two) but must not grow linearly with edits — pre-fix this reached
// 434 nodes after 300 edits and 360 (the firmware cap) after ~237.
REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8);
}
// The output still compiles and tracks the swept cell.
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(301.0)); // off=300 + step value 1 at t=0
}
TEST_CASE("Reclaim: anonymous UGen output survives repeated dependency edits",
"[reclaim][state_slots][nodes]") {
ReclaimHarness h;
// A stateful output that depends on a cell: every (define rate N)
// triggers an on_cell_changed recompile of a graph with an anonymous lfo.
h.eval_ok("(define rate 1)");
h.eval_ok("(a1 (lfo rate))");
uint16_t slots_after_first = h.engine.pool.state_slot_count;
uint16_t nodes_after_first = h.engine.pool.node_count;
for (int i = 2; i <= 100; i++) {
char buf[64];
snprintf(buf, sizeof(buf), "(define rate %d)", i);
h.eval_ok(buf);
}
REQUIRE(h.engine.pool.state_slot_count == slots_after_first);
REQUIRE(h.engine.pool.node_count <= nodes_after_first + 8);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: defstate replacement releases named and nested state resources",
"[reclaim][defstate]") {
ReclaimHarness h;
for (int i = 0; i < 48; i++) {
h.eval_ok("(defstate lifecycle-x 0 (+ lifecycle-x (integrate 1)))");
REQUIRE(h.engine.pool.state_slot_count >= 2);
if (i == 0) h.eval_ok("(a1 lifecycle-x)");
char replacement[80];
if ((i & 1) == 0) {
snprintf(replacement, sizeof(replacement),
"(define lifecycle-x %d)", i + 10);
} else {
snprintf(replacement, sizeof(replacement),
"(defs [lifecycle-x %d])", i + 10);
}
h.eval_ok(replacement);
INFO("iteration " << i);
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.engine.registry.entry_count == 0);
REQUIRE(h.sample("a1") == Approx((double)i + 10.0));
}
}
TEST_CASE("Reclaim: state backing retained LKG graph survives failed reactive recompile",
"[reclaim][defstate][lkg]") {
ReclaimHarness h;
h.eval_ok("(defstate retained-x 2 (+ retained-x 1))");
h.eval_ok("(a1 retained-x)");
REQUIRE(h.engine.pool.state_slot_count == 1);
uint16_t old_root = h.engine.pool.outputs[0].root_node;
h.eval_ok("(define retained-x (no-such-function 1))");
REQUIRE(h.engine.pool.outputs[0].root_node == old_root);
REQUIRE(h.engine.pool.state_slot_count == 1);
REQUIRE(h.engine.pool.state_update_roots[0] != NODE_NONE);
REQUIRE(h.sample("a1") == Approx(2.0));
h.eval_ok("(a1 99)");
REQUIRE(h.engine.pool.state_slot_count == 0);
REQUIRE(h.sample("a1") == Approx(99.0));
}
TEST_CASE("Reclaim: state compaction remaps nested registry and live-edit owners",
"[reclaim][defstate][live-edit]") {
ReclaimHarness h;
h.eval_ok("(defstate compact-a 0 (+ compact-a 1))");
h.eval_ok("(a1 compact-a)");
h.eval_ok(
"(defstate compact-b 10 (+ compact-b "
"(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))");
h.eval_ok("(a2 compact-b)");
REQUIRE(h.engine.pool.state_slot_count == 3);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
h.eval_ok("(define compact-a 7)");
REQUIRE(h.engine.pool.state_slot_count == 2);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.engine.cells.cells[internSymbol("compact-b")].data_table_id == 0);
REQUIRE(h.engine.pool.state_owner_context[0] == MAX_OUTPUTS);
REQUIRE(h.engine.pool.state_owner_context[1] == MAX_OUTPUTS);
REQUIRE(h.engine.registry.entries[0].owner_context == MAX_OUTPUTS);
REQUIRE(h.engine.pool.live_slots[0].owner_context == MAX_OUTPUTS);
h.eval_ok(
"(defstate compact-b 10 (+ compact-b "
"(integrate (live-edit 1 :id \"compact-rate\" :min 0 :max 2))))");
REQUIRE(h.engine.pool.state_slot_count == 2);
REQUIRE(h.engine.registry.entry_count == 1);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.sample("a1") == Approx(7.0));
REQUIRE(h.sample("a2") == Approx(10.0));
}
TEST_CASE("Reclaim: nonnumeric vector define is rejected atomically",
"[reclaim][vectors]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-v [7 8])");
SymbolID sym = internSymbol("lifecycle-v");
Cell before = h.engine.cells.cells[sym];
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval("(define lifecycle-v [1 nope 2])");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(rejected.diagnostic_count >= 1);
REQUIRE(std::string(rejected.diagnostics[0].message).find("numeric") !=
std::string::npos);
REQUIRE(h.engine.cells.data_table_count == tables_before);
REQUIRE(h.engine.cells.cells[sym].kind == before.kind);
REQUIRE(h.engine.cells.cells[sym].data_table_id == before.data_table_id);
REQUIRE(h.engine.cells.cells[sym].value == Approx(before.value));
uint16_t length = 0;
const double* values = h.engine.cells.get_data_table(before.data_table_id, length);
REQUIRE(values != nullptr);
REQUIRE(length == 2);
REQUIRE(values[0] == Approx(7.0));
REQUIRE(values[1] == Approx(8.0));
}
TEST_CASE("Reclaim: nonnumeric vector in defs preserves previous binding",
"[reclaim][vectors][defs]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-v2 42)");
SymbolID sym = internSymbol("lifecycle-v2");
Cell before = h.engine.cells.cells[sym];
uint8_t tables_before = h.engine.cells.data_table_count;
EvalResult rejected = h.eval("(defs [lifecycle-v2 [1 nope 2]])");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.engine.cells.data_table_count == tables_before);
REQUIRE(h.engine.cells.cells[sym].kind == before.kind);
REQUIRE(h.engine.cells.cells[sym].value == Approx(42.0));
}
TEST_CASE("Reclaim: hundreds of identical output re-evals reuse source arena",
"[reclaim][arena]") {
ReclaimHarness h;
// Long enough that the old append-only behavior exhausts the desktop
// arena during this loop (and exhausts the firmware-sized arena much
// earlier), while still being a small, ordinary output expression.
const std::string code = "(a1 (+ (sin beat) 123456789))";
h.eval_ok(code);
const uint32_t head_after_first = h.engine.arena.write_head;
REQUIRE(head_after_first > 0);
for (int i = 0; i < 600; i++) {
EvalResult r = h.eval(code);
INFO("iteration " << i);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
INFO("diagnostic: "
<< (r.diagnostics[0].message ? r.diagnostics[0].message : ""));
}
REQUIRE(r.kind != EvalResult::Error);
}
REQUIRE(h.engine.arena.write_head == head_after_first);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: legal longer replacements keep all source owners bounded",
"[reclaim][arena][replacement]") {
ReclaimHarness h;
const std::string callable_short = "(define lifecycle-x (+ 1 2))";
const std::string callable_long =
"(define lifecycle-x (+ 1 2 333333 444444))";
const std::string function_short =
"(defn lifecycle-f [x] (+ x 1))";
const std::string function_long =
"(defn lifecycle-f [x] (+ x 1 222222 333333))";
const std::string state_short =
"(defstate lifecycle-s 0 (+ lifecycle-s 1))";
const std::string state_long =
"(defstate lifecycle-s 0 (+ lifecycle-s 1 222222 333333))";
const std::string output_short =
"(a1 (+ lifecycle-x (lifecycle-f 1)))";
const std::string output_long =
"(a1 (+ lifecycle-x (lifecycle-f 1) 222222 333333))";
auto install = [&](bool longer) {
h.eval_ok(longer ? callable_long : callable_short);
h.eval_ok(longer ? function_long : function_short);
h.eval_ok(longer ? state_long : state_short);
h.eval_ok(longer ? output_long : output_short);
};
install(false);
const uint32_t short_live_bytes = h.engine.arena.write_head;
install(true);
const uint32_t long_live_bytes = h.engine.arena.write_head;
REQUIRE(long_live_bytes > short_live_bytes);
REQUIRE(long_live_bytes < SOURCE_ARENA_SIZE);
// Cumulative replacement text exceeds the desktop arena many times and
// the 4 KiB firmware arena much earlier. N, N+1, and continued reuse all
// remain bounded by the four currently-published sources.
const uint32_t replacements =
static_cast<uint32_t>(SOURCE_ARENA_SIZE / 8 + 1);
for (uint32_t i = 0; i < replacements; i++) {
INFO("replacement " << i);
bool longer = (i & 1u) != 0;
install(longer);
REQUIRE(h.engine.arena.write_head ==
(longer ? long_live_bytes : short_live_bytes));
}
install(false); // N+1 after historical bytes exceed the fixed store.
REQUIRE(h.engine.arena.write_head == short_live_bytes);
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: callable retirement cannot alias a compacted live source",
"[reclaim][arena][ownership]") {
ReclaimHarness h;
h.eval_ok("(define retired (+ 10 20 30))");
h.eval_ok("(a1 (+ 1000 2))");
const uint32_t with_callable = h.engine.arena.write_head;
// Retiring the callable removes its metadata before compaction. A later
// definition must append/recompact, not reuse the stale old offset and
// overwrite a1's now-relocated source.
h.eval_ok("(define retired 7)");
REQUIRE(h.engine.arena.write_head < with_callable);
h.eval_ok("(define retired (+ 1 2 3 4 5))");
REQUIRE(h.sample("a1") == Approx(1002.0));
h.eval_ok("(define unrelated 9)");
REQUIRE(h.sample("a1") == Approx(1002.0));
}
TEST_CASE("Reclaim: mixed owners remain readable after relocation and recompile",
"[reclaim][arena][ownership][reactive][synth]") {
ReclaimHarness h;
h.eval_ok("(define retired-prefix (+ 10 20 30))");
h.eval_ok("(define reactive-source 1)");
h.eval_ok("(defstate mixed-state 0 (+ mixed-state 1))");
h.eval_ok("(a1 (+ reactive-source mixed-state))");
h.eval_ok(
"(synth \"osc/sine\" :name \"mixed-synth\" "
":freq (+ reactive-source beat) :amp (+ 0.1 (* 0.01 bar)))");
const uint32_t before_retirement = h.engine.arena.write_head;
h.eval_ok("(define retired-prefix 7)");
REQUIRE(h.engine.arena.write_head < before_retirement);
auto require_live_region = [&](uint32_t offset, uint32_t length) {
REQUIRE(length > 0);
REQUIRE(offset < h.engine.arena.write_head);
REQUIRE(length <= h.engine.arena.write_head - offset);
REQUIRE(h.engine.arena.read(offset) != nullptr);
};
SymbolID mixed_state = internSymbol("mixed-state");
REQUIRE(h.engine.cells.cells[mixed_state].flags == 0x02);
uint16_t state_slot = h.engine.cells.cells[mixed_state].data_table_id;
REQUIRE(state_slot < h.engine.pool.state_slot_count);
const StateUpdateSource& state_source = h.engine.state_sources[state_slot];
REQUIRE(state_source.has_source);
require_live_region(state_source.arena_offset, state_source.arena_length);
REQUIRE(h.engine.output_sources[0].has_source);
require_live_region(h.engine.output_sources[0].arena_offset,
h.engine.output_sources[0].arena_length);
REQUIRE(h.engine.synth_graph.control_count() == 2);
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
const SynthControlChannel& control = h.engine.synth_graph.controls[i];
require_live_region(control.source_offset, control.source_length);
REQUIRE(control.root_node != NODE_NONE);
REQUIRE(control.root_node < h.engine.pool.node_count);
}
// Relocated output and synth-control source must still drive reactive
// recompilation when their shared dependency changes.
h.eval_ok("(define reactive-source 2)");
REQUIRE(h.engine.pool.outputs[0].valid);
REQUIRE(h.sample("a1") == Approx(2.0));
REQUIRE(h.engine.synth_graph.control_count() == 2);
for (uint16_t i = 0; i < h.engine.synth_graph.control_count(); i++) {
const SynthControlChannel& control = h.engine.synth_graph.controls[i];
require_live_region(control.source_offset, control.source_length);
REQUIRE(control.root_node != NODE_NONE);
REQUIRE(control.root_node < h.engine.pool.node_count);
}
}
TEST_CASE("Reclaim: do and scope compact between child transactions",
"[reclaim][arena][do][scope][capacity]") {
ReclaimHarness h;
h.eval_ok("(define retired-in-do (+ 1 2 3 4))");
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
h.eval_ok(
"(do (define retired-in-do 7) "
"(define published-in-do (+ 100 20 3)))");
REQUIRE(h.engine.cells.cells[internSymbol("retired-in-do")].kind ==
CellKind::Number);
REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind ==
CellKind::Callable);
REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4);
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
h.eval_ok(
"(scope (define published-in-do 8) "
"(define published-in-scope (+ 200 30 4)))");
REQUIRE(h.engine.cells.cells[internSymbol("published-in-do")].kind ==
CellKind::Number);
REQUIRE(h.engine.cells.cells[internSymbol("published-in-scope")].kind ==
CellKind::Callable);
REQUIRE(h.engine.arena.write_head < SOURCE_ARENA_SIZE / 4);
// Both newly stored callables remain usable after a further compaction.
h.eval_ok("(a1 (+ published-in-scope 1))");
REQUIRE(h.sample("a1") == Approx(235.0));
}
// ============================================================================
// F5: source-arena exhaustion fails loudly instead of reverting outputs
// ============================================================================
TEST_CASE("Reclaim: arena exhaustion fails the eval and never reverts the output",
"[reclaim][arena]") {
ReclaimHarness h;
h.eval_ok("(define off 1)");
h.eval_ok("(a1 (+ off 111))");
REQUIRE(h.sample("a1") == Approx(112.0));
// Exhaust the arena, then try to install a new program.
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 4;
EvalResult r = h.eval("(a1 (+ off 9999))");
// Must be an explicit error, not a silent success...
REQUIRE(r.kind == EvalResult::Error);
REQUIRE(r.diagnostic_count >= 1);
REQUIRE(r.diagnostics[0].message != nullptr);
REQUIRE(std::string(r.diagnostics[0].message).find("storage") !=
std::string::npos);
// ...and the OLD program must still be the active one.
REQUIRE(h.sample("a1") == Approx(112.0));
// The killer pre-fix symptom: a later dependency change recompiled the
// output from stale source text, silently REVERTING it to the rejected
// program's predecessor with mismatched semantics. Now the old program
// is still the honestly-active one and follows its dependencies.
h.eval_ok("(define off 2)");
REQUIRE(h.sample("a1") == Approx(113.0)); // old program, new off
REQUIRE(h.engine.pool.outputs[0].valid);
}
TEST_CASE("Reclaim: rejected shorter output candidate preserves recompilation source",
"[reclaim][arena][rollback]") {
ReclaimHarness h;
h.eval_ok("(define lifecycle-off 1)");
h.eval_ok("(a1 (+ lifecycle-off 100))");
REQUIRE(h.sample("a1") == Approx(101.0));
EvalResult rejected = h.eval("(a1 nope)");
REQUIRE(rejected.kind == EvalResult::Error);
REQUIRE(h.sample("a1") == Approx(101.0));
h.eval_ok("(define lifecycle-off 2)");
REQUIRE(h.sample("a1") == Approx(102.0));
}
TEST_CASE("Reclaim: arena exhaustion fails define/defn/defstate loudly",
"[reclaim][arena]") {
ReclaimHarness h;
h.eval_ok("(define x (+ 1 2))");
h.engine.arena.write_head = SOURCE_ARENA_SIZE - 2;
EvalResult r1 = h.eval("(define y (+ 3 4))");
REQUIRE(r1.kind == EvalResult::Error);
EvalResult r2 = h.eval("(defn f [a] (+ a 1))");
REQUIRE(r2.kind == EvalResult::Error);
EvalResult r3 = h.eval("(defstate c 0 (+ c 1))");
REQUIRE(r3.kind == EvalResult::Error);
// x's stored source is untouched and still usable.
EvalResult get = h.eval("(get-expr x)");
REQUIRE(get.kind == EvalResult::Text);
}
// ============================================================================
// F8: on_cell_changed refreshes the dependency list
// ============================================================================
TEST_CASE("Reclaim: output tracks cells introduced by a redefinition",
"[reclaim][deps]") {
ReclaimHarness h;
h.eval_ok("(define x 1)");
h.eval_ok("(define y 10)");
h.eval_ok("(a1 (* x 2))");
REQUIRE(h.sample("a1") == Approx(2.0));
// Redefine x from a number to an expression referencing y. The
// on_cell_changed recompile of a1 must pick up the NEW dep set {y,...},
// not keep the stale {x}-era list.
h.eval_ok("(define x (+ y 1))");
REQUIRE(h.sample("a1") == Approx(22.0));
// Pre-fix, this change was invisible to a1 forever.
h.eval_ok("(define y 20)");
REQUIRE(h.sample("a1") == Approx(42.0));
// And it keeps tracking on further edits.
h.eval_ok("(define y 30)");
REQUIRE(h.sample("a1") == Approx(62.0));
}