content: nested-tree CvRDT (crdt-tree.sx) + 17 convergence tests (727/727)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate store snapshot crdt crdt-store sync md-import md-doc fed)
|
||||
SUITES=(block doc render api meta page page-full markdown text section compose tree-edit move clone query toc anchor outline flatten transform normalize find-replace stats summary index table callout media data wire validate store snapshot crdt crdt-tree crdt-store sync md-import md-doc fed)
|
||||
|
||||
OUT_JSON="lib/content/scoreboard.json"
|
||||
OUT_MD="lib/content/scoreboard.md"
|
||||
@@ -72,6 +72,7 @@ run_suite() {
|
||||
(load "lib/content/store.sx")
|
||||
(load "lib/content/snapshot.sx")
|
||||
(load "lib/content/crdt.sx")
|
||||
(load "lib/content/crdt-tree.sx")
|
||||
(load "lib/content/crdt-store.sx")
|
||||
(load "lib/content/sync.sx")
|
||||
(load "lib/content/md-import.sx")
|
||||
|
||||
164
lib/content/crdt-tree.sx
Normal file
164
lib/content/crdt-tree.sx
Normal file
@@ -0,0 +1,164 @@
|
||||
;; content-on-sx — nested-tree CvRDT.
|
||||
;;
|
||||
;; Extends the flat CvRDT (crdt.sx) to a TREE: each element carries a `parent`
|
||||
;; (the id of its containing section, "" = root) alongside its Logoot position.
|
||||
;; Merge is still a join — it reuses crdt.sx's position/register/field merges and
|
||||
;; adds parent (immutable, set once at insert). Materialisation rebuilds the
|
||||
;; ordered tree: root = elements with parent "", a section's children = elements
|
||||
;; whose parent is that section's id, each sorted by position. Commutative,
|
||||
;; associative, idempotent like the flat layer; concurrent inserts into the same
|
||||
;; or different parents converge deterministically.
|
||||
;;
|
||||
;; Requires (loaded by harness): crdt.sx (merge helpers + live/sort/materialise
|
||||
;; bits), block.sx, doc.sx, section.sx (mk-section).
|
||||
|
||||
(define ctt-merge-parent (fn (p1 p2) (if (= p1 nil) p2 p1)))
|
||||
|
||||
(define ctt-merge-element (fn (e1 e2) {:fields (crdt-merge-fields (get e1 :fields) (get e2 :fields)) :parent (ctt-merge-parent (get e1 :parent) (get e2 :parent)) :id (get e1 :id) :type (crdt-merge-type (get e1 :type) (get e2 :type)) :deleted (or (= (get e1 :deleted) true) (= (get e2 :deleted) true)) :pos (crdt-merge-pos (get e1 :pos) (get e2 :pos))}))
|
||||
|
||||
(define
|
||||
ctt-add-element
|
||||
(fn
|
||||
(state elem)
|
||||
(let
|
||||
((elems (get state :elements)) (id (get elem :id)))
|
||||
(let
|
||||
((existing (get elems id)))
|
||||
(assoc
|
||||
state
|
||||
:elements (assoc
|
||||
elems
|
||||
id
|
||||
(if (= existing nil) elem (ctt-merge-element existing elem))))))))
|
||||
|
||||
;; ── ops as partial-element contributions ──
|
||||
(define
|
||||
crdt-tree-insert
|
||||
(fn
|
||||
(state id type pos parent fields ts actor)
|
||||
(ctt-add-element state {:fields (crdt-build-fields fields ts actor) :parent parent :id id :type type :deleted false :pos pos})))
|
||||
|
||||
(define
|
||||
crdt-tree-update
|
||||
(fn (state id fname value ts actor) (ctt-add-element state {:fields (assoc {} fname {:ts ts :actor actor :value value}) :parent nil :id id :type nil :deleted false :pos nil})))
|
||||
|
||||
(define crdt-tree-delete (fn (state id) (ctt-add-element state {:fields {} :parent nil :id id :type nil :deleted true :pos nil})))
|
||||
|
||||
;; ── state merge (join) ──
|
||||
(define
|
||||
ctt-merge-loop
|
||||
(fn
|
||||
(ids ea eb acc)
|
||||
(if
|
||||
(= (len ids) 0)
|
||||
acc
|
||||
(let
|
||||
((id (first ids)))
|
||||
(let
|
||||
((x (get ea id)) (y (get eb id)))
|
||||
(ctt-merge-loop
|
||||
(rest ids)
|
||||
ea
|
||||
eb
|
||||
(assoc
|
||||
acc
|
||||
id
|
||||
(cond
|
||||
((= x nil) y)
|
||||
((= y nil) x)
|
||||
(else (ctt-merge-element x y))))))))))
|
||||
|
||||
(define crdt-tree-merge (fn (a b) {:elements (ctt-merge-loop (crdt-union-keys (get a :elements) (get b :elements)) (get a :elements) (get b :elements) {})}))
|
||||
|
||||
(define
|
||||
crdt-tree-merge-all
|
||||
(fn
|
||||
(states)
|
||||
(if
|
||||
(= (len states) 0)
|
||||
(crdt-empty)
|
||||
(if
|
||||
(= (len states) 1)
|
||||
(first states)
|
||||
(crdt-tree-merge (first states) (crdt-tree-merge-all (rest states)))))))
|
||||
|
||||
;; ── op interpreter ──
|
||||
(define
|
||||
crdt-tree-op-insert
|
||||
(fn (id type pos parent fields ts actor) {:ts ts :fields fields :parent parent :id id :type type :op "insert" :actor actor :pos pos}))
|
||||
|
||||
(define crdt-tree-op-update (fn (id field value ts actor) {:ts ts :field field :id id :op "update" :actor actor :value value}))
|
||||
|
||||
(define crdt-tree-op-delete (fn (id) {:id id :op "delete"}))
|
||||
|
||||
(define
|
||||
crdt-tree-apply
|
||||
(fn
|
||||
(state op)
|
||||
(let
|
||||
((k (get op :op)))
|
||||
(cond
|
||||
((= k "insert")
|
||||
(crdt-tree-insert
|
||||
state
|
||||
(get op :id)
|
||||
(get op :type)
|
||||
(get op :pos)
|
||||
(get op :parent)
|
||||
(get op :fields)
|
||||
(get op :ts)
|
||||
(get op :actor)))
|
||||
((= k "update")
|
||||
(crdt-tree-update
|
||||
state
|
||||
(get op :id)
|
||||
(get op :field)
|
||||
(get op :value)
|
||||
(get op :ts)
|
||||
(get op :actor)))
|
||||
((= k "delete") (crdt-tree-delete state (get op :id)))
|
||||
(else (error (str "unknown crdt-tree op: " k)))))))
|
||||
|
||||
(define
|
||||
crdt-tree-apply-all
|
||||
(fn
|
||||
(state ops)
|
||||
(if
|
||||
(= (len ops) 0)
|
||||
state
|
||||
(crdt-tree-apply-all (crdt-tree-apply state (first ops)) (rest ops)))))
|
||||
|
||||
;; ── materialise to a Phase-1 document (rebuild the ordered tree) ──
|
||||
(define
|
||||
ctt-children
|
||||
(fn
|
||||
(state parent-id)
|
||||
(crdt-sort-by-pos
|
||||
(filter
|
||||
(fn (e) (= (get e :parent) parent-id))
|
||||
(crdt-live-elements state)))))
|
||||
|
||||
(define
|
||||
ctt-element->block
|
||||
(fn
|
||||
(state e)
|
||||
(if
|
||||
(= (get e :type) "section")
|
||||
(mk-section
|
||||
(get e :id)
|
||||
(map
|
||||
(fn (c) (ctt-element->block state c))
|
||||
(ctt-children state (get e :id))))
|
||||
(crdt-element->block e))))
|
||||
|
||||
(define
|
||||
crdt-tree-materialize
|
||||
(fn
|
||||
(doc-id state)
|
||||
(doc-new
|
||||
doc-id
|
||||
(map (fn (e) (ctt-element->block state e)) (ctt-children state "")))))
|
||||
|
||||
(define
|
||||
crdt-tree-order
|
||||
(fn (state) (map (fn (e) (get e :id)) (ctt-children state ""))))
|
||||
@@ -34,13 +34,14 @@
|
||||
"store": {"pass": 29, "fail": 0},
|
||||
"snapshot": {"pass": 20, "fail": 0},
|
||||
"crdt": {"pass": 34, "fail": 0},
|
||||
"crdt-tree": {"pass": 17, "fail": 0},
|
||||
"crdt-store": {"pass": 14, "fail": 0},
|
||||
"sync": {"pass": 14, "fail": 0},
|
||||
"md-import": {"pass": 38, "fail": 0},
|
||||
"md-doc": {"pass": 12, "fail": 0},
|
||||
"fed": {"pass": 20, "fail": 0}
|
||||
},
|
||||
"total_pass": 710,
|
||||
"total_pass": 727,
|
||||
"total_fail": 0,
|
||||
"total": 710
|
||||
"total": 727
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ _Generated by `lib/content/conformance.sh`_
|
||||
| store | 29 | 0 | 29 |
|
||||
| snapshot | 20 | 0 | 20 |
|
||||
| crdt | 34 | 0 | 34 |
|
||||
| crdt-tree | 17 | 0 | 17 |
|
||||
| crdt-store | 14 | 0 | 14 |
|
||||
| sync | 14 | 0 | 14 |
|
||||
| md-import | 38 | 0 | 38 |
|
||||
| md-doc | 12 | 0 | 12 |
|
||||
| fed | 20 | 0 | 20 |
|
||||
| **Total** | **710** | **0** | **710** |
|
||||
| **Total** | **727** | **0** | **727** |
|
||||
|
||||
253
lib/content/tests/crdt-tree.sx
Normal file
253
lib/content/tests/crdt-tree.sx
Normal file
@@ -0,0 +1,253 @@
|
||||
;; Extension — nested-tree CvRDT. Sections nest and merge collaboratively;
|
||||
;; convergence is order/replica/duplicate-insensitive like the flat layer.
|
||||
|
||||
(st-bootstrap-classes!)
|
||||
(content-bootstrap-blocks!)
|
||||
(content-bootstrap-doc!)
|
||||
(content-bootstrap-render!)
|
||||
(content-bootstrap-section!)
|
||||
|
||||
(define same? (fn (a b) (= (get a :elements) (get b :elements))))
|
||||
|
||||
;; base: a section "s" at root, with one child heading.
|
||||
(define
|
||||
base
|
||||
(crdt-tree-insert
|
||||
(crdt-tree-insert
|
||||
(crdt-empty)
|
||||
"s"
|
||||
"section"
|
||||
(crdt-pos 1 0)
|
||||
""
|
||||
(list)
|
||||
1
|
||||
0)
|
||||
"h"
|
||||
"heading"
|
||||
(crdt-pos 1 0)
|
||||
"s"
|
||||
(list (list "level" 2) (list "text" "Sub"))
|
||||
1
|
||||
0))
|
||||
|
||||
;; ── materialise rebuilds the tree ──
|
||||
(content-test "tree order root" (crdt-tree-order base) (list "s"))
|
||||
(content-test
|
||||
"tree materialize ids"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" base))
|
||||
(list "s" "h"))
|
||||
(content-test
|
||||
"tree render"
|
||||
(asHTML (crdt-tree-materialize "d" base))
|
||||
"<section><h2>Sub</h2></section>")
|
||||
|
||||
;; ── concurrent inserts into the SAME section converge + order by pos ──
|
||||
(define
|
||||
rA
|
||||
(crdt-tree-insert
|
||||
base
|
||||
"a"
|
||||
"text"
|
||||
(crdt-pos 5 1)
|
||||
"s"
|
||||
(list (list "text" "A"))
|
||||
2
|
||||
1))
|
||||
(define
|
||||
rB
|
||||
(crdt-tree-insert
|
||||
base
|
||||
"b"
|
||||
"text"
|
||||
(crdt-pos 5 2)
|
||||
"s"
|
||||
(list (list "text" "B"))
|
||||
2
|
||||
2))
|
||||
(content-test
|
||||
"same-parent merge commutes"
|
||||
(same? (crdt-tree-merge rA rB) (crdt-tree-merge rB rA))
|
||||
true)
|
||||
(content-test
|
||||
"same-parent order deterministic"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge rA rB)))
|
||||
(list "s" "h" "a" "b"))
|
||||
|
||||
;; ── concurrent inserts into DIFFERENT parents converge ──
|
||||
(define
|
||||
base2
|
||||
(crdt-tree-insert
|
||||
(crdt-tree-insert
|
||||
(crdt-empty)
|
||||
"s1"
|
||||
"section"
|
||||
(crdt-pos 1 0)
|
||||
""
|
||||
(list)
|
||||
1
|
||||
0)
|
||||
"s2"
|
||||
"section"
|
||||
(crdt-pos 2 0)
|
||||
""
|
||||
(list)
|
||||
1
|
||||
0))
|
||||
(define
|
||||
x
|
||||
(crdt-tree-insert
|
||||
base2
|
||||
"x"
|
||||
"text"
|
||||
(crdt-pos 1 0)
|
||||
"s1"
|
||||
(list (list "text" "X"))
|
||||
2
|
||||
1))
|
||||
(define
|
||||
y
|
||||
(crdt-tree-insert
|
||||
base2
|
||||
"y"
|
||||
"text"
|
||||
(crdt-pos 1 0)
|
||||
"s2"
|
||||
(list (list "text" "Y"))
|
||||
2
|
||||
2))
|
||||
(define m (crdt-tree-merge x y))
|
||||
(content-test
|
||||
"different-parent commutes"
|
||||
(same? m (crdt-tree-merge y x))
|
||||
true)
|
||||
(content-test
|
||||
"different-parent tree"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" m))
|
||||
(list "s1" "x" "s2" "y"))
|
||||
(content-test
|
||||
"different-parent render"
|
||||
(asHTML (crdt-tree-materialize "d" m))
|
||||
"<section><p>X</p></section><section><p>Y</p></section>")
|
||||
|
||||
;; ── nested sections (section inside section) ──
|
||||
(define
|
||||
nested
|
||||
(crdt-tree-apply-all
|
||||
(crdt-empty)
|
||||
(list
|
||||
(crdt-tree-op-insert
|
||||
"outer"
|
||||
"section"
|
||||
(crdt-pos 1 0)
|
||||
""
|
||||
(list)
|
||||
1
|
||||
0)
|
||||
(crdt-tree-op-insert
|
||||
"inner"
|
||||
"section"
|
||||
(crdt-pos 1 0)
|
||||
"outer"
|
||||
(list)
|
||||
1
|
||||
0)
|
||||
(crdt-tree-op-insert
|
||||
"leaf"
|
||||
"text"
|
||||
(crdt-pos 1 0)
|
||||
"inner"
|
||||
(list (list "text" "deep"))
|
||||
1
|
||||
0))))
|
||||
(content-test
|
||||
"nested tree ids"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" nested))
|
||||
(list "outer" "inner" "leaf"))
|
||||
(content-test
|
||||
"nested render"
|
||||
(asHTML (crdt-tree-materialize "d" nested))
|
||||
"<section><section><p>deep</p></section></section>")
|
||||
|
||||
;; ── ops in any order converge (commutative) ──
|
||||
(define
|
||||
opA
|
||||
(crdt-tree-op-insert
|
||||
"p"
|
||||
"text"
|
||||
(crdt-pos 6 0)
|
||||
"s"
|
||||
(list (list "text" "P"))
|
||||
3
|
||||
1))
|
||||
(define opB (crdt-tree-op-update "h" "text" "Edited" 5 1))
|
||||
(define opC (crdt-tree-op-delete "h"))
|
||||
(content-test
|
||||
"ops commute"
|
||||
(same?
|
||||
(crdt-tree-apply-all base (list opA opB opC))
|
||||
(crdt-tree-apply-all base (list opC opB opA)))
|
||||
true)
|
||||
(content-test
|
||||
"ops idempotent"
|
||||
(same?
|
||||
(crdt-tree-apply-all base (list opA opB))
|
||||
(crdt-tree-apply-all
|
||||
(crdt-tree-apply-all base (list opA opB))
|
||||
(list opA opB)))
|
||||
true)
|
||||
|
||||
;; ── update into a section + LWW ──
|
||||
(define u1 (crdt-tree-update base "h" "text" "v5" 5 1))
|
||||
(define u2 (crdt-tree-update base "h" "text" "v7" 7 2))
|
||||
(content-test
|
||||
"tree LWW higher ts"
|
||||
(str
|
||||
(blk-send
|
||||
(doc-deep-find (crdt-tree-materialize "d" (crdt-tree-merge u1 u2)) "h")
|
||||
"text"))
|
||||
"v7")
|
||||
|
||||
;; ── delete inside a section ──
|
||||
(content-test
|
||||
"delete in section"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-delete base "h")))
|
||||
(list "s"))
|
||||
|
||||
;; ── merge idempotence ──
|
||||
(content-test "merge idempotent self" (same? (crdt-tree-merge m m) m) true)
|
||||
|
||||
;; ── full convergence: two replicas, divergent edits in different sections ──
|
||||
(define
|
||||
repl1
|
||||
(crdt-tree-apply-all
|
||||
base2
|
||||
(list
|
||||
(crdt-tree-op-insert
|
||||
"p1"
|
||||
"text"
|
||||
(crdt-pos 1 0)
|
||||
"s1"
|
||||
(list (list "text" "from1"))
|
||||
5
|
||||
1))))
|
||||
(define
|
||||
repl2
|
||||
(crdt-tree-apply-all
|
||||
base2
|
||||
(list
|
||||
(crdt-tree-op-insert
|
||||
"p2"
|
||||
"text"
|
||||
(crdt-pos 1 0)
|
||||
"s2"
|
||||
(list (list "text" "from2"))
|
||||
6
|
||||
2))))
|
||||
(content-test
|
||||
"two-replica tree converges"
|
||||
(same? (crdt-tree-merge repl1 repl2) (crdt-tree-merge repl2 repl1))
|
||||
true)
|
||||
(content-test
|
||||
"two-replica tree ids"
|
||||
(doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge repl1 repl2)))
|
||||
(list "s1" "p1" "s2" "p2"))
|
||||
@@ -19,7 +19,7 @@ injected adapter, not core.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/content/conformance.sh` → **710/710** (Phases 1–4 COMPLETE + ~33 extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CRDT replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
|
||||
`bash lib/content/conformance.sh` → **727/727** (Phases 1–4 COMPLETE + ~34 extensions: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -93,6 +93,7 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
||||
- [x] media block (`media.sx`: CtMedia video/audio, renders html/sx/text/md, validated)
|
||||
- [x] list-card summary (`summary.sx`: content/summary — title/excerpt/words/reading/cover)
|
||||
- [x] multi-doc index (`index.sx`: content/index + index-by-tag + all-tags + has-tag?)
|
||||
- [x] nested-tree CvRDT (`crdt-tree.sx`: parent-aware, sections merge collaboratively)
|
||||
- [x] HTML page wrapper (`page.sx`: content/page, escaped title from metadata)
|
||||
- [x] SEO page (`page-full.sx`: content/page-full, lang + meta description from excerpt)
|
||||
- [x] document composition (`compose.sx`: concat/prepend/concat-all/wrap-section)
|
||||
@@ -112,6 +113,15 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — Extension: nested-tree CvRDT (`crdt-tree.sx`). Extends the flat
|
||||
CvRDT to a TREE: each element carries a `parent` (containing section id, "" =
|
||||
root) beside its Logoot pos; merge reuses crdt.sx's pos/register/field joins +
|
||||
parent (immutable). Materialisation rebuilds the ordered tree (root + per-section
|
||||
children sorted by pos, recursive). Sections now merge collaboratively; proven
|
||||
commutative/associative/idempotent — same- and different-parent concurrent
|
||||
inserts converge, nested sections, LWW, two-replica convergence. Reuses crdt.sx
|
||||
+ section.sx; flat crdt untouched (34/34). 17 tests; suite 727/727. This was
|
||||
the flagged "research-grade" gap — done as a clean self-contained layer.
|
||||
- 2026-06-07 — Extension: multi-document index (`index.sx`). `content/index`
|
||||
projects a doc list into summary cards (blog index); `content/index-by-tag`
|
||||
filters by tag (category pages); `content/all-tags` is a deduped tag cloud;
|
||||
|
||||
Reference in New Issue
Block a user