aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md9
-rw-r--r--docs/research/073-single-file-persistent-tree.md10
-rw-r--r--docs/research/074-runtime-owned-drafts.md97
-rw-r--r--prototypes/connections/Makefile2
-rw-r--r--prototypes/connections/README.md2
-rw-r--r--prototypes/connections/state.h31
-rw-r--r--prototypes/connections/store-tree.c193
-rw-r--r--prototypes/connections/test-tree-store.c218
-rw-r--r--research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json69
-rw-r--r--research/experiments/009-runtime-drafts/bench.c120
-rwxr-xr-xresearch/experiments/009-runtime-drafts/run.py45
11 files changed, 782 insertions, 14 deletions
diff --git a/README.md b/README.md
index 2ae94db..4516cce 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,15 @@ unchanged runtime, splice, async and real-PTY TUI checks pass on it
larger, contiguous reads of multi-leaf documents copy, and space is reclaimed
only by compaction, so it remains an opt-in profile.
+On that store the runtime can own an app's working version
+([drafts](docs/research/074-runtime-owned-drafts.md), `state.h`). A draft
+shares unchanged extents with the stored version, lends ordered extents
+without copying, and commits only its new nodes. Typing into the middle of a
+1 MiB document and saving each character peaked at 1,648–1,664 KiB RSS with
+drafts. An app copy that is compared with storage on save peaked at
+4,464–4,496 KiB. Saves were only about 1–2 ms faster; drafts are not yet
+exposed to `.pure` definitions or charged to the runtime budget.
+
The current execution profile has separate `core`, `fixed-editor`, `editor`, and
`server` build targets. `make all` produces the core and the parser-free CLI
from the build-time prepared `editor.pure`; it also builds a compiler tool that
diff --git a/docs/research/073-single-file-persistent-tree.md b/docs/research/073-single-file-persistent-tree.md
index e2a2ee9..092b6ad 100644
--- a/docs/research/073-single-file-persistent-tree.md
+++ b/docs/research/073-single-file-persistent-tree.md
@@ -116,11 +116,11 @@ TUI의 실제 PTY 검사(1 MiB, UTF-8, 충돌, 1,024개 문서 목록)다. 기�
## 다음 결정
-TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 나머지는 저장본 전체를 연속 뷰로 다시 읽어
-초안과 비교하는 "초안 대 저장본 비교" 관례의 비용이다. 편집기는 무엇을 바꿨는지 이미 알고 있다.
+TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 처음에는 나머지 전부를 저장본을 다시 읽어 초안과
+비교하는 비용으로 추정했으나 틀렸다. [074](074-runtime-owned-drafts.md)에서 저장소 API 수준으로
+재 보니 그 비용은 약 1–2 ms였다. 나머지는 화면 갱신·PTY·런타임 경로이며 아직 분해하지 않았다.
-1. 편집 연산을 그대로 전달하고, 초안을 같은 트리의 커밋되지 않은 루트로 둔다. 그러면
- 전체 비교, 전체 복사, 별도 gap buffer가 사라지는지 확인한다.
-2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, TUI 렌더).
+1. 작업 중인 값을 앱이 따로 복사해 두지 않고 런타임이 소유한다 — [074](074-runtime-owned-drafts.md)에서 구현했다.
+2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, 렌더).
3. Pure 간 동기화에서 상대에게 없는 노드만 보낸다.
4. 코드 크기를 줄인다. 기본 저장소 승격 여부는 1–4의 결과와 macOS·전원 차단 검증 뒤에 판단한다.
diff --git a/docs/research/074-runtime-owned-drafts.md b/docs/research/074-runtime-owned-drafts.md
new file mode 100644
index 0000000..71ac8c9
--- /dev/null
+++ b/docs/research/074-runtime-owned-drafts.md
@@ -0,0 +1,97 @@
+# 074 — 작업 중인 값을 런타임이 소유한다
+
+2026-09-25. [073](073-single-file-persistent-tree.md)의 선택형 트리 저장소 위에 런타임 API
+[`state.h`](../../prototypes/connections/state.h)를 추가했다. 기본 빌드와 기본 저장 형식은 바뀌지 않았다.
+
+Pure는 JVM과 같은 위치의 런타임이다. 앱 기능은 별도 저장소에서 만들고, 이 저장소의 CLI·TUI·
+HTTP 호스트는 런타임을 검사하는 호스트다. 따라서 이번 단계는 편집기 기능이 아니라 모든 앱이 쓰는
+런타임 능력으로 만들었다.
+
+착수 기준:
+
+- 바꿀 결정: 앱이 작업 중인 값을 자기 메모리에 복사해 두고 저장할 때 저장본과 비교하는 관례를
+ 유지할지, 런타임이 작업 버전(draft)을 소유하고 앱은 연산만 보낼지.
+- 남은 질문: 초안이 저장본과 익스텐트를 공유하면 메모리가 실제로 줄어드는가? 긴 편집 세션에서
+ 경로 복사로 생긴 쓰레기가 쌓이지 않는가? 충돌·고장·압축에서도 계약이 유지되는가?
+- 최소 확인: 같은 저장소에서 "앱 복사 + 비교" 방식과 draft 방식을 같은 편집으로 비교하고,
+ 무작위 오라클·충돌·고장·파일 교체 검사를 통과시킨다.
+- 중단 기준: 메모리나 저장 시간이 줄지 않거나, 실패한 커밋이 draft나 저장본을 바꾸면 닫는다.
+
+## 의심한 관례
+
+| 관례 | 바꾼 것 |
+|---|---|
+| 앱이 값의 사본(버퍼·모델)을 가진다 | 런타임이 작업 버전을 소유한다. 바뀌지 않은 부분은 저장본의 불변 익스텐트를 그대로 가리킨다 |
+| 저장할 때 사본과 저장본을 비교해 바뀐 곳을 찾는다 | 편집 연산이 곧 변경이다. 커밋은 draft가 새로 만든 노드만 쓴다 |
+| 읽기 = 연속 버퍼로 복사 | 읽기 = 순서대로 빌려주는 익스텐트 열(무복사) |
+| "저장 안 된 편집"은 앱의 상태 | 커밋되지 않은 루트는 런타임 상태이며 커밋 전까지 파일에 쓰지 않는다 |
+
+## API
+
+- `pure_draft_open(path, key)`: 커밋된 버전에서 시작한다. 키가 없으면 revision 0의 빈 draft다.
+- `pure_draft_splice(offset, remove, insert)`: 메모리에서만 바뀐 leaf와 경로를 복사한다.
+- `pure_draft_read(offset, length, visit)`: 저장본 매핑 또는 draft 메모리의 익스텐트를 복사 없이 순서대로 빌려준다.
+- `pure_draft_commit()`: 키가 draft의 기준 revision이면 새 노드만 추가하고 공개한다. 성공하면
+ draft는 새 revision에서 이어가고, 실패하면 draft와 저장본 모두 그대로다.
+
+커밋 쓰기는 draft의 노드 메모리를 바꾸지 않는다. 그래서 동기화 전에 실패해도 draft를 다시 커밋할 수 있다.
+다른 키의 쓰기로 압축이 일어나 파일이 교체되면 draft의 저장 참조는 옛 파일을 가리킨다. 이때 커밋은
+draft가 가진 옛 매핑에서 필요한 노드를 새 파일로 복사한다(한 번 전체 쓰기).
+
+편집할 때마다 경로를 새로 만들므로 도달할 수 없는 노드가 생긴다. 이 쓰레기가
+`max(도달 가능한 바이트, 32 KiB)`를 넘으면 표시·회수한다.
+
+## 측정
+
+같은 Raspberry Pi 5, ext4 `/dev/mmcblk0p2`, 저장소는 둘 다 `store-tree.c`다. 1 MiB 문서 가운데에
+한 글자를 입력하고 저장하기를 24회 반복했고, 방식마다 3회 실행했다. 문서 생성은 별도
+프로세스에서 하여 편집 세션의 피크만 쟀다(Linux `VmHWM`).
+[측정 코드](../../research/experiments/009-runtime-drafts/bench.c),
+[원시 결과](../../research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json).
+
+| 1 MiB 문서 | 앱 사본 + 비교 | 런타임 draft |
+|---|---:|---:|
+| 저장 중앙값 (실행별) | 10.08 · 10.53 · 13.57 ms | 9.25 · 8.64 · 9.13 ms |
+| 편집 세션 피크 RSS | 4,464–4,496 KiB | **1,648–1,664 KiB** |
+| 글자 하나 반영 | 0.04–0.06 µs (gap buffer) | 3.07–3.59 µs |
+
+주된 이득은 메모리다. draft는 문서 전체를 앱 메모리에 올리지 않고, 저장할 때 1 MiB를 다시
+복사하지도 않는다. 저장 시간은 약 1–2 ms 줄었을 뿐이다. 남은 대부분은 두 번의 `fsync`다.
+글자 하나 반영은 gap buffer보다 느리지만(leaf 하나와 경로 복사), 수 µs라 입력 한 번의 비용으로는 작다.
+
+## 검증
+
+[`test-tree-store.c`](../../prototypes/connections/test-tree-store.c) 추가 검사는 ASan·UBSan을 포함해 통과했다.
+
+- 20,000회 무작위 편집을 draft에서 평면 오라클과 비교하고 1,000회마다 커밋했다(최종 419,324 B, 커밋 20회).
+ 커밋 뒤에는 저장본 일치, revision 증가, 빈 arena를 확인했다.
+- 1 MiB 문서 가운데에서 30,000번 입력·삭제(순증 10,000 B): arena 피크 47,339 B.
+ 커밋은 14,952 B만 추가했다.
+- 두 draft가 같은 키를 수정하면 두 번째 커밋은 충돌을 내고 자기 편집을 유지한다.
+- 다른 키의 쓰기로 파일이 압축·교체된 뒤에도 draft 커밋이 올바른 내용을 공개한다.
+- 다섯 고장 단계: 동기화 전 실패는 저장본·파일 크기·draft를 바꾸지 않는다. 결과 불명 뒤의 재커밋은
+ 충돌로 드러나고, 다시 열면 공개된 내용을 본다.
+- 범위 밖 편집·읽기는 draft를 바꾸지 않는다. 읽기는 방문자가 멈추면 중단된다.
+
+`make check-tree` 전체(기존 런타임·부분 수정·비동기·실제 PTY 검사 포함)도 다시 통과했다.
+
+## 비용과 한계
+
+- `store-tree.c` 오브젝트 `text`가 15,899 B에서 19,321 B로 늘었다. draft를 쓰지 않는 트리 CLI도 같은
+ 번역 단위라 함께 링크된다(34,913 B). 분리하거나 줄여야 한다.
+- 아직 런타임 코어(`runtime.c`)의 작업·예산·완료 모델이나 `.pure` 정의에서 draft를 다룰 수 없다.
+ 지금은 호스트 수준 C API다. draft 메모리는 런타임 작업 예산에 계상되지 않는다.
+- 기존 TUI는 그대로 자기 gap buffer와 비교 방식을 쓴다. 앱 쪽 전환은 앱 저장소의 일이다.
+- 커밋은 기준 revision이 다르면 충돌로 끝난다. 두 draft의 연산을 자동으로 합치지 않는다.
+- draft는 기준 파일의 매핑을 끝까지 유지한다. 압축으로 교체된 옛 파일의 공간은 draft를 닫을 때까지
+ 회수되지 않는다. 최신 head가 전원 차단 등으로 손상돼 이전 커밋으로 되돌아가면, 그 뒤를 매핑한 draft의
+ 읽기는 잘린 파일 영역에 닿을 수 있다(기존 저장소의 외부 절단과 같은 신뢰 경계).
+- 한 draft는 한 스레드가 소유한다. 물리 전원 차단, macOS, x86_64는 검증하지 않았다.
+
+## 다음 결정
+
+1. draft를 런타임 코어의 자원으로 만든다. 작업 예산에 계상하고, `.pure` 정의가 draft 연산과
+ 커밋을 효과로 선언하게 한다. 앱은 C API가 아니라 런타임 계약으로 작업 버전을 다룬다.
+2. 호스트에 익스텐트 열을 그대로 넘긴다(HTTP `writev`, 피어 전송). 연속 복사는 호환 경계에만 남긴다.
+3. 같은 키의 동시 draft에서 서로 겹치지 않는 편집을 합칠지(연산 재기준화) 판단한다.
+4. 코드 크기를 줄이고 draft를 쓰지 않는 호스트에서 빠지게 한다.
diff --git a/prototypes/connections/Makefile b/prototypes/connections/Makefile
index 661fbcd..a416bee 100644
--- a/prototypes/connections/Makefile
+++ b/prototypes/connections/Makefile
@@ -11,7 +11,7 @@ HOST = $(STORE) store-port.c evaluate.c
# Document browsers list keys through the store. The tree store keeps its own
# catalog; per-object file stores share a directory listing.
KEYS = $(if $(filter store-tree.c,$(STORE)),,store-keys.c)
-HEADERS = runtime.h program.h store.h store-port.h flow.h result.h
+HEADERS = runtime.h program.h store.h store-port.h state.h flow.h result.h
all: core fixed-editor
core: $(BUILD)/libpure-execution.a
async-core: $(BUILD)/libpure-execution-async.a
diff --git a/prototypes/connections/README.md b/prototypes/connections/README.md
index f0b041e..94d05f0 100644
--- a/prototypes/connections/README.md
+++ b/prototypes/connections/README.md
@@ -148,6 +148,8 @@ python3 prototypes/connections/export_flat_store.py \
트리로 둔다. 편집은 바뀐 leaf와 경로만 기록하고, 읽기는 잠그지 않는다. 기존 런타임·부분 수정·
비동기·실제 PTY 검사와 자체 고장·오라클 검사를 함께 실행한다. 형식이 달라 별도 `--store`
디렉터리를 쓴다. [설계·측정·한계](../../docs/research/073-single-file-persistent-tree.md).
+같은 저장소에서 런타임이 앱의 작업 버전을 소유하는 draft API는 [`state.h`](state.h)이며,
+설계와 측정은 [074](../../docs/research/074-runtime-owned-drafts.md)에 있다.
```sh
make -C prototypes/connections check-tree
diff --git a/prototypes/connections/state.h b/prototypes/connections/state.h
new file mode 100644
index 0000000..d1bc0fa
--- /dev/null
+++ b/prototypes/connections/state.h
@@ -0,0 +1,31 @@
+#ifndef PURE_STATE_H
+#define PURE_STATE_H
+#include "flow.h"
+#include "result.h"
+#include <stdint.h>
+
+/* Runtime-owned working versions of stored values (selected tree store).
+ * A draft starts at the committed version of a key and changes only in
+ * memory: an edit copies the leaf and path it touches and shares every other
+ * extent with the stored version. An app therefore keeps no copy of the value
+ * and never compares a copy with storage to find what it changed. Commit
+ * appends only the draft's new extents. One owner thread per draft. */
+typedef struct PureDraft PureDraft;
+
+/* A missing key opens as an empty draft at revision 0; commit creates it. */
+PureResult pure_draft_open(const char *path, const char *key, PureDraft **out);
+/* Memory only. Fails without change on a bad range or when memory runs out. */
+PureResult pure_draft_splice(PureDraft *, size_t offset, size_t remove, PureSpan insert);
+size_t pure_draft_size(const PureDraft *);
+/* The committed revision this draft is based on; 0 if the key was absent. */
+int64_t pure_draft_revision(const PureDraft *);
+/* Visit [offset, offset + length) in order as borrowed extents, without
+ * copying. Returning false stops early. Spans stay valid until the next
+ * splice, commit or close of this draft. */
+PureResult pure_draft_read(const PureDraft *, size_t offset, size_t length,
+ bool (*visit)(void *, PureSpan), void *context);
+/* Publish if the key is still at the draft's base revision. On success the
+ * draft continues from the new revision; on any failure it is unchanged. */
+PureResult pure_draft_commit(PureDraft *, int64_t *revision);
+void pure_draft_close(PureDraft *);
+#endif
diff --git a/prototypes/connections/store-tree.c b/prototypes/connections/store-tree.c
index 618028a..1c0ad91 100644
--- a/prototypes/connections/store-tree.c
+++ b/prototypes/connections/store-tree.c
@@ -10,6 +10,7 @@
#define _DARWIN_C_SOURCE
#endif
#include "store.h"
+#include "state.h"
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
@@ -20,6 +21,7 @@
#include <sys/stat.h>
#include <unistd.h>
+enum { FREE = 0xffff };
enum { SLOT = 64, DATA = 2 * SLOT, LEAF_MAX = 4096, LEAF_MIN = 1024,
FAN_MAX = 32, FAN_MIN = 8, RECORD = 96, KEY = 64, HEIGHT_MAX = 12,
BUFFER = 65536 };
@@ -43,14 +45,15 @@ typedef struct { Entry *items; size_t count, capacity; } List;
typedef struct {
unsigned char *bytes; /* leaf only */
Entry *children; /* branch only */
- size_t size; /* leaf bytes or child count */
- unsigned height;
+ size_t size; /* leaf bytes, child count, or next free slot + 1 */
+ unsigned height; /* FREE for a reclaimed slot */
} Node;
typedef struct {
const unsigned char *map;
uint64_t end;
Node *nodes;
- size_t count, capacity;
+ size_t count, capacity, free_head, free_count; /* free_head is slot + 1 */
+ size_t bytes; /* payload held by arena nodes, including unreachable ones */
} Tree;
typedef struct { uint64_t seq, end, catalog, catalog_bytes, live; } Head;
typedef struct {
@@ -309,11 +312,23 @@ static bool push(List *list, Entry entry) {
return true;
}
+static size_t node_bytes(const Node *node) {
+ return node->bytes ? node->size : node->size * sizeof(Entry);
+}
+
static bool add_node(Tree *tree, Node node, uint64_t *index) {
+ tree->bytes += node_bytes(&node);
+ if (tree->free_head) {
+ *index = tree->free_head - 1;
+ tree->free_head = tree->nodes[*index].size;
+ --tree->free_count;
+ tree->nodes[*index] = node;
+ return true;
+ }
if (tree->count == tree->capacity) {
size_t capacity = tree->capacity ? 2 * tree->capacity : 64;
Node *nodes = realloc(tree->nodes, capacity * sizeof(*nodes));
- if (!nodes) return false;
+ if (!nodes) { tree->bytes -= node_bytes(&node); return false; }
tree->nodes = nodes;
tree->capacity = capacity;
}
@@ -537,8 +552,9 @@ static uint64_t put_branch(Writer *writer, const Entry *kids, size_t count, unsi
}
/* Append a subtree's arena nodes, children first, and set entry to the
- * stored reference without changing the arena. With relocate, stored nodes
- * are copied too; compaction uses this to rewrite reachable nodes. */
+ * stored reference. The arena is not changed, so a failed commit leaves a
+ * draft intact. With relocate, stored nodes are copied too (compaction, or a
+ * draft whose base file was replaced by compaction). */
static bool write_subtree(const Tree *tree, Writer *writer, Entry *entry, unsigned height,
bool relocate) {
if (!entry->bytes || (!(entry->ref & MEMORY_BIT) && !relocate)) return true;
@@ -918,6 +934,160 @@ PureResult pure_store_keys(const char *path, bool (*visit)(void *, const char *k
return result;
}
+struct PureDraft {
+ char *path;
+ char key[KEY + 1];
+ Tree tree; /* the base file's mapping plus this draft's node arena */
+ dev_t device; /* identity of the base file; compaction replaces it */
+ ino_t inode;
+ Entry root;
+ int64_t revision;
+ size_t live; /* arena bytes reachable at the last collection */
+};
+
+/* Rebase the draft on the committed version of its key. */
+static PureResult load_draft(PureDraft *draft) {
+ int directory = open(draft->path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ if (directory < 0) return PURE_STORE_ERROR;
+ int fd = openat(directory, tree_name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
+ close(directory);
+ if (fd < 0) return PURE_STORE_ERROR;
+ Tree tree = {0};
+ Head head;
+ struct stat info;
+ Entry document;
+ uint64_t index;
+ int64_t revision;
+ bool found;
+ bool ok = !fstat(fd, &info) && read_head(fd, &head) && map_tree(fd, &head, &tree) &&
+ find(&tree, (Entry){head.catalog, head.catalog_bytes}, draft->key, &index, &found,
+ &document, &revision);
+ close(fd);
+ if (!ok) { release_tree(&tree); return PURE_STORE_ERROR; }
+ release_tree(&draft->tree);
+ draft->tree = tree;
+ draft->device = info.st_dev;
+ draft->inode = info.st_ino;
+ draft->root = document;
+ draft->revision = revision;
+ draft->live = 0;
+ return PURE_OK;
+}
+
+PureResult pure_draft_open(const char *path, const char *key, PureDraft **out) {
+ *out = NULL;
+ if (!path || !valid_key(key)) return PURE_INVALID;
+ PureDraft *draft = calloc(1, sizeof(*draft));
+ if (!draft || !(draft->path = strdup(path))) { free(draft); return PURE_ALLOCATION; }
+ memcpy(draft->key, key, strlen(key) + 1);
+ PureResult result = load_draft(draft);
+ if (result != PURE_OK) { pure_draft_close(draft); return result; }
+ *out = draft;
+ return PURE_OK;
+}
+
+void pure_draft_close(PureDraft *draft) {
+ if (!draft) return;
+ release_tree(&draft->tree);
+ free(draft->path);
+ free(draft);
+}
+
+size_t pure_draft_size(const PureDraft *draft) { return (size_t)draft->root.bytes; }
+
+int64_t pure_draft_revision(const PureDraft *draft) { return draft->revision; }
+
+static size_t mark(const Tree *tree, Entry entry, unsigned char *marks) {
+ if (!(entry.ref & MEMORY_BIT)) return 0;
+ size_t at = (size_t)(entry.ref & LOCATION);
+ if (marks[at]) return 0;
+ marks[at] = 1;
+ const Node *node = &tree->nodes[at];
+ size_t bytes = node_bytes(node);
+ if (!node->bytes)
+ for (size_t i = 0; i < node->size; ++i) bytes += mark(tree, node->children[i], marks);
+ return bytes;
+}
+
+/* Every edit copies a leaf and path, so reclaim arena nodes the root no
+ * longer reaches once they outweigh the reachable ones (and 32 KiB). */
+static void collect(PureDraft *draft) {
+ Tree *tree = &draft->tree;
+ size_t allowance = draft->live > 32768 ? draft->live : 32768;
+ if (tree->bytes <= draft->live || tree->bytes - draft->live <= allowance) return;
+ unsigned char *marks = calloc(tree->count, 1);
+ if (!marks) return;
+ draft->live = mark(tree, draft->root, marks);
+ for (size_t i = 0; i < tree->count; ++i) {
+ Node *node = &tree->nodes[i];
+ if (marks[i] || node->height == FREE) continue;
+ tree->bytes -= node_bytes(node);
+ free(node->bytes);
+ free(node->children);
+ *node = (Node){.size = tree->free_head, .height = FREE};
+ tree->free_head = i + 1;
+ ++tree->free_count;
+ }
+ free(marks);
+}
+
+PureResult pure_draft_splice(PureDraft *draft, size_t offset, size_t remove, PureSpan insert) {
+ uint64_t size = draft->root.bytes;
+ if (insert.size && !insert.data) return PURE_INVALID;
+ if (offset > size || remove > size - offset || insert.size > PURE_OBJECT_LIMIT - (size - remove))
+ return PURE_LIMIT;
+ if (!remove && !insert.size) return PURE_OK;
+ Entry next;
+ bool ok = rebuild(&draft->tree, draft->root, offset, remove, insert, &next);
+ if (ok) draft->root = next;
+ collect(draft);
+ return ok ? PURE_OK : PURE_STORE_ERROR;
+}
+
+PureResult pure_draft_read(const PureDraft *draft, size_t offset, size_t length,
+ bool (*visit)(void *, PureSpan), void *context) {
+ unsigned height;
+ if (offset > draft->root.bytes || length > draft->root.bytes - offset) return PURE_LIMIT;
+ if (!length) return PURE_OK;
+ if (!root_height(&draft->tree, draft->root, &height)) return PURE_STORE_ERROR;
+ return visit_range(&draft->tree, draft->root, height, offset, length, visit, context) ?
+ PURE_OK : PURE_STORE_ERROR;
+}
+
+PureResult pure_draft_commit(PureDraft *draft, int64_t *revision) {
+ *revision = draft->revision;
+ Session session;
+ Writer *writer = NULL;
+ Entry current, root = draft->root;
+ uint64_t index;
+ int64_t stored;
+ bool found;
+ PureResult result = open_session(draft->path, &session);
+ if (result != PURE_OK) goto finish;
+ result = PURE_STORE_ERROR;
+ if (!find(&session.tree, (Entry){session.head.catalog, session.head.catalog_bytes}, draft->key,
+ &index, &found, &current, &stored)) goto finish;
+ if (stored != draft->revision) { *revision = stored; result = PURE_CONFLICT; goto finish; }
+ if (stored == INT64_MAX) { result = PURE_LIMIT; goto finish; }
+ /* After compaction the draft's stored references name the old file. */
+ bool relocate = session.file.st_dev != draft->device || session.file.st_ino != draft->inode;
+ writer = malloc(sizeof(*writer));
+ if (!writer) goto finish;
+ *writer = (Writer){.fd = session.fd, .at = session.head.end};
+ if (!write_root(&draft->tree, writer, &root, relocate)) {
+ if (ftruncate(session.fd, (off_t)session.head.end) == 0) sync_fd(session.fd);
+ goto finish;
+ }
+ result = publish(&session, writer, draft->key, index, found, root, revision);
+ /* Rebase while still holding the writer lock; the arena is no longer needed.
+ * If remapping fails the draft keeps equal content at the new revision. */
+ if (result == PURE_OK && load_draft(draft) != PURE_OK) draft->revision = *revision;
+finish:
+ free(writer);
+ close_session(&session);
+ return result;
+}
+
PureResult pure_store_put(const char *path, const char *key, int64_t expected,
PureSpan body, int64_t *revision) {
return mutate(path, key, expected, body, false, 0, 0, PURE_OBJECT_LIMIT, revision);
@@ -946,6 +1116,17 @@ static bool shape(const Tree *tree, Entry entry, unsigned height, bool root,
return true;
}
+/* Test-only: live arena nodes and their payload bytes. */
+void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes) {
+ *nodes = *bytes = 0;
+ for (size_t i = 0; i < draft->tree.count; ++i) {
+ const Node *node = &draft->tree.nodes[i];
+ if (node->height == FREE) continue;
+ ++*nodes;
+ *bytes += node->bytes ? node->size : node->size * sizeof(Entry);
+ }
+}
+
/* Test-only: the stored height, leaf count and number of underfull nodes. */
bool pure_tree_test_shape(const char *path, const char *key, unsigned *height,
uint64_t *leaves, uint64_t *small, uint64_t *file_end) {
diff --git a/prototypes/connections/test-tree-store.c b/prototypes/connections/test-tree-store.c
index 3a1e8f0..8cf1f1e 100644
--- a/prototypes/connections/test-tree-store.c
+++ b/prototypes/connections/test-tree-store.c
@@ -1,5 +1,6 @@
#define _POSIX_C_SOURCE 200809L
#include "store.h"
+#include "state.h"
#include <assert.h>
#include <dirent.h>
#include <fcntl.h>
@@ -22,6 +23,8 @@ int pure_store_test_fault(PureStoreStage stage) {
bool pure_tree_test_shape(const char *path, const char *key, unsigned *height,
uint64_t *leaves, uint64_t *small, uint64_t *file_end);
+void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes);
+
enum { KEYS = 4, CAPACITY = 1 << 20 };
typedef struct { unsigned char *bytes; size_t size; int64_t revision; } Oracle;
@@ -366,6 +369,215 @@ static void keys(const char *path) {
CHECK(pure_store_keys(path, count_key, &count) == PURE_OK && count == 5);
}
+typedef struct { unsigned char *out; size_t spans, largest, limit; } Gather;
+
+static bool gather(void *context, PureSpan span) {
+ Gather *g = context;
+ memcpy(g->out, span.data, span.size);
+ g->out += span.size;
+ if (span.size > g->largest) g->largest = span.size;
+ return ++g->spans != g->limit;
+}
+
+static void expect_draft(const PureDraft *draft, const Oracle *o, unsigned char *scratch) {
+ Gather g = {scratch, 0, 0, 0};
+ CHECK(pure_draft_size(draft) == o->size && pure_draft_revision(draft) == o->revision);
+ CHECK(pure_draft_read(draft, 0, o->size, gather, &g) == PURE_OK);
+ CHECK((size_t)(g.out - scratch) == o->size && (!o->size || !memcmp(scratch, o->bytes, o->size)));
+ CHECK(g.largest <= 4096);
+}
+
+static void apply(Oracle *o, size_t offset, size_t remove, const unsigned char *insert, size_t count) {
+ memmove(o->bytes + offset + count, o->bytes + offset + remove, o->size - offset - remove);
+ if (count) memcpy(o->bytes + offset, insert, count);
+ o->size = o->size - remove + count;
+}
+
+/* Edits live only in the draft until commit; each commit continues from the
+ * published version with an empty arena. */
+static void drafts(const char *path) {
+ Oracle o = {malloc(CAPACITY), 0, 0};
+ unsigned char *scratch = malloc(CAPACITY), *insert = malloc(65536);
+ CHECK(o.bytes && scratch && insert);
+ PureDraft *draft;
+ PureStoredView view;
+ CHECK(pure_draft_open(path, "draft", &draft) == PURE_OK);
+ expect_draft(draft, &o, scratch);
+ uint32_t state = 0x1234567u;
+ for (unsigned step = 0; step < 20000; ++step) {
+ uint32_t kind = random_value(&state) % 100;
+ size_t count = kind < 80 ? random_value(&state) % 8 :
+ kind < 98 ? random_value(&state) % 4000 : random_value(&state) % 65536;
+ size_t offset = o.size ? random_value(&state) % (o.size + 1) : 0;
+ size_t remove = o.size - offset ? random_value(&state) % (o.size - offset + 1) : 0;
+ if (random_value(&state) % 64) remove %= 16;
+ if (o.size - remove + count > CAPACITY) count = 0;
+ fill(insert, count, &state);
+ CHECK(pure_draft_splice(draft, offset, remove, (PureSpan){insert, count}) == PURE_OK);
+ apply(&o, offset, remove, insert, count);
+ if (step % 97 == 0) expect_draft(draft, &o, scratch);
+ if (step % 1000 == 999) {
+ if (o.revision) CHECK(pure_store_get(path, "draft", PURE_OBJECT_LIMIT, &view) == PURE_OK &&
+ view.revision == o.revision);
+ if (o.revision) pure_store_release(&view);
+ int64_t revision;
+ CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1);
+ o.revision = revision;
+ expect(path, "draft", &o);
+ expect_draft(draft, &o, scratch);
+ size_t nodes, bytes;
+ pure_draft_test_arena(draft, &nodes, &bytes);
+ CHECK(nodes == 0);
+ }
+ }
+ CHECK(o.size > 100000);
+ Gather g = {scratch, 0, 0, 3};
+ CHECK(pure_draft_read(draft, 0, o.size, gather, &g) == PURE_OK && g.spans == 3);
+ CHECK(pure_draft_splice(draft, o.size + 1, 0, (PureSpan){0}) == PURE_LIMIT);
+ CHECK(pure_draft_splice(draft, 0, o.size + 1, (PureSpan){0}) == PURE_LIMIT);
+ CHECK(pure_draft_read(draft, o.size, 1, gather, &g) == PURE_LIMIT);
+ expect_draft(draft, &o, scratch);
+ pure_draft_close(draft);
+ printf("{\"draft_steps\":20000,\"draft_commits\":%lld,\"draft_bytes\":%zu}\n",
+ (long long)o.revision, o.size);
+ free(o.bytes);
+ free(scratch);
+ free(insert);
+}
+
+/* Long typing and deleting at one place keeps only the live path in memory,
+ * and its commit appends the typed bytes and path, not the document. */
+static void typing(const char *path) {
+ size_t size = 1048576;
+ Oracle o = {malloc(size + 40000), size, 0};
+ unsigned char *scratch = malloc(size + 40000);
+ uint32_t state = 5;
+ CHECK(o.bytes && scratch);
+ fill(o.bytes, size, &state);
+ CHECK(pure_store_put(path, "typed", 0, (PureSpan){o.bytes, size}, &o.revision) == PURE_OK);
+ PureDraft *draft;
+ CHECK(pure_draft_open(path, "typed", &draft) == PURE_OK);
+ size_t cursor = size / 2, nodes, bytes, peak = 0;
+ for (unsigned i = 0; i < 30000; ++i) {
+ unsigned char byte = (unsigned char)('a' + i % 26);
+ if (i % 3 == 2) {
+ CHECK(pure_draft_splice(draft, cursor - 1, 1, (PureSpan){0}) == PURE_OK);
+ apply(&o, --cursor, 1, NULL, 0);
+ } else {
+ CHECK(pure_draft_splice(draft, cursor, 0, (PureSpan){&byte, 1}) == PURE_OK);
+ apply(&o, cursor++, 0, &byte, 1);
+ }
+ pure_draft_test_arena(draft, &nodes, &bytes);
+ if (bytes > peak) peak = bytes;
+ }
+ expect_draft(draft, &o, scratch);
+ /* Reachable: 10,000 net typed bytes, the leaves they landed in and one
+ * path. Unreachable copies are reclaimed past max(reachable, 32 KiB). */
+ CHECK(peak < 2 * (10000 + 4 * 4096 + 2048) + 32768 + 8192);
+ uint64_t before = file_size(path);
+ int64_t revision;
+ CHECK(pure_draft_commit(draft, &revision) == PURE_OK);
+ o.revision = revision;
+ CHECK(file_size(path) - before < 10000 + 16384);
+ expect(path, "typed", &o);
+ pure_draft_close(draft);
+ printf("{\"typing_keystrokes\":30000,\"peak_arena_bytes\":%zu,\"commit_appended_bytes\":%llu}\n",
+ peak, (unsigned long long)(file_size(path) - before));
+ free(o.bytes);
+ free(scratch);
+}
+
+/* Two drafts of one key: the second commit conflicts and keeps its edits. */
+static void draft_conflict(const char *path) {
+ unsigned char scratch[64];
+ int64_t revision;
+ CHECK(pure_store_put(path, "shared", 0, (PureSpan){(const unsigned char *)"base", 4}, &revision) == PURE_OK);
+ PureDraft *first, *second;
+ CHECK(pure_draft_open(path, "shared", &first) == PURE_OK && pure_draft_open(path, "shared", &second) == PURE_OK);
+ CHECK(pure_draft_splice(first, 4, 0, (PureSpan){(const unsigned char *)"-one", 4}) == PURE_OK);
+ CHECK(pure_draft_splice(second, 0, 0, (PureSpan){(const unsigned char *)"two-", 4}) == PURE_OK);
+ CHECK(pure_draft_commit(first, &revision) == PURE_OK && revision == 2);
+ CHECK(pure_draft_commit(second, &revision) == PURE_CONFLICT && revision == 2);
+ Oracle kept = {(unsigned char *)"two-base", 8, 1};
+ expect_draft(second, &kept, scratch);
+ Oracle stored = {(unsigned char *)"base-one", 8, 2};
+ expect(path, "shared", &stored);
+ pure_draft_close(second);
+ CHECK(pure_draft_open(path, "shared", &second) == PURE_OK);
+ expect_draft(second, &stored, scratch);
+ CHECK(pure_store_put(path, "shared", 2, (PureSpan){(const unsigned char *)"x", 1}, &revision) == PURE_OK);
+ CHECK(pure_draft_commit(first, &revision) == PURE_CONFLICT && revision == 3);
+ pure_draft_close(first);
+ pure_draft_close(second);
+}
+
+/* Another key's writes compact the file under an open draft; the draft's
+ * stored extents are copied from its old mapping on commit. */
+static void draft_relocation(const char *path) {
+ unsigned char *big = malloc(300000), scratch[20000];
+ uint32_t state = 11;
+ CHECK(big);
+ fill(big, 300000, &state);
+ Oracle o = {malloc(20000), 12000, 0};
+ CHECK(o.bytes);
+ fill(o.bytes, o.size, &state);
+ CHECK(pure_store_put(path, "kept", 0, (PureSpan){o.bytes, o.size}, &o.revision) == PURE_OK);
+ PureDraft *draft;
+ CHECK(pure_draft_open(path, "kept", &draft) == PURE_OK);
+ CHECK(pure_draft_splice(draft, 6000, 10, (PureSpan){(const unsigned char *)"relocated", 9}) == PURE_OK);
+ apply(&o, 6000, 10, (const unsigned char *)"relocated", 9);
+ char name[512];
+ snprintf(name, sizeof(name), "%s/pure.tree", path);
+ struct stat before, after;
+ CHECK(stat(name, &before) == 0);
+ int64_t revision = 0;
+ for (unsigned i = 0; i < 8; ++i)
+ CHECK(pure_store_put(path, "other", revision, (PureSpan){big, 300000 - i}, &revision) == PURE_OK);
+ CHECK(stat(name, &after) == 0 && after.st_ino != before.st_ino);
+ CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1);
+ o.revision = revision;
+ expect(path, "kept", &o);
+ expect_draft(draft, &o, scratch);
+ Oracle other = {big, 300000 - 7, 8};
+ expect(path, "other", &other);
+ pure_draft_close(draft);
+ free(o.bytes);
+ free(big);
+}
+
+/* A failed commit changes neither the store nor the draft. */
+static void draft_faults(const char *path) {
+ unsigned char scratch[64];
+ int64_t revision;
+ CHECK(pure_store_put(path, "faulty", 0, (PureSpan){(const unsigned char *)"stable", 6}, &revision) == PURE_OK);
+ PureDraft *draft;
+ CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK);
+ CHECK(pure_draft_splice(draft, 6, 0, (PureSpan){(const unsigned char *)"+edit", 5}) == PURE_OK);
+ Oracle stored = {(unsigned char *)"stable", 6, 1}, edited = {(unsigned char *)"stable+edit", 11, 1};
+ uint64_t size = file_size(path);
+ for (fail_stage = PURE_AFTER_HEADER; fail_stage <= PURE_BEFORE_RENAME; ++fail_stage) {
+ CHECK(pure_draft_commit(draft, &revision) == PURE_STORE_ERROR);
+ expect(path, "faulty", &stored);
+ expect_draft(draft, &edited, scratch);
+ CHECK(file_size(path) == size);
+ }
+ fail_stage = PURE_BEFORE_DIR_SYNC;
+ CHECK(pure_draft_commit(draft, &revision) == PURE_UNCERTAIN && revision == 2);
+ fail_stage = -1;
+ expect_draft(draft, &edited, scratch);
+ CHECK(pure_draft_commit(draft, &revision) == PURE_CONFLICT && revision == 2);
+ pure_draft_close(draft);
+ CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK);
+ edited.revision = 2;
+ expect_draft(draft, &edited, scratch);
+ CHECK(pure_draft_splice(draft, 0, 6, (PureSpan){0}) == PURE_OK);
+ CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == 3);
+ Oracle final = {(unsigned char *)"+edit", 5, 3};
+ expect(path, "faulty", &final);
+ pure_draft_close(draft);
+ CHECK(pure_draft_open(path, "bad/key", &draft) == PURE_INVALID && !draft);
+}
+
static void clean(const char *path) {
DIR *entries = opendir(path);
CHECK(entries);
@@ -380,7 +592,8 @@ static void clean(const char *path) {
int main(void) {
void (*checks[])(const char *) = {oracle, amplification, faults, crashes, torn_head,
- isolation, corruption, many_keys, revision_limit, keys};
+ isolation, corruption, many_keys, revision_limit, keys,
+ drafts, typing, draft_conflict, draft_relocation, draft_faults};
for (size_t i = 0; i < sizeof(checks) / sizeof(*checks); ++i) {
char path[] = "/tmp/pure-tree-check-XXXXXX";
CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK);
@@ -389,6 +602,7 @@ int main(void) {
clean(path);
}
puts("{\"tree_store\":\"random-oracle-balance-held-views-one-byte-amplification-faults-"
- "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing\"}");
+ "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing-"
+ "draft-oracle-typing-arena-conflict-relocation-commit-faults\"}");
return 0;
}
diff --git a/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json
new file mode 100644
index 0000000..e85ec38
--- /dev/null
+++ b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json
@@ -0,0 +1,69 @@
+{
+ "device": "/dev/mmcblk0p2",
+ "filesystem": "ext4",
+ "machine": "aarch64",
+ "kernel": "6.18.39+rpt-rpi-2712",
+ "store": "store-tree.c",
+ "runs": [
+ {
+ "mode": "copy",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 0.06,
+ "save_median_ms": 10.083,
+ "save_p90_ms": 12.536,
+ "peak_rss_kib": 4496,
+ "final_revision": 25
+ },
+ {
+ "mode": "draft",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 3.18,
+ "save_median_ms": 9.245,
+ "save_p90_ms": 27.849,
+ "peak_rss_kib": 1664,
+ "final_revision": 25
+ },
+ {
+ "mode": "draft",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 3.07,
+ "save_median_ms": 8.643,
+ "save_p90_ms": 18.492,
+ "peak_rss_kib": 1648,
+ "final_revision": 25
+ },
+ {
+ "mode": "copy",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 0.04,
+ "save_median_ms": 10.528,
+ "save_p90_ms": 12.78,
+ "peak_rss_kib": 4480,
+ "final_revision": 25
+ },
+ {
+ "mode": "copy",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 0.04,
+ "save_median_ms": 13.571,
+ "save_p90_ms": 21.87,
+ "peak_rss_kib": 4464,
+ "final_revision": 25
+ },
+ {
+ "mode": "draft",
+ "document_bytes": 1048576,
+ "saves": 24,
+ "keystroke_median_us": 3.59,
+ "save_median_ms": 9.128,
+ "save_p90_ms": 50.847,
+ "peak_rss_kib": 1664,
+ "final_revision": 25
+ }
+ ]
+}
diff --git a/research/experiments/009-runtime-drafts/bench.c b/research/experiments/009-runtime-drafts/bench.c
new file mode 100644
index 0000000..e486d68
--- /dev/null
+++ b/research/experiments/009-runtime-drafts/bench.c
@@ -0,0 +1,120 @@
+/* Where an app's working value lives, measured on one store (store-tree.c).
+ *
+ * copy: the app loads its own copy into a gap buffer, types into it, and on
+ * save reads the stored value back, finds the changed range by
+ * comparing prefix and suffix, then submits that splice (the pattern
+ * the current terminal editor uses).
+ * draft: the runtime owns the working version; typing is a draft splice and
+ * save is a commit of the draft's new extents.
+ *
+ * Each save follows one typed character in the middle of the document.
+ * "create" makes the stored document in a separate process, so the peak RSS
+ * of copy and draft covers only the editing session. */
+#define _POSIX_C_SOURCE 200809L
+#include "state.h"
+#include "store.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+static double now_us(void) {
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ return (double)t.tv_sec * 1e6 + (double)t.tv_nsec / 1e3;
+}
+
+static int compare(const void *a, const void *b) {
+ double x = *(const double *)a, y = *(const double *)b;
+ return (x > y) - (x < y);
+}
+
+/* Linux VmHWM belongs to this exec's address space; ru_maxrss would carry
+ * the launching interpreter's peak across exec. */
+static long peak_kib(void) {
+ FILE *file = fopen("/proc/self/status", "r");
+ char line[256];
+ long value = -1;
+ while (file && fgets(line, sizeof(line), file))
+ if (!strncmp(line, "VmHWM:", 6)) value = strtol(line + 6, NULL, 10);
+ if (file) fclose(file);
+ return value;
+}
+
+typedef struct { unsigned char *text; size_t capacity, gap_start, gap_end; } Gap;
+
+static unsigned char at(const Gap *g, size_t i) {
+ return i < g->gap_start ? g->text[i] : g->text[i + (g->gap_end - g->gap_start)];
+}
+
+int main(int argc, char **argv) {
+ if (argc != 5 || (strcmp(argv[1], "copy") && strcmp(argv[1], "draft") && strcmp(argv[1], "create"))) {
+ fputs("bench create|copy|draft DIRECTORY SIZE SAVES\n", stderr);
+ return 2;
+ }
+ const char *mode = argv[1], *path = argv[2];
+ size_t size = strtoull(argv[3], NULL, 10), saves = strtoull(argv[4], NULL, 10);
+ int64_t revision = 1;
+ if (!strcmp(mode, "create")) {
+ unsigned char *body = malloc(size);
+ if (!body || pure_store_init(path) != PURE_OK) return 1;
+ for (size_t i = 0; i < size; ++i) body[i] = (unsigned char)('a' + i % 26);
+ return pure_store_put(path, "doc", 0, (PureSpan){body, size}, &revision) != PURE_OK;
+ }
+ double *save = malloc(saves * sizeof(*save)), *key = malloc(saves * sizeof(*key));
+ if (!save || !key) return 1;
+ size_t cursor = size / 2, length = size;
+ if (!strcmp(mode, "copy")) {
+ PureStoredView view;
+ if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1;
+ Gap g = {malloc(size + 4096), size + 4096, cursor, cursor + 4096};
+ if (!g.text) return 1;
+ memcpy(g.text, view.body.data, cursor);
+ memcpy(g.text + g.gap_end, view.body.data + cursor, size - cursor);
+ pure_store_release(&view);
+ for (size_t i = 0; i < saves; ++i) {
+ double start = now_us();
+ g.text[g.gap_start++] = (unsigned char)('A' + i % 26);
+ ++length;
+ key[i] = now_us() - start;
+ start = now_us();
+ if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1;
+ size_t old = view.body.size, prefix = 0, suffix = 0;
+ while (prefix < old && prefix < length && view.body.data[prefix] == at(&g, prefix)) ++prefix;
+ while (suffix < old - prefix && suffix < length - prefix &&
+ view.body.data[old - suffix - 1] == at(&g, length - suffix - 1)) ++suffix;
+ size_t remove = old - prefix - suffix, insert = length - prefix - suffix;
+ unsigned char *bytes = malloc(insert ? insert : 1);
+ for (size_t k = 0; k < insert; ++k) bytes[k] = at(&g, prefix + k);
+ pure_store_release(&view);
+ PureResult result = pure_store_splice(path, "doc", revision, prefix, remove,
+ (PureSpan){bytes, insert}, PURE_OBJECT_LIMIT, &revision);
+ free(bytes);
+ save[i] = now_us() - start;
+ if (result != PURE_OK) return 1;
+ }
+ free(g.text);
+ } else {
+ PureDraft *draft;
+ if (pure_draft_open(path, "doc", &draft) != PURE_OK) return 1;
+ for (size_t i = 0; i < saves; ++i) {
+ unsigned char byte = (unsigned char)('A' + i % 26);
+ double start = now_us();
+ if (pure_draft_splice(draft, cursor++, 0, (PureSpan){&byte, 1}) != PURE_OK) return 1;
+ key[i] = now_us() - start;
+ start = now_us();
+ if (pure_draft_commit(draft, &revision) != PURE_OK) return 1;
+ save[i] = now_us() - start;
+ }
+ pure_draft_close(draft);
+ }
+ qsort(save, saves, sizeof(*save), compare);
+ qsort(key, saves, sizeof(*key), compare);
+ printf("{\"mode\":\"%s\",\"document_bytes\":%zu,\"saves\":%zu,\"keystroke_median_us\":%.2f,"
+ "\"save_median_ms\":%.3f,\"save_p90_ms\":%.3f,\"peak_rss_kib\":%ld,\"final_revision\":%lld}\n",
+ mode, size, saves, key[saves / 2], save[saves / 2] / 1e3, save[saves * 9 / 10] / 1e3,
+ peak_kib(), (long long)revision);
+ free(save);
+ free(key);
+ return 0;
+}
diff --git a/research/experiments/009-runtime-drafts/run.py b/research/experiments/009-runtime-drafts/run.py
new file mode 100755
index 0000000..08ed55d
--- /dev/null
+++ b/research/experiments/009-runtime-drafts/run.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Run bench.c in copy and draft modes on a real device, alternating order."""
+import argparse
+import json
+import platform
+import shutil
+import subprocess
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+CONNECTIONS = HERE.parents[2] / 'prototypes' / 'connections'
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--work', type=Path, default=CONNECTIONS / 'build' / 'bench-drafts')
+ parser.add_argument('--size', type=int, default=1048576)
+ parser.add_argument('--saves', type=int, default=24)
+ parser.add_argument('--rounds', type=int, default=3)
+ parser.add_argument('--output', type=Path)
+ args = parser.parse_args()
+ shutil.rmtree(args.work, ignore_errors=True)
+ args.work.mkdir(parents=True)
+ device = subprocess.check_output(['findmnt', '-n', '-T', str(args.work), '-o', 'SOURCE,FSTYPE'], text=True).split()
+ assert device[1] != 'tmpfs', 'fsync is free on tmpfs'
+ binary = args.work / 'bench'
+ subprocess.run(['cc', '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', f'-I{CONNECTIONS}',
+ str(HERE / 'bench.c'), str(CONNECTIONS / 'store-tree.c'), '-o', str(binary)], check=True)
+ runs = []
+ for round_number in range(args.rounds):
+ for mode in (('copy', 'draft') if round_number % 2 == 0 else ('draft', 'copy')):
+ directory = args.work / f'{mode}-{round_number}'
+ subprocess.run([str(binary), 'create', str(directory), str(args.size), '0'], check=True)
+ line = subprocess.check_output([str(binary), mode, str(directory), str(args.size), str(args.saves)], text=True)
+ runs.append(json.loads(line))
+ print(line.strip(), flush=True)
+ shutil.rmtree(directory)
+ report = {'device': device[0], 'filesystem': device[1], 'machine': platform.machine(),
+ 'kernel': platform.release(), 'store': 'store-tree.c', 'runs': runs}
+ if args.output:
+ args.output.write_text(json.dumps(report, indent=2) + '\n')
+
+
+if __name__ == '__main__':
+ main()