Compare commits

...

289 Commits

Author SHA1 Message Date
298621e2be artdag: log api facade in plan progress
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m11s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:34:30 +00:00
cfc784e45a artdag: public API facade lib/artdag/api.sx — load list + surface index
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
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) <noreply@anthropic.com>
2026-06-07 12:34:07 +00:00
28fed7c799 artdag: fault-tolerant execution — confined failure, cache never poisoned + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
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) <noreply@anthropic.com>
2026-06-07 12:32:14 +00:00
f29d8c047b artdag: execution stats / cache analytics + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
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) <noreply@anthropic.com>
2026-06-07 12:28:06 +00:00
64ddd29176 artdag: optimize composition pass (fuse + dce) + 4 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
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) <noreply@anthropic.com>
2026-06-07 12:25:41 +00:00
4947d1f5aa artdag: DAG wire serialization — portable record form + integrity + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
serialize.sx emits a topo-ordered (id op inputs params commutative) record list
that survives write/read (string-keyed node dicts do not; empty inputs read back
as nil and are normalized). wire->dag reconstructs a runnable dag by content-id;
wire-verify recomputes ids to reject tampering. dag->string/string->dag for text
transport. serialize 13/13, total 128/128.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:22:17 +00:00
afe69cbdc6 artdag: cost-based scheduling — critical path + makespan + speedup + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 56s
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) <noreply@anthropic.com>
2026-06-07 12:15:51 +00:00
985dbb4c8f artdag: Phase 6 federation — shared content-addressed cache + trust + invalidation + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 59s
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) <noreply@anthropic.com>
2026-06-07 12:11:11 +00:00
228861215d artdag: Phase 5 optimization — DCE + CSE + adjacent-op fusion + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
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) <noreply@anthropic.com>
2026-06-07 12:08:12 +00:00
a2f4fb5e89 artdag: Phase 4 Execute — content-addressed memo + incremental recompute + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
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) <noreply@anthropic.com>
2026-06-07 12:00:50 +00:00
9a0f3d872c artdag: Phase 3 Plan — topological batches + parallelism cap + dirty plan + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m0s
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) <noreply@anthropic.com>
2026-06-07 11:56:13 +00:00
b9afe671ae artdag: Phase 2 Analyze on Datalog + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
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) <noreply@anthropic.com>
2026-06-07 11:53:29 +00:00
e4a8dff9ba artdag: Phase 1 DAG model + structural content addressing + 20 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m7s
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) <noreply@anthropic.com>
2026-06-07 11:49:43 +00:00
b821e6a79d Merge loops/events into architecture: events-on-sx — calendar/ticketing/notification/federation on Datalog+persist+flow (295 tests, 11 suites)
Full RFC 5545 calendar (RRULE DAILY/WEEKLY/MONTHLY + EXDATE/RDATE + RECURRENCE-ID
overrides + timezones/DST), capacity-safe booking on persist/append-expect
(holds/confirm/release/waitlist+auto-promote, no overbooking), paid-ticket
commerce contract, durable notification flows on lib/flow, reminders/digests/
booking-lifecycle/reschedule notifications, trust-gated federation + free/busy +
injected fetch transport.
2026-06-07 10:06:03 +00:00
e3932237bd plans: briefings for 5 language chisels + host/relations/artdag/dream
Language-chisel briefings (plans already existed): elixir, idris, linear, maude,
probabilistic. host-on-sx briefing (native server now, Dream framework layer next).
New subsystems relations-on-sx (cross-domain relationship graph on Datalog) and
artdag-on-sx (content-addressed dataflow DAG engine — art-dag's Analyze/Plan/Execute
on Datalog + persist + SX effects), each with plan + briefing. Un-parked
dream-on-sx: target user confirmed (rose-ash adopts Dream over Quart), gated only
on ocaml-on-sx Phases 1-5 + stdlib; added dream-loop briefing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:57:46 +00:00
bf7bd38010 events: timezone + DST support + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
timezone.sx: wall-clock LOCAL <-> absolute UTC. :fixed + :dst zones (std/dst
offsets + UTC transition rules, EU-style, no IANA DB) computed via calendar
helpers. ev-event-tz authors in local time; ev-expand expands tz events in
LOCAL time then converts each occurrence to UTC, so a 09:00 weekly meeting
stays 09:00 across a DST change (UTC instant shifts). Predefined utc/london/
paris. Plain events unaffected. 295/295 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:31:11 +00:00
d59a999da6 Merge loops/host-persist into architecture: host durable-storage adapter (persist/* + blob/* on disk, restart-safe) 2026-06-07 09:20:17 +00:00
f040f76ebe Merge loops/identity into architecture: identity-on-sx — OAuth2, sessions, membership on Erlang (233 tests, 22 suites) 2026-06-07 09:18:17 +00:00
644ea178c2 Merge loops/search into architecture: search-on-sx full-text search on Haskell
Tokenizer + inverted index, query AST (boolean/phrase) + parser, TF-IDF/BM25
ranking + top-N, federation merge + ACL post-filter, and 9 extensions
(prefix, pagination, fuzzy, highlight, stem, NEAR, synonyms, boolean-ranked
search, did-you-mean). lib/search/conformance.sh => 234/234 across 14 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:16:57 +00:00
c5faf93813 Merge loops/mod into architecture: correct shared-plumbing extraction note (declined) 2026-06-07 09:11:02 +00:00
2913cdc3a8 plans: correct extraction note — declined after reading both impls
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
Reading lib/mod (Prolog) and lib/acl (Datalog) side by side shows the convergence
is in module names only. Federation: opposite trust models (SX registry + decision
sharing vs in-engine Datalog trust facts + fact replication), zero shared code.
Audit: only a ~5-fn core overlaps and it diverges (entry shapes, seq base 0 vs 1,
op sets, mutation idiom) — not worth a shared module under two restricted envs.
Outcome: keep them parallel; revisit only on a third same-model consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:10:57 +00:00
c73b054ec3 Merge loops/content into architecture: content-on-sx CMS on Smalltalk
Block-based documents as message-passing on Smalltalk-on-SX: typed block
objects, ordered tree, render boundary (html/sx/md/text), persist op-log +
versioning, flat + nested-tree CvRDT with durable replication, Ghost sync +
trust-gated federation, plus extensions (tables/callouts/media, deep tree
edits, data/wire serialization, query/transform, TOC/outline, page wrappers).
746/746 across 41 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:07:33 +00:00
fd16c78698 content: lock op-log block-type coverage (callout/media via store) + 4 tests (746/746)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 39s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:03:23 +00:00
dd399303b2 Merge loops/fed-prims into architecture: Phase J — http-request native primitive
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Phase J ships the native http-request primitive in bin/sx_server.ml
that fed-sx-m2 Step 8e (httpc:request/4 BIF wrapper), Step 8f (live
HTTP dispatch), Step 10c (peer-actor doc fetch), and Step 12
(two-instance smoke test) depend on. Surfaces the long-standing
Blocker #2 in plans/fed-sx-milestone-2.md.

NATIVE-ONLY: HTTP/1.1 over Unix sockets + gethostbyname; inline
http:// URL parsing; Connection: close + Host + Content-Length
auto-supplied; reads response via Content-Length or read-to-EOF;
chunked transfer-encoding rejected (Phase K). 6/6 in
bin/test_http_client.sh.
2026-06-07 08:52:08 +00:00
f1b0914797 content: tree-CRDT orphan reparenting (no content loss on concurrent delete-section) + 4 tests (742/742)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:21:39 +00:00
c991c7c3d3 events: injected federation transport (fed-sx-ready) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
fetch abstracts how a peer's agenda arrives: (fetch peer-id ws we) ->
{:status :ok :occurrences} | {:status :error}. ev/federated-agenda-via merges
local + trusted peers fetched via the transport; unreachable peers degrade
gracefully. ev/peer-fetch = in-process adapter; ev/federation-status reports
reachability. A real fed-sx transport drops in unchanged. 278/278 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:12:37 +00:00
d466ca3414 identity: "disconnect app" — revoke_app(Subject, Client) (+4 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
identity_tokens:revoke_app(Subject, Client) revokes every grant a subject
holds for one client at once (audited one revoke per grant), exposed at the
facade as identity:revoke_app. The action counterpart to the grants view —
completing the account-security view+action pairs (sessions/logout_all,
grants/revoke_app, history). Other subjects' same-client grants are
untouched. account 11/11, 233/233.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:59:13 +00:00
07e4cb5f4a events: reschedule notifications + 7 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
ev/reschedule-notifications: when an event carries per-occurrence overrides,
reads the roster at each overridden occurrence's original occ-key and emits a
reschedule message per booked attendee (old-start/new-start/new-duration).
Idempotency key = original-key/reschedule/new-start. 272/272 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:47:00 +00:00
4bbadee100 content: crdt-blocks regression suite — non-core blocks through flat + tree CRDT (738/738)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:42:41 +00:00
98ed2eebdf events: booking lifecycle notifications + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
booking-notify.sx walks the booking stream into ordered notifications by kind
(booked/promoted/held/confirmed/released/cancelled/waitlisted). Promotion
detected by folding the waitlist (a booking for a waitlisted actor is a
promotion). id=occ-key/seq -> idempotent re-derivation, no double-ping.
Connects ticketing to the delivery layer. 265/265 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:20:39 +00:00
526838f320 content: fix ct-class-for-type for all block types (callout/media data round-trip) + 4 tests (731/731)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:04:50 +00:00
b308effb9f events: per-occurrence overrides / reschedule (RECURRENCE-ID) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
ev-with-override re-times/re-sizes a single instance of a series (keyed by
original start). ev-expand applies overrides after EXDATE/RDATE: agenda
re-sorts, instance moved out of window is dropped (slot vacated), no-op for a
non-occurring start. assoc for immutable event update. 254/254 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:52:02 +00:00
48f5b75cc2 events: RRULE EXDATE/RDATE exceptions + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
ev-event-full carries :exdate/:rdate. ev-expand-base = raw expansion;
ev-expand applies exceptions: RDATE adds in-window occurrences, EXDATE removes
matching starts, de-duped, EXDATE wins over RDATE and the rrule (RFC 5545).
RDATE-only events supported; plain ev-event unaffected. 248/248 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:26:15 +00:00
f71eaaa299 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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:22:25 +00:00
7446c24bde events: waitlist + auto-promotion + 21 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
When full, ev/waitlist! queues actors FIFO (:waitlist/:unwaitlist on the
booking stream; waiting fold independent of the seat fold). ev/waitlist,
ev/waitlist-position, ev/leave-waitlist!. ev/cancel-promote! frees a seat and
auto-promotes the head of the queue to a confirmed booking. Idempotent.
240/240 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:59:19 +00:00
3b782eba8a identity: "apps with access" — per-subject active-grant listing (+7 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
identity_tokens:grants_for(Subject) lists a subject's active grants as
[{Client, Scope}] (revoked excluded), exposed through the facade as
identity:grants(Subject). Completes the per-subject account-security trio:
sessions (where logged in), grants (which apps have access), history (what
happened). New tests/account.sx. Conformance internal timeout raised to
1200s (22 suites, ~10min — run in background). 229/229.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:45:46 +00:00
ec4cd63c22 content: multi-doc index + tag filtering (index.sx) + 13 tests (710/710)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:42:02 +00:00
29127d8613 events: federated free/busy across trusted peers + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
Peers publish busy intervals per actor (iCal free/busy model — privacy-
preserving, not event details). ev/peer-with-busy, ev/peer-busy;
ev/federated-busy unions local availability-db busy + trusted peers' published
busy (sorted); ev/federated-free? answers cross-instance availability,
half-open, trust-gated (untrusted peers ignored). 219/219 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:32:04 +00:00
c18545ea08 content: list-card summary projection (summary.sx) + 14 tests (697/697)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:25:24 +00:00
e115af86d8 content: video/audio media block (media.sx) + 15 tests (683/683)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:13:44 +00:00
715dbe248f content: relative block reorder (move.sx) + 11 tests (668/668)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:04:45 +00:00
80174c7197 events: Phase 4 federation — trust-gated peer agenda merge + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 17s
federation.sx: a peer publishes a schedule; ev/federated-agenda merges local
(origin :local) with trusted peers' agendas, sorted by start, tagged with
:origin provenance. Trust is a peer-id set re-checked per merge; untrusted
peers contribute nothing. Real transport slots behind ev/peer-agenda.
209/209 green — all four plan phases implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:59:12 +00:00
c0ca2509d0 content: callout/admonition block (callout.sx) + 12 tests (657/657)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:57:40 +00:00
687f643d74 content: document flatten (flatten.sx) + 10 tests (645/645)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 18s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:50:16 +00:00
8130521f02 identity: dynamic client registration (RFC 7591, +5 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
register_dynamic generates a client_id + secret server-side and registers
the client, returning {ok, ClientId, Secret} — self-service onboarding
distinct from the manual register_client. A dynamic confidential client can
then use client_credentials; a dynamic public client stays
unauthorized_client. New tests/dynreg.sx. 222/222.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:48:45 +00:00
a343f4ea60 content: nested document outline (outline.sx) + 14 tests (635/635)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:41:42 +00:00
f6c1d1e9bf events: reminders + digests from the agenda + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
reminders.sx bridges calendar + durable rosters to notify: ev/occurrence-
reminders (one per booked attendee, fires lead before start, idempotency key
occ-key/recipient/lead), ev/agenda-reminders (sorted by fire-at),
ev/due-reminders (fire-at <= now), ev/reminder->msg (notify wire shape),
ev/agenda-digest + ev/agenda-for-p. 196/196 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:34:49 +00:00
181cfb6e85 content: anchored-heading render (anchor.sx) + 6 tests (621/621)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:33:21 +00:00
b8ead3c223 content: global find/replace (find-replace.sx) + 10 tests (615/615)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:20:02 +00:00
49af154524 content: document normalization (normalize.sx) + 11 tests (605/605)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:11:48 +00:00
398209d484 identity: pushed authorization requests (PAR, RFC 9126, +7 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
push_authorization_request lodges the authorization params under a
single-use request_uri; authorize_pushed redeems it into the normal consent
flow. Pushed requests reuse the pending store ({pushed, Rec} keyed by the
request_uri ref — distinct from consent req_ids, so no collision and no new
loop state). The pushed binding (client + redirect + PKCE) is still enforced
at exchange. New tests/par.sx. 217/217.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:09:55 +00:00
fe2475c49d content: TOC rendering (toc.sx) + 8 tests (594/594)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:04:03 +00:00
e35769411e events: notification delivery flows on lib/flow + 7 tests (Phase 3 start)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
notify.sx: reminders + digests as durable flows over an injected transport.
A flow requests delivery (suspend); the host dispatch sends and resumes with
the outcome. At-least-once + idempotent (transport dedups by msg id; replay
logs outcomes). Retry rides suspend/resume with distinct per-attempt tags,
bounded by maxn. Digest delivers a batch with per-message outcomes.
182/182 green. Delivery core is the delivery-on-sx extraction seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:02:54 +00:00
3c3b09688a identity: RFC 7662 full introspection metadata — introspect_full (+9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 18s
introspect_full returns {active, Subject, Client, Scope, Exp, Iat, bearer}
for live tokens and {inactive} otherwise — deepening the opaque-token /
live-lookup model. Access tokens now carry Iat (clock-at-issue); exp = iat +
ttl. Simple introspect is unchanged (all prior suites green). New
tests/introspect.sx. 210/210.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:56:16 +00:00
d9f2e7330e content: tree-wide block transforms (transform.sx) + 12 tests (586/586)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:56:05 +00:00
53bb3e97b4 content: block query + TOC (query.sx) + 13 tests (574/574)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:47:06 +00:00
c093fdcb54 content: id remapping / clone (clone.sx) + 10 tests (561/561)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:35:28 +00:00
05d5c46730 events: paid-ticket contract (commerce) over holds + 31 tests (Phase 2 done)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
ticket.sx: checkout-request (events->commerce) + payment-result
(commerce->events) wire shapes — commerce imports the contract. ev/request-
ticket! holds a seat + emits a checkout request; ev/settle-payment! confirms
on :paid, releases on failure/expiry. Idempotent; late paid for a vanished
hold -> :paid-but-no-hold (refund signal). 175/175 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:34:15 +00:00
ded7170540 identity: token exchange — downscope into an independent token (RFC 8693, +8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
oauth.sx gains token_exchange(SubjectToken, RequestedScope): a valid access
token is downscoped into a NEW independent grant for the same subject
(subset only, else invalid_scope; inactive subject token → invalid_grant).
The exchanged token's lifecycle is independent of the subject token
(revoking either leaves the other active); exchanges chain. Least-privilege
handoff to downstream services. New tests/exchange.sx. 201/201.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:31:14 +00:00
4e26b3c0f7 content: deep tree editing (tree-edit.sx) + 17 tests (551/551)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:25:46 +00:00
90136f3a99 content: on-the-wire serialization (wire.sx) + 11 tests (534/534)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:18:09 +00:00
b1f9c6bef0 identity: subject-wide session management — sessions + logout_all (+8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
api.sx gains sessions(Subject) (enumerate a subject's live sessions) and
logout_all(Subject) ("log out everywhere") — revokes and deregisters every
session the subject holds, auditing a logout per session, leaving other
subjects' sessions untouched. Builds on registry.sessions_for. New
tests/session_mgmt.sx. 193/193.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:16:21 +00:00
c5bc8d73a2 content: portable data serialization (data.sx) + 21 tests (523/523)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:11:10 +00:00
7153e742c8 events: provisional holds (hold/confirm/release) for paid tickets + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Booking stream gains :hold/:confirm/:release; fold tracks per-actor seat state
(:held/:confirmed). A held seat counts toward capacity so a pending payment
can't be oversold. ev/hold! (capacity-safe), ev/confirm!, ev/release!,
ev/seat-state. Holds race test mirrors the booking race. 144/144 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:07:29 +00:00
db885e15bc identity: identity->acl delegation boundary — 401 gates before 403 (+8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
delegation.sx makes the loop's central rule concrete: check() introspects
the token first — inactive → {error, unauthenticated} (401), acl never
consulted — and only an authenticated subject's request is delegated to
acl, which returns permit/deny ({error, forbidden} = 403). 401 strictly
precedes 403. acl-on-sx (Datalog) is a different SX guest wired at the
integration layer, so the decider here is a labelled stub (permits when
Action in Scope); swap the pid and the boundary is unchanged. New
tests/delegation.sx. 185/185 — extensions backlog clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:05:12 +00:00
a5ff21015e content: document composition (compose.sx) + 17 tests (502/502)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 59s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:02:54 +00:00
20867a62c3 content: SEO page-full w/ meta description (page-full.sx) + 4 tests (485/485)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:55:23 +00:00
d2f5b49d3f identity: unify api.sx facade over audit + membership (+9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
The identity coordinator now owns an audit ledger and a membership registry
alongside its token table (started with the ledger) and session registry.
login/logout are audited; new ops history/enroll/member_status/member_project
surface the audit and membership axes through the one `identity` door.
Identity proves who and reports membership; acl still decides permission.
Existing api behaviour unchanged. New tests/facade.sx. 177/177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:51:48 +00:00
d994579598 content: Markdown doc export w/ frontmatter (md-doc.sx) + 12 tests (481/481)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:49:52 +00:00
26a51ac5d8 content: Markdown frontmatter -> metadata + 9 tests (469/469)
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:44:02 +00:00
24d4db3f0d events: wire persist-backed booking into api.sx + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Durable booking path alongside in-memory: ev/book-occ!, ev/cancel-occ!,
ev/roster-occ, ev/seats-left-occ (capacity from scheduled event); ev/free-p?,
ev/next-free-p, ev/conflicts-p derive availability by replaying persist
booking streams. Reordered conformance preloads. 120/120 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:39:19 +00:00
226d755b57 identity: device authorization grant (RFC 8628, +10 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
device.sx — for input-constrained devices. authorize → {device_code,
user_code}; the human approves/denies out-of-band by user_code; the device
polls by device_code through the §3.5 status machine (authorization_pending
→ access_denied / {ok, Token}). Device code is single-use once a token
issues; approve-after-deny is rejected. Tokens grant-backed via token.sx.
Device-code expiry + slow_down deferred (no wall clock). New
tests/device.sx. 168/168.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:39:03 +00:00
7610da1d6d content: Markdown table import + 5 tests (round-trip, 460/460)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:37:02 +00:00
950ca71a48 content: HTML page wrapper (page.sx) + 7 tests (455/455)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:24:23 +00:00
3f3459d129 identity: client-credentials grant (RFC 6749 §4.4, +9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
oauth.sx now owns a client registry (loop/6) with register_client and the
client_credentials grant. A confidential client authenticates and gets a
token acting on its own behalf (subject = the client), no refresh token
(§4.4.3). A public client is unauthorized_client; any auth failure (unknown
client or wrong secret) is invalid_client — no client-existence oracle
(§5.2). identity-load-oauth! now pulls its deps. New tests/grants.sx.
158/158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:22:26 +00:00
69defdc517 content: table block (table.sx) + 15 tests (448/448)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:17:44 +00:00
9adeff1431 events: booking cancellation + seat release + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Booking stream carries :booking/:cancel events; live roster is the folded
replay so cancelling frees a seat and capacity reopens. ev/cancel! (retrying
append-expect), no-op on unbooked, cancelled actor may re-book. Capacity count
is folded roster size. 110/110 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:09:58 +00:00
7791867bbc content: document statistics (stats.sx) + 17 tests (433/433)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 56s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:09:17 +00:00
9860582b4a identity: OAuth client registry — public/confidential clients + redirect allow-list (11 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
clients.sx (RFC 6749 §2) — confidential clients must present the correct
secret at the token endpoint (wrong → invalid_client); public clients are
identified but not authenticated; redirect_uris are pre-registered and
checked by exact-match valid_redirect (§3.1.2.2 + Security BCP). Standalone
module for now; wiring confidential-client auth into oauth exchange is a
follow-up. New tests/clients.sx. 149/149.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:03:44 +00:00
e5a159f350 content: tree-aware validation (descends into sections) + 6 tests (416/416)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:03:25 +00:00
6e0edc347b content: nested block trees (section.sx) + 25 tests (410/410)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:56:22 +00:00
a43825f25f identity: access-token TTL via logical clock — expires_in (RFC 6749 §4.2.2, +8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
The token registry holds a logical clock (advance/now; the substrate has no
wall clock). Grants carry a Ttl; each access token carries an Expires
(Now-at-issue + Ttl, or infinity); introspect returns inactive once Now
reaches it. Refresh mints a fresh short-lived access token — short access
tokens, long refresh tokens. issue/4 and issue_grant/4 default to infinity so
all prior behaviour is unchanged. New tests/expiry.sx. token loop/6. 138/138.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:53:19 +00:00
897172a5b8 content: plain-text render + excerpt (text.sx) + 20 tests (385/385)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:51:24 +00:00
a101f5a4c3 content: document metadata (meta.sx) + Ghost title plumbing + 27 tests (365/365)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:46:21 +00:00
80a2dee22f events: capacity-safe transactional booking on persist + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
booking.sx: per-occurrence append-only stream, roster = replay. Booking
decided against an observed (roster, last-seq) snapshot, committed via
persist/append-expect — atomic check+append, no overbooking, no lock.
Explicit last-seat race test: two bookers, one booked, one conflict, roster
capped. Idempotent per actor. 97/97 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:44:43 +00:00
e951f23f14 identity: scope-as-set + scope narrowing on refresh (RFC 6749 §6, +6 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Each access token now carries its own effective scope (<= the grant's max).
refresh/3 requests a narrower scope; the request must be a subset of the
grant scope, else {error, invalid_scope} and the refresh token is NOT
consumed (client may retry, §5.2). refresh/2 keeps full scope; scope stays
opaque (atom or list) for issue so all prior atom-scope tests are unchanged.
Also files a Blocker: PKCE S256 is blocked on erlang substrate bugs (binary
=:= always true; crypto:hash ignores binary content). token 24/24, 130/130.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:43:16 +00:00
b97504ab88 content: snapshot cache over op-log replay (snapshot.sx) + 20 tests (338/338)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:39:02 +00:00
295864786d content: Markdown import adapter (md-import) + 24 tests (318/318)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:33:50 +00:00
21673b6731 identity: mark base roadmap complete (124/124); add extensions backlog
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m6s
All four phases done. Records an extensions queue (PKCE S256, token TTL,
scope sets/narrowing, client registry, client-credentials/device grants,
acl delegation, state/nonce, unified facade) to keep deepening the engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:29:47 +00:00
e448220b33 identity: trust-gated federated identity + cross-instance mapping (Phase 4 complete, +13)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 33s
federation.sx — peer-asserted subjects, advisory and trust-gated. An
assertion is accepted only from an explicitly trusted peer (else
{error, untrusted}) and is flagged {peer_asserted, Peer}, never promoted to
local authority; acl decides what a peer-asserted identity may do. Cross-
instance subject mapping namespaces remote subjects by peer
({federated, Peer, Remote}) so two peers' "alice" never collide, with
optional explicit aliasing. Adds an audit-completeness test. New
tests/federation.sx. All four phases done — 124/124.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:29:08 +00:00
7836709f91 content: document validation (validate.sx) + 17 tests (294/294)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:25:37 +00:00
ef38b24110 content: durable CRDT replication (crdt-store) + 14 tests (277/277)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:19:15 +00:00
a5c22c5a01 identity: grant audit ledger — issue/refresh/revoke events, queryable per subject (10 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
audit.sx is an append-only ledger process. token.sx gains start/1(Audit)
and emits an event on every grant transition (issue, refresh, revoke —
including reuse-triggered revoke); start/0 stays unaudited so existing use
is unchanged (token.sx has no compile-time dep on the audit module, it just
sends to a pid). The ledger answers (identity/audit subject) via
audit/actions/count/all, chronological. In-memory event stream; persist
backing is a later Erlang<->persist bridge, out of scope. 111/111.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:16:18 +00:00
15e9503b05 events: api.sx — public events facade + 14 tests (Phase 1 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Immutable store ({:events :bookings}) over calendar+availability:
ev/schedule, ev/book, ev/agenda, ev/agenda-for, ev/free?, ev/next-free,
ev/conflicts. Availability queries auto-widen expansion by longest event.
73/73 green. Phase 1 done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:16:16 +00:00
4fb4b04b21 content: Markdown render mode (asMarkdown) + 20 tests (263/263)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:13:44 +00:00
785faf2441 identity: delegated grant-verification cache with generation invalidation (Phase 3 complete, +9)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
cache.sx — a process wrapping the token registry, memoising introspect.
Revocation stays real via generation invalidation: any revoke/refresh bumps
a generation counter, so every cached positive instantly becomes a miss and
re-validates against the live registry. A revoked token never reads valid
out of cache, not for a millisecond. stats() exposes hits/misses. New
tests/cache.sx. 101/101.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:03:57 +00:00
9c1c8f6b75 content: asSx wire string-escaping (String>>sxEscaped) + 5 tests (243/243)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:03:45 +00:00
dc00ed9786 identity: membership state machine + per-app grant projection (17 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
membership.sx — coop membership as a guarded state machine
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
transitions return explicit {error, CurrentStatus}, never silent no-ops.
project(Subject, App) renders the one canonical state into a per-app claim
({member,Tier,App} / {pending,App} / {lapsed,App} / {denied,App} /
{non_member,App}) — identity reports what the membership is; acl decides
whether the app should honour it. New tests/membership.sx. 92/92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:54:51 +00:00
2c1d8c8064 content: HTML escaping at render boundary (String>>htmlEscaped) + 8 tests (238/238)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:53:06 +00:00
4674b797cb events: next-free slot search + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
ev-next-free finds the earliest free slot >= after for a duration within a
horizon, probing 'after' + busy-interval ends via the busy_in rule (ev-free?).
Finds gaps, skips too-short gaps, half-open at edges. 59/59 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:49:42 +00:00
5d62d08e1c search: did-you-mean spelling suggestion + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
suggest/suggestN rank indexed terms by edit distance to a (misspelled) query
term, alphabetical tiebreak. 234/234.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:46:22 +00:00
56cf920041 identity: silent SSO prompt=none fast-path — one session, many clients (10 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
oauth.sx now owns a session registry. establish creates a subject session;
silent_authorize (OIDC prompt=none §3.1.2.1) asks "does this subject have a
live session?" — if yes it mints a code skipping consent, bound to client +
redirect_uri + PKCE exactly like a consented code; if no it returns
login_required (a negative state, not a login redirect). One session serves
many clients; end_session closes the fast-path. New tests/sso.sx. 75/75.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:45:15 +00:00
9722e97e0a content: trust-gated federation + conflict tests (Phase 4 complete, roadmap done, 230/230)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:42:49 +00:00
ab48a3ba1f content: Ghost/CMS sync via injected adapter + round-trip tests (210/210)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:37:12 +00:00
20ba152e36 identity: wire refresh into oauth + e2e flow tests (Phase 2 complete, +3 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
exchange now issues an access+refresh pair (RFC 6749 §4.1.4/§5.1) via
token.sx issue_grant; added the refresh grant (§6) delegating to token
rotation. End-to-end: code-exchange → refresh → introspect (active),
refresh-token reuse rejected (invalid_grant), and revoke-then-refresh
blocked by grant cascade. oauth 17/17, 65/65.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:35:10 +00:00
edf0ab1755 content: CvRDT collaborative merge + 34 convergence tests (Phase 3 complete, 196/196)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:29:38 +00:00
baee67f561 identity: refresh-token rotation + cascading revocation (token.sx grant-centric, +9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
The grant {Subject,Client,Scope,Status} becomes the unit of authorization
and cascade; access + refresh tokens reference it. issue_grant returns an
access+refresh pair; refresh (RFC 6749 §6) supersedes the presented refresh
token and mints a fresh pair; reusing a superseded refresh token is treated
as theft (RFC 6819 §5.2.2.3) and revokes the whole family, killing the live
descendant. revoke of any token cascades to the grant. All prior token
behaviour preserved. token 18/18, 62/62.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:26:05 +00:00
540933bfca events: availability.sx — free/busy + conflict detection on Datalog + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
occurrence/booking EDB; rules busy/conflict (canonical pair, half-open
overlap)/busy_in. API ev-busy, ev-conflicts, ev-has-conflict?, ev-free?
(transient qwindow). Integrates with calendar expansion. 53/53 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:23:51 +00:00
18696f3251 content: persist-backed op log + versioning + diff (Phase 2 complete, 162/162)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:15:55 +00:00
27f43dbf10 identity: OAuth2 authorization-code flow as message protocol + PKCE (14 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
oauth.sx — RFC 6749 §4.1 as a state machine on one authz-server process:
authorize → {consent_required} → consent(allow|deny) → {code} → exchange
→ {ok, Token}. Exchange enforces single-use codes (§10.5, replay →
invalid_grant), client_id + redirect_uri binding (§4.1.3), and PKCE
(RFC 7636 plain) verifier match. Issued tokens are grant-backed via
token.sx so revocation stays real. 53/53.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:11:18 +00:00
8dc9187645 content: content/* API facade + 26 tests (Phase 1 complete, 133/133)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:08:42 +00:00
0d93a9820f content: render boundary (asHTML/asSx polymorphic) + 29 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:03:05 +00:00
064bbf18b3 identity: service facade api.sx — login/verify/revoke/logout (10 tests, Phase 1 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
identity:start() spawns one coordinator owning the token table + session
registry and exposes the whole-domain ops. The coordinator is the owner
sessions notify on idle timeout, so an expired session deregisters itself
— timeout-driven, never swept. verify/2 answers identity only ({active,
Subject, Client, Scope}); permission is delegated to acl. 39/39.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:00:05 +00:00
db2a5dc6ab search: boolean-filtered ranked search + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
searchRankTfIdf/searchRankBm25 parse a boolean query, filter docs via evalQuery,
then rank survivors by relevance over the query's leaf terms (queryTerms) — the
filter-then-rank pattern. 225/225.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:58:37 +00:00
6e52ad5126 content: ordered block document + edit ops + 40 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:57:34 +00:00
938e90455d identity: session registry — route by id and (subject, client) + SSO fan-out (9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
Directory process holding (SessionId, Subject, Client, Pid) rows. Answers
the SSO probe lookup(Subject, Client) and the fan-out sessions_for(Subject)
(one subject, many clients). Routes only — no grant state, decides nothing.
Integration-tested: register a live session, route to it, confirm active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:55:34 +00:00
70aea21601 events: MONTHLY RRULE expansion (bymonthday + ordinal byday) + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
BYMONTHDAY (negative = from end), ordinal BYDAY ({:ord :wd}, last-weekday),
default day-of-month skipping short months. Weekly+monthly share ev-emit-occs.
37/37 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:52:39 +00:00
6a246039b5 content: typed block objects on smalltalk + 38 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:51:46 +00:00
797c5f9147 events: Phase 1 calendar — DAILY/WEEKLY RRULE expansion + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Civil date arithmetic (Hinnant), integer epoch-minute datetimes, bounded
windowed RRULE expansion (DAILY/WEEKLY with INTERVAL/COUNT/UNTIL/BYDAY),
multi-event merge. Conformance harness + scoreboard wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:48:34 +00:00
ac63501266 identity: opaque grant-backed tokens — issue/introspect/revoke (9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 33s
Token table is a process; the token is an opaque make_ref carrying no
information. introspect() is a live table lookup every time, so
revocation is real (RFC 7009 §2): a revoked token reads {inactive} on
the next introspection with no validity window. Reply shapes follow
RFC 7662 §2.2 ({active, Subject, Client, Scope} / {inactive}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:48:30 +00:00
1c6b80404e identity: session-as-process — create/lookup/expire/revoke + idle timeout (11 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Session is an Erlang process holding {subject, client, status}. lookup/
touch/expire/revoke are messages; expiry is the process's own
`receive ... after Ttl` timeout (RFC-agnostic; no global sweep), which
notifies the owner and tombstones. Tombstoned sessions answer lookups
with an explicit {error, expired|revoked}, never a silent dead mailbox.
Adds the conformance harness + scoreboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:45:50 +00:00
cfa68c3db3 search: synonym / query expansion + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
A synonym map [(Term,[Term])] expands a query term to itself + synonyms
(expandTerm); synDocs unions and synRankTfIdf ranks the expanded set. 214/214.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:27:03 +00:00
d446562ed1 briefings: commerce / content / events / identity loop briefings
Authored from plans/{commerce,content,events,identity}-on-sx.md.
Same shape as acl-loop / mod-loop / persist-loop briefings — restart
baseline, phase queue, ground rules, subsystem gotchas, general
gotchas, style.

Substrate dependencies noted in each:
  commerce -> minikanren + persist + flow
  content  -> smalltalk + persist
  events   -> datalog + persist + flow
  identity -> erlang + persist + acl

Phase 1 of each is unblocked by the substrate that already exists;
later phases gate on persist (and friends) landing.
2026-06-06 23:25:15 +00:00
9f8e4d995d Merge loops/mod into architecture: mod-on-sx moderation engine on Prolog
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Moderation-on-Prolog layer in lib/mod: report schema, policy DSL (boolean algebra
+ count/score/reporters/burst conditions), proof-carrying engine, append-only
audit, lifecycle state machine + escalation/appeal, federation (advisory trust,
wire format, ActivityPub export), plus repeat-offender, quorum, temporal burst,
analytics (trace/whatif/lint/batch/explain/linking), domain policies, and an
end-to-end triage pipeline. Roadmap (4 phases) + 19 extensions, 390/390. Imports
lib/prolog only; Prolog unmodified.
2026-06-06 23:08:13 +00:00
4c8e732803 Merge loops/acl into architecture: acl-on-sx Datalog ACL
Fine-grained, explainable, federation-aware access control as a thin layer
over lib/datalog/. Four phases + hardening, 145/145 conformance:
- Phase 1 direct grants, deny-overrides via stratified negation
- Phase 2 inheritance (group/role member_of, resource child_of, role_grant)
- Phase 3 explanation (proof-tree reconstruction) + append-only audit log
- Phase 4 federation (trust-gated non-transitive delegation, revocation)
- hardening: diamonds, cycles, multi-peer, validation, audit save/restore

Surfaces the lib/guest/rules/ extraction seam (build-db/decide/explain/
revoke) for the second consumer (mod-on-sx). Records two substrate findings:
append! no-ops on map-derived lists; JIT loops on deep proof reconstruction
in warm processes (acl-explain only; acl-permit? unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:07:43 +00:00
cf4e613e43 search: proximity/NEAR search + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
nearDocs k t1 t2 returns docs where both terms occur within k positions
(unordered); candidates from the posting intersection, filtered on positional
postings. 205/205.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:01:42 +00:00
95e981eb03 host-persist: content-addressed blob adapter — Blocker CLOSED
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
blob/put|get|has? backed by <root>/blobs/<cid>, CIDv1 (raw codec,
sha2-256 via Sx_cid/Sx_sha2). put idempotent; persist stores only the
{:cid :size :mime} ref. persist_durable_test.sh extended (8/8): blob
round-trip + content-address idempotency + bytes/ref surviving real
restart. Mock blob suite 14/0 on worktree binary. Durable-storage
Blocker now CLOSED.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:56:27 +00:00
911a2f57c0 search: stemming (suffix stripping) + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 16s
Deterministic English suffix stripping (stem), stemText/stemTokens, indexStemmed.
Worked around two haskell-on-sx string gotchas: take/drop over a String yield
char codes (rebuild via joinChars . map chr), and isSuffixOf's reverse trips ++
(manual suffix compare). 196/196.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:50:19 +00:00
9437f99e28 acl: hardening suite (+25) — diamonds, cycles, validation, audit save/restore
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
New adversarial/cross-phase coverage: diamond resource+group hierarchies
(deny wins per path), chain inheritance + leaf deny, cycle termination,
multi-peer delegation, fact validation, audit snapshot/restore round-trip.
Adds acl-validate-facts/acl-facts-valid? (schema) and acl-audit-snapshot/
restore!/copy (audit). Fixed acl-audit-restore! rebuilding the live log via
map (append! silently no-ops on map-derived lists).

Suite is prover-free: a substrate JIT bug loops the recursive proof
reconstructor on deep chains in warm processes (documented in Blockers);
acl-permit? is unaffected. 145/145.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:44:28 +00:00
c6c2cebf98 host-persist: durable storage adapter for persist/* ops + acceptance
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Sx_persist_store services every persist/* IO op against on-disk storage
(append-only log + separate monotonic .seq high-water + per-key kv files,
SX-serialized). Wired into the (eval) suspension loop, cek_run_with_io
bridge, and in-process _cek_io_resolver. Data-loss repro now (3 3 3).
New persist_durable_test.sh: durable + monotonic-seq + streams + kv +
real process restart all green (5/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:32:16 +00:00
98f5e1bf14 Merge loops/persist into architecture: persist-on-sx durable substrate
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
The shared durable-state substrate (lib/persist) other subsystems build on:
log + kv facets over an injectable backend, projections, subscriptions,
snapshots + compaction, optimistic concurrency, a durable backend over the
kernel perform IO boundary (blobs by reference), plus extensions (materialized
views, kv CAS, stream catalog, query helpers, atomic batch, schema-evolution
upcasters, exactly-once append, global commit ordering) and a worked ACL
reference migration. 201/201 tests across 20 suites. Durability awaits the
host-side storage adapter (tracked in the plan's Blockers; loops/host-persist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:21:27 +00:00
538b8a53e0 plans: shared-plumbing extraction note — defer to post-merge integration
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
mod-sx (Prolog) and acl-sx (Datalog) converged on the same module shape but run
on different engines. Only the audit log + fed trust/outbox shapes truly share;
extract at the architecture-merge point refactoring both consumers atomically,
not unilaterally from a loop branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:20:52 +00:00
7e732b1933 Merge loops/flow into architecture: flow-on-sx durable DAG workflow engine
166/166 across 11 suites, Phases 1-8. Combinators (sequence/parallel/branch/attempt/
map-flow/while/until + retry/timeout/try-catch/recover/tap/fail-model), durable
suspend/resume via deterministic replay (guest call/cc is escape-only), crash
recovery, fed-sx distribution (remote-node/failover/replication/handoff), operational
API + hygiene, and a host integration ABI + reference driver for art-dag / human-in-
the-loop. New lib/flow/** only; imports lib/scheme read-only.
2026-06-06 22:20:18 +00:00
65f274c573 briefings: add host-persist loop briefing (durable storage host adapter)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Briefing for the loop that builds the host-side servicer for persist/* IO ops,
making lib/persist's durable backend actually durable. Points at the Blocker
spec in plans/persist-on-sx.md as the authoritative contract; hard rules on
build isolation (worktree _build only, never clobber the shared binary) and not
pkilling the shared sx_server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:18:03 +00:00
7231cb651f search: highlight + snippet generation + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
highlight marks query-matching (normalized) tokens with [..]; snippet extracts a
context window around the first match. 178/178.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:08:00 +00:00
5945b51cfd search: fuzzy matching via edit distance + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
editDist as an O(m*n) row-based Levenshtein DP (naive recursion is exponential
and times out under load); fuzzyTerms/fuzzyDocs/fuzzyRankTfIdf expand a term to
indexed terms within a max edit distance. 166/166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:47:56 +00:00
3ab8270a58 search: result pagination (offset/limit) + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
paginate windows a ranked list (take lim . drop off); pageTfIdf/pageBm25 and
resultCount. 148/148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:55:25 +00:00
200b93c1f6 persist: Blocker spec for the host durable-storage adapter
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Document the one gap to real durability: a hosts/ servicer for the persist/*
IO ops. Includes the silent-data-loss repro (durable-backend currently no-ops
under sx_server's default resolver), the full op contract table, hard
invariants (monotonic last-seq, etc.), the blob adapter shape, where to
register in sx_server.ml, and an acceptance test (swap transport, run durable +
recovery suites against real storage, survive a real restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:52:44 +00:00
84d5732b38 persist: worked reference migration — acl grants on persist + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
examples/acl.sx: a tested template migrating an ACL-grants store from a
hand-rolled ephemeral map to persist — grants/revokes as events, current set as
a projection, O(1) checks via a materialized view, audit via read-window.
Header carries the BEFORE->AFTER diff. Proves grants survive restart on the
durable backend (the capability the BEFORE version lacked). The pattern other
subsystem loops copy; does not touch the real lib/acl. 201/201.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:43:15 +00:00
a37a158d01 persist: global commit ordering across streams + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
global.sx: persist/gappend records a pointer in a reserved $global index whose
seq is the global commit position; read-global/project-global replay every
event in commit order; global-from for incremental consumers. Opt-in (plain
append untouched); $-prefixed streams now reserved + hidden from the public
catalog (streams-all reveals them). Gives feed its unified timeline.
Deterministic across restart. 191/191.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:41:01 +00:00
9d3b775b25 search: prefix/wildcard queries + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
prefixTerms matches indexed terms by prefix (allTerms + isPrefixOf); prefixDocs
unions their docs; prefixRankTfIdf ranks via the matched terms. 136/136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:22:23 +00:00
77ab827b91 search: Phase 4 federation merge + ACL post-filter + 21 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 39s
fedIndex merges per-peer inverted indices (union posting lists per term) after
relabelling local DocIds to global gid = peer*1000 + local — dedupe by
(peer,doc-id) is automatic and positions survive, so ranking runs once over the
merge and interleaves peers by score. ACL is a post-rank filter over an injected
permit predicate (searchTfIdfAcl/topNTfIdfAcl/searchBm25Acl). Roadmap complete,
122/122.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:08 +00:00
a3f9d4f6c9 search: Phase 3 ranking TF-IDF + BM25 + top-N + 23 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
rankTfIdf and rankBm25 (configurable k1/b) over the candidate set, float scores
with deterministic DocId tiebreak; topNTfIdf/topNBm25. df/idf derived from
posting-list length. Tests cover tf/idf behavior, a BM25-vs-TF-IDF flip from
length-norm + tf-saturation, the b-parameter effect, tiebreak stability. 101/101.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:56:50 +00:00
4c84decc01 search: Phase 2 query parser + 32 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s
Query tokenizer + recursive-descent parser: OR<AND<NOT precedence, implicit AND
on adjacency, quoted phrases, parens, case-insensitive keywords. parseQuery,
searchQuery, showQ. Worked around haskell-on-sx parser limits (ord-based
delimiters; multi-clause fns instead of []-pattern case alts). 78/78.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:43:10 +00:00
739e743918 mod: Ext 19 — end-to-end triage pipeline (capstone), 390/390
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
mod/triage-pipeline domain r reports actor composes domain-policy decision →
explanation → AP activity → wire into one bundle. Integration test runs the whole
federated path across 5 modules (decide → wire → peer → trust-gated apply),
confirming the module-by-module subsystem composes end to end. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:40:36 +00:00
c19f658cf2 mod: Ext 18 — ergonomic defrule / ruleset surface, 375/375
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
mod/defrule collects trailing conditions via &rest; mod/ruleset assembles rules.
No macro needed — conditions are plain data, fn supports &rest here. Produces
structurally identical rules to mk-rule (asserted) and works in the engine
unchanged. Closes the roadmap's original defrule surface. +11 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:37:12 +00:00
2f75ab11fc mod: Ext 17 — per-domain policy registry, 364/364
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
mod/register-policy! domain rules + mod/decide-in domain r reports give each
rose-ash domain its own rule set; unregistered domains fall back to default-rules
(never unmoderated). Same spam report → remove under a strict market policy, hide
under blog default. Engine already took rules as a param, so this is registry +
fallback, no engine change. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:33:24 +00:00
9cfca1d008 flow: reference host driver flow-drive-host/flow-run-host + 4 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
Completes the host ABI from work-queue to driver loop: the host supplies only a
(kind payload) -> answer dispatch fn; flow-drive-host services one tick of pending
requests, flow-run-host ticks until quiescent (bounded). Tested via the art-dag
render -> human-review -> publish pipeline driven entirely by flow-run-host. The
art-dag integration is now: define dispatch, call flow-run-host. 166/166, 11 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:33:04 +00:00
82fbf01bb3 mod: Ext 16 — ActivityPub-shaped decision export, 350/350
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
mod/decision->activity maps a decision to a moderation verb (remove→Delete,
ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity,
preserving the precise action. mod/decisions->activities batch-exports dropping
keeps. With wire (Ext 14) + fed trust (Phase 4) the federated moderation path is
end-to-end: decide → activity/wire → peer → trust-gate → apply. +17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:28:49 +00:00
3e90c780e9 persist: exactly-once append under retries + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
idempotency.sx: persist/append-once appends at most once per (stream,
idempotency key), returning the same event on a repeat. The marker lives in the
kv facet, so idempotency holds across a restart (verified on durable).
persist/seen? check. 180/180.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:28:21 +00:00
0f6dbdfc7d persist: event schema evolution via upcasters + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
upcast.sx: register a pure (event -> event) upcaster per type in an immutable
registry; read-upcast/project-upcast lift legacy events to the current shape on
read so projections see one shape (no version branching, no history rewrite).
upcast-data helper merges new :data fields. 171/171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:26:35 +00:00
62a1485302 persist: atomic batch append — contiguous block + transactional guard + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
batch.sx: persist/append-batch commits (type at data) specs as one contiguous
block; persist/append-batch-expect checks the stream is still at expected
before writing any event, so the batch is all-or-nothing under a concurrent
writer (conflict is a value, not a partial write). 162/162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:24:35 +00:00
3cbf33d2d2 flow: host integration ABI (request/await/host-queue) + 11 tests (Phase 8)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
The seam for hooking flow to art-dag and human-in-the-loop later. (request kind
payload) suspends with a typed (flow-request kind payload) envelope and returns the
host's resume value; await-human/await-render sugar. (flow-host-requests) is the
host work queue: (id kind payload) for every suspended flow awaiting a host effect;
request?/request-kind/request-payload parse a tag. Tests include the art-dag-shaped
driver loop (render -> human-review -> publish). Host owns IO+persistence; flow only
requests (replay-safe). 162/162 across 11 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:24:16 +00:00
329b3c4903 mod: Ext 15 — disjunctive (:any) conditions, 333/333
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
(:any (list c1 c2 ...)) compiles to Prolog disjunction (g1 ; g2 ; ...), completing
the condition boolean algebra (AND via :when list, :not, :any). cond->goal
recurses so combinators nest arbitrarily; the proof tree shows the compiled
disjunction verbatim. Maps onto Prolog's control constructs rather than
reimplementing boolean logic in SX. +10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:23:15 +00:00
4e521e3d7a persist: read-side query helpers — seq/time/type/predicate scans + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
query.sx: read-between (seq range), read-since/read-window (by :at),
read-by-type, read-where, count-where. Pure scans over persist/read for audit
windows, type filters, since-cursors. 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:22:03 +00:00
a00439da6e persist: stream catalog — enumerate streams + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
New backend op :streams (from seq high-water marks, so compacted streams still
list), threaded through mem-backend + durable serve/io-backend. catalog.sx:
persist/streams, stream-count, stream-exists?, total-events. 143/143.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:20:22 +00:00
8e16ba6b04 persist: kv compare-and-swap + create-only put + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
kv.sx: persist/kv-cas sets a key only if its current value equals expected,
else returns {:conflict :expected :actual}; persist/kv-put-new is create-only.
The kv analogue of log append-expect — atomic current-state for sessions, acl
grants, stock counts. 133/133.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:53 +00:00
919bd961d1 apl: migrate conformance onto shared lib/guest driver (counters mode)
Replaces the bespoke 116-line conformance.sh with a conformance.conf + 1-line
exec shim, reusing lib/guest/conformance.sh. Surfaced + fixed a silent undercount:
the old awk extractor reported pipeline=40, but pipeline.sx has 152 assertions —
real total is 562/562, not 450/450. Driver reads counter globals directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:28 +00:00
b43901d297 mod: Ext 14 — decision wire format for federation transport, 323/323
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
mod/decision->wire emits a versioned pipe-delimited line (MOD1|r1|hide|spam-hide);
mod/wire->decision parses it back (mod/wire-valid? guards). split-char built over
slice/len (loaded env has no split). Integration test runs the full federated
path: serialize → wire → deserialize → fed-receive-decision trust-gating
(untrusted→advisory, trusted→applied). +16 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:19 +00:00
ecdaeea223 persist: materialized views — stay current on write, O(1) read + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s
view.sx: persist/view bundles stream + fold + snapshot name; view-attach
subscribes it to a hub so each publish refreshes the snapshot incrementally,
making view-peek an O(1) current read. view-value always folds the tail so it
is never stale. The consumer read-model abstraction (feed indices, audit
rollups, search counters). 122/122.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:16:16 +00:00
4be6988963 persist: crash/restart recovery integration + migration notes — Phase 4 complete
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
recovery.sx: 6-test end-to-end crash/restart of an order ledger (log +
subscription kv read model + snapshot + compaction + invoice blob ref) on the
durable backend; everything survives a restart over the same disk + content
store, seq continues, two restarts converge. Migration notes (mem → durable
under a live subsystem) added to the plan. Roadmap done, 111/111.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:14:01 +00:00
1c7b602978 persist: blob backend — store the ref/CID, never the bytes + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
blob.sx: a blob ref is {:cid :size :mime}; the blob store is a separate
injected dependency (perform in prod, mock content store in tests).
persist/blob-store puts bytes and returns only the ref; bytes live in a
content-addressed store (artdag/IPFS). Tests assert refs in log/kv never carry
the bytes + content-address dedup. 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:48 +00:00
90c2a57975 persist: durable backend over the perform IO boundary + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
durable.sx: io-backend with an injectable transport — persist/durable-backend
performs each op as {:op "persist/..." :args (...)} (kernel suspends, host
resumes); persist/mock-durable services via persist/serve over an in-memory
disk. Identical request shapes mean the whole facet/projection/snapshot/
compaction stack runs unchanged on the durable backend. Crash/restart replay
recovers log+kv+snapshot. 91/91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:09:12 +00:00
68c8e39508 mod: Ext 13 — SLA sweep over pending lifecycle cases, 307/307
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 6m28s
Composes lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with
its state-entry tick; mod/overdue? flags pending cases (open/triaged/appealed)
past a deadline; mod/sla-sweep returns the breached report ids. Terminal states
never breach. Pure overlay — lifecycle stays timeless, caller stamps entry. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:08:37 +00:00
92addf5146 mod: Ext 12 — temporal burst detection, 292/292
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
Reports gain an :at tick (deterministic, supplied). mod/decide-temporal counts
reports about a subject within [now-window, now], asserts burst_count/2, and a
(:burst-at-least K) rule fires only on a real burst. 3 reports at 10/11/12 → hide;
3 at 1/2/12 (window 5) → keep, while the plain count rule escalates both. Fifth
report field threaded through rebuild helpers, non-breaking. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:00:51 +00:00
8292607e38 mod: Ext 11 — batch triage + corpus analytics, 277/277
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
mod/decide-batch triages a queue; mod/action-histogram summarizes outcomes by
action; mod/rule-coverage + mod/never-fired measure which rules fire across a
corpus — the empirical complement to lint's static unreachable check (lint finds
rules that can't fire; never-fired finds rules that didn't). +17 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:56:19 +00:00
bf65de7b24 mod: Ext 10 — policy what-if / impact analysis, 260/260
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
mod/decision-diff compares one report's action under two rule sets;
mod/policy-impact batches a set and returns only the reports whose decision flips;
mod/impact-count / mod/impact-report summarize. Lets a mod team measure a policy
change's blast radius before shipping (e.g. removing spam-hide flips r1 hide→keep).
Pure SX over decide-report. +13 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:51:47 +00:00
3764b62206 mod: Ext 9 — policy dry-run trace diagnostics, 247/247
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
mod/trace-rules evaluates a report against every rule, returning each rule's
proved/unproved status + goal-by-goal derivation (an unproved rule shows which
goal failed). mod/first-proved = winner (matches engine precedence, cross-checked),
mod/proved-rules the firing set, mod/trace-report a [fires]/[ - ] rendering.
Answers 'why didn't my rule fire?' without instrumenting the engine. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:48:44 +00:00
0f0da0319c search: Phase 2 query AST + boolean/phrase eval + 28 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
Query ADT (Term|And|Or|Not|Phrase) and evalQuery over docid-sorted posting
lists: boolean ops as linear merges, Not over the allDocs universe, Phrase via
positional adjacency. Batched both test suites into one program eval each
(search-batch) so they finish under heavy CPU load. 46/46.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:47:42 +00:00
062a76e64f mod: Ext 8 — quorum over distinct reporters (anti-brigade), 232/232
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
(:reporters-at-least N) compiles to setof(Br, report(_, Br, Sr), Bsr),
length(Bsr, Nr), Nr >= N — counts distinct reporters, not raw reports.
mod/decide-quorum asserts every report's report/3 fact (base engine scopes to the
decided report) so Prolog can aggregate reporters. One user filing 3 reports stays
:keep under quorum while the count rule escalates. Own suite. +9 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:45:28 +00:00
aff7d1e84f persist: compaction — drop snapshotted prefix, monotonic seq + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m0s
Backend now tracks last-seq as a monotonic high-water mark (survives
truncation) and exposes :truncate-through. compaction.sx: persist/compact
checkpoints then drops events with seq <= snapshot seq; should-compact?/
maybe-compact give an explicit every-N policy. Determinism: post-compaction
replay value == uncompacted full replay. Phase 3 complete, 76/76.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:42:06 +00:00
b0874b1282 persist: snapshots — checkpoint + replay = snapshot + tail + 11 tests
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
snapshot.sx: snapshot is a projection state {:value :seq} stored in kv under
snapshot/<name>. persist/checkpoint replays and saves; persist/replay folds
only the tail after the snapshot. Tests assert snapshot+tail == full replay
both ways + determinism. 65/65.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:39:41 +00:00
156d6f12ec persist: optimistic concurrency — conflict as a real result + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled
concurrency.sx: persist/append-expect refuses an append when the stream
advanced past the caller's expected seq, returning {:conflict :expected
:actual} instead of crashing or overwriting. persist/conflict? + accessors.
Phase 2 complete, 54/54.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:37:49 +00:00
c2d628e9c3 flow: README — API reference + deterministic-replay contract
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m10s
User-facing docs for the flow engine: the node model, every combinator, the
suspend/resume durability contract (escape-only call/cc -> deterministic replay),
lifecycle/introspection/hygiene API, fed-sx distribution, and substrate notes.
Doc-only; 151/151 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:37:10 +00:00
03da8d4328 persist: subscription hub — read models update on publish + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m6s
subscribe.sx: persist/hub wraps a backend; persist/publish appends then fires
per-stream callbacks (backend stream event). Direct persist/append bypasses
subscribers (bulk load/replay). Callbacks drive kv counters / project-resume. 46/46.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:36:16 +00:00
aabb950256 flow: store hygiene flow/gc + flow/forget + 9 tests
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
flow/gc drops terminal (done/cancelled) records, keeps live suspended flows, returns
count removed; flow/forget id drops one terminal record and refuses live flows.
Bounds unbounded store growth (retention/GC). Bumped conformance sx_server timeout
to 540s for the 10-suite run under CPU contention. 151/151 across 10 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:34:53 +00:00
a6864178c3 persist: projections — fold stream into read model, incremental resume + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s
project.sx: projection state {:value :seq}; persist/project folds the whole
stream, persist/project-resume folds only the tail so read models update
incrementally. Pure step (value event)->value. 37/37.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:34:52 +00:00
314cc37030 persist: Phase 1 — log + kv facets on injectable in-memory backend + 28 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m3s
event/backend/log/kv/api over one injected backend protocol (mem default).
log: append/read/read-from, sequential per-stream seq, stream isolation.
kv: get/put/delete/has?/keys/get-or/update. conformance.sh + 3 suites, 28/28.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:32:51 +00:00
50eb7079e5 briefings: mod-loop — cut/backtracking allowance + sx_write_file-first + loaded-env/not(Goal) gotchas
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m15s
Make explicit that the loop may lean on Prolog backtracking (pl-query-all) and cut,
preferring clause-order precedence via pl-query-one. Default to sx_write_file over
path/pattern edits; flag that sx_insert_near drops all but the first form. Document
the loaded-env primitive restriction (includes?/chars/etc. undefined after prolog
preloads; use the tokenizer's surviving set) and that negation is the not(Goal)
functor, not the prefix \+ operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:30:44 +00:00
c3668e4461 mod: Ext 7 — repeat-offender escalation (audit log as evidence), 223/223
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m13s
mod/subject-sanctions counts prior hide/remove/ban decisions about a subject from
the append-only audit log; mod/decide-escalating upgrades a sanction to :ban when
the subject has >= k priors. Non-sanction outcomes (keep/escalate) pass through.
Closes the loop between audit and policy — the trail feeds future decisions. Own
suite. +19 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:29:36 +00:00
b80cc32363 briefings: add persist-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:24:52 +00:00
b8cf3eb1b8 search: Phase 1 tokenizer + inverted index + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
Tokenizer (lowercase, strip punctuation, positions) and a sorted assoc-list
inverted index [(Term,[(DocId,[Pos])])] with indexDoc/deleteDoc/lookupTerm/
docFreq/allTerms. Search lib is haskell-on-sx source assembled into search/src;
tests reuse hk-test counters via a search-eval helper. conformance.sh models
lib/haskell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:21:49 +00:00
01be84b5d8 mod: Ext 6 — strictest-wins decision strategy + action severity, 204/204
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m3s
mod/decide-strictest collects every proven rule (pl-query-all) and applies the
harshest action by mod/action-severity (keep<escalate<hide<remove<ban), an
alternative to the engine's first-match precedence. Diverges from first-match
exactly when rule order and severity disagree. Same decision shape + :strategy;
engine untouched. Own suite. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:20:15 +00:00
1902cce57f plans: rename store-on-sx → persist-on-sx; clarify it's persistence not shop, and scope (log+kv facets, blobs delegated, cache excluded)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:20:14 +00:00
2b47b2925c flow: end-to-end integration suite + 10 tests (Phase 7)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Realistic flows composing every phase: an order pipeline (validate via attempt ->
payment suspend -> branch -> ledger federation via remote-node) and an onboarding
flow, each run through the full lifecycle including a simulated crash (export/wipe/
import) and a peer handoff mid-flow, with flow/pending|status|result introspection.
142/142 across 9 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:17:40 +00:00
e53a292f1a mod: Ext 5 — policy rule-set lint (unreachable/catch-all/dups), 190/190
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
Static analysis of a policy without running the engine: mod/unreachable-rules
flags rules after an unconditional rule (dead under first-match precedence),
mod/has-catchall? checks total coverage, mod/duplicate-rule-names + mod/rules-ok?
give a well-formedness verdict policy authors can assert. Own suite. +14 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:15:41 +00:00
3d2c1d94f2 mod: Ext 4 — report linking + dedup (Prolog-backed retrieval), 176/176
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
mod/related-ids and mod/reporters-of find reports about a subject via a Prolog
relational query (report(Id, _, 'subject')) — the policy substrate reused for
retrieval. mod/dedup-reports collapses identical reports by a normalized
reporter|subject|reason key; mod/distinct-reporters-of counts unique reporters.
Own suite (tests/link.sx). +12 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:37 +00:00
d9b9da3843 flow: railway attempt combinator — fail-value short-circuit + 10 tests (Phase 6)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
(attempt n1 n2 ...) threads like sequence but stops at the first node returning a
(fail ...) value, returning that failure. Makes the fail/recover error model
compose into validation/ETL pipelines (railway-oriented). 132/132 across 8 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:21 +00:00
102c806451 mod: Ext 3 — human-readable proof explanation (mod/explain), 164/164
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 47s
mod/explain renders a decision's proof tree into legible text: action + rule,
evidence line, and each derivation goal with [proved]/[unproved] and the
unification bindings that satisfied it (e.g. {B=ann, N=3, S=dave}). Pure SX over
the Phase-2 proof data — the audit trail's 'why' made readable. +10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:06:29 +00:00
0a1b89c975 flow: bounded iteration combinators flow-while/flow-until + 6 tests
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
(flow-while pred body max) / (flow-until pred body max) re-run body threading the
value while/until pred holds, capped at max steps for a deterministic bound (no
unbounded loops in pure SX). 122/122 across 7 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:02:59 +00:00
779a592614 mod: Ext 2 — weighted/aggregate scoring (:score-at-least), 154/154
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Report :signals ({:kind :weight}) project to signal(Id, 'kind', weight) facts;
condition (:score-at-least N) compiles to aggregate_all(sum(W), signal(Id,_,W),T),
T >= N. Low-confidence signals accumulate past a threshold via genuine Prolog
arithmetic aggregation. Default policy untouched — proven via custom rule sets.
+8 extension tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:02:52 +00:00
2ea87796a1 mod: Ext 1 — negation-as-failure conditions (:not / :attr), 146/146
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Report attributes (:attrs) project to attr(Id, name) facts; policy gains (:attr x)
and (:not <cond>) conditions. The Prolog substrate exposes negation as a functor
not(Goal) (the prefix \+ operator doesn't parse here). Closed-world example:
hide spam unless author verified. Default policy untouched — feature proven via
custom rule sets, so all 132 base tests stay green. +14 extension tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:59:01 +00:00
0e6ba55647 flow: combinator library — tap, recover, map-flow + 11 tests (Phase 5 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
tap: side-effecting pass-through (returns input). recover: fail-VALUE counterpart
of try-catch (run node; on (fail r) run handler on r). map-flow: run a node over
each item of a list, join results sequentially. 116/116 across 7 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:57:48 +00:00
ee9851c063 mod: Phase 4 — federation (trust, sharing, revocation), 132/132 — roadmap done
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
Cross-instance reports ingest into the local registry with origin tags; the
engine decides them unchanged. Decision sharing pushes to a mock fed-sx outbox
(mod/fed-send! is the transport seam). Trust is advisory by default: a peer's
decision binds locally only under (mod/trusted? peer :mod), else it lands in the
advisory log unapplied. Revocation composes with the Phase-2 proof model —
fed-revoke-if-invalidated re-runs the engine and undoes moderation only when the
action no longer holds (exoneration flips hide→keep → revoked + origin notified).
+26 fed tests. Full mod-on-sx roadmap complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:54:37 +00:00
c1d24eb9b3 flow: operational introspection API — flow/status,result,list,pending + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
flow/status id -> done|suspended|cancelled|unknown; flow/result id -> value or
error; flow/list -> (id status) per flow; flow/pending -> (id waiting-tag) for
suspended flows (operator view of what each awaits). Pure store introspection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:53:23 +00:00
f4f34c1d33 mod: Phase 3 — lifecycle state machine + escalation + appeal, 106/106
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Pure SX state machine (lib/mod/lifecycle.sx) over the engine:
open→triaged→decided→appealed→final, transition table guards illegal moves.
Auto-tier resolves terminal actions; escalate parks at human-tier (resolve
blocked until review supplies evidence). Appeal re-runs the engine — new
exonerated-keep rule at top precedence lets exoneration override a prior hide.
Api façade (mod/triage/resolve/review/appeal/finalize) over a case registry,
logging committed decisions to the audit trail. +46 escalation tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:50:05 +00:00
16cb727406 flow: replication + handoff across instances + 6 tests (Phase 4 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
flow-replicate-to copies the plain-data store export to a peer's replica slot;
flow-restore-from imports it. Handoff = replicate, local instance dies, peer
restores and resumes by id. The replay log survives the move, so all resolved
suspends carry over. Same durable-data mechanism as crash recovery, across
instances. All four phases complete: 93/93.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:48:39 +00:00
f8722b3b08 flow: remote-failover — try peers in order, fall through to local + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
(remote-failover addrs fn local) tries fn on each peer in order, moves to the next
on any raised error, and runs the local node if every peer fails. Threads input,
composes in sequences.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:44:04 +00:00
e1f802cfff flow: remote-node via mock fed-sx transport + 7 tests (Phase 4 begins)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
(remote-node addr fn) runs a node on a federation peer. Transport is the fed-sx
boundary, mocked by a peer registry (flow-peer-register!); raises
flow-remote-unreachable / flow-remote-no-fn. Composes with sequence/suspend/retry.
Also fixes conformance.sh to load remote.sx before api.sx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:40:25 +00:00
ff537bfba2 plans: six subsystem outline plans for the SX rewrite (store, commerce, identity, content, events, host)
Gap analysis from the five-subsystem set (acl/feed/flow/mod/search):
- store-on-sx: event-sourcing foundation the others fake with in-memory lists (build first)
- commerce-on-sx: catalog/cart/pricing/orders on miniKanren (+ store + flow)
- identity-on-sx: OAuth2/sessions/membership on Erlang (the core acl assumes)
- content-on-sx: documents/blocks/CRDT on Smalltalk
- events-on-sx: calendar/ticketing on Datalog + flow-driven delivery
- host-on-sx: the web boundary — off Quart onto native server+SXTP now, dream-on-sx next

All DRAFT outlines; substrate choices proposed, not final.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:29 +00:00
6e825e1283 mod: Phase 2 — evidence accumulation + proof trees + audit log, 60/60
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m7s
Reports carry an :evidence list, asserted as evidence/3 facts; reviewer-remove
rule (highest precedence) lets human review override classification. Proof tree
built constructively by re-querying each rule body goal against the same DB with
the report id bound, so derivations carry real unification bindings. Append-only
audit log records decision + proof + evidence snapshot per decide, monotonic seq,
never mutates prior entries. +29 audit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:37:02 +00:00
8dfc987095 mod: Phase 1 — report schema + policy engine on Prolog, 31/31
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
Reports → Prolog facts (report/3, classification/2, report_count/2); ordered
policy rules compile to policy_action/3 clauses, first match wins via
pl-query-one. Decisions carry their proof (matching rule + conditions +
evidence). Spam/abuse keyword classification, repeated-report escalation via
Prolog join+arithmetic, no-rule→keep default. Registry api + conformance harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:30:50 +00:00
e2de5a4675 briefings: add search-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:27:20 +00:00
97c7623743 flow: crash recovery — store export/import + resumable scan + 8 tests (Phase 3 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 58s
Records are name-keyed (defflow registers names); flow-store-export nulls live
procs to plain data, flow-store-import! restores, flow-resumable-ids scans for
paused flows. Resume re-resolves the proc by name, so a flow survives a wiped
store (simulated restart). The whole durable model persists only plain data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:25:47 +00:00
1e4cf25015 Merge loops/feed into architecture: feed-on-sx activity feed engine on APL
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Activity feeds as APL array math on lib/apl/ — timelines, fanout, ranking,
visibility, federation. Roadmap (4 phases) + 8 extensions, 189/189 tests.

- Phase 1: stream model (normalize, filter/sort/take/reverse)
- Phase 2: fanout via outer product (∘.×), edge-guard, dedupe
- Phase 3: aggregation + ranking (recency/velocity/engagement, top-N)
- Phase 4: per-viewer ACL + federation (injected permit?/transport)
- Extensions: TF-IDF, notifications, home capstone, smart-dedupe,
  trending, mute, pagination, threading

Purely additive under lib/feed/**; no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:23:42 +00:00
e896deffc8 flow: Phase 3 suspend/resume/cancel via deterministic replay + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
Guest Scheme call/cc is escape-only (re-entry hangs), so durable resume uses
deterministic replay: suspend escapes to the driver; resume re-runs the flow and
replays resolved suspends from a (tag value) log. No live continuation is ever
serialized — persisted state is plain data, survives restart. Adds flow/start
(now state-returning, backward compatible), flow/resume, flow/cancel, store.sx.
Harness reuses one env with a per-test reset (full env rebuild 66x was too slow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:20:09 +00:00
72174941aa briefings: add mod-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 45s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:18:02 +00:00
9c4a5d1913 feed: conversation threading — :reply-to transitive closure (thread/replies/thread-size) + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:00:10 +00:00
f91ac82434 feed: pagination — offset/limit + cursor-by-at (before/after/page-before/next-cursor) + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:58:36 +00:00
5136249ae5 feed: viewer mute/block — mute actors/tags/objects + apply-prefs bag + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:05 +00:00
6fc61147a8 feed: trending objects/actors by recent activity window, deterministic tiebreak + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:55:55 +00:00
40be9cd074 acl: Phase 4 federation (trust-gated delegation, revocation) + 31 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 10m25s
federation.sx adds peer/trust/delegate/level_covers facts and one engine
rule: delegated grants apply only when local trust covers the action,
re-checked every query (non-transitive, fail-safe). Local/inherited deny
overrides federated grants; delegation composes with group and resource
inheritance. acl-revoke!/acl-fed-assert! propagate retraction/assertion;
mock fed-sx transport for tests. Federated proofs reconstruct via the
existing explainer. Roadmap complete: 120/120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:54:34 +00:00
0122c41ecb feed: verb-aware smart dedupe — reactions collapse cross-actor, posts stay per-actor + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:54:21 +00:00
58656b03e4 feed: feed/home capstone — fanout∘inbox∘dedupe∘ACL∘rank∘take as one line + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:53:15 +00:00
b0feb7b01b feed: notification feed — per-recipient inbox, verb filter, (verb,object) digest + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:51:53 +00:00
a979297959 feed: TF-IDF content ranking over :tags — tag-df/idf, tfidf-score, by-relevance + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:36 +00:00
37226cf6eb feed: Phase 4 visibility + federation — per-viewer ACL, fanout partition, inbound/backfill/ingest, e2e feed/timeline + 22 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:48:27 +00:00
15c97119e4 acl: Phase 3 explanation + audit, 35 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
explain.sx reconstructs a canonical proof tree (first-rule, first-solution)
by goal-directed search over the saturated db, since Datalog keeps no
provenance; depth-capped for cyclic safety. acl-explain returns
{:allowed? :proof :reason} with the blocking eff_deny proof on denial.
audit.sx is an append-only decision log (monotonic seq, disk serializer).
api gains acl/explain, acl/audit, acl/audit-tail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:47:07 +00:00
50a7f31a39 feed: Phase 3 aggregation + ranking — group-by, recency/velocity/engagement scorers, composite, top-N via stable grade-down + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:44:04 +00:00
e762cc2e32 flow: timeout combinator — cooperative step budget + 7 tests (Phase 2 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
(timeout budget node) bounds a node deterministically: nodes opt in via (tick),
budget ticks are allowed, the next raises flow-timeout. No scheduler/clock in pure
SX so the budget is a step count, not wall-clock. Budgets nest and are per-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:42:16 +00:00
915f51b2b6 feed: Phase 2 fanout via outer product — activities ∘.× audience, flatten, edge-guard, dedupe + 29 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:40:34 +00:00
4674620d7e flow: retry combinator — re-run node on raised exceptions + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
(retry n node) re-runs up to n attempts on a raised exception; the last attempt's
exception propagates. Explicit (fail ...) values are NOT retried — they pass through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:39:21 +00:00
f3da3b975a flow: try-catch combinator — reify raised exceptions + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
(try-catch node handler) runs node; on a raised exception calls (handler error)
with the reified error via Scheme guard, returns the handler value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:37:26 +00:00
9261d69cc5 acl: Phase 2 inheritance (groups, resource trees, roles) + 30 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
eff_grant/eff_deny derived relations inherit through member_of (group +
role membership) and child_of (resource hierarchy); role_grant confers
role capabilities. Deny-overrides via stratified negation, deny
authoritative across the inheritance closure. Cyclic membership
terminates. Phase 1 suite unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:36:24 +00:00
1731476dc6 flow: error model — fail/failed?/fail-reason failure values + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Explicit (fail reason) values flow downstream as data and are inspected with
failed?/fail-reason — distinct from raised exceptions (retry/try-catch territory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:35:40 +00:00
65cbdb8387 flow: branch combinator (conditional) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
Phase 2 control flow. (branch pred then else) selects then/else node by running
pred on the threaded input; named 'branch' since 'cond' is a Scheme special form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:32:37 +00:00
fe47334e52 acl: Phase 1 direct grants + deny-overrides, 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 56s
Datalog ACL layer (schema/facts/engine/api) over lib/datalog/. Direct
grant permits unless explicit deny names same (S,A,R) — deny-overrides
via stratified negation. Conformance wrapper + scoreboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:32:13 +00:00
e7501bdf8f feed: Phase 1 stream model — normalize, APL-backed filter/sort/take/reverse, post/all api + 30 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:31:36 +00:00
91ffba9975 flow: Phase 1 declarative DAG — sequence/parallel/defflow combinators + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
Flow combinators as a Scheme prelude loaded onto scheme-standard-env; a flow is a
Scheme procedure input->output, run inside the interpreter (sets up Phase 3 call/cc
suspend). flow/start entry point, conformance runner, scoreboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:22:22 +00:00
c3a0727645 plans: five rose-ash subsystem plans + three loop briefings
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 19s
Plans for acl-on-sx (Datalog), flow-on-sx (Scheme), feed-on-sx (APL),
mod-on-sx (Prolog), search-on-sx (Haskell). Each is a 4-phase queue
sitting on its respective guest language, targeting rose-ash needs:
access control, durable workflows, activity feeds, moderation, search.
Federation extension in Phase 4 of each (plugs into fed-sx).

Briefings for the three loops we're kicking off now: acl-loop,
flow-loop, feed-loop. mod-sx and search-sx briefings will follow
once the first three have surfaced any shared infrastructure
worth extracting to lib/guest/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:55:39 +00:00
1b94082a71 Merge loops/erlang into architecture: Erlang substrate fixes (FFI + tokenizer + charlists + integer literals)
Four small, contained substrate fixes that came out of the fed-sx-m1 milestone work — all scoped to
lib/erlang/, no other-language regressions:

  c6f397c3  register binary_to_list/1 + list_to_binary/1 BIFs (+9 ffi tests, 738/738)
  9fe5c904  $X char literals decode to char code in tokenizer (+12 eval tests, 750/750)
  5098a8f0  atom_to_list/integer_to_list return Erlang charlists; list_to_* accept both (+9 eval, 759/759)
  bcabed6b  integer literals truncate to strict int (was float; broke integer->char)

Together these complete the byte-level term-codec primitive set:
  binary_to_list / list_to_binary (iolist-aware; round-trips for free)
  $X char literals decoding to int char codes
  atom_to_list / integer_to_list returning standard Erlang charlists
  integer literals coercing to strict int (not float)

Any Erlang-on-SX consumer that needs to construct/deconstruct byte sequences or work with charlists now
does so with standard Erlang semantics. Scoreboard: 759/759 (full Erlang suite).

Loop branch loops/erlang stays alive for future Erlang substrate work; this just lands the closed deliverables.
2026-06-06 15:45:46 +00:00
57184daaee briefings: add kernel-on-sx loop briefing
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Sibling to apl-loop / common-lisp-loop / scheme-loop. Captures the
queue-driven kernel loop pattern (Phase B stratification entry-point,
env-as-value successor, motivates lib/guest/reflective/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:28:09 +00:00
d9e2627b89 Merge loops/go into architecture: Go-on-SX, 609/609 across 11 phases, loop closed
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
2026-06-06 15:17:17 +00:00
bcabed6bce erlang: integer literals truncate to strict int (was float; broke integer->char)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
2026-06-06 08:05:57 +00:00
5098a8f015 erlang: atom_to_list/integer_to_list return Erlang charlists; list_to_* accept both (+9 net eval, 759/759) 2026-06-06 08:04:45 +00:00
9fe5c9044d erlang: $X char literals decode to char code in tokenizer (+12 eval tests, 750/750) 2026-06-06 08:03:46 +00:00
c6f397c3d9 erlang: register binary_to_list/1 + list_to_binary/1 BIFs (+9 ffi tests, 738/738) 2026-06-06 08:02:36 +00:00
f553d5b0aa go: tick Phases 4 + 5b + 11 — every phase box , loop formally closed [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Phase 4 (tree-walk evaluator): acceptance bar (80+ tests) was
crossed long ago; remaining sub-items (pointer semantics, lexical
closures, multi-return) flagged "don't gate Phase 5" — ticking the
phase box now.

Phase 5b (buffered channels + select fairness): deferred-by-design.
Re-open when real preemption lands in lib/guest/scheduler.

Phase 11 (VM bytecode opcodes): deferred-by-design. Re-open when
an e2e program takes > 10s, sister kits need bytecode-shape input,
or scheduler kit needs reified frame state.

Stop condition #3 (every Phase 1-11 box checked) satisfied. Final
state: 12 phase boxes ticked, 7 test suites, 609/609 passing,
sister-plan Phase-1 boxes ticked + diaries populated with the
chisel summary. Go-on-SX loop exits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:48:07 +00:00
14486dd78f go: Phase 10 closed — sister plans cross-referenced [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
plans/lib-guest-scheduler.md and plans/lib-guest-static-types-
bidirectional.md both have Phase 1 ticked complete from Go's side
with status blocks enumerating what landed.

Each sister diary received a consolidated chisel-summary entry:
the kit primitives the Go consumer chiselled out, the three
pluggable predicates / orthogonal first-class-tag axes, and the
v0 limitations the eventual kit must lift.

No new Go code — Phase 10 is doc-only per plan. Go-on-SX loop
fully landed: 11 phases, 7 test suites, 609/609 passing.
Two-consumer rule per sister plan now waits on TypeScript (Phase 2
of the bidirectional sister plan, owned outside this loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:14:12 +00:00
9036ce3400 go: Phase 9 closed — 12 end-to-end programs, total 609/609 [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
12 canonical Go programs running through the full pipeline (lex +
parse + types + eval + sched + stdlib): sieve-of-Eratosthenes via
boolean slice (modulo-free), linear search, slice reverse, fib(10),
sum-of-squares via generic Map+Reduce, word-freq counter, channel
pipeline (gen→sq→sum), worker pool, bubble sort, sentence-reverse,
Filter+len, Ackermann, defer+recover on div-by-zero.

Each test threads ONE self-contained Go program through go-eval-
program. The v0 limitations chiselled in earlier phases (float
division, sync spawn, type erasure, nil-as-unbound) are now
durable as commit-trail artifacts; e2e variants written to avoid
them where possible. HTTP-ish ping-pong + WaitGroup deferred
(real preemption + sync package needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:45:36 +00:00
8c91b34264 go: Phase 8 first slice — stdlib strings/strconv, 41 tests, +40 cleared [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
New :go-package NAME ENTRIES value type with field lookup via
extended go-eval-select. New :go-builtin-fn callable for closure-
based stdlib functions. lib/go/std/strings.sx ships 12 functions
(Contains, HasPrefix, HasSuffix, Index, Count, Repeat, Join,
ToUpper, ToLower, TrimSpace, Split, Replace) + lib/go/std/strconv.sx
ships Itoa/Atoi.

Pre-existing bug fixed: parser was emitting (:literal V) for both
`42` and `"42"`, relying on first-char heuristic in eval/types.
Now emits :literal-string for string/rune literals so Atoi("42")
correctly receives the string. 3 parse tests + 2 in-composite-key
tests updated to new shape.

Total 597/597. Stdlib 41/41 — +40 acceptance bar cleared. Sister
diary documents the 11 value-type kinds (struct/slice/map/chan/
fn/method/builtin/builtin-fn/package/panic/defer) all sharing the
"(:KIND PAYLOAD...)" shape, alongside AST nodes and sentinel signals
as the kit's three orthogonal first-class-tag axes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:14:55 +00:00
a7902df365 go: Phase 7 generics closed — types 102/102, +30 cleared, total 556/556 [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
Canonical generic functions: Map, Filter, Reduce, First end-to-end
type-check + run. Plus 20+ typer-only shape tests covering Apply,
Compose, ToMap, Swap, Box, Triple, ToSlice, Take, Send, Fill, Eq,
Values, Pair, Inspect, etc. Index synth (slice/array/map →
element type) added to typer.

v0 limitations stamped in tests: SX `/` is float (no int mod
emulation), `var r []T` indistinguishable from unbound, single-name
constraints opaque (no type-set arithmetic).

Shape locked in: "the parser recognizes shapes, the validator
recognizes roles." Same AST + different role-validators = different
guest semantics. Diary documents this as the lemma the kit should
extract — three deliverables (binding-groups, control-flow sentinels,
index synthesis) now all instantiate it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:25:23 +00:00
459427512d go: Phase 7 foundation — generics syntax through parser/typer/eval [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
gp-parse-type-params consumes the optional [NAMES CONSTRAINT, ...]
clause after a func name. AST stays backward-compatible: 5-slot
func-decl when no [...] is present, 6-slot when it is.

Typer binds each type-param name as (:ty-param NAME CONSTRAINT) so
body's (:ty-name "T") references resolve. Eval is type-erasing —
ignores type info, dispatches by name + arity.

10 new tests: parse (3), types (5), eval (2). Total 527/527.

Shape: the field binding-group from the canonical kit now feeds
6 consumers (struct fields, var-decls, const-decls, params,
receivers, type-params). Confirms it as a TRUE cross-deliverable
shape — sister-plan diary documents the 5 roles binding-groups
take and why the kit should expose ONE parser + pluggable validators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:31:28 +00:00
c50f5d5155 go: goroutine-panic propagation + 8 corner tests → eval 100/100, Phase 6 acceptance cleared [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 20s
Wired panic through :go stmt (v0 sync surfaces back to spawner —
matches real Go's "crash whole program" end-effect) and through
go-eval-for (was swallowing panic at the loop boundary).

8 tests added: goroutine-panic-surfaces, goroutine-recover-via-
spawner-defer, multi-defer-LIFO-with-recover, defer-fires-on-panic-
path, panic(nil), panic-in-loop, defer-still-runs-in-panicking-fn,
args-eager-on-panic-path. 20 Phase-6 tests total; +20 acceptance
bar cleared (eval/ 80 → 100).

Shape: 4 control-flow sites now repeat the same sentinel dispatch
arm (return-value, break, continue, eval-error, go-panic). The
scheduler kit should bake in a single propagates? helper rather
than have each guest evaluator list every sentinel inline — diary
documents the cross-cutting abstraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:54:56 +00:00
f52ad1fac6 go: panic + recover → eval 92/92, total 509/509, Phase 6 closed [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Panic/recover builtins + per-frame __go-panic-cell of shape
(STATE V). Body panic flips cell :none→:raised BEFORE defers drain
so recover() can find it. recover() walks env chain past shadowing
cells to the outermost :raised one — flips it :recovered, returns V.
Frame exit checks cell: :recovered → return clean; :raised →
propagate (:go-panic V).

6 tests: uncaught-from-program, panic-from-fn, defer-recover-swallow,
recover-captures-via-channel, propagation-through-no-defer-chain,
middle-frame-catches-deeper-panic.

Shape: panic cell is a frame-attached out-of-band channel that
survives function boundaries via env-chain walk. Same primitive
slots into the scheduler kit's termination-record + cleanup-with-
error-context hook. Maps cleanly to Erlang try/catch/after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:20:46 +00:00
219e2fcfe7 go: defer + LIFO drain → eval 86/86, total 503/503 [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Phase 6 first slice. New :defer stmt dispatch, go-eval-defer-stmt
captures (callee, eagerly-evaluated args) onto a frame-local
__go-defer-stack mutable list. go-eval-call installs the stack and
drains LIFO before returning; go-eval-program does the same for
the implicit main frame. New :quoted-value AST node lets defer
re-invoke calls with the frozen arg values.

6 eval tests: single defer, multi-LIFO, args-eager-at-defer-time,
fires-on-early-return, frame-local (no bleed to outer), defer-in-loop.

Shape: defer is a per-frame cleanup queue (LIFO on frame exit) that
the scheduler kit will reuse for panic-unwind + clean-exit + select-
case-rollback paths. Distinct from the scheduler's ready-queue —
diary updated to keep that distinction explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:00:37 +00:00
1d3021d206 go: after(d) timer stub + 13 pattern tests → runtime 40/40, Phase 5 closed [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Acceptance bar hit (40 runtime, 497 total). Tests: timer ready,
select-with-timeout, fan-in (3 producers), worker queue, pipeline,
fan-out-then-fan-in, select source-order, fallback case, default,
producer-consumer, two-stage pipeline, channel-counter, after+default,
tick-collector.

Shape chiselled: timer collapses "after duration" into
"channel ready immediately" — select needs only ready? from each
case. Real time is when the flip happens, not what the protocol is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:24:13 +00:00
fa99652970 go: eval.sx — range-over-{slice,map,chan} + 7 tests; break-env fix [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 30s
Phase 5 cont. New go-eval-range-for handles the parser's :range-for
AST shape. Dispatches on the collection's runtime type:

  :go-slice  → bind index + element, iterate by position
  :go-map    → bind key + value, walk entries assoc list
  :go-chan   → bind value, drain until buffer empty (v0 limitation)

Each loop carries:
  - go-range-extend: handles 0/1/2-name binding patterns uniformly
  - go-range-body:   evaluates body whether it's a :block or other shape
  - per-collection loop helper: threads env, catches :break/:continue/
    :return-value/:eval-error sentinels

**Subtle break fix:** loops were previously returning the *pre-loop*
env when break fired, clobbering all assignments made in prior
iterations. Now returns the current iteration's input env (which
carries forward successful iterations' state). Patched for the three
range variants and for the regular for-loop where the same pattern
applied. The shape:

  (= r :break) env    ;; was: (= r :break) original-env

Tests:
  range: slice — sum of 1..5 = 15
  range: slice — key only (index)
  range: map — sum values
  range: channel — collect all buffered
  range: slice with break exits early
  range: slice with continue skips an element
  range: empty slice — body never runs
  range: chan + goroutine producer

runtime 26/26, total 483/483.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:09:46 +00:00
4807bc9c58 go: eval.sx + sched.sx — select stmt evaluation + 6 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Phase 5 cont. Adds `select` statement evaluation:

  go-select-try-case env COMM →
    :not-ready / extended-env / :eval-error
  go-select-pick env CASES DEFAULT-OR-NIL →
    body-result / blocked-error
  go-eval-select-stmt env STMT  — public entry

Walks cases in declared order:
  * :send case — always ready in v0 (unbounded buffer). Sends value
    via go-chan-send! and returns env unchanged.
  * :short-decl / :assign case — RHS expected to be unary <- on a
    channel. Ready iff go-chan-len > 0; on success, recv-into-var
    binds the new value in env.
  * Bare recv (:app (:var "<-") [CHAN]) — ready iff len > 0; consumes
    the value (discarded).
  * :default — deferred until end of walk. Runs if no other case
    ready. Absence + no ready case → (:eval-error :select-blocked-
    no-default).

New `go-chan-len` accessor on the channel closure-bundle so the
select can peek without consuming.

Subtle bug fix: the :select stmt branch in go-eval-stmt was returning
the old env instead of the env returned by the case body. Assignments
inside select cases (`select { case <-ch: x = 1 ; default: x = 99 }`)
now stick.

Tests (6):
  default fires when no case ready
  recv case fires when ready
  recv-into-var binds the value
  send case always ready
  picks first ready case (deterministic order in v0)
  no default + nothing ready → blocked error
  combined with goroutine fan-in

runtime 18/18, total 475/475.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:03:17 +00:00
b693854dc4 go: sched.sx — channels + goroutines (v0 synchronous) + 12 tests; Phase 5 starts [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Phase 5 (goroutines + channels) opens.

lib/go/sched.sx is the **independent implementation** referenced by
plans/lib-guest-scheduler.md — the first-consumer cut whose realised
shape will inform the eventual sister kit.

Channel representation:
  (list :go-chan SEND-FN RECV-FN CLOSED?-FN CLOSE!-FN)
Each closure shares a mutable `buf` (a list mutated via append! and
set!) and a `closed` flag. Channel identity is closure-instance —
two `make()` calls produce distinct values per Go spec § Channel types.

Primitive API in sched.sx:
  go-make-chan / go-chan? / go-chan-send! / go-chan-recv! /
  go-chan-closed? / go-chan-close!

Eval integration in eval.sx:
  * `make` and `close` added as builtins. v0 `make()` takes no args
    and returns an unbounded-buffer channel.
  * `:send` stmt → go-chan-send! on the channel.
  * Unary `<-` recv on channel values → go-chan-recv!. `:empty`
    sentinel converted to nil (stand-in for blocking semantics).
  * `:go expr` → synchronous eval (v0 limitation, see sched.sx
    header).

**v0 concurrency model — synchronous goroutines.** SX doesn't expose
first-class continuations to guest code, so v0 runs `go f()`
immediately and depends on the spawned goroutine running to
completion before the main goroutine receives. This is the right
semantics for the simple producer/consumer patterns covered here.
True preemption with blocking send/recv is Phase 5b — requires either
a CEK-style trampolining eval rewrite or kit-level continuation
support. Logged in sched.sx header and in the sister-plan diary.

Runtime suite (12 tests):
  * 6 direct API tests: identity, FIFO order, closed-flag
  * 6 source-level: make + send + recv, go ping-pong, close,
    multi-goroutine fan-in, worker-with-result

Sister-plan scheduler diary updated with the channel-as-closure-
bundle insight and the v0 synchronous-spawn caveat.

runtime 12/12, total 469/469.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:55:41 +00:00
674d8115b8 go: eval.sx — method dispatch + unary + e2e programs + 14 tests; Phase 4 bar crossed [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Phase 4 cont. The crossings:

  * Method dispatch — Methods record under #method/TYPE/NAME (same
    mangled-key scheme the type checker uses, intentionally so eval
    and type checker can converge on a shared method-table protocol
    later). go-eval-method-call: lookup the receiver type's method,
    bind receiver param to the struct value, evaluate body. Value and
    pointer receivers treated the same in v0 (pointer semantics not
    modelled yet).
  * Method-call dispatch — In go-eval's :app branch, head=:select
    routes to go-eval-method-call. If the receiver is not a struct,
    falls back to the field-as-callable path.
  * Unary prefix ops — go-eval's :app branch checks for 1-arg :var
    head with op name "-" / "+" / "!". (Other unary ops like
    *p / &v / <-ch / ^x deferred until pointer / channel / bitwise
    semantics arrive.)

End-to-end programs verified:
  * recursive fib(10) = 55
  * struct + method + iterative loop (counter bump 7 times)
  * linear search (returns index or -1)
  * factorial via method on Counter (= 120)
  * count odd numbers in 1..10 = 5

**Phase 4 acceptance bar (80+) crossed: eval 80/80, total 457/457.**

Remaining Phase 4 work (closures, multi-return, full slice triple,
pointer semantics) refines but doesn't gate Phase 5 (goroutines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:47:07 +00:00
99f8f37ff8 go: eval.sx — structs + selector + selector-assign + 8 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
Phase 4 cont. Adds runtime support for Go's struct type.

Struct representation: (list :go-struct TYPE-NAME FIELDS) where
FIELDS is an association list of (field-name value) pairs.

`type T struct { ... }` is now significant at eval-time. The new
go-eval-type-decl registers field-name lists in env under
(:go-struct-type FIELD-NAMES) so positional composite literals can
map argument positions to field names. Non-struct type aliases are
silent no-ops in v0.

go-eval-composite extended:
  * If type is (:var TYPE-NAME), look up in env. Must be a
    :go-struct-type entry — error otherwise.
  * go-eval-struct-lit branches on whether the first elem is :kv
    (keyed) or not (positional). Keyed mode reads key-name from each
    :kv's key (which is a :var node). Positional mode arity-checks
    against the field-names list and zips positionally.

go-eval-select handles (:select OBJ FIELD-NAME) — field lookup with
go-map-get on the FIELDS assoc list.

go-eval-assign-pairs gets a new (:select OBJ FIELD) LHS branch:
  - var-rooted only for v0
  - rebuilds the struct via go-map-set, rebinds the var

**Functions taking and returning structs round-trip end-to-end:**

  type Point struct { x, y int }
  func add(a, b Point) Point { return Point{a.x + b.x, a.y + b.y} }
  add(Point{1, 2}, Point{3, 4})  // Point{4, 6}

Method-dispatch (calling p.M() where M is a method on Point's type)
is the next step; needs threading the type checker's #method/T/N
scheme into eval-time so functions can be looked up by receiver type.

eval 66/66, total 443/443.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:39:06 +00:00
9ed58bd0fc go: eval.sx — maps + index-assign + 8 tests; word-count e2e [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
Phase 4 cont. Adds map values and index-assignment for both
slices and maps.

Map representation: (list :go-map ENTRIES) where ENTRIES is an
association list of (key value) pairs.

  go-map-get / go-map-set    — primitive lookup + functional-update.
  go-slice-set               — same idea for slices.

go-extract-map-entries reads each :kv element in a composite literal,
evaluating key and value. go-eval-composite dispatches on :ty-map to
build the :go-map value.

go-eval-index extended: when OBJ is a :go-map, look up the key via
go-map-get. Missing keys return nil in v0 (Go's real semantics is
the zero value of the value type — needs runtime type info that this
slice doesn't yet thread through).

go-eval-builtin's len handles :go-map alongside :go-slice and strings.

go-eval-assign-pairs gets a new branch for (:index OBJ IDX) LHS:
  - var-rooted indexing only (a[i] = v / m["k"] = v)
  - slice → go-slice-set then rebind the var
  - map   → go-map-set then rebind the var

**Word-counter via map[string]int works end-to-end:**

  words := []string{"a", "b", "a", "c", "a"}
  counts := map[string]int{}
  for i := 0; i < len(words); i++ {
    counts[words[i]] = counts[words[i]] + 1
  }
  // counts["a"] == 3

Builds on:
  - map composite literal eval
  - map index lookup
  - map index-assign
  - slice indexing
  - len() builtin
  - nil + 1 = 1 (numeric-coercion of missing-key default)

eval 58/58, total 435/435.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:17 +00:00
ab04ec1cf7 go: eval.sx — slices + index + slice expr + len/append builtins + 10 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
Phase 4 cont. Adds runtime support for Go's slice type.

Slice representation: (list :go-slice ELEMS) — a simple wrapper around
a list of element values. v0 deferring the full
(length, capacity, backing-vector) triple from the Go spec until
programs need it.

  go-eval-composite      → for (:composite TYPE-OR-EXPR ELEMS) where
                            TYPE is :ty-slice / :ty-array, eval each
                            element (handling :kv index-keyed
                            shorthand by taking only the value) and
                            wrap in :go-slice.
  go-eval-index          → (:index OBJ IDX). Bounds-checked; out-of-
                            range returns (:eval-error :index-out-of-range).
  go-eval-slice          → (:slice OBJ LOW HIGH MAX). Two-index slice
                            with omitted low → 0, omitted high → len.
                            Returns a new :go-slice.
  go-list-slice          → primitive list-slicing helper.

Builtins live in a new starter env go-env-builtins:
  len(slice|string)      → count
  append(slice, ...x)    → new slice with x appended
  print(...)             → no-op in v0

Builtins are bound as (:go-builtin NAME); go-eval-call recognises the
shape and routes to go-eval-builtin instead of go-eval-fn.

**Summing a slice via the canonical Go for-loop works end-to-end:**

  a := []int{1, 2, 3, 4, 5}
  sum := 0
  for i := 0; i < len(a); i++ {
    sum = sum + a[i]
  }
  // sum == 15

eval 50/50, total 427/427.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:28:12 +00:00
a019aa1edc go: eval.sx — for / break / continue / inc-dec + 7 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Phase 4 cont. go-eval-for handles all three for-header shapes:

  for { ... }                          — infinite (cond defaults to true)
  for cond { ... }                     — while-like (init=nil, post=nil)
  for init ; cond ; post { ... }       — C-style

Implementation:
  * Run INIT (if any), extending env.
  * Loop: eval COND. If false, exit with current env.
    Eval body (a :block). Catch sentinels:
      :return-value → propagate up
      :break        → exit loop with pre-break env
      :continue     → still runs POST, then re-loops
    Otherwise: run POST, re-loop.

:break and :continue propagate as keyword sentinels through
go-eval-block alongside the existing :return-value sentinel. The
block returns whichever sentinel hit first; control-flow constructs
(for, switch, select) catch them.

inc-dec (x++ / x--) updates env via the same shadowing model used by
assign — `(go-env-extend env name (+ current 1))`.

**Iterative fact(5) = 120 and the classic sum-to-9 = 45 both
evaluate.** Demonstrates the for-loop machinery is solid enough for
real programs.

eval 40/40, total 417/417.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:22:34 +00:00
1340c2626b go: eval.sx — stmts + function application; recursive fib evaluates + 8 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Phase 4 cont. go-eval-stmt dispatches on:
  :return       → wraps value in (:return-value V) sentinel
  :var-decl     → bind each NAME via go-eval-var-decl
  :short-decl   → bind each (:var NAME) lhs to corresponding expr value
  :assign       → immutable-env shadowing (true mutation deferred)
  :block        → run stmts via go-eval-block, propagating :return-value
  :if / :else   → cond-driven dispatch
  :func-decl    → bind name to (list :go-fn PARAMS BODY)
  else          → expression statement, evaluate for side effects

go-eval-call extends the CALLER's env with param-names → arg-values
(dynamic-scope-ish — closures don't capture lexical env yet), runs the
body block, catches :return-value and unwraps.

**Recursive fib(5) = 5 evaluates correctly.** Recursion works because
top-level func bindings are in the calling env before the recursive
call happens.

True lexical closures (let bind sees outer var; assignments visible to
nested funcs) need an env-cell model with mutation; deferred to a
later slice.

eval 33/33, total 410/410.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:17:26 +00:00
ff9abe3ae6 go: eval.sx scaffold — literals + vars + binops + 25 tests; Phase 3 closed [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Phase 3 — bidirectional type checker — is fully ticked (short-decl
was already implemented). Phase 4 starts here.

lib/go/eval.sx single judgment:

  (go-eval ENV EXPR)  →  VALUE | (list :eval-error TAG ...)

ENV is an association list of (NAME VALUE) bindings — same shape as
the type checker's ctx, but the entries are runtime values. Values
are represented directly in SX: integers/floats as SX numbers,
strings as SX strings, booleans as true/false, nil as nil. Composite
values (slices/maps/structs/pointers/channels) arrive in later slices.

First-slice coverage:

  * go-env-empty / -lookup / -extend
  * Literal decoding:
      decimal (with underscores)
      hex (0x.. / 0X..)
      oct (0o.. / 0O..)
      bin (0b.. / 0B..)
    via go-hex-digit-value (explicit char equality — SX's nth on
    strings returns single-char strings, not numeric codes; the
    arithmetic-on-char-codes pattern from the OCaml kernel ports
    doesn't work here).
  * Identifier lookup with predeclared true / false / nil.
  * Binops: + - * / and the six comparison ops and && / ||.
  * Errors as (:eval-error TAG ...) sentinels.

Statements (block / return / short-decl / assign), control flow
(if / for), and function application / closures arrive in subsequent
slices.

eval 25/25, total 402/402.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:11:20 +00:00
21bb17e4a6 go: types.sx — interface satisfaction (structural method-set check) + 7 tests [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
Phase 3 cont. The headline Go-distinguishing typing feature: interfaces
are satisfied *structurally and silently* — no `implements` declaration,
no nominal subtyping. Any type whose method set contains all the
interface's methods (with matching signatures) satisfies it.

Method declarations now type-check via go-check-method-decl:

  * Receiver type extracted (T or *T → "T") via go-extract-recv-ty-name.
  * Method signature (:ty-func PARAMS RESULTS) bound under a mangled
    key "#method/RECV-NAME/METHOD-NAME" in ctx.
  * Body checked with receiver + params extended into the body ctx.

go-iface-satisfies? CTX TY-NAME IFACE-TYPE walks the interface's
:method elements; for each, looks up #method/TY-NAME/METHOD-NAME and
compares (PARAMS, RESULTS) tuples. Embedded interfaces (:embed
elements) skipped in v0 — recursive interface resolution later.

Tests:
  * method-decl binds under #method/Point/String
  * pointer-receiver method also keys the base type
  * Point with String() satisfies interface { String() string }
  * empty type does NOT satisfy Stringer
  * arity-mismatch method fails satisfaction
  * multi-method satisfaction works
  * partial method-set fails

types 72/72, total 377/377. Phase 3 sub-deliverable list is now
substantially complete; only AST-path error context remains as a UX
sharpener.

Sister-plan static-types-bidirectional diary updated with the
**constraint-satisfies? pluggable predicate** kit-API proposal —
third pluggable point after synth/check + assignable?. Go interfaces,
Haskell typeclasses, Rust traits, and TS structural subtyping all
answer "does this value-type fit this constraint-type?" with
different machinery; the kit's check uses constraint-satisfies? when
EXPECTED is itself a constraint type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:05:08 +00:00
4bd9262060 go: types.sx — composite-literal element checking; Phase 3 bar crossed + 10 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Phase 3 cont. Adds composite-literal type-checking via go-synth-composite:

  []T{...}     — go-check-composite-elems with VAL-TY=T, KEY-TY=nil.
                 Each plain elem assignable to T; :kv element accepted
                 (Go's index-keyed shorthand: `[]int{0: 5, 1: 10}`)
                 with only the value checked.
  [N]T{...}    — same as slice; result :ty-array N T.
  map[K]V{...} — KEY-TY=K, VAL-TY=V. Each :kv pair: key assignable
                 to K, value to V. Non-:kv elements in maps are
                 (:type-error :map-elem-missing-key).

The literal's *synthesised* type is the type expression itself, so
nested composites fall out by recursion:

  [][]int{[]int{1,2}, []int{3,4}}
    → outer: go-check-composite-elems with VAL-TY=[]int
    → each inner []int{1,2} goes through go-synth-composite recursively,
      yielding :ty-slice :ty-name "int" — assignable-equal to VAL-TY.

Coverage: positive cases (homogeneous slices/arrays/maps, empty
slice, nested), and three negative cases (slice element mismatch,
map key mismatch, map value mismatch). Also a decl test:
  var x = []int{1, 2, 3}  →  binds x to :ty-slice :ty-name "int"

Named-type literals (`Point{1,2}`, `pkg.T{...}`) need type-decl-driven
field resolution; deferred. Interface satisfaction and AST-path error
context also remain — neither gates Phase 4.

**Phase 3 acceptance bar (60+) crossed: types 65/65, total 370/370.**

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:59:38 +00:00
5b4a8be689 go: types.sx — call type-checking + 8 tests; recursive funcs now type [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Phase 3 cont. The expression-synth :app dispatch is now bifurcated:

  * go-is-binop-call? — head is :var with an operator name AND 2 args
    AND the operator is in one of the binop tables. Short-circuits to
    go-synth-binop as before.
  * Everything else routes to go-synth-call.

go-synth-call:
  1. Synth the callee. Must produce a (list :ty-func PARAMS RESULTS).
     Otherwise → (:type-error :not-callable TYPE).
  2. Arity-check args vs params. Mismatch → (:type-error :arity-mismatch).
  3. go-check-args-against: each arg assignable to corresponding param
     (untyped-constant flow works — `f(42)` accepts the untyped int
     into an int param).
  4. Result by count:
       0 results → (list :ty-void)
       1 result  → that result directly
       N results → (list :ty-tuple TYPES)   for multi-return

The recursive case lights up: go-check-func-decl binds the function
in its own body's ctx before checking. So:

  func fib(n int) int { return fib(n) + fib(n) }

now type-checks because `fib` resolves inside the body, synth-call
sees its `:ty-func` and verifies the recursive call. Multi-return
functions destructure into `:ty-tuple` which short-decl will need to
consume next iteration.

types 55/55, total 360/360.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:56:10 +00:00
9f4c6787e4 go: types.sx — func-decl + stmt-level dispatch + 7 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
Phase 3 cont. Adds:

  * go-check-func-decl — binds the function in the outer ctx (recursive
    self-reference will work once call-checking lands), extends the
    body's ctx with each :field param group via go-ctx-extend-field
    (the binding-group shape's *third* consumer in the type checker;
    five total across parser+typer when counted with struct fields,
    var-decls, const-decls, func params, method receivers).
  * go-check-stmt — dispatches on :return / :assign / :var-decl /
    :const-decl / :short-decl / :type-decl / :block; falls back to
    go-synth for expression statements.
  * go-check-block — threads ctx through stmts so that decls inside
    the block extend the ctx for subsequent stmts.
  * go-check-return-list — each return expr assignable to the
    corresponding declared result type; mismatch counts are typed.
  * go-check-assign / go-check-assign-pairs — RHS assignable to LHS
    synthesised type, count mismatch typed.
  * Helpers: go-decl-params-to-ty-list (flattens :field NAMES TYPE to
    a flat list of N types), go-extend-with-params (folds extend-field
    over a param-group list), go-repeat-ty.

Coverage tests:
  func empty() {}                                          → ok
  func add(x, y int) int { return x + y }                  → ok
  func bad() int { return "hi" }                           → typed error
  func sig(x int) int                                      → signature-only binds
  func sumsq(x, y int) int { return x*x + y*y }            → params visible
  func two() int { var x int = 1; var y int = 2;           → nested decl
                   return x + y }
  func g() int { var x int; x = 5; return x }              → assign verified

types 47/47, total 352/352.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:52:59 +00:00
5e27a7f0c9 go: types.sx — declaration checking (var/const/type + :=) + 12 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Phase 3 cont. Adds go-check-decl which dispatches on AST shape and
returns either the extended context or a :type-error:

  :var-decl     (:field NAMES TYPE-or-nil) EXPRS-or-nil
  :const-decl   (same shape; same logic in v0 — mutability later)
  :short-decl   LHS-LIST EXPRS         (lhs is a list of :var nodes)
  :type-decl    NAME TYPE              (type alias)

New helpers:

  go-default-type      — untyped-int → int, untyped-float → float64,
                         etc. Used when inferring var x = EXPR.
  go-check-exprs-against — every expr assignable to the declared type.
  go-bind-names-to-synth  — pair names with default-typed synth of
                            corresponding exprs; extends ctx.

The canonical Go pitfall flows through end-to-end now:

  (go-check-decl ctx (go-parse "var x float64 = 42 / 7"))
  →  ctx + (x → float64)

Because: 42/7 synthesises to ty-untyped-int (binop result of two
untyped operands), then go-check-exprs-against uses go-type-assignable?
to check ty-untyped-int → ty-name "float64" — :ok via the
untyped-int-to-any-numeric assignability rule. The 6 (integer) result
gets float-converted on assignment, never floated mid-computation.

types 40/40, total 345/345.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:49:27 +00:00
86ddaf255c go: types.sx — literal synth + binop + assignability; canonical pitfall handled + 16 tests [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Phase 3 cont. Adds:

  * go-classify-literal-string — heuristic detection of literal kind
    from the value-string (parser strips lexer's kind tag; flagged for
    follow-up to extend AST shape).
  * go-synth-literal — :ty-untyped-int / -float / -imag / -string.
  * go-synth-binop — arithmetic, bitwise, comparison, logical ops with
    untyped-constant unification:
      untyped-int + untyped-float → untyped-float
      untyped + typed              → typed
      comparison ops               → bool
      logical ops                  → bool
  * go-untyped? + go-type-assignable? — pluggable assignability that
    swaps in where structural equality used to gate go-check. Untyped
    int assignable to any numeric type; untyped float assignable to
    float/complex; untyped string to string.

**Canonical Go pitfall handled correctly**: `var x float64 = 42 / 7`
parses to a binop, synth produces :ty-untyped-int (since BOTH operands
are untyped, the int division stays in the int domain), and check
against float64 returns :ok via assignability. Wrong implementations
that float-coerce eagerly would give 6.0; the right behaviour is
"compute 6 as int, then convert to float64 = 6.0".

Verified by test "binop: 42 / 7 assignable to float64 (canonical
pitfall)" and the type-only test "binop: 42 / 7 — untyped int".

Sister-plan static-types-bidirectional diary updated with the
**pluggable-assignable-predicate** kit-API proposal:

  (check-with assignable? CTX EXPR EXPECTED)

Each consumer plugs in its own variance discipline (Go untyped-flow,
TS structural subtyping, Rust lifetime-aware identity) without
rewriting synth or the judgment skeleton.

types 28/28, total 333/333.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:46:03 +00:00
6c3b7d1cf9 go: types.sx scaffold — synth/check skeleton + 12 tests; Phase 3 starts [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
First slice of Phase 3 (bidirectional type checker).

lib/go/types.sx defines:
  * go-ctx-empty / go-ctx-extend / go-ctx-lookup — context as a value.
  * go-ctx-extend-field — consumes the (:field NAMES TYPE) shape from
    the parser, binding every name to the shared type. This is the
    cross-deliverable validation of the :field binding-group
    observation made during Phase 2 func decls: parser produces it,
    type checker consumes it, same shape end-to-end.
  * go-predeclared — true / false / nil baked in. Full list expanded
    on demand.
  * go-synth — currently handles variable lookup; literals / calls /
    binops follow in subsequent iterations.
  * go-check — v0 defers to synth + structural type equality. Untyped-
    constant flow and assignment-compatibility relations land later.
  * Type errors carry first-class tags (:unbound, :mismatch,
    :unsupported-synth) so consumers and tooling can dispatch.

Conformance.sh wired with new types suite. Scoreboard cleanup: drop
the "pending" types row since the suite is now real.

types 12/12, total 317/317. Phase 3 underway.

Sister-plan static-types-bidirectional diary updated with the
synth/check shape: judgment skeleton, error tag structure, and the
proposal that `check` should accept a `subtype?` predicate parameter
so each consumer (Go untyped-constants, TS variance, Rust lifetimes)
plugs in its own variance discipline without rewriting the judgment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:41:02 +00:00
2404a593bd go: parse.sx — multi-form file parsing + 7 e2e tests; PHASE 2 COMPLETE [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Final Phase 2 sub-deliverable. go-parse now handles whole Go files:

  - Empty source → nil
  - Single top-level form → that form (backward-compatible with ~169
    existing single-stmt / single-decl tests)
  - Multiple forms → (list :file FORMS), the canonical Go file shape

Implementation: gp-parse-all loops gp-parse-top until eof, tolerating
ASI semis between forms, then returns based on form count.

End-to-end test set (asserts the top-level decl-tag sequence via a
new decl-tags helper, not the full AST tree — that'd be unwieldy):

  - hello-world             :package :import :func-decl
  - recursive fibonacci     :package :func-decl
  - FizzBuzz                :package :import :func-decl
  - goroutine ping-pong     :package :func-decl :func-decl
  - struct + method         :package :type-decl :method-decl :func-decl
  - interface + method      :package :type-decl :type-decl :method-decl
  - defer + select + range  :package :func-decl

Type-switch (`switch v := x.(type) { ... }`) is the one syntactic
shape still deferred from Phase 2; doesn't gate Phase 3.

**Phase 2 (parser) is complete.** parse 176/176, total 305/305. Next:
Phase 3 — bidirectional type checker. The sister-plan diary for
static-types-bidirectional already has the :field binding-group
insight; Phase 3 will add the synth/check shape that emerges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:34:16 +00:00
44fb231391 go: parse.sx — switch + select + 8 tests; stmts done [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Adds Go's switch and select statements:

  switch TAG { case V1, V2: a; case V3: b; default: c }
  switch { case cond: ... }                            — tagless
  select { case x := <-ch: a; case ch <- v: b; default: c }

AST shapes:
  (list :switch TAG CASES)             — TAG nil for tagless
  (list :case VALUES BODY)             — VALUES is expr-list
  (list :select CASES)
  (list :select-case COMM-STMT BODY)   — COMM-STMT is send/recv-assign/bare-recv
  (list :default BODY)

gp-parse-case-body reads stmts until the next case/default/}/eof
without consuming the terminator — used by both switch and select.

select-case parsing reuses gp-parse-stmt for the comm-stmt, so all
four shapes (send, x := <-ch, x = <-ch, bare <-ch) fall out from the
existing stmt parser. Composite-lit suppression is engaged for the
switch tag expression.

Type-switch (`switch v := x.(type) { case int: ... }`) is the one
deferred shape; needs the `.(type)` pseudo-syntax recognised in the
expression layer. Phase 2 statement coverage is otherwise complete.

This is also a chiselling iteration for scheduler sister kit. Diary
updated with select-case design insights:

  * All four select-case shapes share (list :select-case STMT BODY)
    — kit primitive sched-select accepts a uniform list of cases.
  * Default vs no-default determines blocking semantics. Erlang's
    `receive ... after Timeout -> ...` is the analogue — both fit
    "non-blocking fallback case" in the kit API.

parse 169/169, total 298/298.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:29:37 +00:00
171a08a2f8 go: parse.sx — go/defer/send/for-range + 9 tests [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Adds Go's concurrency + iteration primitives to the statement parser:

  go EXPR                     →  (list :go EXPR)
  defer EXPR                  →  (list :defer EXPR)
  ch <- v                     →  (list :send CHAN VALUE)
  for range COLL { ... }      →  (list :range-for nil nil nil COLL BODY)
  for k := range C { ... }    →  (list :range-for :short-decl KEY nil COLL BODY)
  for k, v := range C { }     →  (list :range-for :short-decl KEY VAL COLL BODY)
  for k, v = range C { ... }  →  (list :range-for :assign KEY VAL COLL BODY)

gp-for-find-range pre-scans the for-header (to '{' or eof) looking
for the 'range' keyword; if present, dispatches to gp-parse-for-range
which handles the four range shapes. C-style and while-like and
infinite are now in gp-parse-for-c-style — gp-parse-for is just a
dispatcher.

Send statement detection lives in the LHS-list branch of gp-parse-stmt:
after parsing a single LHS expression, '<-' triggers (:send LHS RHS).
Channel-recv (`<-ch`) was already parsed as unary `<-` in the expression
layer, so both directions cover.

This is the **chiselling-relevant iteration** for the scheduler sister
kit: the AST shapes Go-on-SX will eventually feed into the kit's
scheduler primitives (sched-spawn, sched-defer, chan-op) have landed.
Sister-plan diary updated with three design insights:

  * :go / :defer both wrap a single expr — kit's sched-spawn should
    accept a thunk uniformly across Erlang's spawn(M,F,A) and Go's
    go fn().
  * :send carries CHAN+VALUE symmetrically with the unary <- recv —
    both reduce to (chan-op direction chan value) in the kit.
  * `for v := range ch` uses the same :range-for shape as range-over-
    slice; the scheduler kit's range dispatch is where chan-recv ⇄
    iteration polymorphism lives.

parse 161/161, total 290/290.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:24:23 +00:00
ba41f8a580 go: parse.sx — if/else, for, break/continue, inc-dec + 11 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s
Adds the most-used control-flow forms:
  if COND { ... } [else { ... } | else if ...]
  for { ... }                          — infinite
  for COND { ... }                     — while-like
  for INIT; COND; POST { ... }         — C-style
  break / continue                     — keyword stmts (no labels yet)
  x++ / x--                            — Go statement inc-dec

AST shapes:
  (list :if COND THEN ELSE)              — ELSE nil / :if / :block
  (list :for INIT COND POST BODY)        — any of INIT/COND/POST may be nil
  (list :break LABEL)  (list :continue LABEL)
  (list :inc-dec OP EXPR)                — OP is "++" / "--"

**Closes the parser-mode caveat** logged when composite literals
landed. `gp-no-comp-lit` is a re-entrant counter on the parser state;
control-flow constructs increment it before parsing their condition
and decrement after, suppressing the postfix `{` → composite-lit
interpretation so that `if Foo { ... }` correctly reads `{ ... }` as
the body, not as `Foo{}` composite literal. Verified by the test:

  (go-parse "if Foo {}")  →  (:if (:var "Foo") (:block ()) nil)

gp-parse-control-cond is the single helper that bracket-wraps the
flag bump so future control-flow forms (switch, select, range) can't
forget to engage suppression.

switch / select / defer / go / for-range / channel-send still deferred.

parse 152/152, total 281/281.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:17:40 +00:00
5f6d62f45b go: parse.sx — statements (return / short-decl / assign / block) + 9 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 31s
First slice of Phase 2 statements. Replaces the func-decl ':body'
sentinel with real (:block STMTS) parsing.

gp-parse-stmt dispatches on the leading token:
  return [exprs]                — (list :return EXPRS)
  { ... }                       — nested block (recurses into block-body)
  lhs := exprs                  — (list :short-decl LHS-LIST EXPRS)
  lhs = exprs                   — (list :assign LHS-LIST EXPRS)
  lhs OP= expr                  — (list :assign-op OP LHS-LIST [EXPR])
  expr                          — bare expression statement
  var/const/type/func keywords  — fall through to gp-parse-decl

LHS may be a comma-separated list. Compound-assign covers all 11 Go
forms (+= -= *= /= %= &= |= ^= <<= >>= &^=).

gp-parse-block-body iterates: skips semis, terminates on '}', and for
non-trivial tokens calls gp-parse-stmt. **Two progress guards** added
to avoid infinite loops on unsupported syntax:

  * gp-block-body-loop force-advances one token if gp-parse-stmt
    returns nil without consuming.
  * gp-parse-composite-elems does the same when its expr parser
    returns nil — fixes a hang on '`if true {`x := 1`}`' where the
    parser was misreading `if true{...}` as a composite literal then
    spinning on `:=` inside the brace body.

Existing func/method decl tests updated from the ':body' sentinel to
the new (:block STMTS) shape. Old `gp-skip-block!` left as dead code
(removed once control-flow stmts make the misinterpretation issue
moot).

Control-flow stmts (if/for/switch/select/defer/go/break/continue) and
channel send (`ch <- v`) deferred to subsequent iterations.

parse 141/141, total 270/270.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:11:01 +00:00
ad21776002 go: parse.sx — func + method declarations + 8 tests [shapes-static-types-bidirectional]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Adds Go func and method declarations:
  func main() {}
  func add(x, y int) int { return x + y }
  func mix(x int, y string) {}
  func divmod(a, b int) (int, int) {}
  func sig(x int) int                            (no body)
  func (p *Point) String() string { ... }        (method, pointer recv)
  func (s Stack) Len() int { ... }               (method, value recv)
  func nested() { if true { x := 1; { y := 2 } } }   (nested braces)

New gp-parse-decl-param-group implements named-greedy disambiguation:
collects consecutive 'ident [, ident]*' then parses a type. Anonymous
mixed lists like 'func(int, string)' are a known limitation (parser
treats first ident as a name); flagged in plan.

gp-skip-block! brace-balances over the body; the AST stores ':body'
as a sentinel until statement parsing lands. Methods use the receiver
parameter shape directly.

AST:
  (list :func-decl   NAME PARAMS RESULTS BODY)
  (list :method-decl RECV NAME PARAMS RESULTS BODY)

**All five `:field` binding-group consumers now exist** across the
parser: struct fields, var, const, func params, method receivers.
That's strong cross-deliverable validation of the ast-binding-group
proposal from Blockers — five different declaration contexts, one
shared shape.

This is the chisel-relevant insight for sister plan static-types-
bidirectional: an entry has been appended to its design diary
describing how `:field` will be the load-bearing input shape for
the bidirectional checker's `check Γ e T` judgment across these
contexts.

parse 132/132, total 261/261.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:52:07 +00:00
4922b6e987 go: parse.sx — package/import/var/const/type declarations + 10 tests [consumes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 33s
First slice of Phase 2 declarations:
  package main                      →  (list :package "main")
  import "fmt"                      →  (ast-import "fmt")    [from kit]
  var x int                         →  var-decl + :field binding
  var x = 5                         →  init only (type inferred)
  var x int = 5                     →  both type and init
  var x, y int = 1, 2               →  multi-name shared type
  const Pi = 3.14                   →  const-decl
  const C int = 42                  →  typed const
  type T int                        →  named alias
  type Point struct { x, y int }    →  named struct

New gp-parse-top dispatches on the leading keyword: routes
package/import/var/const/type to gp-parse-decl; everything else
still goes through gp-parse-expr. Existing expression tests are
unaffected (cur won't be a decl keyword at expression start).

var/const decls use the (:field NAMES TYPE) shape from the
ast-binding-group proposal — first concrete cross-deliverable use:
struct fields, var decls, const decls all envelope through the
same node. That's the smell test for whether the kit shape is
right; so far it's clean.

import uses the canonical ast-import from lib/guest/ast.sx — first
direct use of a kit constructor for a declaration shape.

Grouped/parenthesized decls (var (...), import (...), const (...),
type (...)) and func decls (with method receivers + named params)
deferred to subsequent iterations.

parse 124/124, total 253/253.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:44:24 +00:00
632e06d3cf go: parse.sx — composite literals + 8 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Adds Go composite literals:
  T{}                                  empty
  T{1, 2}                              positional
  T{X: 1, Y: 2}                        keyed
  []int{1, 2, 3}                       slice
  [3]int{1, 2, 3}                      array
  map[string]int{"a": 1}               map
  pkg.Point{1, 2}                      qualified
  []Point{Point{1,2}, Point{3,4}}      nested

AST: (list :composite TYPE-OR-EXPR ELEMS). Each element is an
expression or (list :kv KEY VALUE).

Two parser entry points feed the same AST:
  * gp-parse-primary picks up type-prefixed composites by seeing
    a literal-type starter ([, map, struct) and parsing a type
    first, then optionally a '{' body.
  * The postfix loop picks up ident-prefixed composites — after
    any base expression, '{' wraps it as a composite literal.

Known limitation flagged in plan: when statement parsing arrives,
the postfix '{' branch will misread `if cond { ... }` as a composite
literal. Standard fix: parser-mode flag suppressing composite-lit
disambiguation in control-flow expression positions. Added to plan.

Elided types in nested composites (`[][]int{{1,2},{3,4}}` with the
inner `{1,2}` typed implicitly) deferred.

parse 114/114, total 243/243.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:21:47 +00:00
48379e04bc go: parse.sx — interface type expressions + 8 tests; type expressions DONE [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
Adds Go interface type expressions:
  interface {}                              →  empty
  interface { Close() }                     →  no-param method
  interface { String() string }             →  with single return
  interface { Read([]byte) (int, error) }   →  multi-return method
  interface { Stringer }                    →  embedded named iface
  interface { io.Reader }                   →  qualified embedded
  interface { io.Reader; Close() error }    →  mixed

gp-parse-interface-elems walks elements tolerating ASI semis. Each
element is either:
  (list :method NAME PARAMS RESULTS)
  (list :embed TYPE)

Method params/results reuse gp-parse-func-type-params/results — the
shape is identical to a free-standing func type. Go 1.18+ type sets
(interface { ~int | ~float64 }) are deferred until the generics
sub-deliverable.

With this, the full Phase 2 **type expressions** sub-deliverable is
complete (pending only field tags, struct/iface embeds details,
variadic, named func params, generics — all flagged later).

parse 106/106, total 235/235.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:16:24 +00:00
a94ffa0feb go: parse.sx — struct type expressions + 8 tests [proposes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 26s
Adds Go struct types to gp-parse-type:
  struct {}                       →  (list :ty-struct ())
  struct { x int }                →  (list :ty-struct [(:field [x] (:ty-name int))])
  struct { x int; y string }      →  multiple field rows
  struct { x, y int }             →  shared-type row (NAMES is a list)
  struct { inner struct { x int } }  →  nested struct types

gp-parse-struct-fields walks field rows tolerating ASI-inserted semis
(from newlines between fields). Each row collects 1+ names separated
by commas, then a single type that all the names share. Embedded
fields, field tags, and methods are deferred.

The :field shape (NAMES + TYPE) is a recurring multi-language pattern —
struct fields, func params, method receivers, var decls all map to it.
Logged in Blockers as a canonical-AST candidate
(ast-binding-group / ast-named-of-type); worth promoting once a second
consumer (parser of another statically-typed guest, or Go func decls)
exercises the same shape.

parse 98/98, total 227/227.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:12:07 +00:00
9acdbcb8d8 go: parse.sx — func type expressions (anonymous params) + 9 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 20s
Adds Go func-type parsing to gp-parse-type:
  func()                  →  (list :ty-func () ())
  func() int              →  (list :ty-func () [int])
  func(int, string)       →  (list :ty-func [int string] ())
  func(int) string        →  (list :ty-func [int] [string])
  func() (int, error)     →  (list :ty-func () [int error])

gp-parse-func-type-params handles the param list inside (...);
gp-parse-func-type-results dispatches between bare single-return,
multi-return parenthesised list, or no return.

Anonymous-only — named params (`func(a int, b string)`) require a
different shape and are mainly needed for func DECLARATIONS, not for
pure func-type expressions in type position. Variadic ('...T')
deferred.

Covers nested cases: func returning func, chan of func, func with
pointer/slice operands.

parse 90/90, total 219/219.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:06:53 +00:00
8ba66e0dc9 go: parse.sx — slice/array/map/chan type expressions + 11 tests; parse acceptance crossed [proposes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 18s
Adds the bulk of Go's type-expression grammar:
  []T         →  (list :ty-slice T)
  [N]T        →  (list :ty-array N T)         — N is an expr
  map[K]V     →  (list :ty-map K V)
  chan T      →  (list :ty-chan :both T)
  chan<- T    →  (list :ty-chan :send T)
  <-chan T    →  (list :ty-chan :recv T)

gp-parse-type now dispatches on the head token: *, [, map, chan, <-,
or ident; each branch recurses for nested types. Channel direction
is encoded as :both / :send / :recv (Go-specific tag).

Coverage: nested types end-to-end — []*T, [][]int, map[string][]int,
chan map[K]V, *[]int — all via the v.(T) assertion carrier.

Logged a concrete kit-gap proposal in plans/go-on-sx.md Blockers for
canonical type-node shapes. The first six (:ty-name, :ty-sel, :ty-ptr,
:ty-slice, :ty-array, :ty-map) are universal across statically-typed
guests and worth promoting on the next consumer; channel/func shapes
stay guest-specific until a second user.

Phase 2 parse acceptance bar (80+ tests) crossed: parse 81/81, total
210/210. Func / struct / interface types and full decls + stmts still
keep Phase 2 open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:02:08 +00:00
503bdf12d6 go: parse.sx — type assertion v.(T) + minimal type parser + 9 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 18s
Postfix '.' branch now peeks at the next token to disambiguate:
  .ident   →  selector / member access  (list :select OBJ "field")
  .(TYPE)  →  type assertion            (list :assert OBJ TYPE)

New gp-parse-type covers the minimum types needed for assertions:
  name        →  (list :ty-name "int")
  pkg.Name    →  (list :ty-sel "pkg" "Name")
  *T  / **T   →  (list :ty-ptr (list :ty-ptr ...))

Full type grammar — slice []T, array [N]T, map[K]V, chan, func,
struct, interface — is a separate Phase 2 sub-deliverable.

Type AST shapes are Go-specific tagged lists; the canonical AST kit
has no type-system primitives at all yet. Worth a richer kit
discussion once Phase 3 (bidirectional type checker) lands and the
sister plan static-types-bidirectional has a real surface to react to.

parse 70/70, total 199/199.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:57:29 +00:00
e64d72f554 go: parse.sx — index x[i] + slice x[a:b]/x[a:b:c] + 12 tests [proposes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s
Adds the bracket postfix branch:
  a[0] / a[i] / a[i+1] / m["key"]             → (list :index OBJ IDX)
  a[:] / a[1:] / a[:2] / a[1:2] / a[1:2:3]    → (list :slice OBJ LOW HIGH MAX)

LOW/HIGH/MAX are AST nodes or nil for omitted indices. The 4th MAX
slot is only populated by the three-index full-slice form.

Two new lib/guest/ast.sx kit gaps surfaced (logged in plans/go-on-sx.md
Blockers):

  * No :index node — universal across guests with arrays/maps.
  * No :slice node — Python/Rust/Swift/JS/Ruby all need at minimum the
    two-index form. Go's three-index variant is more specialised but
    fits in the same shape with an optional fourth slot.

Parser is permissive on a[1::3] (strict Go rejects, but the type phase
can enforce the grammar; lexer/parser stays loose).

Chained (a[0][1]) and mixed-with-selector (a[0].field) cases work via
the existing left-associative postfix loop.

parse 61/61, total 190/190.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:53:10 +00:00
e1c5fdae53 go: parse.sx — function calls + member access + 12 tests [consumes-ast proposes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s
Adds postfix expression forms per Go spec:
  f()  f(x)  f(x, y, z)       — function calls
  x.y  x.y.z  obj.method(x)   — selector / member access

gp-parse-postfix sits between gp-parse-unary and gp-parse-primary,
so calls and selectors bind tighter than any unary prefix — `-f(x)`
parses as `-(f(x))`, not `(-f)(x)`. Postfix is left-associative
(`x.y.z` = `(x.y).z`), so the loop iterates rather than recurses
on the LHS.

AST shapes:
  Call:     (ast-app FN ARGS)              — canonical
  Selector: (list :select OBJ "field")     — Go-specific tag

The selector shape is a kit gap — lib/guest/ast.sx ships ast-app but
no ast-select, despite `obj.field` being universal across Go, Rust,
Swift, TS, JS, Python, Ruby, Java, C#. Logged in Blockers; tagging
[proposes-ast]. Worth promoting on the next nominally-typed guest.

parse 49/49, total 178/178.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:48:21 +00:00
728a91e49f go: parse.sx — unary prefix operators + 11 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
Adds Go unary prefix operators per Go spec § Operators:
  +x  -x  !x  ^x  *p  &v  <-ch

gp-parse-unary is recursive (so !!x and -^x chain correctly) and
sits between gp-parse-expr and gp-parse-primary — unary therefore
always binds tighter than any binary op without needing a unary
entry in the precedence table.

Symbols +, -, *, &, ^ are shared between unary and binary forms;
the positional split (expression-start sees unary, mid-expression
sees binary) disambiguates them cleanly with no lookback.

Unary nodes are single-arg ast-app:
  (ast-app (ast-var OP) (list OPERAND))

parse 37/37, total 166/166.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:43:34 +00:00
750035d543 go: parse.sx — binary operators via Pratt precedence climbing + 9 tests [consumes-pratt]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 39s
gp-parse-expr / gp-pratt-loop implement classic Pratt climbing
against go-precedence-table (entry shape from lib/guest/pratt.sx).
The kit gives us pratt-op-lookup + accessors; the climbing loop
itself stays per-language (per kit header — Lua and Prolog have
opposite conventions).

Left-associative ops raise the right-recursion min by 1; right-
associative would keep prec. All Go binary operators are left-assoc.

AST shape: a binary node is emitted as
  (ast-app (ast-var OP) [LHS RHS])
— canonical ast-app rather than a Go-specific binary node, since a
future evaluator can recognise operator-named apps without losing
information.

Coverage: equal-prec left-to-right, * tighter than +, && tighter
than ||, comparison tighter than &&, long left-assoc chains, mixed
literal+ident operands.

parse 26/26, total 155/155.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:03 +00:00
976c6dd0ef go: parse.sx scaffold — primary expressions + Go precedence table + 17 tests [consumes-pratt consumes-ast]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 21s
Starts Phase 2. lib/go/parse.sx defines:
  * go-precedence-table — Go's five operator-precedence levels in the
    (NAME PREC ASSOC) entry shape from lib/guest/pratt.sx, ready for the
    binary-operator iteration to consume via pratt-op-lookup.
  * go-parse(src) — tokenises and parses ONE primary expression: int,
    float, imag, string, rune literals become (ast-literal VALUE);
    identifiers become (ast-var NAME). Built directly on lib/guest/ast.sx
    constructors — no intermediate AST shape.

Conformance.sh extended to load lib/guest/{ast,pratt}.sx and run the
new parse suite. Scoreboard cleanup: drop the "pending" parse row since
the suite is now real.

parse 17/17 (lex still 129/129). Total 146/146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:33:31 +00:00
c1baca2e4e go: lex.sx — operator-set audit + tilde; PHASE 1 COMPLETE + 6 tests [proposes-lex]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
Adds the missing tilde operator '~' (Go 1.18+ generics type-set
constraint, e.g. 'interface { ~int | ~float64 }') to the longest-match
operator table. Adds an exhaustive 'op-audit:' test block covering
every Go operator/punctuation token by category — arithmetic +
assignment, bitwise + assignment, comparison + logical, decls /
arrows / variadic / inc-dec, punctuation, and tilde.

Phase 1 (tokenizer) is now complete. Two kit gaps surfaced and logged
in plans/go-on-sx.md Blockers for the substrate maintainer / next
statically-typed guest loop:

  * lib/guest/lex.sx lacks lex-oct-digit? / lex-bin-digit?
    (we rolled local gl-* equivalents for 0o.. and 0b.. literals).
  * lib/guest/lex.sx lacks a table-driven longest-prefix operator
    matcher; our gl-match-op is a 25-clause cond ladder. Rust/Swift/TS
    will each hit the same shape with 50+ ops apiece.

lex 129/129. Phase 2 (parser) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:28:50 +00:00
65467c232b go: lex.sx — raw string literals (backtick) + 9 tests [nothing]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
Adds Go raw string literals per Go spec § String literals:
backtick-delimited, no escape processing, may span multiple
lines, '\r' chars discarded from the value.

gl-read-raw-string! mirrors gl-read-string! but skips escape
handling and the \r filter. scan! routes the leading backtick
to it; emits "string" type (same as interpreted strings — no
need to distinguish at parse/type time).

lex 123/123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:22:01 +00:00
e60c74f8c3 go: lex.sx — decimal float + imaginary literals + 22 tests [consumes-lex]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
Adds Go float and imaginary literal forms per Go spec § Floating-point
literals and § Imaginary literals:
  3.14   .5   1.   1e10   1.5e-3   2.0e+2   1E5    (floats)
  2i     3.14i   1e2i                              (imag)

gl-read-number! returns one of "int" / "float" / "imag"; gl-finish-number!
factors out the post-mantissa exponent + 'i' suffix logic so the int /
float / leading-dot-float paths all share it. scan! adds a .<digit>
branch ahead of the operator matcher so '.5' tokenises as float.

ASI trigger list extended to include float + imag (Go spec § Semicolons:
all literal types trigger).

Greedy-grammar pin (a single test '1.method' lexes as float ident),
since the Go spec says the '.' after a digit always belongs to the
number, never to a following identifier.

Hex floats (0x1.fp0) deferred — not commonly used.

lex 114/114.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:16:56 +00:00
fe614fc531 go: lex.sx — hex/octal/binary integer literals + underscores, +14 tests [consumes-lex]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 29s
Adds prefixed integer forms per Go spec § Integer literals:
0x.. / 0X.. (hex), 0b.. / 0B.. (binary), 0o.. / 0O.. (octal),
legacy 0123 octal also accepted. Underscores allowed between digits
in any run; lexer is permissive (parser/types phase can enforce
strict placement).

Dispatch lives in gl-read-number! against the first 1-2 chars;
hex digit run consumes lex-hex-digit? from lib/guest/lex.sx. Octal
and binary use local gl-oct-digit?/gl-bin-digit? — narrow enough
that promoting them to the kit is premature.

lex 92/92.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:57:47 +00:00
4fc73a97f4 go: lex.sx — keywords, ident/int/string/rune lits, comments, ops, ASI + 78 tests [consumes-lex]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 23s
First Go-on-SX iteration. Tokenizer consumes lib/guest/lex.sx character-class
predicates. Automatic semicolon insertion per Go spec § Semicolons fires on
newline, EOF, and block comments containing a newline, after
ident/int/string/rune/{break,continue,fallthrough,return}/{++,--,),],}}.

Scoreboard + conformance.sh wired; lex 78/78. Plan Phase 1 sub-items
checked; floats/raw-strings/hex-ints still .

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:13:06 +00:00
0f7444e0d5 plans: Go-on-SX + sister lib/guest extraction plans (scheduler, bidirectional types)
- go-on-sx.md: rewrite of 2026-04-26 draft to integrate lib/guest framework.
  Adds Phase 3 (independent bidirectional type checker — first static-typed
  guest), Phase 10 (extraction enabler), chisel discipline, conformance
  scoreboard model. Phases 1-2 now consume lib/guest/core lex+pratt+ast.

- lib-guest-scheduler.md: NEW. Extraction plan for the fork/yield/block/
  resume scheduler shared by Erlang (addressed processes + mailboxes) and
  Go (anonymous channels + goroutines). Two-language rule blocks extraction
  until both consumers independently work; rejected-extraction is a valid
  outcome.

- lib-guest-static-types-bidirectional.md: NEW. Sister to lib/guest/hm.sx.
  Bidirectional checker kit (synth/check judgments, pluggable subtype +
  unify) for the languages HM doesn't fit — Go, Rust, TS, Swift, Kotlin,
  Scala 3, Hack. First consumer: Go-on-SX. Second TBD; recommendation
  TypeScript.

The three plans cross-reference each other. Go-on-SX implements scheduler +
checker independently of the kits; extraction is its own workstream once
two consumers exist.
2026-05-26 20:54:22 +00:00
46e0653911 fed-prims: Phase J — http-request + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 2m48s
NATIVE-ONLY http-request primitive (bin/sx_server.ml). HTTP/1.1 over
Unix sockets + gethostbyname; inline http:// URL parsing (full
url-parse deferred to Phase K); Connection: close + Host +
Content-Length headers auto-supplied; reads response via
Content-Length or read-to-EOF; chunked transfer-encoding rejected.
Test bin/test_http_client.sh spins a Phase-H echo server and drives
a second sx_server: GET+query, POST+body, 404, custom request
header reflected, non-http scheme rejected, integer status — 6/6.
WASM boot green (prim not in lib); Erlang conformance 530/530.
2026-05-26 19:53:58 +00:00
437 changed files with 52013 additions and 282 deletions

View File

@@ -1 +1 @@
{"sessionId":"31c80255-eb92-43e4-8997-84ad84e27326","pid":90960,"procStart":"564684","acquiredAt":1777049890282}
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}

View File

@@ -2,7 +2,7 @@
"mcpServers": {
"sx-tree": {
"type": "stdio",
"command": "./hosts/ocaml/_build/default/bin/mcp_tree.exe"
"command": "/root/rose-ash/hosts/ocaml/_build/default/bin/mcp_tree.exe"
},
"rose-ash-services": {
"type": "stdio",

View File

@@ -571,9 +571,12 @@ and cek_run_with_io state =
Hashtbl.replace d "descent" (Number desc);
Dict d
| _ ->
let args = let a = Sx_runtime.get_val request (String "args") in
(match a with List l -> l | _ -> [a]) in
io_request op args
let argsv = Sx_runtime.get_val request (String "args") in
(match Sx_persist_store.handle_op op argsv with
| Some resp -> resp
| None ->
let args = (match argsv with List l -> l | _ -> [argsv]) in
io_request op args)
in
s := Sx_ref.cek_resume !s response;
loop ()
@@ -855,6 +858,164 @@ let setup_evaluator_bridge env =
done;
Nil
| _ -> raise (Eval_error "http-listen: (port handler)"));
(* fed-sx Milestone 1 client direction (Phase J). NATIVE ONLY —
Unix sockets + DNS; absent from the WASM kernel. HTTP/1.1
request: TCP connect, write request line + headers + body,
read status + headers + body, return {:status :headers :body}.
URL must be http://...; HTTPS is a later phase (needs TLS).
Body read: Content-Length first, else read to EOF (we send
Connection: close). Transfer-Encoding: chunked is rejected —
fed-sx Phase 8 wires this for inter-server POSTs which will
all carry Content-Length. *)
Sx_primitives.register "http-request" (fun args ->
let strip_cr s =
let n = String.length s in
if n > 0 && s.[n - 1] = '\r' then String.sub s 0 (n - 1) else s
in
match args with
| [String meth; String url; headers_v; body_v] ->
let body = match body_v with
| String s -> s
| Nil -> ""
| v -> Sx_types.value_to_string v in
let prefix = "http://" in
let plen = String.length prefix in
let ulen = String.length url in
if ulen < plen || String.sub url 0 plen <> prefix
then raise (Eval_error "http-request: URL must start with http://");
let rest = String.sub url plen (ulen - plen) in
let host_port, path =
match String.index_opt rest '/' with
| Some i ->
String.sub rest 0 i,
String.sub rest i (String.length rest - i)
| None -> rest, "/" in
if host_port = "" then
raise (Eval_error "http-request: missing host");
let host, port =
match String.index_opt host_port ':' with
| Some i ->
let h = String.sub host_port 0 i in
let ps = String.sub host_port (i + 1)
(String.length host_port - i - 1) in
(h,
(try int_of_string ps with _ ->
raise (Eval_error "http-request: bad port")))
| None -> host_port, 80 in
let addr =
(try (Unix.gethostbyname host).h_addr_list.(0)
with Not_found ->
raise (Eval_error ("http-request: dns: " ^ host))) in
let sock = Unix.socket Unix.PF_INET Unix.SOCK_STREAM 0 in
let cleanup () = try Unix.close sock with _ -> () in
let result =
(try
(try Unix.connect sock (Unix.ADDR_INET (addr, port))
with Unix.Unix_error (e, _, _) ->
raise (Eval_error
("http-request: connect: " ^ Unix.error_message e)));
let oc = Unix.out_channel_of_descr sock in
let ic = Unix.in_channel_of_descr sock in
let buf = Buffer.create 256 in
Buffer.add_string buf
(Printf.sprintf "%s %s HTTP/1.1\r\n" meth path);
let host_hdr_sent = ref false in
let clen_sent = ref false in
let conn_sent = ref false in
(match headers_v with
| Dict h ->
Hashtbl.iter (fun k v ->
let kl = String.lowercase_ascii k in
if kl = "host" then host_hdr_sent := true;
if kl = "content-length" then clen_sent := true;
if kl = "connection" then conn_sent := true;
let vs = match v with
| String s -> s
| x -> Sx_types.value_to_string x in
Buffer.add_string buf
(Printf.sprintf "%s: %s\r\n" k vs)) h
| Nil -> ()
| _ -> raise (Eval_error "http-request: headers must be dict"));
if not !host_hdr_sent then
Buffer.add_string buf
(Printf.sprintf "Host: %s\r\n" host_port);
if not !clen_sent then
Buffer.add_string buf
(Printf.sprintf "Content-Length: %d\r\n"
(String.length body));
if not !conn_sent then
Buffer.add_string buf "Connection: close\r\n";
Buffer.add_string buf "\r\n";
Buffer.add_string buf body;
output_string oc (Buffer.contents buf);
flush oc;
let sl =
(try strip_cr (input_line ic)
with End_of_file ->
raise (Eval_error
"http-request: connection closed before status")) in
let status =
match String.split_on_char ' ' sl with
| _ver :: code :: _ ->
(try int_of_string code with _ ->
raise (Eval_error "http-request: bad status code"))
| _ -> raise (Eval_error "http-request: bad status line") in
let rhdrs = Sx_types.make_dict () in
let clen = ref (-1) in
let chunked = ref false in
let rec rdh () =
let h =
(try strip_cr (input_line ic)
with End_of_file -> "") in
if h = "" then ()
else begin
(match String.index_opt h ':' with
| Some i ->
let name =
String.lowercase_ascii
(String.trim (String.sub h 0 i)) in
let value =
String.trim
(String.sub h (i + 1)
(String.length h - i - 1)) in
Hashtbl.replace rhdrs name (String value);
if name = "content-length" then
(try clen := int_of_string value with _ -> ())
else if name = "transfer-encoding" &&
String.lowercase_ascii value = "chunked"
then chunked := true
| None -> ());
rdh ()
end in
rdh ();
if !chunked then
raise (Eval_error
"http-request: chunked transfer-encoding not supported");
let rbody =
if !clen >= 0 then begin
let b = Bytes.create !clen in
really_input ic b 0 !clen;
Bytes.unsafe_to_string b
end else begin
let b = Buffer.create 256 in
(try
while true do
Buffer.add_channel b ic 4096
done; assert false
with End_of_file -> ());
Buffer.contents b
end in
let resp = Sx_types.make_dict () in
Hashtbl.replace resp "status" (Integer status);
Hashtbl.replace resp "headers" (Dict rhdrs);
Hashtbl.replace resp "body" (String rbody);
Dict resp
with e -> cleanup (); raise e) in
cleanup ();
result
| _ -> raise (Eval_error "http-request: (method url headers body)"));
bind "trampoline" (fun args ->
match args with
| [v] ->
@@ -1540,7 +1701,12 @@ let rec dispatch env cmd =
| Some path -> load_library_file path | None -> ());
Nil
end
end else Nil (* non-import IO: resume with nil *) in
end else
(* durable-storage ops: service against on-disk store *)
let args = Sx_runtime.get_val request (String "args") in
(match Sx_persist_store.handle_op op args with
| Some resp -> resp
| None -> Nil (* non-import IO: resume with nil *)) in
s := Sx_ref.cek_resume !s response
done;
Sx_ref.cek_value !s
@@ -3893,7 +4059,10 @@ let http_mode port =
Dict d
| "io-sleep" | "sleep" -> Nil
| "import" -> Nil
| _ -> Nil);
| _ ->
(match Sx_persist_store.handle_op op args with
| Some resp -> resp
| None -> Nil));
(* Response cache — path → full HTTP response string.
Populated during pre-warm, serves cached responses in <0.1ms.
Thread-safe: reads are lock-free (Hashtbl.find_opt is atomic for

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Phase J test — native-only http-request client primitive.
# Reuses Phase H's http-listen to spin up an echo server, then drives
# a separate sx_server via the epoch protocol to issue http-request
# calls and assert response shape + headers + body.
set -u
cd "$(dirname "$0")/.."
SRV=_build/default/bin/sx_server.exe
PORT=${HTTP_CLIENT_TEST_PORT:-8921}
PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
bad() { echo " FAIL: $1$2"; FAIL=$((FAIL+1)); }
if [ ! -x "$SRV" ]; then
echo "build sx_server.exe first (dune build bin/sx_server.exe)"; exit 1
fi
# /echo echoes method/path/query/body and reflects request X-Custom
# back as response X-Got; /missing-test → 404.
H='(begin (define (h req) (if (= (get req "path") "/echo") {:status 200 :headers {"X-Echo" (get req "method") "X-Got" (get (get req "headers") "x-custom")} :body (str "M=" (get req "method") " P=" (get req "path") " Q=" (get req "query") " B=" (get req "body"))} (if (= (get req "path") "/missing-test") {:status 404 :body "nope"} {:status 500 :body "err"}))) (http-listen '"$PORT"' h))'
ESC=${H//\"/\\\"}
{ printf '(epoch 1)\n(eval "%s")\n' "$ESC"; sleep 60; } | "$SRV" >/tmp/test_http_client_srv.out 2>&1 &
SVPID=$!
trap 'kill $SVPID 2>/dev/null; wait 2>/dev/null' EXIT
up=0
for _ in $(seq 1 50); do
curl -s -o /dev/null "http://127.0.0.1:$PORT/echo" 2>/dev/null && { up=1; break; }
sleep 0.2
done
[ "$up" = 1 ] || { echo " FAIL: server did not start"; cat /tmp/test_http_client_srv.out; exit 1; }
emit() {
# $1 = epoch num, $2 = raw SX form. Wraps in (eval "...") with quotes escaped.
local esc=${2//\"/\\\"}
printf '(epoch %s)\n(eval "%s")\n' "$1" "$esc"
}
DRV_OUT=/tmp/test_http_client_drv.out
{
emit 1 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo?x=1" {} ""))) (str "S=" (get r "status") " E=" (get (get r "headers") "x-echo") " B=" (get r "body")))'
emit 2 '(let ((r (http-request "POST" "http://127.0.0.1:'"$PORT"'/echo" {} "hello"))) (str "S=" (get r "status") " B=" (get r "body")))'
emit 3 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/missing-test" {} ""))) (str "S=" (get r "status") " B=" (get r "body")))'
emit 4 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo" {"X-Custom" "myval"} ""))) (get (get r "headers") "x-got"))'
emit 5 '(http-request "GET" "ftp://nope" {} "")'
emit 6 '(let ((r (http-request "GET" "http://127.0.0.1:'"$PORT"'/echo" {} ""))) (get r "status"))'
} | "$SRV" >"$DRV_OUT" 2>&1
# eval results come back as (ok-len N L)\n<body>\n — grep the body content.
grep -q '^"S=200 E=GET B=M=GET P=/echo Q=x=1 B="$' "$DRV_OUT" \
&& ok "GET status + echo header + body" \
|| bad "GET" "$(grep -A1 '^(ok-len 1 ' "$DRV_OUT" | tail -1)"
grep -q '^"S=200 B=M=POST P=/echo Q= B=hello"$' "$DRV_OUT" \
&& ok "POST body roundtrip" \
|| bad "POST" "$(grep -A1 '^(ok-len 2 ' "$DRV_OUT" | tail -1)"
grep -q '^"S=404 B=nope"$' "$DRV_OUT" \
&& ok "404 status + body" \
|| bad "404" "$(grep -A1 '^(ok-len 3 ' "$DRV_OUT" | tail -1)"
grep -q '^"myval"$' "$DRV_OUT" \
&& ok "custom request header reaches server" \
|| bad "custom-header" "$(grep -A1 '^(ok-len 4 ' "$DRV_OUT" | tail -1)"
R5=$(grep '^(error 5 ' "$DRV_OUT" | head -1)
echo "$R5" | grep -q 'URL must start with http' \
&& ok "non-http scheme rejected" \
|| bad "bad-url" "$R5"
# Status is an Integer (200), serialized bare without quotes.
grep -q '^200$' "$DRV_OUT" \
&& ok "response status is integer 200" \
|| bad "status-integer" "$(grep -A1 '^(ok-len 6 ' "$DRV_OUT" | tail -1)"
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]

View File

@@ -0,0 +1,293 @@
(* sx_persist_store — host durable-storage adapter for lib/persist.
Production twin of `persist/serve` (lib/persist/durable.sx): it answers the
same `persist/...` IO ops, but backs them with real on-disk storage so writes
survive a process restart. Stateless-on-disk: every op reads/writes the
filesystem directly, so a fresh process recovers state with no warm-up — the
log on disk IS the state.
On-disk layout under the root dir (default ./persist-data, or $SX_PERSIST_DIR):
streams/<hex(stream)>.log append-only, one SX-serialized event per line
streams/<hex(stream)>.seq per-stream monotonic high-water counter (int)
kv/<hex(key)> one SX-serialized value per key
Invariants honoured (see plans/persist-on-sx.md Blocker spec):
1. last-seq is a per-stream monotonic counter stored in .seq, SEPARATE from
the rows — it keeps climbing across truncate, so a compacted stream never
reassigns a seq.
2. append never renumbers — the event already carries its :seq (log.sx does
last-seq+1); the host only bumps the high-water mark to max(hw, seq).
3. read returns surviving events in append order with :seq intact.
4. streams is the set of streams that ever had an append — keyed off the .seq
file, which truncate never deletes, so it survives full compaction.
5. values round-trip structurally via the SX serializer/parser. *)
open Sx_types
(* ---- root dir ---------------------------------------------------------- *)
let _root : string option ref = ref None
let set_root dir = _root := Some dir
let root_dir () =
match !_root with
| Some d -> d
| None -> (try Sys.getenv "SX_PERSIST_DIR" with Not_found -> "persist-data")
(* ---- filesystem helpers ------------------------------------------------ *)
let rec ensure_dir dir =
if dir = "" || dir = "." || dir = "/" || Sys.file_exists dir then ()
else begin
ensure_dir (Filename.dirname dir);
(try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ())
end
let streams_dir () = Filename.concat (root_dir ()) "streams"
let kv_dir () = Filename.concat (root_dir ()) "kv"
let blobs_dir () = Filename.concat (root_dir ()) "blobs"
let read_file path =
let ic = open_in_bin path in
let n = in_channel_length ic in
let s = really_input_string ic n in
close_in ic;
s
(* Atomic write: temp file in the same dir then rename over the target. *)
let write_file_atomic path contents =
ensure_dir (Filename.dirname path);
let tmp = path ^ ".tmp" in
let oc = open_out_bin tmp in
output_string oc contents;
flush oc;
close_out oc;
Sys.rename tmp path
let append_line path line =
ensure_dir (Filename.dirname path);
let oc = open_out_gen [Open_append; Open_creat; Open_wronly] 0o644 path in
output_string oc line;
output_char oc '\n';
close_out oc
(* ---- name <-> filename (hex, reversible, fs-safe) ---------------------- *)
let hex_encode s =
let b = Buffer.create (String.length s * 2) in
String.iter (fun c -> Buffer.add_string b (Printf.sprintf "%02x" (Char.code c))) s;
Buffer.contents b
let hex_decode s =
let n = String.length s / 2 in
String.init n (fun i -> Char.chr (int_of_string ("0x" ^ String.sub s (i * 2) 2)))
let stream_log stream = Filename.concat (streams_dir ()) (hex_encode stream ^ ".log")
let stream_seq stream = Filename.concat (streams_dir ()) (hex_encode stream ^ ".seq")
let kv_path key = Filename.concat (kv_dir ()) (hex_encode key)
(* ---- value <-> SX text (round-trips through Sx_parser) ----------------- *)
let escape_str s =
let len = String.length s in
let buf = Buffer.create (len + 16) in
for i = 0 to len - 1 do
match s.[i] with
| '"' -> Buffer.add_string buf "\\\""
| '\\' -> Buffer.add_string buf "\\\\"
| '\n' -> Buffer.add_string buf "\\n"
| '\r' -> Buffer.add_string buf "\\r"
| '\t' -> Buffer.add_string buf "\\t"
| c -> Buffer.add_char buf c
done;
Buffer.contents buf
let rec serialize = function
| Nil -> "nil"
| Bool true -> "true"
| Bool false -> "false"
| Integer n -> string_of_int n
| Number n -> format_number n
| String s -> "\"" ^ escape_str s ^ "\""
| Symbol s -> "(quote " ^ s ^ ")"
| Keyword k -> ":" ^ k
| List items | ListRef { contents = items } ->
"(list" ^ (List.fold_left (fun acc v -> acc ^ " " ^ serialize v) "" items) ^ ")"
| Dict d ->
let pairs = Hashtbl.fold (fun k v acc ->
(Printf.sprintf ":%s %s" k (serialize v)) :: acc) d [] in
"{" ^ String.concat " " (List.sort String.compare pairs) ^ "}"
| _ -> "nil"
(* Parse one serialized value back. Empty / blank -> Nil. *)
let rec deserialize line =
let line = String.trim line in
if line = "" then Nil
else match Sx_parser.parse_all line with
| v :: _ -> eval_quote_lists v
| [] -> Nil
(* serialize emits lists as `(list ...)` and symbols as `(quote s)` so the
parser yields data, not a call — but the parser leaves those as AST. Walk
the parsed AST and collapse `(list ...)`/`(quote s)` back to values. *)
and eval_quote_lists v =
match v with
| List (Symbol "quote" :: x :: []) -> x
| List (Symbol "list" :: rest) -> List (List.map eval_quote_lists rest)
| List items -> List (List.map eval_quote_lists items)
| ListRef { contents = items } -> List (List.map eval_quote_lists items)
| Dict d ->
let d' = Hashtbl.create (Hashtbl.length d) in
Hashtbl.iter (fun k v -> Hashtbl.replace d' k (eval_quote_lists v)) d;
Dict d'
| other -> other
(* ---- seq counter ------------------------------------------------------- *)
let read_seq stream =
let p = stream_seq stream in
if Sys.file_exists p then (try int_of_string (String.trim (read_file p)) with _ -> 0)
else 0
let write_seq stream n = write_file_atomic (stream_seq stream) (string_of_int n)
let value_to_int = function
| Integer n -> n
| Number n -> int_of_float n
| _ -> 0
let event_seq ev =
match ev with
| Dict d -> (match Hashtbl.find_opt d "seq" with Some v -> value_to_int v | None -> 0)
| _ -> 0
(* ---- ops --------------------------------------------------------------- *)
let do_append stream ev =
ensure_dir (streams_dir ());
(* bump the monotonic high-water mark; create .seq on first append so the
stream shows up in `streams` and survives later truncation. *)
let hw = read_seq stream in
let s = event_seq ev in
write_seq stream (max hw s);
append_line (stream_log stream) (serialize ev)
let do_read stream =
let p = stream_log stream in
if not (Sys.file_exists p) then List []
else begin
let content = read_file p in
let lines = String.split_on_char '\n' content in
let evs = List.filter_map (fun l ->
if String.trim l = "" then None else Some (deserialize l)) lines in
List evs
end
let do_last_seq stream = Number (float_of_int (read_seq stream))
let list_dir_suffix dir suffix =
if not (Sys.file_exists dir) then []
else
Array.to_list (Sys.readdir dir)
|> List.filter (fun f -> Filename.check_suffix f suffix)
|> List.map (fun f -> hex_decode (Filename.chop_suffix f suffix))
|> List.sort String.compare
let do_streams () = List (List.map (fun s -> String s) (list_dir_suffix (streams_dir ()) ".seq"))
(* drop events with seq <= n; the .seq high-water counter is untouched. *)
let do_truncate stream n =
let p = stream_log stream in
if Sys.file_exists p then begin
let evs = match do_read stream with List l -> l | _ -> [] in
let kept = List.filter (fun ev -> event_seq ev > n) evs in
let body = String.concat "" (List.map (fun ev -> serialize ev ^ "\n") kept) in
write_file_atomic p body
end
let do_kv_get key =
let p = kv_path key in
if Sys.file_exists p then deserialize (read_file p) else Nil
let do_kv_put key v =
ensure_dir (kv_dir ());
write_file_atomic (kv_path key) (serialize v)
let do_kv_delete key =
let p = kv_path key in
if Sys.file_exists p then (try Sys.remove p with _ -> ())
let do_kv_has key = Bool (Sys.file_exists (kv_path key))
let do_kv_keys () =
if not (Sys.file_exists (kv_dir ())) then List []
else
List (
Array.to_list (Sys.readdir (kv_dir ()))
|> List.map hex_decode
|> List.sort String.compare
|> List.map (fun s -> String s))
(* ---- blob store (content-addressed) ------------------------------------ *)
(* Same pattern as the persist ops, but a SEPARATE adapter: large objects live
in a content-addressed directory keyed by a CIDv1 (raw codec, sha2-256).
persist only ever stores the returned ref ({:cid :size :mime}), never bytes.
blob/put is idempotent — identical bytes hash to the same cid + same file. *)
let codec_raw = 0x55
let blob_cid bytes =
let digest = Sx_cid.unhex (Sx_sha2.sha256_hex bytes) in
Sx_cid.cidv1 codec_raw (Sx_cid.multihash Sx_cid.mh_sha2_256 digest)
let blob_path cid = Filename.concat (blobs_dir ()) cid
let do_blob_put bytes =
let cid = blob_cid bytes in
let p = blob_path cid in
if not (Sys.file_exists p) then write_file_atomic p bytes;
String cid
let do_blob_get cid =
let p = blob_path cid in
if Sys.file_exists p then String (read_file p) else Nil
let do_blob_has cid = Bool (Sys.file_exists (blob_path cid))
(* ---- dispatch ---------------------------------------------------------- *)
let arglist = function
| List l | ListRef { contents = l } -> l
| Nil -> []
| v -> [v]
(* Returns Some response if op is a persist op this store owns, None otherwise. *)
let handle_op op args =
let a = arglist args in
let str = function String s -> s | v -> value_to_string v in
match op with
| "persist/append" ->
(match a with stream :: ev :: _ -> do_append (str stream) ev | _ -> ()); Some Nil
| "persist/read" ->
(match a with stream :: _ -> Some (do_read (str stream)) | _ -> Some (List []))
| "persist/last-seq" ->
(match a with stream :: _ -> Some (do_last_seq (str stream)) | _ -> Some (Number 0.0))
| "persist/streams" -> Some (do_streams ())
| "persist/truncate" ->
(match a with stream :: n :: _ -> do_truncate (str stream) (value_to_int n) | _ -> ()); Some Nil
| "persist/kv-get" ->
(match a with key :: _ -> Some (do_kv_get (str key)) | _ -> Some Nil)
| "persist/kv-put" ->
(match a with key :: v :: _ -> do_kv_put (str key) v | _ -> ()); Some Nil
| "persist/kv-delete" ->
(match a with key :: _ -> do_kv_delete (str key) | _ -> ()); Some Nil
| "persist/kv-has?" ->
(match a with key :: _ -> Some (do_kv_has (str key)) | _ -> Some (Bool false))
| "persist/kv-keys" -> Some (do_kv_keys ())
| "blob/put" ->
(match a with bytes :: _ -> Some (do_blob_put (str bytes)) | _ -> Some Nil)
| "blob/get" ->
(match a with cid :: _ -> Some (do_blob_get (str cid)) | _ -> Some Nil)
| "blob/has?" ->
(match a with cid :: _ -> Some (do_blob_has (str cid)) | _ -> Some (Bool false))
| _ -> None

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# hosts/ocaml/test/persist_durable_test.sh
# Acceptance test for the host durable-storage adapter (Sx_persist_store).
#
# Exercises `persist/durable-backend` (REAL `perform`, not the mock) under the
# WORKTREE-built sx_server.exe, and asserts:
# 1. durable: writes land on disk and read back (the silent-data-loss repro
# from plans/persist-on-sx.md now returns correct values).
# 2. last-seq is monotonic across truncate (compaction never reassigns a seq).
# 3. kv ops round-trip and delete.
# 4. recovery: a REAL process restart (write, exit, fresh process, replay)
# recovers state from disk.
#
# Run from repo root or anywhere; locates the worktree binary relative to itself.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../../.." && pwd)" # repo/worktree root
cd "$ROOT"
SX="hosts/ocaml/_build/default/bin/sx_server.exe"
if [ ! -x "$SX" ]; then
echo "ERROR: worktree binary not found at $SX — build it first:" >&2
echo " (cd hosts/ocaml && dune build bin/sx_server.exe)" >&2
exit 1
fi
DATADIR="$(mktemp -d)"
trap 'rm -rf "$DATADIR"' EXIT
PASS=0
FAIL=0
check() { # check <label> <got> <expected>
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1)); printf ' ok %-40s => %s\n' "$1" "$2"
else
FAIL=$((FAIL + 1)); printf ' FAIL %-40s got [%s] want [%s]\n' "$1" "$2" "$3"
fi
}
PRELUDE='(epoch 1)
(load "spec/stdlib.sx")
(load "lib/r7rs.sx")
(load "lib/persist/event.sx")
(load "lib/persist/backend.sx")
(load "lib/persist/log.sx")
(load "lib/persist/kv.sx")
(load "lib/persist/durable.sx")
(load "lib/persist/blob.sx")
(epoch 2)'
# run_eval <sx-expr-string>: prints the final (ok-len 2 ...) payload line.
run_eval() {
local expr="$1"
printf '%s\n(eval %s)\n' "$PRELUDE" "$expr" \
| SX_PERSIST_DIR="$DATADIR" timeout 60 "$SX" 2>/dev/null \
| awk '/^\(ok-len 2 / {getline; print; exit}'
}
# escape an SX program into a single-line double-quoted SX string literal for
# (eval "..."). The REPL reads one command per physical line, so newlines in the
# program are collapsed to spaces.
q() { printf '"%s"' "$(printf '%s' "$1" | tr '\n' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g')"; }
echo "== durable: append/read/last-seq round-trip on disk =="
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
(begin
(persist/append b "s" "x" 0 {:v 1})
(persist/append b "s" "x" 0 {:v 2})
(list (persist/event-seq (persist/append b "s" "x" 0 {:v 3}))
(persist/count b "s")
(len (persist/read b "s")))))')")
check "append/count/read" "$GOT" "(3 3 3)"
echo "== last-seq monotonic across truncate =="
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
(begin
(persist/append b "t" "x" 0 {})
(persist/append b "t" "x" 0 {})
(persist/append b "t" "x" 0 {})
(persist/truncate b "t" 2)
(list (persist/last-seq b "t") (persist/count b "t"))))')")
check "last-seq survives truncate" "$GOT" "(3 1)"
echo "== streams set survives compaction =="
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
(sort ((get b "streams"))))')")
check "streams" "$GOT" '("s" "t")'
echo "== kv round-trip + delete =="
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
(begin
(persist/kv-put b "k" {:a 1 :b "two"})
(persist/kv-put b "gone" 9)
(persist/kv-delete b "gone")
(list (get (persist/kv-get b "k") :b)
(persist/kv-has? b "k")
(persist/kv-has? b "gone"))))')")
check "kv get/has/delete" "$GOT" '("two" true false)'
echo "== recovery: state survives a REAL process restart =="
# write in process A then let it exit; the next run is a brand-new process.
run_eval "$(q '(let ((b (persist/durable-backend)))
(begin
(persist/append b "r" "ev" 0 {:n 1})
(persist/append b "r" "ev" 0 {:n 2})
(persist/kv-put b "survive" "yes")
(persist/count b "r")))')" >/dev/null
# fresh process, same SX_PERSIST_DIR — must replay from disk.
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)))
(list (persist/count b "r")
(persist/last-seq b "r")
(get (get (nth (persist/read b "r") 1) :data) :n)
(persist/kv-get b "survive")))')")
check "recovered after restart" "$GOT" '(2 2 2 "yes")'
echo "== blob: content-addressed put/get/has? round-trip =="
GOT=$(run_eval "$(q '(let ((bs (persist/blob-store-backend)))
(let ((r (persist/blob-store bs "hello world" "text/plain")))
(list (persist/blob-size r)
(persist/blob-mime r)
(persist/blob-fetch bs r)
(persist/blob-exists? bs r))))')")
check "blob size/mime/fetch/exists" "$GOT" '(11 "text/plain" "hello world" true)'
echo "== blob: put is content-addressed (idempotent cid) =="
GOT=$(run_eval "$(q '(let ((bs (persist/blob-store-backend)))
(equal? (persist/blob-cid (persist/blob-store bs "same bytes" "x"))
(persist/blob-cid (persist/blob-store bs "same bytes" "x"))))')")
check "same bytes -> same cid" "$GOT" "true"
echo "== blob: bytes + ref-in-kv survive a REAL restart =="
# process A: store a blob, keep only its ref in the durable kv.
run_eval "$(q '(let ((b (persist/durable-backend)) (bs (persist/blob-store-backend)))
(begin (persist/kv-put b "logo" (persist/blob-store bs "PNGDATA" "image/png")) nil))')" >/dev/null
# fresh process: read the ref from kv, fetch the bytes from the blob store.
GOT=$(run_eval "$(q '(let ((b (persist/durable-backend)) (bs (persist/blob-store-backend)))
(let ((r (persist/kv-get b "logo")))
(list (persist/blob-fetch bs r) (persist/blob-exists? bs r) (persist/blob-mime r))))')")
check "blob recovered via ref after restart" "$GOT" '("PNGDATA" true "image/png")'
echo
echo "durable adapter: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]

45
lib/acl/api.sx Normal file
View File

@@ -0,0 +1,45 @@
;; lib/acl/api.sx — public ACL surface over an implicit current db.
;;
;; Callers load a fact set once, then issue decisions without threading the db
;; through every call. The current db is module state; (acl/load! facts) rebuilds
;; it. This is the boundary the rest of rose-ash imports.
(define acl-current-db nil)
;; Replace the current fact base. Rebuilds the Datalog db under the active
;; ruleset (see lib/acl/engine.sx).
(define
acl/load!
(fn
(facts)
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
;; Ensure a db exists, building an empty one on first use.
(define
acl-ensure-db!
(fn
()
(do
(when
(= acl-current-db nil)
(set! acl-current-db (acl-build-db (list))))
acl-current-db)))
;; Public decision against the current db (pure, no logging).
(define
acl/permit?
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
(define
acl/explain
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
;; Audited decision: logs the outcome to the append-only audit log and returns
;; the boolean. See lib/acl/audit.sx.
(define
acl/audit
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
;; Recent audited decisions (chronological).
(define acl/audit-tail (fn (n) (acl-audit-tail n)))

110
lib/acl/audit.sx Normal file
View File

@@ -0,0 +1,110 @@
;; lib/acl/audit.sx — append-only decision log.
;;
;; Every decision routed through acl-audit-decide! is appended to an in-memory
;; log with a monotonic sequence number (no wall-clock — deterministic and
;; testable; a host can stamp time at the serializer boundary). The log is
;; append-only: there is no mutate or delete, only append, tail, clear,
;; snapshot/restore, and serialize-for-disk.
(define acl-audit-log (list))
(define acl-audit-seq 0)
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
;; log must always be a list built with `list` + `append!`.
(define
acl-audit-copy
(fn
(xs)
(let
((fresh (list)))
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
(define
acl-audit-clear!
(fn
()
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
;; Append a decision record. Returns the record.
(define
acl-audit-record!
(fn
(subj act res allowed?)
(let
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
(do
(set! acl-audit-seq (+ acl-audit-seq 1))
(append! acl-audit-log entry)
entry))))
;; Decide against db, log the outcome, and return the boolean. This is the
;; audited path; acl-permit? remains the pure, side-effect-free decision.
(define
acl-audit-decide!
(fn
(db subj act res)
(let
((allowed? (acl-permit? db subj act res)))
(do (acl-audit-record! subj act res allowed?) allowed?))))
(define acl-audit-count (fn () (len acl-audit-log)))
;; Most recent n entries (in chronological order). n >= log size returns all.
(define
acl-audit-tail
(fn
(n)
(let
((total (len acl-audit-log)))
(if
(<= total n)
acl-audit-log
(acl-audit-drop acl-audit-log (- total n))))))
(define
acl-audit-drop
(fn
(xs k)
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
;; copy of the log (so later appends don't mutate a held snapshot).
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
;; Replace the live log from a snapshot. Restores both entries and the seq
;; counter so subsequent records continue numbering correctly. The log is
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
(define
acl-audit-restore!
(fn
(snap)
(do
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
(set! acl-audit-seq (get snap :seq))
nil)))
;; Serialize the whole log to a disk-ready string: one record per line,
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
;; snapshot/restore.
(define
acl-audit-serialize
(fn
()
(reduce
(fn
(acc e)
(str
acc
(get e :seq)
"\t"
(get e :subj)
"\t"
(get e :act)
"\t"
(get e :res)
"\t"
(get e :allowed?)
"\n"))
""
acl-audit-log)))

32
lib/acl/conformance.conf Normal file
View File

@@ -0,0 +1,32 @@
# ACL conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=acl
MODE=dict
PRELOADS=(
lib/datalog/tokenizer.sx
lib/datalog/parser.sx
lib/datalog/unify.sx
lib/datalog/db.sx
lib/datalog/builtins.sx
lib/datalog/aggregates.sx
lib/datalog/strata.sx
lib/datalog/eval.sx
lib/datalog/api.sx
lib/datalog/magic.sx
lib/acl/schema.sx
lib/acl/facts.sx
lib/acl/engine.sx
lib/acl/explain.sx
lib/acl/audit.sx
lib/acl/federation.sx
lib/acl/api.sx
)
SUITES=(
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
)

3
lib/acl/conformance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

72
lib/acl/engine.sx Normal file
View File

@@ -0,0 +1,72 @@
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
;;
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
;; reduces a (subject, action, resource) decision to a Datalog query against a
;; db built from EDB facts. The rule engine itself is Datalog's.
;;
;; Policy — inheritance + federation with deny-overrides:
;;
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
;; trust(Peer,L), level_covers(L,A).
;;
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
;;
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
;;
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
;; (S,A,R) defeats any effective grant — including federated grants. Deny
;; inherits through the *same* group and resource chains as grant, so a
;; group-level or ancestor-resource deny is authoritative for members/
;; descendants. This is the principled, fail-safe reading of "deny wins".
;;
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
;; Trust is re-checked on every query (it is a body literal), never baked in at
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
;; immediately on the next decision.
;;
;; Termination & stratification:
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
;; EDB relations with no function symbols, so the closure is finite (cyclic
;; membership/containment just reaches a fixpoint, never loops). The
;; federation rule is non-recursive.
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
;; permit, so the program is stratifiable (permit sits in a higher stratum).
(define
acl-rules
(quote
((eff_grant S A R <- (grant S A R))
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
(eff_grant
S
A
R
<-
(delegate Peer S A R)
(trust Peer L)
(level_covers L A))
(eff_deny S A R <- (deny S A R))
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
;; Core decision: does the db permit subject S to perform action A on
;; resource R? Reduces to a ground Datalog query on the derived `permit`
;; relation — non-empty result means permitted.
(define
acl-permit?
(fn
(db subj act res)
(> (len (dl-query db (list (quote permit) subj act res))) 0)))

125
lib/acl/explain.sx Normal file
View File

@@ -0,0 +1,125 @@
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
;;
;; lib/datalog/ records derived facts but not their provenance, so the proof is
;; reconstructed here by goal-directed search over the *saturated* db: for a
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
;; the first solution binding its remaining variables, and recurse on each body
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
;;
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
;; many ways). We pick ONE canonical proof — first matching rule, first solution
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
;; depth cap guards against pathological cyclic data producing unbounded search.
;;
;; A proof node is one of:
;; {:fact <lit> :via "edb"} — base EDB fact
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
;; {:neg-ok <lit>} — negation verified to fail
;; {:fact <lit> :truncated true} — depth cap hit
(define acl-proof-max-depth 64)
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
;; does not recurse into dicts, which would leak the neg's free vars).
(define
acl-subst-lit
(fn
(lit s)
(if
(and (dict? lit) (has-key? lit :neg))
{:neg (dl-apply-subst (get lit :neg) s)}
(dl-apply-subst lit s))))
(define
acl-lit-edb?
(fn
(lit)
(and
(list? lit)
(> (len lit) 0)
(symbol? (first lit))
(has-key? acl-edb-arity (symbol->string (first lit))))))
(define
acl-subst-zip!
(fn
(d ks vs)
(when
(> (len ks) 0)
(do
(dict-set! d (symbol->string (first ks)) (first vs))
(acl-subst-zip! d (rest ks) (rest vs))))))
;; Bind a rule head's variables to a ground goal's arguments (positional).
(define
acl-bind-head
(fn
(head goal)
(let
((d {}))
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
(define
acl-subst-union
(fn
(a b)
(let
((d {}))
(do
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
d))))
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
(define
acl-prove-d
(fn
(db goal depth)
(cond
((> depth acl-proof-max-depth) {:truncated true :fact goal})
((acl-lit-edb? goal)
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
(else (acl-prove-rules db goal acl-rules depth)))))
(define
acl-prove-rules
(fn
(db goal rules depth)
(if
(= (len rules) 0)
nil
(let
((p (dl-rule-from-list (first rules))))
(if
(= (first (get p :head)) (first goal))
(let
((hs (acl-bind-head (get p :head) goal)))
(let
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
(let
((sols (dl-query db qbody)))
(if
(> (len sols) 0)
(acl-prove-build db goal p hs (first sols) depth)
(acl-prove-rules db goal (rest rules) depth)))))
(acl-prove-rules db goal (rest rules) depth))))))
(define
acl-prove-build
(fn
(db goal p hs sol depth)
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
;; Public decision-with-proof. Returns:
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
;; inherited deny), else nil (simply no grant).
(define
acl-explain
(fn
(db subj act res)
(let
((proof (acl-prove db (list (quote permit) subj act res))))
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))

47
lib/acl/facts.sx Normal file
View File

@@ -0,0 +1,47 @@
;; lib/acl/facts.sx — EDB fact constructors.
;;
;; Each constructor returns a Datalog fact tuple (a list whose head is the
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
;; lib/datalog/.
;; Phase 1: actor/resource/grant/deny.
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
;; role_grant (role -> action,resource capability).
;; Phase 4: peer/trust/delegate/level_covers (federation).
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
;; subject S is a member of group/role G (one hop; transitivity is derived).
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
;; resource R is a child of parent P (one hop; transitivity is derived).
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
;; role confers capability (act on res) to every member of the role.
(define
acl-role-grant
(fn (role act res) (list (quote role_grant) role act res)))
;; --- federation ---
;; a known peer instance at addr, of some kind (e.g. peer).
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
;; local trust in a peer at a named level. Gates delegated grants at query time.
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
;; that peer covers action A (see level_covers).
(define
acl-delegate
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
;; local policy: trust `level` authorises delegated grants for action `act`.
(define
acl-level-covers
(fn (level act) (list (quote level_covers) level act)))

61
lib/acl/federation.sx Normal file
View File

@@ -0,0 +1,61 @@
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
;;
;; fed-sx replicates ACL facts between instances; this module models the local
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
;; authorise them (enforced by the engine rule, re-checked every query). The
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
;;
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
;; name) to the list of delegate facts that peer asserts. Returns the facts for
;; `addr`, or an empty list if the peer is unknown / unreachable.
(define
acl-fed-fetch
(fn
(transport addr)
(let
((k (if (symbol? addr) (symbol->string addr) addr)))
(if (has-key? transport k) (get transport k) (list)))))
;; Gather delegate facts from every peer in `addrs` via the transport.
(define
acl-fed-collect
(fn
(transport addrs)
(let
((acc (list)))
(do
(for-each
(fn
(addr)
(for-each
(fn (f) (append! acc f))
(acl-fed-fetch transport addr)))
addrs)
acc))))
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
;; facts must include the `trust`/`level_covers` policy; replicated delegate
;; facts are gated against it by the engine rule at query time.
(define
acl-fed-build-db
(fn
(local-facts transport peers)
(let
((all (list)))
(do
(for-each (fn (f) (append! all f)) local-facts)
(for-each
(fn (f) (append! all f))
(acl-fed-collect transport peers))
(acl-build-db all)))))
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
;; local trust) from a live db. The next decision re-saturates and reflects it.
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
;; Propagated assertion: ingest a newly replicated fact into a live db.
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))

71
lib/acl/schema.sx Normal file
View File

@@ -0,0 +1,71 @@
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
;;
;; Datalog is untyped; this module is the schema-as-data layer. It declares
;; the subject/resource/action sorts and the arity of every EDB predicate the
;; ACL engine recognises, plus light validators. Facts that pass these checks
;; are well-formed inputs to lib/acl/engine.sx.
(define acl-subject-kinds (quote (user group role service)))
(define acl-resource-kinds (quote (page post thread peer)))
;; Actions are open-ended (a grant may name any action symbol), but these are
;; the platform's well-known verbs.
(define acl-actions (quote (read edit comment moderate federate)))
;; EDB predicate name -> arity.
;; Phase 1: actor/resource/grant/deny.
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
;; role_grant (role->action,resource).
;; Phase 4: peer (addr->kind), trust (peer->level),
;; delegate (peer->subj,action,resource), level_covers (level->action).
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
(define
acl-member?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (acl-member? x (rest xs))))))
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
;; is known and the argument count matches the declared arity.
(define
acl-fact-valid?
(fn
(f)
(and
(list? f)
(> (len f) 0)
(symbol? (first f))
(let
((pred (symbol->string (first f))))
(and
(has-key? acl-edb-arity pred)
(= (- (len f) 1) (get acl-edb-arity pred)))))))
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
;; tuple, and custom action symbols are allowed); callers opt in to checking.
(define
acl-validate-facts
(fn
(facts)
(let
((bad (list)))
(do
(for-each
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
facts)
bad))))
(define
acl-facts-valid?
(fn (facts) (= (len (acl-validate-facts facts)) 0)))

14
lib/acl/scoreboard.json Normal file
View File

@@ -0,0 +1,14 @@
{
"lang": "acl",
"total_passed": 145,
"total_failed": 0,
"total": 145,
"suites": [
{"name":"direct","passed":24,"failed":0,"total":24},
{"name":"inherit","passed":30,"failed":0,"total":30},
{"name":"explain","passed":35,"failed":0,"total":35},
{"name":"fed","passed":31,"failed":0,"total":31},
{"name":"harden","passed":25,"failed":0,"total":25}
],
"generated": "2026-06-06T22:43:27+00:00"
}

11
lib/acl/scoreboard.md Normal file
View File

@@ -0,0 +1,11 @@
# acl scoreboard
**145 / 145 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
| direct | 24 | 24 | ok |
| inherit | 30 | 30 | ok |
| explain | 35 | 35 | ok |
| fed | 31 | 31 | ok |
| harden | 25 | 25 | ok |

170
lib/acl/tests/direct.sx Normal file
View File

@@ -0,0 +1,170 @@
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
(define acl-dt-pass 0)
(define acl-dt-fail 0)
(define acl-dt-failures (list))
(define
acl-dt-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-dt-pass (+ acl-dt-pass 1))
(do
(set! acl-dt-fail (+ acl-dt-fail 1))
(append!
acl-dt-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A small fixture used by most cases: alice can read page1, is denied edit on
;; page1, and a service may federate peer1.
(define
acl-dt-fixture
(fn
()
(acl-build-db
(list
(acl-actor (quote alice) (quote user))
(acl-actor (quote svc1) (quote service))
(acl-resource-fact (quote page1) (quote page))
(acl-resource-fact (quote peer1) (quote peer))
(acl-grant (quote alice) (quote read) (quote page1))
(acl-grant (quote alice) (quote edit) (quote page1))
(acl-deny (quote alice) (quote edit) (quote page1))
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
(define
acl-dt-run-all!
(fn
()
(let
((db (acl-dt-fixture)))
(do
(acl-dt-check!
"direct grant permits"
(acl-permit? db (quote alice) (quote read) (quote page1))
true)
(acl-dt-check!
"service grant permits federate"
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
true)
(acl-dt-check!
"missing action denied"
(acl-permit? db (quote alice) (quote comment) (quote page1))
false)
(acl-dt-check!
"missing resource denied"
(acl-permit? db (quote alice) (quote read) (quote page2))
false)
(acl-dt-check!
"missing subject denied"
(acl-permit? db (quote bob) (quote read) (quote page1))
false)
(acl-dt-check!
"wrong subject for service grant denied"
(acl-permit? db (quote alice) (quote federate) (quote peer1))
false)
(acl-dt-check!
"grant plus deny -> deny wins"
(acl-permit? db (quote alice) (quote edit) (quote page1))
false)
(acl-dt-check!
"deny alone still denies"
(acl-permit?
(acl-build-db
(list (acl-deny (quote alice) (quote read) (quote page1))))
(quote alice)
(quote read)
(quote page1))
false)
(acl-dt-check!
"deny on edit does not block read"
(acl-permit? db (quote alice) (quote read) (quote page1))
true)
(acl-dt-check!
"empty db denies"
(acl-permit?
(acl-build-db (list))
(quote alice)
(quote read)
(quote page1))
false)
(let
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
(do
(acl-dt-check!
"subject a allowed"
(acl-permit? db2 (quote a) (quote read) (quote r))
true)
(acl-dt-check!
"subject b denied by override"
(acl-permit? db2 (quote b) (quote read) (quote r))
false)))
(let
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
(acl-dt-check!
"role subject direct grant"
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
true))
(do
(acl/load!
(list
(acl-grant (quote carol) (quote moderate) (quote thread1))))
(acl-dt-check!
"api permit via current db"
(acl/permit? (quote carol) (quote moderate) (quote thread1))
true)
(acl-dt-check!
"api deny via current db"
(acl/permit? (quote carol) (quote read) (quote thread1))
false))
(do
(acl/load! (list))
(acl-dt-check!
"api reload clears prior grants"
(acl/permit? (quote carol) (quote moderate) (quote thread1))
false))
(acl-dt-check!
"schema grant arity valid"
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
true)
(acl-dt-check!
"schema bad arity invalid"
(acl-fact-valid? (list (quote grant) (quote x)))
false)
(acl-dt-check!
"schema unknown predicate invalid"
(acl-fact-valid? (list (quote frobnicate) (quote x)))
false)
(acl-dt-check!
"schema subject kind known"
(acl-subject-kind? (quote service))
true)
(acl-dt-check!
"schema resource kind unknown"
(acl-resource-kind? (quote galaxy))
false)
(acl-dt-check!
"schema known action"
(acl-known-action? (quote moderate))
true)
(acl-dt-check!
"grant constructor shape"
(acl-grant (quote u) (quote read) (quote p))
(list (quote grant) (quote u) (quote read) (quote p)))
(acl-dt-check!
"actor constructor shape"
(acl-actor (quote u) (quote user))
(list (quote actor) (quote u) (quote user)))))))
(define
acl-direct-tests-run!
(fn
()
(do
(set! acl-dt-pass 0)
(set! acl-dt-fail 0)
(set! acl-dt-failures (list))
(acl-dt-run-all!)
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))

316
lib/acl/tests/explain.sx Normal file
View File

@@ -0,0 +1,316 @@
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
(define acl-et-pass 0)
(define acl-et-fail 0)
(define acl-et-failures (list))
;; Name-based deep equality. The host `=` compares symbols by interned
;; identity, which is unstable across substitution/saturation; comparing by
;; name (as the datalog suite does) makes structural assertions deterministic.
(define
acl-et-eq?
(fn
(a b)
(cond
((and (list? a) (list? b))
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
((and (dict? a) (dict? b))
(let
((ka (keys a)) (kb (keys b)))
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
((and (symbol? a) (symbol? b))
(= (symbol->string a) (symbol->string b)))
(else (= a b)))))
(define
acl-et-eq-l?
(fn
(a b i)
(cond
((>= i (len a)) true)
((not (acl-et-eq? (nth a i) (nth b i))) false)
(else (acl-et-eq-l? a b (+ i 1))))))
(define
acl-et-eq-d?
(fn
(a b ka i)
(cond
((>= i (len ka)) true)
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
false)
(else (acl-et-eq-d? a b ka (+ i 1))))))
(define
acl-et-check!
(fn
(name got expected)
(if
(acl-et-eq? got expected)
(set! acl-et-pass (+ acl-et-pass 1))
(do
(set! acl-et-fail (+ acl-et-fail 1))
(append!
acl-et-failures
(str name "\n expected: " expected "\n got: " got))))))
;; --- proof-tree walkers ---
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
(define
acl-et-has-leaf?
(fn
(node target)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :via))
(acl-et-eq? (get node :fact) target))
((and (dict? node) (has-key? node :body))
(acl-et-any-leaf? (get node :body) target))
(else false))))
(define
acl-et-any-leaf?
(fn
(nodes target)
(cond
((= (len nodes) 0) false)
((acl-et-has-leaf? (first nodes) target) true)
(else (acl-et-any-leaf? (rest nodes) target)))))
;; True if the proof records a verified negation (deny did not fire).
(define
acl-et-has-negok?
(fn
(node)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :neg-ok)) true)
((and (dict? node) (has-key? node :body))
(acl-et-any-negok? (get node :body)))
(else false))))
(define
acl-et-any-negok?
(fn
(nodes)
(cond
((= (len nodes) 0) false)
((acl-et-has-negok? (first nodes)) true)
(else (acl-et-any-negok? (rest nodes))))))
(define
acl-et-run-all!
(fn
()
(do
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
(let
((e (acl-explain db (quote u) (quote read) (quote p))))
(do
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
(acl-et-check!
"direct: proof root fact"
(get (get e :proof) :fact)
(list (quote permit) (quote u) (quote read) (quote p)))
(acl-et-check!
"direct: grant leaf present"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote u) (quote read) (quote p)))
true)
(acl-et-check!
"direct: negation verified"
(acl-et-has-negok? (get e :proof))
true)
(acl-et-check!
"direct: reason nil when allowed"
(get e :reason)
nil))))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-et-check! "group: allowed?" (get e :allowed?) true)
(acl-et-check!
"group: member_of alice leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote alice) (quote team)))
true)
(acl-et-check!
"group: member_of team leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote team) (quote org)))
true)
(acl-et-check!
"group: grant org leaf at base"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote org) (quote read) (quote doc)))
true))))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
(let
((e (acl-explain db (quote u) (quote read) (quote sec))))
(do
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
(acl-et-check!
"resource: child_of leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote child_of) (quote sec) (quote book)))
true)
(acl-et-check!
"resource: grant on parent leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote grant) (quote u) (quote read) (quote book)))
true))))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
(let
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
(do
(acl-et-check! "role: allowed?" (get e :allowed?) true)
(acl-et-check!
"role: member_of leaf"
(acl-et-has-leaf?
(get e :proof)
(list (quote member_of) (quote bob) (quote editor)))
true)
(acl-et-check!
"role: role_grant leaf"
(acl-et-has-leaf?
(get e :proof)
(list
(quote role_grant)
(quote editor)
(quote edit)
(quote page1)))
true))))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(let
((e (acl-explain db (quote u) (quote edit) (quote p))))
(do
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
(acl-et-check! "deny: no proof" (get e :proof) nil)
(acl-et-check!
"deny: reason root is eff_deny"
(get (get e :reason) :fact)
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
(acl-et-check!
"deny: reason has deny leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote deny) (quote u) (quote edit) (quote p)))
true))))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-et-check!
"inherited deny: not allowed"
(get e :allowed?)
false)
(acl-et-check!
"inherited deny: reason has member_of leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote member_of) (quote alice) (quote team)))
true)
(acl-et-check!
"inherited deny: reason has group deny leaf"
(acl-et-has-leaf?
(get e :reason)
(list (quote deny) (quote team) (quote read) (quote doc)))
true))))
(let
((db (acl-build-db (list))))
(let
((e (acl-explain db (quote u) (quote read) (quote p))))
(do
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(do
(acl-audit-clear!)
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
(acl-et-check!
"audit decide allowed returns true"
(acl-audit-decide! db (quote u) (quote read) (quote p))
true)
(acl-et-check!
"audit decide denied returns false"
(acl-audit-decide! db (quote u) (quote edit) (quote p))
false)
(acl-audit-decide! db (quote u) (quote comment) (quote p))
(acl-et-check!
"audit: count after three decisions"
(acl-audit-count)
3)
(acl-et-check!
"audit: tail size respects n"
(len (acl-audit-tail 2))
2)
(acl-et-check!
"audit: tail returns most recent"
(get (first (acl-audit-tail 1)) :act)
(quote comment))
(acl-et-check!
"audit: first record seq is 0"
(get (first (acl-audit-tail 3)) :seq)
0)
(acl-et-check!
"audit: allowed flag recorded"
(get (first (acl-audit-tail 3)) :allowed?)
true)
(acl-et-check!
"audit: serialize line count"
(len (acl-et-lines (acl-audit-serialize)))
3)
(acl-audit-clear!)
(acl-et-check!
"audit: clear resets count"
(acl-audit-count)
0))))))
;; count newline-terminated lines in a serialized log
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
(define
acl-et-count-nl
(fn
(s i n)
(if
(>= i (len s))
(if (= n 0) (list) (acl-et-rangelist n))
(acl-et-count-nl
s
(+ i 1)
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
(define
acl-et-rangelist
(fn
(n)
(if
(<= n 0)
(list)
(cons n (acl-et-rangelist (- n 1))))))
(define
acl-explain-tests-run!
(fn
()
(do
(set! acl-et-pass 0)
(set! acl-et-fail 0)
(set! acl-et-failures (list))
(acl-et-run-all!)
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))

273
lib/acl/tests/fed.sx Normal file
View File

@@ -0,0 +1,273 @@
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
(define acl-ft-pass 0)
(define acl-ft-fail 0)
(define acl-ft-failures (list))
;; Name-based deep equality (host `=` compares symbols by unstable interned
;; identity; see lib/acl/tests/explain.sx).
(define
acl-ft-eq?
(fn
(a b)
(cond
((and (list? a) (list? b))
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
((and (symbol? a) (symbol? b))
(= (symbol->string a) (symbol->string b)))
(else (= a b)))))
(define
acl-ft-eq-l?
(fn
(a b i)
(cond
((>= i (len a)) true)
((not (acl-ft-eq? (nth a i) (nth b i))) false)
(else (acl-ft-eq-l? a b (+ i 1))))))
(define
acl-ft-check!
(fn
(name got expected)
(if
(acl-ft-eq? got expected)
(set! acl-ft-pass (+ acl-ft-pass 1))
(do
(set! acl-ft-fail (+ acl-ft-fail 1))
(append!
acl-ft-failures
(str name "\n expected: " expected "\n got: " got))))))
;; proof leaf walker (federated proofs reconstruct through the engine rule).
(define
acl-ft-has-leaf?
(fn
(node target)
(cond
((= node nil) false)
((and (dict? node) (has-key? node :via))
(acl-ft-eq? (get node :fact) target))
((and (dict? node) (has-key? node :body))
(acl-ft-any-leaf? (get node :body) target))
(else false))))
(define
acl-ft-any-leaf?
(fn
(nodes target)
(cond
((= (len nodes) 0) false)
((acl-ft-has-leaf? (first nodes) target) true)
(else (acl-ft-any-leaf? (rest nodes) target)))))
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
;; A standard federation fixture: local trusts peer alpha at "readonly", which
;; covers read+comment. alpha delegates several capabilities to alice.
(define
acl-ft-fixture
(fn
()
(acl-build-db
(list
(acl-trust (quote alpha) (quote readonly))
(acl-level-covers (quote readonly) (quote read))
(acl-level-covers (quote readonly) (quote comment))
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
(define
acl-ft-run-all!
(fn
()
(do
(let
((db (acl-ft-fixture)))
(do
(acl-ft-check!
"trusted delegate, level covers action -> permit"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"trusted delegate, level does NOT cover action -> deny"
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
false)
(acl-ft-check!
"delegated but action class uncovered (comment has no delegate)"
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-ft-check!
"untrusted peer delegate -> deny"
(acl-ft-p? db (quote bob) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(acl-ft-check!
"trust but no level_covers -> deny"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(do
(acl-ft-check!
"trust is per-peer: alpha's delegate applies"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"trust not transitive: beta's delegate does not apply"
(acl-ft-p? db (quote bob) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-ft-check!
"local deny overrides federated grant"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
(acl-ft-check!
"federated grant to group reaches member"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
(acl-ft-check!
"federated grant on parent resource reaches child"
(acl-ft-p? db (quote u) (quote read) (quote sec))
true))
(let
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
(do
(acl-ft-check!
"fetch known peer returns its delegates"
(len (acl-fed-fetch transport (quote alpha)))
1)
(acl-ft-check!
"fetch unknown peer returns empty"
(len (acl-fed-fetch transport (quote delta)))
0)
(acl-ft-check!
"collect across peers"
(len
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
2)
(let
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
(do
(acl-ft-check!
"fed-build-db: alpha delegate permits"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-ft-check!
"fed-build-db: gamma delegate permits"
(acl-ft-p? db (quote carol) (quote read) (quote post))
true)
(acl-ft-check!
"fed-build-db: untrusted action still denied"
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
false)))))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"before revoke: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-revoke!
db
(acl-delegate
(quote alpha)
(quote alice)
(quote read)
(quote doc)))
(acl-ft-check!
"after delegate revoked: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"before trust revoke: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
(acl-ft-check!
"after trust revoked: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
(do
(acl-ft-check!
"delegate without trust: denied"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
false)
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
(acl-ft-check!
"trust ingested then re-checked: permitted"
(acl-ft-p? db (quote alice) (quote read) (quote doc))
true)))
(let
((db (acl-ft-fixture)))
(let
((e (acl-explain db (quote alice) (quote read) (quote doc))))
(do
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
(acl-ft-check!
"federated proof has delegate leaf"
(acl-ft-has-leaf?
(get e :proof)
(list
(quote delegate)
(quote alpha)
(quote alice)
(quote read)
(quote doc)))
true)
(acl-ft-check!
"federated proof has trust leaf"
(acl-ft-has-leaf?
(get e :proof)
(list (quote trust) (quote alpha) (quote readonly)))
true)
(acl-ft-check!
"federated proof has level_covers leaf"
(acl-ft-has-leaf?
(get e :proof)
(list (quote level_covers) (quote readonly) (quote read)))
true))))
(acl-ft-check!
"schema delegate arity valid"
(acl-fact-valid?
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
true)
(acl-ft-check!
"schema trust arity valid"
(acl-fact-valid? (acl-trust (quote p) (quote l)))
true)
(acl-ft-check!
"schema peer arity valid"
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
true)
(acl-ft-check!
"schema level_covers arity valid"
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
true)
(acl-ft-check!
"schema delegate bad arity invalid"
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
false))))
(define
acl-fed-tests-run!
(fn
()
(do
(set! acl-ft-pass 0)
(set! acl-ft-fail 0)
(set! acl-ft-failures (list))
(acl-ft-run-all!)
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))

228
lib/acl/tests/harden.sx Normal file
View File

@@ -0,0 +1,228 @@
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
;;
;; Diamond hierarchies, conflict resolution where deny must win through every
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
;; validation, and audit save/restore.
;;
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
;; reconstruction is covered by tests/explain.sx (and federated proofs by
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
(define acl-hd-pass 0)
(define acl-hd-fail 0)
(define acl-hd-failures (list))
(define
acl-hd-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-hd-pass (+ acl-hd-pass 1))
(do
(set! acl-hd-fail (+ acl-hd-fail 1))
(append!
acl-hd-failures
(str name "\n expected: " expected "\n got: " got))))))
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
(define
acl-hd-run-all!
(fn
()
(do
(let
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
(both-grant
(acl-build-db
(list
(acl-child-of (quote r) (quote p1))
(acl-child-of (quote r) (quote p2))
(acl-grant (quote u) (quote read) (quote p1))
(acl-grant (quote u) (quote read) (quote p2))))))
(do
(acl-hd-check!
"diamond resource: grant+deny parents -> deny wins"
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
false)
(acl-hd-check!
"diamond resource: both grant -> permit"
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
true)
(acl-hd-check!
"diamond resource: deny does not leak to other parent"
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
true)))
(let
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
(both-grant
(acl-build-db
(list
(acl-member-of (quote alice) (quote g1))
(acl-member-of (quote alice) (quote g2))
(acl-grant (quote g1) (quote read) (quote doc))
(acl-grant (quote g2) (quote read) (quote doc))))))
(do
(acl-hd-check!
"diamond group: grant+deny groups -> deny wins"
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
false)
(acl-hd-check!
"diamond group: both grant -> permit"
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
true)))
(let
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
(chain-deny
(acl-build-db
(list
(acl-member-of (quote a0) (quote a1))
(acl-member-of (quote a1) (quote a2))
(acl-member-of (quote a2) (quote a3))
(acl-member-of (quote a3) (quote a4))
(acl-grant (quote a4) (quote read) (quote res))
(acl-deny (quote a0) (quote read) (quote res))))))
(do
(acl-hd-check!
"chain: top-group grant reaches leaf member"
(acl-hd-p? chain (quote a0) (quote read) (quote res))
true)
(acl-hd-check!
"chain: intermediate also covered"
(acl-hd-p? chain (quote a2) (quote read) (quote res))
true)
(acl-hd-check!
"chain: leaf-member deny overrides top grant"
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
false)
(acl-hd-check!
"chain: deny on leaf does not block sibling level"
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
true)))
(let
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
(self-child
(acl-build-db
(list
(acl-child-of (quote r) (quote r))
(acl-grant (quote u) (quote read) (quote r)))))
(two-cycle
(acl-build-db
(list
(acl-member-of (quote x) (quote y))
(acl-member-of (quote y) (quote x))
(acl-grant (quote y) (quote read) (quote r))))))
(do
(acl-hd-check!
"self-membership cycle terminates and grants"
(acl-hd-p? self-member (quote a) (quote read) (quote r))
true)
(acl-hd-check!
"self-child cycle terminates and grants"
(acl-hd-p? self-child (quote u) (quote read) (quote r))
true)
(acl-hd-check!
"two-node membership cycle terminates"
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
true)))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-hd-check!
"federated group grant, local member deny -> deny wins"
(acl-hd-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-hd-check!
"two peers delegate, one trusted -> permit"
(acl-hd-p? db (quote bob) (quote read) (quote doc))
true))
(let
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
(acl-hd-check!
"two peers both trusted -> permit"
(acl-hd-p? db (quote bob) (quote read) (quote doc))
true))
(let
((empty (acl-build-db (list))))
(acl-hd-check!
"empty db: nothing permitted"
(acl-hd-p? empty (quote u) (quote read) (quote r))
false))
(do
(acl-hd-check!
"validate: clean set has no bad facts"
(len
(acl-validate-facts
(list
(acl-grant (quote u) (quote read) (quote p))
(acl-member-of (quote u) (quote g))
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
0)
(acl-hd-check!
"validate: facts-valid? true on clean set"
(acl-facts-valid?
(list (acl-grant (quote u) (quote read) (quote p))))
true)
(acl-hd-check!
"validate: surfaces wrong-arity and unknown predicate"
(len
(acl-validate-facts
(list
(acl-grant (quote u) (quote read) (quote p))
(list (quote grant) (quote u))
(list (quote bogus) (quote x) (quote y)))))
2)
(acl-hd-check!
"validate: empty set is valid"
(acl-facts-valid? (list))
true))
(let
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
(do
(acl-audit-clear!)
(acl-audit-decide! db (quote u) (quote read) (quote p))
(acl-audit-decide! db (quote u) (quote edit) (quote p))
(let
((snap (acl-audit-snapshot)))
(do
(acl-audit-clear!)
(acl-hd-check!
"audit: cleared count is 0"
(acl-audit-count)
0)
(acl-audit-restore! snap)
(acl-hd-check!
"audit: restored count"
(acl-audit-count)
2)
(acl-hd-check!
"audit: restored last act"
(get (first (acl-audit-tail 1)) :act)
(quote edit))
(acl-audit-decide! db (quote u) (quote comment) (quote p))
(acl-hd-check!
"audit: seq continues after restore"
(get (first (acl-audit-tail 1)) :seq)
2)
(acl-hd-check!
"audit: snapshot is an immutable copy"
(len (get snap :entries))
2)
(acl-audit-clear!))))))))
(define
acl-harden-tests-run!
(fn
()
(do
(set! acl-hd-pass 0)
(set! acl-hd-fail 0)
(set! acl-hd-failures (list))
(acl-hd-run-all!)
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))

202
lib/acl/tests/inherit.sx Normal file
View File

@@ -0,0 +1,202 @@
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
;; role expansion) with deny-overrides.
(define acl-it-pass 0)
(define acl-it-fail 0)
(define acl-it-failures (list))
(define
acl-it-check!
(fn
(name got expected)
(if
(= got expected)
(set! acl-it-pass (+ acl-it-pass 1))
(do
(set! acl-it-fail (+ acl-it-fail 1))
(append!
acl-it-failures
(str name "\n expected: " expected "\n got: " got))))))
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
(define
acl-it-run-all!
(fn
()
(do
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
(do
(acl-it-check!
"group grant reaches member"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"group grant: non-member excluded"
(acl-it-p? db (quote bob) (quote read) (quote doc))
false)
(acl-it-check!
"group grant: wrong action"
(acl-it-p? db (quote alice) (quote edit) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
(do
(acl-it-check!
"deep nested group grant reaches leaf member"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"intermediate group also covered"
(acl-it-p? db (quote team) (quote read) (quote doc))
true)
(acl-it-check!
"mid group org covered"
(acl-it-p? db (quote org) (quote read) (quote doc))
true)))
(let
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
(do
(acl-it-check!
"cyclic membership terminates and grants"
(acl-it-p? db (quote a) (quote read) (quote r))
true)
(acl-it-check!
"cyclic membership covers both"
(acl-it-p? db (quote b) (quote read) (quote r))
true)))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
(do
(acl-it-check!
"parent grant reaches direct child"
(acl-it-p? db (quote u) (quote read) (quote chap))
true)
(acl-it-check!
"parent grant reaches deep descendant"
(acl-it-p? db (quote u) (quote read) (quote sec))
true)
(acl-it-check!
"parent grant covers parent itself"
(acl-it-p? db (quote u) (quote read) (quote book))
true)
(acl-it-check!
"child grant does not climb to parent"
(acl-it-p?
(acl-build-db
(list
(acl-child-of (quote sec) (quote book))
(acl-grant (quote u) (quote read) (quote sec))))
(quote u)
(quote read)
(quote book))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
(do
(acl-it-check!
"group + resource: member on child resource"
(acl-it-p? db (quote alice) (quote comment) (quote post1))
true)
(acl-it-check!
"group + resource: member on parent resource"
(acl-it-p? db (quote alice) (quote comment) (quote board))
true)))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
(do
(acl-it-check!
"role confers edit to member"
(acl-it-p? db (quote bob) (quote edit) (quote page1))
true)
(acl-it-check!
"role confers read to member"
(acl-it-p? db (quote bob) (quote read) (quote page1))
true)
(acl-it-check!
"role: capability not in tuple denied"
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
false)
(acl-it-check!
"role: non-member excluded"
(acl-it-p? db (quote eve) (quote edit) (quote page1))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
(acl-it-check!
"role grant flows to child resource"
(acl-it-p? db (quote bob) (quote edit) (quote draft))
true))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
(acl-it-check!
"explicit deny beats inherited group allow"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
(do
(acl-it-check!
"group deny inherits and overrides direct grant"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false)
(acl-it-check!
"group deny: another member also blocked"
(acl-it-p? db (quote team) (quote read) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
(acl-it-check!
"ancestor deny overrides descendant grant"
(acl-it-p? db (quote u) (quote read) (quote sec))
false))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
(do
(acl-it-check!
"deny on edit leaves inherited read intact"
(acl-it-p? db (quote alice) (quote read) (quote doc))
true)
(acl-it-check!
"deny on edit blocks edit"
(acl-it-p? db (quote alice) (quote edit) (quote doc))
false)))
(let
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
(acl-it-check!
"inherited deny, no grant: denied"
(acl-it-p? db (quote alice) (quote read) (quote doc))
false))
(let
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
(do
(acl-it-check!
"deny on sibling a blocks a"
(acl-it-p? db (quote u) (quote read) (quote a))
false)
(acl-it-check!
"deny on sibling a leaves b permitted"
(acl-it-p? db (quote u) (quote read) (quote b))
true)
(acl-it-check!
"root itself still permitted"
(acl-it-p? db (quote u) (quote read) (quote root))
true)))
(let
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
(acl-it-check!
"direct grant under inheritance ruleset"
(acl-it-p? db (quote x) (quote read) (quote y))
true)))))
(define
acl-inherit-tests-run!
(fn
()
(do
(set! acl-it-pass 0)
(set! acl-it-fail 0)
(set! acl-it-failures (list))
(acl-it-run-all!)
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))

63
lib/apl/conformance.conf Normal file
View File

@@ -0,0 +1,63 @@
# APL conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=apl
MODE=counters
COUNTERS_PASS=apl-test-pass
COUNTERS_FAIL=apl-test-fail
TIMEOUT_PER_SUITE=300
PRELOADS=(
spec/stdlib.sx
lib/r7rs.sx
lib/apl/runtime.sx
lib/apl/tokenizer.sx
lib/apl/parser.sx
lib/apl/transpile.sx
lib/apl/test-harness.sx
)
SUITES=(
"structural:lib/apl/tests/structural.sx"
"operators:lib/apl/tests/operators.sx"
"dfn:lib/apl/tests/dfn.sx"
"tradfn:lib/apl/tests/tradfn.sx"
"valence:lib/apl/tests/valence.sx"
"programs:lib/apl/tests/programs.sx"
"system:lib/apl/tests/system.sx"
"idioms:lib/apl/tests/idioms.sx"
"eval-ops:lib/apl/tests/eval-ops.sx"
"pipeline:lib/apl/tests/pipeline.sx"
)
emit_scoreboard_json() {
local n=${#GC_NAMES[@]} i sep
printf '{\n'
printf ' "suites": {\n'
for ((i=0; i<n; i++)); do
sep=","; [ $i -eq $((n-1)) ] && sep=""
printf ' "%s": {"pass": %d, "fail": %d}%s\n' \
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "$sep"
done
printf ' },\n'
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
printf ' "total": %d\n' "$GC_TOTAL"
printf '}\n'
}
emit_scoreboard_md() {
local n=${#GC_NAMES[@]} i
printf '# APL Conformance Scoreboard\n\n'
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
printf '| Suite | Pass | Fail | Total |\n'
printf '|-------|-----:|-----:|------:|\n'
for ((i=0; i<n; i++)); do
printf '| %s | %d | %d | %d |\n' \
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "${GC_TOTAL_S[$i]}"
done
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
printf '\n'
printf '## Notes\n\n'
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
}

View File

@@ -1,116 +1,5 @@
#!/usr/bin/env bash
# lib/apl/conformance.sh — run APL 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=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
OUT_JSON="lib/apl/scoreboard.json"
OUT_MD="lib/apl/scoreboard.md"
run_suite() {
local suite=$1
local file="lib/apl/tests/${suite}.sx"
local TMP
TMP=$(mktemp)
cat > "$TMP" << EPOCHS
(epoch 1)
(load "spec/stdlib.sx")
(load "lib/r7rs.sx")
(load "lib/apl/runtime.sx")
(load "lib/apl/tokenizer.sx")
(load "lib/apl/parser.sx")
(load "lib/apl/transpile.sx")
(epoch 2)
(eval "(define apl-test-pass 0)")
(eval "(define apl-test-fail 0)")
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
(epoch 3)
(load "${file}")
(epoch 4)
(eval "(list apl-test-pass apl-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 APL 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
# scoreboard.json
{
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"
# scoreboard.md
{
printf '# APL Conformance Scoreboard\n\n'
printf '_Generated by `lib/apl/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))"
printf '\n'
printf '## Notes\n\n'
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
} > "$OUT_MD"
echo "Wrote $OUT_JSON and $OUT_MD" >&2
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
[ "$TOTAL_FAIL" -eq 0 ]
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

View File

@@ -9,9 +9,9 @@
"system": {"pass": 13, "fail": 0},
"idioms": {"pass": 64, "fail": 0},
"eval-ops": {"pass": 14, "fail": 0},
"pipeline": {"pass": 40, "fail": 0}
"pipeline": {"pass": 152, "fail": 0}
},
"total_pass": 450,
"total_pass": 562,
"total_fail": 0,
"total": 450
"total": 562
}

View File

@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
| system | 13 | 0 | 13 |
| idioms | 64 | 0 | 64 |
| eval-ops | 14 | 0 | 14 |
| pipeline | 40 | 0 | 40 |
| **Total** | **450** | **0** | **450** |
| pipeline | 152 | 0 | 152 |
| **Total** | **562** | **0** | **562** |
## Notes

15
lib/apl/test-harness.sx Normal file
View File

@@ -0,0 +1,15 @@
; lib/apl/test-harness.sx — counters + assertion fn for the shared conformance
; driver (lib/guest/conformance.sh, MODE=counters). Loaded as a PRELOAD so each
; suite starts from a fresh 0/0; suites call (apl-test name got expected).
(define apl-test-pass 0)
(define apl-test-fail 0)
(define
apl-test
(fn
(name got expected)
(if
(= got expected)
(set! apl-test-pass (+ apl-test-pass 1))
(set! apl-test-fail (+ apl-test-fail 1)))))

88
lib/artdag/analyze.sx Normal file
View File

@@ -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))))

91
lib/artdag/api.sx Normal file
View File

@@ -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")

131
lib/artdag/conformance.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/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 analyze plan execute optimize fed cost serialize stats fault)
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/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/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")
(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")
(load "lib/artdag/api.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 ]

66
lib/artdag/cost.sx Normal file
View File

@@ -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))))

226
lib/artdag/dag.sx Normal file
View File

@@ -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<?) ----
(define
artdag/str<?-at
(fn
(a b i la lb)
(cond
((and (>= 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/str<?-at a b (+ i 1) la lb))))))))
(define
artdag/str<?
(fn
(a b)
(artdag/str<?-at a b 0 (string-length a) (string-length b))))
(define
artdag/insert-string
(fn
(sorted x)
(cond
((empty? sorted) (list x))
((artdag/str<? x (first sorted)) (cons x sorted))
(else (cons (first sorted) (artdag/insert-string (rest sorted) x))))))
(define
artdag/sort-strings
(fn (xs) (reduce (fn (acc x) (artdag/insert-string acc x)) (list) xs)))
; ---- canonical serialization ----
(define
artdag/canon-list
(fn
(xs)
(if
(empty? xs)
""
(reduce
(fn (acc x) (str acc " " (artdag/canon x)))
(artdag/canon (first xs))
(rest xs)))))
(define
artdag/canon-dict
(fn
(d)
(str
"{"
(reduce
(fn (acc k) (str acc " " k "=" (artdag/canon (get d k))))
""
(artdag/sort-strings (keys d)))
"}")))
(define
artdag/canon
(fn
(v)
(let
((t (type-of v)))
(cond
((equal? t "nil") "nil")
((equal? t "boolean") (if v "#t" "#f"))
((equal? t "number") (number->string 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)))))

82
lib/artdag/execute.sx Normal file
View File

@@ -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))))

56
lib/artdag/fault.sx Normal file
View File

@@ -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)))

75
lib/artdag/federation.sx Normal file
View File

@@ -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))))

202
lib/artdag/optimize.sx Normal file
View File

@@ -0,0 +1,202 @@
; 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.
; optimize — fuse then DCE in one pass.
; 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)))))
; ---- 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)))))

100
lib/artdag/plan.sx Normal file
View File

@@ -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))))

View File

@@ -0,0 +1,17 @@
{
"suites": {
"dag": {"pass": 20, "fail": 0},
"analyze": {"pass": 16, "fail": 0},
"plan": {"pass": 18, "fail": 0},
"execute": {"pass": 15, "fail": 0},
"optimize": {"pass": 22, "fail": 0},
"fed": {"pass": 15, "fail": 0},
"cost": {"pass": 13, "fail": 0},
"serialize": {"pass": 13, "fail": 0},
"stats": {"pass": 12, "fail": 0},
"fault": {"pass": 14, "fail": 0}
},
"total_pass": 158,
"total_fail": 0,
"total": 158
}

17
lib/artdag/scoreboard.md Normal file
View File

@@ -0,0 +1,17 @@
# artdag Conformance Scoreboard
_Generated by `lib/artdag/conformance.sh`_
| Suite | Pass | Fail | Total |
|-------|-----:|-----:|------:|
| dag | 20 | 0 | 20 |
| analyze | 16 | 0 | 16 |
| plan | 18 | 0 | 18 |
| execute | 15 | 0 | 15 |
| optimize | 22 | 0 | 22 |
| fed | 15 | 0 | 15 |
| cost | 13 | 0 | 13 |
| serialize | 13 | 0 | 13 |
| stats | 12 | 0 | 12 |
| fault | 14 | 0 | 14 |
| **Total** | **158** | **0** | **158** |

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

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

51
lib/artdag/stats.sx Normal file
View File

@@ -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)}))

119
lib/artdag/tests/analyze.sx Normal file
View File

@@ -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))

117
lib/artdag/tests/cost.sx Normal file
View File

@@ -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)

182
lib/artdag/tests/dag.sx Normal file
View File

@@ -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"))))

188
lib/artdag/tests/execute.sx Normal file
View File

@@ -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)

144
lib/artdag/tests/fault.sx Normal file
View File

@@ -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)

157
lib/artdag/tests/fed.sx Normal file
View File

@@ -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)

View File

@@ -0,0 +1,215 @@
; 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)
; ---- 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)

122
lib/artdag/tests/plan.sx Normal file
View File

@@ -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)))

View File

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

150
lib/artdag/tests/stats.sx Normal file
View File

@@ -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)

51
lib/content/anchor.sx Normal file
View File

@@ -0,0 +1,51 @@
;; content-on-sx — anchored-heading HTML render.
;;
;; Like asHTML, but headings carry an id attribute (the block id), so the TOC's
;; #id links resolve. A separate render so the plain asHTML stays unchanged.
;; Tree-aware (sections recurse); other blocks use their normal asHTML.
;;
;; Requires (loaded by harness): block.sx, doc.sx, render.sx (asHTML +
;; htmlEscaped).
(define
anch-section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define anch-esc (fn (s) (str (st-send s "htmlEscaped" (list)))))
(define
anchor-block
(fn
(b)
(cond
((= (blk-type b) "heading")
(let
((l (str (blk-get b "level"))) (id (blk-id b)))
(str
"<h"
l
" id=\""
id
"\">"
(anch-esc (str (blk-get b "text")))
"</h"
l
">")))
((anch-section? b)
(let
((ch (st-iv-get b "children")))
(str
"<section>"
(anchor-blocks (if (list? ch) ch (list)))
"</section>")))
(else (str (st-send b "asHTML" (list)))))))
(define
anchor-blocks
(fn
(blocks)
(if
(= (len blocks) 0)
""
(str (anchor-block (first blocks)) (anchor-blocks (rest blocks))))))
(define content/html-anchored (fn (doc) (anchor-blocks (doc-blocks doc))))

67
lib/content/api.sx Normal file
View File

@@ -0,0 +1,67 @@
;; content-on-sx — public API facade.
;;
;; The stable surface other code calls. Composes block + doc + render. Document
;; values are immutable; every edit returns a new document, so callers hold
;; explicit versions (the persist op log in Phase 2 becomes the source of truth).
;;
;; Requires (loaded by the harness): block.sx, doc.sx, render.sx and a base
;; Smalltalk class table (st-bootstrap-classes!).
;; Register the content class hierarchy + render methods. Caller bootstraps the
;; base Smalltalk classes first; this only adds content classes (idempotent).
(define
content/bootstrap!
(fn
()
(begin
(content-bootstrap-blocks!)
(content-bootstrap-doc!)
(content-bootstrap-render!)
true)))
;; ── documents ──
(define content/new doc-new)
(define content/empty doc-empty)
(define content/append doc-append)
(define content/blocks doc-blocks)
(define content/count doc-count)
(define content/find doc-find)
(define content/has? doc-has?)
(define content/ids doc-ids)
(define content/types doc-types)
;; ── blocks ──
(define content/block mk-block)
;; ── edit ops (data payload) ──
(define content/insert op-insert)
(define content/update op-update)
(define content/move op-move)
(define content/delete op-delete)
(define content/op? (fn (x) (and (dict? x) (has-key? x :op))))
;; edit — apply one op or a stream of ops; returns a new document.
(define
content/edit
(fn
(doc ops)
(if (content/op? ops) (doc-apply doc ops) (doc-apply-all doc ops))))
;; ── render boundary ──
;; fmt is "html"/"sx"/"md"/"text" (or the matching keyword). "md" needs
;; markdown.sx loaded; "text" needs text.sx loaded.
(define
content/render
(fn
(doc fmt)
(cond
((= fmt "html") (asHTML doc))
((= fmt "sx") (asSx doc))
((= fmt "md") (asMarkdown doc))
((= fmt "markdown") (asMarkdown doc))
((= fmt "text") (asText doc))
(else (error (str "unknown render format: " fmt))))))
(define content/html asHTML)
(define content/sx asSx)

171
lib/content/block.sx Normal file
View File

@@ -0,0 +1,171 @@
;; content-on-sx — typed block objects on Smalltalk-on-SX.
;;
;; A block is a Smalltalk instance. Behaviour (type tag, later render) is a
;; message, not a property switch. Fields are immutable: blk-set / mk-* build a
;; fresh instance via the functional st-iv-set!, so old versions are never
;; clobbered (history-safe for the persist op log and CRDT merge).
;;
;; Hierarchy:
;; CtBlock (id)
;; CtText (text)
;; CtHeading (level)
;; CtCode (language)
;; CtQuote (cite)
;; CtImage (src alt)
;; CtEmbed (url provider)
;; CtDivider
;; CtList (ordered items)
;; Plus self-contained blocks registered by their own files: CtSection,
;; CtTable, CtCallout, CtMedia. ct-class-for-type maps every tag (so mk-block,
;; content/from-data and CRDT materialise build them uniformly); the classes
;; themselves are registered by content-bootstrap-section!/table!/callout!/media!.
(define
ct-def-method!
(fn (cls sel src) (st-class-add-method! cls sel (st-parse-method src))))
;; Register the block hierarchy in the Smalltalk class table. Call AFTER
;; st-bootstrap-classes! (which resets the table). Idempotent.
(define
content-bootstrap-blocks!
(fn
()
(begin
(st-class-define! "CtBlock" "Object" (list "id"))
(ct-def-method! "CtBlock" "id" "id ^ id")
(ct-def-method! "CtBlock" "type" "type ^ #block")
(ct-def-method! "CtBlock" "isBlock" "isBlock ^ true")
(st-class-define! "CtText" "CtBlock" (list "text"))
(ct-def-method! "CtText" "text" "text ^ text")
(ct-def-method! "CtText" "type" "type ^ #text")
(st-class-define! "CtHeading" "CtText" (list "level"))
(ct-def-method! "CtHeading" "level" "level ^ level")
(ct-def-method! "CtHeading" "type" "type ^ #heading")
(st-class-define! "CtCode" "CtText" (list "language"))
(ct-def-method! "CtCode" "language" "language ^ language")
(ct-def-method! "CtCode" "type" "type ^ #code")
(st-class-define! "CtQuote" "CtText" (list "cite"))
(ct-def-method! "CtQuote" "cite" "cite ^ cite")
(ct-def-method! "CtQuote" "type" "type ^ #quote")
(st-class-define! "CtImage" "CtBlock" (list "src" "alt"))
(ct-def-method! "CtImage" "src" "src ^ src")
(ct-def-method! "CtImage" "alt" "alt ^ alt")
(ct-def-method! "CtImage" "type" "type ^ #image")
(st-class-define! "CtEmbed" "CtBlock" (list "url" "provider"))
(ct-def-method! "CtEmbed" "url" "url ^ url")
(ct-def-method! "CtEmbed" "provider" "provider ^ provider")
(ct-def-method! "CtEmbed" "type" "type ^ #embed")
(st-class-define! "CtDivider" "CtBlock" (list))
(ct-def-method! "CtDivider" "type" "type ^ #divider")
(st-class-define! "CtList" "CtBlock" (list "ordered" "items"))
(ct-def-method! "CtList" "ordered" "ordered ^ ordered")
(ct-def-method! "CtList" "items" "items ^ items")
(ct-def-method! "CtList" "type" "type ^ #list")
true)))
;; Apply (name value) pairs functionally onto a fresh instance.
(define
ct-apply-fields
(fn
(inst pairs)
(if
(= (len pairs) 0)
inst
(ct-apply-fields
(st-iv-set!
inst
(first (first pairs))
(first (rest (first pairs))))
(rest pairs)))))
(define
ct-class-for-type
(fn
(tag)
(cond
((= tag "text") "CtText")
((= tag "heading") "CtHeading")
((= tag "code") "CtCode")
((= tag "quote") "CtQuote")
((= tag "image") "CtImage")
((= tag "embed") "CtEmbed")
((= tag "divider") "CtDivider")
((= tag "list") "CtList")
((= tag "section") "CtSection")
((= tag "table") "CtTable")
((= tag "callout") "CtCallout")
((= tag "media") "CtMedia")
(else (error (str "unknown block type: " tag))))))
;; Generic constructor — wire tag + id + (name value) field pairs.
(define
mk-block
(fn
(type-tag id fields)
(ct-apply-fields
(st-iv-set! (st-make-instance (ct-class-for-type type-tag)) "id" id)
fields)))
(define
mk-text
(fn (id text) (mk-block "text" id (list (list "text" text)))))
(define
mk-heading
(fn
(id level text)
(mk-block "heading" id (list (list "level" level) (list "text" text)))))
(define
mk-code
(fn
(id language text)
(mk-block
"code"
id
(list (list "language" language) (list "text" text)))))
(define
mk-quote
(fn
(id cite text)
(mk-block "quote" id (list (list "cite" cite) (list "text" text)))))
(define
mk-image
(fn
(id src alt)
(mk-block "image" id (list (list "src" src) (list "alt" alt)))))
(define
mk-embed
(fn
(id url provider)
(mk-block "embed" id (list (list "url" url) (list "provider" provider)))))
(define mk-divider (fn (id) (mk-block "divider" id (list))))
(define
mk-list
(fn
(id ordered items)
(mk-block
"list"
id
(list (list "ordered" ordered) (list "items" items)))))
;; Accessors. blk-type / blk-id go through message dispatch (polymorphic);
;; blk-get reads any ivar directly; blk-set is copy-on-write.
(define blk-id (fn (b) (st-send b "id" (list))))
(define blk-type (fn (b) (str (st-send b "type" (list)))))
(define blk-send (fn (b sel) (st-send b sel (list))))
(define blk-get (fn (b field) (st-iv-get b field)))
(define blk-set (fn (b field val) (st-iv-set! b field val)))
(define
block?
(fn
(v)
(and
(st-instance? v)
(st-class-inherits-from? (get v :class) "CtBlock"))))

49
lib/content/callout.sx Normal file
View File

@@ -0,0 +1,49 @@
;; content-on-sx — callout / admonition block.
;;
;; CtCallout holds a `kind` (note/warning/tip/…) and `text`. Self-contained: it
;; answers asHTML/asSx/asText/asMarkdown: so it composes with the render boundary
;; with no changes elsewhere. HTML text is htmlEscaped, SX text sxEscaped.
;;
;; Requires (loaded by harness): block.sx, doc.sx, render.sx (escapers);
;; markdown.sx / text.sx for those formats.
(define
content-bootstrap-callout!
(fn
()
(begin
(st-class-define! "CtCallout" "CtBlock" (list "kind" "text"))
(ct-def-method! "CtCallout" "kind" "kind ^ kind")
(ct-def-method! "CtCallout" "text" "text ^ text")
(ct-def-method! "CtCallout" "type" "type ^ #callout")
(ct-def-method!
"CtCallout"
"asHTML"
"asHTML ^ '<aside class=\"callout callout-' , kind htmlEscaped , '\">' , text htmlEscaped , '</aside>'")
(ct-def-method!
"CtCallout"
"asSx"
"asSx ^ '(aside :class \"callout callout-' , kind sxEscaped , '\" \"' , text sxEscaped , '\")'")
(ct-def-method! "CtCallout" "asText" "asText ^ text")
(ct-def-method!
"CtCallout"
"asMarkdown:"
"asMarkdown: nl ^ '> **' , kind , ':** ' , text")
true)))
(define
mk-callout
(fn
(id kind text)
(st-iv-set!
(st-iv-set!
(st-iv-set! (st-make-instance "CtCallout") "id" id)
"kind"
kind)
"text"
text)))
(define
callout?
(fn (b) (and (st-instance? b) (= (get b :class) "CtCallout"))))
(define callout-kind (fn (b) (st-send b "kind" (list))))

34
lib/content/clone.sx Normal file
View File

@@ -0,0 +1,34 @@
;; content-on-sx — block id remapping / clone.
;;
;; Deep-rewrite every block id in the tree (descending into sections) by applying
;; a function. Enables collision-free composition: prefix one document's ids
;; before concatenating it with another. Immutable; content is unchanged, only
;; ids.
;;
;; Requires (loaded by harness): doc.sx, section.sx (section? /
;; section-children / section-with-children).
(define
block-remap-id
(fn
(b f)
(let
((nb (blk-set b "id" (f (blk-id b)))))
(if
(section? nb)
(section-with-children
nb
(map (fn (c) (block-remap-id c f)) (section-children nb)))
nb))))
(define
content/remap-ids
(fn
(doc f)
(doc-with-blocks
doc
(map (fn (b) (block-remap-id b f)) (doc-blocks doc)))))
(define
content/prefix-ids
(fn (doc prefix) (content/remap-ids doc (fn (id) (str prefix id)))))

42
lib/content/compose.sx Normal file
View File

@@ -0,0 +1,42 @@
;; content-on-sx — document composition.
;;
;; Combine documents (header + body + footer, templates, partials) into a new
;; document. The result keeps the FIRST document's id and metadata; blocks are
;; concatenated. Immutable — inputs are untouched. Block-id collisions across
;; combined docs are the caller's concern (content/validate flags duplicates).
;;
;; Requires (loaded by harness): doc.sx.
(define
content/concat
(fn (a b) (doc-with-blocks a (append (doc-blocks a) (doc-blocks b)))))
(define
content/prepend
(fn (a b) (doc-with-blocks a (append (doc-blocks b) (doc-blocks a)))))
(define
content/-concat-fold
(fn
(acc more)
(if
(= (len more) 0)
acc
(content/-concat-fold (content/concat acc (first more)) (rest more)))))
(define
content/concat-all
(fn
(docs)
(if
(= (len docs) 0)
(doc-empty "merged")
(content/-concat-fold (first docs) (rest docs)))))
;; wrap a document's blocks inside a single section (collapse to a subtree).
;; Requires section.sx (mk-section) when used.
(define
content/wrap-section
(fn
(doc section-id)
(doc-with-blocks doc (list (mk-section section-id (doc-blocks doc))))))

158
lib/content/conformance.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# lib/content/conformance.sh — run content-on-sx suites, emit scoreboard.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)"
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
if [ ! -x "$SX_SERVER" ]; then
MAIN_ROOT=$(git worktree list | head -1 | awk '{print $1}')
if [ -x "$MAIN_ROOT/$SX_SERVER" ]; then
SX_SERVER="$MAIN_ROOT/$SX_SERVER"
else
echo "ERROR: sx_server.exe not found." >&2
exit 1
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-tree crdt-blocks crdt-store sync md-import md-doc fed)
OUT_JSON="lib/content/scoreboard.json"
OUT_MD="lib/content/scoreboard.md"
run_suite() {
local suite=$1
local file="lib/content/tests/${suite}.sx"
local TMP
TMP=$(mktemp)
cat > "$TMP" << EPOCHS
(epoch 1)
(load "lib/smalltalk/tokenizer.sx")
(load "lib/smalltalk/parser.sx")
(load "lib/guest/reflective/class-chain.sx")
(load "lib/smalltalk/runtime.sx")
(load "lib/guest/reflective/env.sx")
(load "lib/smalltalk/eval.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/content/block.sx")
(load "lib/content/doc.sx")
(load "lib/content/render.sx")
(load "lib/content/api.sx")
(load "lib/content/meta.sx")
(load "lib/content/text.sx")
(load "lib/content/section.sx")
(load "lib/content/compose.sx")
(load "lib/content/tree-edit.sx")
(load "lib/content/move.sx")
(load "lib/content/clone.sx")
(load "lib/content/query.sx")
(load "lib/content/toc.sx")
(load "lib/content/anchor.sx")
(load "lib/content/outline.sx")
(load "lib/content/flatten.sx")
(load "lib/content/transform.sx")
(load "lib/content/normalize.sx")
(load "lib/content/find-replace.sx")
(load "lib/content/stats.sx")
(load "lib/content/summary.sx")
(load "lib/content/index.sx")
(load "lib/content/table.sx")
(load "lib/content/callout.sx")
(load "lib/content/media.sx")
(load "lib/content/data.sx")
(load "lib/content/wire.sx")
(load "lib/content/page.sx")
(load "lib/content/page-full.sx")
(load "lib/content/markdown.sx")
(load "lib/content/validate.sx")
(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")
(load "lib/content/md-doc.sx")
(load "lib/content/fed.sx")
(epoch 2)
(eval "(define content-test-pass 0)")
(eval "(define content-test-fail 0)")
(eval "(define content-test-fails (list))")
(eval "(define content-test (fn (name got expected) (if (= got expected) (set! content-test-pass (+ content-test-pass 1)) (begin (set! content-test-fail (+ content-test-fail 1)) (set! content-test-fails (cons name content-test-fails))))))")
(epoch 3)
(load "${file}")
(epoch 4)
(eval "(list content-test-pass content-test-fail)")
EPOCHS
local OUTPUT
OUTPUT=$(timeout 240 "$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 content 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 '# content-on-sx Conformance Scoreboard\n\n'
printf '_Generated by `lib/content/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 ]

71
lib/content/crdt-store.sx Normal file
View File

@@ -0,0 +1,71 @@
;; content-on-sx — durable collaborative replication: CRDT ops on persist.
;;
;; Each replica appends its CRDT ops to its own persist stream
;; (crdt:<doc>:<replica>). Any node reconstructs the converged document by
;; replaying every replica's log into a CvRDT state and merging them. Because
;; the merge is a join and crdt-apply is order/duplicate-insensitive, the
;; converged result is identical regardless of replica order or re-delivery —
;; the durable log + CRDT give offline-capable, eventually-consistent editing.
;;
;; Requires (loaded by harness): crdt.sx (+ deps) and persist
;; (event/backend/log/kv/api). Backend `b` injected via (persist/open).
(define crdt/-stream (fn (doc-id replica) (str "crdt:" doc-id ":" replica)))
;; ── commit ops to a replica's durable log ──
(define
crdt/commit!
(fn
(b doc-id replica op at)
(persist/append b (crdt/-stream doc-id replica) (get op :op) at op)))
(define
crdt/commit-all!
(fn
(b doc-id replica ops at)
(if
(= (len ops) 0)
nil
(begin
(crdt/commit! b doc-id replica (first ops) at)
(crdt/commit-all! b doc-id replica (rest ops) at)))))
;; ── read a replica's log ──
(define
crdt/log
(fn (b doc-id replica) (persist/read b (crdt/-stream doc-id replica))))
(define
crdt/replica-ops
(fn
(b doc-id replica)
(map (fn (ev) (persist/event-data ev)) (crdt/log b doc-id replica))))
(define
crdt/replica-version
(fn (b doc-id replica) (persist/last-seq b (crdt/-stream doc-id replica))))
;; ── replay one replica's log into a CvRDT state ──
(define
crdt/replay
(fn
(b doc-id replica)
(crdt-apply-all (crdt-empty) (crdt/replica-ops b doc-id replica))))
;; ── converge: merge every replica's replayed state ──
(define
crdt/converge
(fn
(b doc-id replicas)
(crdt-merge-all (map (fn (r) (crdt/replay b doc-id r)) replicas))))
;; ── converged, materialised document ──
(define
crdt/document
(fn
(b doc-id replicas)
(crdt-materialize doc-id (crdt/converge b doc-id replicas))))
(define
crdt/order
(fn (b doc-id replicas) (crdt-order (crdt/converge b doc-id replicas))))

193
lib/content/crdt-tree.sx Normal file
View File

@@ -0,0 +1,193 @@
;; 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 "" (plus ORPHANS — elements whose
;; parent is not a live section, e.g. after a concurrent delete-section +
;; insert-child, so content is never silently lost); a section's children =
;; elements whose parent is that section's id. Commutative/associative/idempotent
;; like the flat layer.
;;
;; Requires (loaded by harness): crdt.sx (merge helpers + live/sort/materialise
;; bits + crdt-member?), 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-live-section-ids
(fn
(state)
(map
(fn (e) (get e :id))
(filter
(fn (e) (= (get e :type) "section"))
(crdt-live-elements state)))))
;; an element belongs at root if its parent is "" or its parent is not a live
;; section (orphan-reparenting: don't lose content when its section is deleted).
(define
ctt-roots
(fn
(state)
(let
((secids (ctt-live-section-ids state)))
(crdt-sort-by-pos
(filter
(fn
(e)
(if
(= (get e :parent) "")
true
(if (crdt-member? (get e :parent) secids) false true)))
(crdt-live-elements state))))))
(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-roots state)))))
(define
crdt-tree-order
(fn (state) (map (fn (e) (get e :id)) (ctt-roots state))))

378
lib/content/crdt.sx Normal file
View File

@@ -0,0 +1,378 @@
;; content-on-sx — collaborative merge (state-based CvRDT).
;;
;; The merge is a join (least upper bound) on a semilattice, so it is
;; commutative, associative and idempotent BY CONSTRUCTION — applying ops in any
;; order, or merging replicas in any order / twice, converges to the same
;; document. This is NOT last-write-wins-as-cop-out: ordering uses unique dense
;; position keys (Logoot), presence uses OR-tombstones (remove-wins), and each
;; field is an LWW-Register keyed by a logical (ts, actor) clock — an explicit,
;; deterministic per-field conflict policy.
;;
;; Every op (insert/update/delete) contributes a PARTIAL element; the per-id
;; state is the join of all contributions. So update-before-insert and
;; delete-before-insert are not lost — they merge when the rest arrives.
;;
;; Shapes:
;; state = {:elements <dict id -> element>}
;; element = {:id :pos :type :deleted :fields <dict fname -> register>}
;; register = {:value v :ts <int> :actor <int>}
;; position = list of cells; cell = (list digit actor); lexicographic order
;;
;; Requires (loaded by harness): block.sx, doc.sx.
(define CRDT-BASE 65536)
;; ── position order (Logoot) ──
(define
crdt-cell-cmp
(fn
(c1 c2)
(let
((d1 (first c1)) (d2 (first c2)))
(cond
((< d1 d2) -1)
((> d1 d2) 1)
(else
(let
((a1 (first (rest c1))) (a2 (first (rest c2))))
(cond
((< a1 a2) -1)
((> a1 a2) 1)
(else 0))))))))
(define
crdt-pos-compare
(fn
(p1 p2)
(cond
((and (= (len p1) 0) (= (len p2) 0)) 0)
((= (len p1) 0) -1)
((= (len p2) 0) 1)
(else
(let
((c (crdt-cell-cmp (first p1) (first p2))))
(if (= c 0) (crdt-pos-compare (rest p1) (rest p2)) c))))))
;; single-cell position constructor (handy for explicit tests)
(define crdt-pos (fn (digit actor) (list (list digit actor))))
;; allocate a position strictly between left and right (nil = unbounded)
(define
cr-alloc
(fn
(left right actor i acc)
(let
((ld (if (< i (len left)) (first (nth left i)) 0))
(rd (if (< i (len right)) (first (nth right i)) CRDT-BASE)))
(if
(> (- rd ld) 1)
(append
acc
(list
(list
(+
ld
(+
1
(floor (/ (- (- rd ld) 1) 2))))
actor)))
(cr-alloc
left
right
actor
(+ i 1)
(append
acc
(list
(list
ld
(if (< i (len left)) (first (rest (nth left i))) actor)))))))))
(define
crdt-pos-between
(fn
(left right actor)
(cr-alloc
(if (= left nil) (list) left)
(if (= right nil) (list) right)
actor
0
(list))))
;; ── register (LWW by logical (ts, actor)) ──
(define
crdt-reg-max
(fn
(r1 r2)
(cond
((= r1 nil) r2)
((= r2 nil) r1)
(else
(let
((t1 (get r1 :ts)) (t2 (get r2 :ts)))
(cond
((> t1 t2) r1)
((< t1 t2) r2)
(else (if (>= (get r1 :actor) (get r2 :actor)) r1 r2))))))))
;; ── small set/dict helpers ──
(define
crdt-member?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (crdt-member? x (rest xs))))))
(define
crdt-dedup-loop
(fn
(xs seen)
(if
(= (len xs) 0)
(reverse seen)
(if
(crdt-member? (first xs) seen)
(crdt-dedup-loop (rest xs) seen)
(crdt-dedup-loop (rest xs) (cons (first xs) seen))))))
(define crdt-dedup (fn (xs) (crdt-dedup-loop xs (list))))
(define
crdt-union-keys
(fn (d1 d2) (crdt-dedup (append (keys d1) (keys d2)))))
;; ── element join ──
(define
crdt-merge-pos
(fn
(p1 p2)
(cond
((= p1 nil) p2)
((= p2 nil) p1)
((<= (crdt-pos-compare p1 p2) 0) p1)
(else p2))))
(define crdt-merge-type (fn (t1 t2) (if (= t1 nil) t2 t1)))
(define
crdt-merge-fields-loop
(fn
(names f1 f2 acc)
(if
(= (len names) 0)
acc
(let
((nm (first names)))
(crdt-merge-fields-loop
(rest names)
f1
f2
(assoc acc nm (crdt-reg-max (get f1 nm) (get f2 nm))))))))
(define
crdt-merge-fields
(fn
(f1 f2)
(crdt-merge-fields-loop (crdt-union-keys f1 f2) f1 f2 {})))
(define crdt-merge-element (fn (e1 e2) {:fields (crdt-merge-fields (get e1 :fields) (get e2 :fields)) :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))}))
;; ── state ──
(define crdt-empty (fn () {:elements {}}))
(define
crdt-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 (crdt-merge-element existing elem))))))))
(define
crdt-build-fields-loop
(fn
(pairs ts actor acc)
(if
(= (len pairs) 0)
acc
(crdt-build-fields-loop
(rest pairs)
ts
actor
(assoc acc (first (first pairs)) {:ts ts :actor actor :value (first (rest (first pairs)))})))))
(define
crdt-build-fields
(fn (pairs ts actor) (crdt-build-fields-loop pairs ts actor {})))
;; ── ops as partial-element contributions ──
(define
crdt-insert
(fn
(state id type pos fields ts actor)
(crdt-add-element state {:fields (crdt-build-fields fields ts actor) :id id :type type :deleted false :pos pos})))
(define
crdt-update
(fn (state id fname value ts actor) (crdt-add-element state {:fields (assoc {} fname {:ts ts :actor actor :value value}) :id id :type nil :deleted false :pos nil})))
(define crdt-delete (fn (state id) (crdt-add-element state {:fields {} :id id :type nil :deleted true :pos nil})))
;; ── state merge (join) ──
(define
crdt-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)))
(crdt-merge-loop
(rest ids)
ea
eb
(assoc
acc
id
(cond
((= x nil) y)
((= y nil) x)
(else (crdt-merge-element x y))))))))))
(define crdt-merge (fn (a b) {:elements (crdt-merge-loop (crdt-union-keys (get a :elements) (get b :elements)) (get a :elements) (get b :elements) {})}))
(define
crdt-merge-all
(fn
(states)
(if
(= (len states) 0)
(crdt-empty)
(if
(= (len states) 1)
(first states)
(crdt-merge (first states) (crdt-merge-all (rest states)))))))
;; ── op interpreter ──
(define crdt-op-insert (fn (id type pos fields ts actor) {:ts ts :fields fields :id id :type type :op "insert" :actor actor :pos pos}))
(define crdt-op-update (fn (id field value ts actor) {:ts ts :field field :id id :op "update" :actor actor :value value}))
(define crdt-op-delete (fn (id) {:id id :op "delete"}))
(define
crdt-apply
(fn
(state op)
(let
((k (get op :op)))
(cond
((= k "insert")
(crdt-insert
state
(get op :id)
(get op :type)
(get op :pos)
(get op :fields)
(get op :ts)
(get op :actor)))
((= k "update")
(crdt-update
state
(get op :id)
(get op :field)
(get op :value)
(get op :ts)
(get op :actor)))
((= k "delete") (crdt-delete state (get op :id)))
(else (error (str "unknown crdt op: " k)))))))
(define
crdt-apply-all
(fn
(state ops)
(if
(= (len ops) 0)
state
(crdt-apply-all (crdt-apply state (first ops)) (rest ops)))))
;; ── materialise to a Phase-1 document ──
(define
crdt-elements-list
(fn
(state)
(map
(fn (id) (get (get state :elements) id))
(keys (get state :elements)))))
(define
crdt-live?
(fn
(e)
(and
(= (get e :deleted) false)
(if (= (get e :pos) nil) false true)
(if (= (get e :type) nil) false true))))
(define
crdt-live-elements
(fn (state) (filter crdt-live? (crdt-elements-list state))))
(define
crdt-insert-sorted
(fn
(e sorted)
(cond
((= (len sorted) 0) (list e))
((< (crdt-pos-compare (get e :pos) (get (first sorted) :pos)) 0)
(cons e sorted))
(else (cons (first sorted) (crdt-insert-sorted e (rest sorted)))))))
(define
crdt-sort-by-pos
(fn
(elems)
(if
(= (len elems) 0)
(list)
(crdt-insert-sorted (first elems) (crdt-sort-by-pos (rest elems))))))
(define
crdt-field-pairs
(fn
(fields)
(map (fn (nm) (list nm (get (get fields nm) :value))) (keys fields))))
(define
crdt-element->block
(fn
(e)
(mk-block (get e :type) (get e :id) (crdt-field-pairs (get e :fields)))))
(define
crdt-order
(fn
(state)
(map
(fn (e) (get e :id))
(crdt-sort-by-pos (crdt-live-elements state)))))
(define
crdt-materialize
(fn
(doc-id state)
(doc-new
doc-id
(map crdt-element->block (crdt-sort-by-pos (crdt-live-elements state))))))

79
lib/content/data.sx Normal file
View File

@@ -0,0 +1,79 @@
;; content-on-sx — portable data serialization.
;;
;; Converts documents to/from a plain SX data form, decoupling storage and
;; transport from the Smalltalk instance shape. A document becomes
;; {:id :title :slug :tags :blocks (list block-data)}
;; and a block becomes {:id :type :fields {...}} (section children recurse).
;; content/from-data reconstructs real block objects.
;;
;; Requires (loaded by harness): block.sx, doc.sx, meta.sx, section.sx
;; (mk-section), table.sx (mk-table).
;; ── to-data ──
(define
content/-fd-loop
(fn
(ks ivs acc)
(if
(= (len ks) 0)
acc
(let
((k (first ks)))
(if
(= k "id")
(content/-fd-loop (rest ks) ivs acc)
(content/-fd-loop
(rest ks)
ivs
(assoc
acc
k
(if
(= k "children")
(map block->data (get ivs k))
(get ivs k)))))))))
(define block->data (fn (b) {:fields (content/-fd-loop (keys (get b :ivars)) (get b :ivars) {}) :id (blk-id b) :type (blk-type b)}))
(define content/to-data (fn (doc) {:blocks (map block->data (doc-blocks doc)) :slug (doc-slug doc) :id (doc-id doc) :title (doc-title doc) :tags (doc-tags doc)}))
;; ── from-data ──
(define
content/-field-pairs
(fn (fields) (map (fn (k) (list k (get fields k))) (keys fields))))
(define
data->block
(fn
(d)
(let
((type (get d :type)) (id (get d :id)) (fields (get d :fields)))
(cond
((= type "section")
(mk-section id (map data->block (get fields "children"))))
((= type "table")
(mk-table id (get fields "headers") (get fields "rows")))
(else (mk-block type id (content/-field-pairs fields)))))))
(define
content/-meta-of
(fn
(data)
(let
((m1 (if (= (get data :title) nil) {} (assoc {} :title (get data :title)))))
(let
((m2 (if (= (get data :slug) nil) m1 (assoc m1 :slug (get data :slug)))))
(let
((tags (get data :tags)))
(if
(or (= tags nil) (= (len tags) 0))
m2
(assoc m2 :tags tags)))))))
(define
content/from-data
(fn
(data)
(doc-with-meta
(doc-new (get data :id) (map data->block (get data :blocks)))
(content/-meta-of data))))

203
lib/content/doc.sx Normal file
View File

@@ -0,0 +1,203 @@
;; content-on-sx — ordered block document on Smalltalk-on-SX.
;;
;; A document (CtDoc) is a Smalltalk object holding an ordered sequence of block
;; objects. Editing is a stream of ops (data dicts); doc-apply interprets one op
;; and returns a NEW document — the input is never mutated, so any version is the
;; head of an op stream (replay-friendly for persist + CRDT merge).
;;
;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the
;; ergonomic API; they default nil and do not affect block operations.
;;
;; Op shapes (data, not objects — they are the persist event payload):
;; {:op "insert" :block <blk> :after <id|nil>} ; after nil = prepend
;; {:op "update" :id <id> :field <name> :value <v>}
;; {:op "move" :id <id> :index <n>}
;; {:op "delete" :id <id>}
(define
content-bootstrap-doc!
(fn
()
(begin
(st-class-define!
"CtDoc"
"Object"
(list "id" "blocks" "title" "slug" "tags"))
(ct-def-method! "CtDoc" "id" "id ^ id")
(ct-def-method! "CtDoc" "blocks" "blocks ^ blocks")
(ct-def-method! "CtDoc" "type" "type ^ #document")
(ct-def-method! "CtDoc" "title" "title ^ title")
(ct-def-method! "CtDoc" "slug" "slug ^ slug")
(ct-def-method! "CtDoc" "tags" "tags ^ tags")
true)))
;; ── construction ──
(define
doc-new
(fn
(id blocks)
(st-iv-set!
(st-iv-set! (st-make-instance "CtDoc") "id" id)
"blocks"
blocks)))
(define doc-empty (fn (id) (doc-new id (list))))
;; ── accessors (message dispatch) ──
(define doc-id (fn (doc) (st-send doc "id" (list))))
(define doc-type (fn (doc) (str (st-send doc "type" (list)))))
(define doc-blocks (fn (doc) (st-send doc "blocks" (list))))
(define doc-count (fn (doc) (len (doc-blocks doc))))
(define doc-block-at (fn (doc i) (nth (doc-blocks doc) i)))
(define doc? (fn (v) (and (st-instance? v) (= (get v :class) "CtDoc"))))
;; ── list helpers over block sequences ──
(define
ct-index-loop
(fn
(blocks id i)
(cond
((= (len blocks) 0) -1)
((= (blk-id (first blocks)) id) i)
(else (ct-index-loop (rest blocks) id (+ i 1))))))
(define ct-index-of (fn (blocks id) (ct-index-loop blocks id 0)))
(define
ct-insert-at
(fn
(blocks i x)
(cond
((= i 0) (cons x blocks))
((= (len blocks) 0) (list x))
(else
(cons
(first blocks)
(ct-insert-at (rest blocks) (- i 1) x))))))
(define
ct-remove-id
(fn
(blocks id)
(filter (fn (b) (if (= (blk-id b) id) false true)) blocks)))
(define
ct-replace-id
(fn
(blocks id f)
(map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks)))
;; ── query ──
(define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id)))
(define
doc-find
(fn
(doc id)
(let
((hits (filter (fn (b) (= (blk-id b) id)) (doc-blocks doc))))
(if (= (len hits) 0) nil (first hits)))))
(define
doc-has?
(fn (doc id) (if (= (doc-index-of doc id) -1) false true)))
;; ── structural edits (each returns a new document) ──
(define doc-with-blocks (fn (doc blocks) (st-iv-set! doc "blocks" blocks)))
(define
doc-append
(fn
(doc block)
(doc-with-blocks doc (append (doc-blocks doc) (list block)))))
(define
doc-insert-at
(fn
(doc block i)
(doc-with-blocks doc (ct-insert-at (doc-blocks doc) i block))))
(define
doc-insert-after
(fn
(doc block after-id)
(let
((blocks (doc-blocks doc)))
(if
(= after-id nil)
(doc-with-blocks doc (cons block blocks))
(let
((idx (ct-index-of blocks after-id)))
(if
(= idx -1)
(doc-with-blocks doc (append blocks (list block)))
(doc-with-blocks
doc
(ct-insert-at blocks (+ idx 1) block))))))))
(define
doc-update
(fn
(doc id field value)
(doc-with-blocks
doc
(ct-replace-id (doc-blocks doc) id (fn (b) (blk-set b field value))))))
(define
doc-delete
(fn (doc id) (doc-with-blocks doc (ct-remove-id (doc-blocks doc) id))))
(define
doc-move
(fn
(doc id i)
(let
((blk (doc-find doc id)))
(if
(= blk nil)
doc
(doc-with-blocks
doc
(ct-insert-at (ct-remove-id (doc-blocks doc) id) i blk))))))
;; ── op constructors (data payload, reused by persist op log) ──
(define op-insert (fn (block after) {:after after :op "insert" :block block}))
(define op-update (fn (id field value) {:field field :id id :op "update" :value value}))
(define op-move (fn (id index) {:id id :op "move" :index index}))
(define op-delete (fn (id) {:id id :op "delete"}))
;; ── op interpreter ──
(define
doc-apply
(fn
(doc op)
(let
((kind (get op :op)))
(cond
((= kind "insert")
(doc-insert-after doc (get op :block) (get op :after)))
((= kind "update")
(doc-update doc (get op :id) (get op :field) (get op :value)))
((= kind "move") (doc-move doc (get op :id) (get op :index)))
((= kind "delete") (doc-delete doc (get op :id)))
(else (error (str "unknown op: " kind)))))))
(define
doc-apply-all
(fn
(doc ops)
(if
(= (len ops) 0)
doc
(doc-apply-all (doc-apply doc (first ops)) (rest ops)))))
;; ── render-agnostic snapshot: list of (id . type) for assertions/debug ──
(define doc-ids (fn (doc) (map (fn (b) (blk-id b)) (doc-blocks doc))))
(define
doc-types
(fn (doc) (map (fn (b) (blk-type b)) (doc-blocks doc))))

68
lib/content/fed.sx Normal file
View File

@@ -0,0 +1,68 @@
;; content-on-sx — federated documents: trust-gated peer-authored ops.
;;
;; A peer-authored op carries provenance (:author, and a :sig stub). We never
;; auto-accept: a peer op is applied only if it passes a trust gate. The gate is
;; a predicate (fn op -> bool) so acl-on-sx can inject real trust facts later;
;; the convenience form takes an explicit trusted-actor list (the stub).
;;
;; Accepted ops flow through the CvRDT merge (Phase 3), so concurrent local and
;; external edits reconcile deterministically (same-field LWW, order-independent).
;;
;; Requires (loaded by harness): crdt.sx (and its deps).
;; tag an op with provenance
(define content/authored (fn (op author) (assoc op :author author)))
(define
content/signed
(fn (op author sig) (assoc (assoc op :author author) :sig sig)))
;; explicit trust stub: membership in a trusted-actor list
(define content/trusted? (fn (trust author) (crdt-member? author trust)))
;; general form: accept? is a predicate (fn op -> bool). Applies accepted ops
;; through the CRDT; quarantines the rest. Returns
;; {:state :accepted (ops) :rejected (ops)}.
(define
content/-merge-peer-loop
(fn
(state accept? ops accepted rejected)
(if
(= (len ops) 0)
{:state state :accepted (reverse accepted) :rejected (reverse rejected)}
(let
((op (first ops)))
(if
(accept? op)
(content/-merge-peer-loop
(crdt-apply state op)
accept?
(rest ops)
(cons op accepted)
rejected)
(content/-merge-peer-loop
state
accept?
(rest ops)
accepted
(cons op rejected)))))))
(define
content/merge-peer-with
(fn
(state accept? ops)
(content/-merge-peer-loop state accept? ops (list) (list))))
;; convenience: trust = list of trusted actor ids
(define
content/merge-peer
(fn
(state trust ops)
(content/merge-peer-with
state
(fn (op) (content/trusted? trust (get op :author)))
ops)))
(define content/accepted (fn (res) (get res :accepted)))
(define content/rejected (fn (res) (get res :rejected)))
(define content/peer-state (fn (res) (get res :state)))

View File

@@ -0,0 +1,31 @@
;; content-on-sx — global find/replace across text-bearing blocks.
;;
;; Replaces every occurrence of `from` with `to` in the text field of text /
;; heading / code / quote blocks, tree-wide (via the transform layer). For
;; renaming a term throughout a document. Immutable; case-sensitive.
;;
;; Requires (loaded by harness): block.sx, transform.sx (content/map-blocks).
(define
fr-in?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (fr-in? x (rest xs))))))
(define
fr-has-text?
(fn (b) (fr-in? (blk-type b) (list "text" "heading" "code" "quote"))))
(define
content/find-replace
(fn
(doc from to)
(content/map-blocks
doc
fr-has-text?
(fn
(b)
(blk-set b "text" (replace (str (blk-get b "text")) from to))))))

34
lib/content/flatten.sx Normal file
View File

@@ -0,0 +1,34 @@
;; content-on-sx — document flatten.
;;
;; Un-nests a sectioned document into a flat block sequence: each section is
;; replaced inline by its (recursively flattened) children, dropping the section
;; wrapper. The inverse of content/wrap-section, for flat export targets.
;; Immutable; inline tree handling (no section.sx dep).
;;
;; Requires (loaded by harness): block.sx, doc.sx.
(define
flat-section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define
flat-blocks
(fn
(blocks)
(if
(= (len blocks) 0)
(list)
(let
((b (first blocks)))
(append
(if
(flat-section? b)
(let
((ch (st-iv-get b "children")))
(if (list? ch) (flat-blocks ch) (list)))
(list b))
(flat-blocks (rest blocks)))))))
(define
content/flatten
(fn (doc) (doc-with-blocks doc (flat-blocks (doc-blocks doc)))))

51
lib/content/index.sx Normal file
View File

@@ -0,0 +1,51 @@
;; content-on-sx — multi-document index.
;;
;; Projects a list of documents into summary cards (the blog index page), with
;; tag filtering (category pages) and a tag cloud. Composes content/summary +
;; doc metadata.
;;
;; Requires (loaded by harness): summary.sx (content/summary), meta.sx (doc-tags).
(define
idx-in?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (idx-in? x (rest xs))))))
(define
idx-dedup
(fn
(xs seen)
(if
(= (len xs) 0)
(reverse seen)
(if
(idx-in? (first xs) seen)
(idx-dedup (rest xs) seen)
(idx-dedup (rest xs) (cons (first xs) seen))))))
(define content/index (fn (docs) (map content/summary docs)))
(define content/has-tag? (fn (doc tag) (idx-in? tag (doc-tags doc))))
(define
content/index-by-tag
(fn
(docs tag)
(map content/summary (filter (fn (d) (content/has-tag? d tag)) docs))))
(define
content/all-tags
(fn (docs) (idx-dedup (ct-flatmap-tags docs) (list))))
(define
ct-flatmap-tags
(fn
(docs)
(if
(= (len docs) 0)
(list)
(append (doc-tags (first docs)) (ct-flatmap-tags (rest docs))))))

55
lib/content/markdown.sx Normal file
View File

@@ -0,0 +1,55 @@
;; content-on-sx — Markdown render mode.
;;
;; A third boundary format alongside asHTML / asSx, via the same polymorphic
;; dispatch. The newline is supplied by the boundary as a keyword arg
;; (asMarkdown: nl) because this Smalltalk dialect has no Character newline
;; constructor — blocks that need internal newlines (code, lists, doc) use it.
;;
;; No Markdown escaping yet (Markdown's escaping rules differ from HTML); raw
;; text is emitted. Ordered lists emit "1." for every item (Markdown renumbers).
;;
;; Requires (loaded by harness): block.sx, doc.sx.
(define
content-bootstrap-markdown!
(fn
()
(begin
(ct-def-method!
"CtHeading"
"asMarkdown:"
"asMarkdown: nl | h i | h := ''. i := 0. [i < level] whileTrue: [h := h , '#'. i := i + 1]. ^ h , ' ' , text")
(ct-def-method! "CtText" "asMarkdown:" "asMarkdown: nl ^ text")
(ct-def-method!
"CtCode"
"asMarkdown:"
"asMarkdown: nl ^ '```' , language , nl , text , nl , '```'")
(ct-def-method! "CtQuote" "asMarkdown:" "asMarkdown: nl ^ '> ' , text")
(ct-def-method!
"CtImage"
"asMarkdown:"
"asMarkdown: nl ^ '![' , alt , '](' , src , ')'")
(ct-def-method!
"CtEmbed"
"asMarkdown:"
"asMarkdown: nl ^ '[embed](' , url , ')'")
(ct-def-method! "CtDivider" "asMarkdown:" "asMarkdown: nl ^ '---'")
(ct-def-method!
"CtList"
"asMarkdown:"
"asMarkdown: nl | mark | mark := ordered ifTrue: ['1. '] ifFalse: ['- ']. ^ (items inject: '' into: [:a :x | a , (a = '' ifTrue: [''] ifFalse: [nl]) , mark , x])")
(ct-def-method!
"CtDoc"
"asMarkdown:"
"asMarkdown: nl ^ (blocks inject: '' into: [:a :b | a , (a = '' ifTrue: [''] ifFalse: [nl , nl]) , (b asMarkdown: nl)])")
true)))
(define ct-nl (str "\n"))
;; ── SX boundary ──
(define
asMarkdown
(fn (node) (str (st-send node "asMarkdown:" (list ct-nl)))))
(define content/markdown asMarkdown)
(define render-markdown asMarkdown)
(define block-markdown asMarkdown)

63
lib/content/md-doc.sx Normal file
View File

@@ -0,0 +1,63 @@
;; content-on-sx — Markdown document export (frontmatter + body).
;;
;; content/markdown-doc emits a YAML-ish --- frontmatter block from the document
;; metadata (title/slug/tags) followed by the Markdown body, completing the
;; metadata round-trip with md/import (md/import ∘ content/markdown-doc keeps
;; title/slug/tags). With no metadata it is just asMarkdown.
;;
;; Requires (loaded by harness): doc.sx, meta.sx (doc-title/slug/tags),
;; markdown.sx (asMarkdown).
(define mdd-nl (str "\n"))
(define
mdd-join
(fn
(sep parts)
(cond
((= (len parts) 0) "")
((= (len parts) 1) (first parts))
(else (str (first parts) sep (mdd-join sep (rest parts)))))))
(define
content/-fm-parts
(fn
(doc)
(append
(append
(if
(= (doc-title doc) nil)
(list)
(list (str "title: " (doc-title doc))))
(if
(= (doc-slug doc) nil)
(list)
(list (str "slug: " (doc-slug doc)))))
(let
((tags (doc-tags doc)))
(if
(= (len tags) 0)
(list)
(list (str "tags: " (mdd-join ", " tags))))))))
(define
content/-frontmatter
(fn
(doc)
(let
((parts (content/-fm-parts doc)))
(if
(= (len parts) 0)
""
(str "---" mdd-nl (mdd-join mdd-nl parts) mdd-nl "---")))))
(define
content/markdown-doc
(fn
(doc)
(let
((fm (content/-frontmatter doc)))
(if
(= fm "")
(asMarkdown doc)
(str fm mdd-nl mdd-nl (asMarkdown doc))))))

449
lib/content/md-import.sx Normal file
View File

@@ -0,0 +1,449 @@
;; content-on-sx — Markdown import adapter (markdown text -> block document).
;;
;; A line-based parser, the inverse of markdown.sx's asMarkdown. Confined to the
;; adapter boundary: the core knows nothing about Markdown. Handles a leading
;; --- frontmatter block (key: value -> doc metadata), ATX headings (#..######),
;; fenced code (```lang), blockquotes (> ), unordered (- / * ) and ordered (1. )
;; lists, thematic breaks (--- / ***), pipe tables (header + --- separator +
;; body), and paragraphs (consecutive plain lines joined with a space). Block ids
;; are assigned sequentially b0,b1…
;;
;; Requires (loaded by harness): block.sx, doc.sx, table.sx (mk-table),
;; meta.sx (doc-with-meta); markdown.sx for the adapter's export side.
(define md/-id (fn (i) (str "b" i)))
(define md/-blank? (fn (s) (= s "")))
(define md/-hr? (fn (s) (if (= s "---") true (= s "***"))))
(define
ct-in?
(fn
(x xs)
(cond
((= (len xs) 0) false)
((= (first xs) x) true)
(else (ct-in? x (rest xs))))))
(define
ct-starts-with?
(fn
(s prefix)
(and
(>= (string-length s) (string-length prefix))
(= (substring s 0 (string-length prefix)) prefix))))
(define
md/-drop
(fn (s prefix) (substring s (string-length prefix) (string-length s))))
(define
md/-drop-n
(fn
(xs n)
(if
(= n 0)
xs
(if
(= (len xs) 0)
xs
(md/-drop-n (rest xs) (- n 1))))))
(define
md/-join-with
(fn
(sep parts)
(cond
((= (len parts) 0) "")
((= (len parts) 1) (first parts))
(else (str (first parts) sep (md/-join-with sep (rest parts)))))))
(define md/-join-sp (fn (parts) (md/-join-with " " parts)))
(define md/-join-nl (fn (parts) (md/-join-with (str "\n") parts)))
;; ── heading detection (leading #s then a space) ──
(define
md/-hashes
(fn
(s n)
(if
(and
(< n (string-length s))
(= (substring s n (+ n 1)) "#"))
(md/-hashes s (+ n 1))
n)))
(define
md/-heading?
(fn
(line)
(let
((n (md/-hashes line 0)))
(and
(> n 0)
(<= n 6)
(> (string-length line) n)
(= (substring line n (+ n 1)) " ")))))
(define
md/-heading-block
(fn
(line i)
(let
((n (md/-hashes line 0)))
(mk-heading
(md/-id i)
n
(substring line (+ n 1) (string-length line))))))
;; ── list detection ──
(define
ct-digit?
(fn (ch) (ct-in? ch (list "0" "1" "2" "3" "4" "5" "6" "7" "8" "9"))))
(define
md/-digits
(fn
(s n)
(if
(and
(< n (string-length s))
(ct-digit? (substring s n (+ n 1))))
(md/-digits s (+ n 1))
n)))
(define
md/-ol?
(fn
(line)
(let
((n (md/-digits line 0)))
(and
(> n 0)
(>= (string-length line) (+ n 2))
(= (substring line n (+ n 2)) ". ")))))
(define
md/-drop-ol
(fn
(line)
(let
((n (md/-digits line 0)))
(substring line (+ n 2) (string-length line)))))
(define
md/-ul?
(fn
(line)
(if (ct-starts-with? line "- ") true (ct-starts-with? line "* "))))
(define
md/-drop-ul
(fn (line) (substring line 2 (string-length line))))
;; ── table detection ──
(define md/-pipe-row? (fn (line) (ct-starts-with? (trim line) "|")))
(define md/-sep-char? (fn (ch) (ct-in? ch (list "-" ":" "|" " "))))
(define
md/-all-sep?
(fn
(s i)
(if
(>= i (string-length s))
true
(if
(md/-sep-char? (substring s i (+ i 1)))
(md/-all-sep? s (+ i 1))
false))))
(define
md/-has-dash?
(fn
(s i)
(if
(>= i (string-length s))
false
(if
(= (substring s i (+ i 1)) "-")
true
(md/-has-dash? s (+ i 1))))))
(define
md/-sep-row?
(fn
(line)
(and
(md/-pipe-row? line)
(md/-all-sep? (trim line) 0)
(md/-has-dash? line 0))))
(define
md/-table-start?
(fn
(lines)
(and
(md/-pipe-row? (first lines))
(> (len lines) 1)
(md/-sep-row? (nth lines 1)))))
(define
md/-strip-pipes
(fn
(s0)
(let
((s (trim s0)))
(let
((a (if (ct-starts-with? s "|") (substring s 1 (string-length s)) s)))
(if
(and
(> (string-length a) 0)
(=
(substring
a
(- (string-length a) 1)
(string-length a))
"|"))
(substring a 0 (- (string-length a) 1))
a)))))
(define
md/-cells
(fn (line) (map (fn (c) (trim c)) (split (md/-strip-pipes line) "|"))))
(define
md/-plain?
(fn
(line)
(if
(md/-blank? line)
false
(if
(ct-starts-with? line "```")
false
(if
(md/-heading? line)
false
(if
(ct-starts-with? line "> ")
false
(if
(md/-hr? line)
false
(if (md/-ul? line) false (if (md/-ol? line) false true)))))))))
;; ── multi-line collectors ──
(define
md/-code
(fn
(lines i acc)
(md/-code-collect
(rest lines)
(md/-drop (first lines) "```")
(list)
i
acc)))
(define
md/-code-collect
(fn
(lines lang body i acc)
(cond
((= (len lines) 0)
(md/-walk
lines
(+ i 1)
(cons (mk-code (md/-id i) lang (md/-join-nl (reverse body))) acc)))
((= (first lines) "```")
(md/-walk
(rest lines)
(+ i 1)
(cons (mk-code (md/-id i) lang (md/-join-nl (reverse body))) acc)))
(else
(md/-code-collect (rest lines) lang (cons (first lines) body) i acc)))))
(define
md/-table-body
(fn
(lines headers rows i acc)
(if
(= (len lines) 0)
(md/-walk
lines
(+ i 1)
(cons (mk-table (md/-id i) headers (reverse rows)) acc))
(let
((line (first lines)))
(if
(md/-pipe-row? line)
(md/-table-body
(rest lines)
headers
(cons (md/-cells line) rows)
i
acc)
(md/-walk
lines
(+ i 1)
(cons (mk-table (md/-id i) headers (reverse rows)) acc)))))))
(define
md/-table
(fn
(lines i acc)
(md/-table-body
(rest (rest lines))
(md/-cells (first lines))
(list)
i
acc)))
(define
md/-list-collect
(fn
(lines items i acc ordered)
(if
(= (len lines) 0)
(md/-walk
lines
(+ i 1)
(cons (mk-list (md/-id i) ordered (reverse items)) acc))
(let
((line (first lines)))
(cond
(ordered
(if
(md/-ol? line)
(md/-list-collect
(rest lines)
(cons (md/-drop-ol line) items)
i
acc
ordered)
(md/-walk
lines
(+ i 1)
(cons (mk-list (md/-id i) ordered (reverse items)) acc))))
(else
(if
(md/-ul? line)
(md/-list-collect
(rest lines)
(cons (md/-drop-ul line) items)
i
acc
ordered)
(md/-walk
lines
(+ i 1)
(cons (mk-list (md/-id i) ordered (reverse items)) acc)))))))))
(define
md/-para-collect
(fn
(lines parts i acc)
(if
(= (len lines) 0)
(md/-walk
lines
(+ i 1)
(cons (mk-text (md/-id i) (md/-join-sp (reverse parts))) acc))
(let
((line (first lines)))
(if
(md/-plain? line)
(md/-para-collect (rest lines) (cons line parts) i acc)
(md/-walk
lines
(+ i 1)
(cons (mk-text (md/-id i) (md/-join-sp (reverse parts))) acc)))))))
;; ── main walk ──
(define
md/-walk
(fn
(lines i acc)
(if
(= (len lines) 0)
(reverse acc)
(let
((line (first lines)))
(cond
((md/-blank? line) (md/-walk (rest lines) i acc))
((ct-starts-with? line "```") (md/-code lines i acc))
((md/-heading? line)
(md/-walk
(rest lines)
(+ i 1)
(cons (md/-heading-block line i) acc)))
((ct-starts-with? line "> ")
(md/-walk
(rest lines)
(+ i 1)
(cons (mk-quote (md/-id i) "" (md/-drop line "> ")) acc)))
((md/-hr? line)
(md/-walk
(rest lines)
(+ i 1)
(cons (mk-divider (md/-id i)) acc)))
((md/-table-start? lines) (md/-table lines i acc))
((md/-ul? line) (md/-list-collect lines (list) i acc false))
((md/-ol? line) (md/-list-collect lines (list) i acc true))
(else (md/-para-collect lines (list) i acc)))))))
(define
md/parse
(fn (text) (md/-walk (split text (str "\n")) 0 (list))))
;; ── frontmatter (leading --- key: value --- block) ──
(define
md/-frontmatter?
(fn (lines) (and (> (len lines) 0) (= (first lines) "---"))))
(define
md/-fm-end
(fn
(lines i)
(cond
((>= i (len lines)) -1)
((= (nth lines i) "---") i)
(else (md/-fm-end lines (+ i 1))))))
(define
md/-fm-add
(fn
(acc line)
(let
((parts (split line ":")))
(if
(< (len parts) 2)
acc
(let
((key (trim (first parts)))
(val (trim (md/-join-with ":" (rest parts)))))
(cond
((= key "title") (assoc acc :title val))
((= key "slug") (assoc acc :slug val))
((= key "tags")
(assoc acc :tags (map (fn (t) (trim t)) (split val ","))))
(else acc)))))))
(define
md/-fm-pairs
(fn
(lines start end acc)
(if
(>= start end)
acc
(md/-fm-pairs
lines
(+ start 1)
end
(md/-fm-add acc (nth lines start))))))
;; ── adapter ──
(define
md/import
(fn
(text doc-id)
(let
((lines (split text (str "\n"))))
(if
(md/-frontmatter? lines)
(let
((end (md/-fm-end lines 1)))
(if
(= end -1)
(doc-new doc-id (md/-walk lines 0 (list)))
(doc-with-meta
(doc-new
doc-id
(md/-walk
(md/-drop-n lines (+ end 1))
0
(list)))
(md/-fm-pairs lines 1 end {}))))
(doc-new doc-id (md/-walk lines 0 (list)))))))
(define content/from-markdown md/import)
(define markdown-adapter {:export (fn (doc) (asMarkdown doc)) :import md/import})

52
lib/content/media.sx Normal file
View File

@@ -0,0 +1,52 @@
;; content-on-sx — video/audio media block.
;;
;; CtMedia holds a `kind` (video/audio) and `src`. Self-contained: answers
;; asHTML/asSx/asText/asMarkdown: so it composes with the render boundary with no
;; changes elsewhere. HTML src is htmlEscaped, SX src sxEscaped.
;;
;; Requires (loaded by harness): block.sx, doc.sx, render.sx (escapers);
;; markdown.sx / text.sx for those formats.
(define
content-bootstrap-media!
(fn
()
(begin
(st-class-define! "CtMedia" "CtBlock" (list "kind" "src"))
(ct-def-method! "CtMedia" "kind" "kind ^ kind")
(ct-def-method! "CtMedia" "src" "src ^ src")
(ct-def-method! "CtMedia" "type" "type ^ #media")
(ct-def-method!
"CtMedia"
"asHTML"
"asHTML ^ '<' , kind , ' src=\"' , src htmlEscaped , '\" controls></' , kind , '>'")
(ct-def-method!
"CtMedia"
"asSx"
"asSx ^ '(' , kind , ' :src \"' , src sxEscaped , '\")'")
(ct-def-method! "CtMedia" "asText" "asText ^ ''")
(ct-def-method!
"CtMedia"
"asMarkdown:"
"asMarkdown: nl ^ '[' , kind , '](' , src , ')'")
true)))
(define
mk-media
(fn
(id kind src)
(st-iv-set!
(st-iv-set!
(st-iv-set! (st-make-instance "CtMedia") "id" id)
"kind"
kind)
"src"
src)))
(define
media?
(fn (b) (and (st-instance? b) (= (get b :class) "CtMedia"))))
(define media-kind (fn (b) (st-send b "kind" (list))))
(define mk-video (fn (id src) (mk-media id "video" src)))
(define mk-audio (fn (id src) (mk-media id "audio" src)))

53
lib/content/meta.sx Normal file
View File

@@ -0,0 +1,53 @@
;; content-on-sx — document metadata (title / slug / tags).
;;
;; CtDoc carries optional metadata alongside its blocks (ivars declared in
;; doc.sx). Reads go through message dispatch; setters are copy-on-write
;; (functional st-iv-set!), consistent with the immutable document model.
;;
;; Requires (loaded by harness): block.sx, doc.sx.
;; ── reads ──
(define doc-title (fn (doc) (st-send doc "title" (list))))
(define doc-slug (fn (doc) (st-send doc "slug" (list))))
(define
doc-tags
(fn
(doc)
(let ((t (st-send doc "tags" (list)))) (if (= t nil) (list) t))))
(define doc-meta (fn (doc) {:slug (doc-slug doc) :id (doc-id doc) :title (doc-title doc) :tags (doc-tags doc)}))
;; ── copy-on-write setters ──
(define doc-with-title (fn (doc title) (st-iv-set! doc "title" title)))
(define doc-with-slug (fn (doc slug) (st-iv-set! doc "slug" slug)))
(define doc-with-tags (fn (doc tags) (st-iv-set! doc "tags" tags)))
(define
doc-add-tag
(fn (doc tag) (doc-with-tags doc (append (doc-tags doc) (list tag)))))
;; set several at once: meta is a dict with optional :title :slug :tags
(define
doc-with-meta
(fn
(doc meta)
(let
((d1 (if (has-key? meta :title) (doc-with-title doc (get meta :title)) doc)))
(let
((d2 (if (has-key? meta :slug) (doc-with-slug d1 (get meta :slug)) d1)))
(if (has-key? meta :tags) (doc-with-tags d2 (get meta :tags)) d2)))))
;; constructor with metadata
(define
doc-new-meta
(fn (id blocks meta) (doc-with-meta (doc-new id blocks) meta)))
;; ── content/* facade aliases ──
(define content/title doc-title)
(define content/slug doc-slug)
(define content/tags doc-tags)
(define content/meta doc-meta)
(define content/with-title doc-with-title)
(define content/with-slug doc-with-slug)
(define content/with-tags doc-with-tags)
(define content/with-meta doc-with-meta)

69
lib/content/move.sx Normal file
View File

@@ -0,0 +1,69 @@
;; content-on-sx — relative block reorder.
;;
;; Move a top-level block to just before / after another block by id — more
;; ergonomic than the index-based doc-move. No-op if either id is missing.
;; Immutable; composes the doc.sx list helpers.
;;
;; Requires (loaded by harness): doc.sx.
(define
content/move-before
(fn
(doc id target)
(let
((blk (doc-find doc id)))
(if
(= blk nil)
doc
(let
((without (ct-remove-id (doc-blocks doc) id)))
(let
((idx (ct-index-of without target)))
(if
(= idx -1)
doc
(doc-with-blocks doc (ct-insert-at without idx blk)))))))))
(define
content/move-after
(fn
(doc id target)
(let
((blk (doc-find doc id)))
(if
(= blk nil)
doc
(let
((without (ct-remove-id (doc-blocks doc) id)))
(let
((idx (ct-index-of without target)))
(if
(= idx -1)
doc
(doc-with-blocks
doc
(ct-insert-at without (+ idx 1) blk)))))))))
(define
content/move-to-front
(fn
(doc id)
(let
((blk (doc-find doc id)))
(if
(= blk nil)
doc
(doc-with-blocks doc (cons blk (ct-remove-id (doc-blocks doc) id)))))))
(define
content/move-to-back
(fn
(doc id)
(let
((blk (doc-find doc id)))
(if
(= blk nil)
doc
(doc-with-blocks
doc
(append (ct-remove-id (doc-blocks doc) id) (list blk)))))))

49
lib/content/normalize.sx Normal file
View File

@@ -0,0 +1,49 @@
;; content-on-sx — document normalization.
;;
;; A cleanup pass: drop empty text blocks and empty sections across the tree.
;; Sections are normalised first, so a section that becomes empty (all children
;; dropped) is itself dropped. For tidying imported/edited documents. Immutable.
;; Inline tree handling (no section.sx dep).
;;
;; Requires (loaded by harness): block.sx, doc.sx.
(define
norm-section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define
norm-empty-text?
(fn (b) (and (= (blk-type b) "text") (= (str (blk-get b "text")) ""))))
(define
norm-empty-section?
(fn
(b)
(and
(norm-section? b)
(let
((ch (st-iv-get b "children")))
(or (= ch nil) (= (len ch) 0))))))
(define
norm-recurse
(fn
(b)
(if
(norm-section? b)
(let
((ch (st-iv-get b "children")))
(if (list? ch) (st-iv-set! b "children" (norm-blocks ch)) b))
b)))
(define
norm-keep?
(fn
(b)
(if (norm-empty-text? b) false (if (norm-empty-section? b) false true))))
(define
norm-blocks
(fn (blocks) (filter norm-keep? (map norm-recurse blocks))))
(define
content/normalize
(fn (doc) (doc-with-blocks doc (norm-blocks (doc-blocks doc)))))

34
lib/content/outline.sx Normal file
View File

@@ -0,0 +1,34 @@
;; content-on-sx — nested document outline.
;;
;; Builds a hierarchical heading tree from content/headings: each node is
;; {:id :text :level :children}, where a heading nests under the nearest
;; preceding heading of a lower level. The structured companion to the flat TOC,
;; for rendering nested navigation.
;;
;; Requires (loaded by harness): query.sx (content/headings).
;; consume a prefix of `hs` forming nodes whose level > minlevel; return
;; {:nodes ... :rest ...}.
(define
ol-forest
(fn
(hs minlevel)
(if
(= (len hs) 0)
{:rest (list) :nodes (list)}
(let
((h (first hs)))
(if
(<= (get h :level) minlevel)
{:rest hs :nodes (list)}
(let
((sub (ol-forest (rest hs) (get h :level))))
(let
((node {:id (get h :id) :text (get h :text) :children (get sub :nodes) :level (get h :level)}))
(let
((more (ol-forest (get sub :rest) minlevel)))
{:rest (get more :rest) :nodes (cons node (get more :nodes))}))))))))
(define
content/outline
(fn (doc) (get (ol-forest (content/headings doc) 0) :nodes)))

23
lib/content/page-full.sx Normal file
View File

@@ -0,0 +1,23 @@
;; content-on-sx — SEO-complete HTML page.
;;
;; content/page-full extends content/page with a lang attribute and a
;; <meta name="description"> drawn from the document excerpt (plain text,
;; truncated). Composes the page, metadata and text layers.
;;
;; Requires (loaded by harness): page.sx (ct-html-escape, content/page-title),
;; text.sx (content/excerpt), render.sx (asHTML).
(define CONTENT-EXCERPT-LEN 160)
(define
content/page-full
(fn
(doc)
(str
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>"
(ct-html-escape (content/page-title doc))
"</title><meta name=\"description\" content=\""
(ct-html-escape (content/excerpt doc CONTENT-EXCERPT-LEN))
"\"></head><body>"
(asHTML doc)
"</body></html>")))

26
lib/content/page.sx Normal file
View File

@@ -0,0 +1,26 @@
;; content-on-sx — full HTML page wrapper.
;;
;; content/page composes the metadata + render layers into the shippable
;; artifact the blog serves: a minimal valid HTML5 document with an escaped
;; <title> (from doc metadata, falling back to the id) and the rendered blocks
;; as the body.
;;
;; Requires (loaded by harness): doc.sx, render.sx (asHTML + htmlEscaped),
;; meta.sx (doc-title).
(define ct-html-escape (fn (s) (str (st-send s "htmlEscaped" (list)))))
(define
content/page-title
(fn (doc) (let ((t (doc-title doc))) (if (= t nil) (doc-id doc) t))))
(define
content/page
(fn
(doc)
(str
"<!doctype html><html><head><meta charset=\"utf-8\"><title>"
(ct-html-escape (content/page-title doc))
"</title></head><body>"
(asHTML doc)
"</body></html>")))

51
lib/content/query.sx Normal file
View File

@@ -0,0 +1,51 @@
;; content-on-sx — block query + table of contents.
;;
;; Collect blocks across the whole tree (descending into sections) by predicate
;; or type, and derive a table of contents from headings. Tree detection is
;; inline (class + st-iv-get) so this needs no section.sx.
;;
;; Requires (loaded by harness): block.sx, doc.sx.
(define
qry-section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define
qry-tree
(fn
(blocks)
(if
(= (len blocks) 0)
(list)
(let
((b (first blocks)))
(append
(cons
b
(if
(qry-section? b)
(let
((ch (st-iv-get b "children")))
(if (list? ch) (qry-tree ch) (list)))
(list)))
(qry-tree (rest blocks)))))))
(define
content/select
(fn (doc pred) (filter pred (qry-tree (doc-blocks doc)))))
(define
content/select-type
(fn (doc type) (content/select doc (fn (b) (= (blk-type b) type)))))
(define
content/count-type
(fn (doc type) (len (content/select-type doc type))))
(define
content/select-ids
(fn (doc pred) (map (fn (b) (blk-id b)) (content/select doc pred))))
;; table of contents: {:id :level :text} for every heading, in document order.
(define
content/headings
(fn (doc) (map (fn (b) {:id (blk-id b) :text (blk-get b "text") :level (blk-get b "level")}) (content/select-type doc "heading"))))

99
lib/content/render.sx Normal file
View File

@@ -0,0 +1,99 @@
;; content-on-sx — render boundary.
;;
;; Rendering is a message, not a property switch: every block (and the document)
;; answers asHTML and asSx. The internal model carries no presentation — the
;; boundary format is chosen by which message you send. The document folds its
;; children's renderings, so (asHTML doc) / (asSx doc) are pure polymorphic
;; sends with no type dispatch in the SX layer.
;;
;; Escaping happens HERE, at the boundary. asHTML routes text/attrs through
;; String>>htmlEscaped (& < > "); asSx routes them through String>>sxEscaped
;; (\ and ") so values cannot break out of an element or an SX string literal.
(define
content-bootstrap-render!
(fn
()
(begin
(ct-def-method!
"String"
"htmlEscaped"
"htmlEscaped | out i n c | out := ''. n := self size. i := 1. [i <= n] whileTrue: [c := self at: i. (c = $&) ifTrue: [out := out , '&amp;'] ifFalse: [(c = $<) ifTrue: [out := out , '&lt;'] ifFalse: [(c = $>) ifTrue: [out := out , '&gt;'] ifFalse: [(c = $\") ifTrue: [out := out , '&quot;'] ifFalse: [out := out , c asString]]]]. i := i + 1]. ^ out")
(ct-def-method!
"String"
"sxEscaped"
"sxEscaped | out i n c | out := ''. n := self size. i := 1. [i <= n] whileTrue: [c := self at: i. (c = $\\) ifTrue: [out := out , '\\\\'] ifFalse: [(c = $\") ifTrue: [out := out , '\\\"'] ifFalse: [out := out , c asString]]. i := i + 1]. ^ out")
(ct-def-method!
"CtHeading"
"asHTML"
"asHTML | t | t := level printString. ^ '<h' , t , '>' , text htmlEscaped , '</h' , t , '>'")
(ct-def-method!
"CtText"
"asHTML"
"asHTML ^ '<p>' , text htmlEscaped , '</p>'")
(ct-def-method!
"CtCode"
"asHTML"
"asHTML ^ '<pre><code class=\"language-' , language htmlEscaped , '\">' , text htmlEscaped , '</code></pre>'")
(ct-def-method!
"CtQuote"
"asHTML"
"asHTML ^ '<blockquote>' , text htmlEscaped , '</blockquote>'")
(ct-def-method!
"CtImage"
"asHTML"
"asHTML ^ '<img src=\"' , src htmlEscaped , '\" alt=\"' , alt htmlEscaped , '\">'")
(ct-def-method!
"CtEmbed"
"asHTML"
"asHTML ^ '<iframe src=\"' , url htmlEscaped , '\"></iframe>'")
(ct-def-method! "CtDivider" "asHTML" "asHTML ^ '<hr>'")
(ct-def-method!
"CtList"
"asHTML"
"asHTML | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '<' , tag , '>' , (items inject: '' into: [:a :x | a , '<li>' , x htmlEscaped , '</li>']) , '</' , tag , '>'")
(ct-def-method!
"CtDoc"
"asHTML"
"asHTML ^ blocks inject: '' into: [:a :b | a , (b asHTML)]")
(ct-def-method!
"CtHeading"
"asSx"
"asSx | t | t := level printString. ^ '(h' , t , ' \"' , text sxEscaped , '\")'")
(ct-def-method! "CtText" "asSx" "asSx ^ '(p \"' , text sxEscaped , '\")'")
(ct-def-method!
"CtCode"
"asSx"
"asSx ^ '(pre (code \"' , text sxEscaped , '\"))'")
(ct-def-method!
"CtQuote"
"asSx"
"asSx ^ '(blockquote \"' , text sxEscaped , '\")'")
(ct-def-method!
"CtImage"
"asSx"
"asSx ^ '(img :src \"' , src sxEscaped , '\" :alt \"' , alt sxEscaped , '\")'")
(ct-def-method!
"CtEmbed"
"asSx"
"asSx ^ '(iframe :src \"' , url sxEscaped , '\")'")
(ct-def-method! "CtDivider" "asSx" "asSx ^ '(hr)'")
(ct-def-method!
"CtList"
"asSx"
"asSx | tag | tag := ordered ifTrue: ['ol'] ifFalse: ['ul']. ^ '(' , tag , ' ' , (items inject: '' into: [:a :x | a , '(li \"' , x sxEscaped , '\")']) , ')'")
(ct-def-method!
"CtDoc"
"asSx"
"asSx ^ '(article ' , (blocks inject: '' into: [:a :b | a , (b asSx)]) , ')'")
true)))
;; ── SX boundary API — pure message sends ──
(define asHTML (fn (node) (str (st-send node "asHTML" (list)))))
(define asSx (fn (node) (str (st-send node "asSx" (list)))))
;; readable aliases
(define render-html asHTML)
(define render-sx asSx)
(define block-html asHTML)
(define block-sx asSx)

View File

@@ -0,0 +1,48 @@
{
"suites": {
"block": {"pass": 38, "fail": 0},
"doc": {"pass": 40, "fail": 0},
"render": {"pass": 42, "fail": 0},
"api": {"pass": 26, "fail": 0},
"meta": {"pass": 27, "fail": 0},
"page": {"pass": 7, "fail": 0},
"page-full": {"pass": 4, "fail": 0},
"markdown": {"pass": 20, "fail": 0},
"text": {"pass": 20, "fail": 0},
"section": {"pass": 25, "fail": 0},
"compose": {"pass": 17, "fail": 0},
"tree-edit": {"pass": 17, "fail": 0},
"move": {"pass": 11, "fail": 0},
"clone": {"pass": 10, "fail": 0},
"query": {"pass": 13, "fail": 0},
"toc": {"pass": 8, "fail": 0},
"anchor": {"pass": 6, "fail": 0},
"outline": {"pass": 14, "fail": 0},
"flatten": {"pass": 10, "fail": 0},
"transform": {"pass": 12, "fail": 0},
"normalize": {"pass": 11, "fail": 0},
"find-replace": {"pass": 10, "fail": 0},
"stats": {"pass": 17, "fail": 0},
"summary": {"pass": 14, "fail": 0},
"index": {"pass": 13, "fail": 0},
"table": {"pass": 15, "fail": 0},
"callout": {"pass": 12, "fail": 0},
"media": {"pass": 15, "fail": 0},
"data": {"pass": 25, "fail": 0},
"wire": {"pass": 11, "fail": 0},
"validate": {"pass": 23, "fail": 0},
"store": {"pass": 33, "fail": 0},
"snapshot": {"pass": 20, "fail": 0},
"crdt": {"pass": 34, "fail": 0},
"crdt-tree": {"pass": 21, "fail": 0},
"crdt-blocks": {"pass": 7, "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": 746,
"total_fail": 0,
"total": 746
}

48
lib/content/scoreboard.md Normal file
View File

@@ -0,0 +1,48 @@
# content-on-sx Conformance Scoreboard
_Generated by `lib/content/conformance.sh`_
| Suite | Pass | Fail | Total |
|-------|-----:|-----:|------:|
| block | 38 | 0 | 38 |
| doc | 40 | 0 | 40 |
| render | 42 | 0 | 42 |
| api | 26 | 0 | 26 |
| meta | 27 | 0 | 27 |
| page | 7 | 0 | 7 |
| page-full | 4 | 0 | 4 |
| markdown | 20 | 0 | 20 |
| text | 20 | 0 | 20 |
| section | 25 | 0 | 25 |
| compose | 17 | 0 | 17 |
| tree-edit | 17 | 0 | 17 |
| move | 11 | 0 | 11 |
| clone | 10 | 0 | 10 |
| query | 13 | 0 | 13 |
| toc | 8 | 0 | 8 |
| anchor | 6 | 0 | 6 |
| outline | 14 | 0 | 14 |
| flatten | 10 | 0 | 10 |
| transform | 12 | 0 | 12 |
| normalize | 11 | 0 | 11 |
| find-replace | 10 | 0 | 10 |
| stats | 17 | 0 | 17 |
| summary | 14 | 0 | 14 |
| index | 13 | 0 | 13 |
| table | 15 | 0 | 15 |
| callout | 12 | 0 | 12 |
| media | 15 | 0 | 15 |
| data | 25 | 0 | 25 |
| wire | 11 | 0 | 11 |
| validate | 23 | 0 | 23 |
| store | 33 | 0 | 33 |
| snapshot | 20 | 0 | 20 |
| crdt | 34 | 0 | 34 |
| crdt-tree | 21 | 0 | 21 |
| crdt-blocks | 7 | 0 | 7 |
| crdt-store | 14 | 0 | 14 |
| sync | 14 | 0 | 14 |
| md-import | 38 | 0 | 38 |
| md-doc | 12 | 0 | 12 |
| fed | 20 | 0 | 20 |
| **Total** | **746** | **0** | **746** |

103
lib/content/section.sx Normal file
View File

@@ -0,0 +1,103 @@
;; content-on-sx — nested block trees (section container).
;;
;; CtSection is a block whose ivar `children` is an ordered list of blocks (any
;; type, including nested sections → arbitrary depth). This turns the document
;; from a flat sequence into the ordered TREE of the architecture sketch.
;;
;; Self-contained: CtSection answers asHTML/asSx/asText/asMarkdown: by folding
;; its children's renderings — pure polymorphic recursion, so it composes with
;; the existing render boundary with no changes to block.sx or render.sx. (The
;; relevant per-block render bootstrap must be loaded for the children.)
;;
;; Requires (loaded by harness): block.sx, doc.sx, render.sx (asHTML/asSx);
;; markdown.sx / text.sx for those formats on children.
(define
content-bootstrap-section!
(fn
()
(begin
(st-class-define! "CtSection" "CtBlock" (list "children"))
(ct-def-method! "CtSection" "children" "children ^ children")
(ct-def-method! "CtSection" "type" "type ^ #section")
(ct-def-method!
"CtSection"
"asHTML"
"asHTML ^ '<section>' , (children inject: '' into: [:a :b | a , (b asHTML)]) , '</section>'")
(ct-def-method!
"CtSection"
"asSx"
"asSx ^ '(section ' , (children inject: '' into: [:a :b | a , (b asSx)]) , ')'")
(ct-def-method!
"CtSection"
"asText"
"asText ^ (children inject: '' into: [:a :b | (b asText = '') ifTrue: [a] ifFalse: [(a = '' ifTrue: [b asText] ifFalse: [a , ' ' , b asText])]])")
(ct-def-method!
"CtSection"
"asMarkdown:"
"asMarkdown: nl ^ (children inject: '' into: [:a :b | a , (a = '' ifTrue: [''] ifFalse: [nl , nl]) , (b asMarkdown: nl)])")
true)))
(define
mk-section
(fn
(id children)
(st-iv-set!
(st-iv-set! (st-make-instance "CtSection") "id" id)
"children"
children)))
(define
section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define section-children (fn (sec) (st-send sec "children" (list))))
;; copy-on-write child edits (return a new section)
(define
section-with-children
(fn (sec children) (st-iv-set! sec "children" children)))
(define
section-append
(fn
(sec block)
(section-with-children sec (append (section-children sec) (list block)))))
;; ── tree traversal (descends into nested sections) ──
(define
block-deep-find
(fn
(blocks id)
(if
(= (len blocks) 0)
nil
(let
((b (first blocks)))
(if
(= (blk-id b) id)
b
(let
((nested (if (section? b) (block-deep-find (section-children b) id) nil)))
(if (= nested nil) (block-deep-find (rest blocks) id) nested)))))))
(define doc-deep-find (fn (doc id) (block-deep-find (doc-blocks doc) id)))
(define
block-tree-ids
(fn
(blocks)
(if
(= (len blocks) 0)
(list)
(let
((b (first blocks)))
(append
(cons
(blk-id b)
(if (section? b) (block-tree-ids (section-children b)) (list)))
(block-tree-ids (rest blocks)))))))
(define doc-tree-ids (fn (doc) (block-tree-ids (doc-blocks doc))))
(define block-tree-count (fn (blocks) (len (block-tree-ids blocks))))
(define doc-tree-count (fn (doc) (len (doc-tree-ids doc))))

90
lib/content/snapshot.sx Normal file
View File

@@ -0,0 +1,90 @@
;; content-on-sx — snapshot cache over the op-log replay.
;;
;; Snapshots are a CACHE, never primary state: the op log stays the source of
;; truth. A snapshot stores a materialised document at a sequence in the persist
;; KV; cached reads start from it and replay only the tail of ops, so they return
;; a document IDENTICAL to a full replay — just faster. Drop the snapshot and
;; nothing is lost.
;;
;; Requires (loaded by harness): store.sx (+ doc.sx, persist event/log/kv/api).
(define content/-snap-key (fn (doc-id) (str "content-snap:" doc-id)))
;; take a snapshot of the current head at the current version. Returns the seq.
(define
content/snapshot!
(fn
(b doc-id)
(let
((seq (content/version-count b doc-id)))
(begin (persist/kv-put b (content/-snap-key doc-id) {:doc (content/head b doc-id) :seq seq}) seq))))
(define
content/-snapshot
(fn
(b doc-id)
(if
(persist/kv-has? b (content/-snap-key doc-id))
(persist/kv-get b (content/-snap-key doc-id))
nil)))
(define
content/snapshot-seq
(fn
(b doc-id)
(let
((s (content/-snapshot b doc-id)))
(if (= s nil) 0 (get s :seq)))))
(define
content/has-snapshot?
(fn (b doc-id) (persist/kv-has? b (content/-snap-key doc-id))))
(define
content/drop-snapshot!
(fn (b doc-id) (persist/kv-delete b (content/-snap-key doc-id))))
;; ── cached reads (transparent: identical result to store.sx replay) ──
(define
content/-tail-ops
(fn
(b doc-id from to)
(map
(fn (ev) (persist/event-data ev))
(filter
(fn
(ev)
(and
(> (persist/event-seq ev) from)
(<= (persist/event-seq ev) to)))
(content/log b doc-id)))))
(define
content/head-cached
(fn
(b doc-id)
(let
((snap (content/-snapshot b doc-id)))
(if
(= snap nil)
(content/head b doc-id)
(doc-apply-all
(get snap :doc)
(content/-tail-ops
b
doc-id
(get snap :seq)
(content/version-count b doc-id)))))))
(define
content/at-cached
(fn
(b doc-id seq)
(let
((snap (content/-snapshot b doc-id)))
(if
(or (= snap nil) (< seq (get snap :seq)))
(content/at b doc-id seq)
(doc-apply-all
(get snap :doc)
(content/-tail-ops b doc-id (get snap :seq) seq))))))

49
lib/content/stats.sx Normal file
View File

@@ -0,0 +1,49 @@
;; content-on-sx — document statistics (word/char/block counts, reading time).
;;
;; Counts derive from the plain-text projection (asText, tree-accurate via
;; section recursion) and a tree block count (inline class check, so this needs
;; no section.sx). Reading time uses 200 wpm, rounded up.
;;
;; Requires (loaded by harness): block.sx, doc.sx, text.sx (asText).
(define
ct-words
(fn (s) (filter (fn (w) (if (= w "") false true)) (split s " "))))
(define ct-ceil-div (fn (a b) (quotient (+ a (- b 1)) b)))
(define
ct-stat-section?
(fn (b) (and (st-instance? b) (= (get b :class) "CtSection"))))
(define
ct-stat-count
(fn
(blocks)
(if
(= (len blocks) 0)
0
(let
((b (first blocks)))
(+
(+
1
(if
(ct-stat-section? b)
(let
((ch (st-iv-get b "children")))
(if (list? ch) (ct-stat-count ch) 0))
0))
(ct-stat-count (rest blocks)))))))
(define content/word-count (fn (doc) (len (ct-words (asText doc)))))
(define content/char-count (fn (doc) (string-length (asText doc))))
(define content/block-count (fn (doc) (ct-stat-count (doc-blocks doc))))
(define
content/reading-minutes
(fn
(doc)
(let
((w (content/word-count doc)))
(if (= w 0) 0 (ct-ceil-div w 200)))))
(define content/stats (fn (doc) {:blocks (content/block-count doc) :reading-minutes (content/reading-minutes doc) :words (content/word-count doc) :chars (content/char-count doc)}))

101
lib/content/store.sx Normal file
View File

@@ -0,0 +1,101 @@
;; content-on-sx — op log + versioning over the persist event stream.
;;
;; The op log is the source of truth. Editing a document = appending the edit op
;; as a persist event to the document's stream. Any version of the document is a
;; replay of its op stream up to a sequence number; the materialised doc is a
;; cache, never primary state.
;;
;; Requires (loaded by the harness): block.sx, doc.sx, and persist
;; (event/backend/log/kv/api). The persist backend `b` is opened by the caller
;; via (persist/open) and injected — content knows nothing about which backend.
(define content/-stream (fn (doc-id) (str "content:" doc-id)))
;; ── commit: append an edit op as an event. `at` is a caller-supplied logical
;; timestamp (Date.now is unavailable in-kernel). Returns the stored event. ──
(define
content/commit!
(fn
(b doc-id op at)
(persist/append b (content/-stream doc-id) (get op :op) at op)))
(define
content/commit-all!
(fn
(b doc-id ops at)
(if
(= (len ops) 0)
nil
(begin
(content/commit! b doc-id (first ops) at)
(content/commit-all! b doc-id (rest ops) at)))))
;; ── read the raw log / op stream ──
(define
content/log
(fn (b doc-id) (persist/read b (content/-stream doc-id))))
(define
content/ops
(fn
(b doc-id)
(map (fn (ev) (persist/event-data ev)) (content/log b doc-id))))
;; logical version count (highest seq assigned, survives compaction)
(define
content/version-count
(fn (b doc-id) (persist/last-seq b (content/-stream doc-id))))
;; ── replay ──
;; head — materialise the latest document by folding all ops.
(define
content/head
(fn (b doc-id) (doc-apply-all (doc-empty doc-id) (content/ops b doc-id))))
;; at — materialise the document as of sequence `seq` (a version).
(define
content/at
(fn
(b doc-id seq)
(let
((evs (filter (fn (ev) (<= (persist/event-seq ev) seq)) (content/log b doc-id))))
(doc-apply-all
(doc-empty doc-id)
(map (fn (ev) (persist/event-data ev)) evs)))))
;; ── history: per-version metadata, oldest-first ──
(define
content/history
(fn (b doc-id) (map (fn (ev) {:type (persist/event-type ev) :at (persist/event-at ev) :seq (persist/event-seq ev)}) (content/log b doc-id))))
;; ── diff between two materialised document versions ──
;; Returns {:added (ids) :removed (ids) :changed (ids)} where changed = ids
;; present in both whose block content differs.
(define
content/-missing?
(fn (doc id) (= (ct-index-of (doc-blocks doc) id) -1)))
(define
content/-changed
(fn
(old new)
(filter
(fn
(id)
(let
((bo (doc-find old id)) (bn (doc-find new id)))
(cond
((= bo nil) false)
((= bn nil) false)
((= bo bn) false)
(else true))))
(doc-ids old))))
(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (doc-ids old)) :added (filter (fn (id) (content/-missing? old id)) (doc-ids new))}))
;; convenience: diff two persisted versions by seq.
(define
content/diff-versions
(fn
(b doc-id seq-a seq-b)
(content/diff (content/at b doc-id seq-a) (content/at b doc-id seq-b))))

26
lib/content/summary.sx Normal file
View File

@@ -0,0 +1,26 @@
;; content-on-sx — list-card summary projection.
;;
;; content/summary returns a one-call projection for index/listing cards:
;; {:id :title :excerpt :words :reading-minutes :cover}
;; composing the metadata, text, stats and query layers. `cover` is the first
;; image's src (or nil).
;;
;; Requires (loaded by harness): doc.sx, meta.sx (doc-title), text.sx
;; (content/excerpt), stats.sx (word-count/reading), query.sx (select-type).
(define
content/summary-title
(fn (doc) (let ((t (doc-title doc))) (if (= t nil) (doc-id doc) t))))
(define
content/cover
(fn
(doc)
(let
((imgs (content/select-type doc "image")))
(if
(= (len imgs) 0)
nil
(str (blk-get (first imgs) "src"))))))
(define content/summary (fn (doc) {:id (doc-id doc) :reading-minutes (content/reading-minutes doc) :words (content/word-count doc) :title (content/summary-title doc) :excerpt (content/excerpt doc 160) :cover (content/cover doc)}))

74
lib/content/sync.sx Normal file
View File

@@ -0,0 +1,74 @@
;; content-on-sx — external CMS sync via an injected adapter.
;;
;; Sync is a peripheral, not a feature. The core defines a SHAPE — an adapter is
;; a dict {:import (fn external doc-id -> doc) :export (fn doc -> external)} — and
;; delegates to it. The core knows nothing about Ghost's data model; all
;; translation lives in the adapter. Swap the adapter and the core is unchanged;
;; if Ghost goes away, nothing here does.
;;
;; Requires (loaded by harness): block.sx, doc.sx.
;; ── generic boundary: pure delegation ──
(define
content/import
(fn (adapter external doc-id) ((get adapter :import) external doc-id)))
(define content/export (fn (adapter doc) ((get adapter :export) doc)))
;; round-trip a document through an adapter (export then import).
(define
content/round-trip
(fn
(adapter doc)
(content/import adapter (content/export adapter doc) (doc-id doc))))
;; ── a Ghost-flavoured adapter (the peripheral). Ghost knowledge is confined
;; here: a post is {:title :sections (list section)}; a section is a tagged dict
;; {:kind ...} that this adapter maps to/from content blocks. ──
(define
ghost-section->block
(fn
(sec)
(let
((kind (get sec :kind)) (id (get sec :id)))
(cond
((= kind "heading")
(mk-heading id (get sec :level) (get sec :text)))
((= kind "paragraph") (mk-text id (get sec :text)))
((= kind "image") (mk-image id (get sec :src) (get sec :alt)))
((= kind "code") (mk-code id (get sec :language) (get sec :text)))
((= kind "quote") (mk-quote id (get sec :cite) (get sec :text)))
((= kind "hr") (mk-divider id))
((= kind "list") (mk-list id (get sec :ordered) (get sec :items)))
((= kind "embed") (mk-embed id (get sec :url) (get sec :provider)))
(else (mk-text id (get sec :text)))))))
(define
block->ghost-section
(fn
(b)
(let
((t (blk-type b)) (id (blk-id b)))
(cond
((= t "heading") {:id id :text (str (blk-send b "text")) :kind "heading" :level (blk-send b "level")})
((= t "text") {:id id :text (str (blk-send b "text")) :kind "paragraph"})
((= t "image") {:id id :src (str (blk-send b "src")) :alt (str (blk-send b "alt")) :kind "image"})
((= t "code") {:id id :text (str (blk-send b "text")) :kind "code" :language (str (blk-send b "language"))})
((= t "quote") {:cite (str (blk-send b "cite")) :id id :text (str (blk-send b "text")) :kind "quote"})
((= t "divider") {:id id :kind "hr"})
((= t "list") {:items (blk-send b "items") :id id :kind "list" :ordered (blk-send b "ordered")})
((= t "embed") {:id id :provider (str (blk-send b "provider")) :kind "embed" :url (str (blk-send b "url"))})
(else {:id id :text "" :kind "paragraph"})))))
(define
ghost-import
(fn
(post doc-id)
(st-iv-set!
(doc-new doc-id (map ghost-section->block (get post :sections)))
"title"
(get post :title))))
(define ghost-export (fn (doc) {:sections (map block->ghost-section (doc-blocks doc)) :title (st-send doc "title" (list))}))
(define ghost-adapter {:export ghost-export :import ghost-import})

54
lib/content/table.sx Normal file
View File

@@ -0,0 +1,54 @@
;; content-on-sx — table block.
;;
;; CtTable holds `headers` (list of strings) and `rows` (list of string lists).
;; Self-contained: it answers asHTML/asSx/asText/asMarkdown: by folding rows and
;; cells, so it composes with the render boundary with no changes elsewhere. HTML
;; cells are htmlEscaped, SX cells sxEscaped (render.sx must be loaded).
;;
;; Requires (loaded by harness): block.sx, doc.sx, render.sx (escapers);
;; markdown.sx / text.sx for those formats.
(define
content-bootstrap-table!
(fn
()
(begin
(st-class-define! "CtTable" "CtBlock" (list "headers" "rows"))
(ct-def-method! "CtTable" "headers" "headers ^ headers")
(ct-def-method! "CtTable" "rows" "rows ^ rows")
(ct-def-method! "CtTable" "type" "type ^ #table")
(ct-def-method!
"CtTable"
"asHTML"
"asHTML | thead tbody | thead := '<thead><tr>' , (headers inject: '' into: [:a :h | a , '<th>' , h htmlEscaped , '</th>']) , '</tr></thead>'. tbody := '<tbody>' , (rows inject: '' into: [:a :r | a , '<tr>' , (r inject: '' into: [:b :c | b , '<td>' , c htmlEscaped , '</td>']) , '</tr>']) , '</tbody>'. ^ '<table>' , thead , tbody , '</table>'")
(ct-def-method!
"CtTable"
"asSx"
"asSx ^ '(table (thead (tr ' , (headers inject: '' into: [:a :h | a , '(th \"' , h sxEscaped , '\")']) , ')) (tbody ' , (rows inject: '' into: [:a :r | a , '(tr ' , (r inject: '' into: [:b :c | b , '(td \"' , c sxEscaped , '\")']) , ')']) , '))'")
(ct-def-method!
"CtTable"
"asText"
"asText ^ (rows inject: (headers inject: '' into: [:a :h | (a = '' ifTrue: [h] ifFalse: [a , ' ' , h])]) into: [:acc :r | acc , ' ' , (r inject: '' into: [:b :c | (b = '' ifTrue: [c] ifFalse: [b , ' ' , c])])])")
(ct-def-method!
"CtTable"
"asMarkdown:"
"asMarkdown: nl | head sep body | head := '|' , (headers inject: '' into: [:a :h | a , ' ' , h , ' |']). sep := '|' , (headers inject: '' into: [:a :h | a , ' --- |']). body := (rows inject: '' into: [:acc :r | acc , nl , '|' , (r inject: '' into: [:a :c | a , ' ' , c , ' |'])]). ^ head , nl , sep , body")
true)))
(define
mk-table
(fn
(id headers rows)
(st-iv-set!
(st-iv-set!
(st-iv-set! (st-make-instance "CtTable") "id" id)
"headers"
headers)
"rows"
rows)))
(define
table?
(fn (b) (and (st-instance? b) (= (get b :class) "CtTable"))))
(define table-headers (fn (tb) (st-send tb "headers" (list))))
(define table-rows (fn (tb) (st-send tb "rows" (list))))

View File

@@ -0,0 +1,58 @@
;; Extension — anchored-heading HTML render (functional TOC links).
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-section!)
(define
d
(doc-append
(doc-append
(doc-append (doc-empty "d") (mk-heading "intro" 1 "Intro"))
(mk-text "p" "Body"))
(mk-section
"s"
(list (mk-heading "sub" 2 "Sub") (mk-text "n" "nested")))))
;; ── headings get id anchors; other blocks unchanged ──
(content-test
"anchored html"
(content/html-anchored d)
"<h1 id=\"intro\">Intro</h1><p>Body</p><section><h2 id=\"sub\">Sub</h2><p>nested</p></section>")
;; ── heading text escaped ──
(content-test
"anchored escapes text"
(content/html-anchored
(doc-append (doc-empty "d") (mk-heading "h" 2 "A < B")))
"<h2 id=\"h\">A &lt; B</h2>")
;; ── non-heading-only doc identical to asHTML ──
(define
np
(doc-append
(doc-append (doc-empty "d") (mk-text "p" "x"))
(mk-image "i" "/a.png" "alt")))
(content-test "no headings == asHTML" (content/html-anchored np) (asHTML np))
;; ── empty doc ──
(content-test "anchored empty" (content/html-anchored (doc-empty "e")) "")
;; ── anchors match TOC ids (end-to-end) ──
(content-test
"anchor ids match toc"
(map (fn (h) (get h :id)) (content/headings d))
(list "intro" "sub"))
;; ── deep nesting ──
(define
deep
(doc-append
(doc-empty "d")
(mk-section
"o"
(list (mk-section "i" (list (mk-heading "deep" 3 "Deep")))))))
(content-test
"deep anchored"
(content/html-anchored deep)
"<section><section><h3 id=\"deep\">Deep</h3></section></section>")

99
lib/content/tests/api.sx Normal file
View File

@@ -0,0 +1,99 @@
;; Phase 1 — public API facade. End-to-end through content/*.
(st-bootstrap-classes!)
(content/bootstrap!)
;; ── build a document via the facade ──
(define d0 (content/empty "post"))
(define
h
(content/block
"heading"
"h"
(list (list "level" 1) (list "text" "Hi"))))
(define p (content/block "text" "p" (list (list "text" "World"))))
(define d1 (content/append (content/append d0 h) p))
(content/op? (content/insert h nil))
(content-test "count" (content/count d1) 2)
(content-test "ids" (content/ids d1) (list "h" "p"))
(content-test "types" (content/types d1) (list "heading" "text"))
(content-test "find" (blk-id (content/find d1 "p")) "p")
(content-test "has? yes" (content/has? d1 "h") true)
(content-test "has? no" (content/has? d1 "x") false)
;; ── content/op? distinguishes a single op from a list / a block ──
(content-test "op? on insert" (content/op? (content/insert h nil)) true)
(content-test
"op? on update"
(content/op? (content/update "p" "text" "z"))
true)
(content-test "op? on list" (content/op? (list (content/delete "h"))) false)
(content-test "op? on block" (content/op? h) false)
(content-test "op? on doc" (content/op? d1) false)
;; ── edit with a single op ──
(define
img
(content/block
"image"
"img"
(list (list "src" "/c.png") (list "alt" "cat"))))
(define d2 (content/edit d1 (content/insert img "h")))
(content-test "edit single op order" (content/ids d2) (list "h" "img" "p"))
(content-test "edit single immutable" (content/ids d1) (list "h" "p"))
(content-test
"edit update"
(str
(blk-send
(content/find
(content/edit d1 (content/update "p" "text" "Edited"))
"p")
"text"))
"Edited")
(content-test
"edit delete"
(content/ids (content/edit d1 (content/delete "h")))
(list "p"))
(content-test
"edit move"
(content/ids (content/edit d1 (content/move "p" 0)))
(list "p" "h"))
;; ── edit with a stream of ops ──
(define ops (list (content/insert img "h") (content/delete "p")))
(content-test
"edit op stream"
(content/ids (content/edit d1 ops))
(list "h" "img"))
(content-test "edit op stream immutable" (content/ids d1) (list "h" "p"))
;; ── render via facade ──
(content-test
"render html"
(content/render d1 "html")
"<h1>Hi</h1><p>World</p>")
(content-test
"render sx"
(content/render d1 "sx")
"(article (h1 \"Hi\")(p \"World\"))")
(content-test
"render html keyword"
(content/render d1 :html)
"<h1>Hi</h1><p>World</p>")
(content-test
"render sx keyword"
(content/render d1 :sx)
"(article (h1 \"Hi\")(p \"World\"))")
(content-test "content/html" (content/html d1) "<h1>Hi</h1><p>World</p>")
(content-test "content/sx" (content/sx d1) "(article (h1 \"Hi\")(p \"World\"))")
;; ── render reflects each version ──
(content-test
"render edited version"
(content/render (content/edit d1 (content/update "h" "text" "Hey")) "html")
"<h1>Hey</h1><p>World</p>")
(content-test
"render original unchanged"
(content/render d1 "html")
"<h1>Hi</h1><p>World</p>")

View File

@@ -0,0 +1,75 @@
;; Phase 1 — typed block objects. Behaviour via message dispatch; fields
;; immutable (copy-on-write).
(st-bootstrap-classes!)
(content-bootstrap-blocks!)
;; ── construction + polymorphic type dispatch ──
(define h (mk-heading "b1" 2 "Title"))
(define t (mk-text "b2" "Body text"))
(define img (mk-image "b3" "/cat.png" "a cat"))
(define code (mk-code "b4" "sx" "(+ 1 2)"))
(define q (mk-quote "b5" "Ada" "to err"))
(define em (mk-embed "b6" "https://v/1" "vimeo"))
(define dv (mk-divider "b7"))
(define ls (mk-list "b8" true (list "one" "two")))
(content-test "heading type" (blk-type h) "heading")
(content-test "text type" (blk-type t) "text")
(content-test "image type" (blk-type img) "image")
(content-test "code type" (blk-type code) "code")
(content-test "quote type" (blk-type q) "quote")
(content-test "embed type" (blk-type em) "embed")
(content-test "divider type" (blk-type dv) "divider")
(content-test "list type" (blk-type ls) "list")
;; ── id via message dispatch ──
(content-test "heading id" (blk-id h) "b1")
(content-test "image id" (blk-id img) "b3")
(content-test "divider id" (blk-id dv) "b7")
;; ── field reads via messages (incl. inherited text) ──
(content-test "heading text inherited" (str (blk-send h "text")) "Title")
(content-test "heading level" (blk-send h "level") 2)
(content-test "text body" (str (blk-send t "text")) "Body text")
(content-test "image src" (str (blk-send img "src")) "/cat.png")
(content-test "image alt" (str (blk-send img "alt")) "a cat")
(content-test "code language" (str (blk-send code "language")) "sx")
(content-test "code text inherited" (str (blk-send code "text")) "(+ 1 2)")
(content-test "quote cite" (str (blk-send q "cite")) "Ada")
(content-test "embed url" (str (blk-send em "url")) "https://v/1")
(content-test "embed provider" (str (blk-send em "provider")) "vimeo")
(content-test "list ordered" (blk-send ls "ordered") true)
(content-test "list items" (blk-send ls "items") (list "one" "two"))
;; ── blk-get reads ivars directly ──
(content-test "blk-get level" (blk-get h "level") 2)
(content-test "blk-get missing nil" (blk-get h "nope") nil)
;; ── copy-on-write: blk-set returns a new block, original untouched ──
(define h2 (blk-set h "level" 1))
(content-test "blk-set new value" (blk-send h2 "level") 1)
(content-test "blk-set original unchanged" (blk-send h "level") 2)
(content-test "blk-set keeps id" (blk-id h2) "b1")
(content-test "blk-set keeps text" (str (blk-send h2 "text")) "Title")
;; ── predicate ──
(content-test "block? on heading" (block? h) true)
(content-test "block? on divider" (block? dv) true)
(content-test "block? on number" (block? 5) false)
(content-test "block? on string" (block? "x") false)
;; ── isBlock message inherited by all ──
(content-test "isBlock heading" (blk-send h "isBlock") true)
(content-test "isBlock list" (blk-send ls "isBlock") true)
;; ── generic mk-block via wire tag ──
(define
g
(mk-block
"heading"
"g1"
(list (list "level" 3) (list "text" "Gen"))))
(content-test "mk-block type" (blk-type g) "heading")
(content-test "mk-block level" (blk-send g "level") 3)
(content-test "mk-block text" (str (blk-send g "text")) "Gen")

View File

@@ -0,0 +1,55 @@
;; Extension — callout / admonition block.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-markdown!)
(content-bootstrap-text!)
(content-bootstrap-callout!)
(define c (mk-callout "c" "warning" "Be careful"))
;; ── identity ──
(content-test "callout is block" (block? c) true)
(content-test "callout? yes" (callout? c) true)
(content-test "callout type" (blk-type c) "callout")
(content-test "callout kind" (callout-kind c) "warning")
;; ── render ──
(content-test
"callout html"
(asHTML c)
"<aside class=\"callout callout-warning\">Be careful</aside>")
(content-test
"callout sx"
(asSx c)
"(aside :class \"callout callout-warning\" \"Be careful\")")
(content-test "callout text" (asText c) "Be careful")
(content-test "callout markdown" (asMarkdown c) "> **warning:** Be careful")
;; ── html escapes text ──
(content-test
"callout html escapes"
(asHTML (mk-callout "c" "note" "a < b"))
"<aside class=\"callout callout-note\">a &lt; b</aside>")
;; ── in a document ──
(define
d
(doc-append
(doc-append (doc-empty "d") (mk-heading "h" 1 "T"))
c))
(content-test
"doc with callout html"
(asHTML d)
"<h1>T</h1><aside class=\"callout callout-warning\">Be careful</aside>")
;; ── validation ──
(content-test
"valid callout"
(content/valid? (doc-append (doc-empty "d") c))
true)
(content-test
"bad callout kind flagged"
(content/issue-kinds
(doc-append (doc-empty "d") (mk-callout "c" 5 "x")))
(list "field"))

View File

@@ -0,0 +1,55 @@
;; Extension — block id remapping / clone.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-section!)
(define
d
(doc-append
(doc-append (doc-empty "d") (mk-heading "h" 1 "Title"))
(mk-section "s" (list (mk-text "a" "A") (mk-text "b" "B")))))
;; ── prefix-ids rewrites every id in the tree ──
(define p (content/prefix-ids d "x-"))
(content-test "prefix top-level ids" (doc-ids p) (list "x-h" "x-s"))
(content-test
"prefix tree-ids"
(doc-tree-ids p)
(list "x-h" "x-s" "x-a" "x-b"))
(content-test "prefix immutable" (doc-tree-ids d) (list "h" "s" "a" "b"))
(content-test "prefix preserves content" (asHTML p) (asHTML d))
(content-test
"prefix preserves nested content"
(str (blk-send (doc-deep-find p "x-a") "text"))
"A")
;; ── custom remap fn ──
(define u (content/remap-ids d (fn (id) (str id "!"))))
(content-test "remap suffix" (doc-tree-ids u) (list "h!" "s!" "a!" "b!"))
;; ── collision-free composition ──
(define
d2
(doc-append (doc-empty "d2") (mk-heading "h" 2 "Other")))
(define
combined
(content/concat
(content/prefix-ids d "left-")
(content/prefix-ids d2 "right-")))
(content-test
"combined ids unique"
(doc-tree-ids combined)
(list "left-h" "left-s" "left-a" "left-b" "right-h"))
(content-test "combined validates" (content/valid? combined) true)
;; without prefixing, the shared id "h" collides
(content-test
"unprefixed collides"
(content/valid? (content/concat d d2))
false)
;; ── render of combined ──
(content-test
"combined render"
(asHTML combined)
"<h1>Title</h1><section><p>A</p><p>B</p></section><h2>Other</h2>")

View File

@@ -0,0 +1,76 @@
;; Extension — document composition.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-section!)
(define
a
(doc-with-title
(doc-append (doc-empty "a") (mk-heading "h" 1 "A"))
"Doc A"))
(define
b
(doc-append
(doc-append (doc-empty "b") (mk-text "p" "B1"))
(mk-text "q" "B2")))
;; ── concat ──
(define ab (content/concat a b))
(content-test "concat ids" (doc-ids ab) (list "h" "p" "q"))
(content-test "concat keeps first id" (doc-id ab) "a")
(content-test "concat keeps first title" (doc-title ab) "Doc A")
(content-test "concat immutable a" (doc-ids a) (list "h"))
(content-test "concat immutable b" (doc-ids b) (list "p" "q"))
;; ── prepend ──
(define ba (content/prepend a b))
(content-test "prepend ids" (doc-ids ba) (list "p" "q" "h"))
(content-test "prepend keeps a id" (doc-id ba) "a")
;; ── concat with empty ──
(content-test
"concat empty right"
(doc-ids (content/concat a (doc-empty "e")))
(list "h"))
(content-test
"concat empty left"
(doc-ids (content/concat (doc-empty "e") b))
(list "p" "q"))
;; ── concat-all ──
(define c (doc-append (doc-empty "c") (mk-divider "d")))
(content-test
"concat-all order"
(doc-ids (content/concat-all (list a b c)))
(list "h" "p" "q" "d"))
(content-test
"concat-all keeps first id"
(doc-id (content/concat-all (list a b c)))
"a")
(content-test
"concat-all single"
(doc-ids (content/concat-all (list a)))
(list "h"))
(content-test
"concat-all empty"
(doc-ids (content/concat-all (list)))
(list))
;; ── render of composed doc ──
(content-test
"composed renders"
(asHTML (content/concat a b))
"<h1>A</h1><p>B1</p><p>B2</p>")
;; ── wrap-section collapses blocks into a subtree ──
(define w (content/wrap-section ab "sec"))
(content-test "wrap top-level is one section" (doc-ids w) (list "sec"))
(content-test
"wrap children preserved"
(doc-tree-ids w)
(list "sec" "h" "p" "q"))
(content-test
"wrap renders nested"
(asHTML w)
"<section><h1>A</h1><p>B1</p><p>B2</p></section>")

View File

@@ -0,0 +1,136 @@
;; Hardening — non-core block types (callout/table/media/section) survive the
;; flat and tree CvRDT materialise paths (regression for the ct-class-for-type
;; fix: these route through crdt-element->block -> mk-block).
(st-bootstrap-classes!)
(content-bootstrap-blocks!)
(content-bootstrap-doc!)
(content-bootstrap-render!)
(content-bootstrap-section!)
(content-bootstrap-callout!)
(content-bootstrap-table!)
(content-bootstrap-media!)
;; ── flat CRDT: callout / table / media leaves ──
(define
s
(crdt-apply-all
(crdt-empty)
(list
(crdt-op-insert
"co"
"callout"
(crdt-pos 1 0)
(list (list "kind" "note") (list "text" "hi"))
1
0)
(crdt-op-insert
"tb"
"table"
(crdt-pos 2 0)
(list (list "headers" (list "A")) (list "rows" (list (list "1"))))
1
0)
(crdt-op-insert
"vid"
"media"
(crdt-pos 3 0)
(list (list "kind" "video") (list "src" "/v.mp4"))
1
0))))
(content-test
"flat crdt callout render"
(asHTML (crdt-materialize "d" s))
"<aside class=\"callout callout-note\">hi</aside><table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table><video src=\"/v.mp4\" controls></video>")
(content-test "flat crdt order" (crdt-order s) (list "co" "tb" "vid"))
;; ── flat CRDT: callout field via LWW update ──
(define s2 (crdt-update s "co" "text" "edited" 5 1))
(content-test
"flat crdt callout update"
(str (blk-send (doc-find (crdt-materialize "d" s2) "co") "text"))
"edited")
;; ── tree CRDT: callout/table inside a section ──
(define
t
(crdt-tree-apply-all
(crdt-empty)
(list
(crdt-tree-op-insert
"sec"
"section"
(crdt-pos 1 0)
""
(list)
1
0)
(crdt-tree-op-insert
"co"
"callout"
(crdt-pos 1 0)
"sec"
(list (list "kind" "tip") (list "text" "T"))
1
0)
(crdt-tree-op-insert
"tb"
"table"
(crdt-pos 2 0)
"sec"
(list (list "headers" (list "H")) (list "rows" (list)))
1
0))))
(content-test
"tree crdt nested blocks"
(doc-tree-ids (crdt-tree-materialize "d" t))
(list "sec" "co" "tb"))
(content-test
"tree crdt nested render"
(asHTML (crdt-tree-materialize "d" t))
"<section><aside class=\"callout callout-tip\">T</aside><table><thead><tr><th>H</th></tr></thead><tbody></tbody></table></section>")
;; ── tree CRDT: concurrent callout inserts into a section converge ──
(define
base
(crdt-tree-insert
(crdt-empty)
"sec"
"section"
(crdt-pos 1 0)
""
(list)
1
0))
(define
rA
(crdt-tree-insert
base
"x"
"callout"
(crdt-pos 5 1)
"sec"
(list (list "kind" "note") (list "text" "A"))
2
1))
(define
rB
(crdt-tree-insert
base
"y"
"media"
(crdt-pos 5 2)
"sec"
(list (list "kind" "audio") (list "src" "/a.mp3"))
2
2))
(content-test
"tree crdt mixed converge"
(=
(get (crdt-tree-merge rA rB) :elements)
(get (crdt-tree-merge rB rA) :elements))
true)
(content-test
"tree crdt mixed ids"
(doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge rA rB)))
(list "sec" "x" "y"))

View File

@@ -0,0 +1,139 @@
;; Extension — durable collaborative replication (CRDT ops on persist).
;; Replicas log independently; converge merges the logs deterministically.
(st-bootstrap-classes!)
(content-bootstrap-blocks!)
(content-bootstrap-doc!)
(content-bootstrap-render!)
(define same? (fn (a b) (= (get a :elements) (get b :elements))))
(define B (persist/open))
;; replica "a" (origin): inserts h, p
(crdt/commit!
B
"doc"
"a"
(crdt-op-insert
"h"
"heading"
(crdt-pos 1 0)
(list (list "level" 1) (list "text" "T"))
1
1)
1)
(crdt/commit!
B
"doc"
"a"
(crdt-op-insert
"p"
"text"
(crdt-pos 2 0)
(list (list "text" "Body"))
1
1)
1)
;; replica "b" (concurrent): edits p, inserts x
(crdt/commit-all!
B
"doc"
"b"
(list
(crdt-op-update "p" "text" "Edited" 5 2)
(crdt-op-insert
"x"
"text"
(crdt-pos 3 0)
(list (list "text" "X"))
6
2))
5)
;; ── durability ──
(content-test
"replica a version"
(crdt/replica-version B "doc" "a")
2)
(content-test
"replica b version"
(crdt/replica-version B "doc" "b")
2)
(content-test
"replica a ops len"
(len (crdt/replica-ops B "doc" "a"))
2)
;; ── single-replica replay ──
(content-test
"replay a order"
(crdt-order (crdt/replay B "doc" "a"))
(list "h" "p"))
(content-test
"replay a == apply-all"
(same?
(crdt/replay B "doc" "a")
(crdt-apply-all (crdt-empty) (crdt/replica-ops B "doc" "a")))
true)
;; ── converge ──
(content-test
"converge order"
(crdt/order B "doc" (list "a" "b"))
(list "h" "p" "x"))
(content-test
"converge replica-order-independent"
(same?
(crdt/converge B "doc" (list "a" "b"))
(crdt/converge B "doc" (list "b" "a")))
true)
(content-test
"converge LWW p edited"
(str
(blk-send (doc-find (crdt/document B "doc" (list "a" "b")) "p") "text"))
"Edited")
(content-test
"converged document render"
(asHTML (crdt/document B "doc" (list "a" "b")))
"<h1>T</h1><p>Edited</p><p>X</p>")
;; ── duplicate delivery is idempotent ──
(crdt/commit!
B
"doc"
"a"
(crdt-op-insert
"p"
"text"
(crdt-pos 2 0)
(list (list "text" "Body"))
1
1)
1)
(content-test
"duplicate op no effect on converge"
(crdt/order B "doc" (list "a" "b"))
(list "h" "p" "x"))
(content-test
"duplicate keeps LWW value"
(str
(blk-send (doc-find (crdt/document B "doc" (list "a" "b")) "p") "text"))
"Edited")
;; ── new op on a replica is reflected after re-converge ──
(crdt/commit! B "doc" "b" (crdt-op-delete "h") 9)
(content-test
"delete reflected after reconverge"
(crdt/order B "doc" (list "a" "b"))
(list "p" "x"))
;; ── isolation: unknown doc converges to empty ──
(content-test
"unknown doc empty"
(crdt/order B "other" (list "a" "b"))
(list))
(content-test
"unknown replica empty ops"
(len (crdt/replica-ops B "doc" "zzz"))
0)

View File

@@ -0,0 +1,289 @@
;; 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"))
;; ── orphan reparenting: concurrent delete-section + insert-child ──
;; A deletes section s; B inserts a child into s. After merge, s is gone but the
;; child must survive (reparented to root), not silently vanish.
(define delA (crdt-tree-delete base "s"))
(define
insB
(crdt-tree-insert
base
"c"
"text"
(crdt-pos 9 0)
"s"
(list (list "text" "kept"))
5
2))
(define orphan-merge (crdt-tree-merge delA insB))
(content-test
"orphan survives delete-section"
(doc-tree-ids (crdt-tree-materialize "d" orphan-merge))
(list "h" "c"))
(content-test
"orphan reparent commutes"
(same? orphan-merge (crdt-tree-merge insB delA))
true)
(content-test
"orphan content preserved"
(str
(blk-send
(doc-deep-find (crdt-tree-materialize "d" orphan-merge) "c")
"text"))
"kept")
(content-test
"orphan render at root"
(asHTML (crdt-tree-materialize "d" orphan-merge))
"<h2>Sub</h2><p>kept</p>")

315
lib/content/tests/crdt.sx Normal file
View File

@@ -0,0 +1,315 @@
;; Phase 3 — collaborative merge (CvRDT). The merge is a join: commutative,
;; associative, idempotent. Tests apply ops in any order, twice, and merge
;; replicas both ways — all must converge to identical state.
(st-bootstrap-classes!)
(content-bootstrap-blocks!)
(content-bootstrap-doc!)
(content-bootstrap-render!)
(define same? (fn (a b) (= (get a :elements) (get b :elements))))
;; ── position order (Logoot) ──
(content-test
"pos lt"
(crdt-pos-compare
(crdt-pos 1 0)
(crdt-pos 2 0))
-1)
(content-test
"pos gt"
(crdt-pos-compare
(crdt-pos 2 0)
(crdt-pos 1 0))
1)
(content-test
"pos eq"
(crdt-pos-compare
(crdt-pos 1 0)
(crdt-pos 1 0))
0)
(content-test
"pos actor tiebreak"
(crdt-pos-compare
(crdt-pos 1 1)
(crdt-pos 1 2))
-1)
(content-test
"between > left"
(<
(crdt-pos-compare
(crdt-pos 1 0)
(crdt-pos-between
(crdt-pos 1 0)
(crdt-pos 2 0)
9))
0)
true)
(content-test
"between < right"
(<
(crdt-pos-compare
(crdt-pos-between
(crdt-pos 1 0)
(crdt-pos 2 0)
9)
(crdt-pos 2 0))
0)
true)
(content-test
"between start < right"
(<
(crdt-pos-compare
(crdt-pos-between nil (crdt-pos 5 0) 9)
(crdt-pos 5 0))
0)
true)
(content-test
"between end > left"
(<
(crdt-pos-compare
(crdt-pos 5 0)
(crdt-pos-between (crdt-pos 5 0) nil 9))
0)
true)
;; ── build + materialise ──
(define
base
(crdt-insert
(crdt-insert
(crdt-empty)
"h"
"heading"
(crdt-pos 1 0)
(list (list "level" 1) (list "text" "Title"))
1
0)
"p"
"text"
(crdt-pos 2 0)
(list (list "text" "Body"))
1
0))
(content-test "order" (crdt-order base) (list "h" "p"))
(content-test
"materialize ids"
(doc-ids (crdt-materialize "d" base))
(list "h" "p"))
(content-test
"materialize render"
(asHTML (crdt-materialize "d" base))
"<h1>Title</h1><p>Body</p>")
;; ── commutativity: ops in any order converge ──
(define
opA
(crdt-op-insert
"x"
"text"
(crdt-pos 3 0)
(list (list "text" "X"))
2
1))
(define opB (crdt-op-update "p" "text" "Edited" 5 1))
(define opC (crdt-op-delete "h"))
(define s-abc (crdt-apply-all base (list opA opB opC)))
(define s-cba (crdt-apply-all base (list opC opB opA)))
(define s-bca (crdt-apply-all base (list opB opC opA)))
(content-test "commutative abc=cba" (same? s-abc s-cba) true)
(content-test "commutative abc=bca" (same? s-abc s-bca) true)
(content-test "commutative result order" (crdt-order s-abc) (list "p" "x"))
;; ── idempotence: applying ops twice changes nothing ──
(content-test
"idempotent ops"
(same? s-abc (crdt-apply-all s-abc (list opA opB opC)))
true)
;; ── update-before-insert is not lost ──
(define
ub
(crdt-apply-all
(crdt-empty)
(list
(crdt-op-update "z" "text" "late" 3 1)
(crdt-op-insert
"z"
"text"
(crdt-pos 1 0)
(list (list "text" "orig"))
1
1))))
(content-test
"update before insert kept"
(str (blk-send (doc-find (crdt-materialize "d" ub) "z") "text"))
"late")
;; ── delete-before-insert: remove-wins ──
(define
db
(crdt-apply-all
(crdt-empty)
(list
(crdt-op-delete "k")
(crdt-op-insert
"k"
"text"
(crdt-pos 1 0)
(list (list "text" "x"))
1
1))))
(content-test "delete before insert removes" (crdt-order db) (list))
;; ── concurrent inserts converge + deterministic order ──
(define
rA
(crdt-insert
base
"a1"
"text"
(crdt-pos 5 1)
(list (list "text" "A"))
2
1))
(define
rB
(crdt-insert
base
"b1"
"text"
(crdt-pos 5 2)
(list (list "text" "B"))
2
2))
(content-test
"merge commutes"
(same? (crdt-merge rA rB) (crdt-merge rB rA))
true)
(content-test
"merge order deterministic AB"
(crdt-order (crdt-merge rA rB))
(list "h" "p" "a1" "b1"))
(content-test
"merge order deterministic BA"
(crdt-order (crdt-merge rB rA))
(list "h" "p" "a1" "b1"))
;; ── merge idempotence ──
(define mAB (crdt-merge rA rB))
(content-test "merge idempotent self" (same? (crdt-merge mAB mAB) mAB) true)
(content-test
"merge idempotent remerge"
(same? (crdt-merge mAB rA) mAB)
true)
;; ── concurrent same-field update: LWW by (ts, actor) ──
(define u1 (crdt-update base "p" "text" "v-ts5" 5 1))
(define u2 (crdt-update base "p" "text" "v-ts7" 7 2))
(content-test
"LWW higher ts wins"
(str
(blk-send
(doc-find (crdt-materialize "d" (crdt-merge u1 u2)) "p")
"text"))
"v-ts7")
(content-test
"LWW commutes"
(same? (crdt-merge u1 u2) (crdt-merge u2 u1))
true)
(define t1 (crdt-update base "p" "text" "actor1" 9 1))
(define t2 (crdt-update base "p" "text" "actor2" 9 2))
(content-test
"LWW tie -> actor wins"
(str
(blk-send
(doc-find (crdt-materialize "d" (crdt-merge t1 t2)) "p")
"text"))
"actor2")
;; ── concurrent disjoint-field updates both survive ──
(define f1 (crdt-update base "h" "text" "NewTitle" 5 1))
(define f2 (crdt-update base "h" "level" 3 5 2))
(define fm (crdt-merge f1 f2))
(content-test
"disjoint field text"
(str (blk-send (doc-find (crdt-materialize "d" fm) "h") "text"))
"NewTitle")
(content-test
"disjoint field level"
(blk-send (doc-find (crdt-materialize "d" fm) "h") "level")
3)
(content-test "disjoint commutes" (same? fm (crdt-merge f2 f1)) true)
;; ── associativity ──
(define c1 (crdt-update base "p" "text" "c1" 4 1))
(define
c2
(crdt-insert
base
"n2"
"text"
(crdt-pos 6 0)
(list (list "text" "N"))
2
2))
(define c3 (crdt-delete base "h"))
(content-test
"associative"
(same?
(crdt-merge (crdt-merge c1 c2) c3)
(crdt-merge c1 (crdt-merge c2 c3)))
true)
(content-test
"merge-all = fold"
(same?
(crdt-merge-all (list c1 c2 c3))
(crdt-merge c1 (crdt-merge c2 c3)))
true)
;; ── full convergence: two replicas, divergent edits, merge both ways ──
(define
repl-1
(crdt-apply-all
base
(list
(crdt-op-update "p" "text" "from-1" 5 1)
(crdt-op-insert
"img"
"image"
(crdt-pos-between
(crdt-pos 1 0)
(crdt-pos 2 0)
1)
(list (list "src" "/a.png") (list "alt" "a"))
6
1))))
(define
repl-2
(crdt-apply-all
base
(list
(crdt-op-delete "h")
(crdt-op-update "p" "text" "from-2" 7 2))))
(content-test
"two-replica converges"
(same? (crdt-merge repl-1 repl-2) (crdt-merge repl-2 repl-1))
true)
(content-test
"two-replica result order"
(crdt-order (crdt-merge repl-1 repl-2))
(list "img" "p"))
(content-test
"two-replica LWW field"
(str
(blk-send
(doc-find (crdt-materialize "d" (crdt-merge repl-1 repl-2)) "p")
"text"))
"from-2")
(content-test
"two-replica idempotent"
(same?
(crdt-merge (crdt-merge repl-1 repl-2) repl-1)
(crdt-merge repl-1 repl-2))
true)

116
lib/content/tests/data.sx Normal file
View File

@@ -0,0 +1,116 @@
;; Extension — portable data serialization (to-data / from-data round-trip).
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-text!)
(content-bootstrap-markdown!)
(content-bootstrap-section!)
(content-bootstrap-table!)
(content-bootstrap-callout!)
(content-bootstrap-media!)
;; ── block->data shape ──
(define h (mk-heading "h" 2 "Hi"))
(content-test "block->data id" (get (block->data h) :id) "h")
(content-test "block->data type" (get (block->data h) :type) "heading")
(content-test "block->data fields" (get (block->data h) :fields) {:text "Hi" :level 2})
;; ── round-trip a mixed document with metadata ──
(define
d
(doc-with-meta
(doc-append
(doc-append
(doc-append
(doc-append (doc-empty "post") (mk-heading "h" 1 "Title"))
(mk-text "p" "Body"))
(mk-image "img" "/c.png" "cat"))
(mk-list "l" true (list "a" "b")))
{:slug "s" :title "T" :tags (list "x" "y")}))
(define rt (content/from-data (content/to-data d)))
(content-test "rt id" (doc-id rt) "post")
(content-test "rt title" (doc-title rt) "T")
(content-test "rt slug" (doc-slug rt) "s")
(content-test "rt tags" (doc-tags rt) (list "x" "y"))
(content-test "rt ids" (doc-ids rt) (list "h" "p" "img" "l"))
(content-test "rt render" (asHTML rt) (asHTML d))
(content-test
"rt heading level"
(blk-send (doc-find rt "h") "level")
1)
(content-test
"rt list items"
(blk-send (doc-find rt "l") "items")
(list "a" "b"))
;; ── nested sections round-trip ──
(define
ds
(doc-append
(doc-empty "d")
(mk-section
"s"
(list
(mk-heading "nh" 2 "N")
(mk-section "i" (list (mk-text "x" "deep")))))))
(define rts (content/from-data (content/to-data ds)))
(content-test "rt nested render" (asHTML rts) (asHTML ds))
(content-test "rt nested tree-ids" (doc-tree-ids rts) (doc-tree-ids ds))
(content-test
"rt nested deep-find"
(str (blk-send (doc-deep-find rts "x") "text"))
"deep")
;; ── table round-trip ──
(define
dtb
(doc-append
(doc-empty "d")
(mk-table "t" (list "A" "B") (list (list "1" "2")))))
(define rtt (content/from-data (content/to-data dtb)))
(content-test "rt table render" (asHTML rtt) (asHTML dtb))
(content-test
"rt table headers"
(table-headers (doc-find rtt "t"))
(list "A" "B"))
;; ── callout + media round-trip (regression: ct-class-for-type must know them) ──
(define
dcm
(doc-append
(doc-append (doc-empty "d") (mk-callout "co" "warning" "careful"))
(mk-video "vid" "/clip.mp4")))
(define rtcm (content/from-data (content/to-data dcm)))
(content-test "rt callout+media render" (asHTML rtcm) (asHTML dcm))
(content-test
"rt callout kind"
(str (blk-send (doc-find rtcm "co") "kind"))
"warning")
(content-test
"rt media kind"
(str (blk-send (doc-find rtcm "vid") "kind"))
"video")
(content-test
"rt callout+media types"
(doc-types rtcm)
(list "callout" "media"))
;; ── data is plain (no st-instance markers at top level) ──
(define dat (content/to-data d))
(content-test "data id field" (get dat :id) "post")
(content-test "data block count" (len (get dat :blocks)) 4)
(content-test
"data first block type"
(get (first (get dat :blocks)) :type)
"heading")
;; ── empty doc round-trip ──
(content-test
"rt empty ids"
(doc-ids (content/from-data (content/to-data (doc-empty "e"))))
(list))
(content-test
"rt no-meta title nil"
(doc-title (content/from-data (content/to-data (doc-empty "e"))))
nil)

132
lib/content/tests/doc.sx Normal file
View File

@@ -0,0 +1,132 @@
;; Phase 1 — ordered block document: apply edit ops, structural moves.
;; Every op returns a NEW document; the input is never mutated.
(st-bootstrap-classes!)
(content-bootstrap-blocks!)
(content-bootstrap-doc!)
(define h (mk-heading "h" 1 "Title"))
(define p1 (mk-text "p1" "First"))
(define p2 (mk-text "p2" "Second"))
(define img (mk-image "img" "/c.png" "cat"))
;; ── empty + construction ──
(define d0 (doc-empty "doc1"))
(content-test "empty id" (doc-id d0) "doc1")
(content-test "empty type" (doc-type d0) "document")
(content-test "empty count" (doc-count d0) 0)
(content-test "doc? on doc" (doc? d0) true)
(content-test "doc? on block" (doc? h) false)
;; ── append + order ──
(define d1 (doc-append (doc-append (doc-append d0 h) p1) p2))
(content-test "append count" (doc-count d1) 3)
(content-test "append order" (doc-ids d1) (list "h" "p1" "p2"))
(content-test "append types" (doc-types d1) (list "heading" "text" "text"))
(content-test "block-at 0" (blk-id (doc-block-at d1 0)) "h")
;; ── append is immutable ──
(content-test "append leaves original" (doc-count d0) 0)
;; ── find / index / has ──
(content-test "find p1" (blk-id (doc-find d1 "p1")) "p1")
(content-test "find missing" (doc-find d1 "nope") nil)
(content-test "index-of p2" (doc-index-of d1 "p2") 2)
(content-test "index-of missing" (doc-index-of d1 "nope") -1)
(content-test "has? yes" (doc-has? d1 "h") true)
(content-test "has? no" (doc-has? d1 "x") false)
;; ── insert-after ──
(define d2 (doc-insert-after d1 img "h"))
(content-test "insert-after order" (doc-ids d2) (list "h" "img" "p1" "p2"))
(content-test
"insert-after prepend"
(doc-ids (doc-insert-after d1 img nil))
(list "img" "h" "p1" "p2"))
(content-test
"insert-after missing appends"
(doc-ids (doc-insert-after d1 img "zzz"))
(list "h" "p1" "p2" "img"))
(content-test "insert-after immutable" (doc-ids d1) (list "h" "p1" "p2"))
;; ── insert-at ──
(content-test
"insert-at 0"
(doc-ids (doc-insert-at d1 img 0))
(list "img" "h" "p1" "p2"))
(content-test
"insert-at 1"
(doc-ids (doc-insert-at d1 img 1))
(list "h" "img" "p1" "p2"))
;; ── update (copy-on-write block) ──
(define d3 (doc-update d1 "p1" "text" "Edited"))
(content-test
"update value"
(str (blk-send (doc-find d3 "p1") "text"))
"Edited")
(content-test "update keeps order" (doc-ids d3) (list "h" "p1" "p2"))
(content-test
"update immutable"
(str (blk-send (doc-find d1 "p1") "text"))
"First")
;; ── delete ──
(define d4 (doc-delete d1 "p1"))
(content-test "delete order" (doc-ids d4) (list "h" "p2"))
(content-test "delete count" (doc-count d4) 2)
(content-test "delete immutable" (doc-count d1) 3)
(content-test
"delete missing no-op"
(doc-ids (doc-delete d1 "x"))
(list "h" "p1" "p2"))
;; ── move ──
(content-test
"move p2 to front"
(doc-ids (doc-move d1 "p2" 0))
(list "p2" "h" "p1"))
(content-test
"move h to end"
(doc-ids (doc-move d1 "h" 2))
(list "p1" "p2" "h"))
(content-test
"move missing no-op"
(doc-ids (doc-move d1 "x" 0))
(list "h" "p1" "p2"))
(content-test "move immutable" (doc-ids d1) (list "h" "p1" "p2"))
;; ── op constructors + interpreter ──
(content-test
"op-insert apply"
(doc-ids (doc-apply d1 (op-insert img "h")))
(list "h" "img" "p1" "p2"))
(content-test
"op-delete apply"
(doc-ids (doc-apply d1 (op-delete "h")))
(list "p1" "p2"))
(content-test
"op-move apply"
(doc-ids (doc-apply d1 (op-move "p2" 0)))
(list "p2" "h" "p1"))
(content-test
"op-update apply"
(str
(blk-send
(doc-find (doc-apply d1 (op-update "p1" "text" "X")) "p1")
"text"))
"X")
;; ── apply-all: a stream of ops ──
(define
ops
(list (op-insert img "h") (op-delete "p1") (op-move "p2" 0)))
(content-test
"apply-all"
(doc-ids (doc-apply-all d1 ops))
(list "p2" "h" "img"))
(content-test "apply-all immutable" (doc-ids d1) (list "h" "p1" "p2"))
(content-test
"apply-all empty"
(doc-ids (doc-apply-all d1 (list)))
(list "h" "p1" "p2"))

Some files were not shown because too many files have changed in this diff Show More