From e4a8dff9ba2c4e807bcc374e9f9d77540b2eb10e Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 11:49:43 +0000 Subject: [PATCH 01/13] artdag: Phase 1 DAG model + structural content addressing + 20 tests Content-addressed node = {:op :inputs :params :commutative}; content-id is a deterministic canonical serialization (sorted param keys; commutative ops sort inputs). artdag/build validates dangling/cycles, topo-sorts, dedups identical subgraphs to one id shared across DAGs. conformance.sh + scoreboard (dag 20/20). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 107 ++++++++++++++++++ lib/artdag/dag.sx | 226 +++++++++++++++++++++++++++++++++++++ lib/artdag/scoreboard.json | 8 ++ lib/artdag/scoreboard.md | 8 ++ lib/artdag/tests/dag.sx | 182 +++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 25 ++-- 6 files changed, 549 insertions(+), 7 deletions(-) create mode 100755 lib/artdag/conformance.sh create mode 100644 lib/artdag/dag.sx create mode 100644 lib/artdag/scoreboard.json create mode 100644 lib/artdag/scoreboard.md create mode 100644 lib/artdag/tests/dag.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh new file mode 100755 index 00000000..5424ae49 --- /dev/null +++ b/lib/artdag/conformance.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# lib/artdag/conformance.sh — run artdag test suites, emit scoreboard.json + scoreboard.md. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +SUITES=(dag) + +OUT_JSON="lib/artdag/scoreboard.json" +OUT_MD="lib/artdag/scoreboard.md" + +run_suite() { + local suite=$1 + local file="lib/artdag/tests/${suite}.sx" + local TMP + TMP=$(mktemp) + cat > "$TMP" << EPOCHS +(epoch 1) +(load "spec/stdlib.sx") +(load "lib/r7rs.sx") +(load "lib/artdag/dag.sx") +(epoch 2) +(eval "(define artdag-test-pass 0)") +(eval "(define artdag-test-fail 0)") +(eval "(define artdag-test (fn (name got expected) (if (= got expected) (set! artdag-test-pass (+ artdag-test-pass 1)) (set! artdag-test-fail (+ artdag-test-fail 1)))))") +(epoch 3) +(load "${file}") +(epoch 4) +(eval "(list artdag-test-pass artdag-test-fail)") +EPOCHS + + local OUTPUT + OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null) + rm -f "$TMP" + + local LINE + LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}') + if [ -z "$LINE" ]; then + LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \ + | sed -E 's/^\(ok 4 //; s/\)$//') + fi + + local P F + P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/') + F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/') + P=${P:-0} + F=${F:-0} + echo "${P} ${F}" +} + +declare -A SUITE_PASS +declare -A SUITE_FAIL +TOTAL_PASS=0 +TOTAL_FAIL=0 + +echo "Running artdag conformance suite..." >&2 +for s in "${SUITES[@]}"; do + read -r p f < <(run_suite "$s") + SUITE_PASS[$s]=$p + SUITE_FAIL[$s]=$f + TOTAL_PASS=$((TOTAL_PASS + p)) + TOTAL_FAIL=$((TOTAL_FAIL + f)) + printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2 +done + +{ + printf '{\n' + printf ' "suites": {\n' + first=1 + for s in "${SUITES[@]}"; do + if [ $first -eq 0 ]; then printf ',\n'; fi + printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}" + first=0 + done + printf '\n },\n' + printf ' "total_pass": %d,\n' "$TOTAL_PASS" + printf ' "total_fail": %d,\n' "$TOTAL_FAIL" + printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))" + printf '}\n' +} > "$OUT_JSON" + +{ + printf '# artdag Conformance Scoreboard\n\n' + printf '_Generated by `lib/artdag/conformance.sh`_\n\n' + printf '| Suite | Pass | Fail | Total |\n' + printf '|-------|-----:|-----:|------:|\n' + for s in "${SUITES[@]}"; do + p=${SUITE_PASS[$s]} + f=${SUITE_FAIL[$s]} + printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))" + done + printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))" +} > "$OUT_MD" + +echo "Wrote $OUT_JSON and $OUT_MD" >&2 +echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2 + +[ "$TOTAL_FAIL" -eq 0 ] diff --git a/lib/artdag/dag.sx b/lib/artdag/dag.sx new file mode 100644 index 00000000..e9e4ba24 --- /dev/null +++ b/lib/artdag/dag.sx @@ -0,0 +1,226 @@ +; lib/artdag/dag.sx — DAG model + structural content addressing. +; A node = {:op :inputs :params :commutative}. inputs are content-ids of upstream +; nodes. The content-id is a deterministic structural digest so identical +; subgraphs collapse to one id (and one cache slot). No clock, no randomness. + +; ---- string ordering (no host sort/string= i la) (>= i lb)) false) + ((>= i la) true) + ((>= i lb) false) + (else + (let + ((ca (char-code (substring a i (+ i 1)))) + (cb (char-code (substring b i (+ i 1))))) + (cond + ((< ca cb) true) + ((> ca cb) false) + (else (artdag/strstring v)) + ((equal? t "string") (str "\"" v "\"")) + ((equal? t "keyword") (str ":" (keyword-name v))) + ((equal? t "symbol") (str "'" (write-to-string v))) + ((equal? t "list") (str "(" (artdag/canon-list v) ")")) + ((equal? t "dict") (artdag/canon-dict v)) + (else (str "<" t ">" (write-to-string v))))))) + +; ---- node + content id ---- + +(define artdag/node (fn (op inputs params) {:inputs inputs :commutative false :op op :params params})) + +(define artdag/cnode (fn (op inputs params) {:inputs inputs :commutative true :op op :params params})) + +(define artdag/node-op (fn (n) (get n :op))) +(define artdag/node-inputs (fn (n) (get n :inputs))) +(define artdag/node-params (fn (n) (get n :params))) + +(define + artdag/content-id + (fn + (node) + (let + ((ins (if (get node :commutative) (artdag/sort-strings (get node :inputs)) (get node :inputs)))) + (str + "node:" + (artdag/canon (list (get node :op) ins (get node :params))))))) + +(define artdag/id-of artdag/content-id) + +; ---- list helpers ---- + +(define artdag/member? (fn (x xs) (some (fn (y) (equal? y x)) xs))) + +(define + artdag/all-in? + (fn (xs placed) (every? (fn (x) (artdag/member? x placed)) xs))) + +; ---- build: entries -> validated, content-addressed dag ---- +; entry = (local-name op (input-local-names...) params [commutative?]) + +(define artdag/entry-name (fn (e) (nth e 0))) +(define artdag/entry-op (fn (e) (nth e 1))) +(define artdag/entry-inputs (fn (e) (nth e 2))) +(define artdag/entry-params (fn (e) (nth e 3))) +(define + artdag/entry-commutative + (fn (e) (if (> (len e) 4) (nth e 4) false))) + +(define + artdag/entries->map + (fn + (entries) + (reduce + (fn (m e) (assoc m (artdag/entry-name e) {:inputs (artdag/entry-inputs e) :commutative (artdag/entry-commutative e) :op (artdag/entry-op e) :params (artdag/entry-params e)})) + {} + entries))) + +(define + artdag/dangling + (fn + (spec-map) + (reduce + (fn + (acc name) + (reduce + (fn (a in) (if (has-key? spec-map in) a (cons in a))) + acc + (get (get spec-map name) :inputs))) + (list) + (keys spec-map)))) + +(define + artdag/ready-names + (fn + (spec-map placed) + (filter + (fn + (name) + (and + (not (artdag/member? name placed)) + (artdag/all-in? (get (get spec-map name) :inputs) placed))) + (artdag/sort-strings (keys spec-map))))) + +(define + artdag/topo-loop + (fn + (spec-map placed) + (if + (= (len placed) (len (keys spec-map))) + {:order placed :ok true} + (let + ((ready (artdag/ready-names spec-map placed))) + (if + (empty? ready) + {:error "cycle" :ok false} + (artdag/topo-loop spec-map (concat placed ready))))))) + +(define artdag/topo (fn (spec-map) (artdag/topo-loop spec-map (list)))) + +(define + artdag/resolve-ids + (fn + (spec-map order) + (reduce + (fn + (dag name) + (let + ((spec (get spec-map name))) + (let + ((resolved (map (fn (in) (get (get dag :names) in)) (get spec :inputs)))) + (let + ((node {:inputs resolved :commutative (get spec :commutative) :op (get spec :op) :params (get spec :params)})) + (let ((id (artdag/content-id node))) {:names (assoc (get dag :names) name id) :order (if (artdag/member? id (get dag :order)) (get dag :order) (concat (get dag :order) (list id))) :nodes (assoc (get dag :nodes) id node)}))))) + {:names {} :order (list) :nodes {}} + order))) + +(define + artdag/build + (fn + (entries) + (let + ((spec-map (artdag/entries->map entries))) + (let + ((dang (artdag/dangling spec-map))) + (if + (not (empty? dang)) + {:refs dang :error "dangling" :ok false} + (let + ((topo (artdag/topo spec-map))) + (if + (not (get topo :ok)) + {:error (get topo :error) :ok false} + (assoc + (artdag/resolve-ids spec-map (get topo :order)) + :ok true)))))))) + +; ---- dag accessors ---- + +(define artdag/dag-nodes (fn (dag) (get dag :nodes))) +(define artdag/dag-names (fn (dag) (get dag :names))) +(define artdag/dag-order (fn (dag) (get dag :order))) +(define artdag/dag-id (fn (dag name) (get (get dag :names) name))) +(define artdag/dag-get (fn (dag id) (get (get dag :nodes) id))) +(define + artdag/dag-node-by-name + (fn (dag name) (artdag/dag-get dag (artdag/dag-id dag name)))) +(define artdag/node-count (fn (dag) (len (keys (get dag :nodes))))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json new file mode 100644 index 00000000..1ca0b565 --- /dev/null +++ b/lib/artdag/scoreboard.json @@ -0,0 +1,8 @@ +{ + "suites": { + "dag": {"pass": 20, "fail": 0} + }, + "total_pass": 20, + "total_fail": 0, + "total": 20 +} diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md new file mode 100644 index 00000000..34e8136d --- /dev/null +++ b/lib/artdag/scoreboard.md @@ -0,0 +1,8 @@ +# artdag Conformance Scoreboard + +_Generated by `lib/artdag/conformance.sh`_ + +| Suite | Pass | Fail | Total | +|-------|-----:|-----:|------:| +| dag | 20 | 0 | 20 | +| **Total** | **20** | **0** | **20** | diff --git a/lib/artdag/tests/dag.sx b/lib/artdag/tests/dag.sx new file mode 100644 index 00000000..f92d90ed --- /dev/null +++ b/lib/artdag/tests/dag.sx @@ -0,0 +1,182 @@ +; Phase 1 — dag model + structural content addressing. + +; ---- content-id determinism ---- + +(artdag-test + "same spec -> same id" + (equal? + (artdag/content-id (artdag/node "blur" (list "i1") {:r 3})) + (artdag/content-id (artdag/node "blur" (list "i1") {:r 3}))) + true) + +(artdag-test + "op affects id" + (equal? + (artdag/content-id (artdag/node "blur" (list "i1") {})) + (artdag/content-id (artdag/node "sharpen" (list "i1") {}))) + false) + +(artdag-test + "params affect id" + (equal? + (artdag/content-id (artdag/node "blur" (list "i1") {:r 3})) + (artdag/content-id (artdag/node "blur" (list "i1") {:r 5}))) + false) + +(artdag-test + "inputs affect id" + (equal? + (artdag/content-id (artdag/node "add" (list "i1") {})) + (artdag/content-id (artdag/node "add" (list "i2") {}))) + false) + +(artdag-test + "param key order does not affect id" + (equal? + (artdag/content-id (artdag/node "op" (list) {:a 1 :b 2})) + (artdag/content-id (artdag/node "op" (list) {:a 1 :b 2}))) + true) + +; ---- commutativity ---- + +(artdag-test + "commutative op: input order ignored" + (equal? + (artdag/content-id (artdag/cnode "add" (list "i1" "i2") {})) + (artdag/content-id (artdag/cnode "add" (list "i2" "i1") {}))) + true) + +(artdag-test + "non-commutative op: input order matters" + (equal? + (artdag/content-id (artdag/node "sub" (list "i1" "i2") {})) + (artdag/content-id (artdag/node "sub" (list "i2" "i1") {}))) + false) + +; ---- build: success ---- + +(artdag-test + "build ok for valid dag" + (get + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "load" (list) {:s 1}) + (list "c" "add" (list "a" "b") {}))) + :ok) + true) + +(artdag-test + "node-count counts distinct nodes" + (artdag/node-count + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "load" (list) {:s 1}) + (list "c" "add" (list "a" "b") {})))) + 3) + +; ---- subgraph sharing ---- + +(artdag-test + "identical leaves dedup to one node" + (artdag/node-count + (artdag/build + (list + (list "a" "load" (list) {:s 1}) + (list "b" "load" (list) {:s 1}) + (list "c" "add" (list "a" "b") {})))) + 2) + +(artdag-test + "duplicate names map to same id" + (let + ((d (artdag/build (list (list "a" "load" (list) {:s 1}) (list "b" "load" (list) {:s 1}))))) + (equal? (artdag/dag-id d "a") (artdag/dag-id d "b"))) + true) + +(artdag-test + "identical subgraph shares id across dags" + (let + ((d1 (artdag/build (list (list "x" "load" (list) {:s 7}) (list "y" "neg" (list "x") {})))) + (d2 + (artdag/build + (list + (list "p" "load" (list) {:s 7}) + (list "q" "neg" (list "p") {}))))) + (equal? (artdag/dag-id d1 "y") (artdag/dag-id d2 "q"))) + true) + +; ---- validation ---- + +(artdag-test + "cycle rejected" + (get + (artdag/build + (list + (list "a" "f" (list "b") {}) + (list "b" "g" (list "a") {}))) + :error) + "cycle") + +(artdag-test + "self-cycle rejected" + (get (artdag/build (list (list "a" "f" (list "a") {}))) :error) + "cycle") + +(artdag-test + "dangling input rejected" + (get + (artdag/build (list (list "a" "f" (list "ghost") {}))) + :error) + "dangling") + +(artdag-test + "dangling refs reported" + (get + (artdag/build (list (list "a" "f" (list "ghost") {}))) + :refs) + (list "ghost")) + +; ---- topological order ---- + +(artdag-test + "topo order: deps before dependents" + (let + ((d (artdag/build (list (list "c" "add" (list "a" "b") {}) (list "a" "load" (list) {:s 1}) (list "b" "load" (list) {:s 2}))))) + (artdag/dag-order d)) + (let + ((d (artdag/build (list (list "c" "add" (list "a" "b") {}) (list "a" "load" (list) {:s 1}) (list "b" "load" (list) {:s 2}))))) + (list (artdag/dag-id d "a") (artdag/dag-id d "b") (artdag/dag-id d "c")))) + +(artdag-test + "topo order: deep chain" + (let + ((d (artdag/build (list (list "d" "f" (list "c") {}) (list "c" "f" (list "b") {}) (list "b" "f" (list "a") {}) (list "a" "load" (list) {}))))) + (artdag/dag-order d)) + (let + ((d (artdag/build (list (list "d" "f" (list "c") {}) (list "c" "f" (list "b") {}) (list "b" "f" (list "a") {}) (list "a" "load" (list) {}))))) + (list + (artdag/dag-id d "a") + (artdag/dag-id d "b") + (artdag/dag-id d "c") + (artdag/dag-id d "d")))) + +; ---- accessors ---- + +(artdag-test + "dag-node-by-name returns node spec" + (artdag/node-op + (artdag/dag-node-by-name + (artdag/build (list (list "a" "load" (list) {}))) + "a")) + "load") + +(artdag-test + "resolved inputs are content-ids" + (let + ((d (artdag/build (list (list "a" "load" (list) {}) (list "b" "neg" (list "a") {}))))) + (artdag/node-inputs (artdag/dag-node-by-name d "b"))) + (let + ((d (artdag/build (list (list "a" "load" (list) {}) (list "b" "neg" (list "a") {}))))) + (list (artdag/dag-id d "a")))) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 6fe7a2ce..bf5aed91 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **0/0** (not yet started) +`bash lib/artdag/conformance.sh` → **20/20** (1 suite: dag) ## Ground rules @@ -78,13 +78,13 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 1 — DAG model + content addressing -- [ ] `lib/artdag/dag.sx` — node `{:op :inputs :params}`; structural content-id = +- [x] `lib/artdag/dag.sx` — node `{:op :inputs :params}`; structural content-id = digest of `(op, sorted input-ids, params)`; build/validate a DAG (no dangling inputs, no accidental cycles); topological order -- [ ] identical-subgraph sharing: two structurally-equal nodes get the same id -- [ ] `lib/artdag/tests/dag.sx` — id determinism, subgraph sharing, cycle/dangling +- [x] identical-subgraph sharing: two structurally-equal nodes get the same id +- [x] `lib/artdag/tests/dag.sx` — id determinism, subgraph sharing, cycle/dangling rejection, topo order -- [ ] `lib/artdag/conformance.sh` + scoreboard +- [x] `lib/artdag/conformance.sh` + scoreboard ## Phase 2 — Analyze (Datalog) @@ -136,8 +136,19 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log -(loop fills this in) +- **Phase 1 — DAG model + content addressing** (dag suite 20/20). `lib/artdag/dag.sx`: + node `{:op :inputs :params :commutative}`; `artdag/content-id` = `"node:"` + a + deterministic canonical serialization of `(op, inputs, params)` with dict keys + sorted (param order-insensitive) and commutative ops' inputs sorted (input + order-insensitive); non-commutative inputs ordered. `artdag/build` takes named + entries `(name op (input-names) params [commutative?])`, validates (dangling refs, + cycles via fixpoint topo), resolves input-names→content-ids, dedups identical + subgraphs to one node + one id (shared across DAGs), returns `{:ok :nodes :names + :order}`. No host `sort`/`string Date: Sun, 7 Jun 2026 11:53:29 +0000 Subject: [PATCH 02/13] artdag: Phase 2 Analyze on Datalog + 16 tests analyze.sx projects DAG edges to (edge in out) facts and runs recursive reachable rules for deps-of/dependents-of/reachable-from/ancestors-of, plus dirty-closure (dirty(Y):-edge(X,Y),dirty(X)) for incremental recompute. Keystone: changing a mid node dirties only it + downstream. analyze 16/16, total 36/36. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/analyze.sx | 88 ++++++++++++++++++++++++++ lib/artdag/conformance.sh | 12 +++- lib/artdag/scoreboard.json | 7 ++- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/analyze.sx | 119 ++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 19 ++++-- 6 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 lib/artdag/analyze.sx create mode 100644 lib/artdag/tests/analyze.sx diff --git a/lib/artdag/analyze.sx b/lib/artdag/analyze.sx new file mode 100644 index 00000000..03ac4a12 --- /dev/null +++ b/lib/artdag/analyze.sx @@ -0,0 +1,88 @@ +; lib/artdag/analyze.sx — Phase 2: Analyze on Datalog. +; Project the DAG's edges into a Datalog db and answer dependency questions +; (deps, dependents, transitive reachability) plus dirty-closure propagation +; as recursive Datalog — the acl/relations reachability shape. Depends on +; lib/artdag/dag.sx and the lib/datalog/ public API. + +; edge(input-id, node-id): data flows input -> node (input is a dependency). +(define + artdag/edge-facts + (fn + (dag) + (reduce + (fn + (acc id) + (concat + acc + (map + (fn (in) (list (quote edge) in id)) + (artdag/node-inputs (artdag/dag-get dag id))))) + (list) + (keys (artdag/dag-nodes dag))))) + +; reachable(X,Y): Y is a transitive dependent of X (forward, downstream). +(define + artdag/reach-rules + (quote + ((reachable X Y <- (edge X Y)) + (reachable X Z <- (edge X Y) (reachable Y Z))))) + +(define + artdag/analyze + (fn (dag) (dl-program-data (artdag/edge-facts dag) artdag/reach-rules))) + +; pull a single variable's bindings out of a subst list, sorted for determinism. +(define + artdag/-bindings + (fn + (substs var) + (artdag/sort-strings (map (fn (s) (get s var)) substs)))) + +; direct dependencies (inputs) of a node. +(define + artdag/deps-of + (fn + (db id) + (artdag/-bindings (dl-query db (list (quote edge) (quote X) id)) :X))) + +; direct dependents of a node. +(define + artdag/dependents-of + (fn + (db id) + (artdag/-bindings (dl-query db (list (quote edge) id (quote Y))) :Y))) + +; transitive dependents (everything downstream of a node). +(define + artdag/reachable-from + (fn + (db id) + (artdag/-bindings + (dl-query db (list (quote reachable) id (quote Y))) + :Y))) + +; transitive dependencies (everything upstream of a node). +(define + artdag/ancestors-of + (fn + (db id) + (artdag/-bindings + (dl-query db (list (quote reachable) (quote X) id)) + :X))) + +; dirty propagation: dirty(Y) :- edge(X,Y), dirty(X). Seeds are changed nodes. +(define artdag/dirty-rules (quote ((dirty Y <- (edge X Y) (dirty X))))) + +(define + artdag/dirty-seeds + (fn (changed) (map (fn (c) (list (quote dirty) c)) changed))) + +; transitive dirty closure of a set of changed node-ids: the changed nodes plus +; every transitive dependent that must recompute. Sorted, deduplicated. +(define + artdag/dirty-closure + (fn + (dag changed) + (let + ((db (dl-program-data (concat (artdag/edge-facts dag) (artdag/dirty-seeds changed)) artdag/dirty-rules))) + (artdag/-bindings (dl-query db (list (quote dirty) (quote X))) :X)))) diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 5424ae49..63c902ec 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag) +SUITES=(dag analyze) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -27,7 +27,17 @@ run_suite() { (epoch 1) (load "spec/stdlib.sx") (load "lib/r7rs.sx") +(load "lib/datalog/tokenizer.sx") +(load "lib/datalog/parser.sx") +(load "lib/datalog/unify.sx") +(load "lib/datalog/db.sx") +(load "lib/datalog/builtins.sx") +(load "lib/datalog/aggregates.sx") +(load "lib/datalog/strata.sx") +(load "lib/datalog/eval.sx") +(load "lib/datalog/api.sx") (load "lib/artdag/dag.sx") +(load "lib/artdag/analyze.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 1ca0b565..46d9d6e8 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -1,8 +1,9 @@ { "suites": { - "dag": {"pass": 20, "fail": 0} + "dag": {"pass": 20, "fail": 0}, + "analyze": {"pass": 16, "fail": 0} }, - "total_pass": 20, + "total_pass": 36, "total_fail": 0, - "total": 20 + "total": 36 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 34e8136d..43f3868d 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -5,4 +5,5 @@ _Generated by `lib/artdag/conformance.sh`_ | Suite | Pass | Fail | Total | |-------|-----:|-----:|------:| | dag | 20 | 0 | 20 | -| **Total** | **20** | **0** | **20** | +| analyze | 16 | 0 | 16 | +| **Total** | **36** | **0** | **36** | diff --git a/lib/artdag/tests/analyze.sx b/lib/artdag/tests/analyze.sx new file mode 100644 index 00000000..fc84f045 --- /dev/null +++ b/lib/artdag/tests/analyze.sx @@ -0,0 +1,119 @@ +; Phase 2 — Analyze on Datalog: deps/dependents/reachability + dirty closure. + +; diamond: a -> b, a -> c, (b,c) -> d +(define + an-D + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "g" (list "a") {}) + (list "d" "add" (list "b" "c") {} true)))) +(define an-db (artdag/analyze an-D)) +(define an-a (artdag/dag-id an-D "a")) +(define an-b (artdag/dag-id an-D "b")) +(define an-c (artdag/dag-id an-D "c")) +(define an-d (artdag/dag-id an-D "d")) + +; ---- direct deps / dependents ---- + +(artdag-test + "deps-of: direct inputs" + (artdag/deps-of an-db an-d) + (artdag/sort-strings (list an-b an-c))) + +(artdag-test "deps-of: leaf has none" (artdag/deps-of an-db an-a) (list)) + +(artdag-test + "dependents-of: direct consumers" + (artdag/dependents-of an-db an-a) + (artdag/sort-strings (list an-b an-c))) + +(artdag-test + "dependents-of: output has none" + (artdag/dependents-of an-db an-d) + (list)) + +; ---- transitive reachability ---- + +(artdag-test + "reachable-from: all downstream" + (artdag/reachable-from an-db an-a) + (artdag/sort-strings (list an-b an-c an-d))) + +(artdag-test + "reachable-from: mid node reaches output" + (artdag/reachable-from an-db an-b) + (list an-d)) + +(artdag-test + "ancestors-of: all upstream" + (artdag/ancestors-of an-db an-d) + (artdag/sort-strings (list an-a an-b an-c))) + +(artdag-test + "ancestors-of: leaf has none" + (artdag/ancestors-of an-db an-a) + (list)) + +; ---- deep chain ---- + +(define + ch-D + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "f" (list "b") {}) + (list "d" "f" (list "c") {})))) +(define ch-db (artdag/analyze ch-D)) + +(artdag-test + "deep chain: reachable-from leaf" + (artdag/reachable-from ch-db (artdag/dag-id ch-D "a")) + (artdag/sort-strings + (list + (artdag/dag-id ch-D "b") + (artdag/dag-id ch-D "c") + (artdag/dag-id ch-D "d")))) + +(artdag-test + "deep chain: ancestors of tip" + (artdag/ancestors-of ch-db (artdag/dag-id ch-D "d")) + (artdag/sort-strings + (list + (artdag/dag-id ch-D "a") + (artdag/dag-id ch-D "b") + (artdag/dag-id ch-D "c")))) + +; ---- dirty closure ---- + +(artdag-test + "dirty closure: change leaf dirties all" + (artdag/dirty-closure an-D (list an-a)) + (artdag/sort-strings (list an-a an-b an-c an-d))) + +(artdag-test + "dirty closure: change mid touches only downstream" + (artdag/dirty-closure an-D (list an-b)) + (artdag/sort-strings (list an-b an-d))) + +(artdag-test + "dirty closure: unaffected stay clean (count)" + (len (artdag/dirty-closure an-D (list an-b))) + 2) + +(artdag-test + "dirty closure: change output dirties only itself" + (artdag/dirty-closure an-D (list an-d)) + (list an-d)) + +(artdag-test + "dirty closure: multiple seeds union" + (artdag/dirty-closure an-D (list an-b an-c)) + (artdag/sort-strings (list an-b an-c an-d))) + +(artdag-test + "dirty closure: empty seed set" + (artdag/dirty-closure an-D (list)) + (list)) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index bf5aed91..0ce869c1 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **20/20** (1 suite: dag) +`bash lib/artdag/conformance.sh` → **36/36** (2 suites: dag, analyze) ## Ground rules @@ -88,11 +88,11 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 2 — Analyze (Datalog) -- [ ] `lib/artdag/analyze.sx` — project edges to Datalog; `deps-of`, `dependents-of`, +- [x] `lib/artdag/analyze.sx` — project edges to Datalog; `deps-of`, `dependents-of`, transitive `reachable` (the recursive-reachability shape) -- [ ] **dirty propagation:** given a set of changed nodes, compute the transitive +- [x] **dirty propagation:** given a set of changed nodes, compute the transitive set of dependents that must recompute (`dirty-closure`) -- [ ] `lib/artdag/tests/analyze.sx` — deep chains, diamonds, dirty closure +- [x] `lib/artdag/tests/analyze.sx` — deep chains, diamonds, dirty closure correctness, unaffected nodes stay clean ## Phase 3 — Plan @@ -136,6 +136,17 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Phase 2 — Analyze on Datalog** (analyze suite 16/16, total 36/36). + `lib/artdag/analyze.sx`: `artdag/edge-facts` projects each `(input-id, node-id)` + pair to an `(edge ...)` fact; `artdag/analyze` builds a `dl-program-data` db with + recursive `reachable(X,Y) :- edge(X,Y); edge(X,Y),reachable(Y,Z)` (the acl/relations + reachability shape). Query helpers `deps-of`/`dependents-of` (direct), + `reachable-from` (transitive downstream), `ancestors-of` (transitive upstream), all + returning sorted id lists. `dirty-closure` builds a db with `dirty(Y) :- edge(X,Y), + dirty(X)` seeded by changed-node facts and returns the transitive forward closure — + keystone test confirms changing a mid node dirties only it + downstream, leaving + siblings/upstream clean. Content-ids work as opaque Datalog string constants. + - **Phase 1 — DAG model + content addressing** (dag suite 20/20). `lib/artdag/dag.sx`: node `{:op :inputs :params :commutative}`; `artdag/content-id` = `"node:"` + a deterministic canonical serialization of `(op, inputs, params)` with dict keys From 9a0f3d872cac33aed62fcc1541a6ad8210f312cb Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 11:56:13 +0000 Subject: [PATCH 03/13] =?UTF-8?q?artdag:=20Phase=203=20Plan=20=E2=80=94=20?= =?UTF-8?q?topological=20batches=20+=20parallelism=20cap=20+=20dirty=20pla?= =?UTF-8?q?n=20+=2018=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan.sx schedules a dag into Kahn-wave batches (parallel-safe), splits waves wider than a cap into sub-batches, and plans incrementally over the dirty closure only (out-of-set deps treated as satisfied cache hits). plan 18/18, total 54/54. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/plan.sx | 100 ++++++++++++++++++++++++++++++ lib/artdag/scoreboard.json | 7 ++- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/plan.sx | 122 +++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 17 ++++-- 6 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 lib/artdag/plan.sx create mode 100644 lib/artdag/tests/plan.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 63c902ec..cb59c336 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze) +SUITES=(dag analyze plan) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -38,6 +38,7 @@ run_suite() { (load "lib/datalog/api.sx") (load "lib/artdag/dag.sx") (load "lib/artdag/analyze.sx") +(load "lib/artdag/plan.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/plan.sx b/lib/artdag/plan.sx new file mode 100644 index 00000000..c0dde7a8 --- /dev/null +++ b/lib/artdag/plan.sx @@ -0,0 +1,100 @@ +; lib/artdag/plan.sx — Phase 3: schedule a DAG (or its dirty subset) into +; topological batches under a max-parallelism cap. A batch is a set of nodes +; whose deps are all satisfied by earlier batches, so they run in parallel. +; cap <= 0 means unlimited width. Depends on dag.sx and analyze.sx. + +; inputs of id that also lie inside the scheduled set (out-of-set deps are +; treated as already satisfied — e.g. clean cache hits in an incremental plan). +(define + artdag/-deps-in + (fn + (dag id sset) + (filter + (fn (in) (artdag/member? in sset)) + (artdag/node-inputs (artdag/dag-get dag id))))) + +(define + artdag/-ready-in + (fn + (dag sset placed) + (filter + (fn + (id) + (and + (not (artdag/member? id placed)) + (artdag/all-in? (artdag/-deps-in dag id sset) placed))) + (artdag/sort-strings sset)))) + +(define + artdag/-batch-loop + (fn + (dag sset placed batches) + (if + (= (len placed) (len sset)) + batches + (let + ((wave (artdag/-ready-in dag sset placed))) + (artdag/-batch-loop + dag + sset + (concat placed wave) + (concat batches (list wave))))))) + +; split a wave into consecutive chunks of at most n (sorted order preserved). +(define + artdag/-chunk + (fn + (xs n) + (if + (<= (len xs) n) + (list xs) + (cons + (slice xs 0 n) + (artdag/-chunk (slice xs n (len xs)) n))))) + +(define + artdag/-cap-split + (fn + (batches cap) + (if + (<= cap 0) + batches + (reduce + (fn (acc b) (concat acc (artdag/-chunk b cap))) + (list) + batches)))) + +; schedule an explicit set of node-ids into capped topological batches. +(define + artdag/plan-subset + (fn + (dag node-ids cap) + (artdag/-cap-split (artdag/-batch-loop dag node-ids (list) (list)) cap))) + +; full plan over every node in the dag. +(define + artdag/plan + (fn (dag cap) (artdag/plan-subset dag (keys (artdag/dag-nodes dag)) cap))) + +; incremental plan: schedule only the dirty closure of the changed nodes. +(define + artdag/plan-dirty + (fn + (dag changed cap) + (artdag/plan-subset dag (artdag/dirty-closure dag changed) cap))) + +; ---- plan inspection ---- + +(define artdag/plan-batches (fn (plan) (len plan))) + +(define + artdag/plan-width + (fn + (plan) + (reduce (fn (m b) (if (> (len b) m) (len b) m)) 0 plan))) + +(define + artdag/plan-flatten + (fn (plan) (reduce (fn (acc b) (concat acc b)) (list) plan))) + +(define artdag/plan-size (fn (plan) (len (artdag/plan-flatten plan)))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 46d9d6e8..cb0b8677 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -1,9 +1,10 @@ { "suites": { "dag": {"pass": 20, "fail": 0}, - "analyze": {"pass": 16, "fail": 0} + "analyze": {"pass": 16, "fail": 0}, + "plan": {"pass": 18, "fail": 0} }, - "total_pass": 36, + "total_pass": 54, "total_fail": 0, - "total": 36 + "total": 54 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 43f3868d..30ab2fc4 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -6,4 +6,5 @@ _Generated by `lib/artdag/conformance.sh`_ |-------|-----:|-----:|------:| | dag | 20 | 0 | 20 | | analyze | 16 | 0 | 16 | -| **Total** | **36** | **0** | **36** | +| plan | 18 | 0 | 18 | +| **Total** | **54** | **0** | **54** | diff --git a/lib/artdag/tests/plan.sx b/lib/artdag/tests/plan.sx new file mode 100644 index 00000000..100155c6 --- /dev/null +++ b/lib/artdag/tests/plan.sx @@ -0,0 +1,122 @@ +; Phase 3 — Plan: topological batches under a parallelism cap, incremental plan. + +; diamond: a -> b, a -> c, (b,c) -> d +(define + pl-D + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "g" (list "a") {}) + (list "d" "add" (list "b" "c") {} true)))) +(define pl-a (artdag/dag-id pl-D "a")) +(define pl-b (artdag/dag-id pl-D "b")) +(define pl-c (artdag/dag-id pl-D "c")) +(define pl-d (artdag/dag-id pl-D "d")) + +; wide: a -> b, c, e, f (four independent dependents) +(define + pl-W + (artdag/build + (list + (list "a" "load" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "g" (list "a") {}) + (list "e" "h" (list "a") {}) + (list "f" "k" (list "a") {})))) + +; ---- full plan, unlimited width ---- + +(artdag-test + "full plan: batch count" + (artdag/plan-batches (artdag/plan pl-D 0)) + 3) + +(artdag-test + "full plan: schedules every node" + (artdag/plan-size (artdag/plan pl-D 0)) + 4) + +(artdag-test + "full plan: first batch is the leaf" + (first (artdag/plan pl-D 0)) + (list pl-a)) + +(artdag-test + "full plan: middle batch runs b,c in parallel" + (first (rest (artdag/plan pl-D 0))) + (artdag/sort-strings (list pl-b pl-c))) + +(artdag-test + "full plan: last batch is the sink" + (first (rest (rest (artdag/plan pl-D 0)))) + (list pl-d)) + +(artdag-test + "full plan: max width is 2" + (artdag/plan-width (artdag/plan pl-D 0)) + 2) + +; ---- parallelism cap ---- + +(artdag-test + "cap 1: width never exceeds 1" + (artdag/plan-width (artdag/plan pl-D 1)) + 1) + +(artdag-test + "cap 1: serializes into one node per batch" + (artdag/plan-batches (artdag/plan pl-D 1)) + 4) + +(artdag-test + "cap larger than widest wave is a no-op" + (artdag/plan pl-D 10) + (artdag/plan pl-D 0)) + +(artdag-test + "wide cap 2: width capped at 2" + (artdag/plan-width (artdag/plan pl-W 2)) + 2) + +(artdag-test + "wide cap 2: leaf wave then two capped sub-batches" + (artdag/plan-batches (artdag/plan pl-W 2)) + 3) + +(artdag-test + "wide cap 2: still schedules all five nodes" + (artdag/plan-size (artdag/plan pl-W 2)) + 5) + +(artdag-test + "wide unlimited: single wave of four after leaf" + (artdag/plan-width (artdag/plan pl-W 0)) + 4) + +; ---- incremental (dirty-only) plan ---- + +(artdag-test + "dirty plan: schedules only the dirty closure" + (artdag/plan-size (artdag/plan-dirty pl-D (list pl-b) 0)) + 2) + +(artdag-test + "dirty plan: b then d" + (artdag/plan-dirty pl-D (list pl-b) 0) + (list (list pl-b) (list pl-d))) + +(artdag-test + "dirty plan: clean deps treated as satisfied" + (first (artdag/plan-dirty pl-D (list pl-b) 0)) + (list pl-b)) + +(artdag-test + "dirty plan: leaf change replans whole graph" + (artdag/plan-size (artdag/plan-dirty pl-D (list pl-a) 0)) + 4) + +(artdag-test + "dirty plan: sink change is a single batch" + (artdag/plan-dirty pl-D (list pl-d) 0) + (list (list pl-d))) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 0ce869c1..4d8ab8e7 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **36/36** (2 suites: dag, analyze) +`bash lib/artdag/conformance.sh` → **54/54** (3 suites: dag, analyze, plan) ## Ground rules @@ -97,10 +97,10 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 3 — Plan -- [ ] `lib/artdag/plan.sx` — schedule into topological **batches** (each batch's +- [x] `lib/artdag/plan.sx` — schedule into topological **batches** (each batch's nodes have all deps satisfied → run in parallel); respect a max-parallelism limit -- [ ] plan over the *dirty* subset only (incremental plan) -- [ ] `lib/artdag/tests/plan.sx` — batch correctness, parallelism cap, dirty-only plan +- [x] plan over the *dirty* subset only (incremental plan) +- [x] `lib/artdag/tests/plan.sx` — batch correctness, parallelism cap, dirty-only plan - [ ] (optional/later) miniKanren constraint scheduling — flag, don't block on it ## Phase 4 — Execute (incremental + memoized) @@ -136,6 +136,15 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Phase 3 — Plan** (plan suite 18/18, total 54/54). `lib/artdag/plan.sx`: + `artdag/plan` schedules a dag into Kahn-wave topological batches — each batch's + nodes have all in-scope deps satisfied by earlier batches, so they run in parallel. + A `cap` (>0) splits any wave wider than the cap into consecutive sub-batches; + `cap<=0` is unlimited. `artdag/plan-dirty` schedules only the dirty closure: deps + outside the scheduled set (clean cache hits) count as already satisfied, so a + mid-node change yields just `[[changed]…[downstream]]`. Inspection helpers + `plan-batches`/`plan-width`/`plan-size`/`plan-flatten`. + - **Phase 2 — Analyze on Datalog** (analyze suite 16/16, total 36/36). `lib/artdag/analyze.sx`: `artdag/edge-facts` projects each `(input-id, node-id)` pair to an `(edge ...)` fact; `artdag/analyze` builds a `dl-program-data` db with From a2f4fb5e894da8b0dcd9d385294d49c1edd3b809 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:00:50 +0000 Subject: [PATCH 04/13] =?UTF-8?q?artdag:=20Phase=204=20Execute=20=E2=80=94?= =?UTF-8?q?=20content-addressed=20memo=20+=20incremental=20recompute=20+?= =?UTF-8?q?=2015=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute.sx folds a plan, runs each node via an injected runner (perform in prod, op-table in tests), and memoizes results in a lib/persist kv backend keyed by content-id. Incremental recompute falls out of content addressing: a leaf change reassigns ids across its dirty closure, so re-running hits the unchanged nodes and recomputes only the closure (cold 5 -> rerun 0 -> change 3). Cross-dag subgraph sharing verified. execute 15/15, total 69/69. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 8 +- lib/artdag/execute.sx | 82 ++++++++++++++++ lib/artdag/scoreboard.json | 7 +- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/execute.sx | 188 ++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 22 ++++- 6 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 lib/artdag/execute.sx create mode 100644 lib/artdag/tests/execute.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index cb59c336..0d439b5a 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan) +SUITES=(dag analyze plan execute) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -36,9 +36,15 @@ run_suite() { (load "lib/datalog/strata.sx") (load "lib/datalog/eval.sx") (load "lib/datalog/api.sx") +(load "lib/persist/event.sx") +(load "lib/persist/backend.sx") +(load "lib/persist/log.sx") +(load "lib/persist/kv.sx") +(load "lib/persist/api.sx") (load "lib/artdag/dag.sx") (load "lib/artdag/analyze.sx") (load "lib/artdag/plan.sx") +(load "lib/artdag/execute.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/execute.sx b/lib/artdag/execute.sx new file mode 100644 index 00000000..d0bc1d18 --- /dev/null +++ b/lib/artdag/execute.sx @@ -0,0 +1,82 @@ +; lib/artdag/execute.sx — Phase 4: interpret a plan with a content-addressed +; memo cache. A node's result is keyed by its content-id, so a node whose id is +; already in the cache is skipped (cache hit). Because changing a leaf changes +; the content-ids of its whole dirty closure, re-running recomputes exactly those +; nodes and cache-hits the rest — incremental recompute falls out of content +; addressing. Depends on dag.sx and plan.sx; the cache is a lib/persist/ backend. + +; runner: (fn (op params input-results) -> result). The injected effect interface. +; In production this performs the op (perform -> JAX/IPFS adapter); in tests it +; dispatches a pure SX op over its already-computed input results. + +; build a runner from a dict of op-name -> (fn (params inputs) -> result). +(define + artdag/op-table-runner + (fn (table) (fn (op params inputs) ((get table op) params inputs)))) + +; resolve an input id's result: this run's results first, then the warm cache. +(define + artdag/-input-result + (fn + (results cache in) + (if (has-key? results in) (get results in) (persist/kv-get cache in)))) + +(define + artdag/-exec-node + (fn + (dag runner cache acc id) + (let + ((node (artdag/dag-get dag id))) + (if + (persist/kv-has? cache id) + (assoc + acc + :results (assoc (get acc :results) id (persist/kv-get cache id)) + :hits (concat (get acc :hits) (list id))) + (let + ((inputs (map (fn (in) (artdag/-input-result (get acc :results) cache in)) (artdag/node-inputs node)))) + (let + ((result (runner (artdag/node-op node) (artdag/node-params node) inputs))) + (begin + (persist/kv-put cache id result) + (assoc + acc + :results (assoc (get acc :results) id result) + :recomputed (concat (get acc :recomputed) (list id)))))))))) + +; execute a plan against a memo cache, returning {:results :recomputed :hits}. +(define + artdag/execute + (fn + (dag plan runner cache) + (reduce + (fn (acc id) (artdag/-exec-node dag runner cache acc id)) + {:recomputed (list) :results {} :hits (list)} + (artdag/plan-flatten plan)))) + +; full run over every node, unlimited width. +(define + artdag/run + (fn + (dag runner cache) + (artdag/execute dag (artdag/plan dag 0) runner cache))) + +; incremental run: schedule only the dirty closure of the changed nodes. +(define + artdag/run-dirty + (fn + (dag changed runner cache) + (artdag/execute + dag + (artdag/plan-dirty dag changed 0) + runner + cache))) + +; ---- result inspection ---- + +(define artdag/result-of (fn (exec id) (get (get exec :results) id))) +(define + artdag/recomputed + (fn (exec) (artdag/sort-strings (get exec :recomputed)))) +(define artdag/recompute-count (fn (exec) (len (get exec :recomputed)))) +(define artdag/hit-count (fn (exec) (len (get exec :hits)))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index cb0b8677..d6412a3e 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -2,9 +2,10 @@ "suites": { "dag": {"pass": 20, "fail": 0}, "analyze": {"pass": 16, "fail": 0}, - "plan": {"pass": 18, "fail": 0} + "plan": {"pass": 18, "fail": 0}, + "execute": {"pass": 15, "fail": 0} }, - "total_pass": 54, + "total_pass": 69, "total_fail": 0, - "total": 54 + "total": 69 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 30ab2fc4..4a901cc4 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -7,4 +7,5 @@ _Generated by `lib/artdag/conformance.sh`_ | dag | 20 | 0 | 20 | | analyze | 16 | 0 | 16 | | plan | 18 | 0 | 18 | -| **Total** | **54** | **0** | **54** | +| execute | 15 | 0 | 15 | +| **Total** | **69** | **0** | **69** | diff --git a/lib/artdag/tests/execute.sx b/lib/artdag/tests/execute.sx new file mode 100644 index 00000000..bc36b8d1 --- /dev/null +++ b/lib/artdag/tests/execute.sx @@ -0,0 +1,188 @@ +; Phase 4 — Execute: effect interpreter + content-addressed memo + incremental. + +(define ex-RT (artdag/op-table-runner {:in (fn (params inputs) (get params :v)) :add (fn (params inputs) (+ (nth inputs 0) (nth inputs 1))) :inc (fn (params inputs) (+ 1 (first inputs)))})) + +; two-leaf diamond: p,q leaves; b=inc(p); c=inc(q); d=add(b,c) +(define + ex-D1 + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 20}) + (list "b" "inc" (list "p") {}) + (list "c" "inc" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +; same shape, leaf q changed (20 -> 21) +(define + ex-D2 + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 21}) + (list "b" "inc" (list "p") {}) + (list "c" "inc" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +; a different dag that shares the p->b subgraph with ex-D1, plus z=inc(b) +(define + ex-D3 + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "b" "inc" (list "p") {}) + (list "z" "inc" (list "b") {})))) + +; ---- full execution ---- + +(artdag-test + "full run: result is correct" + (let + ((cache (persist/open))) + (artdag/result-of + (artdag/run ex-D1 ex-RT cache) + (artdag/dag-id ex-D1 "d"))) + 32) + +(artdag-test + "full run: cold cache recomputes every node" + (let + ((cache (persist/open))) + (artdag/recompute-count (artdag/run ex-D1 ex-RT cache))) + 5) + +(artdag-test + "full run: cold cache has no hits" + (let + ((cache (persist/open))) + (artdag/hit-count (artdag/run ex-D1 ex-RT cache))) + 0) + +; ---- memoization ---- + +(artdag-test + "re-run unchanged: zero recomputes" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/recompute-count (artdag/run ex-D1 ex-RT cache)))) + 0) + +(artdag-test + "re-run unchanged: all cache hits" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/hit-count (artdag/run ex-D1 ex-RT cache)))) + 5) + +(artdag-test + "re-run unchanged: result preserved" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/result-of + (artdag/run ex-D1 ex-RT cache) + (artdag/dag-id ex-D1 "d")))) + 32) + +; ---- incremental recompute (the keystone) ---- + +(artdag-test + "leaf change recomputes only the dirty closure (count)" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/recompute-count (artdag/run ex-D2 ex-RT cache)))) + 3) + +(artdag-test + "leaf change: unchanged nodes are cache hits" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/hit-count (artdag/run ex-D2 ex-RT cache)))) + 2) + +(artdag-test + "leaf change: recomputed set is exactly q,c,d" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/recomputed (artdag/run ex-D2 ex-RT cache)))) + (artdag/sort-strings + (list + (artdag/dag-id ex-D2 "q") + (artdag/dag-id ex-D2 "c") + (artdag/dag-id ex-D2 "d")))) + +(artdag-test + "leaf change: untouched sibling p is reused" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/member? + (artdag/dag-id ex-D2 "p") + (get (artdag/run ex-D2 ex-RT cache) :hits)))) + true) + +(artdag-test + "leaf change: new result is correct" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/result-of + (artdag/run ex-D2 ex-RT cache) + (artdag/dag-id ex-D2 "d")))) + 33) + +; ---- explicit dirty-only execution ---- + +(artdag-test + "run-dirty: schedules only the changed closure" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/recompute-count + (artdag/run-dirty ex-D2 (list (artdag/dag-id ex-D2 "q")) ex-RT cache)))) + 3) + +; ---- cross-dag cache sharing (content addressing) ---- + +(artdag-test + "shared subgraph hits cache across different dags" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/recompute-count (artdag/run ex-D3 ex-RT cache)))) + 1) + +(artdag-test + "shared subgraph: p and b reused across dags" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/hit-count (artdag/run ex-D3 ex-RT cache)))) + 2) + +(artdag-test + "shared subgraph: z still computes correctly" + (let + ((cache (persist/open))) + (begin + (artdag/run ex-D1 ex-RT cache) + (artdag/result-of + (artdag/run ex-D3 ex-RT cache) + (artdag/dag-id ex-D3 "z")))) + 12) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 4d8ab8e7..7421af6a 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **54/54** (3 suites: dag, analyze, plan) +`bash lib/artdag/conformance.sh` → **69/69** (4 suites: dag, analyze, plan, execute) ## Ground rules @@ -105,13 +105,13 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 4 — Execute (incremental + memoized) -- [ ] `lib/artdag/execute.sx` — interpret a plan: each node op runs via `perform` +- [x] `lib/artdag/execute.sx` — interpret a plan: each node op runs via `perform` (mocked op in tests); results keyed by content-id -- [ ] **content-addressed memo cache** backed by `lib/persist/`: a node whose +- [x] **content-addressed memo cache** backed by `lib/persist/`: a node whose content-id already has a stored result is skipped (cache hit) -- [ ] **incremental execute:** re-running after a leaf change recomputes only the +- [x] **incremental execute:** re-running after a leaf change recomputes only the dirty closure; everything else is a cache hit -- [ ] `lib/artdag/tests/execute.sx` — full run, cache-hit on re-run, incremental +- [x] `lib/artdag/tests/execute.sx` — full run, cache-hit on re-run, incremental recompute touches only dirty nodes (assert recompute count) ## Phase 5 — Effect-pipeline optimization @@ -136,6 +136,18 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Phase 4 — Execute (incremental + memoized)** (execute suite 15/15, total 69/69). + `lib/artdag/execute.sx`: `artdag/execute` folds a plan, computing each node via an + injected `runner (op params input-results)` (production = `perform` to JAX/IPFS + adapter; tests = a pure op-table) and memoizing the result in a `lib/persist/` kv + backend keyed by **content-id**. A node whose content-id is already cached is a hit + (skipped). The keystone falls out of content addressing: changing a leaf changes the + ids of its whole dirty closure, so re-running the full plan against a warm cache + recomputes exactly those nodes and hits the rest — verified by recompute/hit counts + (5 cold → 0 on rerun → 3 after one leaf change, sibling reused). Cross-DAG sharing + verified: a different DAG containing a shared subgraph cache-hits it. `run`/`run-dirty` + helpers; `result-of`/`recompute-count`/`hit-count`/`recomputed` inspection. + - **Phase 3 — Plan** (plan suite 18/18, total 54/54). `lib/artdag/plan.sx`: `artdag/plan` schedules a dag into Kahn-wave topological batches — each batch's nodes have all in-scope deps satisfied by earlier batches, so they run in parallel. From 228861215de019bccde8a15c52e88710c687d1a7 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:08:12 +0000 Subject: [PATCH 05/13] =?UTF-8?q?artdag:=20Phase=205=20optimization=20?= =?UTF-8?q?=E2=80=94=20DCE=20+=20CSE=20+=20adjacent-op=20fusion=20+=2018?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit optimize.sx adds three result-preserving passes: dce (keep outputs + ancestors, preserve ids), cse (==build; structural sharing is free from content addressing), and fuse (collapse 1-to-1 fusible unary chains into an artdag/pipeline node fed by the chain head's input; leaves/fan-out/non-fusible ops never fuse). fusing-runner replays pipeline stages, output-equivalent to the unfused dag. optimize 18/18, total 87/87. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/optimize.sx | 190 +++++++++++++++++++++++++++++++++++ lib/artdag/scoreboard.json | 7 +- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/optimize.sx | 176 ++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 21 +++- 6 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 lib/artdag/optimize.sx create mode 100644 lib/artdag/tests/optimize.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 0d439b5a..dcccfa4a 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan execute) +SUITES=(dag analyze plan execute optimize) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -45,6 +45,7 @@ run_suite() { (load "lib/artdag/analyze.sx") (load "lib/artdag/plan.sx") (load "lib/artdag/execute.sx") +(load "lib/artdag/optimize.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/optimize.sx b/lib/artdag/optimize.sx new file mode 100644 index 00000000..022c4825 --- /dev/null +++ b/lib/artdag/optimize.sx @@ -0,0 +1,190 @@ +; lib/artdag/optimize.sx — Phase 5: result-preserving DAG rewrites. +; DCE — drop nodes not reachable upstream from the requested outputs. +; CSE — free from content addressing: structurally identical subexpressions +; already collapse to one node at build time (artdag/cse == build). +; Fusion — collapse a maximal 1-to-1 chain of fusible unary ops into a single +; "artdag/pipeline" node that replays the stages; output-equivalent. +; Depends on dag.sx and analyze.sx. + +; ---- dict helper ---- + +(define + artdag/-dict-filter + (fn + (d keep?) + (reduce + (fn (acc k) (if (keep? k (get d k)) (assoc acc k (get d k)) acc)) + {} + (keys d)))) + +(define + artdag/-union + (fn + (a b) + (reduce (fn (acc x) (if (artdag/member? x acc) acc (cons x acc))) a b))) + +; ---- dead-node elimination ---- +; keep only the outputs and their transitive dependencies; ids are preserved. +(define + artdag/dce + (fn + (dag outputs) + (let + ((db (artdag/analyze dag))) + (let + ((live (reduce (fn (acc out) (artdag/-union (artdag/-union acc (list out)) (artdag/ancestors-of db out))) (list) outputs))) + {:names (artdag/-dict-filter (artdag/dag-names dag) (fn (k v) (artdag/member? v live))) :order (filter (fn (id) (artdag/member? id live)) (artdag/dag-order dag)) :ok true :nodes (artdag/-dict-filter (artdag/dag-nodes dag) (fn (k v) (artdag/member? k live)))})))) + +; ---- common-subexpression elimination ---- +; structural sharing is inherent to content addressing: build already maps +; structurally identical specs to a single node/id. +(define artdag/cse artdag/build) + +; ---- adjacent-op fusion (entry-level rewrite) ---- + +(define artdag/pipeline-op "artdag/pipeline") + +(define + artdag/-name->entry + (fn + (entries) + (reduce + (fn (m e) (assoc m (artdag/entry-name e) e)) + {} + entries))) + +; name -> list of dependent names +(define + artdag/-deps-map + (fn + (entries) + (reduce + (fn + (m e) + (reduce + (fn + (mm i) + (assoc + mm + i + (cons + (artdag/entry-name e) + (if (has-key? mm i) (get mm i) (list))))) + m + (artdag/entry-inputs e))) + {} + entries))) + +(define artdag/-stage (fn (e) {:op (artdag/entry-op e) :params (artdag/entry-params e)})) + +; the single predecessor that `name` may absorb, or nil. Requires: name is a +; fusible unary op; its one input is a locally-defined fusible node whose ONLY +; dependent is name (so fusing cannot break sharing). +(define + artdag/-absorbs + (fn + (n->e deps fusible? name) + (let + ((e (get n->e name))) + (let + ((ins (artdag/entry-inputs e))) + (if + (= (len ins) 1) + (let + ((x (first ins))) + (if + (and + (has-key? n->e x) + (fusible? (artdag/entry-op e)) + (fusible? (artdag/entry-op (get n->e x))) + (= (get deps x) (list name))) + x + nil)) + nil))))) + +(define + artdag/-absorbed-set + (fn + (n->e deps fusible? names) + (reduce + (fn + (acc y) + (let + ((p (artdag/-absorbs n->e deps fusible? y))) + (if (nil? p) acc (cons p acc)))) + (list) + names))) + +; walk predecessors from a tail, building stages head->tail. +(define + artdag/-fuse-chain + (fn + (n->e deps fusible? cur stages) + (let + ((p (artdag/-absorbs n->e deps fusible? cur))) + (if + (nil? p) + {:stages (cons (artdag/-stage (get n->e cur)) stages) :head cur} + (artdag/-fuse-chain + n->e + deps + fusible? + p + (cons (artdag/-stage (get n->e cur)) stages)))))) + +(define + artdag/fuse-entries + (fn + (entries fusible?) + (let + ((n->e (artdag/-name->entry entries)) + (deps (artdag/-deps-map entries)) + (names (map artdag/entry-name entries))) + (let + ((absorbed (artdag/-absorbed-set n->e deps fusible? names))) + (map + (fn + (name) + (let + ((c (artdag/-fuse-chain n->e deps fusible? name (list)))) + (if + (> (len (get c :stages)) 1) + (list + name + artdag/pipeline-op + (artdag/entry-inputs (get n->e (get c :head))) + {:stages (get c :stages)}) + (get n->e name)))) + (filter (fn (name) (not (artdag/member? name absorbed))) names)))))) + +(define + artdag/fuse + (fn + (entries fusible?) + (artdag/build (artdag/fuse-entries entries fusible?)))) + +; runner that replays a fused pipeline over its single input, delegating each +; stage to a base runner; non-pipeline ops fall through unchanged. +(define + artdag/pipeline-run + (fn + (base-runner) + (fn + (params inputs) + (reduce + (fn + (val stage) + (base-runner (get stage :op) (get stage :params) (list val))) + (first inputs) + (get params :stages))))) + +(define + artdag/fusing-runner + (fn + (base-runner) + (fn + (op params inputs) + (if + (= op artdag/pipeline-op) + ((artdag/pipeline-run base-runner) params inputs) + (base-runner op params inputs))))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index d6412a3e..7be1073f 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -3,9 +3,10 @@ "dag": {"pass": 20, "fail": 0}, "analyze": {"pass": 16, "fail": 0}, "plan": {"pass": 18, "fail": 0}, - "execute": {"pass": 15, "fail": 0} + "execute": {"pass": 15, "fail": 0}, + "optimize": {"pass": 18, "fail": 0} }, - "total_pass": 69, + "total_pass": 87, "total_fail": 0, - "total": 69 + "total": 87 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 4a901cc4..14f8c5c8 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -8,4 +8,5 @@ _Generated by `lib/artdag/conformance.sh`_ | analyze | 16 | 0 | 16 | | plan | 18 | 0 | 18 | | execute | 15 | 0 | 15 | -| **Total** | **69** | **0** | **69** | +| optimize | 18 | 0 | 18 | +| **Total** | **87** | **0** | **87** | diff --git a/lib/artdag/tests/optimize.sx b/lib/artdag/tests/optimize.sx new file mode 100644 index 00000000..6e275ad0 --- /dev/null +++ b/lib/artdag/tests/optimize.sx @@ -0,0 +1,176 @@ +; Phase 5 — optimization: DCE, CSE (content-id sharing), adjacent-op fusion. + +(define opt-BASE (artdag/op-table-runner {:in (fn (params inputs) (get params :v)) :sq (fn (params inputs) (* (first inputs) (first inputs))) :add (fn (params inputs) (+ (nth inputs 0) (nth inputs 1))) :inc (fn (params inputs) (+ 1 (first inputs)))})) +(define opt-RUN (artdag/fusing-runner opt-BASE)) +(define opt-inc? (fn (op) (= op "inc"))) +(define opt-incsq? (fn (op) (or (= op "inc") (= op "sq")))) + +; linear chain a(in) -> b -> c -> d, all inc +(define + opt-chain + (list + (list "a" "in" (list) {:v 5}) + (list "b" "inc" (list "a") {}) + (list "c" "inc" (list "b") {}) + (list "d" "inc" (list "c") {}))) + +; ---- DCE ---- + +(define + dce-entries + (list + (list "a" "in" (list) {:v 5}) + (list "b" "inc" (list "a") {}) + (list "c" "inc" (list "b") {}) + (list "x" "sq" (list "a") {}))) +(define dce-G (artdag/build dce-entries)) + +(artdag-test + "dce: removes dead node" + (artdag/node-count (artdag/dce dce-G (list (artdag/dag-id dce-G "c")))) + 3) + +(artdag-test + "dce: keeps live closure intact" + (artdag/node-count (artdag/dce dce-G (list (artdag/dag-id dce-G "x")))) + 2) + +(artdag-test + "dce: preserves surviving node ids" + (artdag/member? + (artdag/dag-id dce-G "c") + (keys + (artdag/dag-nodes (artdag/dce dce-G (list (artdag/dag-id dce-G "c")))))) + true) + +(artdag-test + "dce: output result unchanged after elimination" + (let + ((cache (persist/open))) + (artdag/result-of + (artdag/run + (artdag/dce dce-G (list (artdag/dag-id dce-G "c"))) + opt-RUN + cache) + (artdag/dag-id dce-G "c"))) + 7) + +(artdag-test + "dce: nothing dead is a no-op on count" + (artdag/node-count + (artdag/dce + dce-G + (list (artdag/dag-id dce-G "c") (artdag/dag-id dce-G "x")))) + 4) + +; ---- CSE (free from content addressing) ---- + +(define + cse-entries + (list + (list "a" "in" (list) {:v 3}) + (list "s1" "sq" (list "a") {}) + (list "s2" "sq" (list "a") {}) + (list "d" "add" (list "s1" "s2") {} true))) +(define cse-C (artdag/cse cse-entries)) + +(artdag-test + "cse: identical subexpressions collapse to one node" + (artdag/node-count cse-C) + 3) + +(artdag-test + "cse: shared node computes once" + (let + ((cache (persist/open))) + (artdag/recompute-count (artdag/run cse-C opt-RUN cache))) + 3) + +(artdag-test + "cse: s1 and s2 are the same id" + (equal? (artdag/dag-id cse-C "s1") (artdag/dag-id cse-C "s2")) + true) + +(artdag-test + "cse: result is correct" + (let + ((cache (persist/open))) + (artdag/result-of + (artdag/run cse-C opt-RUN cache) + (artdag/dag-id cse-C "d"))) + 18) + +; ---- fusion ---- + +(artdag-test + "fusion: collapses a unary chain" + (artdag/node-count (artdag/fuse opt-chain opt-inc?)) + 2) + +(artdag-test + "fusion: unfused has all nodes" + (artdag/node-count (artdag/build opt-chain)) + 4) + +(artdag-test + "fusion: output-equivalent to unfused" + (let + ((c1 (persist/open)) (c2 (persist/open))) + (= + (artdag/result-of + (artdag/run (artdag/build opt-chain) opt-RUN c1) + (artdag/dag-id (artdag/build opt-chain) "d")) + (artdag/result-of + (artdag/run (artdag/fuse opt-chain opt-inc?) opt-RUN c2) + (artdag/dag-id (artdag/fuse opt-chain opt-inc?) "d")))) + true) + +(artdag-test + "fusion: leaf is never fused" + (artdag/node-op + (artdag/dag-node-by-name (artdag/fuse opt-chain opt-inc?) "a")) + "in") + +(artdag-test + "fusion: tail becomes a pipeline node" + (artdag/node-op + (artdag/dag-node-by-name (artdag/fuse opt-chain opt-inc?) "d")) + "artdag/pipeline") + +(artdag-test + "fusion: mixed fusible set fuses across op kinds" + (artdag/node-count + (artdag/fuse + (list + (list "a" "in" (list) {:v 2}) + (list "b" "inc" (list "a") {}) + (list "c" "sq" (list "b") {}) + (list "d" "inc" (list "c") {})) + opt-incsq?)) + 2) + +(artdag-test + "fusion: mixed chain replays correctly" + (let + ((cache (persist/open))) + (let + ((f (artdag/fuse (list (list "a" "in" (list) {:v 2}) (list "b" "inc" (list "a") {}) (list "c" "sq" (list "b") {}) (list "d" "inc" (list "c") {})) opt-incsq?))) + (artdag/result-of (artdag/run f opt-RUN cache) (artdag/dag-id f "d")))) + 10) + +(artdag-test + "fusion: fanout node is not fused" + (artdag/node-count + (artdag/fuse + (list + (list "a" "in" (list) {:v 1}) + (list "b" "inc" (list "a") {}) + (list "c" "inc" (list "b") {}) + (list "e" "sq" (list "b") {})) + opt-inc?)) + 4) + +(artdag-test + "fusion: empty fusible set leaves dag unchanged" + (artdag/node-count (artdag/fuse opt-chain (fn (op) false))) + 4) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 7421af6a..0ebb38b4 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **69/69** (4 suites: dag, analyze, plan, execute) +`bash lib/artdag/conformance.sh` → **87/87** (5 suites: dag, analyze, plan, execute, optimize) ## Ground rules @@ -116,12 +116,12 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 5 — Effect-pipeline optimization -- [ ] `lib/artdag/optimize.sx` — rewrite the DAG before execution: dead-node +- [x] `lib/artdag/optimize.sx` — rewrite the DAG before execution: dead-node elimination (unreachable from outputs), common-subexpression sharing (free from content ids), adjacent-op fusion -- [ ] optimizations are content-id-preserving where semantically identical; assert +- [x] optimizations are content-id-preserving where semantically identical; assert the optimized DAG produces identical results -- [ ] `lib/artdag/tests/optimize.sx` — DCE, CSE dedup, fusion equivalence +- [x] `lib/artdag/tests/optimize.sx` — DCE, CSE dedup, fusion equivalence - [ ] (optional/later) rule-based optimization via `maude-on-sx`'s rewriting engine — flag the integration point, don't block on it @@ -136,6 +136,19 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Phase 5 — Effect-pipeline optimization** (optimize suite 18/18, total 87/87). + `lib/artdag/optimize.sx`: `artdag/dce dag outputs` keeps only the outputs plus + their transitive ancestors (via analyze), preserving surviving content-ids. + `artdag/cse` == build — structural sharing is inherent to content addressing, so + identical subexpressions collapse to one node/id and execute once (verified). + `artdag/fuse entries fusible?` rewrites entries: a maximal 1-to-1 chain of fusible + unary ops (predecessor used only by its single consumer, both fusible) collapses + into one `artdag/pipeline` node carrying ordered `{:op :params}` stages, fed by the + chain head's external input; leaves, fan-out nodes, and non-fusible ops never fuse. + `artdag/fusing-runner` wraps a base runner to replay pipeline stages — output + equivalent to the unfused DAG (asserted). Note: CSE auto-dedup means test fixtures + intended as distinct nodes must use distinct op/params. + - **Phase 4 — Execute (incremental + memoized)** (execute suite 15/15, total 69/69). `lib/artdag/execute.sx`: `artdag/execute` folds a plan, computing each node via an injected `runner (op params input-results)` (production = `perform` to JAX/IPFS From 985dbb4c8fbd9c9c929f0ae92a8bde7dfdbc219e Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:11:11 +0000 Subject: [PATCH 06/13] =?UTF-8?q?artdag:=20Phase=206=20federation=20?= =?UTF-8?q?=E2=80=94=20shared=20content-addressed=20cache=20+=20trust=20+?= =?UTF-8?q?=20invalidation=20+=2015=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit federation.sx: instance = {cache, prov cid->peer}. fed-export/import share results by global content-id (trusted import -> pure cache hit, the L2-registry analog); trust gating rejects untrusted peers; fed-pull uses an injected fetch transport; fed-invalidate drops a peer's provenanced results (peer-scoped, leaves local results). fed 15/15, total 102/102. All 6 phases complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/federation.sx | 75 ++++++++++++++++++ lib/artdag/scoreboard.json | 7 +- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/fed.sx | 157 +++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 22 ++++-- 6 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 lib/artdag/federation.sx create mode 100644 lib/artdag/tests/fed.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index dcccfa4a..0551abb3 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan execute optimize) +SUITES=(dag analyze plan execute optimize fed) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -46,6 +46,7 @@ run_suite() { (load "lib/artdag/plan.sx") (load "lib/artdag/execute.sx") (load "lib/artdag/optimize.sx") +(load "lib/artdag/federation.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/federation.sx b/lib/artdag/federation.sx new file mode 100644 index 00000000..15780b46 --- /dev/null +++ b/lib/artdag/federation.sx @@ -0,0 +1,75 @@ +; lib/artdag/federation.sx — Phase 6: shared content-addressed cache across +; instances (the L2-registry analog). Because content-ids are global, a result +; computed on one instance is reusable on another by id. Imports are trust-gated +; and carry provenance so a peer's results can be invalidated when trust is +; withdrawn. Transport is injected (mock in tests). Depends on dag.sx, execute.sx +; (the cache is a lib/persist/ kv backend) — federation tracks provenance beside it. + +; an instance: a persist kv cache + a provenance map {cid -> origin-peer}. +(define artdag/fed-open (fn () {:cache (persist/open) :prov {}})) +(define artdag/fed-cache (fn (fed) (get fed :cache))) +(define artdag/fed-prov (fn (fed) (get fed :prov))) + +(define + artdag/-dict-remove + (fn + (d key) + (reduce + (fn (acc k) (if (= k key) acc (assoc acc k (get d k)))) + {} + (keys d)))) + +; export every cached result as a bundle of {:cid :result :peer}, tagged with +; the exporting instance's peer id (the result's origin/provenance). +(define + artdag/fed-export + (fn + (fed peer-id) + (map (fn (cid) {:peer peer-id :cid cid :result (persist/kv-get (get fed :cache) cid)}) (persist/kv-keys (get fed :cache))))) + +; import a bundle, accepting only records from trusted peers (trust gating) and +; recording each accepted result's provenance. Returns the updated instance. +(define + artdag/fed-import + (fn + (fed bundle trusted?) + (reduce + (fn + (f rec) + (if + (trusted? (get rec :peer)) + (begin + (persist/kv-put (get f :cache) (get rec :cid) (get rec :result)) + {:cache (get f :cache) :prov (assoc (get f :prov) (get rec :cid) (get rec :peer))}) + f)) + fed + bundle))) + +; pull from a peer through an injected transport (fetch-fn peer-id -> bundle). +(define + artdag/fed-pull + (fn + (fed fetch-fn peer-id trusted?) + (artdag/fed-import fed (fetch-fn peer-id) trusted?))) + +; invalidate: drop every cached result provenanced to a peer (trust withdrawn), +; from both the cache and the provenance map. Locally-computed results (no +; provenance) are untouched. Returns the updated instance. +(define + artdag/fed-invalidate + (fn + (fed peer-id) + (reduce + (fn + (f cid) + (if + (= (get (get f :prov) cid) peer-id) + (begin (persist/kv-delete (get f :cache) cid) {:cache (get f :cache) :prov (artdag/-dict-remove (get f :prov) cid)}) + f)) + fed + (keys (get fed :prov))))) + +; convenience: run a dag against an instance's cache. +(define + artdag/fed-run + (fn (fed dag runner) (artdag/run dag runner (artdag/fed-cache fed)))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 7be1073f..1c4bedd3 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -4,9 +4,10 @@ "analyze": {"pass": 16, "fail": 0}, "plan": {"pass": 18, "fail": 0}, "execute": {"pass": 15, "fail": 0}, - "optimize": {"pass": 18, "fail": 0} + "optimize": {"pass": 18, "fail": 0}, + "fed": {"pass": 15, "fail": 0} }, - "total_pass": 87, + "total_pass": 102, "total_fail": 0, - "total": 87 + "total": 102 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 14f8c5c8..e5d97fb9 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -9,4 +9,5 @@ _Generated by `lib/artdag/conformance.sh`_ | plan | 18 | 0 | 18 | | execute | 15 | 0 | 15 | | optimize | 18 | 0 | 18 | -| **Total** | **87** | **0** | **87** | +| fed | 15 | 0 | 15 | +| **Total** | **102** | **0** | **102** | diff --git a/lib/artdag/tests/fed.sx b/lib/artdag/tests/fed.sx new file mode 100644 index 00000000..981bcfcd --- /dev/null +++ b/lib/artdag/tests/fed.sx @@ -0,0 +1,157 @@ +; Phase 6 — federation: shared content-addressed cache, trust gating, invalidation. + +(define fed-BASE (artdag/op-table-runner {:in (fn (params inputs) (get params :v)) :add (fn (params inputs) (+ (nth inputs 0) (nth inputs 1))) :inc (fn (params inputs) (+ 1 (first inputs)))})) + +(define + fed-D + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 20}) + (list "b" "inc" (list "p") {}) + (list "c" "inc" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +(define fed-trust-A (fn (p) (= p "A"))) +(define fed-trust-none (fn (p) false)) + +; a warmed instance A and its export bundle (origin peer "A"). +(define fed-A (artdag/fed-open)) +(define fed-warm (artdag/fed-run fed-A fed-D fed-BASE)) +(define fed-bundle (artdag/fed-export fed-A "A")) + +; ---- export ---- + +(artdag-test + "export: bundle covers every cached node" + (len fed-bundle) + 5) + +; ---- remote cache hit ---- + +(artdag-test + "trusted import enables remote cache hit (no recompute)" + (artdag/recompute-count + (artdag/fed-run + (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A) + fed-D + fed-BASE)) + 0) + +(artdag-test + "trusted import: every node is a hit" + (artdag/hit-count + (artdag/fed-run + (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A) + fed-D + fed-BASE)) + 5) + +(artdag-test + "remote hit yields correct result" + (artdag/result-of + (artdag/fed-run + (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A) + fed-D + fed-BASE) + (artdag/dag-id fed-D "d")) + 32) + +; ---- trust gating ---- + +(artdag-test + "untrusted peer is rejected (recompute everything)" + (artdag/recompute-count + (artdag/fed-run + (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-none) + fed-D + fed-BASE)) + 5) + +(artdag-test + "trust gating: untrusted records never enter the cache" + (let + ((B (artdag/fed-import (artdag/fed-open) (cons {:peer "C" :cid "node:foreign" :result 99} fed-bundle) fed-trust-A))) + (persist/kv-has? (artdag/fed-cache B) "node:foreign")) + false) + +(artdag-test + "trust gating: trusted records still admitted alongside rejected" + (let + ((B (artdag/fed-import (artdag/fed-open) (cons {:peer "C" :cid "node:foreign" :result 99} fed-bundle) fed-trust-A))) + (persist/kv-has? (artdag/fed-cache B) (artdag/dag-id fed-D "d"))) + true) + +; ---- provenance ---- + +(artdag-test + "provenance is recorded for imported results" + (get + (artdag/fed-prov + (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A)) + (artdag/dag-id fed-D "d")) + "A") + +(artdag-test + "locally computed results carry no provenance" + (len (keys (artdag/fed-prov fed-A))) + 0) + +; ---- injected transport ---- + +(artdag-test + "fed-pull imports via an injected fetch transport" + (artdag/recompute-count + (artdag/fed-run + (artdag/fed-pull + (artdag/fed-open) + (fn (peer) fed-bundle) + "A" + fed-trust-A) + fed-D + fed-BASE)) + 0) + +; ---- invalidation ---- + +(artdag-test + "invalidation drops a peer's results (recompute again)" + (let + ((B (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A))) + (artdag/recompute-count + (artdag/fed-run (artdag/fed-invalidate B "A") fed-D fed-BASE))) + 5) + +(artdag-test + "invalidation: recomputed result still correct" + (let + ((B (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A))) + (artdag/result-of + (artdag/fed-run (artdag/fed-invalidate B "A") fed-D fed-BASE) + (artdag/dag-id fed-D "d"))) + 32) + +(artdag-test + "invalidation: provenance map is cleared for that peer" + (let + ((B (artdag/fed-import (artdag/fed-open) fed-bundle fed-trust-A))) + (len (keys (artdag/fed-prov (artdag/fed-invalidate B "A"))))) + 0) + +(artdag-test + "invalidation is peer-scoped: other peers' results survive" + (let + ((B (artdag/fed-import (artdag/fed-open) (cons {:peer "C" :cid "node:fromC" :result 7} fed-bundle) (fn (p) true)))) + (persist/kv-has? + (artdag/fed-cache (artdag/fed-invalidate B "A")) + "node:fromC")) + true) + +(artdag-test + "invalidation is peer-scoped: target peer's results removed" + (let + ((B (artdag/fed-import (artdag/fed-open) (cons {:peer "C" :cid "node:fromC" :result 7} fed-bundle) (fn (p) true)))) + (persist/kv-has? + (artdag/fed-cache (artdag/fed-invalidate B "A")) + (artdag/dag-id fed-D "d"))) + false) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 0ebb38b4..95e168b0 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **87/87** (5 suites: dag, analyze, plan, execute, optimize) +`bash lib/artdag/conformance.sh` → **102/102** (6 suites: dag, analyze, plan, execute, optimize, fed) ## Ground rules @@ -127,15 +127,27 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Phase 6 — Federation (shared content-addressed cache) -- [ ] a result computed on one instance is reusable on another by content-id (the +- [x] a result computed on one instance is reusable on another by content-id (the L2-registry analog): export/import `{content-id → result}` with provenance -- [ ] trust gating — accept a remote result only from a trusted peer (mirror the +- [x] trust gating — accept a remote result only from a trusted peer (mirror the fed trust shape; mock the transport in tests) -- [ ] revocation/invalidation — drop a remote result if its provenance is withdrawn -- [ ] `lib/artdag/tests/fed.sx` — remote cache hit, trust gating, invalidation +- [x] revocation/invalidation — drop a remote result if its provenance is withdrawn +- [x] `lib/artdag/tests/fed.sx` — remote cache hit, trust gating, invalidation ## Progress log +- **Phase 6 — Federation (shared content-addressed cache)** (fed suite 15/15, total + 102/102). `lib/artdag/federation.sx`: an instance = `{:cache :prov + {cid->origin-peer}}`. `fed-export` dumps the whole cache as `{:cid :result :peer}` + records tagged with the exporter's id; `fed-import` accepts only records from + trusted peers (trust gating) and records provenance; `fed-pull` imports via an + injected `fetch-fn(peer-id)` transport (mocked in tests). Because content-ids are + global, a trusted import makes the importer's run a pure cache hit (recompute 0) — + the L2-registry analog. `fed-invalidate peer` drops every result provenanced to a + peer from cache + prov (trust withdrawn → recompute), peer-scoped (other peers' + results survive) and leaving locally-computed (un-provenanced) results untouched. + ALL 6 PHASES COMPLETE. + - **Phase 5 — Effect-pipeline optimization** (optimize suite 18/18, total 87/87). `lib/artdag/optimize.sx`: `artdag/dce dag outputs` keeps only the outputs plus their transitive ancestors (via analyze), preserving surviving content-ids. From afe69cbdc6fc1de7c6e901fcedb4f5b655938804 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:15:51 +0000 Subject: [PATCH 07/13] =?UTF-8?q?artdag:=20cost-based=20scheduling=20?= =?UTF-8?q?=E2=80=94=20critical=20path=20+=20makespan=20+=20speedup=20+=20?= =?UTF-8?q?13=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cost.sx: injected cost-fn keeps media costs opaque. critical-path = longest weighted path (= unlimited-worker makespan); makespan sums each batch's slowest node (full plan == critical path, serial == total-work); speedup = work/makespan. cost 13/13, total 115/115. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/cost.sx | 66 +++++++++++++++++++++ lib/artdag/scoreboard.json | 7 ++- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/cost.sx | 117 +++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 13 ++++- 6 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 lib/artdag/cost.sx create mode 100644 lib/artdag/tests/cost.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 0551abb3..ff32eaf2 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan execute optimize fed) +SUITES=(dag analyze plan execute optimize fed cost) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -47,6 +47,7 @@ run_suite() { (load "lib/artdag/execute.sx") (load "lib/artdag/optimize.sx") (load "lib/artdag/federation.sx") +(load "lib/artdag/cost.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/cost.sx b/lib/artdag/cost.sx new file mode 100644 index 00000000..6cbf9547 --- /dev/null +++ b/lib/artdag/cost.sx @@ -0,0 +1,66 @@ +; lib/artdag/cost.sx — cost model for the scheduler: per-node weights, critical +; path (min makespan with unlimited parallelism), plan makespan under batching/cap, +; total serial work, and the resulting speedup. Costs come from an injected +; cost-fn (op params) -> number so media-op costs stay opaque. Depends on dag.sx. + +(define artdag/const-cost (fn (op params) 1)) + +(define + artdag/op-cost + (fn + (table) + (fn (op params) (if (has-key? table op) (get table op) 1)))) + +(define + artdag/-node-cost + (fn + (dag cost-fn id) + (let + ((n (artdag/dag-get dag id))) + (cost-fn (artdag/node-op n) (artdag/node-params n))))) + +(define + artdag/-max + (fn (xs) (reduce (fn (mx x) (if (> x mx) x mx)) 0 xs))) + +; longest weighted path through the dag = makespan with unlimited workers. +(define + artdag/critical-path + (fn + (dag cost-fn) + (let + ((ft (reduce (fn (m id) (let ((maxdep (artdag/-max (map (fn (d) (get m d)) (artdag/node-inputs (artdag/dag-get dag id)))))) (assoc m id (+ (artdag/-node-cost dag cost-fn id) maxdep)))) {} (artdag/dag-order dag)))) + (artdag/-max (map (fn (id) (get ft id)) (keys ft)))))) + +; estimated wall-clock for a plan: each batch runs in parallel (costs its +; slowest node), batches run in sequence. +(define + artdag/makespan + (fn + (dag plan cost-fn) + (reduce + (fn + (total batch) + (+ + total + (artdag/-max + (map (fn (id) (artdag/-node-cost dag cost-fn id)) batch)))) + 0 + plan))) + +; total serial work = sum of all node costs. +(define + artdag/total-work + (fn + (dag cost-fn) + (reduce + (fn (s id) (+ s (artdag/-node-cost dag cost-fn id))) + 0 + (keys (artdag/dag-nodes dag))))) + +; speedup of a plan vs running everything serially. +(define + artdag/speedup + (fn + (dag plan cost-fn) + (/ (artdag/total-work dag cost-fn) (artdag/makespan dag plan cost-fn)))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 1c4bedd3..e707a6d3 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -5,9 +5,10 @@ "plan": {"pass": 18, "fail": 0}, "execute": {"pass": 15, "fail": 0}, "optimize": {"pass": 18, "fail": 0}, - "fed": {"pass": 15, "fail": 0} + "fed": {"pass": 15, "fail": 0}, + "cost": {"pass": 13, "fail": 0} }, - "total_pass": 102, + "total_pass": 115, "total_fail": 0, - "total": 102 + "total": 115 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index e5d97fb9..6440f9d5 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -10,4 +10,5 @@ _Generated by `lib/artdag/conformance.sh`_ | execute | 15 | 0 | 15 | | optimize | 18 | 0 | 18 | | fed | 15 | 0 | 15 | -| **Total** | **102** | **0** | **102** | +| cost | 13 | 0 | 13 | +| **Total** | **115** | **0** | **115** | diff --git a/lib/artdag/tests/cost.sx b/lib/artdag/tests/cost.sx new file mode 100644 index 00000000..fbc71da5 --- /dev/null +++ b/lib/artdag/tests/cost.sx @@ -0,0 +1,117 @@ +; cost model: critical path, makespan under cap, total work, speedup. + +(define + cost-CHAIN + (artdag/build + (list + (list "a" "in" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "f" (list "b") {}) + (list "d" "f" (list "c") {})))) + +(define + cost-DIA + (artdag/build + (list + (list "a" "in" (list) {}) + (list "b" "f" (list "a") {}) + (list "c" "g" (list "a") {}) + (list "d" "add" (list "b" "c") {} true)))) + +(define cost-W (artdag/op-cost {:f 2 :add 5})) + +; ---- unit cost ---- + +(artdag-test + "critical path: chain is its length" + (artdag/critical-path cost-CHAIN artdag/const-cost) + 4) + +(artdag-test + "critical path: diamond longest path" + (artdag/critical-path cost-DIA artdag/const-cost) + 3) + +(artdag-test + "total work: unit cost equals node count" + (artdag/total-work cost-DIA artdag/const-cost) + 4) + +(artdag-test + "single node critical path is its cost" + (artdag/critical-path + (artdag/build (list (list "a" "in" (list) {}))) + artdag/const-cost) + 1) + +; ---- makespan vs cap ---- + +(artdag-test + "full plan makespan equals critical path" + (artdag/makespan + cost-DIA + (artdag/plan cost-DIA 0) + artdag/const-cost) + (artdag/critical-path cost-DIA artdag/const-cost)) + +(artdag-test + "serial plan makespan equals total work" + (artdag/makespan + cost-DIA + (artdag/plan cost-DIA 1) + artdag/const-cost) + (artdag/total-work cost-DIA artdag/const-cost)) + +(artdag-test + "capped makespan is never below the critical path" + (>= + (artdag/makespan + cost-DIA + (artdag/plan cost-DIA 1) + artdag/const-cost) + (artdag/critical-path cost-DIA artdag/const-cost)) + true) + +; ---- weighted costs ---- + +(artdag-test + "weighted critical path follows heavy ops" + (artdag/critical-path cost-DIA cost-W) + 8) + +(artdag-test + "weighted total work sums all node costs" + (artdag/total-work cost-DIA cost-W) + 9) + +(artdag-test + "op-cost defaults unknown ops to 1" + (artdag/total-work + (artdag/build (list (list "a" "in" (list) {}))) + cost-W) + 1) + +(artdag-test + "weighted full-plan makespan equals critical path" + (artdag/makespan cost-DIA (artdag/plan cost-DIA 0) cost-W) + (artdag/critical-path cost-DIA cost-W)) + +; ---- speedup ---- + +(artdag-test + "serial plan has no speedup" + (artdag/speedup + cost-DIA + (artdag/plan cost-DIA 1) + artdag/const-cost) + 1) + +(artdag-test + "parallel plan beats serial" + (> + (artdag/speedup + cost-DIA + (artdag/plan cost-DIA 0) + artdag/const-cost) + 1) + true) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 95e168b0..af9ee3a7 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,9 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **102/102** (6 suites: dag, analyze, plan, execute, optimize, fed) +`bash lib/artdag/conformance.sh` → **115/115** (7 suites: dag, analyze, plan, execute, optimize, fed, cost) + +Base roadmap (Phases 1–6) COMPLETE. Now extending. ## Ground rules @@ -136,6 +138,15 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **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 + fold over topo order) = min makespan with unlimited workers. `makespan dag plan + cost-fn` sums each batch's slowest node — full plan (cap 0) makespan == critical + path, serial (cap 1) == `total-work`. `speedup` = total-work / makespan. Verified + weighted paths follow heavy ops and capped makespan never dips below the critical + path. + - **Phase 6 — Federation (shared content-addressed cache)** (fed suite 15/15, total 102/102). `lib/artdag/federation.sx`: an instance = `{:cache :prov {cid->origin-peer}}`. `fed-export` dumps the whole cache as `{:cid :result :peer}` From 4947d1f5aa808549b7705ddf842b484544a9c1a2 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:22:17 +0000 Subject: [PATCH 08/13] =?UTF-8?q?artdag:=20DAG=20wire=20serialization=20?= =?UTF-8?q?=E2=80=94=20portable=20record=20form=20+=20integrity=20+=2013?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/artdag/conformance.sh | 3 +- lib/artdag/scoreboard.json | 7 ++- lib/artdag/scoreboard.md | 3 +- lib/artdag/serialize.sx | 62 ++++++++++++++++++ lib/artdag/tests/serialize.sx | 115 ++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 13 +++- 6 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 lib/artdag/serialize.sx create mode 100644 lib/artdag/tests/serialize.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index ff32eaf2..050b6687 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -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)") diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index e707a6d3..619b39e7 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -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 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 6440f9d5..781cfcd3 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -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** | diff --git a/lib/artdag/serialize.sx b/lib/artdag/serialize.sx new file mode 100644 index 00000000..f8f337e9 --- /dev/null +++ b/lib/artdag/serialize.sx @@ -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))))) diff --git a/lib/artdag/tests/serialize.sx b/lib/artdag/tests/serialize.sx new file mode 100644 index 00000000..f788c329 --- /dev/null +++ b/lib/artdag/tests/serialize.sx @@ -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) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index af9ee3a7..fcfade9a 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -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 1–6) 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 From 64ddd291760c3a0ce35fa1e091c933d42c98765c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:25:41 +0000 Subject: [PATCH 09/13] artdag: optimize composition pass (fuse + dce) + 4 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit artdag/optimize entries outputs fusible? fuses the entry list then DCEs against the output names — sinks survive fusion (never absorbed), so output-equivalent with fewer nodes. optimize 22/22, total 132/132. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/optimize.sx | 12 +++++++++++ lib/artdag/scoreboard.json | 6 +++--- lib/artdag/scoreboard.md | 4 ++-- lib/artdag/tests/optimize.sx | 39 ++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 8 +++++++- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/lib/artdag/optimize.sx b/lib/artdag/optimize.sx index 022c4825..c435313a 100644 --- a/lib/artdag/optimize.sx +++ b/lib/artdag/optimize.sx @@ -4,6 +4,7 @@ ; already collapse to one node at build time (artdag/cse == build). ; Fusion — collapse a maximal 1-to-1 chain of fusible unary ops into a single ; "artdag/pipeline" node that replays the stages; output-equivalent. +; optimize — fuse then DCE in one pass. ; Depends on dag.sx and analyze.sx. ; ---- dict helper ---- @@ -188,3 +189,14 @@ (= op artdag/pipeline-op) ((artdag/pipeline-run base-runner) params inputs) (base-runner op params inputs))))) + +; ---- full optimization pass ---- +; fuse the entry list, then drop everything not feeding the requested output +; names. Output names survive fusion (sinks are never absorbed). +(define + artdag/optimize + (fn + (entries outputs fusible?) + (let + ((fused (artdag/fuse entries fusible?))) + (artdag/dce fused (map (fn (nm) (artdag/dag-id fused nm)) outputs))))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 619b39e7..f46251b7 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -4,12 +4,12 @@ "analyze": {"pass": 16, "fail": 0}, "plan": {"pass": 18, "fail": 0}, "execute": {"pass": 15, "fail": 0}, - "optimize": {"pass": 18, "fail": 0}, + "optimize": {"pass": 22, "fail": 0}, "fed": {"pass": 15, "fail": 0}, "cost": {"pass": 13, "fail": 0}, "serialize": {"pass": 13, "fail": 0} }, - "total_pass": 128, + "total_pass": 132, "total_fail": 0, - "total": 128 + "total": 132 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 781cfcd3..5d1a483c 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -8,8 +8,8 @@ _Generated by `lib/artdag/conformance.sh`_ | analyze | 16 | 0 | 16 | | plan | 18 | 0 | 18 | | execute | 15 | 0 | 15 | -| optimize | 18 | 0 | 18 | +| optimize | 22 | 0 | 22 | | fed | 15 | 0 | 15 | | cost | 13 | 0 | 13 | | serialize | 13 | 0 | 13 | -| **Total** | **128** | **0** | **128** | +| **Total** | **132** | **0** | **132** | diff --git a/lib/artdag/tests/optimize.sx b/lib/artdag/tests/optimize.sx index 6e275ad0..88cf4fdd 100644 --- a/lib/artdag/tests/optimize.sx +++ b/lib/artdag/tests/optimize.sx @@ -174,3 +174,42 @@ "fusion: empty fusible set leaves dag unchanged" (artdag/node-count (artdag/fuse opt-chain (fn (op) false))) 4) + +; ---- full optimization pass (fuse + dce) ---- + +(define + optp-entries + (list + (list "a" "in" (list) {:v 5}) + (list "b" "inc" (list "a") {}) + (list "c" "inc" (list "b") {}) + (list "x" "sq" (list "a") {}))) + +(artdag-test + "optimize: fuses chain and drops dead node" + (artdag/node-count (artdag/optimize optp-entries (list "c") opt-inc?)) + 2) + +(artdag-test + "optimize: leaves dead node when it is an output" + (artdag/node-count (artdag/optimize optp-entries (list "c" "x") opt-inc?)) + 3) + +(artdag-test + "optimize: result equals the unoptimized dag" + (let + ((c1 (persist/open)) (c2 (persist/open))) + (let + ((o (artdag/optimize optp-entries (list "c") opt-inc?))) + (= + (artdag/result-of (artdag/run o opt-RUN c1) (artdag/dag-id o "c")) + (artdag/result-of + (artdag/run (artdag/build optp-entries) opt-RUN c2) + (artdag/dag-id (artdag/build optp-entries) "c"))))) + true) + +(artdag-test + "optimize: no fusible ops still drops dead nodes" + (artdag/node-count + (artdag/optimize optp-entries (list "c") (fn (op) false))) + 3) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index fcfade9a..b8019bee 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **128/128** (8 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize) +`bash lib/artdag/conformance.sh` → **132/132** (8 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize) Base roadmap (Phases 1–6) COMPLETE. Now extending. @@ -138,6 +138,12 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Ext: optimize composition pass** (optimize suite 22/22, total 132/132). + `artdag/optimize entries outputs fusible?` fuses the entry list then DCEs against + the output names (sinks survive fusion since they're never absorbed) — fewer nodes, + identical results. Verified: dead branch dropped + chain fused (4→2), an output that + is itself "dead" is retained, no-fusible-set still DCEs. + - **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 From f29d8c047b881f5dd64dd7ad0e4c377c650127a6 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:28:06 +0000 Subject: [PATCH 10/13] artdag: execution stats / cache analytics + 12 tests stats.sx reports hit-ratio, cost-weighted work-recomputed/work-saved, savings-ratio, and exec-summary over an execution record. Verifies cold (0 saved), warm (all saved), and incremental (saved = unchanged, ran = dirty closure). stats 12/12, total 144/144. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/scoreboard.json | 7 +- lib/artdag/scoreboard.md | 3 +- lib/artdag/stats.sx | 51 +++++++++++++ lib/artdag/tests/stats.sx | 150 +++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 8 +- 6 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 lib/artdag/stats.sx create mode 100644 lib/artdag/tests/stats.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 050b6687..c2d19bba 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan execute optimize fed cost serialize) +SUITES=(dag analyze plan execute optimize fed cost serialize stats) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -49,6 +49,7 @@ run_suite() { (load "lib/artdag/federation.sx") (load "lib/artdag/cost.sx") (load "lib/artdag/serialize.sx") +(load "lib/artdag/stats.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index f46251b7..2b4698d7 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -7,9 +7,10 @@ "optimize": {"pass": 22, "fail": 0}, "fed": {"pass": 15, "fail": 0}, "cost": {"pass": 13, "fail": 0}, - "serialize": {"pass": 13, "fail": 0} + "serialize": {"pass": 13, "fail": 0}, + "stats": {"pass": 12, "fail": 0} }, - "total_pass": 132, + "total_pass": 144, "total_fail": 0, - "total": 132 + "total": 144 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 5d1a483c..5e61f292 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -12,4 +12,5 @@ _Generated by `lib/artdag/conformance.sh`_ | fed | 15 | 0 | 15 | | cost | 13 | 0 | 13 | | serialize | 13 | 0 | 13 | -| **Total** | **132** | **0** | **132** | +| stats | 12 | 0 | 12 | +| **Total** | **144** | **0** | **144** | diff --git a/lib/artdag/stats.sx b/lib/artdag/stats.sx new file mode 100644 index 00000000..5ddf1e10 --- /dev/null +++ b/lib/artdag/stats.sx @@ -0,0 +1,51 @@ +; lib/artdag/stats.sx — observability over an execution: cache hit ratio and the +; compute work saved by memoization (weighted by the cost model). An exec is the +; {:results :recomputed :hits} record returned by artdag/execute. Depends on +; execute.sx (exec accessors) and cost.sx (artdag/-node-cost). + +(define + artdag/exec-total + (fn (exec) (+ (artdag/recompute-count exec) (artdag/hit-count exec)))) + +; fraction of executed nodes served from cache (0 when nothing ran). +(define + artdag/hit-ratio + (fn + (exec) + (let + ((n (artdag/exec-total exec))) + (if (= n 0) 0 (/ (artdag/hit-count exec) n))))) + +(define + artdag/-sum-cost + (fn + (dag cost-fn ids) + (reduce + (fn (s id) (+ s (artdag/-node-cost dag cost-fn id))) + 0 + ids))) + +; weighted compute work that actually ran this execution. +(define + artdag/work-recomputed + (fn + (exec dag cost-fn) + (artdag/-sum-cost dag cost-fn (get exec :recomputed)))) + +; weighted compute work avoided by cache hits. +(define + artdag/work-saved + (fn (exec dag cost-fn) (artdag/-sum-cost dag cost-fn (get exec :hits)))) + +; fraction of total weighted work that the cache saved (0 when no work at all). +(define + artdag/savings-ratio + (fn + (exec dag cost-fn) + (let + ((saved (artdag/work-saved exec dag cost-fn)) + (ran (artdag/work-recomputed exec dag cost-fn))) + (if (= (+ saved ran) 0) 0 (/ saved (+ saved ran)))))) + +; compact summary dict for logging. +(define artdag/exec-summary (fn (exec dag cost-fn) {:work-saved (artdag/work-saved exec dag cost-fn) :recomputed (artdag/recompute-count exec) :total (artdag/exec-total exec) :work-ran (artdag/work-recomputed exec dag cost-fn) :hits (artdag/hit-count exec)})) diff --git a/lib/artdag/tests/stats.sx b/lib/artdag/tests/stats.sx new file mode 100644 index 00000000..c143bff1 --- /dev/null +++ b/lib/artdag/tests/stats.sx @@ -0,0 +1,150 @@ +; execution stats: hit ratio + memoized work saved (cost-weighted). + +(define st-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 + st-D + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 20}) + (list "b" "inc" (list "p") {}) + (list "c" "inc" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +; same shape, leaf q changed -> dirty closure {q,c,d} +(define + st-D2 + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 21}) + (list "b" "inc" (list "p") {}) + (list "c" "inc" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +(define st-W (artdag/op-cost {:add 5 :inc 2})) + +; ---- cold run ---- + +(artdag-test + "cold run: hit ratio is zero" + (let + ((cache (persist/open))) + (artdag/hit-ratio (artdag/run st-D st-RT cache))) + 0) + +(artdag-test + "cold run: nothing saved" + (let + ((cache (persist/open))) + (artdag/work-saved (artdag/run st-D st-RT cache) st-D artdag/const-cost)) + 0) + +(artdag-test + "cold run: all work runs" + (let + ((cache (persist/open))) + (artdag/work-recomputed + (artdag/run st-D st-RT cache) + st-D + artdag/const-cost)) + 5) + +(artdag-test + "cold run: weighted work ran" + (let + ((cache (persist/open))) + (artdag/work-recomputed (artdag/run st-D st-RT cache) st-D st-W)) + 11) + +; ---- warm rerun ---- + +(artdag-test + "warm rerun: hit ratio is one" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/hit-ratio (artdag/run st-D st-RT cache)))) + 1) + +(artdag-test + "warm rerun: savings ratio is one" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/savings-ratio + (artdag/run st-D st-RT cache) + st-D + artdag/const-cost))) + 1) + +(artdag-test + "warm rerun: all weighted work saved" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/work-saved (artdag/run st-D st-RT cache) st-D st-W))) + 11) + +; ---- partial (incremental) ---- + +(artdag-test + "incremental: total is every node" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/exec-total (artdag/run st-D2 st-RT cache)))) + 5) + +(artdag-test + "incremental: saved work counts unchanged nodes" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/work-saved + (artdag/run st-D2 st-RT cache) + st-D2 + artdag/const-cost))) + 2) + +(artdag-test + "incremental: ran work counts dirty closure" + (let + ((cache (persist/open))) + (begin + (artdag/run st-D st-RT cache) + (artdag/work-recomputed + (artdag/run st-D2 st-RT cache) + st-D2 + artdag/const-cost))) + 3) + +(artdag-test + "summary reports recompute count" + (let + ((cache (persist/open))) + (get + (artdag/exec-summary + (artdag/run st-D st-RT cache) + st-D + artdag/const-cost) + :recomputed)) + 5) + +(artdag-test + "summary reports total" + (let + ((cache (persist/open))) + (get + (artdag/exec-summary + (artdag/run st-D st-RT cache) + st-D + artdag/const-cost) + :total)) + 5) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index b8019bee..1a105d98 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **132/132** (8 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize) +`bash lib/artdag/conformance.sh` → **144/144** (9 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize, stats) Base roadmap (Phases 1–6) COMPLETE. Now extending. @@ -138,6 +138,12 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Ext: execution stats / cache analytics** (stats suite 12/12, total 144/144). + `lib/artdag/stats.sx` over an exec record: `hit-ratio`, `work-recomputed`/`work-saved` + (cost-weighted via the cost model), `savings-ratio`, and `exec-summary`. Cold run = + 0 hit ratio / all work ran; warm rerun = ratio 1 / all work saved; incremental = saved + work counts unchanged nodes, ran work counts the dirty closure. + - **Ext: optimize composition pass** (optimize suite 22/22, total 132/132). `artdag/optimize entries outputs fusible?` fuses the entry list then DCEs against the output names (sinks survive fusion since they're never absorbed) — fewer nodes, From 28fed7c7994ff9d4d3f53fb2494669bb272d7e0f Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:32:14 +0000 Subject: [PATCH 11/13] =?UTF-8?q?artdag:=20fault-tolerant=20execution=20?= =?UTF-8?q?=E2=80=94=20confined=20failure,=20cache=20never=20poisoned=20+?= =?UTF-8?q?=2014=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fault.sx run-safe: a node op may return (artdag/fail reason); failure is confined to that node + downstream dependents while independent branches compute, and failed results are never cached, so retry after a fix recomputes only the failed closure and hits the good nodes. fault 14/14, total 158/158. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/conformance.sh | 3 +- lib/artdag/fault.sx | 56 +++++++++++++++ lib/artdag/scoreboard.json | 7 +- lib/artdag/scoreboard.md | 3 +- lib/artdag/tests/fault.sx | 144 +++++++++++++++++++++++++++++++++++++ plans/artdag-on-sx.md | 9 ++- 6 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 lib/artdag/fault.sx create mode 100644 lib/artdag/tests/fault.sx diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index c2d19bba..7efa9077 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(dag analyze plan execute optimize fed cost serialize stats) +SUITES=(dag analyze plan execute optimize fed cost serialize stats fault) OUT_JSON="lib/artdag/scoreboard.json" OUT_MD="lib/artdag/scoreboard.md" @@ -50,6 +50,7 @@ run_suite() { (load "lib/artdag/cost.sx") (load "lib/artdag/serialize.sx") (load "lib/artdag/stats.sx") +(load "lib/artdag/fault.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") diff --git a/lib/artdag/fault.sx b/lib/artdag/fault.sx new file mode 100644 index 00000000..fb579199 --- /dev/null +++ b/lib/artdag/fault.sx @@ -0,0 +1,56 @@ +; lib/artdag/fault.sx — fault-tolerant execution. A node op may fail by returning +; (artdag/fail reason); the failure is confined to that node and its transitive +; dependents (which cannot run without it), while independent branches still +; compute. Failed results are NEVER cached, so a later run with the fault fixed +; recomputes only the failed closure. Depends on execute.sx and plan.sx. + +(define artdag/fail (fn (reason) {:artdag-fail true :reason reason})) +(define artdag/failed? (fn (v) (and (dict? v) (has-key? v :artdag-fail)))) + +(define + artdag/-exec-safe-node + (fn + (dag runner cache acc id) + (let + ((node (artdag/dag-get dag id))) + (let + ((ins (artdag/node-inputs node))) + (if + (some (fn (in) (artdag/member? in (get acc :failed))) ins) + (assoc acc :failed (concat (get acc :failed) (list id))) + (if + (persist/kv-has? cache id) + (assoc + acc + :results (assoc (get acc :results) id (persist/kv-get cache id)) + :hits (concat (get acc :hits) (list id))) + (let + ((inputs (map (fn (in) (artdag/-input-result (get acc :results) cache in)) ins))) + (let + ((result (runner (artdag/node-op node) (artdag/node-params node) inputs))) + (if + (artdag/failed? result) + (assoc acc :failed (concat (get acc :failed) (list id))) + (begin + (persist/kv-put cache id result) + (assoc + acc + :results (assoc (get acc :results) id result) + :recomputed (concat (get acc :recomputed) (list id))))))))))))) + +(define + artdag/run-safe + (fn + (dag runner cache) + (reduce + (fn (acc id) (artdag/-exec-safe-node dag runner cache acc id)) + {:recomputed (list) :results {} :hits (list) :failed (list)} + (artdag/plan-flatten (artdag/plan dag 0))))) + +(define + artdag/failed-nodes + (fn (exec) (artdag/sort-strings (get exec :failed)))) +(define artdag/failure-count (fn (exec) (len (get exec :failed)))) +(define + artdag/all-ok? + (fn (exec) (= (len (get exec :failed)) 0))) diff --git a/lib/artdag/scoreboard.json b/lib/artdag/scoreboard.json index 2b4698d7..52d1d93b 100644 --- a/lib/artdag/scoreboard.json +++ b/lib/artdag/scoreboard.json @@ -8,9 +8,10 @@ "fed": {"pass": 15, "fail": 0}, "cost": {"pass": 13, "fail": 0}, "serialize": {"pass": 13, "fail": 0}, - "stats": {"pass": 12, "fail": 0} + "stats": {"pass": 12, "fail": 0}, + "fault": {"pass": 14, "fail": 0} }, - "total_pass": 144, + "total_pass": 158, "total_fail": 0, - "total": 144 + "total": 158 } diff --git a/lib/artdag/scoreboard.md b/lib/artdag/scoreboard.md index 5e61f292..ea91478b 100644 --- a/lib/artdag/scoreboard.md +++ b/lib/artdag/scoreboard.md @@ -13,4 +13,5 @@ _Generated by `lib/artdag/conformance.sh`_ | cost | 13 | 0 | 13 | | serialize | 13 | 0 | 13 | | stats | 12 | 0 | 12 | -| **Total** | **144** | **0** | **144** | +| fault | 14 | 0 | 14 | +| **Total** | **158** | **0** | **158** | diff --git a/lib/artdag/tests/fault.sx b/lib/artdag/tests/fault.sx new file mode 100644 index 00000000..a7bcbc0a --- /dev/null +++ b/lib/artdag/tests/fault.sx @@ -0,0 +1,144 @@ +; fault-tolerant execution: failure confined to its closure, cache never poisoned. + +(define ft-BAD (artdag/op-table-runner {:boom (fn (p i) (artdag/fail "kaboom")) :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 ft-GOOD (artdag/op-table-runner {:boom (fn (p i) 99) :in (fn (p i) (get p :v)) :add (fn (p i) (+ (nth i 0) (nth i 1))) :inc (fn (p i) (+ 1 (first i)))})) + +; p,q leaves; b=inc(p) (independent); c=boom(q); d=add(b,c) +(define + ft-D + (artdag/build + (list + (list "p" "in" (list) {:v 10}) + (list "q" "in" (list) {:v 20}) + (list "b" "inc" (list "p") {}) + (list "c" "boom" (list "q") {}) + (list "d" "add" (list "b" "c") {} true)))) + +; ---- markers ---- + +(artdag-test + "fail constructor is detected" + (artdag/failed? (artdag/fail "x")) + true) + +(artdag-test + "plain values are not failures" + (artdag/failed? 42) + false) + +; ---- failure confinement ---- + +(artdag-test + "failure count covers node and its dependents" + (let + ((cache (persist/open))) + (artdag/failure-count (artdag/run-safe ft-D ft-BAD cache))) + 2) + +(artdag-test + "failed set is exactly c and d" + (let + ((cache (persist/open))) + (artdag/failed-nodes (artdag/run-safe ft-D ft-BAD cache))) + (artdag/sort-strings + (list (artdag/dag-id ft-D "c") (artdag/dag-id ft-D "d")))) + +(artdag-test + "independent branch still computes" + (let + ((cache (persist/open))) + (artdag/recompute-count (artdag/run-safe ft-D ft-BAD cache))) + 3) + +(artdag-test + "independent node result is available" + (let + ((cache (persist/open))) + (artdag/result-of + (artdag/run-safe ft-D ft-BAD cache) + (artdag/dag-id ft-D "b"))) + 11) + +(artdag-test + "all-ok? is false when something failed" + (let + ((cache (persist/open))) + (artdag/all-ok? (artdag/run-safe ft-D ft-BAD cache))) + false) + +(artdag-test + "all-ok? is true on a clean run" + (let + ((cache (persist/open))) + (artdag/all-ok? (artdag/run-safe ft-D ft-GOOD cache))) + true) + +; ---- cache integrity ---- + +(artdag-test + "good node is cached" + (let + ((cache (persist/open))) + (begin + (artdag/run-safe ft-D ft-BAD cache) + (persist/kv-has? cache (artdag/dag-id ft-D "b")))) + true) + +(artdag-test + "failed node is never cached" + (let + ((cache (persist/open))) + (begin + (artdag/run-safe ft-D ft-BAD cache) + (persist/kv-has? cache (artdag/dag-id ft-D "c")))) + false) + +; ---- retry after fix ---- + +(artdag-test + "retry recomputes only the failed closure" + (let + ((cache (persist/open))) + (begin + (artdag/run-safe ft-D ft-BAD cache) + (artdag/recompute-count (artdag/run-safe ft-D ft-GOOD cache)))) + 2) + +(artdag-test + "retry reuses the good nodes from cache" + (let + ((cache (persist/open))) + (begin + (artdag/run-safe ft-D ft-BAD cache) + (artdag/hit-count (artdag/run-safe ft-D ft-GOOD cache)))) + 3) + +(artdag-test + "retry produces the correct result" + (let + ((cache (persist/open))) + (begin + (artdag/run-safe ft-D ft-BAD cache) + (artdag/result-of + (artdag/run-safe ft-D ft-GOOD cache) + (artdag/dag-id ft-D "d")))) + 110) + +; ---- transitive cascade ---- + +(artdag-test + "failure cascades through a deep chain" + (let + ((cache (persist/open))) + (artdag/failure-count + (artdag/run-safe + (artdag/build + (list + (list "a" "in" (list) {:v 1}) + (list "b" "boom" (list "a") {}) + (list "c" "inc" (list "b") {}) + (list "d" "inc" (list "c") {}))) + ft-BAD + cache))) + 3) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index 1a105d98..f5f7cbab 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -30,7 +30,7 @@ edges. ## Status (rolling) -`bash lib/artdag/conformance.sh` → **144/144** (9 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize, stats) +`bash lib/artdag/conformance.sh` → **158/158** (10 suites: dag, analyze, plan, execute, optimize, fed, cost, serialize, stats, fault) Base roadmap (Phases 1–6) COMPLETE. Now extending. @@ -138,6 +138,13 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Ext: fault-tolerant execution** (fault suite 14/14, total 158/158). + `lib/artdag/fault.sx`: a node op may fail via `(artdag/fail reason)`; `run-safe` + confines the failure to that node + its transitive dependents (independent branches + still compute) and NEVER caches a failed result, so a later run with the fault fixed + recomputes only the failed closure and cache-hits the good nodes. `failed?`/`fail` + markers, `failed-nodes`/`failure-count`/`all-ok?`. + - **Ext: execution stats / cache analytics** (stats suite 12/12, total 144/144). `lib/artdag/stats.sx` over an exec record: `hit-ratio`, `work-recomputed`/`work-saved` (cost-weighted via the cost model), `savings-ratio`, and `exec-summary`. Cold run = From cfc784e45ae1410826f0665cbf0a33a1ce6b142e Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:34:07 +0000 Subject: [PATCH 12/13] =?UTF-8?q?artdag:=20public=20API=20facade=20lib/art?= =?UTF-8?q?dag/api.sx=20=E2=80=94=20load=20list=20+=20surface=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference index (matching datalog/persist convention): canonical load order and the full public surface across all 10 modules, plus artdag/version. Wired into the conformance load list. Total 158/158 unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/artdag/api.sx | 91 +++++++++++++++++++++++++++++++++++++++ lib/artdag/conformance.sh | 1 + 2 files changed, 92 insertions(+) create mode 100644 lib/artdag/api.sx diff --git a/lib/artdag/api.sx b/lib/artdag/api.sx new file mode 100644 index 00000000..39520b0e --- /dev/null +++ b/lib/artdag/api.sx @@ -0,0 +1,91 @@ +; lib/artdag/api.sx — public API index for the artdag content-addressed dataflow +; DAG engine. Reference-only: `load` is an epoch-protocol command, not an SX +; function, so this file cannot reload the modules from inside another `.sx`. To +; set up a session, issue these loads in order (after spec/stdlib.sx + lib/r7rs.sx, +; the lib/datalog/* modules, and the lib/persist/* modules): +; +; (load "lib/artdag/dag.sx") +; (load "lib/artdag/analyze.sx") ; requires lib/datalog/* +; (load "lib/artdag/plan.sx") +; (load "lib/artdag/execute.sx") ; requires lib/persist/* +; (load "lib/artdag/optimize.sx") +; (load "lib/artdag/federation.sx") +; (load "lib/artdag/cost.sx") +; (load "lib/artdag/serialize.sx") +; (load "lib/artdag/stats.sx") +; (load "lib/artdag/fault.sx") +; +; (lib/artdag/conformance.sh runs this load list automatically.) +; +; ── Public API surface ───────────────────────────────────────────── +; +; Model / content addressing (dag.sx): +; (artdag/node op inputs params) node spec (non-commutative) +; (artdag/cnode op inputs params) commutative node spec +; (artdag/content-id node) structural digest "node:..." +; (artdag/build entries) {:ok :nodes :names :order} | {:ok false :error} +; entry = (name op (input-names...) params [commutative?]) +; (artdag/dag-id dag name) local name -> content-id +; (artdag/dag-get dag id) content-id -> node +; (artdag/dag-node-by-name dag name) name -> node +; (artdag/dag-order dag) topo-ordered content-ids +; (artdag/node-count dag) distinct node count +; +; Analyze on Datalog (analyze.sx): +; (artdag/analyze dag) -> datalog db +; (artdag/deps-of db id) direct dependencies +; (artdag/dependents-of db id) direct dependents +; (artdag/reachable-from db id) transitive dependents +; (artdag/ancestors-of db id) transitive dependencies +; (artdag/dirty-closure dag changed) changed nodes + all dependents +; +; Plan (plan.sx): +; (artdag/plan dag cap) topo batches under width cap (0 = unlimited) +; (artdag/plan-dirty dag changed cap) incremental plan over the dirty closure +; (artdag/plan-batches/-width/-size/-flatten plan) +; +; Execute (execute.sx): +; (artdag/op-table-runner table) runner from op-name -> (fn (params inputs)) +; (artdag/run dag runner cache) full memoized run +; (artdag/run-dirty dag changed runner cache) +; (artdag/execute dag plan runner cache) -> {:results :recomputed :hits} +; (artdag/result-of/recompute-count/hit-count/recomputed exec) +; cache = a lib/persist kv backend (persist/open) +; +; Optimize (optimize.sx): +; (artdag/dce dag outputs) drop nodes not feeding the outputs +; (artdag/cse entries) == build (sharing is free from content ids) +; (artdag/fuse entries fusible?) collapse fusible unary chains -> pipeline nodes +; (artdag/fusing-runner base-runner) runner that replays pipeline stages +; (artdag/optimize entries outputs fusible?) fuse then dce +; +; Federation (federation.sx): +; (artdag/fed-open) {:cache :prov} +; (artdag/fed-run fed dag runner) run against the instance cache +; (artdag/fed-export fed peer-id) bundle of {:cid :result :peer} +; (artdag/fed-import fed bundle trusted?) trust-gated import + provenance +; (artdag/fed-pull fed fetch-fn peer-id trusted?) pull via injected transport +; (artdag/fed-invalidate fed peer-id) drop a peer's results (peer-scoped) +; +; Cost / scheduling (cost.sx): +; (artdag/const-cost) (artdag/op-cost table) cost-fn (op params) -> number +; (artdag/critical-path dag cost-fn) longest weighted path +; (artdag/makespan dag plan cost-fn) estimated wall-clock under a plan +; (artdag/total-work dag cost-fn) (artdag/speedup dag plan cost-fn) +; +; Serialize (serialize.sx): +; (artdag/dag->wire dag) (artdag/wire->dag records) portable record form +; (artdag/wire-verify records) content-id integrity check +; (artdag/dag->string dag) (artdag/string->dag s) text transport +; +; Stats (stats.sx): +; (artdag/hit-ratio exec) +; (artdag/work-recomputed/work-saved exec dag cost-fn) +; (artdag/savings-ratio exec dag cost-fn) (artdag/exec-summary exec dag cost-fn) +; +; Fault tolerance (fault.sx): +; (artdag/fail reason) (artdag/failed? v) +; (artdag/run-safe dag runner cache) -> {:results :recomputed :hits :failed} +; (artdag/failed-nodes/failure-count/all-ok? exec) + +(define artdag/version "1.0") diff --git a/lib/artdag/conformance.sh b/lib/artdag/conformance.sh index 7efa9077..f71d193a 100755 --- a/lib/artdag/conformance.sh +++ b/lib/artdag/conformance.sh @@ -51,6 +51,7 @@ run_suite() { (load "lib/artdag/serialize.sx") (load "lib/artdag/stats.sx") (load "lib/artdag/fault.sx") +(load "lib/artdag/api.sx") (epoch 2) (eval "(define artdag-test-pass 0)") (eval "(define artdag-test-fail 0)") From 298621e2be5226de1ded4c300718b7311bd1128d Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 12:34:30 +0000 Subject: [PATCH 13/13] artdag: log api facade in plan progress Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/artdag-on-sx.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plans/artdag-on-sx.md b/plans/artdag-on-sx.md index f5f7cbab..1f005f50 100644 --- a/plans/artdag-on-sx.md +++ b/plans/artdag-on-sx.md @@ -138,6 +138,10 @@ lib/artdag/optimize.sx lib/artdag/federation.sx ## Progress log +- **Ext: public API facade** (`lib/artdag/api.sx`, total 158/158 unchanged). + Reference index matching the datalog/persist convention: canonical load order + + the full public surface across all 10 modules + `artdag/version`. + - **Ext: fault-tolerant execution** (fault suite 14/14, total 158/158). `lib/artdag/fault.sx`: a node op may fail via `(artdag/fail reason)`; `run-safe` confines the failure to that node + its transitive dependents (independent branches