artdag: DAG wire serialization — portable record form + integrity + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s

serialize.sx emits a topo-ordered (id op inputs params commutative) record list
that survives write/read (string-keyed node dicts do not; empty inputs read back
as nil and are normalized). wire->dag reconstructs a runnable dag by content-id;
wire-verify recomputes ids to reject tampering. dag->string/string->dag for text
transport. serialize 13/13, total 128/128.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:22:17 +00:00
parent afe69cbdc6
commit 4947d1f5aa
6 changed files with 197 additions and 6 deletions

View File

@@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then
exit 1
fi
SUITES=(dag analyze plan execute optimize fed cost)
SUITES=(dag analyze plan execute optimize fed cost serialize)
OUT_JSON="lib/artdag/scoreboard.json"
OUT_MD="lib/artdag/scoreboard.md"
@@ -48,6 +48,7 @@ run_suite() {
(load "lib/artdag/optimize.sx")
(load "lib/artdag/federation.sx")
(load "lib/artdag/cost.sx")
(load "lib/artdag/serialize.sx")
(epoch 2)
(eval "(define artdag-test-pass 0)")
(eval "(define artdag-test-fail 0)")

View File

@@ -6,9 +6,10 @@
"execute": {"pass": 15, "fail": 0},
"optimize": {"pass": 18, "fail": 0},
"fed": {"pass": 15, "fail": 0},
"cost": {"pass": 13, "fail": 0}
"cost": {"pass": 13, "fail": 0},
"serialize": {"pass": 13, "fail": 0}
},
"total_pass": 115,
"total_pass": 128,
"total_fail": 0,
"total": 115
"total": 128
}

View File

@@ -11,4 +11,5 @@ _Generated by `lib/artdag/conformance.sh`_
| optimize | 18 | 0 | 18 |
| fed | 15 | 0 | 15 |
| cost | 13 | 0 | 13 |
| **Total** | **115** | **0** | **115** |
| serialize | 13 | 0 | 13 |
| **Total** | **128** | **0** | **128** |

62
lib/artdag/serialize.sx Normal file
View File

@@ -0,0 +1,62 @@
; lib/artdag/serialize.sx — portable wire form for whole DAGs, so a peer can
; receive and run a graph it did not author. The form is a topo-ordered list of
; node records (id op inputs params commutative) — plain lists with keyword-keyed
; param dicts, which survive write/read (unlike string-keyed node dicts). The id
; is the content-id, so the form is self-verifying. Depends on dag.sx.
(define
artdag/node->record
(fn
(dag id)
(let
((n (artdag/dag-get dag id)))
(list
id
(artdag/node-op n)
(artdag/node-inputs n)
(artdag/node-params n)
(get n :commutative)))))
; dag -> list of records, in topological order.
(define
artdag/dag->wire
(fn
(dag)
(map (fn (id) (artdag/node->record dag id)) (artdag/dag-order dag))))
; an empty input list reads back as nil; normalize it.
(define
artdag/-rec-inputs
(fn (rec) (let ((i (nth rec 2))) (if (nil? i) (list) i))))
(define artdag/-rec->node (fn (rec) {:inputs (artdag/-rec-inputs rec) :commutative (nth rec 4) :op (nth rec 1) :params (nth rec 3)}))
; records -> dag. Local author names are not part of the wire form; the receiver
; works by content-id. :names is left empty.
(define
artdag/wire->dag
(fn
(records)
(reduce
(fn (dag rec) (let ((id (nth rec 0))) {:names (get dag :names) :order (concat (get dag :order) (list id)) :ok true :nodes (assoc (get dag :nodes) id (artdag/-rec->node rec))}))
{:names {} :order (list) :ok true :nodes {}}
records)))
; integrity: each record's id must equal the content-id recomputed from its spec.
(define
artdag/wire-verify
(fn
(records)
(every?
(fn
(rec)
(= (nth rec 0) (artdag/content-id (artdag/-rec->node rec))))
records)))
; string transport.
(define
artdag/dag->string
(fn (dag) (write-to-string (artdag/dag->wire dag))))
(define
artdag/string->dag
(fn (s) (artdag/wire->dag (read (open-input-string s)))))

View File

@@ -0,0 +1,115 @@
; portable wire form: dag <-> records <-> string, with content-id integrity.
(define ser-RT (artdag/op-table-runner {:in (fn (p i) (get p :v)) :add (fn (p i) (+ (nth i 0) (nth i 1))) :inc (fn (p i) (+ 1 (first i)))}))
(define
ser-D
(artdag/build
(list
(list "a" "in" (list) {:v 10})
(list "b" "inc" (list "a") {})
(list "c" "add" (list "a" "b") {} true))))
(define ser-cid (artdag/dag-id ser-D "c"))
; ---- wire form ----
(artdag-test
"wire has one record per node"
(len (artdag/dag->wire ser-D))
3)
(artdag-test
"wire records follow topological order"
(map (fn (rec) (nth rec 0)) (artdag/dag->wire ser-D))
(artdag/dag-order ser-D))
(artdag-test
"wire record carries the content-id"
(nth (nth (artdag/dag->wire ser-D) 0) 0)
(artdag/dag-id ser-D "a"))
; ---- reconstruction ----
(artdag-test
"wire->dag restores node count"
(artdag/node-count (artdag/wire->dag (artdag/dag->wire ser-D)))
3)
(artdag-test
"wire->dag restores order"
(artdag/dag-order (artdag/wire->dag (artdag/dag->wire ser-D)))
(artdag/dag-order ser-D))
(artdag-test
"reconstructed leaf inputs normalize to empty list"
(artdag/node-inputs
(artdag/dag-get
(artdag/wire->dag (artdag/dag->wire ser-D))
(artdag/dag-id ser-D "a")))
(list))
(artdag-test
"reconstructed node preserves inputs"
(artdag/node-inputs
(artdag/dag-get (artdag/wire->dag (artdag/dag->wire ser-D)) ser-cid))
(artdag/node-inputs (artdag/dag-get ser-D ser-cid)))
(artdag-test
"reconstructed node id matches recomputed content-id"
(artdag/content-id
(artdag/dag-get (artdag/wire->dag (artdag/dag->wire ser-D)) ser-cid))
ser-cid)
; ---- execution equivalence ----
(artdag-test
"reconstructed dag executes to same result"
(let
((c1 (persist/open)) (c2 (persist/open)))
(=
(artdag/result-of (artdag/run ser-D ser-RT c1) ser-cid)
(artdag/result-of
(artdag/run (artdag/wire->dag (artdag/dag->wire ser-D)) ser-RT c2)
ser-cid)))
true)
(artdag-test
"string round-trip executes to same result"
(let
((cache (persist/open)))
(artdag/result-of
(artdag/run
(artdag/string->dag (artdag/dag->string ser-D))
ser-RT
cache)
ser-cid))
21)
; ---- integrity ----
(artdag-test
"wire-verify accepts a genuine wire form"
(artdag/wire-verify (artdag/dag->wire ser-D))
true)
(artdag-test
"wire-verify rejects a tampered id"
(artdag/wire-verify
(list (list "node:bogus" "in" (list) {:v 1} false)))
false)
(artdag-test
"wire-verify rejects mutated params under a stale id"
(artdag/wire-verify
(map
(fn
(rec)
(list
(nth rec 0)
(nth rec 1)
(nth rec 2)
{:v 999}
(nth rec 4)))
(artdag/dag->wire ser-D)))
false)

View File

@@ -30,7 +30,7 @@ edges.
## Status (rolling)
`bash lib/artdag/conformance.sh`**115/115** (7 suites: dag, analyze, plan, execute, optimize, fed, cost)
`bash lib/artdag/conformance.sh`**128/128** (8 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize)
Base roadmap (Phases 16) COMPLETE. Now extending.
@@ -138,6 +138,17 @@ lib/artdag/optimize.sx lib/artdag/federation.sx
## Progress log
- **Ext: DAG wire serialization** (serialize suite 13/13, total 128/128).
`lib/artdag/serialize.sx`: `dag->wire` emits a topo-ordered list of
`(id op inputs params commutative)` records — plain lists with keyword-keyed param
dicts, which survive `write-to-string`/`read` (string-keyed node dicts do NOT; and
`()` reads back as nil, so `wire->dag` normalizes empty inputs). `wire->dag`
reconstructs a runnable dag by content-id (author names dropped); executes
identically to the original. `wire-verify` recomputes each record's content-id and
rejects tampered ids or mutated params under a stale id (self-verifying transport).
`dag->string`/`string->dag` for text transport. Gotcha logged: `sx-parse` primitive
is unbound in the server env — use `(read (open-input-string s))`.
- **Ext: cost-based scheduling** (cost suite 13/13, total 115/115).
`lib/artdag/cost.sx`: an injected `cost-fn (op params)` keeps media-op costs opaque
(`const-cost`, `op-cost table`). `critical-path` = longest weighted path (finish-time