aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-10-19 02:48:58 +0100
committereunhhu <[email protected]>2026-10-19 02:48:58 +0100
commit7d41626b1c8f343b5b59a35ea3bce6570b996213 (patch)
tree3f006bc4fdaeeb63be6a27996a86d464689caec2
parent46935dc0cc4711ef58fcc5129020f75c5e4e098e (diff)
downloadpure-7d41626b1c8f343b5b59a35ea3bce6570b996213.tar.gz
pure-7d41626b1c8f343b5b59a35ea3bce6570b996213.zip
140's 1, DNS messages (RFC 1035), written in aup with no new opcode (aup's examples/dns.aup: a message read by a handler into its header and records, names uncompressed in their wire form, those in NS, CNAME, PTR, MX, SOA and SRV data too, each pointer pointing before the last; written back by the view wire, the owners compressed): 400 messages dnspython made (148,582 bytes, 3,466 records: A, AAAA, NS, CNAME, PTR, MX, SOA, TXT, SRV, EDNS, shared suffixes, 63-byte labels, bytes above 0x7f, 245-byte names) read on the three paths as dnspython reads them (each section's records as a set) and written back to bytes dnspython reads as the same message, the paths' outputs the same bytes; 300 damaged messages as dns_message.py (the Python reference) reads them, 185 refused by both and 115 the same records and bytes; module_specs in make check: 200 of those messages (tests/specs/dns, no dnspython at check time) read on the three paths, written back and read again to the same state and bytes, 23 cut ones failing; dnspython shuffles a set's records unless want_shuffle is off, and merges a set's differing TTLs to the least; bars: written, two workarounds both the core's (bytes have no ==: TEXT_COMPARE takes text alone; a view calls no block fn: CALL is in handlers and functions alone), 115 lines against Python's 72, 244 ns a message byte read and written against the same algorithm in C 3.65 (67x), glibc's resolver reading alone 4.4, pure Python 131 (Pure 1.9x slower), 218 compiled; 63.6 instructions a byte, reading 42%, writing 58%; CHECKPOINT's direction row
-rw-r--r--CHECKPOINT.md2
-rw-r--r--docs/research/140-spec-suite.md72
-rw-r--r--research/experiments/044-specs/2026-10-19-dns.jsonl5
-rw-r--r--research/experiments/044-specs/dns_check.py166
-rw-r--r--research/experiments/044-specs/dns_message.py101
-rw-r--r--research/experiments/044-specs/dns_time.c255
-rw-r--r--tests/check.py34
-rw-r--r--tests/specs/dns/README.md8
-rw-r--r--tests/specs/dns/messages.binbin0 -> 78578 bytes
9 files changed, 639 insertions, 4 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md
index 450acc7..72775c4 100644
--- a/CHECKPOINT.md
+++ b/CHECKPOINT.md
@@ -26,7 +26,7 @@
| 표 크기의 상태 | 목록이 영속 B+트리다(변경·키 탐색·저장본 위치 O(log n), 스냅숏 O(1), 1,600만 항목, 런타임 상태 1 GiB). pgbench의 TPC-B 같은 이체(세 표의 행과 이력, 한 트랜잭션)가 10만 계좌에서 RAM 24.5k/s(전: 3만 계좌 3.2k/s), SD 카드 내구 1,339/s(SQLite WAL FULL 84–153/s) | [090](docs/research/090-tables.md) |
| 일반 트랜잭션 | TPC-C의 다섯 트랜잭션을 aup 앱 하나로 쓴다(여러 표 읽기·쓰기, 조각 훑기, 중복 없는 셈, 실패하면 통째 되돌림). 같은 입력에서 SQLite와 아홉 표의 모든 행이 같다(창고 하나와 넷). RAM 초당 40,976건(W=4 35,838; 조율한 SQLite 10,164, 트랜잭션당 CPU 43–50 µs, HTTP·JSON 포함), SD 카드 내구 4,980건(W=4 4,590). SQLite도 카드에 맞게 조율하고 묶어 커밋하면 739건(W=4 426)이다. 남은 차이는 거래당 쓰는 바이트다(SQLite는 페이지째 써서 8–18배) | [092](docs/research/092-tpcc.md), [106](docs/research/106-tpcc-conditions-first.md), [107](docs/research/107-snapshot-nodes-reused.md) |
| 질의 | 질의는 앱의 상태 위에 보기를 더한 모듈이다(`aup query`, `PUT·GET /queries/이름`). 검증한 뒤 스냅숏에서 돌고, 열 안의 fold가 그 행을 읽는다(상관 부질의). 잎이 항목을 필드별로 들고, fold와 필터는 잎 하나씩 돌며(결함이 날 잎만 항목별로), 열 안의 그룹·순위 fold는 목록을 한 번 지나 모든 행에 답한다. 10만 계좌에서 지점별 합계 4.9 ms(SQLite 45, 인덱스를 주면 14.3), 조인 0.48 ms(1.47), 자기 조인 순위 6.8 ms(2,840, 인덱스를 주면 7.6). Pure에는 인덱스 선언이 없다 | [091](docs/research/091-queries.md) |
-| 방향(1.0 전) | 앱 계약을 1.0에서 얼릴 후보로 적었다: 이벤트는 전함수이고 원자적이며 결정적이다, 시간은 입력이다, 보기는 커밋된 상태의 순수 함수다, 보이는 역사는 커밋의 앞부분이다, 전달은 순서대로 한 번이다. 계산의 언어(명령 90개, 상태 종류 7개)는 패턴의 모음이라 다시 세운다. 분야마다 고른 명세 열한 개의 묶음에서 오늘의 aup는 바이트, 비트 연산, 중첩, 재귀, 갈래, 글자 읽기에 막힌다(탐침 열 개). 1.0 전에는 호환을 챙기지 않고, JSON과 HTTP는 경계에 남는다. 계산의 핵을 다섯 단계로 다시 세운다(027). 1단계(비트 연산, 자리의 바이트, 수의 리스트, 한 번에 주기)로 aup로 쓴 SHA-256이 공식 벡터를 세 실행 길에서 모두 지난다. 2a(형식 지정, 바이트 잇기와 자르기, 바이트와 text의 let, `while`, 단락 평가의 `&&`·`||`)로 쓴 JSON 검사기가 JSONTestSuite에서 하나도 틀리지 않는다. 2b(레코드 안의 레코드, 갈래와 `match`, 레코드 안의 리스트, 수 읽기)로 JSON을 트리로 읽어 다이제스트가 파이썬과 같다(JSONTestSuite, 무작위 문서 300개). 2c(스스로 부르는 함수: 부름마다 제 스택·지역 칸·리스트·레지스터·담은 값, 깊이는 표로 센 프레임 바이트가 몫에서 묶음; 값으로서의 레코드; 글자 읽기)로 JSON 트리를 재귀 하강으로 다시 써 JSONTestSuite와 무작위 문서 300개가 파이썬과 같고, 10만 겹은 `memory`로 거절한다. 반복 아래에 값을 둔 모듈이 검증을 지나 실행기를 죽이던 구멍도 찾아 막았다. 3단계(중첩한 저장 상태: 저장과 transient의 리스트가 리스트를 담은 레코드를 받고, 그 칸은 값을 항목들의 저장 형태로 바이트처럼 담아 이미지·로그·되돌리기·회복이 그대로다)로 열 → 카드 → 태그의 보드가 메모리 앱과 여덟 번 재시작하는 저장 앱에서 매 걸음 같고(스냅숏 2,400개), 커밋의 여섯 단계 어디서 죽여도 전부 아니면 없음이며, 교체는 이름으로 깊이까지 옮긴다. 중첩 리스트를 바꾸는 비용은 그 값의 크기에 비례한다(이미 있는 항목 하나에 170 ns). 줄 수는 검사기만 파이썬 이하이고(80 대 90), SHA-256(42 대 28)과 트리(142 대 105)는 길다. 시간은 C의 40–159배이고 바이트를 다루는 일은 순수 파이썬과 비슷하거나 느리다(0.43–1.6배): 핵이 선 뒤 빠르게 할 과녁이다. 4단계(패턴 모으기)로 메시지의 행이 중첩을 받고, 키별 문서가 키 있는 리스트에 녹고, 커서와 인덱스가 앵커 하나가 되고, 키·text·타이머·프레임·가리킴이 묶인 메시지가 되고, 보기가 아무 값(리스트 보기: 상태 그대로, 걸러짐, 지도, 본문이 지은 리스트; 바이트)이 되고, 고정 한도가 형식과 리스트 엔진의 너비만 남았다: 명령 106 → 95, 상태 종류 7 → 6, 입력 종류 6 → 3, 그동안 다섯 일의 명령 수와 은행 질의는 걸음마다 1% 안, TPC-C는 3단계 끝과 같다(+0.37%). 5단계(수의 모델: number는 binary64로 리터럴과 셈의 기본, i64는 정확한 정수·비트·돈, `/`와 내림한 몫 `//`, i64의 합은 정확하게, 돈은 i64의 센트로 `.2d`에 보이고, 교체는 정확할 때만 옮긴다)로 TPC-C의 돈과 비율이 Pure와 SQLite 모두에서 정수가 되어, 2만 트랜잭션 뒤 모든 행이 오차 없이 같다: 명령 95 → 96, 다섯 일의 명령 수와 은행 질의는 4단계 끝과 1% 안, TPC-C는 런타임이 같고(−0.03%) i64의 돈으로 앱이 2.6% 빠르다. 027의 다섯 단계를 마쳤다. 그 뒤 묶음의 3(inflate)을 새 명령 없이 썼다: zlib가 만든 스트림 640개와 손상된 240개가 파이썬 참조와 같지만, 우회가 셋(리스트를 받지 않는 fn, 바이트 하나인 상태, 블록 안의 리스트)이고 줄은 파이썬의 1.6배, 시간은 같은 알고리즘의 C의 108배다 | [026](docs/design/026-app-contract.md), [027](docs/design/027-computation-core.md), [140](docs/research/140-spec-suite.md) |
+| 방향(1.0 전) | 앱 계약을 1.0에서 얼릴 후보로 적었다: 이벤트는 전함수이고 원자적이며 결정적이다, 시간은 입력이다, 보기는 커밋된 상태의 순수 함수다, 보이는 역사는 커밋의 앞부분이다, 전달은 순서대로 한 번이다. 계산의 언어(명령 90개, 상태 종류 7개)는 패턴의 모음이라 다시 세운다. 분야마다 고른 명세 열한 개의 묶음에서 오늘의 aup는 바이트, 비트 연산, 중첩, 재귀, 갈래, 글자 읽기에 막힌다(탐침 열 개). 1.0 전에는 호환을 챙기지 않고, JSON과 HTTP는 경계에 남는다. 계산의 핵을 다섯 단계로 다시 세운다(027). 1단계(비트 연산, 자리의 바이트, 수의 리스트, 한 번에 주기)로 aup로 쓴 SHA-256이 공식 벡터를 세 실행 길에서 모두 지난다. 2a(형식 지정, 바이트 잇기와 자르기, 바이트와 text의 let, `while`, 단락 평가의 `&&`·`||`)로 쓴 JSON 검사기가 JSONTestSuite에서 하나도 틀리지 않는다. 2b(레코드 안의 레코드, 갈래와 `match`, 레코드 안의 리스트, 수 읽기)로 JSON을 트리로 읽어 다이제스트가 파이썬과 같다(JSONTestSuite, 무작위 문서 300개). 2c(스스로 부르는 함수: 부름마다 제 스택·지역 칸·리스트·레지스터·담은 값, 깊이는 표로 센 프레임 바이트가 몫에서 묶음; 값으로서의 레코드; 글자 읽기)로 JSON 트리를 재귀 하강으로 다시 써 JSONTestSuite와 무작위 문서 300개가 파이썬과 같고, 10만 겹은 `memory`로 거절한다. 반복 아래에 값을 둔 모듈이 검증을 지나 실행기를 죽이던 구멍도 찾아 막았다. 3단계(중첩한 저장 상태: 저장과 transient의 리스트가 리스트를 담은 레코드를 받고, 그 칸은 값을 항목들의 저장 형태로 바이트처럼 담아 이미지·로그·되돌리기·회복이 그대로다)로 열 → 카드 → 태그의 보드가 메모리 앱과 여덟 번 재시작하는 저장 앱에서 매 걸음 같고(스냅숏 2,400개), 커밋의 여섯 단계 어디서 죽여도 전부 아니면 없음이며, 교체는 이름으로 깊이까지 옮긴다. 중첩 리스트를 바꾸는 비용은 그 값의 크기에 비례한다(이미 있는 항목 하나에 170 ns). 줄 수는 검사기만 파이썬 이하이고(80 대 90), SHA-256(42 대 28)과 트리(142 대 105)는 길다. 시간은 C의 40–159배이고 바이트를 다루는 일은 순수 파이썬과 비슷하거나 느리다(0.43–1.6배): 핵이 선 뒤 빠르게 할 과녁이다. 4단계(패턴 모으기)로 메시지의 행이 중첩을 받고, 키별 문서가 키 있는 리스트에 녹고, 커서와 인덱스가 앵커 하나가 되고, 키·text·타이머·프레임·가리킴이 묶인 메시지가 되고, 보기가 아무 값(리스트 보기: 상태 그대로, 걸러짐, 지도, 본문이 지은 리스트; 바이트)이 되고, 고정 한도가 형식과 리스트 엔진의 너비만 남았다: 명령 106 → 95, 상태 종류 7 → 6, 입력 종류 6 → 3, 그동안 다섯 일의 명령 수와 은행 질의는 걸음마다 1% 안, TPC-C는 3단계 끝과 같다(+0.37%). 5단계(수의 모델: number는 binary64로 리터럴과 셈의 기본, i64는 정확한 정수·비트·돈, `/`와 내림한 몫 `//`, i64의 합은 정확하게, 돈은 i64의 센트로 `.2d`에 보이고, 교체는 정확할 때만 옮긴다)로 TPC-C의 돈과 비율이 Pure와 SQLite 모두에서 정수가 되어, 2만 트랜잭션 뒤 모든 행이 오차 없이 같다: 명령 95 → 96, 다섯 일의 명령 수와 은행 질의는 4단계 끝과 1% 안, TPC-C는 런타임이 같고(−0.03%) i64의 돈으로 앱이 2.6% 빠르다. 027의 다섯 단계를 마쳤다. 그 뒤 묶음의 3(inflate)을 새 명령 없이 썼다: zlib가 만든 스트림 640개와 손상된 240개가 파이썬 참조와 같지만, 우회가 셋(리스트를 받지 않는 fn, 바이트 하나인 상태, 블록 안의 리스트)이고 줄은 파이썬의 1.6배, 시간은 같은 알고리즘의 C의 108배다. 묶음의 1(DNS 메시지)도 새 명령 없이 썼다: dnspython이 만든 메시지 400개를 읽고 다시 써 dnspython이 같게 읽고, 손상된 300개가 파이썬 참조와 같다. 우회는 둘(바이트의 `==`, 보기에서 부르는 블록 fn)이고 둘 다 핵의 일이며, 줄은 파이썬의 1.6배, 시간은 C의 67배(파이썬보다 1.9배 느림)다 | [026](docs/design/026-app-contract.md), [027](docs/design/027-computation-core.md), [140](docs/research/140-spec-suite.md) |
키로 나눈 소유자와 저장을 넘길 스레드는 하지 않기로 했다. 이 장치는 동기화를 한 줄로 세워, 나누면 쓰기가 23% 줄고 넘겨도
기기의 일은 줄지 않는다([084](docs/research/084-parallel-syncs.md)). 다만 동기화가 공짜인 RAM에서 TPC-C는 소유자 하나가
diff --git a/docs/research/140-spec-suite.md b/docs/research/140-spec-suite.md
index 6bb2138..4dfffdb 100644
--- a/docs/research/140-spec-suite.md
+++ b/docs/research/140-spec-suite.md
@@ -28,9 +28,12 @@
- **직접 확인할 것.** `research/experiments/044-specs/run.sh`가 탐침을 컴파일하고 진단을 낸다.
- **다음 결정.** 계산의 핵을 설계한다. 여섯 가지와 고정 한도, 상수 시간 암호가 핵이 받아야 할 최소의 후보다.
- **027 뒤(2026-10-19).** 핵을 다섯 단계로 다시 세웠다. 탐침의 필요는 셋을 빼고 모두 컴파일된다: 보기에서 재귀 fn을
- 부르기, 레코드나 갈래 하나인 상태, 커서 자리의 글자(아래 "027 뒤의 탐침"). 묶음에서 쓴 것은 2(SHA-256), 4(JSON), 3(inflate)이다.
+ 부르기, 레코드나 갈래 하나인 상태, 커서 자리의 글자(아래 "027 뒤의 탐침"). 묶음에서 쓴 것은 2(SHA-256), 4(JSON), 3(inflate),
+ 1(DNS)이다.
inflate는 새 명령 없이 써졌고 zlib의 스트림을 모두 맞게 푼다. 다만 우회가 셋(리스트를 받는 fn, 바이트 하나인 상태, 블록 안의
리스트)이고, 줄은 파이썬의 1.6배, 시간은 같은 알고리즘의 C의 108배다.
+ DNS도 새 명령 없이 써졌고 dnspython의 메시지를 모두 맞게 읽고 다시 쓴다. 우회는 둘(바이트의 `==`, 보기에서의 블록 fn)이고,
+ 보기에서 블록 fn을 부르지 못하는 틈에 써서 닿은 것은 DNS가 처음이다(배치는 탐침뿐). 줄은 파이썬의 1.6배, 시간은 C의 67배다.
## 묶음
@@ -42,7 +45,7 @@
| # | 명세 | 분야 | 처음 필요한 것 | Pure 안의 검증 | 10-13 | 027 뒤(10-19) |
|---|---|---|---|---|---|---|
-| 1 | DNS 메시지(RFC 1035) | 와이어 프로토콜 | 바이트 읽고 쓰기, 빅엔디언, 비트 필드, 이름 압축(바이트 안의 참조) | 알려진 구현이 만든 질의·응답 바이트와 대조 | 못 쓴다(바이트, 비트) | 처음 필요한 것(바이트, 비트)이 컴파일된다. 쓰지 않음 |
+| 1 | DNS 메시지(RFC 1035) | 와이어 프로토콜 | 바이트 읽고 쓰기, 빅엔디언, 비트 필드, 이름 압축(바이트 안의 참조) | 알려진 구현이 만든 질의·응답 바이트와 대조 | 못 쓴다(바이트, 비트) | 썼다: dnspython의 메시지 400개를 읽고 다시 써 같다. 우회 둘, 줄 수 못 미침, C의 67배(아래) |
| 2 | SHA-256(FIPS 180-4) | 해시 | 32비트 낱말, 회전, xor | NIST CAVP의 SHA 벡터 | 우회로만 될 것으로 보인다(써 보지 않음) | 썼다: NIST 벡터를 세 실행 길에서 지난다(1단계) |
| 3 | inflate(RFC 1951) | 압축 | 비트 읽기, 허프만 표, 뒤 참조 | zlib로 압축한 말뭉치를 풀어 원본과 비교 | 못 쓴다(바이트, 비트) | 썼다: zlib 스트림 640개, 손상 240개가 파이썬 참조와 같다. 우회 셋, 줄 수 못 미침, C의 108배(아래) |
| 4 | JSON(RFC 8259) → 트리와 질의 | 깊이를 모르는 데이터 | 글자 읽기, 중첩, 재귀, 갈래 | JSONTestSuite | 못 쓴다(글자, 중첩, 재귀, 갈래) | 썼다: JSONTestSuite와 무작위 문서가 파이썬과 같다(2a–2c) |
@@ -526,6 +529,71 @@
- 그 사이 Pure의 API가 바뀌어(4b, 메시지의 키가 빠졌다) 컴파일되지 않던 시간 도구 넷(`time.c`, `json_time.c`, `json_tree_time.c`,
`json_rec_time.c`)의 메시지 부름을 고쳤다.
+## 묶음의 1: DNS 메시지 (2026-10-19)
+
+027 뒤에 둘째로 쓴 명세다. 새 명령도 코어의 C도 없이 aup와 Pure만으로 썼다(aup의 `examples/dns.aup`).
+- **읽기는 핸들러다.** 메시지의 바이트를 머리(id, 플래그)와 레코드의 리스트로 읽는다. 이름은 압축을 푼 와이어 꼴(길이를 앞에 둔
+ 라벨들과 뿌리)로 둔다. 소유자의 이름과, NS·CNAME·PTR·MX·SOA·SRV 데이터 안의 이름이 그렇다. 다른 데이터는 그대로다.
+- **포인터.** 압축 포인터는 앞의 것보다 앞을 가리켜야 한다(dnspython과 같다). 그래서 고리가 생기지 않고 끝난다.
+- **쓰기는 보기 `wire`다.** 그 상태에서 메시지의 바이트를 쓰고, 소유자의 이름을 압축한다. 메시지는 상태이고, 와이어는 그 상태의
+ 순수 함수다. 그래서 바이트 하나인 상태(inflate의 둘째 우회)에 닿지 않았다.
+- **SRV.** dnspython은 RFC 2782와 달리 SRV의 이름도 압축해 쓴다. 알려진 구현이 그렇게 쓰므로, 읽는 쪽이 SRV의 이름도 푼다.
+
+**맞음.**
+- `dns_check.py`: dnspython 2.7.0이 만든 메시지 400개(148,582바이트, 레코드 3,466개)를 세 실행 길에서 읽는다.
+ - 메시지는 질의와 응답이다. 레코드는 A, AAAA, NS, CNAME, PTR, MX, SOA, TXT, SRV이고, EDNS가 있다.
+ - 이름은 접미를 나눠 가져 압축이 앞을 가리킨다. 63바이트 라벨, 0x7f 위의 바이트, 245바이트 이름도 있다.
+ - 머리와 레코드가 dnspython이 읽은 것과 같다. 섹션마다 레코드의 모음으로 견준다. dnspython은 한 이름과 종류의 레코드를 집합
+ 하나로 모으므로, 그 차례가 메시지의 차례가 아니다.
+ - 보기가 다시 쓴 바이트를 dnspython이 같은 메시지로 읽는다. 세 길의 출력은 바이트까지 같다.
+- 손상된 메시지 300개(자른 것, 비트 하나를 뒤집은 것)를 같은 길로 읽는 파이썬 참조(`dns_message.py`)와 견준다. 185개는 둘 다
+ 거절하고, 115개는 같은 레코드와 같은 다시 쓴 바이트를 낸다.
+- Pure의 `make check`(`module_specs`)에 들어갔다. dnspython에 기대지 않도록, 그것이 만든 메시지 200개를 `tests/specs/dns`에 두었다.
+ - 그 메시지들(레코드 1,780개)을 세 길에서 읽는다.
+ - 다시 쓴 것을 다시 읽으면 같은 상태와 같은 바이트다.
+ - 반으로 자른 23개는 이벤트가 실패한다.
+- 말뭉치를 만들며 dnspython에 대해 알게 된 것이 둘이다.
+ - `to_wire`는 RRset 안의 레코드를 시드 없이 섞는다(`want_shuffle`). 끄지 않으면 말뭉치가 실행마다 다르다.
+ - TTL이 다른 같은 RRset을 합치며 작은 TTL을 고른다(RFC 2181 §5.2). 그래서 생성기는 한 이름과 종류에 TTL 하나를 준다.
+
+**막대.**
+
+| 막대 | 잰 것 | |
+|---|---|---|
+| 쓸 수 있다 | aup와 Pure만. 새 명령 없음 | 지남 |
+| 우회가 없다 | 둘(아래): 바이트에 `==`가 없어 한 바이트씩 견준다, 보기가 블록 fn을 부르지 못해 압축을 보기 안에 풀어 쓴다 | **못 미침** |
+| 줄 수 ≤ 파이썬 참조 | aup 115줄(닫는 괄호만인 줄 21) 대 파이썬 72줄 | **못 미침** |
+| C 대비 시간 | 메시지 400개를 읽고 다시 쓰기, 메시지 바이트당 244 ns. 같은 알고리즘의 C(3.65 ns)의 **67배**. glibc의 해석기는 읽기만 4.4 ns(쓰는 쪽이 없다). 순수 파이썬 131 ns(Pure가 **1.9배 느림**). 기계어 계층(`PURE_JIT=all`)에서 218 ns | 기록(막대 없음) |
+| 견고하다 | 위의 맞음, 세 길이 같음, 예산 안에서 끝남. 세 기계는 아직 돌리지 않았다 | 일부 |
+
+**우회 둘(언어의 틈).**
+1. **바이트에 `==`가 없다.** 압축은 앞서 쓴 이름의 접미와 견준다. aup는 바이트의 `==`를 거절하고, Pure의 TEXT_COMPARE는 text만
+ 받는다(CONCAT은 인자로 바이트도 받는다). 그래서 보기 안에서 한 바이트씩 견주는 고리를 쓴다.
+ - 이것은 핵의 일이다. TEXT_COMPARE가 바이트를 받거나(CONCAT처럼 인자로), 같은 뜻의 명령이 있어야 한다.
+ - 둘 이상의 명세가 필요로 하는가? HTTP/1.1(9)의 머리 이름과 메서드도 바이트를 견줄 것이다. 아직 쓰지 않았다.
+2. **보기가 블록 fn을 부르지 못한다**(027 뒤의 탐침의 첫째 틈). 그래서 압축을 fn으로 빼지 못하고 보기 안에 풀어 썼다.
+ - Pure의 CALL은 핸들러와 함수의 코드에만 있다(008). 그래서 핵의 일이다.
+ - 써서 이 틈에 닿은 명세는 DNS가 처음이다. 묶음 8(배치)은 탐침에서만 보였다(쓰지 않음).
+
+**줄을 길게 한 것.** 115 대 72다. 닫는 괄호의 줄을 빼면 94다.
+- 닫는 괄호의 줄이 21줄이다.
+- 한 바이트씩 견주는 고리가 `==` 하나로 쓸 때보다 9줄 길다. 파이썬은 사전에서 찾는다(`owner[k:] not in seen`).
+- 레코드 둘(`Record`, `Seen`)의 선언이 12줄이다. 파이썬은 튜플과 사전이다.
+- 상태와 입력의 선언이 4줄이다.
+
+**시간이 가는 곳.** 메시지 바이트당 Pure 명령이 63.6개이고, 명령 하나에 약 3.8 ns가 든다.
+- 읽기(핸들러)가 42%, 쓰기(보기)가 58%다.
+- 쓰기의 압축은 앞서 쓴 이름을 차례로 훑으며 바이트마다 견준다. 같은 알고리즘의 C도 차례로 훑지만 `memcmp`로 견준다. 파이썬은
+ 사전으로 찾는다.
+- 기계어 계층은 시간을 11% 줄인다. 보기의 고리 일부를 컴파일한다(inflate에서는 0%였다).
+
+**원본.** `research/experiments/044-specs/`
+- `dns_message.py`: 파이썬 참조
+- `dns_check.py`: 맞음과 줄 수. 경로를 주면 `dns_time.c`가 읽을 메시지도 쓴다.
+- `dns_time.c`: 같은 메시지로 같은 알고리즘의 C, glibc의 해석기, Pure의 시간. Pure와 C가 쓴 바이트가 같아야 잰다.
+- `2026-10-19-dns.jsonl`: 잰 수
+- `tests/specs/dns/`: Pure의 검사가 읽는 메시지 200개
+
## 착수 기준 (다음 일)
- **바꿀 결정.** 계산의 핵을 설계한다(027). 위 표의 여덟 후보 가운데 무엇을 어떤 모양으로 받을지 정한다.
diff --git a/research/experiments/044-specs/2026-10-19-dns.jsonl b/research/experiments/044-specs/2026-10-19-dns.jsonl
new file mode 100644
index 0000000..05bd91d
--- /dev/null
+++ b/research/experiments/044-specs/2026-10-19-dns.jsonl
@@ -0,0 +1,5 @@
+{"messages": 400, "bytes": 148582, "records": 3466, "right": {"batches": 400, "rounds": 400, "all": 400}, "paths_same": true, "damaged": 300, "damaged_refused": 185, "damaged_agreed": 300, "aup_lines": 115, "aup_brace_lines": 21, "python_lines": 72}
+{"messages":400,"bytes":148582,"jit":"0","batch":"","c_ns_per_byte":3.646,"resolver_read_ns_per_byte":4.401,"pure_ns_per_byte":244.424,"to_c":67.0,"to_resolver_read":55.5,"instructions":9444428,"instructions_per_byte":63.6}
+{"messages":400,"bytes":148582,"jit":"0","batch":"0","c_ns_per_byte":3.655,"resolver_read_ns_per_byte":4.404,"pure_ns_per_byte":243.692,"to_c":66.7,"to_resolver_read":55.3,"instructions":9444428,"instructions_per_byte":63.6}
+{"messages":400,"bytes":148582,"jit":"all","batch":"","c_ns_per_byte":3.656,"resolver_read_ns_per_byte":4.401,"pure_ns_per_byte":217.982,"to_c":59.6,"to_resolver_read":49.5,"instructions":9444428,"instructions_per_byte":63.6}
+{"messages":400,"bytes":148582,"python_ns_per_byte":130.9}
diff --git a/research/experiments/044-specs/dns_check.py b/research/experiments/044-specs/dns_check.py
new file mode 100644
index 0000000..d492fda
--- /dev/null
+++ b/research/experiments/044-specs/dns_check.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""Research 140: DNS messages (RFC 1035) written in aup (aup's examples/dns.aup).
+
+Builds it with aup (AUP, default the sibling checkout) and reads messages dnspython made (queries and responses: A,
+AAAA, NS, CNAME, PTR, MX, SOA, TXT and SRV records, EDNS, names sharing suffixes so compression points back, labels of
+63 bytes and bytes above 0x7f, names near 255 bytes) on Pure's three paths (batches, rounds, all compiled): each
+message's id, flags and records (names uncompressed) as dnspython reads them, and the message the view wire writes back
+read by dnspython as the same message. Then damaged messages (cut short, a bit flipped) against dns_message.py, which
+reads them the same way: the same records and bytes when it takes one, a failed event when it refuses it. Then counts
+lines (code lines: neither blank nor a comment; brace lines: a closing brace alone) of dns.aup and of dns_message.py.
+One JSON line. Given a path, it also writes the messages there for dns_time.c."""
+import json, os, random, subprocess, sys, tempfile
+
+import dns.flags, dns.message, dns.rrset
+
+here = os.path.dirname(os.path.abspath(__file__))
+pure = os.path.normpath(os.path.join(here, '../../..'))
+aup = os.environ.get('AUP', os.path.join(pure, '..', 'aup'))
+sys.path.insert(0, here)
+import dns_message # noqa: E402
+
+
+def lines(path, stop=None):
+ code = braces = 0
+ for line in open(path):
+ if stop and line.startswith(stop): break
+ text = line.strip()
+ if not text or text.startswith('#'): continue
+ code += 1
+ braces += text == '}'
+ return code, braces
+
+
+def messages(chooser, count):
+ def label():
+ pick = chooser.random()
+ if pick < 0.05:
+ return ''.join(chooser.choices('abcdefghij', k=63))
+ if pick < 0.1:
+ return ''.join(f'\\{chooser.randrange(128, 256):03d}' for _ in range(chooser.randrange(1, 4)))
+ return ''.join(chooser.choices('abcdefghijklmnopqrstuvwxyz0123456789-', k=chooser.randrange(1, 12)))
+
+ zones = [f'{label()}.{chooser.choice(["com", "net", "org", "kr"])}.' for _ in range(6)]
+
+ def name():
+ pick = chooser.random()
+ if pick < 0.03: # 245 bytes
+ return '.'.join(''.join(chooser.choices('xyz', k=60)) for _ in range(4)) + '.'
+ if pick < 0.06:
+ return '.'
+ return '.'.join([label() for _ in range(chooser.randrange(0, 3))] + [chooser.choice(zones)])
+
+ def rdata(kind):
+ return {'A': lambda: '.'.join(str(chooser.randrange(256)) for _ in range(4)),
+ 'AAAA': lambda: ':'.join(f'{chooser.randrange(65536):x}' for _ in range(8)),
+ 'NS': name, 'CNAME': name, 'PTR': name,
+ 'MX': lambda: f'{chooser.randrange(65536)} {name()}',
+ 'SOA': lambda: f'{name()} {name()} ' + ' '.join(str(chooser.randrange(2 ** 32)) for _ in range(5)),
+ 'TXT': lambda: ' '.join('"' + ''.join(chooser.choices('abc xyz', k=chooser.randrange(0, 40))) + '"'
+ for _ in range(chooser.randrange(1, 4))),
+ 'SRV': lambda: f'{chooser.randrange(65536)} {chooser.randrange(65536)} {chooser.randrange(65536)} {name()}'}[kind]()
+
+ kinds = ['A', 'AAAA', 'NS', 'CNAME', 'PTR', 'MX', 'SOA', 'TXT', 'SRV']
+ out = []
+ for _ in range(count):
+ q = dns.message.make_query(name(), chooser.choice(kinds), use_edns=0 if chooser.random() < 0.4 else None,
+ payload=chooser.choice([512, 1232, 4096]), id=chooser.randrange(65536))
+ if chooser.random() < 0.2:
+ out.append(q)
+ continue
+ r = dns.message.make_response(q)
+ r.flags |= chooser.choice([0, dns.flags.AA, dns.flags.TC | dns.flags.AA, dns.flags.RA])
+ r.set_rcode(chooser.choice([0, 0, 0, 2, 3, 5]))
+ ttls = {} # an RRset's records share a TTL (RFC 2181), so one name and type keep theirs
+ for section in (r.answer, r.authority, r.additional):
+ for _ in range(chooser.choice([0, 1, 1, 2, 4])):
+ kind, owner = chooser.choice(kinds), name()
+ ttl = ttls.setdefault((owner, kind), chooser.randrange(2 ** 31))
+ section.append(dns.rrset.from_text(owner, ttl, 'IN', kind, *[rdata(kind) for _ in range(chooser.randrange(1, 4))]))
+ out.append(r)
+ return out
+
+
+def seen(m):
+ """A message's id, flags and records (section, name, type, class, ttl, data) as dnspython reads it, each section's
+ records in order (dnspython gathers a section's records of one name and type into one set, so its order is not the
+ message's)."""
+ records = [(0, q.name.to_wire(), q.rdtype, q.rdclass, 0, b'') for q in m.question]
+ for section, rrsets in ((1, m.answer), (2, m.authority), (3, m.additional)):
+ for rrset in rrsets:
+ records += [(section, rrset.name.to_wire(), rrset.rdtype, rrset.rdclass, rrset.ttl, rd.to_wire()) for rd in rrset]
+ if m.opt:
+ records += [(3, b'\0', 41, m.opt.rdclass, m.opt.ttl, rd.to_wire()) for rd in m.opt]
+ return m.id, m.flags, sorted(records)
+
+
+def damaged(chooser, wires):
+ out = []
+ for wire in chooser.sample(wires, 150):
+ cut = wire[:chooser.randrange(len(wire))]
+ flipped = bytearray(wire)
+ flipped[chooser.randrange(len(wire))] ^= 1 << chooser.randrange(8)
+ for message in (cut, bytes(flipped)):
+ try:
+ id, flags, records = dns_message.read(message)
+ out.append((message, (id, flags, records, dns_message.write(id, flags, records))))
+ except (ValueError, IndexError):
+ out.append((message, None))
+ return out
+
+
+def run(module, wires, env, work):
+ given = []
+ for n, wire in enumerate(wires):
+ path = os.path.join(work, f'{n}.dns')
+ with open(path, 'wb') as f:
+ f.write(wire)
+ given.append(f'message read @{path}\nshow\n')
+ out = subprocess.run([os.path.join(pure, 'build', 'pure-run'), module], input=''.join(given), capture_output=True,
+ text=True, env=dict(os.environ, **env), timeout=1800).stdout.splitlines()
+ got = []
+ for line in out[1::2]: # each message's show
+ state = json.loads(line)
+ s = state['states']
+ records = [(r['section'], bytes.fromhex(r['name']), r['type'], r['class'], r['ttl'], bytes.fromhex(r['data']))
+ for r in s['records']]
+ got.append((s['id'], s['flags'], records, bytes.fromhex(state['views']['wire'])))
+ failed = [json.loads(line)['fault'] != 'none' for line in out[0::2]]
+ return out, got, failed
+
+
+chooser = random.Random(140)
+made = messages(chooser, 400)
+wires = [m.to_wire(max_size=65535, want_shuffle=False) for m in made] # dnspython shuffles a set's records
+if len(sys.argv) > 1: # the messages for dns_time.c: each a u16 big-endian length, then its bytes (as over TCP)
+ with open(sys.argv[1], 'wb') as f:
+ f.write(b''.join(len(w).to_bytes(2, 'big') + w for w in wires))
+broken = damaged(chooser, wires)
+paths = {}
+with tempfile.TemporaryDirectory() as work:
+ module = os.path.join(work, 'dns.pmod')
+ subprocess.run([os.path.join(aup, 'build', 'aup'), 'build', os.path.join(aup, 'examples', 'dns.aup'), '-o', module],
+ check=True)
+ outputs = {}
+ for name, env in (('batches', {'PURE_JIT': '0'}), ('rounds', {'PURE_JIT': '0', 'PURE_BATCH': '0'}),
+ ('all', {'PURE_JIT': 'all'})):
+ raw, got, failed = run(module, wires, env, work)
+ right = 0
+ for wire, g, f in zip(wires, got, failed):
+ read = dns.message.from_wire(wire)
+ right += not f and (g[0], g[1], sorted(g[2])) == seen(read) and dns.message.from_wire(g[3]) == read and \
+ dns_message.read(wire)[2] == g[2]
+ paths[name] = right if len(got) == len(wires) else 0
+ outputs[name] = raw
+ same_paths = outputs['rounds'] == outputs['batches'] and outputs['all'] == outputs['batches']
+ raw, got, failed = run(module, [m for m, _ in broken], {}, work)
+ agreed = sum((want is None and f) or (want is not None and not f and g == want)
+ for (_, want), g, f in zip(broken, got, failed)) if len(got) == len(broken) else 0
+ refused = sum(want is None for _, want in broken)
+aup_code, aup_braces = lines(os.path.join(aup, 'examples', 'dns.aup'))
+python_code, _ = lines(os.path.join(here, 'dns_message.py'), stop='if __name__')
+ok = all(n == len(wires) for n in paths.values()) and same_paths and agreed == len(broken)
+print(json.dumps({'messages': len(wires), 'bytes': sum(map(len, wires)), 'records': sum(len(dns_message.read(w)[2]) for w in wires),
+ 'right': paths, 'paths_same': same_paths, 'damaged': len(broken), 'damaged_refused': refused,
+ 'damaged_agreed': agreed, 'aup_lines': aup_code, 'aup_brace_lines': aup_braces, 'python_lines': python_code}))
+sys.exit(0 if ok else 1)
diff --git a/research/experiments/044-specs/dns_message.py b/research/experiments/044-specs/dns_message.py
new file mode 100644
index 0000000..cf9ab87
--- /dev/null
+++ b/research/experiments/044-specs/dns_message.py
@@ -0,0 +1,101 @@
+# DNS messages (RFC 1035) in plain Python: the reference 140 counts aup's lines against. A message's bytes read into its
+# header and records, names in their wire form uncompressed (the owner's and those inside NS, CNAME, PTR, MX, SOA and
+# SRV data), and written back with the owners compressed. Each pointer points before the last, so they end.
+NAMED = {2: 0, 5: 0, 12: 0, 15: 2, 33: 6} # NS, CNAME, PTR, MX, SRV: the bytes before the name
+
+
+def name(d, i):
+ """The name at i uncompressed, and where it ends in place."""
+ out, j, end, below = b'', i, None, i
+ while d[j]:
+ if d[j] >= 0xc0:
+ to = (d[j] & 0x3f) << 8 | d[j + 1]
+ if to >= below:
+ raise ValueError('a pointer forward')
+ end, below, j = end or j + 2, to, to
+ elif d[j] < 0x40:
+ out += d[j:j + d[j] + 1]
+ j += d[j] + 1
+ else:
+ raise ValueError('a label type')
+ if len(out) >= 255:
+ raise ValueError('a name too long')
+ return out + b'\0', end or j + 1
+
+
+def data(d, i, size, type):
+ """A record's data, the names in it uncompressed."""
+ if type in NAMED:
+ n, end = name(d, i + NAMED[type])
+ if end != i + size:
+ raise ValueError('data length')
+ return d[i:i + NAMED[type]] + n
+ if type == 6:
+ m, after = name(d, i)
+ r, end = name(d, after)
+ if end + 20 != i + size:
+ raise ValueError('data length')
+ return m + r + d[end:end + 20]
+ return d[i:i + size]
+
+
+def read(d):
+ """The id, the flags and the records (section, name, type, class, ttl, data) of a message."""
+ if len(d) < 12:
+ raise ValueError('a header is 12 bytes')
+ word = lambda i: d[i] << 8 | d[i + 1]
+ records, at = [], 12
+ for section in range(4):
+ for _ in range(word(4 + 2 * section)):
+ owner, at = name(d, at)
+ type, klass = word(at), word(at + 2)
+ at += 4
+ if section == 0:
+ records.append((0, owner, type, klass, 0, b''))
+ continue
+ ttl, size = word(at) << 16 | word(at + 2), word(at + 4)
+ if at + 6 + size > len(d):
+ raise ValueError('data length')
+ records.append((section, owner, type, klass, ttl, data(d, at + 6, size, type)))
+ at += 6 + size
+ if at != len(d):
+ raise ValueError('bytes after the records')
+ return word(0), word(2), records
+
+
+def write(id, flags, records):
+ """The message's bytes, each owner from its first label whose rest was written before, as a pointer to it."""
+ out = bytearray(id.to_bytes(2, 'big') + flags.to_bytes(2, 'big'))
+ for section in range(4):
+ out += sum(r[0] == section for r in records).to_bytes(2, 'big')
+ seen = {}
+ for section, owner, type, klass, ttl, rdata in records:
+ k = 0
+ while owner[k] and owner[k:] not in seen:
+ if len(out) < 0x4000:
+ seen[owner[k:]] = len(out)
+ out += owner[k:k + owner[k] + 1]
+ k += owner[k] + 1
+ out += (0xc000 | seen[owner[k:]]).to_bytes(2, 'big') if owner[k] else b'\0'
+ out += type.to_bytes(2, 'big') + klass.to_bytes(2, 'big')
+ if section:
+ out += ttl.to_bytes(4, 'big') + len(rdata).to_bytes(2, 'big') + rdata
+ return bytes(out)
+
+
+if __name__ == '__main__':
+ import sys, time
+ with open(sys.argv[1], 'rb') as f: # dns_check.py's messages: each a u16 big-endian length, then its bytes
+ given = f.read()
+ messages, at = [], 0
+ while at < len(given):
+ messages.append(given[at + 2:at + 2 + int.from_bytes(given[at:at + 2], 'big')])
+ at += 2 + len(messages[-1])
+ size = sum(map(len, messages))
+ runs = []
+ for _ in range(7):
+ start = time.perf_counter()
+ for m in messages:
+ write(*read(m))
+ runs.append((time.perf_counter() - start) * 1e9 / size)
+ print('{"messages":%d,"bytes":%d,"python_ns_per_byte":%.1f}' % (len(messages), size, sorted(runs)[3]))
diff --git a/research/experiments/044-specs/dns_time.c b/research/experiments/044-specs/dns_time.c
new file mode 100644
index 0000000..b8c6563
--- /dev/null
+++ b/research/experiments/044-specs/dns_time.c
@@ -0,0 +1,255 @@
+/* Research 140: DNS messages (RFC 1035) in aup (aup's examples/dns.aup, run by Pure) against the same algorithm in C
+ * (each message read into its records, names uncompressed, then written back with the owners compressed) and against
+ * glibc's resolver reading them (ns_initparse, ns_parserr and ns_name_unpack for the names in data: reading only, the
+ * world's C has no writer of whole messages), on the same messages. Pure's and C's written bytes must be the same, and
+ * the resolver must take every message; then each is timed (the median of seven runs of rounds passes).
+ *
+ * cc -O2 -std=c17 -I../../../core dns_time.c ../../../build/libpure.a -lresolv -lm -pthread -o dns_time
+ * ./dns_time dns.pmod MESSAGES ROUNDS (MESSAGES: each a u16 big-endian length, then its bytes, as over TCP)
+ *
+ * One JSON line: the messages and their bytes, the nanoseconds per message byte of each, Pure's ratios and the
+ * instructions Pure counted for one pass (each event's and each view's). */
+#define _DEFAULT_SOURCE
+#include "module.h"
+#include <arpa/nameser.h>
+#include <resolv.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+/* ---- the same algorithm in C */
+typedef struct {
+ int section, type, class;
+ unsigned long ttl;
+ unsigned char name[256], data[1024];
+ size_t name_size, data_size;
+} Record;
+
+static int word(const unsigned char *d, size_t i) { return d[i] << 8 | d[i + 1]; }
+
+/* The name at i uncompressed into out; where it ends in place, 0 if it is none. */
+static size_t name(const unsigned char *d, size_t size, size_t i, unsigned char *out, size_t *used) {
+ size_t j = i, end = 0, below = i, n = 0;
+ for (;;) {
+ if (j >= size) return 0;
+ if (!d[j]) break;
+ if (d[j] >= 0xc0) {
+ if (j + 1 >= size) return 0;
+ size_t to = (size_t)((d[j] & 0x3f) << 8 | d[j + 1]);
+ if (to >= below) return 0;
+ if (!end) end = j + 2;
+ below = j = to;
+ } else {
+ if (d[j] >= 0x40 || j + d[j] + 1 > size || n + d[j] + 1 >= 255) return 0;
+ memcpy(out + n, d + j, d[j] + 1u);
+ n += d[j] + 1u;
+ j += d[j] + 1u;
+ }
+ }
+ out[n++] = 0;
+ *used = n;
+ return end ? end : j + 1;
+}
+
+static int data(const unsigned char *d, size_t size, size_t i, size_t length, int type, Record *r) {
+ int skip = type == 15 ? 2 : type == 33 ? 6 : 0;
+ size_t used, end;
+ if (type == 2 || type == 5 || type == 12 || type == 15 || type == 33) {
+ memcpy(r->data, d + i, (size_t)skip);
+ if ((end = name(d, size, i + (size_t)skip, r->data + skip, &used)) != i + length) return -1;
+ r->data_size = (size_t)skip + used;
+ } else if (type == 6) {
+ size_t a, b;
+ if (!(end = name(d, size, i, r->data, &a)) || (end = name(d, size, end, r->data + a, &b)) + 20 != i + length) return -1;
+ memcpy(r->data + a + b, d + end, 20);
+ r->data_size = a + b + 20;
+ } else {
+ if (length > sizeof(r->data)) return -1;
+ memcpy(r->data, d + i, length);
+ r->data_size = length;
+ }
+ return 0;
+}
+
+/* The records of a message (at most room); their count, -1 if it is none. */
+static int read_message(const unsigned char *d, size_t size, Record *records, int room, int *id, int *flags) {
+ if (size < 12) return -1;
+ int n = 0;
+ size_t at = 12;
+ for (int section = 0; section < 4; ++section)
+ for (int k = word(d, 4 + 2 * (size_t)section); k > 0; --k) {
+ if (n == room) return -1;
+ Record *r = &records[n++];
+ size_t next = name(d, size, at, r->name, &r->name_size);
+ if (!next || next + 4 > size) return -1;
+ r->section = section, r->type = word(d, next), r->class = word(d, next + 2), r->ttl = 0, r->data_size = 0;
+ at = next + 4;
+ if (section) {
+ if (at + 6 > size) return -1;
+ size_t length = (size_t)word(d, at + 4);
+ if (at + 6 + length > size) return -1;
+ r->ttl = (unsigned long)word(d, at) << 16 | (unsigned long)word(d, at + 2);
+ if (data(d, size, at + 6, length, r->type, r)) return -1;
+ at += 6 + length;
+ }
+ }
+ if (at != size) return -1;
+ *id = word(d, 0), *flags = word(d, 2);
+ return n;
+}
+
+static void two(unsigned char *out, size_t *n, unsigned v) { out[(*n)++] = (unsigned char)(v >> 8), out[(*n)++] = (unsigned char)v; }
+
+/* The message's bytes, each owner from its first label whose rest was written before, as a pointer to it. */
+static size_t write_message(const Record *records, int count, int id, int flags, unsigned char *out) {
+ static struct { const unsigned char *name; size_t size, at; } seen[4096];
+ size_t n = 0, kept = 0;
+ two(out, &n, (unsigned)id), two(out, &n, (unsigned)flags);
+ for (int section = 0; section < 4; ++section) {
+ unsigned c = 0;
+ for (int i = 0; i < count; ++i) c += records[i].section == section;
+ two(out, &n, c);
+ }
+ for (int i = 0; i < count; ++i) {
+ const Record *r = &records[i];
+ size_t k = 0;
+ bool done = false;
+ while (!done && r->name[k]) {
+ for (size_t s = 0; s < kept && !done; ++s)
+ if (seen[s].size == r->name_size - k && !memcmp(seen[s].name, r->name + k, seen[s].size)) {
+ two(out, &n, 0xc000u | (unsigned)seen[s].at);
+ done = true;
+ }
+ if (!done) {
+ if (n < 0x4000 && kept < 4096) seen[kept].name = r->name + k, seen[kept].size = r->name_size - k, seen[kept++].at = n;
+ memcpy(out + n, r->name + k, r->name[k] + 1u);
+ n += r->name[k] + 1u;
+ k += r->name[k] + 1u;
+ }
+ }
+ if (!done) out[n++] = 0;
+ two(out, &n, (unsigned)r->type), two(out, &n, (unsigned)r->class);
+ if (r->section) {
+ two(out, &n, (unsigned)(r->ttl >> 16)), two(out, &n, (unsigned)r->ttl), two(out, &n, (unsigned)r->data_size);
+ memcpy(out + n, r->data, r->data_size);
+ n += r->data_size;
+ }
+ }
+ return n;
+}
+
+/* ---- glibc's resolver reading a message: every record, and the names in NS, CNAME, PTR, MX, SOA and SRV data */
+static int resolver_read(const unsigned char *d, size_t size) {
+ ns_msg msg;
+ if (ns_initparse(d, (int)size, &msg)) return -1;
+ int n = 0;
+ for (int section = 0; section < 4; ++section)
+ for (int k = 0; k < ns_msg_count(msg, (ns_sect)section); ++k) {
+ ns_rr rr;
+ if (ns_parserr(&msg, (ns_sect)section, k, &rr)) return -1;
+ ++n;
+ int type = ns_rr_type(rr), skip = type == 15 ? 2 : type == 33 ? 6 : 0;
+ unsigned char name[NS_MAXCDNAME];
+ if (section && (type == 2 || type == 5 || type == 12 || type == 15 || type == 33 || type == 6)) {
+ const unsigned char *at = ns_rr_rdata(rr) + skip;
+ int used = ns_name_unpack(d, d + size, at, name, sizeof(name));
+ if (used < 0 || (type == 6 && ns_name_unpack(d, d + size, at + used, name, sizeof(name)) < 0)) return -1;
+ }
+ }
+ return n;
+}
+
+static double now_ns(void) {
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ return (double)t.tv_sec * 1e9 + (double)t.tv_nsec;
+}
+
+static int by_value(const void *a, const void *b) {
+ double x = *(const double *)a, y = *(const double *)b;
+ return (x > y) - (x < y);
+}
+
+typedef struct { unsigned char *bytes; size_t used, room; } Gathered;
+static bool gather(void *context, PureSpan span) {
+ Gathered *g = context;
+ if (g->used + span.size > g->room) return false;
+ memcpy(g->bytes + g->used, span.data, span.size);
+ g->used += span.size;
+ return true;
+}
+
+int main(int argc, char **argv) {
+ if (argc != 4) { fprintf(stderr, "usage: %s MODULE MESSAGES ROUNDS\n", argv[0]); return 2; }
+ FILE *f = fopen(argv[2], "rb");
+ if (!f) { fprintf(stderr, "cannot read %s\n", argv[2]); return 1; }
+ static unsigned char all[1 << 24];
+ size_t size = fread(all, 1, sizeof(all), f);
+ fclose(f);
+ int rounds = atoi(argv[3]), count = 0;
+ static size_t starts[65536], sizes[65536];
+ size_t bytes = 0;
+ for (size_t at = 0; at + 2 <= size && count < 65536; at += 2 + sizes[count++])
+ starts[count] = at + 2, sizes[count] = (size_t)word(all, at), bytes += sizes[count];
+
+ PureModule module = {0};
+ char error[160];
+ if (pure_module_read(argv[1], &module, error, sizeof(error)) != PURE_OK) { fprintf(stderr, "%s\n", error); return 1; }
+ PureApp app;
+ if (pure_app_start(&app, &module, (PureData){0}) != PURE_OK) return 1;
+ int input = pure_module_input(&module, "read"), view = pure_module_view(&module, "wire");
+ static Record records[4096];
+ static unsigned char written[1 << 17], viewed[1 << 17];
+ uint64_t work = 0; /* what each event and each view evaluation used (app.work counts the unit running) */
+ for (int m = 0; m < count; ++m) {
+ int id, flags, n = read_message(all + starts[m], sizes[m], records, 4096, &id, &flags);
+ if (n < 0 || resolver_read(all + starts[m], sizes[m]) != n) { fprintf(stderr, "message %d not read\n", m); return 1; }
+ size_t length = write_message(records, n, id, flags, written);
+ Gathered g = {viewed, 0, sizeof(viewed)};
+ bool read = pure_app_message(&app, (uint32_t)input, (PureSpan){all + starts[m], sizes[m]}) == PURE_OK;
+ work += app.work;
+ if (!read || pure_app_view_text(&app, (uint32_t)view, gather, &g) != PURE_OK || g.used != length ||
+ memcmp(viewed, written, length)) {
+ fprintf(stderr, "message %d: Pure's bytes differ\n", m);
+ return 1;
+ }
+ work += app.work;
+ }
+
+ double c_runs[7], resolver_runs[7], pure_runs[7];
+ volatile size_t sink = 0;
+ for (int r = 0; r < 7; ++r) {
+ double start = now_ns();
+ for (int i = 0; i < rounds; ++i)
+ for (int m = 0; m < count; ++m) {
+ int id, flags, n = read_message(all + starts[m], sizes[m], records, 4096, &id, &flags);
+ sink += write_message(records, n, id, flags, written);
+ }
+ c_runs[r] = (now_ns() - start) / rounds;
+ start = now_ns();
+ for (int i = 0; i < rounds; ++i)
+ for (int m = 0; m < count; ++m) sink += (size_t)resolver_read(all + starts[m], sizes[m]);
+ resolver_runs[r] = (now_ns() - start) / rounds;
+ start = now_ns();
+ for (int i = 0; i < rounds; ++i)
+ for (int m = 0; m < count; ++m) {
+ Gathered g = {viewed, 0, sizeof(viewed)};
+ if (pure_app_message(&app, (uint32_t)input, (PureSpan){all + starts[m], sizes[m]}) != PURE_OK ||
+ pure_app_view_text(&app, (uint32_t)view, gather, &g) != PURE_OK) return 1;
+ sink += g.used;
+ }
+ pure_runs[r] = (now_ns() - start) / rounds;
+ }
+ qsort(c_runs, 7, sizeof(double), by_value);
+ qsort(resolver_runs, 7, sizeof(double), by_value);
+ qsort(pure_runs, 7, sizeof(double), by_value);
+ double n = (double)bytes;
+ const char *jit = getenv("PURE_JIT"), *batch = getenv("PURE_BATCH");
+ printf("{\"messages\":%d,\"bytes\":%zu,\"jit\":\"%s\",\"batch\":\"%s\",\"c_ns_per_byte\":%.3f,\"resolver_read_ns_per_byte\":%.3f,"
+ "\"pure_ns_per_byte\":%.3f,\"to_c\":%.1f,\"to_resolver_read\":%.1f,\"instructions\":%llu,\"instructions_per_byte\":%.1f}\n",
+ count, bytes, jit ? jit : "", batch ? batch : "", c_runs[3] / n, resolver_runs[3] / n, pure_runs[3] / n,
+ pure_runs[3] / c_runs[3], pure_runs[3] / resolver_runs[3], (unsigned long long)work, (double)work / n);
+ pure_app_stop(&app);
+ return 0;
+}
diff --git a/tests/check.py b/tests/check.py
index 4f2fb98..b78b184 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -1731,7 +1731,9 @@ def module_specs(aup, pure, work):
every y_ case taken, every n_ case refused (a refusal: valid false, or
the event failing as encoding on bytes that are not UTF-8); JSON read into
a tree (its examples/json_tree.aup); raw deflate streams zlib made inflated
- by its examples/inflate.aup to the original's bytes, cut ones refused."""
+ by its examples/inflate.aup to the original's bytes, cut ones refused; DNS
+ messages dnspython made (tests/specs/dns) read by its examples/dns.aup and
+ written back by its view to the same records, cut ones refused."""
import hashlib
import os
import random
@@ -1864,6 +1866,36 @@ def module_specs(aup, pure, work):
for state in shown[len(streams):]:
assert state['fault'] != 'none' and state['states']['inflated'][-1]['data'] == streams[-1][0].hex(), ('inflate cut', state['fault'])
runs['inflate'] = {'streams': len(streams), 'bytes': sum(len(d) for d, _ in streams), 'cut': len(cut)}
+ # DNS messages (RFC 1035, its examples/dns.aup): messages dnspython made (tests/specs/dns), each read without fault on
+ # the three paths, what its view wire writes back read to the same records and written again to the same bytes; a
+ # message cut short fails its event.
+ program = work / 'dns.pmod'
+ compile_module(aup, EXAMPLES / 'dns.aup', program)
+ given, wires = (pure / 'tests/specs/dns/messages.bin').read_bytes(), []
+ while given:
+ wires.append(given[2:2 + int.from_bytes(given[:2], 'big')])
+ given = given[2 + len(wires[-1]):]
+
+ def read(messages, env):
+ lines = ''
+ for n, wire in enumerate(messages):
+ (work / f'{n}.dns').write_bytes(wire)
+ lines += f'message read @{work / f"{n}.dns"}\n'
+ return subprocess.run([str(pure / 'build/pure-run'), str(program)], input=lines, capture_output=True, text=True,
+ timeout=600, env=dict(os.environ, **env)).stdout
+
+ outputs = {name: read(wires, env) for name, env in
+ (('batches', {'PURE_JIT': '0'}), ('rounds', {'PURE_JIT': '0', 'PURE_BATCH': '0'}), ('all', {'PURE_JIT': 'all'}))}
+ for name in ('rounds', 'all'):
+ assert outputs[name] == outputs['batches'], ('dns', name)
+ first = [json.loads(line) for line in outputs['batches'].splitlines()]
+ assert len(first) == len(wires) and all(state['fault'] == 'none' for state in first), 'dns'
+ written = [bytes.fromhex(state['views']['wire']) for state in first]
+ again = [json.loads(line) for line in read(written, {}).splitlines()]
+ assert [(a['states'], a['views']) for a in again] == [(f['states'], f['views']) for f in first], 'dns written back'
+ cut = [wire[:len(wire) // 2] for wire in wires[::9]]
+ assert all(json.loads(line)['fault'] != 'none' for line in read(cut, {}).splitlines()), 'dns cut'
+ runs['dns'] = {'messages': len(wires), 'records': sum(len(state['states']['records']) for state in first), 'cut': len(cut)}
return runs
diff --git a/tests/specs/dns/README.md b/tests/specs/dns/README.md
new file mode 100644
index 0000000..873dc02
--- /dev/null
+++ b/tests/specs/dns/README.md
@@ -0,0 +1,8 @@
+# DNS messages dnspython made
+
+`messages.bin` is the first 200 of the 400 messages `research/experiments/044-specs/dns_check.py` makes with dnspython
+2.7.0 (its seed 140, records unshuffled): queries and responses with A, AAAA, NS, CNAME, PTR, MX, SOA, TXT and SRV records, EDNS, names
+sharing suffixes (so compression points back), labels of 63 bytes and of bytes above 0x7f, and names of 245 bytes. Each
+is a u16 big-endian length, then its bytes, as over TCP. `tests/check.py`'s `module_specs` reads them with aup's
+`examples/dns.aup` on Pure's three paths, then reads what its view `wire` writes back; the research check compares the
+records with dnspython's reading.
diff --git a/tests/specs/dns/messages.bin b/tests/specs/dns/messages.bin
new file mode 100644
index 0000000..3fb520a
--- /dev/null
+++ b/tests/specs/dns/messages.bin
Binary files differ