aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-10-18 18:25:17 +0100
committereunhhu <[email protected]>2026-10-18 18:25:17 +0100
commit531200a6c24842784fad68102878c3ab77391b13 (patch)
tree7e7816ad2db16fa699127d446c0fc539c3a62f98
parent9f17dd667c3282aa28d6b3961eda28e592961478 (diff)
downloadpure-531200a6c24842784fad68102878c3ab77391b13.tar.gz
pure-531200a6c24842784fad68102878c3ab77391b13.zip
Module executor and pure-serve: 027's 4a, a message's rows holding lists: rows whose records hold lists are read, as the message arrives, by stage 3's decoder into the event's lists (value_take into the rows' list), the event beginning with them; the JSON edge reads an object's array as its record's objects, 64 lists deep at most (items_of: an item's lists encoded first and the item moved down over them); the load no longer refuses them; a hole closed: the decoder read past the end where fewer than 4 bytes were left for a length (size - at - 4 wrapped around), unseen while it read only what was stored; unit test nested_rows (three events, four rows not whole changing nothing); check server_trees (300 random forests over HTTP against a Python model walking them in the same order, a require refusing some, a list field not an array, a field the record lacks and 65 lists deep refused, a restart); counts: b30 against b29 within 0.001% but BFS with batches +0.12%, the bank's queries -0.00% to +0.23%; 027's stage 4 planned in six steps (4a to 4f, the counts it starts from, 4b in two commits) and 4a as made; 008 on nested rows
-rw-r--r--core/module-run.h1
-rw-r--r--core/module.c51
-rw-r--r--core/module.h8
-rw-r--r--core/test-module.c59
-rw-r--r--docs/design/008-module-format.md6
-rw-r--r--docs/design/027-computation-core.md61
-rw-r--r--research/experiments/040-against-c/2026-10-18-counts-s30.jsonl4
-rw-r--r--research/experiments/041-batches/2026-10-18-counts.jsonl10
-rw-r--r--serve/message-json.c66
-rw-r--r--serve/message-json.h6
-rw-r--r--tests/check.py128
11 files changed, 359 insertions, 41 deletions
diff --git a/core/module-run.h b/core/module-run.h
index 4ae94a4..2ad87c6 100644
--- a/core/module-run.h
+++ b/core/module-run.h
@@ -102,6 +102,7 @@ typedef struct {
PureValue *locals; /* the event's locals (a handler's, and what runs in it), or NULL */
bool arguments; /* FIELD reads the arguments given for a view, not the message */
bool modified, exists;
+ bool nested; /* its rows made some of the event's lists in records (027's 4a): the event begins with them */
uint32_t (*spans)[2]; /* a message's variable fields: start and size in the payload */
} Event;
diff --git a/core/module.c b/core/module.c
index 6804a4c..03cc434 100644
--- a/core/module.c
+++ b/core/module.c
@@ -1822,16 +1822,14 @@ PURE_COLD PureResult pure_module_load(const void *bytes, size_t size, PureModule
out->record_field_type[out->record[out->state_ref[i]].first + out->state_key[i] - 1u] == PURE_RECORD_BOOL))) ||
(out->state_type[i] == PURE_STATE_INDEX && !state_of(out, out->state_ref[i], PURE_STATE_LIST)))
REFUSE("state reference", i);
- /* A list in a record is an event's alone (027's stage 2b): no stored, transient or message's list holds one, and no
- * list is keyed by one; a register is no message's rows. */
+ /* No list is keyed by a list in its records; a register is no message's rows. */
for (uint32_t i = 0; i < out->states; ++i) {
if (out->state_type[i] != PURE_STATE_LIST) continue;
if (out->state_key[i] && out->record_field_type[out->record[out->state_ref[i]].first + out->state_key[i] - 1u] == PURE_RECORD_LIST)
REFUSE("list in a record as a key", i);
}
for (uint32_t f = 0; f < out->fields; ++f)
- if (out->field[f].type == PURE_FIELD_ROWS &&
- (out->record_nested[out->state_ref[out->field[f].rows]] || out->state_flags[out->field[f].rows] & PURE_LIST_BOUND >> 8))
+ if (out->field[f].type == PURE_FIELD_ROWS && out->state_flags[out->field[f].rows] & PURE_LIST_BOUND >> 8)
REFUSE("rows field", f);
/* A stored position into what is not stored would come back meaning nothing. */
for (uint32_t i = 0; i < out->states; ++i)
@@ -2518,28 +2516,31 @@ static unsigned char *value_put(const PureApp *app, uint32_t record, uint64_t li
return out;
}
-/* A list value's stored form (bytes) read into a new one of the event's lists of record's items, its lists in fields
- * too: its number (0: none), or 0 with *made false where the bytes are not that form or memory ran out (*bad: they are
- * not). */
-static uint64_t value_take(PureApp *app, uint32_t record, const unsigned char *bytes, size_t size, bool *made, bool *bad) {
+/* A list value's stored form (bytes) read into a new one of the event's lists of record's items (or appended to into, a
+ * message's rows), its lists in fields too: its number (0: none, or into), or 0 with *made false where the bytes are not
+ * that form or memory ran out (*bad: they are not). */
+static uint64_t value_take(PureApp *app, uint32_t record, const unsigned char *bytes, size_t size, PureList *into, bool *made,
+ bool *bad) {
const PureModule *m = app->module;
const PureRecord *r = &m->record[record];
*made = true;
if (!size) return 0;
- uint64_t list = nest(app, record, NULL);
- if (!list) return *made = false, 0;
+ uint64_t list = into ? 0 : nest(app, record, NULL);
+ if (!into && !list) return *made = false, 0;
for (size_t at = 0; at < size;) {
PureCell cells[PURE_RECORD_FIELDS];
for (unsigned f = 0; f < r->count; ++f) {
uint32_t field = r->first + f;
uint8_t type = m->record_field_type[field];
if (type == PURE_RECORD_TEXT || type == PURE_RECORD_LIST) {
- uint32_t length = size - at >= 4 ? read32(bytes + at) : UINT32_MAX;
+ if (size - at < 4) return *bad = true, *made = false, 0;
+ uint32_t length = read32(bytes + at);
if (length > size - at - 4 || (type == PURE_RECORD_TEXT && !pure_utf8_valid((PureSpan){bytes + at + 4, length})))
return *bad = true, *made = false, 0;
cells[f] = (PureCell){.text = (unsigned char *)bytes + at + 4, .size = length};
if (type == PURE_RECORD_LIST) {
- cells[f] = (PureCell){.i64 = (int64_t)value_take(app, nested_record(m, field), bytes + at + 4, length, made, bad)};
+ cells[f] = (PureCell){.i64 = (int64_t)value_take(app, nested_record(m, field), bytes + at + 4, length, NULL, made,
+ bad)};
if (!*made) return 0;
}
at += 4 + length;
@@ -2550,7 +2551,7 @@ static uint64_t value_take(PureApp *app, uint32_t record, const unsigned char *b
if (!pure_cell_fits(pure_record_kind(m, field), cells[f])) return *bad = true, *made = false, 0;
}
}
- PureList *items = app->nested.list[list - 1]; /* (the lists in its fields may have moved the table) */
+ PureList *items = into ? into : app->nested.list[list - 1]; /* (the lists in its fields may have moved the table) */
if (pure_list_insert(items, pure_list_count(items), cells, NULL) != PURE_OK) return *made = false, 0;
if (m->record_nested[record]) share_cells(app, m, record, cells);
}
@@ -2604,7 +2605,8 @@ static __attribute__((cold, noinline)) PureResult kept_value(PureApp *app, uint3
int64_t *out) {
const PureModule *m = app->module;
bool made, bad = false;
- uint64_t value = value_take(app, nested_record(m, m->record[m->state_ref[list]].first + field), cell.text, cell.size, &made, &bad);
+ uint32_t record = nested_record(m, m->record[m->state_ref[list]].first + field);
+ uint64_t value = value_take(app, record, cell.text, cell.size, NULL, &made, &bad);
if (!made) return bad ? PURE_STORE_ERROR : PURE_ALLOCATION;
if (!grows(app, (Held){0, 0}, (Held){0, cell.size})) return PURE_BUDGET;
*out = (int64_t)value;
@@ -5118,7 +5120,7 @@ static PureResult dispatch(PureApp *app, uint32_t event, Event *ev) {
app->grown = app->spent = 0;
app->room = app->memory > PURE_GROWTH_DEFAULT ? app->memory : PURE_GROWTH_DEFAULT;
for (unsigned i = 0; m->held && i < PURE_MODULE_HELD; ++i) app->held[i].size = 0, app->held[i].by_ref = false;
- if (app->nested.count) unbind(app), drop_nested(app); /* a view's body bound some on the app itself */
+ if (app->nested.count && !ev->nested) unbind(app), drop_nested(app); /* a view's body bound some on the app itself */
Mark mark;
mark_event(app, &mark);
memcpy(pending, app->values, sizeof(app->values));
@@ -5310,19 +5312,32 @@ static PureResult message(PureApp *app, uint32_t input, const char *key, int64_t
if (opened != PURE_OK) { app->document = NULL; return opened; }
if (base >= 0 && base != app->revision) { pure_app_finish(app); return PURE_CONFLICT; }
}
- /* Its rows fill their lists (emptied when the event ends); rows that are not whole fail it. */
+ /* Its rows fill their lists (emptied when the event ends); rows that are not whole fail it. Rows whose records hold
+ * lists (027's 4a) read those into the event's lists as a kept list's cell is read, in the event's lists begun anew. */
+ bool nested = false;
for (uint32_t f = 0; f < shape->field_count; ++f) {
const PureField *field = &m->field[shape->field_start + f];
if (field->type != PURE_FIELD_ROWS) continue;
- PureResult filled = pure_list_read(app->lists[field->rows], payload.data + spans[f][0], spans[f][1]);
+ uint32_t record = m->state_ref[field->rows];
+ const unsigned char *rows = payload.data + spans[f][0];
+ PureResult filled;
+ if (m->record_nested[record]) {
+ if (!nested && app->nested.count) unbind(app), drop_nested(app);
+ nested = true;
+ bool made, bad = false;
+ value_take(app, record, rows, spans[f][1], app->lists[field->rows], &made, &bad);
+ filled = made ? PURE_OK : bad ? PURE_STORE_ERROR : PURE_ALLOCATION;
+ } else
+ filled = pure_list_read(app->lists[field->rows], rows, spans[f][1]);
if (filled == PURE_OK) continue;
clear_rows(app);
+ if (nested) drop_nested(app);
pure_app_finish(app);
return filled == PURE_ALLOCATION ? filled : fault(app, PURE_FAULT_LIMIT, UINT32_MAX);
}
app->payload = payload;
Event ev = {.app = app, .shape = shape, .key = key, .base = base, .arena = &app->arena, .exists = app->revision > 0,
- .spans = spans};
+ .nested = nested, .spans = spans};
PureResult result = dispatch(app, 1 + 2 * input, &ev);
if (result != PURE_OK) pure_app_finish(app);
return result;
diff --git a/core/module.h b/core/module.h
index 15109b6..349c4d2 100644
--- a/core/module.h
+++ b/core/module.h
@@ -98,9 +98,11 @@ enum { PURE_MODULE_HELD = 32, PURE_HELD_TEXT = 16, PURE_HELD_APPEND = 1 << 8 };
* an empty one), an i64 in its cell, so moves and reads (ITEM_GET) are an
* integer's; such lists go as the event or a view's body ends. A list is a
* value: storing one in a second cell shares it, and a list shared or bound
- * is copied before it changes (so no other value sees the change). Since
- * stage 3 a stored or a transient list's records may hold lists too (a
- * message's rows not, nor as a key): there the cell holds the value in its
+ * is copied before it changes (so no other value sees the change). A
+ * message's rows may hold lists (027's 4a): each comes in as one of the
+ * event's lists, read from its stored form (below) before the handler runs.
+ * Since stage 3 a stored or a transient list's records may hold lists too
+ * (never as a key): there the cell holds the value in its
* items' stored form (PURE_CELL_LIST), read into one of the event's lists
* as ITEM_GET reads it and written from one as it is set (LIST_APPEND,
* LIST_INSERT, ITEM_SET; APPEND_IN reads, appends and writes back), so
diff --git a/core/test-module.c b/core/test-module.c
index eb6752d..9d925dd 100644
--- a/core/test-module.c
+++ b/core/test-module.c
@@ -6628,6 +6628,64 @@ static void nested_replace(void) {
printf("{\"nested_replace\":{\"lists\":2,\"items\":3}}\n");
}
+/* 027's 4a: a message's rows whose records hold lists. Each node's kids come in as one of the event's lists, read
+ * through a register; the rows and their lists go as the event ends, and rows that are not whole fail it with nothing
+ * changed. */
+static size_t put_node(unsigned char *out, int64_t name, const int64_t *kids, uint32_t count) {
+ memcpy(out, &name, 8);
+ put32(out + 8, 8 * count);
+ for (uint32_t k = 0; k < count; ++k) memcpy(out + 12 + 8 * k, &kids[k], 8);
+ return 12 + 8 * (size_t)count;
+}
+
+static void nested_rows(void) {
+ enum { LC_ = PURE_OP_LIST_COUNT, BIND_ = PURE_OP_LIST_BIND, NODES = 1, KIDS = 2, TOTAL = 3 };
+ static const S nums[] = {{"n", 0}};
+ static const RF leaf_fields[] = {{"v", 3, 0}};
+ static const RF node_fields[] = {{"name", 3, 0}, {"kids", 4, 0}};
+ static const R records[] = {{"Leaf", leaf_fields, 1}, {"Node", node_fields, 2}};
+ static const T others[] = {{"order.nodes", 3 | (uint32_t)PURE_LIST_ROWS << 8, 1},
+ {"let.kids", 3 | (uint32_t)(PURE_LIST_ROWS | PURE_LIST_BOUND) << 8, 0}, {"total", PURE_STATE_I64, 0}};
+ static const F fields[] = {{"nodes", PURE_FIELD_ROWS | NODES << 8}};
+ static const I inputs[] = {{"order", NULL, PURE_KIND_MESSAGE, false, fields, 1}};
+ static const O code[] = {
+ /* 0: for x in order.nodes { total += sum(k.v for k in x.kids) } */
+ {EACH, WALKING(NODES, 10), 0}, {ROW, NODES, 0}, {IG, ITEM(NODES, 1), 0}, {BIND_, KIDS, 0},
+ {L, TOTAL, 0}, {FOLD, FOLDING(PURE_FOLD_SUM | PURE_FOLD_INTEGER, KIDS, 3), 0}, {ROW, KIDS, 0}, {IG, ITEM(KIDS, 0), 0},
+ {C, 1, 1}, {IADD, 0, 0}, {ST, TOTAL, 0},
+ /* 11: n = n * 10 + order.nodes.count */
+ {L, 0, 0}, {C, 0, 10}, {MUL, 0, 0}, {LC_, NODES, 0}, {ADD, 0, 0}, {ST, 0, 0},
+ };
+ H handler = {1, 0, LEN(code)};
+ Spec spec = {nums, 1, inputs, 1, NULL, 0, &handler, 1, code, LEN(code), NULL, 0, others, LEN(others), NULL, 0, NULL, 0,
+ records, 2, NULL, 0};
+ if (load(spec, &module_a) != PURE_OK) { fprintf(stderr, "nested rows: %s\n", error); abort(); }
+ PureApp app;
+ CHECK(pure_app_start(&app, &module_a, (PureData){0}) == PURE_OK);
+ static const int64_t first[] = {7, 9}, second[] = {5};
+ unsigned char payload[64];
+ size_t size = put_node(payload, 1, first, 2);
+ size += put_node(payload + size, 2, second, 1);
+ size += put_node(payload + size, 3, NULL, 0);
+ for (int round = 1; round <= 3; ++round) { /* each event begins with its lists and theirs empty */
+ CHECK(pure_app_message(&app, 0, NULL, -1, (PureSpan){payload, size}) == PURE_OK);
+ CHECK(app.values[TOTAL].i64 == 21 * round && app.values[0].num == (round == 1 ? 3 : round == 2 ? 33 : 333));
+ CHECK(pure_list_count(app.lists[NODES]) == 0 && app.nested.count == 2); /* the third node's kids are none */
+ }
+ /* Not whole: a node's kids past the end, a kid cut short, a node cut short. */
+ const size_t cuts[] = {size - 1, size - 12 - 4, 4};
+ unsigned char broken[64];
+ memcpy(broken, payload, size);
+ put32(broken + 8, 1000);
+ CHECK(pure_app_message(&app, 0, NULL, -1, (PureSpan){broken, size}) == PURE_LIMIT && app.fault == PURE_FAULT_LIMIT);
+ for (size_t k = 0; k < LEN(cuts); ++k)
+ CHECK(pure_app_message(&app, 0, NULL, -1, (PureSpan){payload, cuts[k]}) == PURE_LIMIT && app.fault == PURE_FAULT_LIMIT);
+ CHECK(app.values[TOTAL].i64 == 63 && app.values[0].num == 333 && pure_list_count(app.lists[NODES]) == 0 && !app.nested.count);
+ CHECK(pure_app_message(&app, 0, NULL, -1, (PureSpan){payload, size}) == PURE_OK && app.values[TOTAL].i64 == 84);
+ pure_app_stop(&app);
+ printf("{\"nested_rows\":{\"rounds\":3,\"refused\":%zu}}\n", LEN(cuts) + 1);
+}
+
/* A timer's tick sees the time the host set; floor and a floored modulo
* take it apart. now is for handlers only. */
enum { NOW = PURE_OP_NOW, FLOOR = PURE_OP_FLOOR, MOD = PURE_OP_MOD };
@@ -7007,6 +7065,7 @@ int main(void) {
functions_and_characters();
nested_crashes();
nested_replace();
+ nested_rows();
memory_budget();
work_budget();
fold_mutations();
diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md
index 223bc4f..4ab39f5 100644
--- a/docs/design/008-module-format.md
+++ b/docs/design/008-module-format.md
@@ -32,7 +32,7 @@ v6에는 뒤에 **i64**가 더해졌다([016](016-bool-and-i64.md)의 2단계):
- 레코드 필드의 타입 4(LIST)는 항목들의 레코드를 기본값 자리(i64)에 둔다.
- 칸의 값은 이벤트가 만든 리스트의 번호(0은 빈 리스트)다. 정수처럼 옮겨지고 ITEM_GET으로 읽힌다. 스택의 타입은 리스트이고, 그
레코드를 가진다.
-- 3단계부터 저장 상태와 transient의 리스트도 그런 레코드를 받는다. 메시지의 행은 받지 않고, 리스트 필드는 키가 되지 않는다.
+- 3단계부터 저장 상태와 transient의 리스트도 그런 레코드를 받는다. 4a부터 메시지의 행도 받는다. 리스트 필드는 키가 되지 않는다.
- 이벤트를 넘어 사는 리스트(저장, transient)의 칸은 리스트 값을 그 항목들의 저장 형태로 담는다(바이트처럼 담고, 이미지 머리는 종류 5로
적는다). 그런 칸을 다루는 명령은 로드가 실행기만의 내부 명령으로 바꿔 쓴다(다른 명령의 걸음은 그대로 둔다). 항목마다
필드의 차례로, 수·bool·i64는 8바이트 little-endian, text와 리스트는 u32le 길이와 바이트(리스트는 다시 이 형태)다.
@@ -286,7 +286,9 @@ bool은 실행 중에 수의 칸에 든 0이나 1이고, i64는 같은 8바이�
**메시지의 행.** 행 필드는 가변 필드이고, 바이트는 그 행 목록의 저장 형식(항목마다 필드 순서대로 수는 binary64, text는
u32 길이와 바이트)이다. 메시지가 도착하면 핸들러가 돌기 전에 행 목록을 그 행들로 채우고, 유한하지 않은 수, UTF-8이 아닌
-text, 끝이 맞지 않는 바이트는 메시지를 `limit`으로 실패시킨다. 행 목록은 모든 이벤트가 끝날 때(성공이든 실패든) 비워지고,
+text, 끝이 맞지 않는 바이트는 메시지를 `limit`으로 실패시킨다. 행의 레코드가 리스트를 담으면(027의 4a) 그 필드는 3단계의 칸과 같은
+저장 형식(u32 길이와, 그 레코드의 항목들을 다시 이 형식으로)이고, 도착할 때 이벤트의 리스트 하나로 읽힌다. JSON 경계는 객체
+안의 배열을 그 레코드의 객체들로 읽고, 리스트 64겹까지 받는다. 행 목록은 모든 이벤트가 끝날 때(성공이든 실패든) 비워지고,
저장되지 않으며, 그 변경은 되돌리기 기록에 쌓이지 않는다(비울 것이므로). 그래서 핸들러는 메시지의 행을 보통의 리스트로
읽고(걷기, 집계, `count`), 한 이벤트 안의 임시 표로 쓸 수도 있다. 한 메시지의 모든 행은 한 트랜잭션이다. 행 하나가 실패하면
(없는 키, `require`) 어느 행도 적용되지 않는다.
diff --git a/docs/design/027-computation-core.md b/docs/design/027-computation-core.md
index 09392f3..3c07590 100644
--- a/docs/design/027-computation-core.md
+++ b/docs/design/027-computation-core.md
@@ -210,6 +210,67 @@
- 명령과 상태 종류의 수, 코어의 줄 수가 줄었다(세어 적는다).
- TPC-C와 은행 질의가 그대로다.
+**계획(2026-10-18).** 여섯 걸음으로 나눈다.
+- 걸음마다 지킬 것: Pure, aup, Vega의 검사가 초록이다. 기존 일의 명령 수(041의 다섯 일, 은행 질의)가 막대(1%) 안이다. 걸음마다
+ 커밋한다.
+- 시작의 수(3단계 뒤, 9f17dd6):
+ - 명령 106개
+ - 상태 종류 7개: 수, text, 커서, 리스트, 인덱스, bool, i64
+ - 입력 종류 6개: 키, 메시지, text, 타이머, 출력, 인자. 여기에 프레임 이벤트와 가리킴(`pure_app_point`)이 따로 있다.
+ - 보기 종류 5개: 수, text, 표, bool, i64
+ - 메시지 필드의 타입 7개, 레코드 필드의 타입 5개
+ - 코어(`core/`의 시험이 아닌 파일) 22,398줄, serve 5,086줄
+- **4a. 메시지의 리스트가 중첩을 받는다.**
+ - 메시지의 행은 이미 이벤트의 리스트로 들어온다(let의 리스트와 같은 것). 그 레코드가 리스트를 담게 한다.
+ - 페이로드는 3단계의 저장 형태 그대로다. JSON 경계는 객체 안의 배열을 그 형태로 짓는다.
+ - 3단계가 남긴 것이다. JSON 경계에서 바로 트리로 들어온다.
+ - **만듦(2026-10-18).**
+ - Pure: 메시지가 도착할 때 리스트를 담은 행을 3단계의 해독기로 읽는다. 행마다의 리스트는 이벤트의 리스트가 된다.
+ - serve: JSON 경계가 객체 안의 배열을 그 레코드의 객체들로 짓는다(리스트 64겹까지).
+ - aup: 메시지의 행이 리스트를 담는 것을 받는다. 메시지의 행 안의 리스트에 덧붙이는 것은 거절한다(행은 읽기만 한다).
+ - 찾아 막은 구멍: 3단계의 해독기가 4바이트가 남지 않은 길이를 넘겨 읽었다(`size - at - 4`가 넘쳐 돌았다). 저장한 것만 읽던
+ 3단계에서는 드러나지 않았고, 밖에서 오는 메시지가 드러냈다.
+ - 시험:
+ - 단위 시험 `nested_rows`: 세 번의 이벤트, 온전하지 않은 행 넷이 아무것도 바꾸지 않는다.
+ - Pure의 검사 `server_trees`: 무작위 숲 300개를 JSON으로 보내 같은 차례로 도는 파이썬 모델과 견준다. 거절 셋(리스트
+ 필드가 배열이 아님, 없는 필드, 65겹)과 재시작도 본다.
+ - 명령 수(b30 대 b29): 다섯 일이 묶음 BFS +0.12%(늘 오가는 범위) 말고는 ±0.001% 안이다. 은행 질의(s29 대 s30, 각 두
+ 번)는 −0.00%에서 +0.23%다.
+- **4b. 키별 문서를 없앤다(026의 의심 4).**
+ - 레코드 필드가 바이트를 받는다. 편집기는 `list of Doc by key`와 앱이 가진 리비전으로 쓴다.
+ - Pure에서 컬렉션, DOC·DOC_SIZE·REPLACE, 키 있는 메시지와 기준 리비전, PureData의 문서 콜백, 문서별 묶음 저장이 빠진다. serve의
+ 문서 모드(키마다 앱, ETag와 If-Match)도 빠진다.
+ - 옛 편집기(editor.pure)와의 대조는 같은 뜻의 파이썬 모델과의 대조로 바꾼다.
+ - 두 커밋으로 나눈다.
+ - 4b-1: 레코드 필드의 바이트(Pure, aup). 새 편집기를 키 있는 리스트로 쓰고 모델과 견준다. 옛 문서의 기계는 아직 둔다.
+ - 4b-2: 키별 문서를 지운다. 문서에 기대던 검사는 같은 것을 보는 다른 앱으로 옮긴다.
+ - 파이프라이닝과 묶음 저장(kill -9 포함)은 새 편집기의 입력과 보기로 본다.
+ - 빌려 읽기와 그 예산은 text 상태의 보기로 본다. text 상태도 저장된 조각을 빌려준다.
+ - HTTP 벤치마크의 문서 묶음은 그 계약(axum, drogon과 같은 `/objects`)이 사라지므로 걷는다. 앱 묶음은 남는다.
+ - 잃는 것: 키마다 따로 도는 앱(문서만 가진 모듈이 스레드마다 앱 하나)과 큰 문서의 조각 편집. 키 있는 리스트를 키로 나눠
+ 돌리는 것과 큰 바이트 값의 조각 표현은 뜻을 바꾸지 않고 나중에 고를 수 있는 구현이다.
+- **4c. 커서와 인덱스를 앵커 하나로(의심 3).**
+ - 상태 종류 "앵커"가 text나 리스트 하나를 가리킨다.
+ - 뜻은 그대로다: 편집을 따라가고, 글자의 경계에 서고, 지운 항목에서는 이웃으로 간다.
+ - 같은 일을 하는 커서와 인덱스의 명령은 하나로 합친다.
+- **4d. 입력은 메시지 하나(의심 1).**
+ - 키, text, 타이머, 프레임, 가리킴은 표면과 호스트가 만드는 메시지다.
+ - 모듈의 입력은 메시지(필드들)와 표면이 읽는 묶음(키 이름, text, 주기, 프레임)이다. 묶음을 해석하는 것은 표면이고, Pure는 메시지만
+ 받는다.
+ - `dt`는 프레임 메시지의 필드가 된다. 키의 누름과 뗌은 메시지의 bool 필드다.
+- **4e. 보기는 아무 값(의심 2).**
+ - 보기의 타입은 레코드 필드의 타입이다. 표는 레코드의 리스트를 내는 보기이고, 열은 그 레코드의 필드다.
+ - 게으른 칸 계산과 한 번 훑는 집계는 그 구현으로 남는다. 항목이 리스트를 담을 수 있고, 보기의 본문이 지은 리스트도 낼 수 있다.
+- **4f. 고정 한도를 뺀다(의심 7).**
+ - 상태, 입력, 레코드, 필드, 중첩의 수를 모듈에 맞춰 잡는다. 비트 하나씩 쓰던 집합은 길이를 가진다.
+ - aup 소스의 32 KB도 뺀다.
+ - 앞의 걸음들이 표를 바꾸므로 마지막에 한다.
+- **차례의 까닭.**
+ - 4a는 작고, 3단계의 끝을 맺는다.
+ - 4b는 가장 많이 지운다. 키 있는 메시지와 기준 리비전이 사라지면 4d가 단순해진다.
+ - 4c는 가리킴(4d)이 무엇을 옮기는지 정한다.
+ - 4e는 표면(Vega)이 읽는 쪽을 바꾸므로, 입력 쪽(4d)과 따로 잰다.
+
### 5단계: 수의 모델
- 정수 리터럴의 기본, `/`와 `//`, 돈의 십진수, 묶음 실행기의 2^53 검사를 정한다.
diff --git a/research/experiments/040-against-c/2026-10-18-counts-s30.jsonl b/research/experiments/040-against-c/2026-10-18-counts-s30.jsonl
new file mode 100644
index 0000000..8d5d46e
--- /dev/null
+++ b/research/experiments/040-against-c/2026-10-18-counts-s30.jsonl
@@ -0,0 +1,4 @@
+{"serve": "pure-serve", "per_branch": {"instructions": 27230405, "cycles": 12221300}, "in_credit": {"instructions": 3545992, "cycles": 1675191}, "large": {"instructions": 1423198, "cycles": 622136}, "ranks": {"instructions": 24536813, "cycles": 14895547}, "none": {"instructions": 617123, "cycles": 193648}, "field": {"instructions": 9028796, "cycles": 3338189}, "found": {"instructions": 14179417, "cycles": 10655083}}
+{"serve": "pure-serve", "per_branch": {"instructions": 26908242, "cycles": 11686470}, "in_credit": {"instructions": 3473397, "cycles": 1676065}, "large": {"instructions": 1423286, "cycles": 682081}, "ranks": {"instructions": 24358211, "cycles": 14718864}, "none": {"instructions": 617123, "cycles": 196214}, "field": {"instructions": 9028796, "cycles": 3314507}, "found": {"instructions": 14182593, "cycles": 10595866}}
+{"serve": "pure-serve", "per_branch": {"instructions": 26952627, "cycles": 11776386}, "in_credit": {"instructions": 3483449, "cycles": 1583305}, "large": {"instructions": 1423246, "cycles": 674235}, "ranks": {"instructions": 24382297, "cycles": 15052012}, "none": {"instructions": 617123, "cycles": 190875}, "field": {"instructions": 9028796, "cycles": 3351305}, "found": {"instructions": 14180762, "cycles": 10924627}}
+{"serve": "pure-serve", "per_branch": {"instructions": 27346357, "cycles": 12085410}, "in_credit": {"instructions": 3572085, "cycles": 1636879}, "large": {"instructions": 1423174, "cycles": 613836}, "ranks": {"instructions": 24601158, "cycles": 14958524}, "none": {"instructions": 617115, "cycles": 192553}, "field": {"instructions": 9028891, "cycles": 3345141}, "found": {"instructions": 14180354, "cycles": 10664594}}
diff --git a/research/experiments/041-batches/2026-10-18-counts.jsonl b/research/experiments/041-batches/2026-10-18-counts.jsonl
index 6db0072..04c6b37 100644
--- a/research/experiments/041-batches/2026-10-18-counts.jsonl
+++ b/research/experiments/041-batches/2026-10-18-counts.jsonl
@@ -48,3 +48,13 @@
{"version":"b29","way":"rounds","work":"join","instructions":7670274118,"cycles":3526186126}
{"version":"b29","way":"batches","work":"bfs","instructions":3364524544,"cycles":1644560620}
{"version":"b29","way":"rounds","work":"bfs","instructions":4723942606,"cycles":2096987224}
+{"version":"b30","way":"batches","work":"matmul","instructions":713395456,"cycles":228717396}
+{"version":"b30","way":"rounds","work":"matmul","instructions":21265247853,"cycles":7438769118}
+{"version":"b30","way":"batches","work":"knapsack","instructions":348634134,"cycles":111296108}
+{"version":"b30","way":"rounds","work":"knapsack","instructions":21881636299,"cycles":8100998092}
+{"version":"b30","way":"batches","work":"roots","instructions":157303671,"cycles":109698296}
+{"version":"b30","way":"rounds","work":"roots","instructions":2719817880,"cycles":1109694376}
+{"version":"b30","way":"batches","work":"join","instructions":1783720564,"cycles":1011535653}
+{"version":"b30","way":"rounds","work":"join","instructions":7670273852,"cycles":3502744585}
+{"version":"b30","way":"batches","work":"bfs","instructions":3368525625,"cycles":1640099773}
+{"version":"b30","way":"rounds","work":"bfs","instructions":4723944330,"cycles":2101713847}
diff --git a/serve/message-json.c b/serve/message-json.c
index 0905b34..f0f965c 100644
--- a/serve/message-json.c
+++ b/serve/message-json.c
@@ -293,15 +293,15 @@ static bool rows_room(PureJsonMessage *b, size_t more) {
return true;
}
-/* A rows field's array (at its opening bracket): each an object of its
- * record's fields by name, a number or a string, a field left out taking its
- * default; encoded one after another as the list's stored form (a number's
- * binary64 bits, a text's u32le length and bytes). */
-static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size, size_t *at, uint32_t f, unsigned depth) {
+/* An array (at its opening bracket) of objects of a record's fields by name: a number, a string, or an array of the
+ * record a list field holds (027's 4a), a field left out taking its default; encoded one after another into rows as
+ * the list's stored form (a number's binary64 bits, a text's u32le length and bytes, a list's u32le length and its
+ * items so). An item's lists are encoded first and the item after them, moved down over them at its end. */
+static const char *items_of(PureJsonMessage *b, unsigned char *json, size_t size, size_t *at, uint32_t record_index,
+ unsigned depth) {
const PureModule *m = b->module;
- const PureField *field = &m->field[b->shape->field_start + f];
- const PureRecord *record = &m->record[m->state_ref[field->rows]];
- size_t start = b->rows_size;
+ const PureRecord *record = &m->record[record_index];
+ if (depth >= 64) return "limit"; /* 64 lists deep at most */
if (*at == size || json[(*at)++] != '[') return "json_type";
*at = pure_json_space(json, *at, size);
for (bool first = true; *at < size && json[*at] != ']'; first = false) {
@@ -312,7 +312,9 @@ static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size,
if (*at == size || json[(*at)++] != '{') return "json_type";
PureValue numbers[PURE_RECORD_FIELDS]; /* a number's, a bool's or an i64's */
PureSpan texts[PURE_RECORD_FIELDS];
+ size_t lists[PURE_RECORD_FIELDS][2]; /* a list's stored form: where in rows, and its bytes */
bool given[PURE_RECORD_FIELDS] = {false};
+ size_t item = b->rows_size;
*at = pure_json_space(json, *at, size);
for (bool member = true; *at < size && json[*at] != '}'; member = false) {
if (!member) {
@@ -340,6 +342,16 @@ static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size,
given[k] = true;
uint8_t type = m->record_field_type[record->first + k];
bool text = type == PURE_RECORD_TEXT, truth;
+ if (type == PURE_RECORD_LIST) {
+ size_t from = b->rows_size;
+ const char *why = items_of(b, json, size, at, (uint32_t)m->record_field_default[record->first + k].i64,
+ depth + 1);
+ if (why) return why;
+ lists[k][0] = from;
+ lists[k][1] = b->rows_size - from;
+ *at = pure_json_space(json, *at, size);
+ continue;
+ }
if (type == PURE_RECORD_BOOL) { /* true or false, nothing else */
size_t word = json_bool(json + *at, size - *at, &truth);
if (!word) return "json_type";
@@ -370,16 +382,24 @@ static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size,
}
if (*at == size) return "json";
++*at;
+ size_t lists_end = b->rows_size;
for (unsigned k = 0; k < record->count; ++k) {
uint32_t at_field = record->first + k;
- if (m->record_field_type[at_field] == PURE_RECORD_TEXT) {
- uint32_t constant = (uint32_t)m->record_field_default[at_field].num;
- PureSpan text = given[k] ? texts[k]
- : (PureSpan){m->constant_data + m->constant_offset[constant], m->constant_length[constant]};
- if (!rows_room(b, 4 + text.size)) return "memory";
- write_u32(b->rows + b->rows_size, (uint32_t)text.size);
- if (text.size) memcpy(b->rows + b->rows_size + 4, text.data, text.size);
- b->rows_size += 4 + text.size;
+ uint8_t type = m->record_field_type[at_field];
+ if (type == PURE_RECORD_TEXT || type == PURE_RECORD_LIST) {
+ PureSpan text = {0};
+ size_t length = type == PURE_RECORD_LIST ? (given[k] ? lists[k][1] : 0) : 0;
+ if (type == PURE_RECORD_TEXT) {
+ uint32_t constant = (uint32_t)m->record_field_default[at_field].num;
+ text = given[k] ? texts[k]
+ : (PureSpan){m->constant_data + m->constant_offset[constant], m->constant_length[constant]};
+ length = text.size;
+ }
+ if (length > UINT32_MAX || !rows_room(b, 4 + length)) return "memory";
+ write_u32(b->rows + b->rows_size, (uint32_t)length);
+ if (type == PURE_RECORD_LIST && length) memcpy(b->rows + b->rows_size + 4, b->rows + lists[k][0], length);
+ else if (length) memcpy(b->rows + b->rows_size + 4, text.data, length);
+ b->rows_size += 4 + length;
} else {
uint64_t bits = (uint64_t)(given[k] ? numbers[k] : m->record_field_default[at_field]).i64; /* its bits */
if (!rows_room(b, 8)) return "memory";
@@ -387,9 +407,23 @@ static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size,
b->rows_size += 8;
}
}
+ if (lists_end > item) { /* the item over its lists' forms */
+ memmove(b->rows + item, b->rows + lists_end, b->rows_size - lists_end);
+ b->rows_size -= lists_end - item;
+ }
}
if (*at == size) return "json";
++*at;
+ return NULL;
+}
+
+/* A rows field's array, encoded so (items_of) and placed as the table says. */
+static const char *rows_of(PureJsonMessage *b, unsigned char *json, size_t size, size_t *at, uint32_t f, unsigned depth) {
+ const PureModule *m = b->module;
+ const PureField *field = &m->field[b->shape->field_start + f];
+ size_t start = b->rows_size;
+ const char *why = items_of(b, json, size, at, m->state_ref[field->rows], depth);
+ if (why) return why;
if (b->rows_base + b->rows_size > UINT32_MAX >> 2) return "limit";
write_u32(b->table + 8 * f, (uint32_t)(b->rows_base + start));
write_u32(b->table + 8 * f + 4, (uint32_t)(b->rows_size - start));
diff --git a/serve/message-json.h b/serve/message-json.h
index ffb1651..45fae92 100644
--- a/serve/message-json.h
+++ b/serve/message-json.h
@@ -16,14 +16,16 @@ typedef struct {
bool seen[PURE_MODULE_FIELDS];
/* A rows field's array of objects is encoded in the stored form of its
* list into rows (grown with malloc: the caller frees it), which the
- * caller places from rows_base on in the payload, as the table says. */
+ * caller places from rows_base on in the payload, as the table says. A
+ * list field of an object is an array of its record's objects so, 64
+ * lists deep at most. */
unsigned char *rows;
size_t rows_size, rows_capacity, rows_base;
} PureJsonMessage;
/* The object at json[*at] (its opening brace), *at moved past it: NULL, or
* what is wrong ("json", "json_field": unknown or given twice, "json_type",
- * "limit": rows past what a payload holds, "memory"). */
+ * "limit": rows past what a payload holds or 64 lists deep, "memory"). */
const char *pure_json_message(PureJsonMessage *, unsigned char *json, size_t size, size_t *at);
size_t pure_json_space(const unsigned char *json, size_t at, size_t size);
/* A fixed field's value from its text (a query value or a JSON value) into
diff --git a/tests/check.py b/tests/check.py
index ed2a5c9..52aa17d 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -3637,6 +3637,133 @@ def server_rows(aup, pure, work):
'orders_applied': applied, 'orders_refused': refused, 'logged': len(log)}
+TREES = '''
+record Node {
+ name: text
+ n = 0
+ kids: list of Node
+}
+state nodes = 0
+state total = 0
+state deepest = 0
+state name_bytes = 0
+input tree = message {
+ label: text
+ roots: list of Node
+}
+
+fn walk(x, depth) {
+ nodes = nodes + 1
+ total = total + x.n
+ name_bytes = name_bytes + x.name.bytes
+ if depth > deepest { deepest = depth }
+ for k in x.kids { walk(k, depth + 1) }
+}
+
+on tree {
+ require tree.label != "refuse"
+ for r in tree.roots { walk(r, 1) }
+}
+
+view count = nodes
+view sum = total
+view depth = deepest
+view named = name_bytes
+'''
+
+
+def server_trees(aup, pure, work):
+ """A message's rows whose records hold lists (027's 4a): a JSON forest
+ read straight into the event's lists, nested to any depth up to 64, and
+ walked by a function that calls itself. Random forests (names with
+ escapes and characters past ASCII, fields left out taking their
+ defaults) against a model walking them in the same order; a require
+ refusing one leaves everything as it was; malformed trees (a list field
+ not an array, a field the record lacks, past 64 deep) are refused before
+ the app sees them; a restart keeps the states and no rows."""
+ import http.client
+ import random
+ source, module = work / 'trees.aup', work / 'trees.pmod'
+ source.write_text(TREES)
+ compile_module(aup, source, module)
+ store = work / 'trees-store'
+
+ def call(port, method, path, body=None):
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=60)
+ try:
+ connection.request(method, path, body, {'Content-Type': 'application/json'} if body is not None else {})
+ answer = connection.getresponse()
+ return answer.status, answer.getheader('X-Pure-Error'), answer.read()
+ finally:
+ connection.close()
+
+ def views(port):
+ return tuple(float(call(port, 'GET', f'/views/{v}')[2]) for v in ('count', 'sum', 'depth', 'named'))
+
+ rng = random.Random(44)
+ names = ['', 'a', 'node', '빨리', 'a "quoted" \\ name', 'tab\there', '😀', 'line\nbreak', 'é']
+
+ def forest(depth):
+ roots = []
+ for _ in range(rng.randrange(4 if depth < 6 else 1)):
+ node = {'name': rng.choice(names)}
+ if rng.random() < 0.8:
+ node['n'] = rng.randrange(-1000, 1000) / 8
+ kids = forest(depth + 1)
+ if kids or rng.random() < 0.5:
+ node['kids'] = kids
+ roots.append(node)
+ return roots
+
+ model = [0, 0.0, 0, 0]
+
+ def walk(node, depth, into):
+ into[0] += 1
+ into[1] += node.get('n', 0)
+ into[2] = max(into[2], depth)
+ into[3] += len(node['name'].encode())
+ for k in node.get('kids', []):
+ walk(k, depth + 1, into)
+
+ host = HttpHost([pure / 'build/pure-serve', '--module', module, '--store', store])
+ sent = refused = 0
+ try:
+ for i in range(300):
+ roots = forest(1)
+ label = 'refuse' if rng.random() < 0.1 else 'tree'
+ after = list(model)
+ for r in roots:
+ walk(r, 1, after)
+ status, error, _ = call(host.port, 'POST', '/inputs/tree', json.dumps({'label': label, 'roots': roots}).encode())
+ if label == 'refuse':
+ assert (status, error) == (422, 'limit'), (status, error) # require fails as limit
+ refused += 1
+ else:
+ assert (status, error) == (200, 'none'), (status, error)
+ model = after
+ sent += 1
+ if i % 25 == 0:
+ assert views(host.port) == tuple(model), (views(host.port), model)
+ deep = {'name': 'z'}
+ for _ in range(63):
+ deep = {'name': 'z', 'kids': [deep]}
+ for body, want in [({'label': 'x', 'roots': [{'name': 'a', 'kids': 3}]}, (400, 'json_type')),
+ ({'label': 'x', 'roots': [{'name': 'a', 'kid': []}]}, (400, 'json_field')),
+ ({'label': 'x', 'roots': [{'name': 'a', 'kids': [deep]}]}, (400, 'limit'))]:
+ assert call(host.port, 'POST', '/inputs/tree', json.dumps(body).encode())[:2] == want, body
+ assert call(host.port, 'POST', '/inputs/tree', json.dumps({'label': 'x', 'roots': [deep]}).encode())[:2] == (200, 'none')
+ walk(deep, 1, model)
+ assert views(host.port) == tuple(model) and model[2] == 64
+ finally:
+ host.stop()
+ host = HttpHost([pure / 'build/pure-serve', '--module', module, '--store', store])
+ try:
+ assert views(host.port) == tuple(model)
+ finally:
+ host.stop()
+ assert sent > 200 and refused > 10
+ return {'forests': sent, 'refused': refused, 'nodes': model[0], 'deepest': model[2]}
+
ARGUED = '''
record Account {
aid = 0
@@ -6332,6 +6459,7 @@ def main():
report['server_surface'] = server_surface(aup, pure, work)
report['server_queries'] = server_queries(aup, pure, work)
report['server_rows'] = server_rows(aup, pure, work)
+ report['server_trees'] = server_trees(aup, pure, work)
report['server_arguments'] = server_arguments(aup, pure, work)
report['server_answers'] = server_answers(aup, pure, work)
report['server_answers_memory'] = server_answers_memory(aup, pure, work)