Compare commits
86 Commits
loops/fed-
...
loops/sear
| Author | SHA1 | Date | |
|---|---|---|---|
| db2a5dc6ab | |||
| cfa68c3db3 | |||
| cf4e613e43 | |||
| 911a2f57c0 | |||
| 7231cb651f | |||
| 5945b51cfd | |||
| 3ab8270a58 | |||
| 9d3b775b25 | |||
| 77ab827b91 | |||
| a3f9d4f6c9 | |||
| 4c84decc01 | |||
| 0f0da0319c | |||
| b8cf3eb1b8 | |||
| e2de5a4675 | |||
| 1e4cf25015 | |||
| 9c4a5d1913 | |||
| f91ac82434 | |||
| 5136249ae5 | |||
| 6fc61147a8 | |||
| 0122c41ecb | |||
| 58656b03e4 | |||
| b0feb7b01b | |||
| a979297959 | |||
| 37226cf6eb | |||
| 50a7f31a39 | |||
| 915f51b2b6 | |||
| e7501bdf8f | |||
| c3a0727645 | |||
| 1b94082a71 | |||
| 57184daaee | |||
| d9e2627b89 | |||
| bcabed6bce | |||
| 5098a8f015 | |||
| 9fe5c9044d | |||
| c6f397c3d9 | |||
| f553d5b0aa | |||
| 14486dd78f | |||
| 9036ce3400 | |||
| 8c91b34264 | |||
| a7902df365 | |||
| 459427512d | |||
| c50f5d5155 | |||
| f52ad1fac6 | |||
| 219e2fcfe7 | |||
| 1d3021d206 | |||
| fa99652970 | |||
| 4807bc9c58 | |||
| b693854dc4 | |||
| 674d8115b8 | |||
| 99f8f37ff8 | |||
| 9ed58bd0fc | |||
| ab04ec1cf7 | |||
| a019aa1edc | |||
| 1340c2626b | |||
| ff9abe3ae6 | |||
| 21bb17e4a6 | |||
| 4bd9262060 | |||
| 5b4a8be689 | |||
| 9f4c6787e4 | |||
| 5e27a7f0c9 | |||
| 86ddaf255c | |||
| 6c3b7d1cf9 | |||
| 2404a593bd | |||
| 44fb231391 | |||
| 171a08a2f8 | |||
| ba41f8a580 | |||
| 5f6d62f45b | |||
| ad21776002 | |||
| 4922b6e987 | |||
| 632e06d3cf | |||
| 48379e04bc | |||
| a94ffa0feb | |||
| 9acdbcb8d8 | |||
| 8ba66e0dc9 | |||
| 503bdf12d6 | |||
| e64d72f554 | |||
| e1c5fdae53 | |||
| 728a91e49f | |||
| 750035d543 | |||
| 976c6dd0ef | |||
| c1baca2e4e | |||
| 65467c232b | |||
| e60c74f8c3 | |||
| fe614fc531 | |||
| 4fc73a97f4 | |||
| 0f7444e0d5 |
@@ -1 +1 @@
|
||||
{"sessionId":"31c80255-eb92-43e4-8997-84ad84e27326","pid":90960,"procStart":"564684","acquiredAt":1777049890282}
|
||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||
@@ -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",
|
||||
|
||||
@@ -1561,7 +1561,66 @@
|
||||
(er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash)
|
||||
(er-register-pure-bif! "cid" "from_bytes" 1 er-bif-cid-from-bytes)
|
||||
(er-register-pure-bif! "cid" "to_string" 1 er-bif-cid-to-string)
|
||||
|
||||
;; ── binary_to_list / list_to_binary (Step 3b — term codec) ──────
|
||||
;; Standard Erlang semantics:
|
||||
;; binary_to_list(<<B1,B2,...>>) -> [B1, B2, ...] (Erlang cons of ints)
|
||||
;; list_to_binary(IoList) -> <<...>> (flattens nested
|
||||
;; iolists; elements are byte ints 0-255 or binaries)
|
||||
;; Bad arg / out-of-range byte / non-iolist element -> error:badarg.
|
||||
|
||||
(define er-bif-binary-to-list
|
||||
(fn (vs)
|
||||
(let ((v (nth vs 0)))
|
||||
(cond
|
||||
(not (er-binary? v))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((bs (get v :bytes)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(set! out (er-mk-cons (nth bs (- (- (len bs) 1) i)) out)))
|
||||
(range 0 (len bs)))
|
||||
out)))))
|
||||
|
||||
;; Walk an Erlang iolist, appending bytes to `acc` (a mutable SX list).
|
||||
;; Accepts: nil, cons-of-X, binary, integer in 0..255. Anything else
|
||||
;; signals failure by setting (nth fail 0) to true.
|
||||
(define er-iolist-walk!
|
||||
(fn (v acc fail)
|
||||
(cond
|
||||
(nth fail 0) nil
|
||||
(er-nil? v) nil
|
||||
(er-cons? v)
|
||||
(do (er-iolist-walk! (get v :head) acc fail)
|
||||
(er-iolist-walk! (get v :tail) acc fail))
|
||||
(er-binary? v)
|
||||
(for-each
|
||||
(fn (i) (append! acc (nth (get v :bytes) i)))
|
||||
(range 0 (len (get v :bytes))))
|
||||
(= (type-of v) "number")
|
||||
(cond
|
||||
(and (>= v 0) (<= v 255)) (append! acc v)
|
||||
:else (set-nth! fail 0 true))
|
||||
:else (set-nth! fail 0 true))))
|
||||
|
||||
(define er-bif-list-to-binary
|
||||
(fn (vs)
|
||||
(let ((v (nth vs 0)) (acc (list)) (fail (list false)))
|
||||
(cond
|
||||
(not (or (er-nil? v) (er-cons? v) (er-binary? v)))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(do
|
||||
(er-iolist-walk! v acc fail)
|
||||
(cond
|
||||
(nth fail 0)
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (er-mk-binary acc)))))))
|
||||
|
||||
(er-register-bif! "file" "list_dir" 1 er-bif-file-list-dir)
|
||||
(er-register-pure-bif! "erlang" "binary_to_list" 1 er-bif-binary-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_binary" 1 er-bif-list-to-binary)
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
;; Register everything at load time.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"language": "erlang",
|
||||
"total_pass": 729,
|
||||
"total": 729,
|
||||
"total_pass": 761,
|
||||
"total": 761,
|
||||
"suites": [
|
||||
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
||||
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
||||
{"name":"eval","pass":385,"total":385,"status":"ok"},
|
||||
{"name":"eval","pass":408,"total":408,"status":"ok"},
|
||||
{"name":"runtime","pass":93,"total":93,"status":"ok"},
|
||||
{"name":"ring","pass":4,"total":4,"status":"ok"},
|
||||
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
|
||||
{"name":"bank","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
||||
{"name":"fib","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"ffi","pass":28,"total":28,"status":"ok"},
|
||||
{"name":"ffi","pass":37,"total":37,"status":"ok"},
|
||||
{"name":"vm","pass":78,"total":78,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Erlang-on-SX Scoreboard
|
||||
|
||||
**Total: 729 / 729 tests passing**
|
||||
**Total: 761 / 761 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | tokenize | 62 | 62 |
|
||||
| ✅ | parse | 52 | 52 |
|
||||
| ✅ | eval | 385 | 385 |
|
||||
| ✅ | eval | 408 | 408 |
|
||||
| ✅ | runtime | 93 | 93 |
|
||||
| ✅ | ring | 4 | 4 |
|
||||
| ✅ | ping-pong | 4 | 4 |
|
||||
| ✅ | bank | 8 | 8 |
|
||||
| ✅ | echo | 7 | 7 |
|
||||
| ✅ | fib | 8 | 8 |
|
||||
| ✅ | ffi | 28 | 28 |
|
||||
| ✅ | ffi | 37 | 37 |
|
||||
| ✅ | vm | 78 | 78 |
|
||||
|
||||
|
||||
|
||||
@@ -228,9 +228,10 @@
|
||||
(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0)
|
||||
|
||||
;; ── BIFs: atom / list conversions ───────────────────────────────
|
||||
(er-eval-test "atom_to_list" (ev "atom_to_list(hello)") "hello")
|
||||
(er-eval-test "atom_to_list -> charlist length" (ev "length(atom_to_list(hello))") 5)
|
||||
(er-eval-test "atom_to_list -> head $h" (ev "hd(atom_to_list(hello))") 104)
|
||||
(er-eval-test "list_to_atom roundtrip"
|
||||
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo")
|
||||
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo") ;; round-trip via charlist
|
||||
(er-eval-test "list_to_atom fresh"
|
||||
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||
|
||||
@@ -1060,11 +1061,13 @@
|
||||
(er-eval-test "list_to_tuple roundtrip"
|
||||
(ev "tuple_size(list_to_tuple([10, 20, 30]))") 3)
|
||||
|
||||
(er-eval-test "integer_to_list" (ev "integer_to_list(42)") "42")
|
||||
(er-eval-test "integer_to_list neg" (ev "integer_to_list(-99)") "-99")
|
||||
(er-eval-test "integer_to_list -> charlist length" (ev "length(integer_to_list(42))") 2)
|
||||
(er-eval-test "integer_to_list 42 head $4" (ev "hd(integer_to_list(42))") 52)
|
||||
(er-eval-test "integer_to_list neg -> charlist length" (ev "length(integer_to_list(-99))") 3)
|
||||
(er-eval-test "integer_to_list -99 head $-" (ev "hd(integer_to_list(-99))") 45)
|
||||
(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123)
|
||||
(er-eval-test "list_to_integer roundtrip"
|
||||
(ev "list_to_integer(integer_to_list(7))") 7)
|
||||
(ev "list_to_integer(integer_to_list(7))") 7) ;; round-trip via charlist
|
||||
|
||||
(er-eval-test "is_function fun"
|
||||
(nm (ev "F = fun (X) -> X end, is_function(F)")) "true")
|
||||
@@ -1341,6 +1344,42 @@
|
||||
(get (nth (get er-rt-cap-result :elements) 4) :name) "true")
|
||||
|
||||
|
||||
|
||||
;; ── $X char literals (Step 3b substrate fix 2026-06-04) ──────────
|
||||
(er-eval-test "char $A" (ev "$A") 65)
|
||||
(er-eval-test "char $a" (ev "$a") 97)
|
||||
(er-eval-test "char $0 is digit, not escape-NUL" (ev "$0") 48)
|
||||
(er-eval-test "char $\\n is newline (10)" (ev "$\\n") 10)
|
||||
(er-eval-test "char $\\t is tab (9)" (ev "$\\t") 9)
|
||||
(er-eval-test "char $\\r is CR (13)" (ev "$\\r") 13)
|
||||
(er-eval-test "char $\\s is space (32)" (ev "$\\s") 32)
|
||||
(er-eval-test "char $\\0 is NUL (0)" (ev "$\\0") 0)
|
||||
(er-eval-test "char $\\\\ is backslash (92)" (ev "$\\\\") 92)
|
||||
(er-eval-test "[$h,$i] head is 104" (ev "hd([$h, $i])") 104)
|
||||
(er-eval-test "list_to_binary char-list -> bytes"
|
||||
(ev "byte_size(list_to_binary([$f, $e, $d]))") 3)
|
||||
(er-eval-test "list_to_binary char-list round-trip"
|
||||
(nm (ev "list_to_binary([$h, $i]) =:= <<104, 105>>")) "true")
|
||||
|
||||
|
||||
;; ── atom_to_list / integer_to_list charlist semantics (Step 3b substrate fix #3) ──
|
||||
(er-eval-test "atom_to_list hd is char code"
|
||||
(ev "hd(atom_to_list(hi))") 104)
|
||||
(er-eval-test "atom_to_list maps to bytes via list_to_binary"
|
||||
(ev "byte_size(list_to_binary(atom_to_list(hello)))") 5)
|
||||
(er-eval-test "atom_to_list -> list_to_binary -> bytes content"
|
||||
(nm (ev "list_to_binary(atom_to_list(ok)) =:= <<111, 107>>")) "true")
|
||||
(er-eval-test "integer_to_list 12345 -> 5 chars"
|
||||
(ev "length(integer_to_list(12345))") 5)
|
||||
(er-eval-test "integer_to_list -> bytes -> back"
|
||||
(ev "list_to_integer(integer_to_list(99999))") 99999)
|
||||
(er-eval-test "list_to_atom from charlist"
|
||||
(nm (ev "list_to_atom([$f, $o, $o])")) "foo")
|
||||
(er-eval-test "list_to_atom from SX-string back-compat"
|
||||
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||
(er-eval-test "list_to_integer from charlist"
|
||||
(ev "list_to_integer([$1, $0, $0])") 100)
|
||||
|
||||
(define
|
||||
er-eval-test-summary
|
||||
(str "eval " er-eval-test-pass "/" er-eval-test-count))
|
||||
|
||||
@@ -160,6 +160,51 @@
|
||||
(ffi-nm (ffi-ev "element(2, file:list_dir(\"/no/such/dir/xyz\"))"))
|
||||
"enoent")
|
||||
|
||||
(er-ffi-test
|
||||
"binary_to_list <<1,2,3>> length"
|
||||
(ffi-ev "length(binary_to_list(<<1,2,3,4,5>>))")
|
||||
5)
|
||||
|
||||
(er-ffi-test
|
||||
"binary_to_list hd byte"
|
||||
(ffi-ev "hd(binary_to_list(<<7,8,9>>))")
|
||||
7)
|
||||
|
||||
(er-ffi-test
|
||||
"binary_to_list empty -> []"
|
||||
(ffi-nm (ffi-ev "case binary_to_list(<<>>) of [] -> empty end"))
|
||||
"empty")
|
||||
|
||||
(er-ffi-test
|
||||
"list_to_binary flat list bytes"
|
||||
(ffi-ev "byte_size(list_to_binary([1,2,3]))")
|
||||
3)
|
||||
|
||||
(er-ffi-test
|
||||
"list_to_binary nested iolist"
|
||||
(ffi-ev "byte_size(list_to_binary([1, <<2,3>>, [4, [5]]]))")
|
||||
5)
|
||||
|
||||
(er-ffi-test
|
||||
"list_to_binary round-trip via binary_to_list"
|
||||
(ffi-nm (ffi-ev "list_to_binary(binary_to_list(<<10,20,30>>)) =:= <<10,20,30>>"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"binary_to_list non-binary -> error:badarg"
|
||||
(ffi-nm (ffi-ev "try binary_to_list(42) catch error:badarg -> ok end"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"list_to_binary out-of-range byte -> error:badarg"
|
||||
(ffi-nm (ffi-ev "try list_to_binary([300]) catch error:badarg -> ok end"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"list_to_binary non-iolist -> error:badarg"
|
||||
(ffi-nm (ffi-ev "try list_to_binary(42) catch error:badarg -> ok end"))
|
||||
"ok")
|
||||
|
||||
;; ── Still deferred (no host primitive): httpc (HTTP client, v2),
|
||||
;; sqlite-* (v2 indexes). Assert NOT registered so a future iteration
|
||||
;; that wires them without updating this suite fails fast.
|
||||
|
||||
@@ -229,13 +229,37 @@
|
||||
(= ch "$")
|
||||
(do
|
||||
(er-advance! 1)
|
||||
(if
|
||||
(and (< pos src-len) (= (er-cur) "\\"))
|
||||
(do
|
||||
(er-advance! 1)
|
||||
(when (< pos src-len) (er-advance! 1)))
|
||||
(when (< pos src-len) (er-advance! 1)))
|
||||
(er-emit! "integer" (slice src start pos) start)
|
||||
;; Emit the char's decimal code as the integer token value
|
||||
;; (was: raw "$X" text — parse-number then returned nil).
|
||||
(let
|
||||
((code (cond
|
||||
(>= pos src-len) 0
|
||||
(= (er-cur) "\\")
|
||||
(do
|
||||
(er-advance! 1)
|
||||
(let ((esc (if (< pos src-len) (er-cur) "")))
|
||||
(when (< pos src-len) (er-advance! 1))
|
||||
(cond
|
||||
(= esc "n") 10
|
||||
(= esc "t") 9
|
||||
(= esc "r") 13
|
||||
(= esc "s") 32
|
||||
(= esc "b") 8
|
||||
(= esc "e") 27
|
||||
(= esc "f") 12
|
||||
(= esc "v") 11
|
||||
(= esc "d") 127
|
||||
(= esc "0") 0
|
||||
(= esc "\\") 92
|
||||
(= esc "\"") 34
|
||||
(= esc "'") 39
|
||||
(= esc "") 0
|
||||
:else (char->integer (nth (string->list esc) 0)))))
|
||||
:else
|
||||
(let ((c (er-cur)))
|
||||
(er-advance! 1)
|
||||
(char->integer (nth (string->list c) 0))))))
|
||||
(er-emit! "integer" (str code) start))
|
||||
(scan!))
|
||||
(er-lower? ch)
|
||||
(do
|
||||
|
||||
@@ -107,7 +107,12 @@
|
||||
(let
|
||||
((ty (get node :type)))
|
||||
(cond
|
||||
(= ty "integer") (parse-number (get node :value))
|
||||
(= ty "integer")
|
||||
(let ((n (parse-number (get node :value))))
|
||||
(cond
|
||||
(= n nil) (error (str "Erlang: invalid integer literal: "
|
||||
(get node :value)))
|
||||
:else (truncate n)))
|
||||
(= ty "float") (parse-number (get node :value))
|
||||
(= ty "atom") (er-mk-atom (get node :value))
|
||||
(= ty "string") (get node :value)
|
||||
@@ -821,16 +826,30 @@
|
||||
(len (get v :elements))
|
||||
(error "Erlang: tuple_size: not a tuple")))))
|
||||
|
||||
(define er-string->charlist
|
||||
(fn (s)
|
||||
(let ((cs (string->list s)) (out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(set! out (er-mk-cons
|
||||
(char->integer (nth cs (- (- (len cs) 1) i)))
|
||||
out)))
|
||||
(range 0 (len cs)))
|
||||
out)))
|
||||
|
||||
(define
|
||||
er-bif-atom-to-list
|
||||
(fn
|
||||
(vs)
|
||||
(let
|
||||
((v (er-bif-arg1 vs "atom_to_list")))
|
||||
;; Standard Erlang: atom_to_list/1 returns an Erlang charlist
|
||||
;; (list of integer char codes). Was: SX string of :name —
|
||||
;; unusable from Erlang-land for [Char|T] / ++ / binary segments.
|
||||
(if
|
||||
(er-atom? v)
|
||||
(get v :name)
|
||||
(error "Erlang: atom_to_list: not an atom")))))
|
||||
(er-string->charlist (get v :name))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))))))
|
||||
|
||||
(define
|
||||
er-bif-list-to-atom
|
||||
@@ -838,10 +857,11 @@
|
||||
(vs)
|
||||
(let
|
||||
((v (er-bif-arg1 vs "list_to_atom")))
|
||||
(if
|
||||
(= (type-of v) "string")
|
||||
(er-mk-atom v)
|
||||
(error "Erlang: list_to_atom: not a string")))))
|
||||
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||
(let ((s (er-source-to-string v)))
|
||||
(cond
|
||||
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (er-mk-atom s))))))
|
||||
|
||||
;; ── lists module ─────────────────────────────────────────────────
|
||||
(define
|
||||
@@ -1597,10 +1617,12 @@
|
||||
(vs)
|
||||
(let
|
||||
((v (er-bif-arg1 vs "integer_to_list")))
|
||||
;; Standard Erlang: integer_to_list/1 returns an Erlang charlist
|
||||
;; (e.g. integer_to_list(42) -> [$4, $2] -> [52, 50]).
|
||||
(cond
|
||||
(not (= (type-of v) "number"))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (str v)))))
|
||||
:else (er-string->charlist (str v))))))
|
||||
|
||||
(define
|
||||
er-bif-list-to-integer
|
||||
@@ -1608,15 +1630,14 @@
|
||||
(vs)
|
||||
(let
|
||||
((v (er-bif-arg1 vs "list_to_integer")))
|
||||
(cond
|
||||
(not (= (type-of v) "string"))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (let
|
||||
((n (parse-number v)))
|
||||
(cond
|
||||
(= n nil)
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else n))))))
|
||||
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||
(let ((s (er-source-to-string v)))
|
||||
(cond
|
||||
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (let ((n (parse-number s)))
|
||||
(cond
|
||||
(= n nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else n)))))))
|
||||
|
||||
(define
|
||||
er-bif-is-function
|
||||
|
||||
38
lib/feed/acl.sx
Normal file
38
lib/feed/acl.sx
Normal file
@@ -0,0 +1,38 @@
|
||||
; feed/acl — per-viewer visibility filtering. The same candidate stream yields
|
||||
; different timelines for different viewers, so ACL is applied per request and
|
||||
; pre-ACL timelines are never cached.
|
||||
;
|
||||
; permit? is injected: (permit? viewer activity) -> bool. Wire a real acl-sx
|
||||
; predicate here; feed/permit-acl? is a self-contained default that reads an
|
||||
; optional :visible-to allowlist on the activity.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/-elem?), lib/feed/rank.sx (feed/top).
|
||||
|
||||
; default permit: actor always sees own activity; absent/nil :visible-to is
|
||||
; public; otherwise viewer must be in the allowlist.
|
||||
(define
|
||||
feed/permit-acl?
|
||||
(fn
|
||||
(viewer a)
|
||||
(or
|
||||
(equal? viewer (get a :actor))
|
||||
(let
|
||||
((allowed (get a :visible-to nil)))
|
||||
(if (= allowed nil) true (feed/-elem? viewer allowed))))))
|
||||
|
||||
(define feed/permit-public? (fn (viewer a) true))
|
||||
|
||||
; filter a stream to what viewer may read
|
||||
(define
|
||||
feed/visible
|
||||
(fn
|
||||
(stream viewer permit?)
|
||||
(feed/filter stream (fn (a) (permit? viewer a)))))
|
||||
|
||||
; the capstone: candidate stream -> ACL for viewer -> rank -> top-N
|
||||
(define
|
||||
feed/timeline
|
||||
(fn
|
||||
(stream viewer permit? score-fn n)
|
||||
(feed/top (feed/visible stream viewer permit?) score-fn n)))
|
||||
62
lib/feed/aggregate.sx
Normal file
62
lib/feed/aggregate.sx
Normal file
@@ -0,0 +1,62 @@
|
||||
; feed/aggregate — group-by / counting via key-reduce. Keys must be strings
|
||||
; (dict keys), so composite keys (actor, day) are joined into one string.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||
|
||||
; group activities into a dict: key-string -> (list of activities), order-preserving
|
||||
(define
|
||||
feed/group-by
|
||||
(fn
|
||||
(stream key-fn)
|
||||
(reduce
|
||||
(fn
|
||||
(g a)
|
||||
(let
|
||||
((k (key-fn a)))
|
||||
(assoc g k (append (get g k (list)) (list a)))))
|
||||
{}
|
||||
(feed/items stream))))
|
||||
|
||||
; key-string -> count
|
||||
(define
|
||||
feed/group-count
|
||||
(fn
|
||||
(stream key-fn)
|
||||
(reduce
|
||||
(fn
|
||||
(g a)
|
||||
(let
|
||||
((k (key-fn a)))
|
||||
(assoc g k (+ (get g k 0) 1))))
|
||||
{}
|
||||
(feed/items stream))))
|
||||
|
||||
; --- composite keys ---------------------------------------------------------
|
||||
|
||||
(define feed/day (fn (at window) (floor (/ at window))))
|
||||
|
||||
; (actor, day-bucket) -> "actor#day"
|
||||
(define
|
||||
feed/actor-day-key
|
||||
(fn
|
||||
(window)
|
||||
(fn
|
||||
(a)
|
||||
(string-append
|
||||
(get a :actor)
|
||||
"#"
|
||||
(number->string (feed/day (get a :at) window))))))
|
||||
|
||||
(define
|
||||
feed/by-actor-day
|
||||
(fn (stream window) (feed/group-count stream (feed/actor-day-key window))))
|
||||
|
||||
; per-actor activity counts
|
||||
(define
|
||||
feed/actor-counts
|
||||
(fn (stream) (feed/group-count stream feed/actor)))
|
||||
|
||||
; per-object activity counts (engagement)
|
||||
(define
|
||||
feed/object-counts
|
||||
(fn (stream) (feed/group-count stream feed/object)))
|
||||
24
lib/feed/api.sx
Normal file
24
lib/feed/api.sx
Normal file
@@ -0,0 +1,24 @@
|
||||
; feed/api — ergonomic API over the stream layer for non-APL callers.
|
||||
; A single mutable activity log; post appends, all returns it as a stream.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx (loaded by harness).
|
||||
|
||||
(define feed/-log (list))
|
||||
|
||||
; post — normalize then append. Returns the stored activity.
|
||||
(define
|
||||
feed/post
|
||||
(fn
|
||||
(raw)
|
||||
(let
|
||||
((a (feed/normalize raw)))
|
||||
(begin (set! feed/-log (append feed/-log (list a))) a))))
|
||||
|
||||
; all — the whole log as a stream (insertion order)
|
||||
(define feed/all (fn () (feed/stream feed/-log)))
|
||||
|
||||
; reset! — clear the log (test hygiene)
|
||||
(define feed/reset! (fn () (begin (set! feed/-log (list)) nil)))
|
||||
|
||||
; size — number of posted activities
|
||||
(define feed/size (fn () (len feed/-log)))
|
||||
125
lib/feed/conformance.sh
Executable file
125
lib/feed/conformance.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/feed/conformance.sh — run feed 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=(basic fanout rank integration content notify home dedupe trending mute page thread)
|
||||
|
||||
OUT_JSON="lib/feed/scoreboard.json"
|
||||
OUT_MD="lib/feed/scoreboard.md"
|
||||
|
||||
run_suite() {
|
||||
local suite=$1
|
||||
local file="lib/feed/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/feed/normalize.sx")
|
||||
(load "lib/feed/stream.sx")
|
||||
(load "lib/feed/api.sx")
|
||||
(load "lib/feed/fanout.sx")
|
||||
(load "lib/feed/dedupe.sx")
|
||||
(load "lib/feed/aggregate.sx")
|
||||
(load "lib/feed/rank.sx")
|
||||
(load "lib/feed/acl.sx")
|
||||
(load "lib/feed/fed.sx")
|
||||
(load "lib/feed/content.sx")
|
||||
(load "lib/feed/notify.sx")
|
||||
(load "lib/feed/home.sx")
|
||||
(load "lib/feed/trending.sx")
|
||||
(load "lib/feed/mute.sx")
|
||||
(load "lib/feed/page.sx")
|
||||
(load "lib/feed/thread.sx")
|
||||
(epoch 2)
|
||||
(eval "(define feed-test-pass 0)")
|
||||
(eval "(define feed-test-fail 0)")
|
||||
(eval "(define feed-test (fn (name got expected) (if (= got expected) (set! feed-test-pass (+ feed-test-pass 1)) (set! feed-test-fail (+ feed-test-fail 1)))))")
|
||||
(epoch 3)
|
||||
(load "${file}")
|
||||
(epoch 4)
|
||||
(eval "(list feed-test-pass feed-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 feed 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 '# feed Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/feed/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 ]
|
||||
68
lib/feed/content.sx
Normal file
68
lib/feed/content.sx
Normal file
@@ -0,0 +1,68 @@
|
||||
; feed/content — TF-IDF relevance over activity :tags. Rare tags carry more
|
||||
; signal, so an activity matching an uncommon tag ranks above one matching a
|
||||
; common tag. Composes with rank.sx: feed/tfidf-score is just another scorer.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/-distinct), lib/feed/rank.sx (feed/rank).
|
||||
|
||||
; document frequency: tag -> number of activities whose :tags contain it
|
||||
; (a tag repeated within one activity counts once toward df)
|
||||
(define
|
||||
feed/tag-df
|
||||
(fn
|
||||
(stream)
|
||||
(reduce
|
||||
(fn
|
||||
(df a)
|
||||
(reduce
|
||||
(fn (d t) (assoc d t (+ (get d t 0) 1)))
|
||||
df
|
||||
(feed/-distinct (get a :tags))))
|
||||
{}
|
||||
(feed/items stream))))
|
||||
|
||||
; inverse document frequency: tag -> log(N / df)
|
||||
(define
|
||||
feed/tag-idf
|
||||
(fn
|
||||
(stream)
|
||||
(let
|
||||
((n (feed/count stream)) (df (feed/tag-df stream)))
|
||||
(reduce
|
||||
(fn (idf t) (assoc idf t (log (/ n (get df t)))))
|
||||
{}
|
||||
(keys df)))))
|
||||
|
||||
; term frequency within one activity: tag -> occurrence count
|
||||
(define
|
||||
feed/-tf
|
||||
(fn
|
||||
(a)
|
||||
(reduce
|
||||
(fn (tf t) (assoc tf t (+ (get tf t 0) 1)))
|
||||
{}
|
||||
(get a :tags))))
|
||||
|
||||
; relevance of an activity to a query (list of tags) given precomputed idf:
|
||||
; sum over query tags of tf(tag in activity) * idf(tag in corpus)
|
||||
(define
|
||||
feed/tfidf-score
|
||||
(fn
|
||||
(idf query)
|
||||
(fn
|
||||
(a)
|
||||
(let
|
||||
((tf (feed/-tf a)))
|
||||
(reduce
|
||||
(fn
|
||||
(acc t)
|
||||
(+ acc (* (get tf t 0) (get idf t 0))))
|
||||
0
|
||||
query)))))
|
||||
|
||||
; rank a stream by relevance to query tags (idf computed over the stream itself)
|
||||
(define
|
||||
feed/by-relevance
|
||||
(fn
|
||||
(stream query)
|
||||
(feed/rank stream (feed/tfidf-score (feed/tag-idf stream) query))))
|
||||
76
lib/feed/dedupe.sx
Normal file
76
lib/feed/dedupe.sx
Normal file
@@ -0,0 +1,76 @@
|
||||
; feed/dedupe — collapse duplicate items, keeping first occurrence per key.
|
||||
; Each verb may want its own key (see briefing): "alice posted X" keys on
|
||||
; (actor verb object) — distinct per actor; "alice liked X / bob liked X"
|
||||
; collapse on (verb object) so the cross-actor likes fold into one.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/-elem? lives in fanout.sx).
|
||||
|
||||
; generic: dedupe a stream by key-fn, first occurrence wins (stable)
|
||||
(define
|
||||
feed/-dedup-by
|
||||
(fn
|
||||
(items key-fn)
|
||||
(get
|
||||
(reduce
|
||||
(fn
|
||||
(st x)
|
||||
(let
|
||||
((k (key-fn x)))
|
||||
(if (feed/-elem? k (get st :seen)) st {:seen (append (get st :seen) (list k)) :out (append (get st :out) (list x))})))
|
||||
{:seen (list) :out (list)}
|
||||
items)
|
||||
:out)))
|
||||
|
||||
(define
|
||||
feed/dedupe
|
||||
(fn
|
||||
(stream key-fn)
|
||||
(feed/stream (feed/-dedup-by (feed/items stream) key-fn))))
|
||||
|
||||
; --- keys -------------------------------------------------------------------
|
||||
|
||||
(define
|
||||
feed/activity-key
|
||||
(fn (a) (list (get a :actor) (get a :verb) (get a :object))))
|
||||
|
||||
; collapse cross-actor duplicates of the same verb+object (e.g. likes)
|
||||
(define feed/collapse-key (fn (a) (list (get a :verb) (get a :object))))
|
||||
|
||||
; per-receiver inbox key — one inbox event per (receiver, actor, verb, object)
|
||||
(define
|
||||
feed/event-key
|
||||
(fn
|
||||
(ev)
|
||||
(let
|
||||
((a (get ev :activity)))
|
||||
(list (get ev :to) (get a :actor) (get a :verb) (get a :object)))))
|
||||
|
||||
; verbs whose duplicates collapse across actors (reactions, not authorship).
|
||||
; rebindable: callers can (set! feed/collapse-verbs ...) to tune the policy.
|
||||
(define
|
||||
feed/collapse-verbs
|
||||
(list "like" "favourite" "follow" "boost" "repost"))
|
||||
|
||||
; per-verb key: collapse-verbs fold on (verb object); the rest key on
|
||||
; (actor verb object).
|
||||
(define
|
||||
feed/smart-key
|
||||
(fn
|
||||
(a)
|
||||
(if
|
||||
(feed/-elem? (get a :verb) feed/collapse-verbs)
|
||||
(feed/collapse-key a)
|
||||
(feed/activity-key a))))
|
||||
|
||||
; --- ready-made dedupers ----------------------------------------------------
|
||||
|
||||
(define feed/dedupe-activities (fn (s) (feed/dedupe s feed/activity-key)))
|
||||
|
||||
(define feed/dedupe-collapse (fn (s) (feed/dedupe s feed/collapse-key)))
|
||||
|
||||
; verb-aware: reactions collapse cross-actor, posts stay distinct per actor
|
||||
(define feed/dedupe-smart (fn (s) (feed/dedupe s feed/smart-key)))
|
||||
|
||||
; dedupe an inbox: at most one event per receiver per (actor verb object)
|
||||
(define feed/dedupe-inbox (fn (inbox) (feed/dedupe inbox feed/event-key)))
|
||||
114
lib/feed/fanout.sx
Normal file
114
lib/feed/fanout.sx
Normal file
@@ -0,0 +1,114 @@
|
||||
; feed/fanout — THE SHOWCASE. Fan activities out to followers via the APL outer
|
||||
; product (∘.×). activities ∘.× audience → an (activity × follower) matrix of
|
||||
; inbox events; flatten to a vector; guard-keep only real follow edges.
|
||||
;
|
||||
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||
;
|
||||
; NOTE: apl-outer's combiner result is run through (if (scalar? r) (disclose r) r).
|
||||
; A bare dict counts as a scalar (shape ()) and disclose nils it — so the combiner
|
||||
; must (enclose ...) its event dict; apl-outer then discloses it back intact.
|
||||
|
||||
; --- graph: {followee -> (list of followers)} -------------------------------
|
||||
|
||||
(define feed/followers (fn (graph user) (get graph user (list))))
|
||||
|
||||
; build a graph from (follower followee) edges: "follower follows followee"
|
||||
(define
|
||||
feed/follow-graph
|
||||
(fn
|
||||
(edges)
|
||||
(reduce
|
||||
(fn
|
||||
(g e)
|
||||
(let
|
||||
((follower (first e)) (followee (nth e 1)))
|
||||
(assoc
|
||||
g
|
||||
followee
|
||||
(append (feed/followers g followee) (list follower)))))
|
||||
{}
|
||||
edges)))
|
||||
|
||||
; --- helpers ----------------------------------------------------------------
|
||||
|
||||
; unwrap an apl-scalar (has :ravel) back to its value; pass activities through
|
||||
(define
|
||||
feed/-val
|
||||
(fn
|
||||
(x)
|
||||
(if (and (= (type-of x) "dict") (has-key? x :ravel)) (disclose x) x)))
|
||||
|
||||
(define feed/-elem? (fn (x lst) (some (fn (y) (equal? x y)) lst)))
|
||||
|
||||
(define
|
||||
feed/-distinct
|
||||
(fn
|
||||
(lst)
|
||||
(if
|
||||
(= (len lst) 0)
|
||||
(list)
|
||||
(get (apl-unique (make-array (list (len lst)) lst)) :ravel))))
|
||||
|
||||
; rank-2 matrix -> rank-1 stream of its ravel
|
||||
(define feed/-flatten (fn (arr) (feed/stream (get arr :ravel))))
|
||||
|
||||
; distinct receivers across the whole graph, sorted for determinism
|
||||
; (dict key order is unspecified, so sort to pin audience/recipient ordering)
|
||||
(define
|
||||
feed/audience
|
||||
(fn
|
||||
(graph)
|
||||
(sort
|
||||
(feed/-distinct
|
||||
(reduce
|
||||
(fn (acc k) (append acc (feed/followers graph k)))
|
||||
(list)
|
||||
(keys graph))))))
|
||||
|
||||
; --- the outer product ------------------------------------------------------
|
||||
|
||||
; one (activity, follower) inbox event, enclosed so apl-outer keeps the dict
|
||||
(define feed/-mk-event (fn (a f) (enclose {:activity (feed/-val a) :to (feed/-val f)})))
|
||||
|
||||
; keep events where :to actually follows the activity's actor
|
||||
(define
|
||||
feed/-edge?
|
||||
(fn
|
||||
(graph)
|
||||
(fn
|
||||
(ev)
|
||||
(feed/-elem?
|
||||
(get ev :to)
|
||||
(feed/followers graph (get (get ev :activity) :actor))))))
|
||||
|
||||
; fanout — activities ∘.× audience, flatten, guard-keep real edges
|
||||
(define
|
||||
feed/fanout
|
||||
(fn
|
||||
(stream graph)
|
||||
(let
|
||||
((matrix (apl-outer feed/-mk-event stream (feed/stream (feed/audience graph)))))
|
||||
(feed/filter (feed/-flatten matrix) (feed/-edge? graph)))))
|
||||
|
||||
; --- inbox queries ----------------------------------------------------------
|
||||
|
||||
(define
|
||||
feed/inbox-for
|
||||
(fn
|
||||
(inbox user)
|
||||
(feed/filter inbox (fn (ev) (equal? (get ev :to) user)))))
|
||||
|
||||
(define
|
||||
feed/recipients
|
||||
(fn
|
||||
(inbox)
|
||||
(feed/-distinct (map (fn (ev) (get ev :to)) (feed/items inbox)))))
|
||||
|
||||
; the activities (unwrapped) destined for a user
|
||||
(define
|
||||
feed/inbox-activities
|
||||
(fn
|
||||
(inbox user)
|
||||
(map
|
||||
(fn (ev) (get ev :activity))
|
||||
(feed/items (feed/inbox-for inbox user)))))
|
||||
60
lib/feed/fed.sx
Normal file
60
lib/feed/fed.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
; feed/fed — federation. Outbound: a local post fans out, then splits into local
|
||||
; vs remote inboxes; remote events are handed to an injected send-fn. Inbound:
|
||||
; peer activities merge into the local stream, deduped. Backfill: pull peer
|
||||
; history via an injected fetch-fn and merge.
|
||||
;
|
||||
; remote? / send-fn / fetch-fn are injected so real fed-sx transport wires in here
|
||||
; without feed depending on it.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx,
|
||||
; lib/feed/dedupe.sx.
|
||||
|
||||
; --- merge / ingest ---------------------------------------------------------
|
||||
|
||||
(define
|
||||
feed/merge
|
||||
(fn (s1 s2) (feed/stream (append (feed/items s1) (feed/items s2)))))
|
||||
|
||||
; merge a peer stream into local, dropping (actor verb object) duplicates
|
||||
(define
|
||||
feed/ingest
|
||||
(fn (local peer) (feed/dedupe-activities (feed/merge local peer))))
|
||||
|
||||
; --- inbound ----------------------------------------------------------------
|
||||
|
||||
; peer pushes raw activities to the local inbox; normalize + ingest
|
||||
(define
|
||||
feed/inbound
|
||||
(fn
|
||||
(local raw-activities)
|
||||
(feed/ingest local (feed/stream (map feed/normalize raw-activities)))))
|
||||
|
||||
; backfill on subscribe: pull peer history via fetch-fn, normalize, ingest
|
||||
(define
|
||||
feed/backfill
|
||||
(fn (local fetch-fn peer-id) (feed/inbound local (fetch-fn peer-id))))
|
||||
|
||||
; --- outbound ---------------------------------------------------------------
|
||||
|
||||
; split an inbox into local vs remote deliveries by viewer-id predicate
|
||||
(define feed/partition-inbox (fn (inbox remote?) {:local (feed/filter inbox (fn (ev) (not (remote? (get ev :to))))) :remote (feed/filter inbox (fn (ev) (remote? (get ev :to))))}))
|
||||
|
||||
; fan a stream out over the graph, then partition by locality
|
||||
(define
|
||||
feed/federate
|
||||
(fn
|
||||
(stream graph remote?)
|
||||
(feed/partition-inbox (feed/fanout stream graph) remote?)))
|
||||
|
||||
; deliver: hand each remote event to send-fn, return the local inbox to enqueue
|
||||
(define
|
||||
feed/deliver
|
||||
(fn
|
||||
(stream graph remote? send-fn)
|
||||
(let
|
||||
((parts (feed/federate stream graph remote?)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn (ev) (send-fn (get ev :to) (get ev :activity)))
|
||||
(feed/items (get parts :remote)))
|
||||
(get parts :local)))))
|
||||
23
lib/feed/home.sx
Normal file
23
lib/feed/home.sx
Normal file
@@ -0,0 +1,23 @@
|
||||
; feed/home — the capstone. A user's home timeline is the whole pipeline as one
|
||||
; line: fan all activities out over the follow graph, take the events landing in
|
||||
; the viewer's inbox, dedupe cross-posts, apply the viewer's ACL, rank, take N.
|
||||
;
|
||||
; Requires: fanout.sx, dedupe.sx, acl.sx (feed/timeline), rank.sx, stream.sx.
|
||||
|
||||
; the activities in a user's inbox, as a stream
|
||||
(define
|
||||
feed/inbox-stream
|
||||
(fn (inbox user) (feed/stream (feed/inbox-activities inbox user))))
|
||||
|
||||
; fanout ∘ inbox ∘ dedupe ∘ ACL ∘ rank ∘ take
|
||||
(define
|
||||
feed/home
|
||||
(fn
|
||||
(stream graph viewer permit? score-fn n)
|
||||
(feed/timeline
|
||||
(feed/dedupe-activities
|
||||
(feed/inbox-stream (feed/fanout stream graph) viewer))
|
||||
viewer
|
||||
permit?
|
||||
score-fn
|
||||
n)))
|
||||
44
lib/feed/mute.sx
Normal file
44
lib/feed/mute.sx
Normal file
@@ -0,0 +1,44 @@
|
||||
; feed/mute — viewer-controlled filtering. ACL (acl.sx) is author-controlled
|
||||
; visibility; mute is the reader's own preference: hide muted actors or tags.
|
||||
; Like ACL it is per-viewer and applied per request, never cached.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/-elem?).
|
||||
|
||||
; drop activities authored by a muted actor
|
||||
(define
|
||||
feed/mute-actors
|
||||
(fn
|
||||
(stream actors)
|
||||
(feed/filter
|
||||
stream
|
||||
(fn (a) (not (feed/-elem? (get a :actor) actors))))))
|
||||
|
||||
; drop activities carrying any muted tag
|
||||
(define
|
||||
feed/mute-tags
|
||||
(fn
|
||||
(stream tags)
|
||||
(feed/filter
|
||||
stream
|
||||
(fn (a) (not (some (fn (t) (feed/-elem? t tags)) (get a :tags)))))))
|
||||
|
||||
; drop activities about a muted object (thread mute)
|
||||
(define
|
||||
feed/mute-objects
|
||||
(fn
|
||||
(stream objects)
|
||||
(feed/filter
|
||||
stream
|
||||
(fn (a) (not (feed/-elem? (get a :object) objects))))))
|
||||
|
||||
; apply a viewer preference bag: {:mute-actors (...) :mute-tags (...) :mute-objects (...)}
|
||||
(define
|
||||
feed/apply-prefs
|
||||
(fn
|
||||
(stream prefs)
|
||||
(feed/mute-objects
|
||||
(feed/mute-tags
|
||||
(feed/mute-actors stream (get prefs :mute-actors (list)))
|
||||
(get prefs :mute-tags (list)))
|
||||
(get prefs :mute-objects (list)))))
|
||||
31
lib/feed/normalize.sx
Normal file
31
lib/feed/normalize.sx
Normal file
@@ -0,0 +1,31 @@
|
||||
; feed/normalize — coerce arbitrary input into the canonical activity record.
|
||||
; An activity is a small dict {:actor :verb :object :at :tags}; a stream is an
|
||||
; APL vector of such dicts (see stream.sx). Extra keys on the raw input survive
|
||||
; (e.g. :visible-to for ACL, peer metadata for federation) — :tags is the
|
||||
; flexible bag but the record is not closed.
|
||||
|
||||
(define feed/activity-keys (list :actor :verb :object :at :tags))
|
||||
|
||||
(define
|
||||
feed/normalize
|
||||
(fn
|
||||
(raw)
|
||||
(let
|
||||
((d (if (= (type-of raw) "dict") raw {})))
|
||||
(merge d {:actor (get d :actor "") :object (get d :object nil) :at (get d :at 0) :tags (let ((t (get d :tags (list)))) (if (list? t) t (list t))) :verb (get d :verb "post")}))))
|
||||
|
||||
(define
|
||||
feed/activity
|
||||
(fn (actor verb object at tags) (feed/normalize {:actor actor :object object :at at :tags tags :verb verb})))
|
||||
|
||||
(define feed/actor (fn (a) (get a :actor)))
|
||||
(define feed/verb (fn (a) (get a :verb)))
|
||||
(define feed/object (fn (a) (get a :object)))
|
||||
(define feed/at (fn (a) (get a :at)))
|
||||
(define feed/tags (fn (a) (get a :tags)))
|
||||
|
||||
(define
|
||||
feed/activity?
|
||||
(fn
|
||||
(a)
|
||||
(and (= (type-of a) "dict") (has-key? a :actor) (has-key? a :verb))))
|
||||
45
lib/feed/notify.sx
Normal file
45
lib/feed/notify.sx
Normal file
@@ -0,0 +1,45 @@
|
||||
; feed/notify — a notification feed is a thin layer over a recipient's inbox:
|
||||
; the events directed at a user, optionally verb-filtered, and a digest that
|
||||
; collapses "alice, bob and 1 other liked X" by (verb, object).
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/inbox-for, feed/-elem?).
|
||||
|
||||
; all inbox events for a user (their raw notifications)
|
||||
(define feed/notifications (fn (inbox user) (feed/inbox-for inbox user)))
|
||||
|
||||
; restrict to notification-worthy verbs (e.g. (list "like" "reply" "follow"))
|
||||
(define
|
||||
feed/notify-verbs
|
||||
(fn
|
||||
(inbox user verbs)
|
||||
(feed/filter
|
||||
(feed/inbox-for inbox user)
|
||||
(fn (ev) (feed/-elem? (get (get ev :activity) :verb) verbs)))))
|
||||
|
||||
; group key "verb|object" — deterministic, sortable
|
||||
(define
|
||||
feed/-notify-key
|
||||
(fn
|
||||
(ev)
|
||||
(let
|
||||
((a (get ev :activity)))
|
||||
(string-append (get a :verb) "|" (get a :object)))))
|
||||
|
||||
; digest: one entry per (verb, object) with the distinct actors and a count,
|
||||
; ordered by key for determinism.
|
||||
(define
|
||||
feed/notify-digest
|
||||
(fn
|
||||
(inbox user)
|
||||
(let
|
||||
((events (feed/items (feed/inbox-for inbox user))))
|
||||
(let
|
||||
((groups (reduce (fn (g ev) (let ((a (get ev :activity)) (k (feed/-notify-key ev))) (let ((cur (get g k {:object (get a :object) :actors (list) :verb (get a :verb)}))) (assoc g k (assoc cur :actors (append (get cur :actors) (list (get a :actor)))))))) {} events)))
|
||||
(map
|
||||
(fn
|
||||
(k)
|
||||
(let
|
||||
((grp (get groups k)))
|
||||
(assoc grp :count (len (get grp :actors)))))
|
||||
(sort (keys groups)))))))
|
||||
50
lib/feed/page.sx
Normal file
50
lib/feed/page.sx
Normal file
@@ -0,0 +1,50 @@
|
||||
; feed/page — pagination. Offset/limit for indexed access, and cursor-based
|
||||
; (by :at) for recency feeds, which is stable under inserts: a cursor is the
|
||||
; :at of the last item seen, and the next page is the newest items older than it.
|
||||
;
|
||||
; Requires: lib/feed/stream.sx (feed/recent, feed/take, feed/filter).
|
||||
|
||||
; --- offset / limit ---------------------------------------------------------
|
||||
|
||||
(define
|
||||
feed/page
|
||||
(fn
|
||||
(stream offset limit)
|
||||
(feed/stream (take (drop (feed/items stream) offset) limit))))
|
||||
|
||||
(define
|
||||
feed/page-count
|
||||
(fn (stream limit) (ceil (/ (feed/count stream) limit))))
|
||||
|
||||
; --- cursor (recency feeds) -------------------------------------------------
|
||||
|
||||
; activities strictly older than cursor (scroll down / load older)
|
||||
(define
|
||||
feed/before
|
||||
(fn
|
||||
(stream cursor)
|
||||
(feed/filter stream (fn (a) (< (get a :at) cursor)))))
|
||||
|
||||
; activities strictly newer than cursor (load newer / "N new posts")
|
||||
(define
|
||||
feed/after
|
||||
(fn
|
||||
(stream cursor)
|
||||
(feed/filter stream (fn (a) (> (get a :at) cursor)))))
|
||||
|
||||
; one page: the `limit` newest activities older than cursor, newest first
|
||||
(define
|
||||
feed/page-before
|
||||
(fn
|
||||
(stream cursor limit)
|
||||
(feed/take (feed/recent (feed/before stream cursor)) limit)))
|
||||
|
||||
; cursor to fetch the next (older) page: :at of the last item of a page,
|
||||
; or nil when the page is empty (end of feed)
|
||||
(define
|
||||
feed/next-cursor
|
||||
(fn
|
||||
(page)
|
||||
(let
|
||||
((items (feed/items page)))
|
||||
(if (= (len items) 0) nil (get (last items) :at)))))
|
||||
92
lib/feed/rank.sx
Normal file
92
lib/feed/rank.sx
Normal file
@@ -0,0 +1,92 @@
|
||||
; feed/rank — scoring + ranking. Scorers are (activity -> number). Ranking is a
|
||||
; stable two-pass grade-down: first by :at descending (the tiebreak), then by
|
||||
; score descending — so ties resolve by recency, then by input order. Fully
|
||||
; deterministic on ties.
|
||||
;
|
||||
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||
|
||||
; --- scorers ----------------------------------------------------------------
|
||||
|
||||
; recency: half-life decay. score = 0.5 ^ (age / half-life). at==now -> 1.0.
|
||||
(define
|
||||
feed/recency
|
||||
(fn
|
||||
(now half-life)
|
||||
(fn (a) (expt 0.5 (/ (- now (get a :at)) half-life)))))
|
||||
|
||||
; velocity: how many of this actor's activities fall in (at-window, at] —
|
||||
; a burst of recent activity scores higher.
|
||||
(define
|
||||
feed/velocity
|
||||
(fn
|
||||
(stream window)
|
||||
(fn
|
||||
(a)
|
||||
(len
|
||||
(filter
|
||||
(fn
|
||||
(b)
|
||||
(and
|
||||
(equal? (get b :actor) (get a :actor))
|
||||
(<= (get b :at) (get a :at))
|
||||
(> (get b :at) (- (get a :at) window))))
|
||||
(feed/items stream))))))
|
||||
|
||||
; engagement: how many activities in the stream touch this activity's :object
|
||||
(define
|
||||
feed/engagement
|
||||
(fn
|
||||
(stream)
|
||||
(fn
|
||||
(a)
|
||||
(len
|
||||
(filter
|
||||
(fn (b) (equal? (get b :object) (get a :object)))
|
||||
(feed/items stream))))))
|
||||
|
||||
; composite: weighted sum. parts = (list (list weight scorer) ...)
|
||||
(define
|
||||
feed/composite
|
||||
(fn
|
||||
(parts)
|
||||
(fn
|
||||
(a)
|
||||
(reduce
|
||||
(fn (acc p) (+ acc (* (first p) ((nth p 1) a))))
|
||||
0
|
||||
parts))))
|
||||
|
||||
; --- ranking ----------------------------------------------------------------
|
||||
|
||||
; stable reorder of items by key-fn, descending (grade-down is stable)
|
||||
(define
|
||||
feed/-desc-by
|
||||
(fn
|
||||
(items key-fn)
|
||||
(let
|
||||
((keys (make-array (list (len items)) (map key-fn items))))
|
||||
(let
|
||||
((order (get (apl-grade-down keys) :ravel)))
|
||||
(map (fn (i) (nth items (- i 1))) order)))))
|
||||
|
||||
; rank by score descending; ties -> :at descending -> input order
|
||||
(define
|
||||
feed/rank
|
||||
(fn
|
||||
(stream score-fn)
|
||||
(let
|
||||
((by-at (feed/-desc-by (feed/items stream) feed/at)))
|
||||
(feed/stream (feed/-desc-by by-at score-fn)))))
|
||||
|
||||
; attach a :score to each activity (for inspection / debugging)
|
||||
(define
|
||||
feed/with-scores
|
||||
(fn
|
||||
(stream score-fn)
|
||||
(feed/stream
|
||||
(map (fn (a) (assoc a :score (score-fn a))) (feed/items stream)))))
|
||||
|
||||
; top-N ranked timeline
|
||||
(define
|
||||
feed/top
|
||||
(fn (stream score-fn n) (feed/take (feed/rank stream score-fn) n)))
|
||||
19
lib/feed/scoreboard.json
Normal file
19
lib/feed/scoreboard.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"suites": {
|
||||
"basic": {"pass": 30, "fail": 0},
|
||||
"fanout": {"pass": 29, "fail": 0},
|
||||
"rank": {"pass": 24, "fail": 0},
|
||||
"integration": {"pass": 22, "fail": 0},
|
||||
"content": {"pass": 15, "fail": 0},
|
||||
"notify": {"pass": 8, "fail": 0},
|
||||
"home": {"pass": 6, "fail": 0},
|
||||
"dedupe": {"pass": 9, "fail": 0},
|
||||
"trending": {"pass": 11, "fail": 0},
|
||||
"mute": {"pass": 9, "fail": 0},
|
||||
"page": {"pass": 14, "fail": 0},
|
||||
"thread": {"pass": 12, "fail": 0}
|
||||
},
|
||||
"total_pass": 189,
|
||||
"total_fail": 0,
|
||||
"total": 189
|
||||
}
|
||||
19
lib/feed/scoreboard.md
Normal file
19
lib/feed/scoreboard.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# feed Conformance Scoreboard
|
||||
|
||||
_Generated by `lib/feed/conformance.sh`_
|
||||
|
||||
| Suite | Pass | Fail | Total |
|
||||
|-------|-----:|-----:|------:|
|
||||
| basic | 30 | 0 | 30 |
|
||||
| fanout | 29 | 0 | 29 |
|
||||
| rank | 24 | 0 | 24 |
|
||||
| integration | 22 | 0 | 22 |
|
||||
| content | 15 | 0 | 15 |
|
||||
| notify | 8 | 0 | 8 |
|
||||
| home | 6 | 0 | 6 |
|
||||
| dedupe | 9 | 0 | 9 |
|
||||
| trending | 11 | 0 | 11 |
|
||||
| mute | 9 | 0 | 9 |
|
||||
| page | 14 | 0 | 14 |
|
||||
| thread | 12 | 0 | 12 |
|
||||
| **Total** | **189** | **0** | **189** |
|
||||
75
lib/feed/stream.sx
Normal file
75
lib/feed/stream.sx
Normal file
@@ -0,0 +1,75 @@
|
||||
; feed/stream — a stream is an APL vector (rank-1 array) whose ravel holds
|
||||
; activity dicts. Operations lift APL primitives onto this shape: filter via
|
||||
; compress (/), sort via grade (⍋), take via ↑, reverse via ⌽.
|
||||
;
|
||||
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx (loaded by harness).
|
||||
|
||||
(define feed/stream (fn (acts) (make-array (list (len acts)) acts)))
|
||||
|
||||
(define feed/items (fn (s) (get s :ravel)))
|
||||
|
||||
(define feed/count (fn (s) (len (get s :ravel))))
|
||||
|
||||
(define feed/empty (feed/stream (list)))
|
||||
|
||||
(define feed/empty? (fn (s) (= (feed/count s) 0)))
|
||||
|
||||
; filter — bool mask ∘ compress. pred : activity -> truthy
|
||||
(define
|
||||
feed/filter
|
||||
(fn
|
||||
(s pred)
|
||||
(let
|
||||
((items (get s :ravel)))
|
||||
(let
|
||||
((mask (make-array (list (len items)) (map (fn (a) (if (pred a) 1 0)) items))))
|
||||
(apl-compress mask s)))))
|
||||
|
||||
; sort-by — ascending, stable on ties (grade-up is stable). key-fn : activity -> number
|
||||
(define
|
||||
feed/sort-by
|
||||
(fn
|
||||
(s key-fn)
|
||||
(let
|
||||
((items (get s :ravel)))
|
||||
(let
|
||||
((keys (make-array (list (len items)) (map key-fn items))))
|
||||
(let
|
||||
((order (get (apl-grade-up keys) :ravel)))
|
||||
(feed/stream (map (fn (i) (nth items (- i 1))) order)))))))
|
||||
|
||||
(define feed/sort-by-at (fn (s) (feed/sort-by s feed/at)))
|
||||
|
||||
; newest-first: ascending sort then reverse (⌽)
|
||||
(define feed/recent (fn (s) (apl-reverse (feed/sort-by-at s))))
|
||||
|
||||
; take N (↑), clamped to stream length so it never over-takes/pads
|
||||
(define
|
||||
feed/take
|
||||
(fn
|
||||
(s n)
|
||||
(let
|
||||
((c (feed/count s)))
|
||||
(if (>= n c) s (apl-take (apl-scalar n) s)))))
|
||||
|
||||
(define feed/reverse (fn (s) (apl-reverse s)))
|
||||
|
||||
; common predicates
|
||||
(define
|
||||
feed/by-actor
|
||||
(fn (s actor) (feed/filter s (fn (a) (equal? (get a :actor) actor)))))
|
||||
|
||||
(define
|
||||
feed/by-verb
|
||||
(fn (s verb) (feed/filter s (fn (a) (equal? (get a :verb) verb)))))
|
||||
|
||||
(define
|
||||
feed/by-object
|
||||
(fn
|
||||
(s object)
|
||||
(feed/filter s (fn (a) (equal? (get a :object) object)))))
|
||||
|
||||
; activities at or after timestamp t
|
||||
(define
|
||||
feed/since
|
||||
(fn (s t) (feed/filter s (fn (a) (>= (get a :at) t)))))
|
||||
118
lib/feed/tests/basic.sx
Normal file
118
lib/feed/tests/basic.sx
Normal file
@@ -0,0 +1,118 @@
|
||||
; Phase 1 — normalize, stream ops, api. Uses the feed-test harness
|
||||
; (feed-test name got expected) provided by conformance.sh.
|
||||
|
||||
; ---------- normalize ----------
|
||||
|
||||
(feed-test
|
||||
"normalize default actor"
|
||||
(feed/actor (feed/normalize {}))
|
||||
"")
|
||||
(feed-test
|
||||
"normalize default verb"
|
||||
(feed/verb (feed/normalize {}))
|
||||
"post")
|
||||
(feed-test
|
||||
"normalize default at"
|
||||
(feed/at (feed/normalize {}))
|
||||
0)
|
||||
(feed-test
|
||||
"normalize default object"
|
||||
(feed/object (feed/normalize {}))
|
||||
nil)
|
||||
(feed-test
|
||||
"normalize default tags"
|
||||
(feed/tags (feed/normalize {}))
|
||||
(list))
|
||||
(feed-test
|
||||
"normalize keeps actor"
|
||||
(feed/actor (feed/normalize {:actor "alice"}))
|
||||
"alice")
|
||||
(feed-test
|
||||
"normalize keeps verb"
|
||||
(feed/verb (feed/normalize {:verb "like"}))
|
||||
"like")
|
||||
(feed-test
|
||||
"normalize scalar tag -> list"
|
||||
(feed/tags (feed/normalize {:tags "x"}))
|
||||
(list "x"))
|
||||
(feed-test
|
||||
"normalize list tags kept"
|
||||
(feed/tags (feed/normalize {:tags (list "a" "b")}))
|
||||
(list "a" "b"))
|
||||
(feed-test
|
||||
"activity constructor at"
|
||||
(feed/at (feed/activity "a" "post" "o" 5 (list)))
|
||||
5)
|
||||
(feed-test
|
||||
"activity? on activity"
|
||||
(feed/activity? (feed/normalize {:actor "a"}))
|
||||
true)
|
||||
(feed-test "activity? on number" (feed/activity? 5) false)
|
||||
(feed-test "activity? on bare dict" (feed/activity? {:foo 1}) false)
|
||||
|
||||
; ---------- stream ----------
|
||||
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 30 (list))
|
||||
(feed/activity "bob" "like" "p1" 10 (list))
|
||||
(feed/activity "alice" "post" "p2" 20 (list)))))
|
||||
|
||||
(feed-test "stream count" (feed/count S) 3)
|
||||
(feed-test "stream items len" (len (feed/items S)) 3)
|
||||
(feed-test
|
||||
"sort-by-at actors asc"
|
||||
(map feed/actor (feed/items (feed/sort-by-at S)))
|
||||
(list "bob" "alice" "alice"))
|
||||
(feed-test
|
||||
"recent newest first"
|
||||
(map feed/at (feed/items (feed/recent S)))
|
||||
(list 30 20 10))
|
||||
(feed-test
|
||||
"take 2 of recent"
|
||||
(feed/count (feed/take (feed/recent S) 2))
|
||||
2)
|
||||
(feed-test
|
||||
"take clamps past end"
|
||||
(feed/count (feed/take S 10))
|
||||
3)
|
||||
(feed-test
|
||||
"by-actor alice count"
|
||||
(feed/count (feed/by-actor S "alice"))
|
||||
2)
|
||||
(feed-test
|
||||
"by-verb like actor"
|
||||
(map feed/actor (feed/items (feed/by-verb S "like")))
|
||||
(list "bob"))
|
||||
(feed-test
|
||||
"by-object p1 count"
|
||||
(feed/count (feed/by-object S "p1"))
|
||||
2)
|
||||
(feed-test
|
||||
"since 20 count"
|
||||
(feed/count (feed/since S 20))
|
||||
2)
|
||||
(feed-test
|
||||
"reverse ats"
|
||||
(map feed/at (feed/items (feed/reverse S)))
|
||||
(list 20 10 30))
|
||||
(feed-test "empty? on empty" (feed/empty? feed/empty) true)
|
||||
(feed-test
|
||||
"empty? on filtered-out"
|
||||
(feed/empty? (feed/by-actor S "zzz"))
|
||||
true)
|
||||
|
||||
; ---------- api ----------
|
||||
|
||||
(feed/reset!)
|
||||
(feed/post {:actor "x" :at 1 :verb "post"})
|
||||
(feed/post {:actor "y" :at 2 :verb "like"})
|
||||
(feed-test "api size after posts" (feed/size) 2)
|
||||
(feed-test "api all count" (feed/count (feed/all)) 2)
|
||||
(feed-test
|
||||
"post returns normalized verb"
|
||||
(feed/verb (feed/post {:actor "z"}))
|
||||
"post")
|
||||
(feed-test "api size after third post" (feed/size) 3)
|
||||
85
lib/feed/tests/content.sx
Normal file
85
lib/feed/tests/content.sx
Normal file
@@ -0,0 +1,85 @@
|
||||
; Follow-up — TF-IDF content ranking over :tags. (feed-test name got expected)
|
||||
|
||||
(define
|
||||
corpus
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/normalize {:actor "u" :object "o1" :at 10 :tags (list "cats" "funny")})
|
||||
(feed/normalize {:actor "u" :object "o2" :at 20 :tags (list "cats" "news")})
|
||||
(feed/normalize {:actor "u" :object "o3" :at 30 :tags (list "politics" "news")})
|
||||
(feed/normalize {:actor "u" :object "o4" :at 40 :tags (list "cats")}))))
|
||||
|
||||
; ---------- document frequency ----------
|
||||
|
||||
(feed-test "df cats" (get (feed/tag-df corpus) "cats") 3)
|
||||
(feed-test "df news" (get (feed/tag-df corpus) "news") 2)
|
||||
(feed-test "df funny" (get (feed/tag-df corpus) "funny") 1)
|
||||
(feed-test "df politics" (get (feed/tag-df corpus) "politics") 1)
|
||||
(feed-test "df full" (feed/tag-df corpus) {:news 2 :funny 1 :politics 1 :cats 3})
|
||||
|
||||
; ---------- inverse document frequency ----------
|
||||
|
||||
(feed-test
|
||||
"idf news = log(4/2)"
|
||||
(get (feed/tag-idf corpus) "news")
|
||||
(log 2))
|
||||
(feed-test
|
||||
"idf funny = log(4/1)"
|
||||
(get (feed/tag-idf corpus) "funny")
|
||||
(log 4))
|
||||
(feed-test
|
||||
"rarer tag has higher idf"
|
||||
(>
|
||||
(get (feed/tag-idf corpus) "funny")
|
||||
(get (feed/tag-idf corpus) "cats"))
|
||||
true)
|
||||
|
||||
; ---------- tf-idf scoring ----------
|
||||
|
||||
(define idf (feed/tag-idf corpus))
|
||||
|
||||
(feed-test
|
||||
"score query funny on o1"
|
||||
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats" "funny")}))
|
||||
(log 4))
|
||||
(feed-test
|
||||
"score query funny on non-match"
|
||||
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
|
||||
0)
|
||||
(feed-test
|
||||
"unknown query tag scores 0"
|
||||
((feed/tfidf-score idf (list "zzz")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
|
||||
0)
|
||||
|
||||
; ---------- ranking by relevance ----------
|
||||
|
||||
; query news: o2,o3 match (score log2), o1,o4 don't (0); ties break by :at desc
|
||||
(feed-test
|
||||
"by-relevance news order"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/by-relevance corpus (list "news"))))
|
||||
(list "o3" "o2" "o4" "o1"))
|
||||
|
||||
; query funny: only o1 matches -> ranks first
|
||||
(feed-test
|
||||
"by-relevance funny first"
|
||||
(get
|
||||
(nth (feed/items (feed/by-relevance corpus (list "funny"))) 0)
|
||||
:object)
|
||||
"o1")
|
||||
|
||||
; query (cats news): o2 carries both tags -> highest combined tf-idf
|
||||
(feed-test
|
||||
"by-relevance cats+news top"
|
||||
(get
|
||||
(nth
|
||||
(feed/items (feed/by-relevance corpus (list "cats" "news")))
|
||||
0)
|
||||
:object)
|
||||
"o2")
|
||||
|
||||
(feed-test
|
||||
"by-relevance preserves count"
|
||||
(feed/count (feed/by-relevance corpus (list "cats")))
|
||||
4)
|
||||
56
lib/feed/tests/dedupe.sx
Normal file
56
lib/feed/tests/dedupe.sx
Normal file
@@ -0,0 +1,56 @@
|
||||
; Follow-up — verb-aware (smart) dedupe. (feed-test name got expected)
|
||||
|
||||
; reactions (like/follow) collapse cross-actor; posts stay distinct per actor
|
||||
(define
|
||||
M
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "like" "X" 1 (list))
|
||||
(feed/activity "bob" "like" "X" 2 (list))
|
||||
(feed/activity "alice" "post" "P" 3 (list))
|
||||
(feed/activity "bob" "post" "P" 4 (list))
|
||||
(feed/activity "alice" "follow" "C" 5 (list))
|
||||
(feed/activity "bob" "follow" "C" 6 (list))))) ; collapses
|
||||
|
||||
(feed-test
|
||||
"smart dedupe total"
|
||||
(feed/count (feed/dedupe-smart M))
|
||||
4)
|
||||
(feed-test
|
||||
"smart keeps both posts"
|
||||
(feed/count (feed/by-verb (feed/dedupe-smart M) "post"))
|
||||
2)
|
||||
(feed-test
|
||||
"smart collapses likes to one"
|
||||
(feed/count (feed/by-verb (feed/dedupe-smart M) "like"))
|
||||
1)
|
||||
(feed-test
|
||||
"smart collapses follows to one"
|
||||
(feed/count (feed/by-verb (feed/dedupe-smart M) "follow"))
|
||||
1)
|
||||
(feed-test
|
||||
"collapsed like keeps first actor"
|
||||
(map feed/actor (feed/items (feed/by-verb (feed/dedupe-smart M) "like")))
|
||||
(list "alice"))
|
||||
|
||||
; contrast: plain activity dedupe keeps cross-actor likes distinct
|
||||
(feed-test
|
||||
"activity dedupe keeps both likes"
|
||||
(feed/count (feed/by-verb (feed/dedupe-activities M) "like"))
|
||||
2)
|
||||
|
||||
; contrast: blanket collapse folds the two posts (same verb+object) too
|
||||
(feed-test
|
||||
"collapse dedupe folds posts"
|
||||
(feed/count (feed/by-verb (feed/dedupe-collapse M) "post"))
|
||||
1)
|
||||
|
||||
; smart-key dispatch
|
||||
(feed-test
|
||||
"smart-key reaction -> (verb object)"
|
||||
(feed/smart-key (feed/activity "alice" "like" "X" 0 (list)))
|
||||
(list "like" "X"))
|
||||
(feed-test
|
||||
"smart-key post -> (actor verb object)"
|
||||
(feed/smart-key (feed/activity "alice" "post" "P" 0 (list)))
|
||||
(list "alice" "post" "P"))
|
||||
187
lib/feed/tests/fanout.sx
Normal file
187
lib/feed/tests/fanout.sx
Normal file
@@ -0,0 +1,187 @@
|
||||
; Phase 2 — fanout via outer product + dedupe. (feed-test name got expected)
|
||||
|
||||
; ---------- graph ----------
|
||||
|
||||
; edges: (follower followee). bob,carol follow alice; carol,dave follow bob.
|
||||
(define
|
||||
G
|
||||
(feed/follow-graph
|
||||
(list
|
||||
(list "bob" "alice")
|
||||
(list "carol" "alice")
|
||||
(list "carol" "bob")
|
||||
(list "dave" "bob"))))
|
||||
|
||||
(feed-test "followers alice" (feed/followers G "alice") (list "bob" "carol"))
|
||||
(feed-test "followers bob" (feed/followers G "bob") (list "carol" "dave"))
|
||||
(feed-test "followers unknown" (feed/followers G "zzz") (list))
|
||||
(feed-test "audience distinct" (feed/audience G) (list "bob" "carol" "dave"))
|
||||
|
||||
; ---------- fanout ----------
|
||||
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 10 (list))
|
||||
(feed/activity "alice" "post" "p2" 20 (list))
|
||||
(feed/activity "bob" "like" "p1" 30 (list)))))
|
||||
|
||||
(define IB (feed/fanout S G))
|
||||
|
||||
(feed-test "fanout total edges" (feed/count IB) 6)
|
||||
(feed-test
|
||||
"inbox bob count"
|
||||
(feed/count (feed/inbox-for IB "bob"))
|
||||
2)
|
||||
(feed-test
|
||||
"inbox carol count"
|
||||
(feed/count (feed/inbox-for IB "carol"))
|
||||
3)
|
||||
(feed-test
|
||||
"inbox dave count"
|
||||
(feed/count (feed/inbox-for IB "dave"))
|
||||
1)
|
||||
(feed-test
|
||||
"inbox alice (follows none)"
|
||||
(feed/count (feed/inbox-for IB "alice"))
|
||||
0)
|
||||
(feed-test
|
||||
"recipients order"
|
||||
(feed/recipients IB)
|
||||
(list "bob" "carol" "dave"))
|
||||
(feed-test
|
||||
"bob inbox objects"
|
||||
(map (fn (a) (get a :object)) (feed/inbox-activities IB "bob"))
|
||||
(list "p1" "p2"))
|
||||
(feed-test
|
||||
"dave inbox objects"
|
||||
(map (fn (a) (get a :object)) (feed/inbox-activities IB "dave"))
|
||||
(list "p1"))
|
||||
(feed-test
|
||||
"dave inbox verb"
|
||||
(map (fn (a) (get a :verb)) (feed/inbox-activities IB "dave"))
|
||||
(list "like"))
|
||||
|
||||
; empty graph → no audience → no edges
|
||||
(feed-test
|
||||
"empty graph fanout"
|
||||
(feed/count (feed/fanout S {}))
|
||||
0)
|
||||
|
||||
; actor nobody follows produces no edges
|
||||
(define
|
||||
Sghost
|
||||
(feed/stream (list (feed/activity "ghost" "post" "g1" 5 (list)))))
|
||||
(feed-test
|
||||
"unfollowed actor fanout"
|
||||
(feed/count (feed/fanout Sghost G))
|
||||
0)
|
||||
|
||||
; ---------- high fanout (popular actor) ----------
|
||||
|
||||
(define
|
||||
Gstar
|
||||
(feed/follow-graph
|
||||
(list
|
||||
(list "u1" "star")
|
||||
(list "u2" "star")
|
||||
(list "u3" "star")
|
||||
(list "u4" "star")
|
||||
(list "u5" "star"))))
|
||||
(define
|
||||
Sstar
|
||||
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
|
||||
(feed-test
|
||||
"star fanout count"
|
||||
(feed/count (feed/fanout Sstar Gstar))
|
||||
5)
|
||||
(feed-test "star audience size" (len (feed/audience Gstar)) 5)
|
||||
|
||||
; ---------- mutual follow ----------
|
||||
|
||||
(define Gmut (feed/follow-graph (list (list "a" "b") (list "b" "a"))))
|
||||
(define
|
||||
Smut
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "a" "post" "pa" 1 (list))
|
||||
(feed/activity "b" "post" "pb" 2 (list)))))
|
||||
(define IBmut (feed/fanout Smut Gmut))
|
||||
(feed-test "mutual total" (feed/count IBmut) 2)
|
||||
(feed-test
|
||||
"mutual a gets pb"
|
||||
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "a"))
|
||||
(list "pb"))
|
||||
(feed-test
|
||||
"mutual b gets pa"
|
||||
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "b"))
|
||||
(list "pa"))
|
||||
|
||||
; ---------- dedupe ----------
|
||||
|
||||
(define
|
||||
Sdup2
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 1 (list))
|
||||
(feed/activity "alice" "post" "p1" 9 (list))
|
||||
(feed/activity "alice" "post" "p2" 2 (list)))))
|
||||
(feed-test
|
||||
"dedupe-activities collapses dup"
|
||||
(feed/count (feed/dedupe-activities Sdup2))
|
||||
2)
|
||||
(feed-test
|
||||
"dedupe-activities keeps distinct"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/dedupe-activities Sdup2)))
|
||||
(list "p1" "p2"))
|
||||
|
||||
(define
|
||||
Slikes
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "like" "X" 1 (list))
|
||||
(feed/activity "bob" "like" "X" 2 (list))
|
||||
(feed/activity "carol" "like" "Y" 3 (list)))))
|
||||
(feed-test
|
||||
"collapse cross-actor likes"
|
||||
(feed/count (feed/dedupe-collapse Slikes))
|
||||
2)
|
||||
(feed-test
|
||||
"collapse keeps distinct objects"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/dedupe-collapse Slikes)))
|
||||
(list "X" "Y"))
|
||||
|
||||
(feed-test
|
||||
"activity-key shape"
|
||||
(feed/activity-key (feed/activity "a" "post" "o" 0 (list)))
|
||||
(list "a" "post" "o"))
|
||||
(feed-test
|
||||
"collapse-key shape"
|
||||
(feed/collapse-key (feed/activity "a" "like" "o" 0 (list)))
|
||||
(list "like" "o"))
|
||||
|
||||
; cross-post: alice posts p1 twice → bob's inbox has it twice → dedupe-inbox → once
|
||||
(define
|
||||
Scross
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 1 (list))
|
||||
(feed/activity "alice" "post" "p1" 5 (list)))))
|
||||
(define IBcross (feed/fanout Scross G))
|
||||
(feed-test
|
||||
"cross-post raw bob count"
|
||||
(feed/count (feed/inbox-for IBcross "bob"))
|
||||
2)
|
||||
(feed-test
|
||||
"cross-post deduped bob count"
|
||||
(feed/count (feed/inbox-for (feed/dedupe-inbox IBcross) "bob"))
|
||||
1)
|
||||
(feed-test
|
||||
"dedupe-inbox keeps distinct receivers"
|
||||
(feed/count (feed/dedupe-inbox IBcross))
|
||||
2)
|
||||
73
lib/feed/tests/home.sx
Normal file
73
lib/feed/tests/home.sx
Normal file
@@ -0,0 +1,73 @@
|
||||
; Follow-up — feed/home capstone pipeline. (feed-test name got expected)
|
||||
|
||||
; alice follows star and bob (edges: follower followee)
|
||||
(define
|
||||
G
|
||||
(feed/follow-graph (list (list "alice" "star") (list "alice" "bob"))))
|
||||
|
||||
; star posts s1 then s2; bob posts b1; star re-posts s1 (cross-post dup);
|
||||
; zoe posts z1 (alice does NOT follow zoe)
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "star" "post" "s1" 10 (list))
|
||||
(feed/activity "star" "post" "s2" 20 (list))
|
||||
(feed/activity "bob" "post" "b1" 15 (list))
|
||||
(feed/activity "star" "post" "s1" 5 (list))
|
||||
(feed/activity "zoe" "post" "z1" 30 (list)))))
|
||||
|
||||
(define rec (feed/recency 100 10))
|
||||
|
||||
(feed-test
|
||||
"home count (deduped, followed only)"
|
||||
(feed/count (feed/home S G "alice" feed/permit-public? rec 10))
|
||||
3)
|
||||
|
||||
(feed-test
|
||||
"home order by recency"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/home S G "alice" feed/permit-public? rec 10)))
|
||||
(list "s2" "b1" "s1"))
|
||||
|
||||
(feed-test
|
||||
"home excludes unfollowed zoe"
|
||||
(feed/-elem?
|
||||
"z1"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/home S G "alice" feed/permit-public? rec 10))))
|
||||
false)
|
||||
|
||||
(feed-test
|
||||
"home top-2"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/home S G "alice" feed/permit-public? rec 2)))
|
||||
(list "s2" "b1"))
|
||||
|
||||
(feed-test
|
||||
"home dedupes cross-post (one s1)"
|
||||
(len
|
||||
(filter
|
||||
(fn (o) (equal? o "s1"))
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items
|
||||
(feed/home S G "alice" feed/permit-public? rec 10)))))
|
||||
1)
|
||||
|
||||
; ACL applied per-viewer in the home pipeline
|
||||
(define
|
||||
Sacl
|
||||
(feed/stream
|
||||
(list (feed/normalize {:actor "star" :object "pub" :at 20}) (feed/normalize {:actor "star" :object "sec" :visible-to (list "carol") :at 25}))))
|
||||
(define Gacl (feed/follow-graph (list (list "alice" "star"))))
|
||||
|
||||
(feed-test
|
||||
"home hides activity alice not permitted"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/home Sacl Gacl "alice" feed/permit-acl? rec 10)))
|
||||
(list "pub"))
|
||||
155
lib/feed/tests/integration.sx
Normal file
155
lib/feed/tests/integration.sx
Normal file
@@ -0,0 +1,155 @@
|
||||
; Phase 4 — visibility (ACL) + federation, and the end-to-end timeline.
|
||||
; (feed-test name got expected)
|
||||
|
||||
; ---------- ACL visibility ----------
|
||||
; pub: public. sec: bob, allows carol. dm: frank, allows dave.
|
||||
|
||||
(define
|
||||
C
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/normalize {:actor "alice" :object "pub" :at 10})
|
||||
(feed/normalize {:actor "bob" :object "sec" :visible-to (list "carol") :at 20})
|
||||
(feed/normalize {:actor "frank" :object "dm" :visible-to (list "dave") :at 30}))))
|
||||
|
||||
(feed-test
|
||||
"public visible to anyone"
|
||||
(feed/count (feed/visible C "zoe" feed/permit-acl?))
|
||||
1)
|
||||
(feed-test
|
||||
"carol sees allowlisted + public"
|
||||
(feed/count (feed/visible C "carol" feed/permit-acl?))
|
||||
2)
|
||||
(feed-test
|
||||
"dave sees dm + public"
|
||||
(feed/count (feed/visible C "dave" feed/permit-acl?))
|
||||
2)
|
||||
(feed-test
|
||||
"author always sees own private"
|
||||
(feed/count (feed/visible C "frank" feed/permit-acl?))
|
||||
2)
|
||||
(feed-test
|
||||
"permit-public? lets all through"
|
||||
(feed/count (feed/visible C "zoe" feed/permit-public?))
|
||||
3)
|
||||
(feed-test
|
||||
"visible objects for dave"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/visible C "dave" feed/permit-acl?)))
|
||||
(list "pub" "dm"))
|
||||
|
||||
; per-viewer: same stream, different timelines
|
||||
(feed-test
|
||||
"zoe timeline differs from carol"
|
||||
(not
|
||||
(=
|
||||
(feed/count (feed/visible C "zoe" feed/permit-acl?))
|
||||
(feed/count (feed/visible C "carol" feed/permit-acl?))))
|
||||
true)
|
||||
|
||||
; ---------- federation: merge / ingest ----------
|
||||
|
||||
(define
|
||||
L
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 10 (list))
|
||||
(feed/activity "alice" "post" "p2" 20 (list)))))
|
||||
(define
|
||||
P
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p2" 20 (list))
|
||||
(feed/activity "peer" "post" "p9" 25 (list)))))
|
||||
|
||||
(feed-test "merge concatenates" (feed/count (feed/merge L P)) 4)
|
||||
(feed-test
|
||||
"ingest dedupes overlap"
|
||||
(feed/count (feed/ingest L P))
|
||||
3)
|
||||
|
||||
(feed-test
|
||||
"inbound normalizes + ingests"
|
||||
(feed/count (feed/inbound L (list {:actor "peer" :object "p9" :at 25} {:actor "alice" :object "p1" :at 10})))
|
||||
3)
|
||||
|
||||
; backfill via injected fetch-fn
|
||||
(define peer-history (fn (peer-id) (list {:actor peer-id :object "h1" :at 1} {:actor peer-id :object "h2" :at 2})))
|
||||
(feed-test
|
||||
"backfill merges peer history"
|
||||
(feed/count (feed/backfill L peer-history "remote"))
|
||||
4)
|
||||
(feed-test
|
||||
"backfill objects present"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items
|
||||
(feed/by-actor (feed/backfill L peer-history "remote") "remote")))
|
||||
(list "h1" "h2"))
|
||||
|
||||
; ---------- federation: outbound partition ----------
|
||||
|
||||
; bob (local), alice@remote + carol@remote (remote) follow star
|
||||
(define
|
||||
Gf
|
||||
(feed/follow-graph
|
||||
(list
|
||||
(list "bob" "star")
|
||||
(list "alice@remote" "star")
|
||||
(list "carol@remote" "star"))))
|
||||
(define
|
||||
Sf
|
||||
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
|
||||
(define
|
||||
remote?
|
||||
(fn (id) (feed/-elem? id (list "alice@remote" "carol@remote"))))
|
||||
(define parts (feed/federate Sf Gf remote?))
|
||||
|
||||
(feed-test "local deliveries" (feed/count (get parts :local)) 1)
|
||||
(feed-test "remote deliveries" (feed/count (get parts :remote)) 2)
|
||||
(feed-test
|
||||
"local recipient is bob"
|
||||
(feed/recipients (get parts :local))
|
||||
(list "bob"))
|
||||
|
||||
; deliver: send-fn receives each remote event, local inbox returned
|
||||
(define sent (list))
|
||||
(define send-fn (fn (to act) (set! sent (append sent (list to)))))
|
||||
(define local-inbox (feed/deliver Sf Gf remote? send-fn))
|
||||
(feed-test "deliver returns local inbox" (feed/count local-inbox) 1)
|
||||
(feed-test "deliver sent to both remotes" (len sent) 2)
|
||||
(feed-test "deliver remote targets" sent (list "alice@remote" "carol@remote"))
|
||||
|
||||
; ---------- end-to-end: federated, ACL-filtered, ranked timeline ----------
|
||||
|
||||
(define
|
||||
base
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/normalize {:actor "alice" :object "a1" :at 100})
|
||||
(feed/normalize {:actor "bob" :object "b1" :visible-to (list "carol") :at 90})
|
||||
(feed/normalize {:actor "eve" :object "e1" :visible-to (list "dave") :at 80}))))
|
||||
(define federated (feed/inbound base (list {:actor "peer" :object "x1" :at 110})))
|
||||
(define rec (feed/recency 120 10))
|
||||
(define
|
||||
carol-tl
|
||||
(feed/timeline federated "carol" feed/permit-acl? rec 3))
|
||||
|
||||
; eve's :visible-to excludes carol -> filtered out; peer/alice public, bob allows carol
|
||||
(feed-test "carol federated timeline count" (feed/count carol-tl) 3)
|
||||
(feed-test
|
||||
"carol timeline order (recency)"
|
||||
(map (fn (a) (get a :object)) (feed/items carol-tl))
|
||||
(list "x1" "a1" "b1"))
|
||||
(feed-test
|
||||
"eve dm excluded from carol"
|
||||
(feed/-elem? "e1" (map (fn (a) (get a :object)) (feed/items carol-tl)))
|
||||
false)
|
||||
(feed-test
|
||||
"dave sees eve dm not bob"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items
|
||||
(feed/timeline federated "dave" feed/permit-acl? rec 5)))
|
||||
(list "x1" "a1" "e1"))
|
||||
68
lib/feed/tests/mute.sx
Normal file
68
lib/feed/tests/mute.sx
Normal file
@@ -0,0 +1,68 @@
|
||||
; Follow-up — viewer mute/block filtering. (feed-test name got expected)
|
||||
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/normalize {:actor "alice" :object "P1" :at 1 :tags (list "news")})
|
||||
(feed/normalize {:actor "bob" :object "P2" :at 2 :tags (list "spam")})
|
||||
(feed/normalize {:actor "alice" :object "P3" :at 3 :tags (list "cats")})
|
||||
(feed/normalize {:actor "carol" :object "P4" :at 4 :tags (list "news" "spam")}))))
|
||||
|
||||
; ---------- mute actors ----------
|
||||
|
||||
(feed-test
|
||||
"mute bob drops his post"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/mute-actors S (list "bob"))))
|
||||
(list "P1" "P3" "P4"))
|
||||
(feed-test
|
||||
"mute alice drops two"
|
||||
(feed/count (feed/mute-actors S (list "alice")))
|
||||
2)
|
||||
(feed-test
|
||||
"mute nobody keeps all"
|
||||
(feed/count (feed/mute-actors S (list)))
|
||||
4)
|
||||
|
||||
; ---------- mute tags ----------
|
||||
|
||||
(feed-test
|
||||
"mute spam tag drops two"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/mute-tags S (list "spam"))))
|
||||
(list "P1" "P3"))
|
||||
(feed-test
|
||||
"mute news+cats leaves spam-only"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/mute-tags S (list "news" "cats"))))
|
||||
(list "P2"))
|
||||
|
||||
; ---------- mute objects ----------
|
||||
|
||||
(feed-test
|
||||
"mute object P3 (thread mute)"
|
||||
(feed/count (feed/mute-objects S (list "P3")))
|
||||
3)
|
||||
|
||||
; ---------- combined prefs ----------
|
||||
|
||||
(feed-test
|
||||
"apply-prefs actors + tags"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/apply-prefs S {:mute-actors (list "bob") :mute-tags (list "cats")})))
|
||||
(list "P1" "P4"))
|
||||
(feed-test
|
||||
"apply-prefs empty keeps all"
|
||||
(feed/count (feed/apply-prefs S {}))
|
||||
4)
|
||||
(feed-test
|
||||
"apply-prefs all three filters"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/apply-prefs S {:mute-objects (list "P3") :mute-actors (list "carol") :mute-tags (list "spam")})))
|
||||
(list "P1"))
|
||||
69
lib/feed/tests/notify.sx
Normal file
69
lib/feed/tests/notify.sx
Normal file
@@ -0,0 +1,69 @@
|
||||
; Follow-up — notification feed over an inbox. (feed-test name got expected)
|
||||
|
||||
; an inbox is a stream of {:to receiver :activity act} events
|
||||
(define mk-ev (fn (to act) {:activity act :to to}))
|
||||
|
||||
(define
|
||||
IB
|
||||
(feed/stream
|
||||
(list
|
||||
(mk-ev "alice" (feed/activity "bob" "like" "P" 10 (list)))
|
||||
(mk-ev "alice" (feed/activity "carol" "like" "P" 20 (list)))
|
||||
(mk-ev "alice" (feed/activity "dave" "reply" "Q" 30 (list)))
|
||||
(mk-ev "bob" (feed/activity "eve" "like" "R" 40 (list))))))
|
||||
|
||||
; ---------- raw notifications ----------
|
||||
|
||||
(feed-test
|
||||
"alice notification count"
|
||||
(feed/count (feed/notifications IB "alice"))
|
||||
3)
|
||||
(feed-test
|
||||
"bob notification count"
|
||||
(feed/count (feed/notifications IB "bob"))
|
||||
1)
|
||||
(feed-test
|
||||
"zoe no notifications"
|
||||
(feed/count (feed/notifications IB "zoe"))
|
||||
0)
|
||||
|
||||
; ---------- verb filtering ----------
|
||||
|
||||
(feed-test
|
||||
"alice likes only"
|
||||
(feed/count (feed/notify-verbs IB "alice" (list "like")))
|
||||
2)
|
||||
(feed-test
|
||||
"alice replies only"
|
||||
(feed/count (feed/notify-verbs IB "alice" (list "reply")))
|
||||
1)
|
||||
(feed-test
|
||||
"alice like+reply"
|
||||
(feed/count (feed/notify-verbs IB "alice" (list "like" "reply")))
|
||||
3)
|
||||
(feed-test
|
||||
"alice follow (none)"
|
||||
(feed/count (feed/notify-verbs IB "alice" (list "follow")))
|
||||
0)
|
||||
|
||||
; ---------- digest ----------
|
||||
|
||||
(define dig (feed/notify-digest IB "alice"))
|
||||
|
||||
(feed-test "digest group count" (len dig) 2)
|
||||
(feed-test
|
||||
"digest sorted by key (like|P before reply|Q)"
|
||||
(map (fn (g) (get g :object)) dig)
|
||||
(list "P" "Q"))
|
||||
(feed-test
|
||||
"like group actors"
|
||||
(get (nth dig 0) :actors)
|
||||
(list "bob" "carol"))
|
||||
(feed-test "like group count" (get (nth dig 0) :count) 2)
|
||||
(feed-test "like group verb" (get (nth dig 0) :verb) "like")
|
||||
(feed-test "reply group count" (get (nth dig 1) :count) 1)
|
||||
(feed-test
|
||||
"reply group actors"
|
||||
(get (nth dig 1) :actors)
|
||||
(list "dave"))
|
||||
(feed-test "empty digest for zoe" (feed/notify-digest IB "zoe") (list))
|
||||
86
lib/feed/tests/page.sx
Normal file
86
lib/feed/tests/page.sx
Normal file
@@ -0,0 +1,86 @@
|
||||
; Follow-up — pagination (offset + cursor). (feed-test name got expected)
|
||||
|
||||
; ---------- offset / limit ----------
|
||||
|
||||
(define
|
||||
O
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "u" "post" "o1" 1 (list))
|
||||
(feed/activity "u" "post" "o2" 2 (list))
|
||||
(feed/activity "u" "post" "o3" 3 (list))
|
||||
(feed/activity "u" "post" "o4" 4 (list))
|
||||
(feed/activity "u" "post" "o5" 5 (list)))))
|
||||
|
||||
(feed-test
|
||||
"page 1"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/page O 0 2)))
|
||||
(list "o1" "o2"))
|
||||
(feed-test
|
||||
"page 2"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/page O 2 2)))
|
||||
(list "o3" "o4"))
|
||||
(feed-test
|
||||
"page 3 (partial)"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/page O 4 2)))
|
||||
(list "o5"))
|
||||
(feed-test
|
||||
"page past end empty"
|
||||
(feed/count (feed/page O 10 2))
|
||||
0)
|
||||
(feed-test "page-count 5/2 = 3" (feed/page-count O 2) 3)
|
||||
(feed-test "page-count 5/5 = 1" (feed/page-count O 5) 1)
|
||||
|
||||
; ---------- cursor (recency) ----------
|
||||
|
||||
(define
|
||||
R
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "u" "post" "a" 50 (list))
|
||||
(feed/activity "u" "post" "b" 40 (list))
|
||||
(feed/activity "u" "post" "c" 30 (list))
|
||||
(feed/activity "u" "post" "d" 20 (list))
|
||||
(feed/activity "u" "post" "e" 10 (list)))))
|
||||
|
||||
(define p1 (feed/page-before R 100 2))
|
||||
(feed-test
|
||||
"cursor page 1 newest first"
|
||||
(map (fn (a) (get a :object)) (feed/items p1))
|
||||
(list "a" "b"))
|
||||
(feed-test "next cursor after page 1" (feed/next-cursor p1) 40)
|
||||
|
||||
(define p2 (feed/page-before R (feed/next-cursor p1) 2))
|
||||
(feed-test
|
||||
"cursor page 2"
|
||||
(map (fn (a) (get a :object)) (feed/items p2))
|
||||
(list "c" "d"))
|
||||
(feed-test "next cursor after page 2" (feed/next-cursor p2) 20)
|
||||
|
||||
(define p3 (feed/page-before R (feed/next-cursor p2) 2))
|
||||
(feed-test
|
||||
"cursor page 3 (partial)"
|
||||
(map (fn (a) (get a :object)) (feed/items p3))
|
||||
(list "e"))
|
||||
|
||||
(feed-test
|
||||
"empty page nil cursor"
|
||||
(feed/next-cursor (feed/page-before R 5 2))
|
||||
nil)
|
||||
|
||||
(feed-test
|
||||
"after cursor loads newer"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/recent (feed/after R 30))))
|
||||
(list "a" "b"))
|
||||
(feed-test
|
||||
"before cursor count"
|
||||
(feed/count (feed/before R 30))
|
||||
2)
|
||||
160
lib/feed/tests/rank.sx
Normal file
160
lib/feed/tests/rank.sx
Normal file
@@ -0,0 +1,160 @@
|
||||
; Phase 3 — aggregation + ranking. (feed-test name got expected)
|
||||
|
||||
; ---------- aggregation ----------
|
||||
|
||||
(define
|
||||
A
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 5 (list))
|
||||
(feed/activity "alice" "post" "p2" 15 (list))
|
||||
(feed/activity "bob" "post" "p3" 25 (list))
|
||||
(feed/activity "alice" "like" "p1" 35 (list)))))
|
||||
|
||||
(feed-test "actor-counts" (feed/actor-counts A) {:alice 3 :bob 1})
|
||||
(feed-test "object-counts" (feed/object-counts A) {:p2 1 :p3 1 :p1 2})
|
||||
(feed-test
|
||||
"group-by actor alice len"
|
||||
(len (get (feed/group-by A feed/actor) "alice"))
|
||||
3)
|
||||
(feed-test
|
||||
"group-count empty"
|
||||
(feed/group-count feed/empty feed/actor)
|
||||
{})
|
||||
|
||||
; day bucketing
|
||||
(define
|
||||
D
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "alice" "post" "p1" 5 (list))
|
||||
(feed/activity "alice" "post" "p2" 8 (list))
|
||||
(feed/activity "alice" "post" "p3" 12 (list)))))
|
||||
|
||||
(feed-test "feed/day floor" (feed/day 12 10) 1)
|
||||
(feed-test "feed/day same bucket" (feed/day 8 10) 0)
|
||||
(feed-test "by-actor-day" (feed/by-actor-day D 10) {:alice#0 2 :alice#1 1})
|
||||
|
||||
; ---------- recency ----------
|
||||
|
||||
(define rec (feed/recency 100 10))
|
||||
(feed-test
|
||||
"recency at=now -> 1"
|
||||
(rec (feed/activity "x" "post" "o" 100 (list)))
|
||||
1)
|
||||
(feed-test
|
||||
"recency age=hl -> .5"
|
||||
(rec (feed/activity "x" "post" "o" 90 (list)))
|
||||
0.5)
|
||||
(feed-test
|
||||
"recency age=2hl -> .25"
|
||||
(rec (feed/activity "x" "post" "o" 80 (list)))
|
||||
0.25)
|
||||
|
||||
; ---------- velocity ----------
|
||||
|
||||
(define vel (feed/velocity D 10))
|
||||
(feed-test
|
||||
"velocity burst (at=12)"
|
||||
(vel (feed/activity "alice" "post" "z" 12 (list)))
|
||||
3)
|
||||
(feed-test
|
||||
"velocity mid (at=8)"
|
||||
(vel (feed/activity "alice" "post" "z" 8 (list)))
|
||||
2)
|
||||
(feed-test
|
||||
"velocity first (at=5)"
|
||||
(vel (feed/activity "alice" "post" "z" 5 (list)))
|
||||
1)
|
||||
(feed-test
|
||||
"velocity other actor"
|
||||
(vel (feed/activity "bob" "post" "z" 12 (list)))
|
||||
0)
|
||||
|
||||
; ---------- engagement ----------
|
||||
|
||||
(define eng (feed/engagement A))
|
||||
(feed-test
|
||||
"engagement p1"
|
||||
(eng (feed/activity "x" "post" "p1" 0 (list)))
|
||||
2)
|
||||
(feed-test
|
||||
"engagement p2"
|
||||
(eng (feed/activity "x" "post" "p2" 0 (list)))
|
||||
1)
|
||||
|
||||
; ---------- composite ----------
|
||||
|
||||
(define
|
||||
cmp1
|
||||
(feed/composite (list (list 2 (fn (a) (get a :at))))))
|
||||
(feed-test
|
||||
"composite single part"
|
||||
(cmp1 (feed/activity "x" "post" "o" 5 (list)))
|
||||
10)
|
||||
(define
|
||||
cmp2
|
||||
(feed/composite
|
||||
(list
|
||||
(list 2 (fn (a) (get a :at)))
|
||||
(list 3 (fn (a) 1)))))
|
||||
(feed-test
|
||||
"composite two parts"
|
||||
(cmp2 (feed/activity "x" "post" "o" 5 (list)))
|
||||
13)
|
||||
|
||||
; ---------- ranking ----------
|
||||
|
||||
(define
|
||||
R
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "u" "post" "oC" 80 (list))
|
||||
(feed/activity "u" "post" "oA" 100 (list))
|
||||
(feed/activity "u" "post" "oB" 90 (list)))))
|
||||
|
||||
(feed-test
|
||||
"rank by recency objects"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/rank R rec)))
|
||||
(list "oA" "oB" "oC"))
|
||||
(feed-test
|
||||
"top-2 by recency"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/top R rec 2)))
|
||||
(list "oA" "oB"))
|
||||
(feed-test "top-2 count" (feed/count (feed/top R rec 2)) 2)
|
||||
|
||||
; constant score -> tiebreak by :at descending
|
||||
(define
|
||||
T
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "u" "post" "f" 10 (list))
|
||||
(feed/activity "u" "post" "g" 30 (list))
|
||||
(feed/activity "u" "post" "h" 20 (list)))))
|
||||
(feed-test
|
||||
"tiebreak at-desc"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/rank T (fn (a) 0))))
|
||||
(list "g" "h" "f"))
|
||||
|
||||
; equal score AND equal :at -> stable input order
|
||||
(define
|
||||
E
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "u" "post" "first" 50 (list))
|
||||
(feed/activity "u" "post" "second" 50 (list)))))
|
||||
(feed-test
|
||||
"stable equal-key input order"
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(feed/items (feed/rank E (fn (a) 0))))
|
||||
(list "first" "second"))
|
||||
|
||||
(feed-test
|
||||
"with-scores attaches score"
|
||||
(get (nth (feed/items (feed/with-scores R rec)) 1) :score)
|
||||
1)
|
||||
|
||||
(feed-test "rank preserves count" (feed/count (feed/rank A rec)) 4)
|
||||
49
lib/feed/tests/thread.sx
Normal file
49
lib/feed/tests/thread.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
; Follow-up — conversation threading via :reply-to closure. (feed-test name got expected)
|
||||
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/normalize {:actor "a" :object "root" :at 1})
|
||||
(feed/normalize {:actor "b" :object "r1" :at 2 :verb "reply" :reply-to "root"})
|
||||
(feed/normalize {:actor "c" :object "r2" :at 3 :verb "reply" :reply-to "root"})
|
||||
(feed/normalize {:actor "d" :object "r3" :at 4 :verb "reply" :reply-to "r1"})
|
||||
(feed/normalize {:actor "e" :object "x" :at 5}))))
|
||||
|
||||
; ---------- direct replies ----------
|
||||
|
||||
(feed-test "direct replies to root" (feed/reply-count S "root") 2)
|
||||
(feed-test "direct replies to r1" (feed/reply-count S "r1") 1)
|
||||
(feed-test "no replies to r3" (feed/reply-count S "r3") 0)
|
||||
(feed-test
|
||||
"replies objects to root"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/replies S "root")))
|
||||
(list "r1" "r2"))
|
||||
|
||||
; ---------- thread closure ----------
|
||||
|
||||
(feed-test
|
||||
"thread objects root (transitive)"
|
||||
(feed/thread-objects S "root")
|
||||
(list "root" "r1" "r2" "r3"))
|
||||
(feed-test
|
||||
"thread root chronological"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root")))
|
||||
(list "root" "r1" "r2" "r3"))
|
||||
(feed-test "thread size root" (feed/thread-size S "root") 4)
|
||||
(feed-test
|
||||
"thread excludes unrelated x"
|
||||
(feed/-elem?
|
||||
"x"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root"))))
|
||||
false)
|
||||
|
||||
; ---------- sub-thread ----------
|
||||
|
||||
(feed-test
|
||||
"thread from r1 (sub-tree)"
|
||||
(map (fn (a) (get a :object)) (feed/items (feed/thread S "r1")))
|
||||
(list "r1" "r3"))
|
||||
(feed-test "thread size r1" (feed/thread-size S "r1") 2)
|
||||
(feed-test "leaf thread is itself" (feed/thread-size S "r3") 1)
|
||||
(feed-test "unrelated thread is itself" (feed/thread-size S "x") 1)
|
||||
82
lib/feed/tests/trending.sx
Normal file
82
lib/feed/tests/trending.sx
Normal file
@@ -0,0 +1,82 @@
|
||||
; Follow-up — trending objects/actors by recent activity. (feed-test name got expected)
|
||||
|
||||
; window (50,100]: X@60,X@70 (a), Y@80 (b), Z@90 (c); W@40 is too old
|
||||
(define
|
||||
S
|
||||
(feed/stream
|
||||
(list
|
||||
(feed/activity "a" "post" "X" 60 (list))
|
||||
(feed/activity "a" "post" "X" 70 (list))
|
||||
(feed/activity "b" "post" "Y" 80 (list))
|
||||
(feed/activity "c" "post" "Z" 90 (list))
|
||||
(feed/activity "d" "post" "W" 40 (list)))))
|
||||
|
||||
; ---------- trending objects ----------
|
||||
|
||||
(feed-test
|
||||
"trending count (3 in window)"
|
||||
(len (feed/trending S 100 50 10))
|
||||
3)
|
||||
(feed-test
|
||||
"trending top object"
|
||||
(get
|
||||
(nth (feed/trending S 100 50 10) 0)
|
||||
:object)
|
||||
"X")
|
||||
(feed-test
|
||||
"trending top count"
|
||||
(get
|
||||
(nth (feed/trending S 100 50 10) 0)
|
||||
:count)
|
||||
2)
|
||||
(feed-test
|
||||
"trending order (count desc, key asc tiebreak)"
|
||||
(map
|
||||
(fn (e) (get e :object))
|
||||
(feed/trending S 100 50 10))
|
||||
(list "X" "Y" "Z"))
|
||||
(feed-test
|
||||
"trending top-2"
|
||||
(map
|
||||
(fn (e) (get e :object))
|
||||
(feed/trending S 100 50 2))
|
||||
(list "X" "Y"))
|
||||
(feed-test
|
||||
"old object W excluded"
|
||||
(feed/-elem?
|
||||
"W"
|
||||
(map
|
||||
(fn (e) (get e :object))
|
||||
(feed/trending S 100 50 10)))
|
||||
false)
|
||||
(feed-test
|
||||
"narrow window keeps only newest"
|
||||
(map
|
||||
(fn (e) (get e :object))
|
||||
(feed/trending S 100 15 10))
|
||||
(list "Z"))
|
||||
(feed-test
|
||||
"empty window -> nothing"
|
||||
(feed/trending S 100 5 10)
|
||||
(list))
|
||||
|
||||
; ---------- trending actors ----------
|
||||
|
||||
(feed-test
|
||||
"trending actor top"
|
||||
(get
|
||||
(nth (feed/trending-actors S 100 50 10) 0)
|
||||
:actor)
|
||||
"a")
|
||||
(feed-test
|
||||
"trending actor count"
|
||||
(get
|
||||
(nth (feed/trending-actors S 100 50 10) 0)
|
||||
:count)
|
||||
2)
|
||||
(feed-test
|
||||
"trending actors order"
|
||||
(map
|
||||
(fn (e) (get e :actor))
|
||||
(feed/trending-actors S 100 50 10))
|
||||
(list "a" "b" "c"))
|
||||
59
lib/feed/thread.sx
Normal file
59
lib/feed/thread.sx
Normal file
@@ -0,0 +1,59 @@
|
||||
; feed/thread — conversation threading. A reply carries :reply-to <parent-object>
|
||||
; (normalize preserves it). A thread is the transitive closure over :reply-to from
|
||||
; a root object: root + replies + replies-to-replies, gathered chronologically.
|
||||
;
|
||||
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||
; (feed/-elem?, feed/-distinct).
|
||||
|
||||
; direct replies to an object
|
||||
(define
|
||||
feed/replies
|
||||
(fn
|
||||
(stream object)
|
||||
(feed/filter stream (fn (a) (equal? (get a :reply-to) object)))))
|
||||
|
||||
(define
|
||||
feed/reply-count
|
||||
(fn (stream object) (feed/count (feed/replies stream object))))
|
||||
|
||||
; iterate f from x until the result stops growing (set-closure fixpoint)
|
||||
(define
|
||||
feed/-fixpoint
|
||||
(fn
|
||||
(f x)
|
||||
(let
|
||||
((nx (f x)))
|
||||
(if (= (len nx) (len x)) x (feed/-fixpoint f nx)))))
|
||||
|
||||
; the set of object-ids in the thread rooted at `root`
|
||||
(define
|
||||
feed/thread-objects
|
||||
(fn
|
||||
(stream root)
|
||||
(let
|
||||
((all (feed/items stream)))
|
||||
(feed/-fixpoint
|
||||
(fn
|
||||
(acc)
|
||||
(feed/-distinct
|
||||
(append
|
||||
acc
|
||||
(map
|
||||
(fn (a) (get a :object))
|
||||
(filter (fn (a) (feed/-elem? (get a :reply-to) acc)) all)))))
|
||||
(list root)))))
|
||||
|
||||
; the full thread as a chronological stream (root + all descendants)
|
||||
(define
|
||||
feed/thread
|
||||
(fn
|
||||
(stream root)
|
||||
(let
|
||||
((objs (feed/thread-objects stream root)))
|
||||
(feed/sort-by-at
|
||||
(feed/filter stream (fn (a) (feed/-elem? (get a :object) objs)))))))
|
||||
|
||||
; how many activities are in the thread (root counts as 1)
|
||||
(define
|
||||
feed/thread-size
|
||||
(fn (stream root) (feed/count (feed/thread stream root))))
|
||||
42
lib/feed/trending.sx
Normal file
42
lib/feed/trending.sx
Normal file
@@ -0,0 +1,42 @@
|
||||
; feed/trending — what's hot right now: objects (or actors) ranked by activity
|
||||
; count within a recency window. Deterministic: count descending, ties broken by
|
||||
; key ascending (entries are pre-sorted by key, then stable grade-down by count).
|
||||
;
|
||||
; Requires: lib/feed/stream.sx, lib/feed/aggregate.sx (object/actor-counts),
|
||||
; lib/feed/rank.sx (feed/-desc-by).
|
||||
|
||||
; activities within (now-window, now]
|
||||
(define
|
||||
feed/-recent
|
||||
(fn
|
||||
(stream now window)
|
||||
(feed/filter
|
||||
stream
|
||||
(fn (a) (and (<= (get a :at) now) (> (get a :at) (- now window)))))))
|
||||
|
||||
; counts dict -> top-N entries {label key, :count n}, count desc, key asc
|
||||
(define
|
||||
feed/-top-counts
|
||||
(fn
|
||||
(counts label n)
|
||||
(let
|
||||
((entries (map (fn (k) (assoc {:count (get counts k)} label k)) (sort (keys counts)))))
|
||||
(take (feed/-desc-by entries (fn (e) (get e :count))) n))))
|
||||
|
||||
; top-N trending objects in the window
|
||||
(define
|
||||
feed/trending
|
||||
(fn
|
||||
(stream now window n)
|
||||
(feed/-top-counts
|
||||
(feed/object-counts (feed/-recent stream now window))
|
||||
:object n)))
|
||||
|
||||
; top-N most active actors in the window
|
||||
(define
|
||||
feed/trending-actors
|
||||
(fn
|
||||
(stream now window n)
|
||||
(feed/-top-counts
|
||||
(feed/actor-counts (feed/-recent stream now window))
|
||||
:actor n)))
|
||||
141
lib/go/conformance.sh
Executable file
141
lib/go/conformance.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Go-on-SX conformance runner.
|
||||
#
|
||||
# Loads every Go-on-SX test suite via the epoch protocol, collects
|
||||
# pass/fail counts, and writes lib/go/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/go/conformance.sh # run all suites
|
||||
# bash lib/go/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/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
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | pass-counter | total-counter
|
||||
SUITES=(
|
||||
"lex|go-test-pass|go-test-count"
|
||||
"parse|go-parse-test-pass|go-parse-test-count"
|
||||
"types|go-types-test-pass|go-types-test-count"
|
||||
"eval|go-eval-test-pass|go-eval-test-count"
|
||||
"runtime|go-rt-test-pass|go-rt-test-count"
|
||||
"stdlib|go-std-test-pass|go-std-test-count"
|
||||
"e2e|go-e2e-test-pass|go-e2e-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" <<'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/guest/lex.sx")
|
||||
(load "lib/guest/ast.sx")
|
||||
(load "lib/guest/pratt.sx")
|
||||
(load "lib/go/lex.sx")
|
||||
(load "lib/go/parse.sx")
|
||||
(load "lib/go/types.sx")
|
||||
(load "lib/go/sched.sx")
|
||||
(load "lib/go/eval.sx")
|
||||
(load "lib/go/std/strings.sx")
|
||||
(load "lib/go/std/strconv.sx")
|
||||
(load "lib/go/tests/lex.sx")
|
||||
(load "lib/go/tests/parse.sx")
|
||||
(load "lib/go/tests/types.sx")
|
||||
(load "lib/go/tests/eval.sx")
|
||||
(load "lib/go/tests/runtime.sx")
|
||||
(load "lib/go/tests/stdlib.sx")
|
||||
(load "lib/go/tests/e2e.sx")
|
||||
EPOCHS
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
pass_var=$(echo "$entry" | awk -F'|' '{print $2}')
|
||||
total_var=$(echo "$entry" | awk -F'|' '{print $3}')
|
||||
epoch=$((100 + idx))
|
||||
echo "(epoch $epoch)" >> "$TMPFILE"
|
||||
echo "(eval \"(list $pass_var $total_var)\")" >> "$TMPFILE"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
"$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nGo-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
cat > lib/go/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > lib/go/scoreboard.md <<MD
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/go/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
1539
lib/go/eval.sx
Normal file
1539
lib/go/eval.sx
Normal file
File diff suppressed because it is too large
Load Diff
476
lib/go/lex.sx
Normal file
476
lib/go/lex.sx
Normal file
@@ -0,0 +1,476 @@
|
||||
;; lib/go/lex.sx — Go tokenizer with automatic semicolon insertion.
|
||||
;;
|
||||
;; Consumes lib/guest/lex.sx character-class predicates.
|
||||
;;
|
||||
;; Tokens: {:type T :value V :pos P}
|
||||
;; Types:
|
||||
;; "ident" — identifiers (foo, _bar, mixedCase)
|
||||
;; "keyword" — one of the 25 Go keywords
|
||||
;; "int" — integer literals (decimal, 0x.. hex, 0b.. binary, 0o.. octal,
|
||||
;; legacy 0123 octal; underscores between digits allowed)
|
||||
;; "float" — decimal float literals (3.14, .5, 1., 1e10, 1.5e-3, 1E5)
|
||||
;; "imag" — imaginary literals (2i, 3.14i, 1e2i)
|
||||
;; "string" — interpreted string literals "..." OR raw string literals `...`
|
||||
;; "rune" — rune literals 'x' (single char + simple escapes)
|
||||
;; "op" — operators & punctuation; :value is the literal text
|
||||
;; "semi" — explicit ';' or auto-inserted (Go spec § Semicolons)
|
||||
;; "eof" — end-of-input sentinel
|
||||
;;
|
||||
;; ASI (Go spec § Semicolons): a newline (or EOF, or a block comment
|
||||
;; containing a newline) emits a ";semi" if the previous emitted token's
|
||||
;; type is ident/int/float/imag/string/rune, or its value is one of
|
||||
;; {break, continue, fallthrough, return, ++, --, ), ], }}.
|
||||
;;
|
||||
;; All scanner locals are gl- prefixed: SX host primitives (peek/emit/etc.)
|
||||
;; silently shadow guest-language defines. See feedback_sx_bind_clash.
|
||||
|
||||
(define
|
||||
go-keywords
|
||||
(list
|
||||
"break"
|
||||
"case"
|
||||
"chan"
|
||||
"const"
|
||||
"continue"
|
||||
"default"
|
||||
"defer"
|
||||
"else"
|
||||
"fallthrough"
|
||||
"for"
|
||||
"func"
|
||||
"go"
|
||||
"goto"
|
||||
"if"
|
||||
"import"
|
||||
"interface"
|
||||
"map"
|
||||
"package"
|
||||
"range"
|
||||
"return"
|
||||
"select"
|
||||
"struct"
|
||||
"switch"
|
||||
"type"
|
||||
"var"))
|
||||
|
||||
(define go-keyword? (fn (s) (some (fn (k) (= k s)) go-keywords)))
|
||||
|
||||
(define go-asi-keywords (list "break" "continue" "fallthrough" "return"))
|
||||
|
||||
(define go-asi-ops (list "++" "--" ")" "]" "}"))
|
||||
|
||||
(define go-asi-lit-types (list "ident" "int" "float" "imag" "string" "rune"))
|
||||
|
||||
(define
|
||||
go-asi-trigger?
|
||||
(fn
|
||||
(tok)
|
||||
(if
|
||||
(= tok nil)
|
||||
false
|
||||
(let
|
||||
((ty (get tok :type)) (v (get tok :value)))
|
||||
(or
|
||||
(some (fn (lt) (= lt ty)) go-asi-lit-types)
|
||||
(and (= ty "keyword") (some (fn (k) (= k v)) go-asi-keywords))
|
||||
(and (= ty "op") (some (fn (o) (= o v)) go-asi-ops)))))))
|
||||
|
||||
(define
|
||||
go-tokenize
|
||||
(fn
|
||||
(src)
|
||||
(let
|
||||
((tokens (list)) (pos 0) (src-len (len src)))
|
||||
(define
|
||||
gl-peek
|
||||
(fn
|
||||
(offset)
|
||||
(if (< (+ pos offset) src-len) (nth src (+ pos offset)) nil)))
|
||||
(define gl-cur (fn () (gl-peek 0)))
|
||||
(define gl-advance! (fn (n) (set! pos (+ pos n))))
|
||||
(define
|
||||
gl-last
|
||||
(fn
|
||||
()
|
||||
(if
|
||||
(= (len tokens) 0)
|
||||
nil
|
||||
(nth tokens (- (len tokens) 1)))))
|
||||
(define gl-emit! (fn (type value start) (append! tokens {:type type :value value :pos start})))
|
||||
(define
|
||||
gl-maybe-asi!
|
||||
(fn
|
||||
(at)
|
||||
(when (go-asi-trigger? (gl-last)) (gl-emit! "semi" "\n" at))))
|
||||
(define
|
||||
gl-oct-digit?
|
||||
(fn (c) (and (not (= c nil)) (>= c "0") (<= c "7"))))
|
||||
(define gl-bin-digit? (fn (c) (or (= c "0") (= c "1"))))
|
||||
(define
|
||||
gl-skip-line!
|
||||
(fn
|
||||
()
|
||||
(when
|
||||
(and (< pos src-len) (not (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-line!))))
|
||||
(define
|
||||
gl-skip-block!
|
||||
(fn
|
||||
(saw-nl)
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
saw-nl
|
||||
(and (= (gl-cur) "*") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) saw-nl)
|
||||
:else (let
|
||||
((is-nl (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-block! (or saw-nl is-nl))))))
|
||||
(define
|
||||
gl-read-ident!
|
||||
(fn
|
||||
(start)
|
||||
(when
|
||||
(and (< pos src-len) (lex-ident-char? (gl-cur)))
|
||||
(gl-advance! 1)
|
||||
(gl-read-ident! start))
|
||||
(slice src start pos)))
|
||||
(define
|
||||
gl-read-digit-run!
|
||||
(fn
|
||||
(digit?)
|
||||
(when
|
||||
(and (< pos src-len) (or (digit? (gl-cur)) (= (gl-cur) "_")))
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! digit?))))
|
||||
(define
|
||||
gl-finish-number!
|
||||
(fn
|
||||
(has-fraction?)
|
||||
(let
|
||||
((typ (if has-fraction? "float" "int")))
|
||||
(when
|
||||
(or (= (gl-cur) "e") (= (gl-cur) "E"))
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(or (= (gl-cur) "+") (= (gl-cur) "-"))
|
||||
(gl-advance! 1))
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(set! typ "float"))
|
||||
(cond
|
||||
(= (gl-cur) "i")
|
||||
(do (gl-advance! 1) "imag")
|
||||
:else typ))))
|
||||
(define
|
||||
gl-read-number!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "x")
|
||||
(= (gl-peek 1) "X")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! lex-hex-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "b")
|
||||
(= (gl-peek 1) "B")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-bin-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "o")
|
||||
(= (gl-peek 1) "O")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-oct-digit?)
|
||||
"int")
|
||||
:else (do
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (not (= (gl-peek 1) ".")))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
:else (gl-finish-number! false))))))
|
||||
(define
|
||||
gl-read-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-string-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\"")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\\")
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1)))
|
||||
(gl-string-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-string-loop)))))
|
||||
(gl-string-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-raw-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-raw-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "`")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\r")
|
||||
(do (gl-advance! 1) (gl-raw-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-raw-loop)))))
|
||||
(gl-raw-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-rune!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(cond
|
||||
(and (< pos src-len) (= (gl-cur) "\\"))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1))))
|
||||
(< pos src-len)
|
||||
(do (append! chars (gl-cur)) (gl-advance! 1)))
|
||||
(when
|
||||
(and (< pos src-len) (= (gl-cur) "'"))
|
||||
(gl-advance! 1))
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-match-op
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((c0 (gl-cur))
|
||||
(c1 (gl-peek 1))
|
||||
(c2 (gl-peek 2)))
|
||||
(cond
|
||||
(and (= c0 "<") (= c1 "<") (= c2 "="))
|
||||
"<<="
|
||||
(and (= c0 ">") (= c1 ">") (= c2 "="))
|
||||
">>="
|
||||
(and (= c0 "&") (= c1 "^") (= c2 "="))
|
||||
"&^="
|
||||
(and (= c0 ".") (= c1 ".") (= c2 "."))
|
||||
"..."
|
||||
(and (= c0 "=") (= c1 "="))
|
||||
"=="
|
||||
(and (= c0 "!") (= c1 "="))
|
||||
"!="
|
||||
(and (= c0 "<") (= c1 "="))
|
||||
"<="
|
||||
(and (= c0 ">") (= c1 "="))
|
||||
">="
|
||||
(and (= c0 "&") (= c1 "&"))
|
||||
"&&"
|
||||
(and (= c0 "|") (= c1 "|"))
|
||||
"||"
|
||||
(and (= c0 "+") (= c1 "+"))
|
||||
"++"
|
||||
(and (= c0 "-") (= c1 "-"))
|
||||
"--"
|
||||
(and (= c0 "<") (= c1 "<"))
|
||||
"<<"
|
||||
(and (= c0 ">") (= c1 ">"))
|
||||
">>"
|
||||
(and (= c0 "+") (= c1 "="))
|
||||
"+="
|
||||
(and (= c0 "-") (= c1 "="))
|
||||
"-="
|
||||
(and (= c0 "*") (= c1 "="))
|
||||
"*="
|
||||
(and (= c0 "/") (= c1 "="))
|
||||
"/="
|
||||
(and (= c0 "%") (= c1 "="))
|
||||
"%="
|
||||
(and (= c0 "&") (= c1 "="))
|
||||
"&="
|
||||
(and (= c0 "|") (= c1 "="))
|
||||
"|="
|
||||
(and (= c0 "^") (= c1 "="))
|
||||
"^="
|
||||
(and (= c0 ":") (= c1 "="))
|
||||
":="
|
||||
(and (= c0 "<") (= c1 "-"))
|
||||
"<-"
|
||||
(and (= c0 "&") (= c1 "^"))
|
||||
"&^"
|
||||
(or
|
||||
(= c0 "+")
|
||||
(= c0 "-")
|
||||
(= c0 "*")
|
||||
(= c0 "/")
|
||||
(= c0 "%")
|
||||
(= c0 "&")
|
||||
(= c0 "|")
|
||||
(= c0 "^")
|
||||
(= c0 "<")
|
||||
(= c0 ">")
|
||||
(= c0 "=")
|
||||
(= c0 "!")
|
||||
(= c0 "(")
|
||||
(= c0 ")")
|
||||
(= c0 "{")
|
||||
(= c0 "}")
|
||||
(= c0 "[")
|
||||
(= c0 "]")
|
||||
(= c0 ",")
|
||||
(= c0 ".")
|
||||
(= c0 ":")
|
||||
(= c0 "~"))
|
||||
c0
|
||||
:else nil))))
|
||||
(define
|
||||
gl-scan!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\n")
|
||||
(do (gl-maybe-asi! pos) (gl-advance! 1) (gl-scan!))
|
||||
(lex-space? (gl-cur))
|
||||
(do (gl-advance! 1) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) (gl-skip-line!) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "*"))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(let
|
||||
((saw-nl (gl-skip-block! false)))
|
||||
(when saw-nl (gl-maybe-asi! pos)))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) ";")
|
||||
(do
|
||||
(gl-emit! "semi" ";" pos)
|
||||
(gl-advance! 1)
|
||||
(gl-scan!))
|
||||
(lex-ident-start? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos))
|
||||
(gl-read-ident! start)
|
||||
(let
|
||||
((word (slice src start pos)))
|
||||
(gl-emit!
|
||||
(if (go-keyword? word) "keyword" "ident")
|
||||
word
|
||||
start)))
|
||||
(gl-scan!))
|
||||
(lex-digit? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "\"")
|
||||
(let
|
||||
((start pos) (v (gl-read-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "`")
|
||||
(let
|
||||
((start pos) (v (gl-read-raw-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "'")
|
||||
(let
|
||||
((start pos) (v (gl-read-rune!)))
|
||||
(gl-emit! "rune" v start)
|
||||
(gl-scan!))
|
||||
:else (let
|
||||
((op (gl-match-op)))
|
||||
(cond
|
||||
op
|
||||
(do
|
||||
(gl-emit! "op" op pos)
|
||||
(gl-advance! (len op))
|
||||
(gl-scan!))
|
||||
:else (do (gl-advance! 1) (gl-scan!)))))))
|
||||
(gl-scan!)
|
||||
(gl-maybe-asi! pos)
|
||||
(gl-emit! "eof" nil pos)
|
||||
tokens)))
|
||||
1262
lib/go/parse.sx
Normal file
1262
lib/go/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
66
lib/go/sched.sx
Normal file
66
lib/go/sched.sx
Normal file
@@ -0,0 +1,66 @@
|
||||
;; lib/go/sched.sx — Go scheduler primitives: channels + goroutines.
|
||||
;;
|
||||
;; This is **the independent implementation** referenced by
|
||||
;; plans/lib-guest-scheduler.md. The shape that emerges here informs
|
||||
;; the eventual sister kit; this file's structures are the Phase 5
|
||||
;; "first-consumer" cut.
|
||||
;;
|
||||
;; v0 concurrency model — IMPORTANT
|
||||
;;
|
||||
;; SX has no first-class continuations exposed to guest code, so we
|
||||
;; can't suspend a goroutine mid-statement. v0 runs `go f()` SYNCHRO-
|
||||
;; NOUSLY (it's an immediate call whose return value is dropped). This
|
||||
;; preserves the right semantics for patterns where the spawned
|
||||
;; goroutine simply pushes to a channel that the main goroutine then
|
||||
;; receives — because the spawned goroutine runs to completion first
|
||||
;; and leaves the value in the channel buffer.
|
||||
;;
|
||||
;; True preemption with blocking sends/recvs is a Phase 5b refinement.
|
||||
;; The sister-plan diary tracks the design insight (single
|
||||
;; sched-spawn primitive, channel-op direction tag) so the eventual
|
||||
;; kit doesn't bake in v0's synchronous limitation.
|
||||
;;
|
||||
;; Channel representation
|
||||
;;
|
||||
;; (list :go-chan ACCESSORS-FN-LIST)
|
||||
;;
|
||||
;; ACCESSORS-FN-LIST is a list of closures sharing a mutable buffer
|
||||
;; and a closed flag. The closures expose:
|
||||
;; index 1: send-fn — (lambda (val) ...)
|
||||
;; index 2: recv-fn — (lambda () val-or-:empty)
|
||||
;; index 3: closed?-fn — (lambda () bool)
|
||||
;; index 4: close!-fn — (lambda () ...)
|
||||
;;
|
||||
;; Channel identity: distinct calls to go-make-chan produce closures
|
||||
;; with distinct identity — `(= ch1 ch2)` is false for distinct
|
||||
;; channels, matching Go spec § Channel types.
|
||||
|
||||
(define
|
||||
go-make-chan
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((buf (list)) (closed false))
|
||||
(list
|
||||
:go-chan (fn (v) (append! buf v) nil)
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(= (len buf) 0)
|
||||
:empty :else
|
||||
(let ((v (first buf))) (set! buf (rest buf)) v)))
|
||||
(fn () closed)
|
||||
(fn () (set! closed true) nil)
|
||||
(fn () (len buf))))))
|
||||
|
||||
(define
|
||||
go-chan?
|
||||
(fn
|
||||
(v)
|
||||
(and (list? v) (not (= (len v) 0)) (= (first v) :go-chan))))
|
||||
|
||||
(define go-chan-send! (fn (ch val) ((nth ch 1) val)))
|
||||
(define go-chan-recv! (fn (ch) ((nth ch 2))))
|
||||
(define go-chan-closed? (fn (ch) ((nth ch 3))))
|
||||
(define go-chan-close! (fn (ch) ((nth ch 4))))
|
||||
(define go-chan-len (fn (ch) ((nth ch 5))))
|
||||
13
lib/go/scoreboard.json
Normal file
13
lib/go/scoreboard.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": 609,
|
||||
"total": 609,
|
||||
"suites": [
|
||||
{"name":"lex","pass":129,"total":129,"status":"ok"},
|
||||
{"name":"parse","pass":179,"total":179,"status":"ok"},
|
||||
{"name":"types","pass":102,"total":102,"status":"ok"},
|
||||
{"name":"eval","pass":106,"total":106,"status":"ok"},
|
||||
{"name":"runtime","pass":40,"total":40,"status":"ok"},
|
||||
{"name":"stdlib","pass":41,"total":41,"status":"ok"},
|
||||
{"name":"e2e","pass":12,"total":12,"status":"ok"}]
|
||||
}
|
||||
16
lib/go/scoreboard.md
Normal file
16
lib/go/scoreboard.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: 609 / 609 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | lex | 129 | 129 |
|
||||
| ✅ | parse | 179 | 179 |
|
||||
| ✅ | types | 102 | 102 |
|
||||
| ✅ | eval | 106 | 106 |
|
||||
| ✅ | runtime | 40 | 40 |
|
||||
| ✅ | stdlib | 41 | 41 |
|
||||
| ✅ | e2e | 12 | 12 |
|
||||
|
||||
|
||||
Generated by `lib/go/conformance.sh`.
|
||||
71
lib/go/std/strconv.sx
Normal file
71
lib/go/std/strconv.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/go/std/strconv.sx — Go's `strconv` package, v0 subset.
|
||||
|
||||
(define
|
||||
go-strconv-itoa
|
||||
;; Itoa(n) → string. Real Go returns the decimal representation.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-itoa-arity (len args))
|
||||
:else
|
||||
(let ((n (first args)))
|
||||
(cond
|
||||
(not (number? n)) (list :eval-error :strconv-itoa-not-number n)
|
||||
:else (str n))))))
|
||||
|
||||
(define
|
||||
go-strconv-atoi
|
||||
;; Atoi(s) → (int, error). v0 returns just the int on success or
|
||||
;; an :eval-error on failure (multi-return is a later refinement).
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-atoi-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strconv-atoi-not-string s)
|
||||
(= (len s) 0) (list :eval-error :strconv-atoi-empty)
|
||||
:else (go-strconv-parse-int s 0 (= (nth s 0) "-") 0))))))
|
||||
|
||||
(define
|
||||
go-strconv-parse-int
|
||||
;; Parse a (possibly signed) base-10 integer literal. Stops on the
|
||||
;; first non-digit char and returns the parsed prefix, or :eval-error
|
||||
;; if no digits were consumed.
|
||||
(fn (s start neg acc)
|
||||
(let ((i (cond (= start 0) (cond neg 1 :else 0) :else start)))
|
||||
(cond
|
||||
(>= i (len s))
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(let ((d (go-strconv-digit (nth s i))))
|
||||
(cond
|
||||
(< d 0)
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(go-strconv-parse-int s (+ i 1) neg (+ (* acc 10) d))))))))
|
||||
|
||||
(define
|
||||
go-strconv-digit
|
||||
(fn (c)
|
||||
(cond
|
||||
(= c "0") 0 (= c "1") 1 (= c "2") 2 (= c "3") 3
|
||||
(= c "4") 4 (= c "5") 5 (= c "6") 6 (= c "7") 7
|
||||
(= c "8") 8 (= c "9") 9
|
||||
:else -1)))
|
||||
|
||||
(define
|
||||
go-std-strconv
|
||||
(list :go-package "strconv"
|
||||
(list
|
||||
(list "Itoa" (list :go-builtin-fn go-strconv-itoa))
|
||||
(list "Atoi" (list :go-builtin-fn go-strconv-atoi)))))
|
||||
386
lib/go/std/strings.sx
Normal file
386
lib/go/std/strings.sx
Normal file
@@ -0,0 +1,386 @@
|
||||
;; lib/go/std/strings.sx — Go's `strings` package, v0 subset.
|
||||
;;
|
||||
;; Exposed as `go-std-strings`, a (:go-package "strings" ENTRIES) value.
|
||||
;; Register with `(go-env-extend env "strings" go-std-strings)` to make
|
||||
;; `strings.X(...)` call sites work in evaluated Go code.
|
||||
;;
|
||||
;; Each entry is (FIELD-NAME (list :go-fn PARAMS BODY)) — the same
|
||||
;; shape user-defined Go functions get. Bodies are written in SX
|
||||
;; directly via go-builtin closures wrapping host-level string ops
|
||||
;; for speed, OR as parsed Go source for fidelity. v0 uses
|
||||
;; go-builtin wrappers — simpler and fast.
|
||||
|
||||
;; ── helpers: implement go-std-strings entries as builtins ────────
|
||||
|
||||
(define
|
||||
go-strings-contains
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-contains-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else
|
||||
(go-strings-index-of s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-of
|
||||
;; Returns true if SUB appears in S at or after START, else false.
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) true
|
||||
(> (+ start sublen) slen) false
|
||||
(go-strings-match-at s sub start 0) true
|
||||
:else (go-strings-index-of s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-match-at
|
||||
(fn (s sub start k)
|
||||
(cond
|
||||
(>= k (len sub)) true
|
||||
(= (nth s (+ start k)) (nth sub k))
|
||||
(go-strings-match-at s sub start (+ k 1))
|
||||
:else false)))
|
||||
|
||||
(define
|
||||
go-strings-has-prefix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hasprefix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (p (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? p)) (list :eval-error :strings-not-string p)
|
||||
(> (len p) (len s)) false
|
||||
:else (go-strings-match-at s p 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-has-suffix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hassuffix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (suf (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? suf)) (list :eval-error :strings-not-string suf)
|
||||
(> (len suf) (len s)) false
|
||||
:else
|
||||
(go-strings-match-at s suf (- (len s) (len suf)) 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-index-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-index-loop s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-loop
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) 0
|
||||
(> (+ start sublen) slen) -1
|
||||
(go-strings-match-at s sub start 0) start
|
||||
:else (go-strings-index-loop s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-repeat-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (n (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(< n 0) (list :eval-error :strings-repeat-negative n)
|
||||
:else (go-strings-repeat-loop s n ""))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat-loop
|
||||
(fn (s n acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-strings-repeat-loop s (- n 1) (str acc s)))))
|
||||
|
||||
(define
|
||||
go-strings-count
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-count-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-count-loop s sub 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-count-loop
|
||||
(fn (s sub start acc)
|
||||
(let ((idx (go-strings-index-loop s sub start)))
|
||||
(cond
|
||||
(< idx 0) acc
|
||||
:else
|
||||
(go-strings-count-loop s sub (+ idx (max 1 (len sub))) (+ acc 1))))))
|
||||
|
||||
(define
|
||||
go-strings-join
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-join-arity (len args))
|
||||
:else
|
||||
(let ((sep (nth args 1)) (xs (first args)))
|
||||
(cond
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(not (and (list? xs) (= (first xs) :go-slice)))
|
||||
(list :eval-error :strings-join-not-slice xs)
|
||||
:else (go-strings-join-loop (nth xs 1) sep ""))))))
|
||||
|
||||
(define
|
||||
go-strings-join-loop
|
||||
(fn (xs sep acc)
|
||||
(cond
|
||||
(= (len xs) 0) acc
|
||||
(= (len acc) 0) (go-strings-join-loop (rest xs) sep (first xs))
|
||||
:else
|
||||
(go-strings-join-loop (rest xs) sep (str acc sep (first xs))))))
|
||||
|
||||
;; ── case conversion ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-char-to-upper
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "a") (<= c "z"))
|
||||
;; ASCII uppercase shift: 'a' is 0x61, 'A' is 0x41 → diff 0x20.
|
||||
;; SX has no charcode primitive, so use a char-pair table.
|
||||
(go-strings-letter-toggle c true)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-char-to-lower
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "A") (<= c "Z"))
|
||||
(go-strings-letter-toggle c false)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-letter-toggle
|
||||
;; Toggle a single ASCII letter's case via direct mapping.
|
||||
;; `to-upper?` true means input is lowercase, output uppercase.
|
||||
(fn (c to-upper?)
|
||||
(cond
|
||||
to-upper?
|
||||
(cond
|
||||
(= c "a") "A" (= c "b") "B" (= c "c") "C" (= c "d") "D"
|
||||
(= c "e") "E" (= c "f") "F" (= c "g") "G" (= c "h") "H"
|
||||
(= c "i") "I" (= c "j") "J" (= c "k") "K" (= c "l") "L"
|
||||
(= c "m") "M" (= c "n") "N" (= c "o") "O" (= c "p") "P"
|
||||
(= c "q") "Q" (= c "r") "R" (= c "s") "S" (= c "t") "T"
|
||||
(= c "u") "U" (= c "v") "V" (= c "w") "W" (= c "x") "X"
|
||||
(= c "y") "Y" (= c "z") "Z" :else c)
|
||||
:else
|
||||
(cond
|
||||
(= c "A") "a" (= c "B") "b" (= c "C") "c" (= c "D") "d"
|
||||
(= c "E") "e" (= c "F") "f" (= c "G") "g" (= c "H") "h"
|
||||
(= c "I") "i" (= c "J") "j" (= c "K") "k" (= c "L") "l"
|
||||
(= c "M") "m" (= c "N") "n" (= c "O") "o" (= c "P") "p"
|
||||
(= c "Q") "q" (= c "R") "r" (= c "S") "s" (= c "T") "t"
|
||||
(= c "U") "u" (= c "V") "v" (= c "W") "w" (= c "X") "x"
|
||||
(= c "Y") "y" (= c "Z") "z" :else c))))
|
||||
|
||||
(define
|
||||
go-strings-map-chars
|
||||
(fn (s i acc char-fn)
|
||||
(cond
|
||||
(>= i (len s)) acc
|
||||
:else
|
||||
(go-strings-map-chars s (+ i 1) (str acc (char-fn (nth s i))) char-fn))))
|
||||
|
||||
(define
|
||||
go-strings-to-upper
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-toupper-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-upper))))))
|
||||
|
||||
(define
|
||||
go-strings-to-lower
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-tolower-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-lower))))))
|
||||
|
||||
;; ── TrimSpace ────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-is-space?
|
||||
(fn (c)
|
||||
(or (= c " ") (= c "\t") (= c "\n") (= c "\r"))))
|
||||
|
||||
(define
|
||||
go-strings-trim-left
|
||||
(fn (s i)
|
||||
(cond
|
||||
(>= i (len s)) i
|
||||
(go-strings-is-space? (nth s i)) (go-strings-trim-left s (+ i 1))
|
||||
:else i)))
|
||||
|
||||
(define
|
||||
go-strings-trim-right
|
||||
(fn (s end)
|
||||
(cond
|
||||
(<= end 0) 0
|
||||
(go-strings-is-space? (nth s (- end 1))) (go-strings-trim-right s (- end 1))
|
||||
:else end)))
|
||||
|
||||
(define
|
||||
go-strings-substr
|
||||
;; Substring [lo, hi) — naive but predictable.
|
||||
(fn (s lo hi)
|
||||
(cond
|
||||
(>= lo hi) ""
|
||||
:else
|
||||
(go-strings-substr-loop s lo hi ""))))
|
||||
|
||||
(define
|
||||
go-strings-substr-loop
|
||||
(fn (s i hi acc)
|
||||
(cond
|
||||
(>= i hi) acc
|
||||
:else (go-strings-substr-loop s (+ i 1) hi (str acc (nth s i))))))
|
||||
|
||||
(define
|
||||
go-strings-trim-space
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-trimspace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else
|
||||
(let ((lo (go-strings-trim-left s 0)))
|
||||
(let ((hi (go-strings-trim-right s (len s))))
|
||||
(go-strings-substr s lo hi))))))))
|
||||
|
||||
;; ── Split ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-split
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-split-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sep (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(= (len sep) 0)
|
||||
;; Empty separator: real Go splits to all chars; v0 keeps
|
||||
;; behaviour simple — single-element slice.
|
||||
(list :go-slice (list s))
|
||||
:else
|
||||
(list :go-slice (go-strings-split-loop s sep 0 (list))))))))
|
||||
|
||||
(define
|
||||
go-strings-split-loop
|
||||
(fn (s sep start acc)
|
||||
(let ((idx (go-strings-index-loop s sep start)))
|
||||
(cond
|
||||
(< idx 0)
|
||||
(go-strings-split-finalize acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-split-loop s sep (+ idx (len sep))
|
||||
(go-strings-split-finalize acc
|
||||
(go-strings-substr s start idx)))))))
|
||||
|
||||
(define
|
||||
go-strings-split-finalize
|
||||
;; Append a piece to acc, growing the list in order.
|
||||
(fn (acc piece)
|
||||
(cond
|
||||
(= (len acc) 0) (list piece)
|
||||
:else (go-name-concat acc (list piece)))))
|
||||
|
||||
;; ── Replace ──────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-replace
|
||||
;; Replace(s, old, new, n). n < 0 = all.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 4))
|
||||
(list :eval-error :strings-replace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (old (nth args 1))
|
||||
(newv (nth args 2)) (n (nth args 3)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? old)) (list :eval-error :strings-not-string old)
|
||||
(not (string? newv)) (list :eval-error :strings-not-string newv)
|
||||
(= (len old) 0) s
|
||||
:else (go-strings-replace-loop s old newv n 0 ""))))))
|
||||
|
||||
(define
|
||||
go-strings-replace-loop
|
||||
(fn (s old newv n start acc)
|
||||
(let ((idx (go-strings-index-loop s old start)))
|
||||
(cond
|
||||
(or (< idx 0) (= n 0))
|
||||
(str acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-replace-loop s old newv
|
||||
(cond (< n 0) -1 :else (- n 1))
|
||||
(+ idx (len old))
|
||||
(str acc (go-strings-substr s start idx) newv))))))
|
||||
|
||||
;; ── go-std-strings package value ─────────────────────────────────
|
||||
|
||||
(define
|
||||
go-std-strings
|
||||
(list :go-package "strings"
|
||||
(list
|
||||
(list "Contains" (list :go-builtin-fn go-strings-contains))
|
||||
(list "HasPrefix" (list :go-builtin-fn go-strings-has-prefix))
|
||||
(list "HasSuffix" (list :go-builtin-fn go-strings-has-suffix))
|
||||
(list "Index" (list :go-builtin-fn go-strings-index))
|
||||
(list "Count" (list :go-builtin-fn go-strings-count))
|
||||
(list "Repeat" (list :go-builtin-fn go-strings-repeat))
|
||||
(list "Join" (list :go-builtin-fn go-strings-join))
|
||||
(list "ToUpper" (list :go-builtin-fn go-strings-to-upper))
|
||||
(list "ToLower" (list :go-builtin-fn go-strings-to-lower))
|
||||
(list "TrimSpace" (list :go-builtin-fn go-strings-trim-space))
|
||||
(list "Split" (list :go-builtin-fn go-strings-split))
|
||||
(list "Replace" (list :go-builtin-fn go-strings-replace)))))
|
||||
186
lib/go/tests/e2e.sx
Normal file
186
lib/go/tests/e2e.sx
Normal file
@@ -0,0 +1,186 @@
|
||||
;; Go end-to-end tests — complete programs exercising lex+parse+
|
||||
;; types+eval+sched+stdlib together. Each test runs a multi-line Go
|
||||
;; program and inspects the final env.
|
||||
|
||||
(define go-e2e-test-count 0)
|
||||
(define go-e2e-test-pass 0)
|
||||
(define go-e2e-test-fails (list))
|
||||
|
||||
(define
|
||||
go-e2e-test
|
||||
(fn (name actual expected)
|
||||
(set! go-e2e-test-count (+ go-e2e-test-count 1))
|
||||
(if (= actual expected)
|
||||
(set! go-e2e-test-pass (+ go-e2e-test-pass 1))
|
||||
(append! go-e2e-test-fails
|
||||
{:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-e2e-env
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-e2e-run
|
||||
(fn (src-list)
|
||||
(go-eval-program go-e2e-env (map go-parse src-list))))
|
||||
|
||||
;; ── 1. Sieve via boolean slice (no modulo needed) ────────────────
|
||||
(go-e2e-test "e2e: sieve-of-Eratosthenes via boolean slice — count primes ≤ 30"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
;; sieve[i] true means i is COMPOSITE (saves the
|
||||
;; default-bool initialisation for primes).
|
||||
"sieve := []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false}"
|
||||
"for p := 2; p < 31; p = p + 1 { if sieve[p] == false { for k := p + p; k < 31; k = k + p { sieve[k] = true } } }"
|
||||
"count := 0"
|
||||
"for i := 2; i < 31; i = i + 1 { if sieve[i] == false { count = count + 1 } }"))))
|
||||
(go-env-lookup env "count"))
|
||||
;; primes ≤ 30: 2,3,5,7,11,13,17,19,23,29 = 10
|
||||
10)
|
||||
|
||||
;; ── 1b. Range-membership check (works without mod) ───────────────
|
||||
(go-e2e-test "e2e: linear search across slice of strings"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"words := []string{\"apple\", \"banana\", \"cherry\", \"date\"}"
|
||||
"func indexOf(xs []string, target string) int { for i, v := range xs { if v == target { return i } } ; return -1 }"
|
||||
"i := indexOf(words, \"cherry\")"
|
||||
"missing := indexOf(words, \"xyz\")"))))
|
||||
(list (go-env-lookup env "i") (go-env-lookup env "missing")))
|
||||
(list 2 -1))
|
||||
|
||||
;; ── 2. Reverse a slice ───────────────────────────────────────────
|
||||
(go-e2e-test "e2e: reverse a slice of ints"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func reverse(xs []int) []int { r := []int{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"out := reverse([]int{1, 2, 3, 4, 5})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 5 4 3 2 1)))
|
||||
|
||||
;; ── 3. Fibonacci (recursive) ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: fib(10) = 55"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func fib(n int) int { if n < 2 { return n } ; return fib(n-1) + fib(n-2) }"
|
||||
"r := fib(10)"))))
|
||||
(go-env-lookup env "r"))
|
||||
55)
|
||||
|
||||
;; ── 4. Sum-of-squares via Map+Reduce ─────────────────────────────
|
||||
(go-e2e-test "e2e: sum-of-squares 1..5 via Map+Reduce"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }"
|
||||
"func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }"
|
||||
"func sq(x int) int { return x * x }"
|
||||
"func add(a int, b int) int { return a + b }"
|
||||
"squares := Map([]int{1, 2, 3, 4, 5}, sq)"
|
||||
"total := Reduce(squares, 0, add)"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1 + 4 + 9 + 16 + 25 = 55
|
||||
55)
|
||||
|
||||
;; ── 5. Word frequency counter ────────────────────────────────────
|
||||
(go-e2e-test "e2e: word-frequency over a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"text := \"the quick brown fox jumps over the lazy dog the\""
|
||||
"words := strings.Split(text, \" \")"
|
||||
"counts := map[string]int{}"
|
||||
"for i, w := range words { counts[w] = counts[w] + 1 }"
|
||||
"the_count := counts[\"the\"]"
|
||||
"fox_count := counts[\"fox\"]"
|
||||
"dog_count := counts[\"dog\"]"))))
|
||||
(list (go-env-lookup env "the_count")
|
||||
(go-env-lookup env "fox_count")
|
||||
(go-env-lookup env "dog_count")))
|
||||
(list 3 1 1))
|
||||
|
||||
;; ── 6. Pipeline via channels ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: pipeline — generate, square, sum"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func gen(c chan int, n int) { for i := 1; i <= n; i = i + 1 { c <- i } ; close(c) }"
|
||||
"func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }"
|
||||
"src := make()"
|
||||
"sqs := make()"
|
||||
"go gen(src, 4)"
|
||||
"go sq(src, sqs)"
|
||||
"total := 0"
|
||||
"for v := range sqs { total = total + v }"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1+4+9+16 = 30
|
||||
30)
|
||||
|
||||
;; ── 7. Worker pool draining a job channel ────────────────────────
|
||||
(go-e2e-test "e2e: worker pool — sum of doubled jobs"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func worker(jobs chan int, results chan int) { for j := range jobs { results <- j * 2 } }"
|
||||
"jobs := make()"
|
||||
"results := make()"
|
||||
"jobs <- 10 ; jobs <- 20 ; jobs <- 30"
|
||||
"close(jobs)"
|
||||
"go worker(jobs, results)"
|
||||
"close(results)"
|
||||
"sum := 0"
|
||||
"for r := range results { sum = sum + r }"))))
|
||||
(go-env-lookup env "sum"))
|
||||
;; 20 + 40 + 60 = 120
|
||||
120)
|
||||
|
||||
;; ── 8. Bubble sort ───────────────────────────────────────────────
|
||||
(go-e2e-test "e2e: bubble sort ascending"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func bubble(xs []int) []int { n := len(xs) ; for i := 0; i < n; i = i + 1 { for j := 0; j < n - 1; j = j + 1 { if xs[j] > xs[j+1] { tmp := xs[j] ; xs[j] = xs[j+1] ; xs[j+1] = tmp } } } ; return xs }"
|
||||
"out := bubble([]int{3, 1, 4, 1, 5, 9, 2, 6})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 1 1 2 3 4 5 6 9)))
|
||||
|
||||
;; ── 9. String reverse using strings.Split + reverse + Join ──────
|
||||
(go-e2e-test "e2e: reverse words in a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func rev(xs []string) []string { r := []string{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"text := \"go on sx\""
|
||||
"out := strings.Join(rev(strings.Split(text, \" \")), \"-\")"))))
|
||||
(go-env-lookup env "out"))
|
||||
"sx-on-go")
|
||||
|
||||
;; ── 10. Counting occurrences via Filter ──────────────────────────
|
||||
(go-e2e-test "e2e: count even numbers via Filter+len"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }"
|
||||
"func gt5(x int) bool { return x > 5 }"
|
||||
"n := len(Filter([]int{1, 2, 6, 3, 7, 8, 4, 9}, gt5))"))))
|
||||
(go-env-lookup env "n"))
|
||||
;; gt5: 6,7,8,9 = 4
|
||||
4)
|
||||
|
||||
;; ── 11. Recursive ackermann (small inputs) ───────────────────────
|
||||
(go-e2e-test "e2e: ackermann(2, 3) = 9"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func ack(m int, n int) int { if m == 0 { return n + 1 } ; if n == 0 { return ack(m - 1, 1) } ; return ack(m - 1, ack(m, n - 1)) }"
|
||||
"r := ack(2, 3)"))))
|
||||
(go-env-lookup env "r"))
|
||||
9)
|
||||
|
||||
;; ── 12. Defer + recover smoke test ───────────────────────────────
|
||||
(go-e2e-test "e2e: defer + recover in real-fn flow"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func safeDivide(a int, b int) int { defer recover() ; if b == 0 { panic(\"div by zero\") } ; return a / b }"
|
||||
"r := safeDivide(10, 0)"
|
||||
"after := 99"))))
|
||||
(go-env-lookup env "after"))
|
||||
99)
|
||||
|
||||
(define
|
||||
go-e2e-test-summary
|
||||
(str "e2e " go-e2e-test-pass "/" go-e2e-test-count))
|
||||
667
lib/go/tests/eval.sx
Normal file
667
lib/go/tests/eval.sx
Normal file
@@ -0,0 +1,667 @@
|
||||
;; Go evaluator tests.
|
||||
|
||||
(define go-eval-test-count 0)
|
||||
(define go-eval-test-pass 0)
|
||||
(define go-eval-test-fails (list))
|
||||
|
||||
(define
|
||||
go-eval-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-eval-test-count (+ go-eval-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-eval-test-pass (+ go-eval-test-pass 1))
|
||||
(append! go-eval-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define gtev (fn (env src) (go-eval env (go-parse src))))
|
||||
|
||||
;; ── env ──────────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"env: empty lookup returns nil"
|
||||
(go-env-lookup go-env-empty "x")
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"env: extend then lookup"
|
||||
(go-env-lookup (go-env-extend go-env-empty "x" 42) "x")
|
||||
42)
|
||||
|
||||
;; ── literals ────────────────────────────────────────────────────
|
||||
(go-eval-test "lit: 42 → 42" (gtev go-env-empty "42") 42)
|
||||
|
||||
(go-eval-test "lit: 0 → 0" (gtev go-env-empty "0") 0)
|
||||
|
||||
(go-eval-test "lit: 0xFF → 255" (gtev go-env-empty "0xFF") 255)
|
||||
|
||||
(go-eval-test "lit: 0b1010 → 10" (gtev go-env-empty "0b1010") 10)
|
||||
|
||||
(go-eval-test "lit: 0o17 → 15" (gtev go-env-empty "0o17") 15)
|
||||
|
||||
(go-eval-test
|
||||
"lit: underscore separator 1_000 → 1000"
|
||||
(gtev go-env-empty "1_000")
|
||||
1000)
|
||||
|
||||
(go-eval-test "lit: string" (gtev go-env-empty "\"hello\"") "hello")
|
||||
|
||||
;; ── predeclared ─────────────────────────────────────────────────
|
||||
(go-eval-test "var: true" (gtev go-env-empty "true") true)
|
||||
(go-eval-test "var: false" (gtev go-env-empty "false") false)
|
||||
(go-eval-test "var: nil" (gtev go-env-empty "nil") nil)
|
||||
|
||||
;; ── variable lookup ─────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var: bound x → 5"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"var: unbound y → :eval-error"
|
||||
(gtev go-env-empty "y")
|
||||
(list :eval-error :unbound "y"))
|
||||
|
||||
;; ── binary ops ─────────────────────────────────────────────────
|
||||
(go-eval-test "binop: 1 + 2 → 3" (gtev go-env-empty "1 + 2") 3)
|
||||
(go-eval-test "binop: 10 - 4 → 6" (gtev go-env-empty "10 - 4") 6)
|
||||
(go-eval-test "binop: 3 * 7 → 21" (gtev go-env-empty "3 * 7") 21)
|
||||
(go-eval-test "binop: 42 / 7 → 6" (gtev go-env-empty "42 / 7") 6)
|
||||
(go-eval-test
|
||||
"binop: 2 + 3 * 4 → 14 (prec)"
|
||||
(gtev go-env-empty "2 + 3 * 4")
|
||||
14)
|
||||
(go-eval-test
|
||||
"binop: a + b uses env"
|
||||
(go-eval
|
||||
(go-env-extend (go-env-extend go-env-empty "a" 3) "b" 4)
|
||||
(go-parse "a + b"))
|
||||
7)
|
||||
|
||||
(go-eval-test "binop: 1 < 2 → true" (gtev go-env-empty "1 < 2") true)
|
||||
(go-eval-test "binop: 5 == 5 → true" (gtev go-env-empty "5 == 5") true)
|
||||
(go-eval-test "binop: 5 != 5 → false" (gtev go-env-empty "5 != 5") false)
|
||||
(go-eval-test
|
||||
"binop: true && false → false"
|
||||
(gtev go-env-empty "true && false")
|
||||
false)
|
||||
(go-eval-test
|
||||
"binop: false || true → true"
|
||||
(gtev go-env-empty "false || true")
|
||||
true)
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var-decl: var x = 5 — env has x=5"
|
||||
(go-env-lookup
|
||||
(go-eval-program go-env-empty (list (go-parse "var x = 5")))
|
||||
"x")
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"short-decl: a, b := 3, 4 — env has both"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a, b := 3, 4")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 3 4))
|
||||
|
||||
(go-eval-test
|
||||
"assign: x = 5 then x → 5"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 1) (list (go-parse "x = 5")))))
|
||||
(go-env-lookup env "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"if: true branch evaluates"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if true { x = 1 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"if-else: false → else branch"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if false { x = 1 } else { x = 2 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"fn: define + call — double(7) = 14"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func double(x int) int { return x * 2 }")))))
|
||||
(go-eval env (go-parse "double(7)")))
|
||||
14)
|
||||
|
||||
(go-eval-test
|
||||
"fn: add(2, 3) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func add(x, y int) int { return x + y }")))))
|
||||
(go-eval env (go-parse "add(2, 3)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"fn: recursive fib(5) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(5)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: count to 10 with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 10; i++ { sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
45)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x++ updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x++")))))
|
||||
(go-env-lookup env "x"))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x-- updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x--")))))
|
||||
(go-env-lookup env "x"))
|
||||
4)
|
||||
|
||||
(go-eval-test
|
||||
"for: break exits the loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var i = 0") (go-parse "for i < 100 { if i == 5 { break } ; i++ }")))))
|
||||
(go-env-lookup env "i"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: continue skips body but runs post"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 5; i++ { if i == 2 { continue } ; sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"for: infinite + break with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var s = 0") (go-parse "var i = 1") (go-parse "for { if i > 4 { break } ; s = s + i ; i++ }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-eval-test
|
||||
"fn: iterative factorial via for-loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fact(n int) int { r := 1 ; for i := 2 ; i <= n ; i++ { r = r * i } ; return r }")))))
|
||||
(go-eval env (go-parse "fact(5)")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"slice: []int{1,2,3} → :go-slice"
|
||||
(gtev go-env-empty "[]int{1, 2, 3}")
|
||||
(list :go-slice (list 1 2 3)))
|
||||
|
||||
(go-eval-test
|
||||
"index: a[0] = 10, a[2] = 30"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}")))))
|
||||
(list (go-eval env (go-parse "a[0]")) (go-eval env (go-parse "a[2]"))))
|
||||
(list 10 30))
|
||||
|
||||
(go-eval-test
|
||||
"index: out-of-range error"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2}")))))
|
||||
(go-eval env (go-parse "a[5]")))
|
||||
(list :eval-error :index-out-of-range 5 2))
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(slice) = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "len(a)")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(string)"
|
||||
(go-eval go-env-builtins (go-parse "len(\"hello\")"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: append(a, 4, 5)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "append(a, 4, 5)")))
|
||||
(list
|
||||
:go-slice (list 1 2 3 4 5)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[1:3]"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "a[1:3]")))
|
||||
(list :go-slice (list 20 30)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[:2] (omitted low)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[:2]")))
|
||||
(list :go-slice (list 1 2)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[2:] (omitted high)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[2:]")))
|
||||
(list :go-slice (list 3 4)))
|
||||
|
||||
(go-eval-test
|
||||
"fn: sum slice via for-loop with len + index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "sum := 0") (go-parse "for i := 0; i < len(a); i++ { sum = sum + a[i] }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-eval-test
|
||||
"map: map[string]int{...} → :go-map"
|
||||
(gtev go-env-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :go-map (list (list "a" 1) (list "b" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"map: m[\"a\"] → 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"map: missing key → nil (v0 stand-in for zero value)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}")))))
|
||||
(go-eval env (go-parse "m[\"missing\"]")))
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"map: len(m) = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "len(m)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign updates existing key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}") (go-parse "m[\"a\"] = 99")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign adds new key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{}") (go-parse "m[\"new\"] = 7")))))
|
||||
(go-eval env (go-parse "m[\"new\"]")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"slice: index-assign a[0] = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}") (go-parse "a[0] = 99")))))
|
||||
(go-eval env (go-parse "a[0]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: word count via loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "words := []string{\"a\", \"b\", \"a\", \"c\", \"a\"}") (go-parse "counts := map[string]int{}") (go-parse "for i := 0; i < len(words); i++ { counts[words[i]] = counts[words[i]] + 1 }")))))
|
||||
(go-eval env (go-parse "counts[\"a\"]")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"type-decl: registers struct field names"
|
||||
(go-env-lookup
|
||||
(go-eval-program
|
||||
go-env-empty
|
||||
(list (go-parse "type Point struct { x, y int }")))
|
||||
"Point")
|
||||
(list :go-struct-type (list "x" "y")))
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional composite Point{1, 2}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1, 2}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 1) (list "y" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: keyed composite Point{x: 5, y: 10}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{x: 5, y: 10}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 5) (list "y" 10))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.x = 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.y = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.y")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector-assign p.x = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}") (go-parse "p.x = 99")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional arity-mismatch"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1}")))
|
||||
(list :eval-error :struct-arity-mismatch "Point" 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"struct: function takes/returns struct"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func add(a, b Point) Point { return Point{a.x + b.x, a.y + b.y} }")))))
|
||||
(go-eval env (go-parse "add(Point{1, 2}, Point{3, 4})")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 4) (list "y" 6))))
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Sum() = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Sum() int { return p.x + p.y }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Sum()")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Add(5) = 6 (with arg)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Add(d int) int { return p.x + d }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Add(5)")))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"method: pointer receiver works value-style in v0"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p *Point) GetX() int { return p.x }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.GetX()")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"method: missing method → :no-such-method"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Ghost()")))
|
||||
(list :eval-error :no-such-method "Point" "Ghost"))
|
||||
|
||||
(go-eval-test
|
||||
"unary: -x"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "-x"))
|
||||
-5)
|
||||
|
||||
(go-eval-test "unary: !true → false" (gtev go-env-empty "!true") false)
|
||||
|
||||
(go-eval-test "unary: !false → true" (gtev go-env-empty "!false") true)
|
||||
|
||||
(go-eval-test
|
||||
"unary: -3 + 5 = 2 (unary binds tighter)"
|
||||
(gtev go-env-empty "-3 + 5")
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: count odd numbers in 1..10 = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty
|
||||
(list (go-parse "odds := 0")
|
||||
(go-parse "i := 1")
|
||||
(go-parse "for i <= 10 { odds = odds + 1; i = i + 2 }")))))
|
||||
(go-env-lookup env "odds"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: factorial via method on Counter"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Acc struct { v int }") (go-parse "func (a Acc) Mul(x int) Acc { return Acc{a.v * x} }") (go-parse "a := Acc{1}") (go-parse "for i := 1; i <= 5; i++ { a = a.Mul(i) }")))))
|
||||
(go-eval env (go-parse "a.v")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: recursive fibonacci fib(10) = 55"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(10)")))
|
||||
55)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: struct + method + iterative loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Counter struct { n int }") (go-parse "func (c Counter) Bump() Counter { return Counter{c.n + 1} }") (go-parse "c := Counter{0}") (go-parse "for i := 0; i < 7; i++ { c = c.Bump() }")))))
|
||||
(go-eval env (go-parse "c.n")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "find(nums, 30)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns -1 when missing"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30}")))))
|
||||
(go-eval env (go-parse "find(nums, 99)")))
|
||||
-1)
|
||||
|
||||
(go-eval-test
|
||||
"defer: single defer runs after surrounding fn body returns"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func run(c chan int) { defer push2(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "first := <-ch") (go-parse "second := <-ch")))))
|
||||
(list (go-env-lookup env "first") (go-env-lookup env "second")))
|
||||
(list 1 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: multiple defers run LIFO"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func p3(c chan int) { c <- 3 }") (go-parse "func run(c chan int) { defer p2(c) ; defer p3(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")))
|
||||
(list 1 3 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: arguments are evaluated at defer-time (not call-time)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { x := 7 ; defer pushN(c, x) ; x = 99 }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"defer: runs even when fn returns early via return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 42 }") (go-parse "func run(c chan int) int { defer note(c) ; return 1 }") (go-parse "r := run(ch)") (go-parse "n := <-ch")))))
|
||||
(list (go-env-lookup env "r") (go-env-lookup env "n")))
|
||||
(list 1 42))
|
||||
|
||||
(go-eval-test
|
||||
"defer: stack is frame-local — outer defers don't run on inner return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push1(c chan int) { c <- 1 }") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func inner(c chan int) { defer push2(c) }") (go-parse "func outer(c chan int) { defer push1(c) ; inner(c) }") (go-parse "outer(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"defer: in a loop, all defers fire on fn return (not loop iter)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushI(c chan int, v int) { c <- v }") (go-parse "func loop(c chan int) { for i := 0; i < 4; i = i + 1 { defer pushI(c, i) } }") (go-parse "loop(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch") (go-parse "e := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")
|
||||
(go-env-lookup env "e")))
|
||||
(list 3 2 1 0))
|
||||
|
||||
(go-eval-test
|
||||
"panic: uncaught panic surfaces as (:go-panic V) from program"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(\"boom\")")))))
|
||||
r)
|
||||
(list :go-panic "boom"))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside fn: surfaces from fn call too"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"oops\") }") (go-parse "boom()")))))
|
||||
r)
|
||||
(list :go-panic "oops"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover swallows panic, fn returns normally"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func safe() { defer recover() ; panic(\"x\") }") (go-parse "safe()") (go-parse "after := 42")))))
|
||||
(go-env-lookup env "after"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover captures the panic value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func grab(c chan int) { r := recover() ; c <- r }") (go-parse "func safe(c chan int) { defer grab(c) ; panic(99) }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"panic: propagates through intermediate frames without defers"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { middle() }") (go-parse "outer()")))))
|
||||
r)
|
||||
(list :go-panic "deep"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: middle-frame defer catches panic from deeper frame"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { defer recover() ; middle() }") (go-parse "outer()") (go-parse "after := 7")))))
|
||||
(go-env-lookup env "after"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic: surfaces synchronously back to spawner (v0)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"goroutine\") }") (go-parse "go boom()")))))
|
||||
r)
|
||||
(list :go-panic "goroutine"))
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic + spawner-defer-recover catches it (v0 sync)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"g\") }") (go-parse "func main() { defer recover() ; go boom() }") (go-parse "main()") (go-parse "after := 11")))))
|
||||
(go-env-lookup env "after"))
|
||||
11)
|
||||
|
||||
(go-eval-test
|
||||
"defer order with recover: all defers run, recover catches"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func rec(c chan int) { recover() ; c <- 7 }") (go-parse "func safe(c chan int) { defer p2(c) ; defer rec(c) ; panic(0) }") (go-parse "safe(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 7 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer fires when fn panics (not just normal return)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 5 }") (go-parse "func safe(c chan int) { defer note(c) ; defer recover() ; panic(\"!\") }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"panic with nil value: still surfaces as (:go-panic nil)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(nil)")))))
|
||||
r)
|
||||
(list :go-panic nil))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside loop body: aborts loop + propagates"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func find(x int) { for i := 0; i < 10; i = i + 1 { if i == x { panic(i) } } }") (go-parse "find(3)")))))
|
||||
r)
|
||||
(list :go-panic 3))
|
||||
|
||||
(go-eval-test
|
||||
"defer in panicking fn: still runs even though no return reached"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func mark(c chan int) { c <- 8 }") (go-parse "func inner(c chan int) { defer mark(c) ; panic(\"!\") }") (go-parse "func outer(c chan int) { defer recover() ; inner(c) }") (go-parse "outer(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"defer fn captures args by value, not reference (re-confirm)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { defer recover() ; x := 5 ; defer pushN(c, x) ; x = 999 ; panic(\"k\") }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"generic: identity Id[T any](x) returns x at runtime"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(42)")))))
|
||||
(go-env-lookup env "r"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"generic: Id works with strings (type erasure)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(\"hi\")")))))
|
||||
(go-env-lookup env "r"))
|
||||
"hi")
|
||||
|
||||
(go-eval-test
|
||||
"generic: Map[T, U] over []int with double — produces []int"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }") (go-parse "func dbl(x int) int { return x * 2 }") (go-parse "out := Map([]int{1, 2, 3}, dbl)") (go-parse "first := out[0]") (go-parse "second := out[1]") (go-parse "third := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "second")
|
||||
(go-env-lookup env "third")))
|
||||
(list 2 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Filter[T any] keeps elements satisfying predicate"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }") (go-parse "func gt3(x int) bool { return x > 3 }") (go-parse "out := Filter([]int{1, 2, 3, 4, 5, 6}, gt3)") (go-parse "n := len(out)") (go-parse "first := out[0]") (go-parse "last := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "n")
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "last")))
|
||||
(list 3 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Reduce[T, U] sums []int with seed 0"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }") (go-parse "func add(a int, b int) int { return a + b }") (go-parse "total := Reduce([]int{10, 20, 30, 40}, 0, add)")))))
|
||||
(go-env-lookup env "total"))
|
||||
100)
|
||||
|
||||
(go-eval-test
|
||||
"generic: First[T any]([]T) T returns element zero"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func First[T any](xs []T) T { return xs[0] }") (go-parse "v := First([]int{42, 99})")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(define
|
||||
go-eval-test-summary
|
||||
(str "eval " go-eval-test-pass "/" go-eval-test-count))
|
||||
339
lib/go/tests/lex.sx
Normal file
339
lib/go/tests/lex.sx
Normal file
@@ -0,0 +1,339 @@
|
||||
;; Go tokenizer tests.
|
||||
|
||||
(define go-test-count 0)
|
||||
(define go-test-pass 0)
|
||||
(define go-test-fails (list))
|
||||
|
||||
(define gtok-type (fn (t) (get t :type)))
|
||||
(define gtok-value (fn (t) (get t :value)))
|
||||
(define tok-types (fn (src) (map gtok-type (go-tokenize src))))
|
||||
(define tok-values (fn (src) (map gtok-value (go-tokenize src))))
|
||||
|
||||
(define
|
||||
go-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-test-count (+ go-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-test-pass (+ go-test-pass 1))
|
||||
(append! go-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── empty / whitespace ────────────────────────────────────────────
|
||||
(go-test "empty source" (tok-types "") (list "eof"))
|
||||
(go-test "spaces only" (tok-types " ") (list "eof"))
|
||||
(go-test "tabs only" (tok-types "\t\t") (list "eof"))
|
||||
(go-test
|
||||
"newline only — no prior token, no ASI"
|
||||
(tok-types "\n")
|
||||
(list "eof"))
|
||||
|
||||
;; ── identifiers ───────────────────────────────────────────────────
|
||||
(go-test "ident: simple" (tok-values "foo") (list "foo" "\n" nil))
|
||||
(go-test
|
||||
"ident: underscore prefix"
|
||||
(tok-values "_bar")
|
||||
(list "_bar" "\n" nil))
|
||||
(go-test "ident: mixed case" (tok-values "fooBar") (list "fooBar" "\n" nil))
|
||||
(go-test "ident: with digits" (tok-values "x123") (list "x123" "\n" nil))
|
||||
(go-test "ident: type tag" (tok-types "foo") (list "ident" "semi" "eof"))
|
||||
|
||||
;; ── keywords (all 25) ─────────────────────────────────────────────
|
||||
(go-test "kw: break" (tok-types "break") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: case" (tok-types "case") (list "keyword" "eof"))
|
||||
(go-test "kw: chan" (tok-types "chan") (list "keyword" "eof"))
|
||||
(go-test "kw: const" (tok-types "const") (list "keyword" "eof"))
|
||||
(go-test "kw: continue" (tok-types "continue") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: default" (tok-types "default") (list "keyword" "eof"))
|
||||
(go-test "kw: defer" (tok-types "defer") (list "keyword" "eof"))
|
||||
(go-test "kw: else" (tok-types "else") (list "keyword" "eof"))
|
||||
(go-test
|
||||
"kw: fallthrough"
|
||||
(tok-types "fallthrough")
|
||||
(list "keyword" "semi" "eof"))
|
||||
(go-test "kw: for" (tok-types "for") (list "keyword" "eof"))
|
||||
(go-test "kw: func" (tok-types "func") (list "keyword" "eof"))
|
||||
(go-test "kw: go" (tok-types "go") (list "keyword" "eof"))
|
||||
(go-test "kw: goto" (tok-types "goto") (list "keyword" "eof"))
|
||||
(go-test "kw: if" (tok-types "if") (list "keyword" "eof"))
|
||||
(go-test "kw: import" (tok-types "import") (list "keyword" "eof"))
|
||||
(go-test "kw: interface" (tok-types "interface") (list "keyword" "eof"))
|
||||
(go-test "kw: map" (tok-types "map") (list "keyword" "eof"))
|
||||
(go-test "kw: package" (tok-types "package") (list "keyword" "eof"))
|
||||
(go-test "kw: range" (tok-types "range") (list "keyword" "eof"))
|
||||
(go-test "kw: return" (tok-types "return") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: select" (tok-types "select") (list "keyword" "eof"))
|
||||
(go-test "kw: struct" (tok-types "struct") (list "keyword" "eof"))
|
||||
(go-test "kw: switch" (tok-types "switch") (list "keyword" "eof"))
|
||||
(go-test "kw: type" (tok-types "type") (list "keyword" "eof"))
|
||||
(go-test "kw: var" (tok-types "var") (list "keyword" "eof"))
|
||||
|
||||
;; ── integer literals — decimal ────────────────────────────────────
|
||||
(go-test "int: zero" (tok-values "0") (list "0" "\n" nil))
|
||||
(go-test "int: small" (tok-values "42") (list "42" "\n" nil))
|
||||
(go-test "int: bigger" (tok-values "123456") (list "123456" "\n" nil))
|
||||
(go-test "int: type" (tok-types "42") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── integer literals — prefixed + underscores ─────────────────────
|
||||
(go-test "int: hex lower" (tok-values "0x1f") (list "0x1f" "\n" nil))
|
||||
(go-test "int: hex upper-x" (tok-values "0X1F") (list "0X1F" "\n" nil))
|
||||
(go-test
|
||||
"int: hex mixed digits"
|
||||
(tok-values "0xDEADbeef")
|
||||
(list "0xDEADbeef" "\n" nil))
|
||||
(go-test "int: binary lower" (tok-values "0b1010") (list "0b1010" "\n" nil))
|
||||
(go-test "int: binary upper" (tok-values "0B1101") (list "0B1101" "\n" nil))
|
||||
(go-test "int: octal modern" (tok-values "0o755") (list "0o755" "\n" nil))
|
||||
(go-test "int: octal upper" (tok-values "0O17") (list "0O17" "\n" nil))
|
||||
(go-test "int: octal legacy" (tok-values "0755") (list "0755" "\n" nil))
|
||||
(go-test "int: hex type" (tok-types "0x1F") (list "int" "semi" "eof"))
|
||||
(go-test "int: bin type" (tok-types "0b101") (list "int" "semi" "eof"))
|
||||
(go-test
|
||||
"int: dec underscore"
|
||||
(tok-values "1_000_000")
|
||||
(list "1_000_000" "\n" nil))
|
||||
(go-test
|
||||
"int: hex underscore"
|
||||
(tok-values "0xDEAD_BEEF")
|
||||
(list "0xDEAD_BEEF" "\n" nil))
|
||||
(go-test
|
||||
"int: bin underscore"
|
||||
(tok-values "0b1010_1010")
|
||||
(list "0b1010_1010" "\n" nil))
|
||||
(go-test
|
||||
"int: hex then +"
|
||||
(tok-types "0xFF + 1")
|
||||
(list "int" "op" "int" "semi" "eof"))
|
||||
|
||||
;; ── float literals (Go spec § Floating-point literals) ────────────
|
||||
(go-test "float: simple" (tok-values "3.14") (list "3.14" "\n" nil))
|
||||
(go-test "float: trailing dot" (tok-values "1.") (list "1." "\n" nil))
|
||||
(go-test "float: leading dot" (tok-values ".5") (list ".5" "\n" nil))
|
||||
(go-test "float: exp lower" (tok-values "1e10") (list "1e10" "\n" nil))
|
||||
(go-test "float: exp upper" (tok-values "1E5") (list "1E5" "\n" nil))
|
||||
(go-test "float: exp negative" (tok-values "1.5e-3") (list "1.5e-3" "\n" nil))
|
||||
(go-test "float: exp positive" (tok-values "2.0e+2") (list "2.0e+2" "\n" nil))
|
||||
(go-test "float: zero" (tok-values "0.0") (list "0.0" "\n" nil))
|
||||
(go-test "float: dot-only-exp" (tok-values ".5e2") (list ".5e2" "\n" nil))
|
||||
(go-test "float: underscore" (tok-values "1_000.5") (list "1_000.5" "\n" nil))
|
||||
(go-test "float: type" (tok-types "3.14") (list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: trailing dot type"
|
||||
(tok-types "1.")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: exp-only type"
|
||||
(tok-types "1e10")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: then +"
|
||||
(tok-types "3.14 + 0.1")
|
||||
(list "float" "op" "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: greedy 1.method"
|
||||
(tok-types "1.method")
|
||||
(list "float" "ident" "semi" "eof"))
|
||||
|
||||
;; ── imaginary literals (Go spec § Imaginary literals) ─────────────
|
||||
(go-test "imag: int i" (tok-values "2i") (list "2i" "\n" nil))
|
||||
(go-test "imag: float i" (tok-values "3.14i") (list "3.14i" "\n" nil))
|
||||
(go-test "imag: exp i" (tok-values "1e2i") (list "1e2i" "\n" nil))
|
||||
(go-test "imag: int-i type" (tok-types "2i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: float-i type" (tok-types "3.14i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: ASI at newline" (tok-types "1i\n") (list "imag" "semi" "eof"))
|
||||
|
||||
;; ── string literals ───────────────────────────────────────────────
|
||||
(go-test "raw: simple" (tok-values "`hello`") (list "hello" "\n" nil))
|
||||
(go-test "raw: empty" (tok-values "``") (list "" "\n" nil))
|
||||
(go-test
|
||||
"raw: backslash literal — no escape processing"
|
||||
(tok-values "`a\\nb`")
|
||||
(list "a\\nb" "\n" nil))
|
||||
(go-test
|
||||
"raw: multi-line"
|
||||
(tok-values "`line1\nline2`")
|
||||
(list "line1\nline2" "\n" nil))
|
||||
(go-test
|
||||
"raw: contains double-quote"
|
||||
(tok-values "`say \"hi\"`")
|
||||
(list "say \"hi\"" "\n" nil))
|
||||
(go-test
|
||||
"raw: CR stripped (Go spec § String literals)"
|
||||
(tok-values "`a\r\nb`")
|
||||
(list "a\nb" "\n" nil))
|
||||
(go-test "raw: type" (tok-types "`x`") (list "string" "semi" "eof"))
|
||||
|
||||
;; ── rune literals ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"raw: then +"
|
||||
(tok-types "`x` + 1")
|
||||
(list "string" "op" "int" "semi" "eof"))
|
||||
(go-test
|
||||
"raw: ASI at newline after"
|
||||
(tok-types "`abc`\n")
|
||||
(list "string" "semi" "eof"))
|
||||
(go-test "string: empty" (tok-values "\"\"") (list "" "\n" nil))
|
||||
|
||||
;; ── comments ──────────────────────────────────────────────────────
|
||||
(go-test "string: hello" (tok-values "\"hello\"") (list "hello" "\n" nil))
|
||||
(go-test
|
||||
"string: with space"
|
||||
(tok-values "\"hi there\"")
|
||||
(list "hi there" "\n" nil))
|
||||
(go-test "string: escape n" (tok-values "\"a\\nb\"") (list "a\nb" "\n" nil))
|
||||
(go-test "string: escape quote" (tok-values "\"a\\\"b\"") (list "a\"b" "\n" nil))
|
||||
(go-test
|
||||
"string: escape backslash"
|
||||
(tok-values "\"a\\\\b\"")
|
||||
(list "a\\b" "\n" nil))
|
||||
|
||||
;; ── operators & punctuation ───────────────────────────────────────
|
||||
(go-test "string: type" (tok-types "\"x\"") (list "string" "semi" "eof"))
|
||||
(go-test "rune: simple" (tok-values "'a'") (list "a" "\n" nil))
|
||||
(go-test "rune: escape" (tok-values "'\\n'") (list "\n" "\n" nil))
|
||||
(go-test "rune: type" (tok-types "'a'") (list "rune" "semi" "eof"))
|
||||
(go-test "line comment" (tok-types "// ignored") (list "eof"))
|
||||
(go-test "line comment then code" (tok-values "// hi\nx") (list "x" "\n" nil))
|
||||
(go-test "block comment" (tok-types "/* a b c */") (list "eof"))
|
||||
(go-test
|
||||
"block comment inline"
|
||||
(tok-values "x /* mid */ y")
|
||||
(list "x" "y" "\n" nil))
|
||||
(go-test
|
||||
"block comment with newline — ASI"
|
||||
(tok-types "x /* multi\nline */ y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
;; ── automatic semicolon insertion (Go spec § Semicolons) ──────────
|
||||
(go-test
|
||||
"ops: arithmetic"
|
||||
(tok-values "+ - * / %")
|
||||
(list "+" "-" "*" "/" "%" nil))
|
||||
(go-test
|
||||
"ops: comparison"
|
||||
(tok-values "== != < > <= >=")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" nil))
|
||||
(go-test "ops: logical" (tok-values "&& || !") (list "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"ops: assign forms"
|
||||
(tok-values "= := += -=")
|
||||
(list "=" ":=" "+=" "-=" nil))
|
||||
(go-test "ops: channel arrow" (tok-values "<- chan") (list "<-" "chan" nil))
|
||||
(go-test "ops: incdec ASI" (tok-types "++ --") (list "op" "op" "semi" "eof"))
|
||||
(go-test "ops: ellipsis" (tok-values "...") (list "..." nil))
|
||||
(go-test
|
||||
"punct: all brackets"
|
||||
(tok-values "( ) { } [ ]")
|
||||
(list "(" ")" "{" "}" "[" "]" "\n" nil))
|
||||
(go-test
|
||||
"punct: comma colon dot"
|
||||
(tok-values ", : .")
|
||||
(list "," ":" "." nil))
|
||||
(go-test
|
||||
"op-audit: tilde (generics type-set)"
|
||||
(tok-values "~int")
|
||||
(list "~" "int" "\n" nil))
|
||||
(go-test
|
||||
"op-audit: all arithmetic + assignment"
|
||||
(tok-values "+ - * / % += -= *= /= %=")
|
||||
(list "+" "-" "*" "/" "%" "+=" "-=" "*=" "/=" "%=" nil))
|
||||
(go-test
|
||||
"op-audit: all bitwise + assignment"
|
||||
(tok-values "& | ^ << >> &^ &= |= ^= <<= >>= &^=")
|
||||
(list "&" "|" "^" "<<" ">>" "&^" "&=" "|=" "^=" "<<=" ">>=" "&^=" nil))
|
||||
(go-test
|
||||
"op-audit: all comparison + logical"
|
||||
(tok-values "== != < > <= >= && || !")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"op-audit: assign / decls / arrows / variadic / inc-dec"
|
||||
(tok-values "= := <- ++ -- ...")
|
||||
(list "=" ":=" "<-" "++" "--" "..." nil))
|
||||
|
||||
;; ── short program ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"op-audit: punctuation"
|
||||
(tok-values "( ) [ ] { } , . :")
|
||||
(list "(" ")" "[" "]" "{" "}" "," "." ":" nil))
|
||||
(go-test
|
||||
"ASI: after ident at newline"
|
||||
(tok-types "x\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
(go-test "ASI: after int" (tok-types "42\n") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── report ────────────────────────────────────────────────────────
|
||||
(go-test "ASI: after float" (tok-types "3.14\n") (list "float" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after string"
|
||||
(tok-types "\"hi\"\n")
|
||||
(list "string" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after rune" (tok-types "'a'\n") (list "rune" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after )"
|
||||
(tok-types "f()\n")
|
||||
(list "ident" "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after ]"
|
||||
(tok-types "x[0]\n")
|
||||
(list "ident" "op" "int" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after }" (tok-types "{}\n") (list "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after ++" (tok-types "i++\n") (list "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after +"
|
||||
(tok-types "x +\ny")
|
||||
(list "ident" "op" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after ("
|
||||
(tok-types "f(\nx)")
|
||||
(list "ident" "op" "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: blank lines collapse — single semi only"
|
||||
(tok-types "x\n\n\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: at EOF after ident"
|
||||
(tok-types "x")
|
||||
(list "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: explicit semi"
|
||||
(tok-types "x;y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (types)"
|
||||
(tok-types "x := 42")
|
||||
(list "ident" "op" "int" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (values)"
|
||||
(tok-values "x := 42")
|
||||
(list "x" ":=" "42" "\n" nil))
|
||||
|
||||
(go-test
|
||||
"func decl shape"
|
||||
(tok-types "func foo() int { return 0 }")
|
||||
(list
|
||||
"keyword"
|
||||
"ident"
|
||||
"op"
|
||||
"op"
|
||||
"ident"
|
||||
"op"
|
||||
"keyword"
|
||||
"int"
|
||||
"op"
|
||||
"semi"
|
||||
"eof"))
|
||||
|
||||
(define go-lex-test-summary (str "lex " go-test-pass "/" go-test-count))
|
||||
1231
lib/go/tests/parse.sx
Normal file
1231
lib/go/tests/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
311
lib/go/tests/runtime.sx
Normal file
311
lib/go/tests/runtime.sx
Normal file
@@ -0,0 +1,311 @@
|
||||
;; Go runtime tests — goroutines + channels.
|
||||
|
||||
(define go-rt-test-count 0)
|
||||
(define go-rt-test-pass 0)
|
||||
(define go-rt-test-fails (list))
|
||||
|
||||
(define
|
||||
go-rt-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-rt-test-count (+ go-rt-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-rt-test-pass (+ go-rt-test-pass 1))
|
||||
(append! go-rt-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── channel primitives (direct API, no source parsing) ─────────
|
||||
(go-rt-test "chan: make returns a chan value" (go-chan? (go-make-chan)) true)
|
||||
|
||||
(go-rt-test
|
||||
"chan: distinct channels have distinct identity"
|
||||
(= (go-make-chan) (go-make-chan))
|
||||
false)
|
||||
|
||||
(go-rt-test
|
||||
"chan: send + recv round-trip"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 42)
|
||||
(go-chan-recv! ch))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"chan: empty recv returns :empty marker"
|
||||
(let ((ch (go-make-chan))) (go-chan-recv! ch))
|
||||
:empty)
|
||||
|
||||
(go-rt-test
|
||||
"chan: FIFO order"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 1)
|
||||
(go-chan-send! ch 2)
|
||||
(go-chan-send! ch 3)
|
||||
(list (go-chan-recv! ch) (go-chan-recv! ch) (go-chan-recv! ch)))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"chan: closed? flag flips"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(let
|
||||
((before (go-chan-closed? ch)))
|
||||
(go-chan-close! ch)
|
||||
(list before (go-chan-closed? ch))))
|
||||
(list false true))
|
||||
|
||||
;; ── source-level: make / send / recv / close ───────────────────
|
||||
(go-rt-test
|
||||
"src: ch := make() returns chan"
|
||||
(go-chan?
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: ch <- 5 then <-ch = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 5")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
5)
|
||||
|
||||
(go-rt-test
|
||||
"src: go + chan ping-pong"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sender(c chan int) { c <- 99 }") (go-parse "ch := make()") (go-parse "go sender(ch)")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"src: close(ch) marks it closed"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "close(ch)")))))
|
||||
(go-chan-closed? (go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: multiple goroutines feeding one channel"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 1)") (go-parse "go push(ch, 2)") (go-parse "go push(ch, 3)")))))
|
||||
(list
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"src: worker pattern — send sum back"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func work(c chan int, a int, b int) { c <- a + b }") (go-parse "result := make()") (go-parse "go work(result, 7, 13)")))))
|
||||
(go-eval env (go-parse "<-result")))
|
||||
20)
|
||||
|
||||
;; ── report ─────────────────────────────────────────────────────
|
||||
(go-rt-test
|
||||
"select: default runs when no case is ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv case fires when ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 7") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv-into-var binds the value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 42") (go-parse "select { case v := <-ch: v }")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"select: send case (always ready in v0)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "select { case ch <- 5: }")))))
|
||||
(go-chan-len (go-env-lookup env "ch")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: picks first ready case"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 100") (go-parse "x := 0") (go-parse "select { case <-a: x = 1 ; case <-b: x = 2 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-rt-test
|
||||
"select: no default + nothing ready → blocked error"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-eval-stmt env (go-parse "select { case <-ch: }") (list)))
|
||||
(list :eval-error :select-blocked-no-default))
|
||||
|
||||
(go-rt-test
|
||||
"select: combined with goroutine fan-in"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 7)") (go-parse "result := 0") (go-parse "select { case v := <-ch: result = v ; default: result = -1 }")))))
|
||||
(go-env-lookup env "result"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — sum of 1..5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var sum = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — key only (index)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{10, 20, 30}") (go-parse "for i := range a { s = s + i }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: map — sum values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "m := map[string]int{\"a\": 1, \"b\": 2, \"c\": 3}") (go-parse "for k, v := range m { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: channel — collect all buffered"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 1") (go-parse "ch <- 2") (go-parse "ch <- 3") (go-parse "var sum = 0") (go-parse "for v := range ch { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with break exits early"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { break } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with continue skips an element"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { continue } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
12)
|
||||
|
||||
(go-rt-test
|
||||
"range: empty slice — body never runs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{}") (go-parse "for v := range a { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
0)
|
||||
|
||||
(go-rt-test
|
||||
"range: chan + goroutine producer"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emit(c chan int) { c <- 10 ; c <- 20 ; c <- 30 }") (go-parse "ch := make()") (go-parse "go emit(ch)") (go-parse "var total = 0") (go-parse "for v := range ch { total = total + v }")))))
|
||||
(go-env-lookup env "total"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"timer: after(d) returns a ready channel (v0 stub)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(100)")))))
|
||||
(go-chan-len (go-env-lookup env "t")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select with timer (after) — buffered value wins, timer is fallback"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push99(c chan int) { c <- 99 }") (go-parse "c := make()") (go-parse "go push99(c)") (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-c: v = x; case y := <-t: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"fan-in: 3 producer goroutines, main sums their values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func send10(c chan int) { c <- 10 }") (go-parse "func send20(c chan int) { c <- 20 }") (go-parse "func send30(c chan int) { c <- 30 }") (go-parse "c := make()") (go-parse "go send10(c)") (go-parse "go send20(c)") (go-parse "go send30(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 3; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"worker queue: range over closed buffered chan drains all jobs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "jobs := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "jobs <- 4") (go-parse "close(jobs)") (go-parse "var s = 0") (go-parse "for j := range jobs { s = s + j }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"pipeline: stage1 squares, stage2 sums via channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }") (go-parse "in := make()") (go-parse "out := make()") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "in <- 4") (go-parse "close(in)") (go-parse "go sq(in, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
29)
|
||||
|
||||
(go-rt-test
|
||||
"fan-out then fan-in: split job stream across N workers, collect results"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func worker(in chan int, out chan int) { for v := range in { out <- v + 100 } }") (go-parse "jobs := make()") (go-parse "results := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "close(jobs)") (go-parse "go worker(jobs, results)") (go-parse "close(results)") (go-parse "var s = 0") (go-parse "for r := range results { s = s + r }")))))
|
||||
(go-env-lookup env "s"))
|
||||
306)
|
||||
|
||||
(go-rt-test
|
||||
"select: first ready case wins (channel order = source order)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "a <- 1") (go-parse "b <- 2") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 10; case y := <-b: v = 20 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"select: only second case has a value, that branch executes"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 7") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = -1; case y := <-b: v = y }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"select with default: no case ready → default fires"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 1; case y := <-b: v = 2; default: v = 99 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"producer-consumer: one goroutine fills, main drains by count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fill5(c chan int) { c <- 1 ; c <- 2 ; c <- 3 ; c <- 4 ; c <- 5 }") (go-parse "c := make()") (go-parse "go fill5(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 5; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"two-stage pipeline: doubler + adder threaded through 3 channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func dbl(in chan int, mid chan int) { for v := range in { mid <- v * 2 } ; close(mid) }") (go-parse "func plus1(mid chan int, out chan int) { for v := range mid { out <- v + 1 } ; close(out) }") (go-parse "in := make()") (go-parse "mid := make()") (go-parse "out := make()") (go-parse "in <- 1") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "close(in)") (go-parse "go dbl(in, mid)") (go-parse "go plus1(mid, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"channel as counter: append integers, count buffer size"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fillN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- i } }") (go-parse "c := make()") (go-parse "go fillN(c, 7)")))))
|
||||
(go-chan-len (go-env-lookup env "c")))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"after(0) + select with default: timer ready, default not taken"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-t: v = 7; default: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"tick collector: timer + counter accumulates ticks via range count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emitN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- 1 } ; close(c) }") (go-parse "ticks := make()") (go-parse "go emitN(ticks, 5)") (go-parse "var total = 0") (go-parse "for t := range ticks { total = total + t }")))))
|
||||
(go-env-lookup env "total"))
|
||||
5)
|
||||
|
||||
(define
|
||||
go-rt-test-summary
|
||||
(str "runtime " go-rt-test-pass "/" go-rt-test-count))
|
||||
209
lib/go/tests/stdlib.sx
Normal file
209
lib/go/tests/stdlib.sx
Normal file
@@ -0,0 +1,209 @@
|
||||
;; Go stdlib tests — exercises lib/go/std/*.sx packages via the
|
||||
;; idiomatic `import-style` qualified call (`strings.Contains(...)`).
|
||||
|
||||
(define go-std-test-count 0)
|
||||
(define go-std-test-pass 0)
|
||||
(define go-std-test-fails (list))
|
||||
|
||||
(define
|
||||
go-std-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-std-test-count (+ go-std-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-std-test-pass (+ go-std-test-pass 1))
|
||||
(append! go-std-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-std-env
|
||||
;; Convenience: env with all stdlib packages registered.
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-std-run
|
||||
;; Parse + run Go source against the stdlib env; return final env.
|
||||
(fn (src-list)
|
||||
(go-eval-program go-std-env (map go-parse src-list))))
|
||||
|
||||
;; ── strings.Contains ─────────────────────────────────────────────
|
||||
(go-std-test "strings.Contains: hit"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.Contains: miss"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello\", \"xyz\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.Contains: empty substring is always present"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"abc\", \"\")")) "r")
|
||||
true)
|
||||
|
||||
;; ── strings.HasPrefix / HasSuffix ────────────────────────────────
|
||||
(go-std-test "strings.HasPrefix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello world\", \"hello\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasPrefix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.HasSuffix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasSuffix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
;; ── strings.Index ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Index: found at 6"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello world\", \"world\")")) "r")
|
||||
6)
|
||||
|
||||
(go-std-test "strings.Index: not found = -1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello\", \"xyz\")")) "r")
|
||||
-1)
|
||||
|
||||
(go-std-test "strings.Index: empty substring = 0"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"abc\", \"\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Count ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Count: 3 occurrences of 'a'"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"banana\", \"a\")")) "r")
|
||||
3)
|
||||
|
||||
(go-std-test "strings.Count: 0 occurrences"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"hello\", \"z\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Repeat ────────────────────────────────────────────────
|
||||
(go-std-test "strings.Repeat: ab × 3 = ababab"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"ab\", 3)")) "r")
|
||||
"ababab")
|
||||
|
||||
(go-std-test "strings.Repeat: any × 0 = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"x\", 0)")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Join ──────────────────────────────────────────────────
|
||||
(go-std-test "strings.Join: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"a\", \"b\", \"c\"}, \", \")")) "r")
|
||||
"a, b, c")
|
||||
|
||||
(go-std-test "strings.Join: empty slice = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{}, \"-\")")) "r")
|
||||
"")
|
||||
|
||||
(go-std-test "strings.Join: single elem = elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"solo\"}, \",\")")) "r")
|
||||
"solo")
|
||||
|
||||
;; ── strings.ToUpper / ToLower ─────────────────────────────────────
|
||||
(go-std-test "strings.ToUpper: hello → HELLO"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"hello\")")) "r")
|
||||
"HELLO")
|
||||
|
||||
(go-std-test "strings.ToUpper: leaves digits alone"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"abc123\")")) "r")
|
||||
"ABC123")
|
||||
|
||||
(go-std-test "strings.ToLower: HELLO → hello"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"HELLO\")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.ToLower: mixed case"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"MixED\")")) "r")
|
||||
"mixed")
|
||||
|
||||
;; ── strings.TrimSpace ─────────────────────────────────────────────
|
||||
(go-std-test "strings.TrimSpace: leading + trailing"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" hello \")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.TrimSpace: no whitespace = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\"abc\")")) "r")
|
||||
"abc")
|
||||
|
||||
(go-std-test "strings.TrimSpace: all whitespace → empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" \")")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Split ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Split: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"a,b,c\", \",\")")) "r")
|
||||
(list :go-slice (list "a" "b" "c")))
|
||||
|
||||
(go-std-test "strings.Split: no occurrence → single elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"abc\", \"-\")")) "r")
|
||||
(list :go-slice (list "abc")))
|
||||
|
||||
(go-std-test "strings.Split: leading/trailing sep → empty pieces"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\",a,\", \",\")")) "r")
|
||||
(list :go-slice (list "" "a" "")))
|
||||
|
||||
;; ── strings.Replace ───────────────────────────────────────────────
|
||||
(go-std-test "strings.Replace: replace once with n=1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", 1)")) "r")
|
||||
"a-b,c")
|
||||
|
||||
(go-std-test "strings.Replace: replace all with n=-1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", -1)")) "r")
|
||||
"a-b-c")
|
||||
|
||||
(go-std-test "strings.Replace: no match = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"abc\", \"x\", \"y\", -1)")) "r")
|
||||
"abc")
|
||||
|
||||
;; ── strconv.Itoa ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Itoa: 42 → \"42\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(42)")) "r")
|
||||
"42")
|
||||
|
||||
(go-std-test "strconv.Itoa: 0 → \"0\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(0)")) "r")
|
||||
"0")
|
||||
|
||||
;; ── strconv.Atoi ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Atoi: \"42\" → 42"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"42\")")) "r")
|
||||
42)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"-7\" → -7"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"-7\")")) "r")
|
||||
-7)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"100\" → 100"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"100\")")) "r")
|
||||
100)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n positive"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(12345))")) "r")
|
||||
12345)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n negative"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(-9999))")) "r")
|
||||
-9999)
|
||||
|
||||
(go-std-test "strings: Pipeline ToUpper(TrimSpace(s))"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(strings.TrimSpace(\" go \"))")) "r")
|
||||
"GO")
|
||||
|
||||
(go-std-test "strings: Join(Split(s, sep), sep) round-trip"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join(strings.Split(\"a,b,c\", \",\"), \",\")")) "r")
|
||||
"a,b,c")
|
||||
|
||||
(go-std-test "strings: Count(Repeat(s, n), s) == n"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(strings.Repeat(\"ab\", 5), \"ab\")")) "r")
|
||||
5)
|
||||
|
||||
(go-std-test "round-trip: Itoa(Atoi(s)) → s"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(strconv.Atoi(\"777\"))")) "r")
|
||||
"777")
|
||||
|
||||
(define
|
||||
go-std-test-summary
|
||||
(str "stdlib " go-std-test-pass "/" go-std-test-count))
|
||||
778
lib/go/tests/types.sx
Normal file
778
lib/go/tests/types.sx
Normal file
@@ -0,0 +1,778 @@
|
||||
;; Go type-checker tests.
|
||||
|
||||
(define go-types-test-count 0)
|
||||
(define go-types-test-pass 0)
|
||||
(define go-types-test-fails (list))
|
||||
|
||||
(define
|
||||
go-types-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-types-test-count (+ go-types-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-types-test-pass (+ go-types-test-pass 1))
|
||||
(append! go-types-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; Convenience: parse + synth in one step.
|
||||
(define gtsy (fn (ctx src) (go-synth ctx (go-parse src))))
|
||||
(define gtchk (fn (ctx src ty) (go-check ctx (go-parse src) ty)))
|
||||
|
||||
;; ── context helpers ──────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"ctx: empty lookup returns nil"
|
||||
(go-ctx-lookup go-ctx-empty "x")
|
||||
nil)
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend then lookup"
|
||||
(go-ctx-lookup (go-ctx-extend go-ctx-empty "x" (list :ty-name "int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: shadow via extend"
|
||||
(go-ctx-lookup
|
||||
(go-ctx-extend
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
"x"
|
||||
(list :ty-name "string"))
|
||||
"x")
|
||||
(list :ty-name "string"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend-field binds all names"
|
||||
(let
|
||||
((ctx (go-ctx-extend-field go-ctx-empty (list :field (list "a" "b" "c") (list :ty-name "int")))))
|
||||
(list
|
||||
(go-ctx-lookup ctx "a")
|
||||
(go-ctx-lookup ctx "b")
|
||||
(go-ctx-lookup ctx "c")
|
||||
(go-ctx-lookup ctx "d")))
|
||||
(list
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
nil))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"predeclared: true"
|
||||
(gtsy go-ctx-empty "true")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: false"
|
||||
(gtsy go-ctx-empty "false")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: nil"
|
||||
(gtsy go-ctx-empty "nil")
|
||||
(list :ty-untyped-nil))
|
||||
|
||||
;; ── synth: variable lookup ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: bound variable returns its type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"synth: unbound variable is a type error"
|
||||
(go-synth go-ctx-empty (go-parse "ghost"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── check: structural type equality ─────────────────────────────
|
||||
(go-types-test
|
||||
"check: ident vs declared type — matching"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"check: ident vs declared type — mismatch"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "string"))
|
||||
(list :type-error :mismatch (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"check: unbound propagates the synth error"
|
||||
(go-check go-ctx-empty (go-parse "ghost") (list :ty-name "int"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: int literal — untyped int"
|
||||
(gtsy go-ctx-empty "42")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"synth: float literal — untyped float"
|
||||
(gtsy go-ctx-empty "3.14")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"synth: imag literal — untyped imag"
|
||||
(gtsy go-ctx-empty "2i")
|
||||
(list :ty-untyped-imag))
|
||||
|
||||
(go-types-test
|
||||
"synth: string literal — untyped string"
|
||||
(gtsy go-ctx-empty "\"hello\"")
|
||||
(list :ty-untyped-string))
|
||||
|
||||
(go-types-test
|
||||
"synth: hex int — untyped int"
|
||||
(gtsy go-ctx-empty "0xFF")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 + 7 — untyped int"
|
||||
(gtsy go-ctx-empty "42 + 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 — untyped int (canonical pitfall LHS)"
|
||||
(gtsy go-ctx-empty "42 / 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 assignable to float64 (canonical pitfall)"
|
||||
(gtchk go-ctx-empty "42 / 7" (list :ty-name "float64"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"binop: 3.14 * 2.0 — untyped float"
|
||||
(gtsy go-ctx-empty "3.14 * 2.0")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: 1 + 2.5 — untyped int + untyped float → untyped float"
|
||||
(gtsy go-ctx-empty "1 + 2.5")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: comparison produces bool"
|
||||
(gtsy go-ctx-empty "1 < 2")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"binop: typed-var + untyped-int — propagates var's type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int64"))
|
||||
(go-parse "x + 1"))
|
||||
(list :ty-name "int64"))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → int"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → float32"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "float32"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → string fails"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "string"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-string → string"
|
||||
(gtchk go-ctx-empty "\"hi\"" (list :ty-name "string"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int (no init) — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int = 5 — checks 5 vs int, binds"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 5 — inferred, default-typed to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 3.14 — inferred, default-typed to float64"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 3.14")) "x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x float64 = 42 / 7 — canonical pitfall"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x float64 = 42 / 7"))
|
||||
"x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x string = 42 — type-error"
|
||||
(go-check-decl go-ctx-empty (go-parse "var x string = 42"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x, y int — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "var x, y int"))))
|
||||
(list (go-ctx-lookup ctx "x") (go-ctx-lookup ctx "y")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"decl: const Pi = 3.14 — binds Pi to float64"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const Pi = 3.14"))
|
||||
"Pi")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: const C int = 42 — typed const"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const C int = 42"))
|
||||
"C")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: type T int — binds T to int alias"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "type T int")) "T")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl x := 5 — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "x := 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl a, b := 1, 2 — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "a, b := 1, 2"))))
|
||||
(list (go-ctx-lookup ctx "a") (go-ctx-lookup ctx "b")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func empty() — binds empty to func type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func empty() {}"))
|
||||
"empty")
|
||||
(list :ty-func (list) (list)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func add(x, y int) int { return x + y } — ok"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func add(x, y int) int { return x + y }"))
|
||||
"add")
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func bad() int { return \"hi\" } — type error"
|
||||
(go-check-decl go-ctx-empty (go-parse "func bad() int { return \"hi\" }"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: signature-only (no body)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func sig(x int) int"))
|
||||
"sig")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: param-bound — body sees x and y"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func sumsq(x, y int) int { return x*x + y*y }"))
|
||||
"sumsq")
|
||||
(list :ty-func
|
||||
(list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: nested decl in body extends ctx for later stmts"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func two() int { var x int = 1; var y int = 2; return x + y }"))
|
||||
"two")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: assign inside body — type-checks RHS vs LHS"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func g() int { var x int; x = 5; return x }"))
|
||||
"g")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: synth result of typed func"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(5)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-count mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(1, 2)"))
|
||||
(list :type-error :arity-mismatch 1 2))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-type mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"f"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "f(\"hi\")"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"call: not callable (calling an int)"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x(1)"))
|
||||
(list :type-error :not-callable (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"call: no-result func (void) call"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"log"
|
||||
(list :ty-func (list (list :ty-name "string")) (list)))
|
||||
(go-parse "log(\"hi\")"))
|
||||
(list :ty-void))
|
||||
|
||||
(go-types-test
|
||||
"call: multi-return → :ty-tuple"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"divmod"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int") (list :ty-name "int"))))
|
||||
(go-parse "divmod(10, 3)"))
|
||||
(list :ty-tuple (list (list :ty-name "int") (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: recursive func works (fib)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func fib(n int) int { return fib(n) + fib(n) }"))
|
||||
"fib")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: untyped-int arg accepted into int param"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(42)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1,2,3} — synth slice type"
|
||||
(gtsy go-ctx-empty "[]int{1, 2, 3}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []string{\"a\",\"b\"}"
|
||||
(gtsy go-ctx-empty "[]string{\"a\", \"b\"}")
|
||||
(list :ty-slice (list :ty-name "string")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1, \"bad\"} — element type-error"
|
||||
(gtsy go-ctx-empty "[]int{1, \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: empty []int{}"
|
||||
(gtsy go-ctx-empty "[]int{}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: [3]int{1,2,3} array"
|
||||
(gtsy go-ctx-empty "[3]int{1, 2, 3}")
|
||||
(list :ty-array (list :literal "3") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map[string]int — synth map type"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :ty-map (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map value type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: map key type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{42: 1}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"composite: nested [][]int{[]int{1,2}, []int{3,4}}"
|
||||
(gtsy go-ctx-empty "[][]int{[]int{1, 2}, []int{3, 4}}")
|
||||
(list :ty-slice (list :ty-slice (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"composite: var x = []int{1,2,3} — inferred slice"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x = []int{1, 2, 3}"))
|
||||
"x")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"method: decl binds method-key"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"method: pointer receiver also keyed by base type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p *Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"iface: Point satisfies Stringer (structural)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String() string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: empty type does NOT satisfy Stringer"
|
||||
(go-iface-satisfies?
|
||||
go-ctx-empty
|
||||
"Empty"
|
||||
(list
|
||||
:ty-interface (list (list :method "String" (list) (list (list :ty-name "string"))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: type with wrong-arity method fails"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String(x int) string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: multi-method satisfaction (signature-only methods)"
|
||||
(let
|
||||
((ctx
|
||||
(go-check-decl
|
||||
(go-check-decl go-ctx-empty
|
||||
(go-parse "func (r Reader) Read(b []byte) int"))
|
||||
(go-parse "func (r Reader) Close() bool"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list)
|
||||
(list (list :ty-name "bool")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: partial method set fails (missing one method)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (r Reader) Read(b []byte) int { return 0 }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list
|
||||
:method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list) (list (list :ty-name "error")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity func [T any] checks (body uses x of type T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Id[T any](x T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: two type params [T, U any] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Pair[T, U any](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multi-group type params [T any, U comparable] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any, U comparable](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: empty body with type params still checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Noop[T any]() {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multiple uses of same type param check (x T, y T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func H[T any](x T, y T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Map[T, U any]([]T, func(T) U) []U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { var r []U ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Filter[T any]([]T, func(T) bool) []T type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Reduce[T, U any]([]T, U, func(U, T) U) U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { return seed }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: First[T any]([]T) T type-checks (slice indexing on T-param)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func First[T any](xs []T) T { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: slice[i] synthesizes element type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func head(xs []int) int { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: map[k] synthesizes value type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func g(m map[string]int) int { return m[\"k\"] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Zip[T, U any]([]T, []U) returns slice of struct — type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Zip[T any, U any](xs []T, ys []U) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: nested call shape — Map of First over slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any](xs []T) T { var y []T ; return y[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: type param T appears in func-type results too"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func G[T any](xs []T, f func(T) T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: constraint name 'comparable' accepted as type-set"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Contains[T comparable](xs []T, v T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ptr-to-T param accepted"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Inspect[T any](p *T) T { return *p }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: map[K]V with V from type param checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Values[K comparable, V any](m map[K]V) []V { var r []V ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: variadic-like multi-return shape checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Swap[T any](a T, b T) T { return b }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: T-typed local short-decl assigns OK"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Twice[T any](x T) T { y := x ; return y }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: composite slice literal []T{} resolves T from type-params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Empty[T any]() []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: closure-like pass-through accepting func(T) T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Apply[T any](x T, f func(T) T) T { return f(x) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ordered comparable returns bool"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Eq[T comparable](a T, b T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: three type params [A, B, C any]"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Triple[A any, B any, C any](a A, b B, c C) A { return a }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity returning slice type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToSlice[T any](x T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: takes slice returns first via len-check"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Take[T any](xs []T, n int) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: returns map[K]V combining two type params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToMap[K comparable, V any](k K, v V) map[K]V { var m map[K]V ; return m }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with channel of T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Send[T any](c chan T, v T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with pointer + slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Fill[T any](p *T, xs []T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: int constraint accepted (treated as any-equivalent in v0)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Sum[T int](xs []T) T { var z T ; return z }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: single type param used 4× in signature"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Compose[T any](f func(T) T, g func(T) T, x T) T { return f(g(x)) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(define
|
||||
go-types-test-summary
|
||||
(str "types " go-types-test-pass "/" go-types-test-count))
|
||||
824
lib/go/types.sx
Normal file
824
lib/go/types.sx
Normal file
@@ -0,0 +1,824 @@
|
||||
;; lib/go/types.sx — Go bidirectional type checker.
|
||||
;;
|
||||
;; Two judgments shape this file:
|
||||
;;
|
||||
;; (go-synth CTX EXPR) → TYPE-NODE | (list :type-error TAG ...)
|
||||
;; Given a context and an expression, produce a type.
|
||||
;;
|
||||
;; (go-check CTX EXPR EXPECTED) → :ok | (list :type-error TAG ...)
|
||||
;; Given a context, expression, and expected type, verify compatibility.
|
||||
;;
|
||||
;; The two judgments are mutually recursive. Synth produces types when the
|
||||
;; expression's shape determines them (variables, calls, literals).
|
||||
;; Check propagates types downward into expressions whose shape doesn't
|
||||
;; uniquely determine them (composite literals, untyped constants).
|
||||
;;
|
||||
;; Type representations reuse the parser's :ty-* AST nodes from
|
||||
;; lib/go/parse.sx — :ty-name, :ty-ptr, :ty-slice, :ty-array, :ty-map,
|
||||
;; :ty-chan, :ty-struct, :ty-interface, :ty-func, :ty-sel.
|
||||
;;
|
||||
;; Context: an association list of (NAME TYPE) bindings. Per-block scope
|
||||
;; via a fresh extension on entry.
|
||||
;;
|
||||
;; **Independent implementation.** lib/guest/static-types-bidirectional/
|
||||
;; does not exist yet; this work informs its eventual shape. Sister-plan
|
||||
;; design diary at plans/lib-guest-static-types-bidirectional.md tracks
|
||||
;; the chiselling insights as Phase 3 progresses.
|
||||
|
||||
;; ── context ───────────────────────────────────────────────────────
|
||||
|
||||
(define go-ctx-empty (list))
|
||||
|
||||
(define
|
||||
go-ctx-lookup
|
||||
(fn
|
||||
(ctx name)
|
||||
(cond
|
||||
(= (len ctx) 0)
|
||||
nil
|
||||
(= (first (first ctx)) name)
|
||||
(nth (first ctx) 1)
|
||||
:else (go-ctx-lookup (rest ctx) name))))
|
||||
|
||||
(define go-ctx-extend (fn (ctx name type) (cons (list name type) ctx)))
|
||||
|
||||
(define
|
||||
go-ctx-extend-field
|
||||
(fn
|
||||
(ctx field)
|
||||
(let
|
||||
((names (nth field 1)) (ty (nth field 2)))
|
||||
(cond
|
||||
(= (len names) 0)
|
||||
ctx
|
||||
:else (let
|
||||
((rest-ctx (go-ctx-extend ctx (first names) ty)))
|
||||
(cond
|
||||
(= (len names) 1)
|
||||
rest-ctx
|
||||
:else (go-ctx-extend-field rest-ctx (list :field (rest names) ty))))))))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-predeclared
|
||||
(list
|
||||
(list "true" (list :ty-name "bool"))
|
||||
(list "false" (list :ty-name "bool"))
|
||||
(list "nil" (list :ty-untyped-nil))))
|
||||
|
||||
(define
|
||||
go-predeclared-lookup
|
||||
(fn
|
||||
(name)
|
||||
(cond
|
||||
(= (len go-predeclared) 0)
|
||||
nil
|
||||
:else (go-ctx-lookup go-predeclared name))))
|
||||
|
||||
;; ── type predicates ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-type-error?
|
||||
(fn
|
||||
(x)
|
||||
(and
|
||||
(list? x)
|
||||
(not (= (len x) 0))
|
||||
(= (first x) :type-error))))
|
||||
|
||||
(define go-type-equal? (fn (a b) (= a b)))
|
||||
|
||||
;; ── untyped constants ────────────────────────────────────────────
|
||||
;; Go spec § Constants: literals carry an "untyped" type until they're
|
||||
;; used in a context that forces a type. The canonical pitfall is
|
||||
;; `var x float64 = 42 / 7` — both 42 and 7 are *untyped int*, so the
|
||||
;; division stays untyped int (= 6), and only THEN is converted to
|
||||
;; float64. (Wrong implementations float-coerce first, getting 6.0 from
|
||||
;; what was meant to round.) The :ty-untyped-* tags below model this.
|
||||
|
||||
(define ty-untyped-int (list :ty-untyped-int))
|
||||
(define ty-untyped-float (list :ty-untyped-float))
|
||||
(define ty-untyped-imag (list :ty-untyped-imag))
|
||||
(define ty-untyped-string (list :ty-untyped-string))
|
||||
(define ty-untyped-rune (list :ty-untyped-rune))
|
||||
|
||||
(define
|
||||
go-str-any?
|
||||
(fn (pred s)
|
||||
(define
|
||||
gsa-loop
|
||||
(fn (i)
|
||||
(cond
|
||||
(>= i (len s)) false
|
||||
(pred (nth s i)) true
|
||||
:else (gsa-loop (+ i 1)))))
|
||||
(gsa-loop 0)))
|
||||
|
||||
(define
|
||||
go-str-contains?
|
||||
(fn (s ch) (go-str-any? (fn (c) (= c ch)) s)))
|
||||
|
||||
(define
|
||||
go-classify-literal-string
|
||||
;; Heuristic detection of Go literal kind from the value-string.
|
||||
;; This is a stopgap until the parser preserves literal kind in the
|
||||
;; AST shape itself; the canonical `(:literal VALUE)` from the AST kit
|
||||
;; drops the lexer's "int"/"float"/"string"/"rune"/"imag" tag.
|
||||
;; Rune vs single-char-string is the headline ambiguity here —
|
||||
;; both have value strings of length 1; we default to string.
|
||||
(fn (v)
|
||||
(cond
|
||||
(or (not (string? v)) (= (len v) 0)) :string
|
||||
(or (and (>= (nth v 0) "0") (<= (nth v 0) "9"))
|
||||
(and (= (nth v 0) ".") (>= (len v) 2)
|
||||
(>= (nth v 1) "0") (<= (nth v 1) "9")))
|
||||
(cond
|
||||
(= (nth v (- (len v) 1)) "i") :imag
|
||||
(go-str-contains? v ".") :float
|
||||
(and (or (go-str-contains? v "e") (go-str-contains? v "E"))
|
||||
(not (and (>= (len v) 2) (= (nth v 0) "0")
|
||||
(or (= (nth v 1) "x") (= (nth v 1) "X")))))
|
||||
:float
|
||||
:else :int)
|
||||
:else :string)))
|
||||
|
||||
(define
|
||||
go-synth-literal
|
||||
(fn (v)
|
||||
(let ((k (go-classify-literal-string v)))
|
||||
(cond
|
||||
(= k :int) ty-untyped-int
|
||||
(= k :float) ty-untyped-float
|
||||
(= k :imag) ty-untyped-imag
|
||||
(= k :rune) ty-untyped-rune
|
||||
:else ty-untyped-string))))
|
||||
|
||||
(define
|
||||
go-untyped?
|
||||
(fn (t)
|
||||
(and (list? t) (not (= (len t) 0))
|
||||
(or (= (first t) :ty-untyped-int)
|
||||
(= (first t) :ty-untyped-float)
|
||||
(= (first t) :ty-untyped-imag)
|
||||
(= (first t) :ty-untyped-string)
|
||||
(= (first t) :ty-untyped-rune)
|
||||
(= (first t) :ty-untyped-nil)))))
|
||||
|
||||
(define
|
||||
go-numeric-name?
|
||||
;; Built-in numeric type names per Go spec § Numeric types.
|
||||
(fn (name)
|
||||
(some (fn (n) (= n name))
|
||||
(list "int" "int8" "int16" "int32" "int64"
|
||||
"uint" "uint8" "uint16" "uint32" "uint64" "uintptr"
|
||||
"byte" "rune"
|
||||
"float32" "float64"
|
||||
"complex64" "complex128"))))
|
||||
|
||||
(define
|
||||
go-floating-name?
|
||||
(fn (name)
|
||||
(or (= name "float32") (= name "float64"))))
|
||||
|
||||
(define
|
||||
go-complex-name?
|
||||
(fn (name)
|
||||
(or (= name "complex64") (= name "complex128"))))
|
||||
|
||||
(define
|
||||
go-type-assignable?
|
||||
;; Can a value of type GOT be assigned to a slot of type EXPECTED?
|
||||
;; Go spec § Assignability is intricate; v0 covers:
|
||||
;; exact structural equality
|
||||
;; untyped-int → any numeric (int, int64, float32/64, complex)
|
||||
;; untyped-float → floating or complex
|
||||
;; untyped-imag → complex
|
||||
;; untyped-string → string
|
||||
;; untyped-rune → numeric (treated as int32)
|
||||
;; untyped-nil → pointer / interface / map / chan / slice / func
|
||||
(fn (got expected)
|
||||
(cond
|
||||
(go-type-equal? got expected) true
|
||||
(and (list? expected) (not (= (len expected) 0))
|
||||
(= (first expected) :ty-name))
|
||||
(let ((tn (nth expected 1)))
|
||||
(cond
|
||||
(= (first got) :ty-untyped-int) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-float)
|
||||
(or (go-floating-name? tn) (go-complex-name? tn))
|
||||
(= (first got) :ty-untyped-imag) (go-complex-name? tn)
|
||||
(= (first got) :ty-untyped-rune) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-string) (= tn "string")
|
||||
:else false))
|
||||
:else false)))
|
||||
|
||||
;; ── synth ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-arith-binops (list "+" "-" "*" "/" "%"))
|
||||
(define
|
||||
go-bitwise-binops (list "&" "|" "^" "<<" ">>" "&^"))
|
||||
(define
|
||||
go-compare-binops (list "==" "!=" "<" "<=" ">" ">="))
|
||||
(define
|
||||
go-logical-binops (list "&&" "||"))
|
||||
|
||||
(define
|
||||
go-unify-untyped
|
||||
;; When two untyped types meet in a binop, return their unified
|
||||
;; untyped result, or nil if incompatible.
|
||||
(fn (a b)
|
||||
(cond
|
||||
(go-type-equal? a b) a
|
||||
(and (= (first a) :ty-untyped-int) (= (first b) :ty-untyped-float))
|
||||
ty-untyped-float
|
||||
(and (= (first a) :ty-untyped-float) (= (first b) :ty-untyped-int))
|
||||
ty-untyped-float
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-synth
|
||||
(fn (ctx expr)
|
||||
(cond
|
||||
(and (list? expr) (= (first expr) :literal))
|
||||
(go-synth-literal (nth expr 1))
|
||||
(and (list? expr) (= (first expr) :literal-string))
|
||||
ty-untyped-string
|
||||
(and (list? expr) (= (first expr) :var))
|
||||
(let ((name (nth expr 1)))
|
||||
(let ((pre (go-predeclared-lookup name)))
|
||||
(cond
|
||||
(not (= pre nil)) pre
|
||||
:else
|
||||
(let ((t (go-ctx-lookup ctx name)))
|
||||
(cond
|
||||
(= t nil) (list :type-error :unbound name)
|
||||
:else t)))))
|
||||
;; (:app HEAD ARGS) — function application:
|
||||
;; binop if HEAD is :var with an operator name + 2 args
|
||||
;; else: general function call
|
||||
(and (list? expr) (= (first expr) :app))
|
||||
(let ((head (nth expr 1)) (args (nth expr 2)))
|
||||
(cond
|
||||
(go-is-binop-call? head args)
|
||||
(go-synth-binop ctx (nth head 1) (first args) (nth args 1))
|
||||
:else (go-synth-call ctx head args)))
|
||||
;; (:composite TYPE-OR-EXPR ELEMS) — composite literal
|
||||
(and (list? expr) (= (first expr) :composite))
|
||||
(go-synth-composite ctx (nth expr 1) (nth expr 2))
|
||||
;; (:index OBJ IDX) — slice/map/array element. v0: element type
|
||||
;; is the slice/array element type, or the map value type.
|
||||
(and (list? expr) (= (first expr) :index))
|
||||
(let ((obj-ty (go-synth ctx (nth expr 1))))
|
||||
(cond
|
||||
(go-type-error? obj-ty) obj-ty
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-slice))
|
||||
(nth obj-ty 1)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-array))
|
||||
(nth obj-ty 2)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-map))
|
||||
(nth obj-ty 2)
|
||||
:else (list :type-error :index-not-indexable obj-ty)))
|
||||
:else (list :type-error :unsupported-synth expr))))
|
||||
|
||||
(define
|
||||
go-is-binop-call?
|
||||
(fn (head args)
|
||||
(and (list? head) (= (first head) :var)
|
||||
(= (len args) 2)
|
||||
(let ((op (nth head 1)))
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops)
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(some (fn (o) (= o op)) go-logical-binops))))))
|
||||
|
||||
(define
|
||||
go-check-args-against
|
||||
;; Each arg in ARGS assignable to the corresponding PARAMS type.
|
||||
;; Caller already verified arities match.
|
||||
(fn (ctx args params)
|
||||
(cond
|
||||
(or (= (len args) 0) (= (len params) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first args) (first params))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-args-against ctx (rest args) (rest params)))))))
|
||||
|
||||
(define
|
||||
go-check-composite-elems
|
||||
;; KEY-TY is nil for slice/array; non-nil for map.
|
||||
;; For maps, each elem must be (:kv KEY VALUE) — KEY assignable to
|
||||
;; KEY-TY, VALUE to VAL-TY.
|
||||
;; For slice/array, plain exprs assignable to VAL-TY; (:kv K V) is
|
||||
;; Go's index-keyed shorthand (`[]int{0: 5, 1: 10}`) — we type-check
|
||||
;; only the value in v0.
|
||||
(fn (ctx elems val-ty key-ty)
|
||||
(cond
|
||||
(or (= elems nil) (= (len elems) 0)) :ok
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(let ((err
|
||||
(cond
|
||||
(and (list? e) (= (first e) :kv))
|
||||
(let ((k (nth e 1)) (v (nth e 2)))
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx v val-ty)
|
||||
:else
|
||||
(let ((kerr (go-check ctx k key-ty)))
|
||||
(cond
|
||||
(go-type-error? kerr) kerr
|
||||
:else (go-check ctx v val-ty)))))
|
||||
:else
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx e val-ty)
|
||||
:else
|
||||
(list :type-error :map-elem-missing-key e)))))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else
|
||||
(go-check-composite-elems ctx (rest elems) val-ty key-ty)))))))
|
||||
|
||||
(define
|
||||
go-synth-composite
|
||||
;; Composite literal: (:composite TYPE-OR-EXPR ELEMS).
|
||||
;; []T{...} — each elem assignable to T; result :ty-slice T
|
||||
;; [N]T{...} — same; result :ty-array N T
|
||||
;; map[K]V{...} — each :kv key:K, value:V; result :ty-map K V
|
||||
;; Named-type literals (Point{...}, pkg.T{...}) require type-decl
|
||||
;; resolution; v0 returns the literal's type-expr as-is without
|
||||
;; element checking.
|
||||
(fn (ctx ty elems)
|
||||
(cond
|
||||
(and (list? ty) (= (first ty) :ty-slice))
|
||||
(let ((elem-ty (nth ty 1)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-array))
|
||||
(let ((elem-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-map))
|
||||
(let ((key-ty (nth ty 1)) (val-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems val-ty key-ty)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
:else ty)))
|
||||
|
||||
(define
|
||||
go-synth-call
|
||||
;; Synth a function call. Returns the result type, or :type-error.
|
||||
;; 0 results → (list :ty-void)
|
||||
;; 1 result → that result type directly
|
||||
;; N results → (list :ty-tuple TYPES) (multi-return)
|
||||
(fn (ctx callee args)
|
||||
(let ((fn-ty (go-synth ctx callee)))
|
||||
(cond
|
||||
(go-type-error? fn-ty) fn-ty
|
||||
(not (and (list? fn-ty) (= (first fn-ty) :ty-func)))
|
||||
(list :type-error :not-callable fn-ty)
|
||||
:else
|
||||
(let ((params (nth fn-ty 1)) (results (nth fn-ty 2)))
|
||||
(cond
|
||||
(not (= (len args) (len params)))
|
||||
(list :type-error :arity-mismatch
|
||||
(len params) (len args))
|
||||
:else
|
||||
(let ((err (go-check-args-against ctx args params)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
(= (len results) 0) (list :ty-void)
|
||||
(= (len results) 1) (first results)
|
||||
:else (list :ty-tuple results)))))))))
|
||||
|
||||
(define
|
||||
go-synth-binop
|
||||
(fn (ctx op lhs rhs)
|
||||
(let ((lt (go-synth ctx lhs)) (rt (go-synth ctx rhs)))
|
||||
(cond
|
||||
(go-type-error? lt) lt
|
||||
(go-type-error? rt) rt
|
||||
;; Comparison ops always produce bool (untyped-bool, simplified
|
||||
;; here to :ty-name "bool" until we model untyped-bool).
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(list :ty-name "bool")
|
||||
(some (fn (o) (= o op)) go-logical-binops)
|
||||
(list :ty-name "bool")
|
||||
;; Arithmetic / bitwise: types must unify.
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops))
|
||||
(cond
|
||||
(and (go-untyped? lt) (go-untyped? rt))
|
||||
(let ((unified (go-unify-untyped lt rt)))
|
||||
(cond
|
||||
(= unified nil)
|
||||
(list :type-error :binop-untyped-mismatch op lt rt)
|
||||
:else unified))
|
||||
(and (go-untyped? lt) (not (go-untyped? rt)))
|
||||
(cond
|
||||
(go-type-assignable? lt rt) rt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(and (not (go-untyped? lt)) (go-untyped? rt))
|
||||
(cond
|
||||
(go-type-assignable? rt lt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(go-type-equal? lt rt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
:else (list :type-error :unsupported-binop op)))))
|
||||
|
||||
;; ── check ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-check
|
||||
(fn
|
||||
(ctx expr expected)
|
||||
(let
|
||||
((got (go-synth ctx expr)))
|
||||
(cond
|
||||
(go-type-error? got)
|
||||
got
|
||||
(go-type-assignable? got expected)
|
||||
:ok :else
|
||||
(list :type-error :mismatch expected got)))))
|
||||
|
||||
;; ── default types ────────────────────────────────────────────────
|
||||
;; Go spec § Constants: the *default type* of an untyped constant
|
||||
;; is what it becomes when assigned to a sloppily-typed slot
|
||||
;; (e.g., `var x = 42` makes x an int).
|
||||
|
||||
(define
|
||||
go-default-type
|
||||
(fn (t)
|
||||
(cond
|
||||
(not (list? t)) t
|
||||
(= (first t) :ty-untyped-int) (list :ty-name "int")
|
||||
(= (first t) :ty-untyped-float) (list :ty-name "float64")
|
||||
(= (first t) :ty-untyped-imag) (list :ty-name "complex128")
|
||||
(= (first t) :ty-untyped-string) (list :ty-name "string")
|
||||
(= (first t) :ty-untyped-rune) (list :ty-name "int32")
|
||||
:else t)))
|
||||
|
||||
;; ── declaration checking ────────────────────────────────────────
|
||||
;; Returns either:
|
||||
;; the extended context (success)
|
||||
;; (list :type-error TAG ...) (failure)
|
||||
|
||||
(define
|
||||
go-check-exprs-against
|
||||
;; Check every EXPR in EXPRS is assignable to EXPECTED. Returns the
|
||||
;; first :type-error encountered, or :ok.
|
||||
(fn (ctx exprs expected)
|
||||
(cond
|
||||
(or (= exprs nil) (= (len exprs) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) expected)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-exprs-against ctx (rest exprs) expected))))))
|
||||
|
||||
(define
|
||||
go-bind-names-to-synth
|
||||
;; Pair each NAME with the synthesised default-typed type of the
|
||||
;; corresponding EXPR; extend CTX with all pairs. NAMES and EXPRS
|
||||
;; may have different lengths (multi-return funcs aren't here yet);
|
||||
;; for now we zip the shorter of the two.
|
||||
(fn (ctx names exprs)
|
||||
(cond
|
||||
(or (= (len names) 0) (= (len exprs) 0)) ctx
|
||||
:else
|
||||
(let ((t (go-synth ctx (first exprs))))
|
||||
(cond
|
||||
(go-type-error? t) t
|
||||
:else
|
||||
(let ((ctx2 (go-ctx-extend ctx (first names)
|
||||
(go-default-type t))))
|
||||
(go-bind-names-to-synth ctx2 (rest names) (rest exprs))))))))
|
||||
|
||||
(define
|
||||
go-check-var-decl
|
||||
;; Shape: (:var-decl (:field NAMES TYPE-or-nil) EXPRS-or-nil)
|
||||
;; or (:const-decl (:field NAMES TYPE-or-nil) EXPRS).
|
||||
;; Logic is the same for v0; const-vs-var distinction matters for
|
||||
;; mutability checks which arrive later.
|
||||
(fn (ctx decl)
|
||||
(let ((field (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (nth field 1)) (ann-ty (nth field 2)))
|
||||
(cond
|
||||
;; var x T (no init) → bind names to T
|
||||
(or (= exprs nil) (= (len exprs) 0))
|
||||
(cond
|
||||
(= ann-ty nil) (list :type-error :missing-type-or-init names)
|
||||
:else (go-ctx-extend-field ctx field))
|
||||
;; Annotated: var x T = expr — check each expr against T
|
||||
(not (= ann-ty nil))
|
||||
(let ((err (go-check-exprs-against ctx exprs ann-ty)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else (go-ctx-extend-field ctx field)))
|
||||
;; Inferred: var x = expr — bind names to default(synth(expr))
|
||||
:else (go-bind-names-to-synth ctx names exprs))))))
|
||||
|
||||
(define
|
||||
go-check-short-decl
|
||||
;; Shape: (:short-decl LHS-LIST EXPRS). LHS is a list of (:var NAME).
|
||||
;; Extracts the names and falls through to bind-names-to-synth.
|
||||
(fn (ctx decl)
|
||||
(let ((lhs-list (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (map (fn (lhs)
|
||||
(cond
|
||||
(and (list? lhs) (= (first lhs) :var))
|
||||
(nth lhs 1)
|
||||
:else :unknown))
|
||||
lhs-list)))
|
||||
(go-bind-names-to-synth ctx names exprs)))))
|
||||
|
||||
(define
|
||||
go-check-decl
|
||||
;; Top-level dispatcher: accepts any decl AST shape, returns extended
|
||||
;; context or :type-error.
|
||||
(fn (ctx decl)
|
||||
(cond
|
||||
(and (list? decl) (= (first decl) :var-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :const-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :short-decl)) (go-check-short-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :type-decl))
|
||||
(let ((name (nth decl 1)) (ty (nth decl 2)))
|
||||
(go-ctx-extend ctx name ty))
|
||||
(and (list? decl) (= (first decl) :func-decl))
|
||||
(go-check-func-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :method-decl))
|
||||
(go-check-method-decl ctx decl)
|
||||
:else ctx)))
|
||||
|
||||
;; ── method declarations and interface satisfaction ──────────────
|
||||
;; Methods are recorded in CTX under a mangled key
|
||||
;; "#method/RECV-TYPE-NAME/METHOD-NAME"
|
||||
;; bound to the method's :ty-func signature. Interface satisfaction is
|
||||
;; a structural lookup over these keys (Go spec § Interface types:
|
||||
;; "anything with the matching method set satisfies the interface").
|
||||
|
||||
(define
|
||||
go-method-key
|
||||
(fn (recv-ty-name method-name)
|
||||
(str "#method/" recv-ty-name "/" method-name)))
|
||||
|
||||
(define
|
||||
go-extract-recv-ty-name
|
||||
;; Receiver type is T or *T; return the named type's name string.
|
||||
(fn (recv-ty)
|
||||
(cond
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-name))
|
||||
(nth recv-ty 1)
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-ptr))
|
||||
(go-extract-recv-ty-name (nth recv-ty 1))
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-check-method-decl
|
||||
;; (list :method-decl RECV NAME PARAMS RESULTS BODY)
|
||||
;; Binds the method under the mangled key, then checks body with
|
||||
;; receiver + params extended.
|
||||
(fn (ctx decl)
|
||||
(let ((recv (nth decl 1)) (name (nth decl 2))
|
||||
(params (nth decl 3)) (results (nth decl 4))
|
||||
(body (nth decl 5)))
|
||||
(let ((recv-ty (nth recv 2)))
|
||||
(let ((recv-name (go-extract-recv-ty-name recv-ty)))
|
||||
(let ((sig (list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx2
|
||||
(cond
|
||||
(= recv-name nil) ctx
|
||||
:else
|
||||
(go-ctx-extend ctx
|
||||
(go-method-key recv-name name) sig))))
|
||||
(cond
|
||||
(= body nil) ctx2
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx2 recv) params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx
|
||||
(nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx2)))
|
||||
:else ctx2))))))))
|
||||
|
||||
(define
|
||||
go-iface-elems-satisfied?
|
||||
;; Each :method element in ELEMS must have a matching method in CTX
|
||||
;; under #method/TY-NAME/M-NAME. :embed elements are skipped in v0
|
||||
;; (they'd need recursive interface resolution).
|
||||
(fn (ctx ty-name elems)
|
||||
(cond
|
||||
(= (len elems) 0) true
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(cond
|
||||
(= (first e) :method)
|
||||
(let ((m-name (nth e 1)) (m-params (nth e 2))
|
||||
(m-results (nth e 3)))
|
||||
(let ((found (go-ctx-lookup ctx
|
||||
(go-method-key ty-name m-name))))
|
||||
(cond
|
||||
(= found nil) false
|
||||
(and (= (nth found 1) m-params)
|
||||
(= (nth found 2) m-results))
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else false)))
|
||||
(= (first e) :embed)
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems)))))))
|
||||
|
||||
(define
|
||||
go-iface-satisfies?
|
||||
;; Does the type named TY-NAME satisfy the interface IFACE-TYPE
|
||||
;; under context CTX? Structural method-set match per Go spec.
|
||||
(fn (ctx ty-name iface-type)
|
||||
(cond
|
||||
(not (and (list? iface-type) (= (first iface-type) :ty-interface)))
|
||||
false
|
||||
:else (go-iface-elems-satisfied? ctx ty-name (nth iface-type 1)))))
|
||||
|
||||
;; ── function-decl checking ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-repeat-ty
|
||||
(fn (n ty acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-repeat-ty (- n 1) ty (cons ty acc)))))
|
||||
|
||||
(define
|
||||
go-decl-params-to-ty-list
|
||||
;; Flatten (:field NAMES TYPE) param groups into a list of types,
|
||||
;; one entry per name. For func-type signatures.
|
||||
(fn (params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) (list)
|
||||
:else
|
||||
(let ((field (first params)))
|
||||
(let ((names (nth field 1)) (ty (nth field 2)))
|
||||
(let ((rest-tys (go-decl-params-to-ty-list (rest params))))
|
||||
(go-repeat-ty (len names) ty rest-tys)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-params
|
||||
;; Extend CTX with every binding in every (:field NAMES TYPE) param group.
|
||||
(fn (ctx params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) ctx
|
||||
:else
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx (first params))
|
||||
(rest params)))))
|
||||
|
||||
(define
|
||||
go-check-return-list
|
||||
;; Each EXPR assignable to the corresponding RESULTS type.
|
||||
;; v0: lengths must match; multi-return funcs deferred.
|
||||
(fn (ctx exprs results)
|
||||
(cond
|
||||
(and (= (len exprs) 0) (= (len results) 0)) :ok
|
||||
(not (= (len exprs) (len results)))
|
||||
(list :type-error :return-count-mismatch
|
||||
(len exprs) (len results))
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) (first results))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-return-list ctx (rest exprs) (rest results)))))))
|
||||
|
||||
(define
|
||||
go-check-assign
|
||||
(fn (ctx stmt)
|
||||
(let ((lhs-list (nth stmt 1)) (rhs-list (nth stmt 2)))
|
||||
(cond
|
||||
(not (= (len lhs-list) (len rhs-list)))
|
||||
(list :type-error :assign-count-mismatch
|
||||
(len lhs-list) (len rhs-list))
|
||||
:else (go-check-assign-pairs ctx lhs-list rhs-list)))))
|
||||
|
||||
(define
|
||||
go-check-assign-pairs
|
||||
(fn (ctx lhs-list rhs-list)
|
||||
(cond
|
||||
(= (len lhs-list) 0) :ok
|
||||
:else
|
||||
(let ((lhs-ty (go-synth ctx (first lhs-list))))
|
||||
(cond
|
||||
(go-type-error? lhs-ty) lhs-ty
|
||||
:else
|
||||
(let ((r (go-check ctx (first rhs-list) lhs-ty)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else
|
||||
(go-check-assign-pairs ctx (rest lhs-list)
|
||||
(rest rhs-list)))))))))
|
||||
|
||||
(define
|
||||
go-check-stmt
|
||||
;; Returns either an extended CTX (decls), :ok (sealed stmts), or
|
||||
;; :type-error. RESULTS is the enclosing func's declared return types
|
||||
;; (used by :return).
|
||||
(fn (ctx stmt results)
|
||||
(cond
|
||||
(and (list? stmt) (= (first stmt) :var-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :const-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :short-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :type-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :return))
|
||||
(let ((exprs (nth stmt 1)))
|
||||
(let ((err (go-check-return-list ctx exprs results)))
|
||||
(cond (go-type-error? err) err :else ctx)))
|
||||
(and (list? stmt) (= (first stmt) :block))
|
||||
(let ((err (go-check-block ctx (nth stmt 1) results)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
(and (list? stmt) (= (first stmt) :assign))
|
||||
(let ((err (go-check-assign ctx stmt)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
:else
|
||||
(let ((t (go-synth ctx stmt)))
|
||||
(cond (go-type-error? t) t :else ctx)))))
|
||||
|
||||
(define
|
||||
go-check-block
|
||||
;; Thread ctx through stmts; if any stmt is a decl, its extension
|
||||
;; propagates to subsequent stmts. Returns :ok or :type-error.
|
||||
(fn (ctx stmts results)
|
||||
(cond
|
||||
(or (= stmts nil) (= (len stmts) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check-stmt ctx (first stmts) results)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-block r (rest stmts) results))))))
|
||||
|
||||
(define
|
||||
go-check-func-decl
|
||||
;; Bind the function in the outer ctx (so recursion works), extend
|
||||
;; ctx with type params + value params, check the body. Returns the
|
||||
;; outer ctx with the function bound, or :type-error.
|
||||
;;
|
||||
;; Type parameters become opaque type variables in the body's ctx:
|
||||
;; each name `T` is bound as a type alias to (:ty-param "T") so the
|
||||
;; checker treats references to T as "this type", not "unknown".
|
||||
;; Constraint enforcement (T satisfies `comparable` etc.) is a
|
||||
;; later refinement; v0 just allows any operation that's polymorphic
|
||||
;; under the constraint `any`.
|
||||
(fn (ctx decl)
|
||||
(let ((name (nth decl 1)) (params (nth decl 2))
|
||||
(results (nth decl 3)) (body (nth decl 4))
|
||||
(type-params (cond (> (len decl) 5) (nth decl 5) :else nil)))
|
||||
(let ((fn-ty
|
||||
(list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx-with-fn (go-ctx-extend ctx name fn-ty)))
|
||||
(cond
|
||||
(= body nil) ctx-with-fn
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-params ctx-with-fn params)
|
||||
type-params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx (nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx-with-fn)))
|
||||
:else ctx-with-fn))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-params
|
||||
;; Each (:field NAMES CONSTRAINT) field contributes opaque type
|
||||
;; vars: bind each NAME as a type alias to (:ty-param NAME). The
|
||||
;; constraint type is stored alongside so future "constraint
|
||||
;; satisfaction" checks can find it; for v0 it's informational.
|
||||
(fn (ctx type-params)
|
||||
(cond
|
||||
(or (= type-params nil) (= (len type-params) 0)) ctx
|
||||
:else
|
||||
(let ((field (first type-params)))
|
||||
(let ((names (nth field 1)) (constraint (nth field 2)))
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-type-param-names ctx names constraint)
|
||||
(rest type-params)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-param-names
|
||||
(fn (ctx names constraint)
|
||||
(cond
|
||||
(= (len names) 0) ctx
|
||||
:else
|
||||
(let ((nm (first names)))
|
||||
(go-extend-with-type-param-names
|
||||
(go-ctx-extend ctx nm
|
||||
(list :ty-param nm constraint))
|
||||
(rest names) constraint)))))
|
||||
42
lib/search/api.sx
Normal file
42
lib/search/api.sx
Normal file
@@ -0,0 +1,42 @@
|
||||
;; search public API — assembles the canonical Haskell source from all layers.
|
||||
;; Tests and callers concatenate `search/src` with their own top-level bindings
|
||||
;; (e.g. "result = lookupTerm \"cat\" idx\n") and evaluate via the haskell-on-sx
|
||||
;; interpreter. Public Haskell entry points: indexDoc, lookupTerm, deleteDoc,
|
||||
;; docFreq, allTerms, tokens, positioned, evalQuery, parseQuery, searchQuery,
|
||||
;; rankTfIdf, rankBm25, topNTfIdf, topNBm25, fedIndex, aclFilter, searchTfIdfAcl,
|
||||
;; topNTfIdfAcl, searchBm25Acl, prefixTerms, prefixDocs, prefixRankTfIdf,
|
||||
;; paginate, pageTfIdf, pageBm25, resultCount, editDist, fuzzyTerms, fuzzyDocs,
|
||||
;; fuzzyRankTfIdf, highlight, snippet, stem, stemText, stemTokens, indexStemmed,
|
||||
;; nearDocs, expandTerm, synDocs, synRankTfIdf, queryTerms, searchRankTfIdf,
|
||||
;; searchRankBm25.
|
||||
|
||||
(define
|
||||
search/src
|
||||
(str
|
||||
search/tokenize-src
|
||||
"\n"
|
||||
search/index-src
|
||||
"\n"
|
||||
search/query-src
|
||||
"\n"
|
||||
search/parse-src
|
||||
"\n"
|
||||
search/rank-src
|
||||
"\n"
|
||||
search/fed-src
|
||||
"\n"
|
||||
search/prefix-src
|
||||
"\n"
|
||||
search/page-src
|
||||
"\n"
|
||||
search/fuzzy-src
|
||||
"\n"
|
||||
search/highlight-src
|
||||
"\n"
|
||||
search/stem-src
|
||||
"\n"
|
||||
search/near-src
|
||||
"\n"
|
||||
search/syn-src
|
||||
"\n"
|
||||
search/rankq-src))
|
||||
53
lib/search/conformance.conf
Normal file
53
lib/search/conformance.conf
Normal file
@@ -0,0 +1,53 @@
|
||||
# search-on-sx conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=search
|
||||
SCOREBOARD_DIR=lib/search
|
||||
MODE=counters
|
||||
COUNTERS_PASS=hk-test-pass
|
||||
COUNTERS_FAIL=hk-test-fail
|
||||
TIMEOUT_PER_SUITE=600
|
||||
|
||||
PRELOADS=(
|
||||
lib/haskell/tokenizer.sx
|
||||
lib/haskell/layout.sx
|
||||
lib/haskell/parser.sx
|
||||
lib/haskell/desugar.sx
|
||||
lib/haskell/runtime.sx
|
||||
lib/haskell/match.sx
|
||||
lib/haskell/eval.sx
|
||||
lib/haskell/map.sx
|
||||
lib/haskell/set.sx
|
||||
lib/haskell/testlib.sx
|
||||
lib/search/tokenize.sx
|
||||
lib/search/index.sx
|
||||
lib/search/query.sx
|
||||
lib/search/parse.sx
|
||||
lib/search/rank.sx
|
||||
lib/search/fed.sx
|
||||
lib/search/prefix.sx
|
||||
lib/search/page.sx
|
||||
lib/search/fuzzy.sx
|
||||
lib/search/highlight.sx
|
||||
lib/search/stem.sx
|
||||
lib/search/near.sx
|
||||
lib/search/syn.sx
|
||||
lib/search/rankq.sx
|
||||
lib/search/api.sx
|
||||
lib/search/testlib.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"index:lib/search/tests/index.sx"
|
||||
"boolean:lib/search/tests/boolean.sx"
|
||||
"parse:lib/search/tests/parse.sx"
|
||||
"rank:lib/search/tests/rank.sx"
|
||||
"integration:lib/search/tests/integration.sx"
|
||||
"prefix:lib/search/tests/prefix.sx"
|
||||
"page:lib/search/tests/page.sx"
|
||||
"fuzzy:lib/search/tests/fuzzy.sx"
|
||||
"highlight:lib/search/tests/highlight.sx"
|
||||
"stem:lib/search/tests/stem.sx"
|
||||
"near:lib/search/tests/near.sx"
|
||||
"syn:lib/search/tests/syn.sx"
|
||||
"rankq:lib/search/tests/rankq.sx"
|
||||
)
|
||||
3
lib/search/conformance.sh
Executable file
3
lib/search/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/search/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
16
lib/search/fed.sx
Normal file
16
lib/search/fed.sx
Normal file
@@ -0,0 +1,16 @@
|
||||
;; search federation + ACL — Haskell source fragment. Depends on index + rank.
|
||||
;; Federation merges per-peer INDICES (not ranked results): each peer's local
|
||||
;; DocIds are relabelled to global ids `gid peer local = peer*1000 + local`
|
||||
;; (dedupe by (peer,doc-id) is automatic via the bijection), then posting lists
|
||||
;; are unioned per term. Ranking then runs once over the merged index, which is
|
||||
;; rank-correct. ACL is a post-rank filter: an injected `permit :: DocId -> Bool`
|
||||
;; predicate (viewer baked in by the caller) — never baked into the index.
|
||||
;; fedIndex :: [(PeerId, Index)] -> Index
|
||||
;; aclFilter :: (DocId -> Bool) -> [DocId] -> [DocId]
|
||||
;; searchTfIdfAcl :: (DocId -> Bool) -> [Term] -> Index -> [DocId]
|
||||
;; topNTfIdfAcl :: Int -> (DocId -> Bool) -> [Term] -> Index -> [DocId]
|
||||
;; searchBm25Acl :: (DocId -> Bool) -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/fed-src
|
||||
"gid peer local = peer * 1000 + local\nfedRelabelPosting peer p = (gid peer (fst p), snd p)\nfedRelabelEntry peer e = (fst e, map (fedRelabelPosting peer) (snd e))\nfedRelabelIndex peer idx = map (fedRelabelEntry peer) idx\nfedInsP p [] = [p]\nfedInsP p (q:qs) = if fst p < fst q then p : q : qs else if fst p == fst q then p : qs else q : fedInsP p qs\nfedMergePL a b = foldr fedInsP b a\nfedInsTerm t pl [] = [(t, pl)]\nfedInsTerm t pl (x:xs) = if t < fst x then (t, pl) : x : xs else if t == fst x then (fst x, fedMergePL pl (snd x)) : xs else x : fedInsTerm t pl xs\nfedMergeEntry idx e = fedInsTerm (fst e) (snd e) idx\nfedMergeTwo a b = foldl fedMergeEntry a b\nfedAddPeer acc pair = fedMergeTwo acc (fedRelabelIndex (fst pair) (snd pair))\nfedIndex pairs = foldl fedAddPeer emptyIndex pairs\naclFilter permit docs = filter permit docs\nsearchTfIdfAcl permit ts idx = aclFilter permit (rankTfIdf ts idx)\ntopNTfIdfAcl n permit ts idx = take n (aclFilter permit (rankTfIdf ts idx))\nsearchBm25Acl permit k1 b ts idx = aclFilter permit (rankBm25 k1 b ts idx)\n")
|
||||
12
lib/search/fuzzy.sx
Normal file
12
lib/search/fuzzy.sx
Normal file
@@ -0,0 +1,12 @@
|
||||
;; search fuzzy matching — Haskell source fragment. Depends on index + rank.
|
||||
;; Levenshtein edit distance (O(m*n) row-based DP — the naive recursive version is
|
||||
;; exponential and far too slow under load) expands a query term to all indexed
|
||||
;; terms within a max distance, then unions / ranks their docs.
|
||||
;; editDist :: String -> String -> Int
|
||||
;; fuzzyTerms :: Int -> String -> Index -> [Term] (sorted)
|
||||
;; fuzzyDocs :: Int -> String -> Index -> [DocId] (sorted union)
|
||||
;; fuzzyRankTfIdf :: Int -> String -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/fuzzy-src
|
||||
"edMin3 a b c = min a (min b c)\nedCost x y = if x == y then 0 else 1\nedUpto i n = if i > n then [] else i : edUpto (i + 1) n\nedLast [x] = x\nedLast (x:xs) = edLast xs\nedNrow x [] prev left = []\nedNrow x (y:ys) prev left = let v = edMin3 (head (tail prev) + 1) (left + 1) (head prev + edCost x y) in v : edNrow x ys (tail prev) v\nedRow x ys prev = let f = head prev + 1 in f : edNrow x ys prev f\nedRows [] ys prev = prev\nedRows (x:xs) ys prev = edRows xs ys (edRow x ys prev)\neditDist xs ys = edLast (edRows xs ys (edUpto 0 (length ys)))\nqWithinDist maxd term t = editDist term t <= maxd\nfuzzyTerms maxd term idx = filter (qWithinDist maxd term) (allTerms idx)\nfuzzyDocs maxd term idx = foldl (candStep idx) [] (fuzzyTerms maxd term idx)\nfuzzyRankTfIdf maxd term idx = rankTfIdf (fuzzyTerms maxd term idx) idx\n")
|
||||
10
lib/search/highlight.sx
Normal file
10
lib/search/highlight.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; search highlight / snippet — Haskell source fragment. Depends on tokenize.
|
||||
;; Operates on document text (not the index): marks query-matching tokens with
|
||||
;; [..] and extracts a context window around the first match. Tokens are
|
||||
;; normalized (lowercase, punctuation-stripped) by `tokens`, matching index side.
|
||||
;; highlight :: [Term] -> String -> String
|
||||
;; snippet :: Int -> [Term] -> String -> String (ctx tokens each side of 1st match)
|
||||
|
||||
(define
|
||||
search/highlight-src
|
||||
"hlMark terms t = if elem t terms then \"[\" ++ t ++ \"]\" else t\nhighlight terms text = unwords (map (hlMark terms) (tokens text))\nhlIdxFrom terms [] i = 0 - 1\nhlIdxFrom terms (t:ts) i = if elem t terms then i else hlIdxFrom terms ts (i + 1)\nhlIdx terms toks = hlIdxFrom terms toks 0\nhlMax0 x = if x < 0 then 0 else x\nsnipStart ctx i = if i < 0 then 0 else hlMax0 (i - ctx)\nsnipToks ctx terms toks = unwords (map (hlMark terms) (take (2 * ctx + 1) (drop (snipStart ctx (hlIdx terms toks)) toks)))\nsnippet ctx terms text = snipToks ctx terms (tokens text)\n")
|
||||
15
lib/search/index.sx
Normal file
15
lib/search/index.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
;; search inverted index — Haskell source fragment (depends on tokenize).
|
||||
;; Index = [(Term, [(DocId, [Pos])])], sorted by Term; postings sorted by DocId.
|
||||
;; Data.Map's public API lacks toList/keys/map/filter, so a sorted assoc-list
|
||||
;; index is used — it is the conceptual `Map Term [(DocId,[Pos])]` and exposes
|
||||
;; term iteration (allTerms) and df naturally for ranking.
|
||||
;; emptyIndex :: Index
|
||||
;; indexDoc :: DocId -> String -> Index -> Index (re-index replaces)
|
||||
;; lookupTerm :: Term -> Index -> [(DocId, [Pos])]
|
||||
;; deleteDoc :: DocId -> Index -> Index
|
||||
;; docFreq :: Term -> Index -> Int
|
||||
;; allTerms :: Index -> [Term]
|
||||
|
||||
(define
|
||||
search/index-src
|
||||
"emptyIndex = []\ngroupBump [] t p = [(t, [p])]\ngroupBump (g:gs) t p = if fst g == t then (t, snd g ++ [p]) : gs else g : groupBump gs t p\ngroupStep acc tp = groupBump acc (fst tp) (snd tp)\ngroupTok pairs = foldl groupStep [] pairs\ninsPosting d ps [] = [(d, ps)]\ninsPosting d ps (q:qs) = if d < fst q then (d, ps) : q : qs else if d == fst q then (d, ps) : qs else q : insPosting d ps qs\ninsTerm t d ps [] = [(t, [(d, ps)])]\ninsTerm t d ps (e:es) = if t < fst e then (t, [(d, ps)]) : e : es else if t == fst e then (fst e, insPosting d ps (snd e)) : es else e : insTerm t d ps es\nindexStep d ix tp = insTerm (fst tp) d (snd tp) ix\nindexDoc d text idx = foldl (indexStep d) idx (groupTok (positioned text))\nlookupTerm t idx = case lookup t idx of { Nothing -> []; Just pl -> pl }\ndocFreq t idx = length (lookupTerm t idx)\nallTerms idx = map fst idx\npostingKeep d q = fst q /= d\ndropTermDoc d e = (fst e, filter (postingKeep d) (snd e))\nplKeep e = not (null (snd e))\ndeleteDoc d idx = filter plKeep (map (dropTermDoc d) idx)\n")
|
||||
8
lib/search/near.sx
Normal file
8
lib/search/near.sx
Normal file
@@ -0,0 +1,8 @@
|
||||
;; search proximity (NEAR) — Haskell source fragment. Depends on query (posIn,
|
||||
;; docsWith, sortedInter). Finds docs where two terms occur within k positions of
|
||||
;; each other (unordered), using the positional postings.
|
||||
;; nearDocs :: Int -> Term -> Term -> Index -> [DocId] (sorted)
|
||||
|
||||
(define
|
||||
search/near-src
|
||||
"nrAbsDiff a b = if a > b then a - b else b - a\nnrCloseTo k x [] = False\nnrCloseTo k x (y:ys) = if nrAbsDiff x y <= k then True else nrCloseTo k x ys\nnrAnyClose k [] ys = False\nnrAnyClose k (x:xs) ys = if nrCloseTo k x ys then True else nrAnyClose k xs ys\nnearInDoc k t1 t2 d idx = nrAnyClose k (posIn t1 d idx) (posIn t2 d idx)\nnearHere k t1 t2 idx d = nearInDoc k t1 t2 d idx\nnearDocs k t1 t2 idx = filter (nearHere k t1 t2 idx) (sortedInter (docsWith t1 idx) (docsWith t2 idx))\n")
|
||||
11
lib/search/page.sx
Normal file
11
lib/search/page.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search pagination — Haskell source fragment. Depends on rank.
|
||||
;; Windows a ranked result list by offset/limit (offset >= length -> empty;
|
||||
;; limit clamps to what remains).
|
||||
;; paginate :: Int -> Int -> [DocId] -> [DocId] (offset, limit)
|
||||
;; pageTfIdf :: Int -> Int -> [Term] -> Index -> [DocId]
|
||||
;; pageBm25 :: Int -> Int -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
;; resultCount :: [Term] -> Index -> Int
|
||||
|
||||
(define
|
||||
search/page-src
|
||||
"paginate off lim docs = take lim (drop off docs)\npageTfIdf off lim ts idx = paginate off lim (rankTfIdf ts idx)\npageBm25 off lim k1 b ts idx = paginate off lim (rankBm25 k1 b ts idx)\nresultCount ts idx = length (rankTfIdf ts idx)\n")
|
||||
18
lib/search/parse.sx
Normal file
18
lib/search/parse.sx
Normal file
@@ -0,0 +1,18 @@
|
||||
;; search query parser — Haskell source fragment. Depends on tokenize + query.
|
||||
;; Grammar (precedence OR < AND < NOT):
|
||||
;; expr = orExpr
|
||||
;; orExpr = andExpr (OR andExpr)*
|
||||
;; andExpr= notExpr ((AND | <implicit>) notExpr)* -- adjacency means AND
|
||||
;; notExpr= NOT notExpr | atom
|
||||
;; atom = '(' expr ')' | '"' word+ '"' | word
|
||||
;; Keywords AND/OR/NOT are case-insensitive; bare words are normalized via tokens.
|
||||
;; Gotchas: delimiters matched by ord (escaped char literals like '\"' break the
|
||||
;; haskell-on-sx tokenizer); an [] *pattern* inside a `case` alt also breaks the
|
||||
;; parser, so qNormTerm/qDropRP/showQ are written as multi-clause functions.
|
||||
;; parseQuery :: String -> Query
|
||||
;; searchQuery :: String -> Index -> [DocId]
|
||||
;; showQ :: Query -> String -- canonical render for tests/debug
|
||||
|
||||
(define
|
||||
search/parse-src
|
||||
"data QTok = TAnd | TOr | TNot | TLP | TRP | TWord String | TPhrase [String]\nqIsSpace c = ord c == 32\nqIsLP c = ord c == 40\nqIsRP c = ord c == 41\nqIsQuote c = ord c == 34\nqDelim c = qIsSpace c || qIsLP c || qIsRP c || qIsQuote c\nqReadWord [] = ([], [])\nqReadWord (c:cs) = if qDelim c then ([], c:cs) else let (w, rest) = qReadWord cs in (c:w, rest)\nqReadPhrase [] = ([], [])\nqReadPhrase (c:cs) = if qIsQuote c then ([], cs) else let (w, rest) = qReadPhrase cs in (c:w, rest)\ntoUpperCh c = chr (toUpper (ord c))\nqUpper w = joinChars (map toUpperCh w)\nqFirstTok [] = \"\"\nqFirstTok (x:xs) = x\nqNormTerm w = qFirstTok (tokens w)\nqClassify w = if qUpper w == \"AND\" then TAnd else if qUpper w == \"OR\" then TOr else if qUpper w == \"NOT\" then TNot else TWord (qNormTerm w)\nqPhraseTok cs = let (p, rest) = qReadPhrase cs in TPhrase (tokens p) : qtokens rest\nqWordTok cs = let (w, rest) = qReadWord cs in qClassify w : qtokens rest\nqtokens [] = []\nqtokens (c:cs) = if qIsSpace c then qtokens cs else if qIsLP c then TLP : qtokens cs else if qIsRP c then TRP : qtokens cs else if qIsQuote c then qPhraseTok cs else qWordTok (c:cs)\nqDropRP (q, (TRP:rest)) = (q, rest)\nqDropRP (q, ts) = (q, ts)\nparseAtom [] = (Term \"\", [])\nparseAtom (TLP:ts) = qDropRP (parseExpr ts)\nparseAtom (TPhrase ps : ts) = (Phrase ps, ts)\nparseAtom (TWord w : ts) = (Term w, ts)\nparseAtom ts = (Term \"\", ts)\nqWrapNot (q, ts) = (Not q, ts)\nparseNot (TNot:ts) = qWrapNot (parseNot ts)\nparseNot ts = parseAtom ts\nqStartsAtom (TWord w : ts) = True\nqStartsAtom (TPhrase p : ts) = True\nqStartsAtom (TLP : ts) = True\nqStartsAtom (TNot : ts) = True\nqStartsAtom ts = False\nqAndStep left ts = let (r, rest) = parseNot ts in parseAndR (And left r) rest\nparseAndR left (TAnd:ts) = qAndStep left ts\nparseAndR left ts = if qStartsAtom ts then qAndStep left ts else (left, ts)\nparseAnd ts = let (l, rest) = parseNot ts in parseAndR l rest\nparseOrR left (TOr:ts) = let (r, rest) = parseAnd ts in parseOrR (Or left r) rest\nparseOrR left ts = (left, ts)\nparseExpr ts = let (l, rest) = parseAnd ts in parseOrR l rest\nparseQuery s = fst (parseExpr (qtokens s))\nsearchQuery s idx = evalQuery idx (parseQuery s)\njoinSp [] = \"\"\njoinSp [x] = x\njoinSp (x:xs) = x ++ \"-\" ++ joinSp xs\nshowQ (Term t) = \"T:\" ++ t\nshowQ (And a b) = \"(\" ++ showQ a ++ \" & \" ++ showQ b ++ \")\"\nshowQ (Or a b) = \"(\" ++ showQ a ++ \" | \" ++ showQ b ++ \")\"\nshowQ (Not a) = \"!\" ++ showQ a\nshowQ (Phrase ts) = \"P:\" ++ joinSp ts\n")
|
||||
10
lib/search/prefix.sx
Normal file
10
lib/search/prefix.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; search prefix / wildcard queries — Haskell source fragment. Depends on index +
|
||||
;; rank (reuses candStep / rankTfIdf). A prefix matches every indexed term that
|
||||
;; starts with it; the matching terms are unioned (OR) into a docid set.
|
||||
;; prefixTerms :: String -> Index -> [Term] (sorted, from allTerms)
|
||||
;; prefixDocs :: String -> Index -> [DocId] (sorted union)
|
||||
;; prefixRankTfIdf :: String -> Index -> [DocId] (ranked by the matched terms)
|
||||
|
||||
(define
|
||||
search/prefix-src
|
||||
"prefixTerms pre idx = filter (isPrefixOf pre) (allTerms idx)\nprefixDocs pre idx = foldl (candStep idx) [] (prefixTerms pre idx)\nprefixRankTfIdf pre idx = rankTfIdf (prefixTerms pre idx) idx\n")
|
||||
11
lib/search/query.sx
Normal file
11
lib/search/query.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search query AST + boolean/phrase evaluation — Haskell source fragment.
|
||||
;; Depends on tokenize + index.
|
||||
;; data Query = Term String | And Query Query | Or Query Query
|
||||
;; | Not Query | Phrase [String]
|
||||
;; evalQuery :: Index -> Query -> [DocId] (sorted, unique)
|
||||
;; Boolean ops are linear merges over docid-sorted posting lists; Not uses
|
||||
;; allDocs as the universe; Phrase checks positional adjacency.
|
||||
|
||||
(define
|
||||
search/query-src
|
||||
"data Query = Term String | And Query Query | Or Query Query | Not Query | Phrase [String]\ndocsWith t idx = map fst (lookupTerm t idx)\nsortedUnion [] ys = ys\nsortedUnion xs [] = xs\nsortedUnion (x:xs) (y:ys) = if x < y then x : sortedUnion xs (y:ys) else if x > y then y : sortedUnion (x:xs) ys else x : sortedUnion xs ys\nsortedInter [] ys = []\nsortedInter xs [] = []\nsortedInter (x:xs) (y:ys) = if x < y then sortedInter xs (y:ys) else if x > y then sortedInter (x:xs) ys else x : sortedInter xs ys\nsortedDiff [] ys = []\nsortedDiff xs [] = xs\nsortedDiff (x:xs) (y:ys) = if x < y then x : sortedDiff xs (y:ys) else if x > y then sortedDiff (x:xs) ys else sortedDiff xs ys\nmergeDocs acc e = sortedUnion acc (map fst (snd e))\nallDocs idx = foldl mergeDocs [] idx\nposIn t d idx = case lookup d (lookupTerm t idx) of { Nothing -> []; Just ps -> ps }\nelemSorted x [] = False\nelemSorted x (y:ys) = if x == y then True else if x < y then False else elemSorted x ys\nphraseAtAll [] d idx p i = True\nphraseAtAll (t:ts) d idx p i = if elemSorted (p + i) (posIn t d idx) then phraseAtAll ts d idx p (i + 1) else False\nphraseStartsAt ts d idx p = phraseAtAll ts d idx p 0\nphraseInDoc [] d idx = True\nphraseInDoc (t0:rest) d idx = any (phraseStartsAt (t0:rest) d idx) (posIn t0 d idx)\nphraseHere ts idx d = phraseInDoc ts d idx\ninterStep idx acc tt = sortedInter acc (docsWith tt idx)\nphraseCands [] idx = allDocs idx\nphraseCands (t:ts) idx = foldl (interStep idx) (docsWith t idx) ts\nphraseDocs ts idx = filter (phraseHere ts idx) (phraseCands ts idx)\nevalQuery idx q = case q of { Term t -> docsWith t idx ; And a b -> sortedInter (evalQuery idx a) (evalQuery idx b) ; Or a b -> sortedUnion (evalQuery idx a) (evalQuery idx b) ; Not a -> sortedDiff (allDocs idx) (evalQuery idx a) ; Phrase ts -> phraseDocs ts idx }\n")
|
||||
14
lib/search/rank.sx
Normal file
14
lib/search/rank.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
;; search ranking — Haskell source fragment. Depends on tokenize + index + query.
|
||||
;; Ranked retrieval over the candidate set (docs containing any query term).
|
||||
;; Scores are floats; ties broken by DocId ascending (deterministic).
|
||||
;; numDocs :: Index -> Int
|
||||
;; docFreq :: Term -> Index -> Int (from index)
|
||||
;; docLen :: DocId -> Index -> Int
|
||||
;; rankTfIdf :: [Term] -> Index -> [DocId]
|
||||
;; topNTfIdf :: Int -> [Term] -> Index -> [DocId]
|
||||
;; rankBm25 :: Float -> Float -> [Term] -> Index -> [DocId] (k1, b)
|
||||
;; topNBm25 :: Int -> Float -> Float -> [Term] -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/rank-src
|
||||
"numDocs idx = length (allDocs idx)\ntfIn t d idx = length (posIn t d idx)\nqIdf n df = if df == 0 then 0 else log (n / df)\nidf t idx = qIdf (numDocs idx) (docFreq t idx)\ntermScoreTf idx d t = tfIn t d idx * idf t idx\ntfidfDoc ts idx d = sum (map (termScoreTf idx d) ts)\ncandStep idx acc t = sortedUnion acc (docsWith t idx)\ncandDocs ts idx = foldl (candStep idx) [] ts\ncmpScore p1 p2 = if fst p1 > fst p2 then LT else if fst p1 < fst p2 then GT else compare (snd p1) (snd p2)\nmkPair f ts idx d = (f ts idx d, d)\nrankWith f ts idx = map snd (sortBy cmpScore (map (mkPair f ts idx) (candDocs ts idx)))\nrankTfIdf ts idx = rankWith tfidfDoc ts idx\ntopNTfIdf n ts idx = take n (rankTfIdf ts idx)\ntfAt d idx t = tfIn t d idx\ndocLen d idx = sum (map (tfAt d idx) (allTerms idx))\nlenAt idx d = docLen d idx\navgDocLen idx = sum (map (lenAt idx) (allDocs idx)) / numDocs idx\nbm25idf t idx = log ((numDocs idx - docFreq t idx + 0.5) / (docFreq t idx + 0.5) + 1)\nbm25Term k1 b avgdl idx d t = bm25idf t idx * (tfIn t d idx * (k1 + 1)) / (tfIn t d idx + k1 * (1 - b + b * docLen d idx / avgdl))\nbm25Doc k1 b ts idx d = sum (map (bm25Term k1 b (avgDocLen idx) idx d) ts)\nrankBm25 k1 b ts idx = rankWith (bm25Doc k1 b) ts idx\ntopNBm25 n k1 b ts idx = take n (rankBm25 k1 b ts idx)\n")
|
||||
11
lib/search/rankq.sx
Normal file
11
lib/search/rankq.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
;; search boolean-filtered ranked search — Haskell source fragment.
|
||||
;; Depends on parse (parseQuery/Query), query (evalQuery), rank (tfidfDoc/bm25Doc/
|
||||
;; cmpScore). Filters by the boolean query, then ranks the surviving docs by
|
||||
;; relevance over the query's leaf terms — the real-world filter-then-rank pattern.
|
||||
;; queryTerms :: Query -> [Term]
|
||||
;; searchRankTfIdf :: String -> Index -> [DocId]
|
||||
;; searchRankBm25 :: Float -> Float -> String -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/rankq-src
|
||||
"queryTerms (Term t) = [t]\nqueryTerms (And a b) = queryTerms a ++ queryTerms b\nqueryTerms (Or a b) = queryTerms a ++ queryTerms b\nqueryTerms (Not a) = queryTerms a\nqueryTerms (Phrase ts) = ts\nmkSubPair f terms idx d = (f terms idx d, d)\nrankSubsetWith f terms docs idx = map snd (sortBy cmpScore (map (mkSubPair f terms idx) docs))\nsearchRankTfIdf s idx = let q = parseQuery s in rankSubsetWith tfidfDoc (queryTerms q) (evalQuery idx q) idx\nsearchRankBm25 k1 b s idx = let q = parseQuery s in rankSubsetWith (bm25Doc k1 b) (queryTerms q) (evalQuery idx q) idx\n")
|
||||
22
lib/search/scoreboard.json
Normal file
22
lib/search/scoreboard.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"lang": "search",
|
||||
"total_passed": 225,
|
||||
"total_failed": 0,
|
||||
"total": 225,
|
||||
"suites": [
|
||||
{"name":"index","passed":18,"failed":0,"total":18},
|
||||
{"name":"boolean","passed":28,"failed":0,"total":28},
|
||||
{"name":"parse","passed":32,"failed":0,"total":32},
|
||||
{"name":"rank","passed":23,"failed":0,"total":23},
|
||||
{"name":"integration","passed":21,"failed":0,"total":21},
|
||||
{"name":"prefix","passed":14,"failed":0,"total":14},
|
||||
{"name":"page","passed":12,"failed":0,"total":12},
|
||||
{"name":"fuzzy","passed":18,"failed":0,"total":18},
|
||||
{"name":"highlight","passed":12,"failed":0,"total":12},
|
||||
{"name":"stem","passed":18,"failed":0,"total":18},
|
||||
{"name":"near","passed":9,"failed":0,"total":9},
|
||||
{"name":"syn","passed":9,"failed":0,"total":9},
|
||||
{"name":"rankq","passed":11,"failed":0,"total":11}
|
||||
],
|
||||
"generated": "2026-06-06T23:58:05+00:00"
|
||||
}
|
||||
19
lib/search/scoreboard.md
Normal file
19
lib/search/scoreboard.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# search scoreboard
|
||||
|
||||
**225 / 225 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| index | 18 | 18 | ok |
|
||||
| boolean | 28 | 28 | ok |
|
||||
| parse | 32 | 32 | ok |
|
||||
| rank | 23 | 23 | ok |
|
||||
| integration | 21 | 21 | ok |
|
||||
| prefix | 14 | 14 | ok |
|
||||
| page | 12 | 12 | ok |
|
||||
| fuzzy | 18 | 18 | ok |
|
||||
| highlight | 12 | 12 | ok |
|
||||
| stem | 18 | 18 | ok |
|
||||
| near | 9 | 9 | ok |
|
||||
| syn | 9 | 9 | ok |
|
||||
| rankq | 11 | 11 | ok |
|
||||
15
lib/search/stem.sx
Normal file
15
lib/search/stem.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
;; search stemming — Haskell source fragment. Depends on tokenize + index.
|
||||
;; Lightweight, deterministic English suffix stripping (recall-improving
|
||||
;; normalizer). Rules are checked most-specific first; conservative length guards
|
||||
;; avoid mangling short words. Not a full Porter stemmer.
|
||||
;; Gotcha: take/drop over a String yield char CODES (ints), not char strings, so
|
||||
;; rebuild strings with `stStr = joinChars . map chr`. (isSuffixOf's reverse also
|
||||
;; trips `++` on the String representation, hence the manual stEnds.)
|
||||
;; stem :: String -> String
|
||||
;; stemText :: String -> String (tokenize + stem + rejoin)
|
||||
;; stemTokens :: String -> [String]
|
||||
;; indexStemmed:: DocId -> String -> Index -> Index (index the stemmed text)
|
||||
|
||||
(define
|
||||
search/stem-src
|
||||
"stStr cs = joinChars (map chr cs)\nstEnds suf w = let n = length w in let m = length suf in if m > n then False else stStr (drop (n - m) w) == suf\nstDropEnd k w = stStr (take (length w - k) w)\nstem w = if stEnds \"ies\" w && length w >= 5 then stDropEnd 3 w ++ \"y\" else if stEnds \"ss\" w then w else if stEnds \"es\" w && length w >= 5 then stDropEnd 2 w else if stEnds \"s\" w && length w >= 4 then stDropEnd 1 w else if stEnds \"ing\" w && length w >= 6 then stDropEnd 3 w else if stEnds \"ed\" w && length w >= 5 then stDropEnd 2 w else w\nstemTokens s = map stem (tokens s)\nstemText s = unwords (stemTokens s)\nindexStemmed d text idx = indexDoc d (stemText text) idx\n")
|
||||
10
lib/search/syn.sx
Normal file
10
lib/search/syn.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
;; search synonym / query expansion — Haskell source fragment. Depends on index +
|
||||
;; rank. A synonym map is an assoc list [(Term, [Term])]; a query term is expanded
|
||||
;; to itself plus its synonyms, then the expanded set is unioned / ranked.
|
||||
;; expandTerm :: [(Term,[Term])] -> Term -> [Term]
|
||||
;; synDocs :: [(Term,[Term])] -> Term -> Index -> [DocId]
|
||||
;; synRankTfIdf :: [(Term,[Term])] -> Term -> Index -> [DocId]
|
||||
|
||||
(define
|
||||
search/syn-src
|
||||
"synLookup synmap t = case lookup t synmap of { Nothing -> [] ; Just ss -> ss }\nexpandTerm synmap t = t : synLookup synmap t\nsynDocs synmap t idx = foldl (candStep idx) [] (expandTerm synmap t)\nsynRankTfIdf synmap t idx = rankTfIdf (expandTerm synmap t) idx\n")
|
||||
50
lib/search/testlib.sx
Normal file
50
lib/search/testlib.sx
Normal file
@@ -0,0 +1,50 @@
|
||||
;; search test helpers — convert forced haskell values to plain SX and run
|
||||
;; programs built on top of search/src. Reuses hk-test / counters from
|
||||
;; lib/haskell/testlib.sx (preloaded by the conformance config).
|
||||
|
||||
;; Recursively turn a forced HK value into plain SX:
|
||||
;; cons-list -> SX list, Tuple -> SX list, leaves unchanged.
|
||||
(define
|
||||
search-hk->sx
|
||||
(fn
|
||||
(v)
|
||||
(cond
|
||||
((and (list? v) (not (empty? v)) (= (first v) "[]")) (list))
|
||||
((and (list? v) (not (empty? v)) (= (first v) ":"))
|
||||
(cons
|
||||
(search-hk->sx (nth v 1))
|
||||
(search-hk->sx (nth v 2))))
|
||||
((and (list? v) (not (empty? v)) (= (first v) "Tuple"))
|
||||
(map search-hk->sx (rest v)))
|
||||
(:else v))))
|
||||
|
||||
;; Evaluate `extra` (extra top-level Haskell bindings) on top of search/src
|
||||
;; and return binding `name` as plain SX.
|
||||
(define
|
||||
search-eval
|
||||
(fn
|
||||
(extra name)
|
||||
(search-hk->sx
|
||||
(hk-deep-force
|
||||
(get (hk-eval-program (hk-core (str search/src extra))) name)))))
|
||||
|
||||
(define
|
||||
search-join
|
||||
(fn
|
||||
(sep xs)
|
||||
(cond
|
||||
((empty? xs) "")
|
||||
((empty? (rest xs)) (first xs))
|
||||
(:else (str (first xs) sep (search-join sep (rest xs)))))))
|
||||
|
||||
;; Batch many haskell expressions into ONE program evaluation (amortizes the
|
||||
;; cost of parsing/binding search/src — important under heavy CPU load).
|
||||
;; `setup` is extra top-level Haskell; `exprs` is a list of expression strings
|
||||
;; whose results form a single haskell list. Returns the SX list of results.
|
||||
(define
|
||||
search-batch
|
||||
(fn
|
||||
(setup exprs)
|
||||
(search-eval
|
||||
(str setup "\nresult = [" (search-join ", " exprs) "]\n")
|
||||
"result")))
|
||||
123
lib/search/tests/boolean.sx
Normal file
123
lib/search/tests/boolean.sx
Normal file
@@ -0,0 +1,123 @@
|
||||
;; Phase 2 — query AST + boolean/phrase evaluation (hand-built Query values).
|
||||
;; Corpus:
|
||||
;; doc 1 "the quick brown dog" -> the quick brown dog
|
||||
;; doc 2 "a quick brown fox" -> a quick brown fox
|
||||
;; doc 3 "the dog barks loudly" -> the dog barks loudly
|
||||
;; All queries run in ONE program evaluation (search-batch) to stay fast.
|
||||
|
||||
(define
|
||||
search-corpus
|
||||
"idx = indexDoc 3 \"the dog barks loudly\" (indexDoc 2 \"a quick brown fox\" (indexDoc 1 \"the quick brown dog\" emptyIndex))\n")
|
||||
|
||||
(define
|
||||
bool-cases
|
||||
(list
|
||||
(list
|
||||
"term in two docs"
|
||||
"evalQuery idx (Term \"quick\")"
|
||||
(list 1 2))
|
||||
(list
|
||||
"term in two docs (the)"
|
||||
"evalQuery idx (Term \"the\")"
|
||||
(list 1 3))
|
||||
(list "term in one doc" "evalQuery idx (Term \"fox\")" (list 2))
|
||||
(list "term absent" "evalQuery idx (Term \"zzz\")" (list))
|
||||
(list
|
||||
"term case-sensitive at AST level"
|
||||
"evalQuery idx (Term \"QUICK\")"
|
||||
(list))
|
||||
(list "term on empty index" "evalQuery emptyIndex (Term \"cat\")" (list))
|
||||
(list
|
||||
"and both terms"
|
||||
"evalQuery idx (And (Term \"quick\") (Term \"brown\"))"
|
||||
(list 1 2))
|
||||
(list
|
||||
"and overlap subset"
|
||||
"evalQuery idx (And (Term \"the\") (Term \"dog\"))"
|
||||
(list 1 3))
|
||||
(list
|
||||
"and disjoint is empty"
|
||||
"evalQuery idx (And (Term \"the\") (Term \"fox\"))"
|
||||
(list))
|
||||
(list
|
||||
"and right-nested"
|
||||
"evalQuery idx (And (Term \"the\") (And (Term \"dog\") (Term \"barks\")))"
|
||||
(list 3))
|
||||
(list
|
||||
"or two singletons"
|
||||
"evalQuery idx (Or (Term \"fox\") (Term \"barks\"))"
|
||||
(list 2 3))
|
||||
(list
|
||||
"or all docs"
|
||||
"evalQuery idx (Or (Term \"quick\") (Term \"the\"))"
|
||||
(list 1 2 3))
|
||||
(list
|
||||
"or with absent term"
|
||||
"evalQuery idx (Or (Term \"fox\") (Term \"zzz\"))"
|
||||
(list 2))
|
||||
(list "not term" "evalQuery idx (Not (Term \"the\"))" (list 2))
|
||||
(list "not term 2" "evalQuery idx (Not (Term \"quick\"))" (list 3))
|
||||
(list
|
||||
"and with not"
|
||||
"evalQuery idx (And (Term \"quick\") (Not (Term \"the\")))"
|
||||
(list 2))
|
||||
(list
|
||||
"double negation"
|
||||
"evalQuery idx (Not (Not (Term \"fox\")))"
|
||||
(list 2))
|
||||
(list
|
||||
"or of and with term"
|
||||
"evalQuery idx (Or (And (Term \"the\") (Term \"dog\")) (Term \"fox\"))"
|
||||
(list 1 2 3))
|
||||
(list
|
||||
"phrase adjacent both docs"
|
||||
"evalQuery idx (Phrase [\"quick\", \"brown\"])"
|
||||
(list 1 2))
|
||||
(list
|
||||
"phrase adjacent one doc"
|
||||
"evalQuery idx (Phrase [\"brown\", \"dog\"])"
|
||||
(list 1))
|
||||
(list
|
||||
"phrase the quick"
|
||||
"evalQuery idx (Phrase [\"the\", \"quick\"])"
|
||||
(list 1))
|
||||
(list
|
||||
"phrase dog barks"
|
||||
"evalQuery idx (Phrase [\"dog\", \"barks\"])"
|
||||
(list 3))
|
||||
(list
|
||||
"phrase non-adjacent empty"
|
||||
"evalQuery idx (Phrase [\"quick\", \"dog\"])"
|
||||
(list))
|
||||
(list
|
||||
"phrase order matters"
|
||||
"evalQuery idx (Phrase [\"brown\", \"quick\"])"
|
||||
(list))
|
||||
(list
|
||||
"phrase single term"
|
||||
"evalQuery idx (Phrase [\"dog\"])"
|
||||
(list 1 3))
|
||||
(list
|
||||
"phrase three terms"
|
||||
"evalQuery idx (Phrase [\"the\", \"dog\", \"barks\"])"
|
||||
(list 3))
|
||||
(list
|
||||
"and of phrase and term"
|
||||
"evalQuery idx (And (Phrase [\"quick\", \"brown\"]) (Term \"dog\"))"
|
||||
(list 1))
|
||||
(list
|
||||
"not of phrase"
|
||||
"evalQuery idx (Not (Phrase [\"quick\", \"brown\"]))"
|
||||
(list 3))))
|
||||
|
||||
(define
|
||||
bool-results
|
||||
(search-batch search-corpus (map (fn (c) (nth c 1)) bool-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth bool-results i) (nth c 2)))
|
||||
bool-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
74
lib/search/tests/fuzzy.sx
Normal file
74
lib/search/tests/fuzzy.sx
Normal file
@@ -0,0 +1,74 @@
|
||||
;; Extension — fuzzy matching via Levenshtein edit distance.
|
||||
;; Corpus: 1 "color flavor" 2 "colour kitten" 3 "colored"
|
||||
;; allTerms: color colored colour flavor kitten
|
||||
|
||||
(define
|
||||
fuzzy-setup
|
||||
"idx = indexDoc 3 \"colored\" (indexDoc 2 \"colour kitten\" (indexDoc 1 \"color flavor\" emptyIndex))\n")
|
||||
|
||||
(define
|
||||
fuzzy-cases
|
||||
(list
|
||||
(list
|
||||
"editDist substitution"
|
||||
"[editDist \"kitten\" \"sitten\"]"
|
||||
(list 1))
|
||||
(list "editDist equal" "[editDist \"abc\" \"abc\"]" (list 0))
|
||||
(list "editDist deletion" "[editDist \"abc\" \"ab\"]" (list 1))
|
||||
(list "editDist insertion" "[editDist \"ab\" \"abc\"]" (list 1))
|
||||
(list "editDist from empty" "[editDist \"\" \"abc\"]" (list 3))
|
||||
(list "editDist both empty" "[editDist \"\" \"\"]" (list 0))
|
||||
(list
|
||||
"editDist classic"
|
||||
"[editDist \"kitten\" \"sitting\"]"
|
||||
(list 3))
|
||||
(list
|
||||
"editDist color colour"
|
||||
"[editDist \"color\" \"colour\"]"
|
||||
(list 1))
|
||||
(list
|
||||
"editDist color colored"
|
||||
"[editDist \"color\" \"colored\"]"
|
||||
(list 2))
|
||||
(list
|
||||
"fuzzy terms dist 1"
|
||||
"fuzzyTerms 1 \"color\" idx"
|
||||
(list "color" "colour"))
|
||||
(list
|
||||
"fuzzy terms dist 2"
|
||||
"fuzzyTerms 2 \"color\" idx"
|
||||
(list "color" "colored" "colour"))
|
||||
(list "fuzzy terms exact" "fuzzyTerms 0 \"color\" idx" (list "color"))
|
||||
(list
|
||||
"fuzzy terms other word"
|
||||
"fuzzyTerms 1 \"flavour\" idx"
|
||||
(list "flavor"))
|
||||
(list
|
||||
"fuzzy docs dist 1"
|
||||
"fuzzyDocs 1 \"color\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"fuzzy docs dist 2"
|
||||
"fuzzyDocs 2 \"color\" idx"
|
||||
(list 1 2 3))
|
||||
(list "fuzzy docs none" "fuzzyDocs 1 \"zzzzz\" idx" (list))
|
||||
(list
|
||||
"fuzzy rank dist 1"
|
||||
"fuzzyRankTfIdf 1 \"color\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"fuzzy rank dist 2"
|
||||
"fuzzyRankTfIdf 2 \"color\" idx"
|
||||
(list 1 2 3))))
|
||||
|
||||
(define
|
||||
fuzzy-results
|
||||
(search-batch fuzzy-setup (map (fn (c) (nth c 1)) fuzzy-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth fuzzy-results i) (nth c 2)))
|
||||
fuzzy-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
66
lib/search/tests/highlight.sx
Normal file
66
lib/search/tests/highlight.sx
Normal file
@@ -0,0 +1,66 @@
|
||||
;; Extension — highlight + snippet over document text.
|
||||
;; Text: "the quick brown fox jumps"
|
||||
|
||||
(define
|
||||
hl-cases
|
||||
(list
|
||||
(list
|
||||
"highlight two terms"
|
||||
"highlight [\"quick\", \"fox\"] \"the quick brown fox jumps\""
|
||||
"the [quick] brown [fox] jumps")
|
||||
(list
|
||||
"highlight none"
|
||||
"highlight [] \"the quick brown fox jumps\""
|
||||
"the quick brown fox jumps")
|
||||
(list
|
||||
"highlight absent term"
|
||||
"highlight [\"zzz\"] \"the quick brown fox jumps\""
|
||||
"the quick brown fox jumps")
|
||||
(list
|
||||
"highlight first token"
|
||||
"highlight [\"the\"] \"the quick brown fox jumps\""
|
||||
"[the] quick brown fox jumps")
|
||||
(list
|
||||
"highlight normalizes text"
|
||||
"highlight [\"quick\"] \"The Quick, brown!\""
|
||||
"the [quick] brown")
|
||||
(list
|
||||
"snippet around middle"
|
||||
"snippet 1 [\"brown\"] \"the quick brown fox jumps\""
|
||||
"quick [brown] fox")
|
||||
(list
|
||||
"snippet at start"
|
||||
"snippet 1 [\"the\"] \"the quick brown fox jumps\""
|
||||
"[the] quick brown")
|
||||
(list
|
||||
"snippet near end"
|
||||
"snippet 1 [\"fox\"] \"the quick brown fox jumps\""
|
||||
"brown [fox] jumps")
|
||||
(list
|
||||
"snippet ctx zero"
|
||||
"snippet 0 [\"brown\"] \"the quick brown fox jumps\""
|
||||
"[brown]")
|
||||
(list
|
||||
"snippet clamps at end"
|
||||
"snippet 2 [\"jumps\"] \"the quick brown fox jumps\""
|
||||
"brown fox [jumps]")
|
||||
(list
|
||||
"snippet no match shows head"
|
||||
"snippet 1 [\"zzz\"] \"the quick brown fox jumps\""
|
||||
"the quick brown")
|
||||
(list
|
||||
"snippet wide window"
|
||||
"snippet 5 [\"brown\"] \"the quick brown fox jumps\""
|
||||
"the quick [brown] fox jumps")))
|
||||
|
||||
(define
|
||||
hl-results
|
||||
(search-batch "" (map (fn (c) (nth c 1)) hl-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth hl-results i) (nth c 2)))
|
||||
hl-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
88
lib/search/tests/index.sx
Normal file
88
lib/search/tests/index.sx
Normal file
@@ -0,0 +1,88 @@
|
||||
;; Phase 1 — tokenize + inverted index.
|
||||
;; All cases run in ONE program evaluation (search-batch) to stay fast under load.
|
||||
;; Scalar results (docFreq) are wrapped as singleton lists so the batch is a list
|
||||
;; of lists.
|
||||
|
||||
(define
|
||||
index-cases
|
||||
(list
|
||||
(list
|
||||
"tokens basic lowercases"
|
||||
"tokens \"The Cat sat\""
|
||||
(list "the" "cat" "sat"))
|
||||
(list
|
||||
"tokens strips punctuation"
|
||||
"tokens \"Hello, World!\""
|
||||
(list "hello" "world"))
|
||||
(list "tokens collapses whitespace" "tokens \" a b \"" (list "a" "b"))
|
||||
(list "tokens empty is empty" "tokens \"\"" (list))
|
||||
(list "tokens keeps digits" "tokens \"abc123 x9\"" (list "abc123" "x9"))
|
||||
(list
|
||||
"positioned attaches ordinals"
|
||||
"positioned \"a b a\""
|
||||
(list
|
||||
(list "a" 0)
|
||||
(list "b" 1)
|
||||
(list "a" 2)))
|
||||
(list
|
||||
"index + lookup single doc"
|
||||
"lookupTerm \"cat\" (indexDoc 1 \"the cat sat\" emptyIndex)"
|
||||
(list (list 1 (list 1))))
|
||||
(list
|
||||
"lookup missing term is empty"
|
||||
"lookupTerm \"dog\" (indexDoc 1 \"the cat sat\" emptyIndex)"
|
||||
(list))
|
||||
(list
|
||||
"lookup records all positions"
|
||||
"lookupTerm \"the\" (indexDoc 1 \"the cat the dog the\" emptyIndex)"
|
||||
(list (list 1 (list 0 2 4))))
|
||||
(list
|
||||
"multi-doc posting list sorted by docid"
|
||||
"lookupTerm \"x\" (indexDoc 1 \"x y\" (indexDoc 2 \"x z\" emptyIndex))"
|
||||
(list
|
||||
(list 1 (list 0))
|
||||
(list 2 (list 0))))
|
||||
(list
|
||||
"index/query case symmetry"
|
||||
"lookupTerm \"cat\" (indexDoc 1 \"CAT Cat cat\" emptyIndex)"
|
||||
(list (list 1 (list 0 1 2))))
|
||||
(list
|
||||
"re-index replaces a doc"
|
||||
"lookupTerm \"a\" (indexDoc 1 \"a a a\" (indexDoc 1 \"a\" emptyIndex))"
|
||||
(list (list 1 (list 0 1 2))))
|
||||
(list
|
||||
"delete removes a doc"
|
||||
"lookupTerm \"cat\" (deleteDoc 1 (indexDoc 1 \"the cat\" emptyIndex))"
|
||||
(list))
|
||||
(list
|
||||
"delete leaves other docs"
|
||||
"lookupTerm \"cat\" (deleteDoc 2 (indexDoc 2 \"big cat\" (indexDoc 1 \"the cat\" emptyIndex)))"
|
||||
(list (list 1 (list 1))))
|
||||
(list
|
||||
"docFreq counts docs"
|
||||
"[docFreq \"cat\" (indexDoc 2 \"a cat\" (indexDoc 1 \"the cat\" emptyIndex))]"
|
||||
(list 2))
|
||||
(list
|
||||
"docFreq zero for missing"
|
||||
"[docFreq \"zzz\" (indexDoc 1 \"a b\" emptyIndex)]"
|
||||
(list 0))
|
||||
(list
|
||||
"allTerms sorted and unique"
|
||||
"allTerms (indexDoc 1 \"banana apple cherry apple\" emptyIndex)"
|
||||
(list "apple" "banana" "cherry"))
|
||||
(list
|
||||
"allTerms merged across docs"
|
||||
"allTerms (indexDoc 2 \"d a\" (indexDoc 1 \"c b\" emptyIndex))"
|
||||
(list "a" "b" "c" "d"))))
|
||||
|
||||
(define
|
||||
index-results
|
||||
(search-batch "" (map (fn (c) (nth c 1)) index-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth index-results i) (nth c 2)))
|
||||
index-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
102
lib/search/tests/integration.sx
Normal file
102
lib/search/tests/integration.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; Phase 4 — federation (merge per-peer indices) + ACL post-filter.
|
||||
;; Peers (global id = peer*1000 + local):
|
||||
;; peer 1: 1 "alpha beta" 2 "alpha gamma" -> 1001 1002
|
||||
;; peer 2: 1 "alpha delta" 2 "beta gamma" -> 2001 2002
|
||||
;; ACL predicates are injected (viewer baked in by the caller), applied post-rank.
|
||||
|
||||
(define
|
||||
fed-setup
|
||||
"p1 = indexDoc 2 \"alpha gamma\" (indexDoc 1 \"alpha beta\" emptyIndex)\np2 = indexDoc 2 \"beta gamma\" (indexDoc 1 \"alpha delta\" emptyIndex)\nfed = fedIndex [(1, p1), (2, p2)]\npermitP1 g = g < 2000\npermitNone g = False\npermitList g = elem g [1002, 2001]\n")
|
||||
|
||||
(define
|
||||
fed-cases
|
||||
(list
|
||||
(list
|
||||
"fed merges all docs"
|
||||
"sort (allDocs fed)"
|
||||
(list 1001 1002 2001 2002))
|
||||
(list
|
||||
"fed docFreq across peers"
|
||||
"[docFreq \"alpha\" fed]"
|
||||
(list 3))
|
||||
(list "fed docFreq beta" "[docFreq \"beta\" fed]" (list 2))
|
||||
(list "fed numDocs" "[numDocs fed]" (list 4))
|
||||
(list
|
||||
"fed term lookup spans peers"
|
||||
"map fst (lookupTerm \"gamma\" fed)"
|
||||
(list 1002 2002))
|
||||
(list
|
||||
"fed preserves positions"
|
||||
"lookupTerm \"beta\" fed"
|
||||
(list
|
||||
(list 1001 (list 1))
|
||||
(list 2002 (list 0))))
|
||||
(list
|
||||
"fed rank alpha tie by gid"
|
||||
"rankTfIdf [\"alpha\"] fed"
|
||||
(list 1001 1002 2001))
|
||||
(list
|
||||
"fed rank beta"
|
||||
"rankTfIdf [\"beta\"] fed"
|
||||
(list 1001 2002))
|
||||
(list
|
||||
"fed boolean and"
|
||||
"searchQuery \"alpha AND beta\" fed"
|
||||
(list 1001))
|
||||
(list
|
||||
"fed boolean or"
|
||||
"searchQuery \"delta OR barks\" fed"
|
||||
(list 2001))
|
||||
(list
|
||||
"fed phrase within peer1"
|
||||
"searchQuery \"\\\"alpha beta\\\"\" fed"
|
||||
(list 1001))
|
||||
(list
|
||||
"fed phrase within peer2"
|
||||
"searchQuery \"\\\"beta gamma\\\"\" fed"
|
||||
(list 2002))
|
||||
(list
|
||||
"fed phrase peer2 alpha delta"
|
||||
"searchQuery \"\\\"alpha delta\\\"\" fed"
|
||||
(list 2001))
|
||||
(list "fed empty peer list" "allDocs (fedIndex [])" (list))
|
||||
(list
|
||||
"fed single relabelled peer"
|
||||
"rankTfIdf [\"alpha\"] (fedIndex [(5, p1)])"
|
||||
(list 5001 5002))
|
||||
(list
|
||||
"acl peer1 only"
|
||||
"aclFilter permitP1 (rankTfIdf [\"alpha\"] fed)"
|
||||
(list 1001 1002))
|
||||
(list
|
||||
"acl allowlist preserves rank order"
|
||||
"aclFilter permitList (rankTfIdf [\"alpha\"] fed)"
|
||||
(list 1002 2001))
|
||||
(list
|
||||
"acl topN after filter"
|
||||
"topNTfIdfAcl 1 permitP1 [\"alpha\"] fed"
|
||||
(list 1001))
|
||||
(list
|
||||
"acl denies all"
|
||||
"aclFilter permitNone (rankTfIdf [\"alpha\"] fed)"
|
||||
(list))
|
||||
(list
|
||||
"acl on bm25"
|
||||
"searchBm25Acl permitP1 1.5 0.75 [\"alpha\"] fed"
|
||||
(list 1001 1002))
|
||||
(list
|
||||
"acl end-to-end tfidf"
|
||||
"searchTfIdfAcl permitP1 [\"alpha\"] fed"
|
||||
(list 1001 1002))))
|
||||
|
||||
(define
|
||||
fed-results
|
||||
(search-batch fed-setup (map (fn (c) (nth c 1)) fed-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth fed-results i) (nth c 2)))
|
||||
fed-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
49
lib/search/tests/near.sx
Normal file
49
lib/search/tests/near.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
;; Extension — proximity (NEAR) search: terms within k positions, unordered.
|
||||
;; Corpus:
|
||||
;; 1 "the quick brown fox" the0 quick1 brown2 fox3
|
||||
;; 2 "quick the lazy fox dog" quick0 the1 lazy2 fox3 dog4
|
||||
;; 3 "fox runs quick" fox0 runs1 quick2
|
||||
|
||||
(define
|
||||
near-setup
|
||||
"idx = indexDoc 3 \"fox runs quick\" (indexDoc 2 \"quick the lazy fox dog\" (indexDoc 1 \"the quick brown fox\" emptyIndex))\n")
|
||||
|
||||
(define
|
||||
near-cases
|
||||
(list
|
||||
(list
|
||||
"near adjacent one doc"
|
||||
"nearDocs 1 \"quick\" \"brown\" idx"
|
||||
(list 1))
|
||||
(list
|
||||
"near adjacent both docs"
|
||||
"nearDocs 1 \"quick\" \"the\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"near within 2"
|
||||
"nearDocs 2 \"quick\" \"fox\" idx"
|
||||
(list 1 3))
|
||||
(list "near too far at k1" "nearDocs 1 \"quick\" \"fox\" idx" (list))
|
||||
(list
|
||||
"near unordered symmetric"
|
||||
"nearDocs 2 \"fox\" \"quick\" idx"
|
||||
(list 1 3))
|
||||
(list "near wider window" "nearDocs 5 \"the\" \"dog\" idx" (list 2))
|
||||
(list "near absent term" "nearDocs 1 \"quick\" \"zzz\" idx" (list))
|
||||
(list "near needs both terms" "nearDocs 3 \"brown\" \"dog\" idx" (list))
|
||||
(list
|
||||
"near same docs only"
|
||||
"nearDocs 3 \"fox\" \"runs\" idx"
|
||||
(list 3))))
|
||||
|
||||
(define
|
||||
near-results
|
||||
(search-batch near-setup (map (fn (c) (nth c 1)) near-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth near-results i) (nth c 2)))
|
||||
near-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
53
lib/search/tests/page.sx
Normal file
53
lib/search/tests/page.sx
Normal file
@@ -0,0 +1,53 @@
|
||||
;; Extension — result pagination (offset / limit) over ranked results.
|
||||
;; Corpus (tf of "x" descending): 1 x4 2 x3 3 x2 4 x1 5 y(no x)
|
||||
;; rankTfIdf ["x"] -> [1,2,3,4]
|
||||
|
||||
(define
|
||||
page-setup
|
||||
"idx = indexDoc 5 \"y\" (indexDoc 4 \"x\" (indexDoc 3 \"x x\" (indexDoc 2 \"x x x\" (indexDoc 1 \"x x x x other\" emptyIndex))))\n")
|
||||
|
||||
(define
|
||||
page-cases
|
||||
(list
|
||||
(list "first page" "pageTfIdf 0 2 [\"x\"] idx" (list 1 2))
|
||||
(list
|
||||
"second page"
|
||||
"pageTfIdf 2 2 [\"x\"] idx"
|
||||
(list 3 4))
|
||||
(list
|
||||
"sliding window"
|
||||
"pageTfIdf 1 2 [\"x\"] idx"
|
||||
(list 2 3))
|
||||
(list
|
||||
"limit exceeds remaining"
|
||||
"pageTfIdf 3 10 [\"x\"] idx"
|
||||
(list 4))
|
||||
(list "offset past end" "pageTfIdf 4 2 [\"x\"] idx" (list))
|
||||
(list "limit zero" "pageTfIdf 0 0 [\"x\"] idx" (list))
|
||||
(list
|
||||
"whole result"
|
||||
"pageTfIdf 0 10 [\"x\"] idx"
|
||||
(list 1 2 3 4))
|
||||
(list
|
||||
"paginate raw list"
|
||||
"paginate 1 2 [10, 20, 30, 40]"
|
||||
(list 20 30))
|
||||
(list "paginate raw past end" "paginate 9 2 [10, 20]" (list))
|
||||
(list
|
||||
"bm25 page window size"
|
||||
"[length (pageBm25 0 2 1.5 0.75 [\"x\"] idx)]"
|
||||
(list 2))
|
||||
(list "result count" "[resultCount [\"x\"] idx]" (list 4))
|
||||
(list "result count zero" "[resultCount [\"zzz\"] idx]" (list 0))))
|
||||
|
||||
(define
|
||||
page-results
|
||||
(search-batch page-setup (map (fn (c) (nth c 1)) page-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth page-results i) (nth c 2)))
|
||||
page-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
139
lib/search/tests/parse.sx
Normal file
139
lib/search/tests/parse.sx
Normal file
@@ -0,0 +1,139 @@
|
||||
;; Phase 2 — query parser (parseQuery / searchQuery).
|
||||
;; AST cases assert showQ (parseQuery s); search cases assert searchQuery s idx
|
||||
;; against the standard corpus. Each group runs in one batched program eval.
|
||||
;; doc 1 "the quick brown dog" doc 2 "a quick brown fox" doc 3 "the dog barks loudly"
|
||||
|
||||
(define
|
||||
parse-corpus
|
||||
"idx = indexDoc 3 \"the dog barks loudly\" (indexDoc 2 \"a quick brown fox\" (indexDoc 1 \"the quick brown dog\" emptyIndex))\n")
|
||||
|
||||
(define
|
||||
ast-cases
|
||||
(list
|
||||
(list "single term" "showQ (parseQuery \"cat\")" "T:cat")
|
||||
(list "term normalized" "showQ (parseQuery \"CAT\")" "T:cat")
|
||||
(list "explicit and" "showQ (parseQuery \"cat AND dog\")" "(T:cat & T:dog)")
|
||||
(list
|
||||
"lowercase and keyword"
|
||||
"showQ (parseQuery \"cat and dog\")"
|
||||
"(T:cat & T:dog)")
|
||||
(list "implicit and" "showQ (parseQuery \"cat dog\")" "(T:cat & T:dog)")
|
||||
(list "or" "showQ (parseQuery \"cat OR dog\")" "(T:cat | T:dog)")
|
||||
(list "not" "showQ (parseQuery \"NOT cat\")" "!T:cat")
|
||||
(list
|
||||
"and binds tighter than or"
|
||||
"showQ (parseQuery \"cat AND dog OR bird\")"
|
||||
"((T:cat & T:dog) | T:bird)")
|
||||
(list
|
||||
"or then and"
|
||||
"showQ (parseQuery \"cat OR dog AND bird\")"
|
||||
"(T:cat | (T:dog & T:bird))")
|
||||
(list
|
||||
"parens override precedence"
|
||||
"showQ (parseQuery \"(cat OR dog) AND bird\")"
|
||||
"((T:cat | T:dog) & T:bird)")
|
||||
(list
|
||||
"and with not"
|
||||
"showQ (parseQuery \"cat AND NOT dog\")"
|
||||
"(T:cat & !T:dog)")
|
||||
(list
|
||||
"two-word phrase"
|
||||
"showQ (parseQuery \"\\\"quick brown\\\"\")"
|
||||
"P:quick-brown")
|
||||
(list
|
||||
"three-word phrase"
|
||||
"showQ (parseQuery \"\\\"quick brown fox\\\"\")"
|
||||
"P:quick-brown-fox")
|
||||
(list
|
||||
"and left-assoc"
|
||||
"showQ (parseQuery \"a AND b AND c\")"
|
||||
"((T:a & T:b) & T:c)")
|
||||
(list
|
||||
"or left-assoc"
|
||||
"showQ (parseQuery \"a OR b OR c\")"
|
||||
"((T:a | T:b) | T:c)")
|
||||
(list
|
||||
"punctuation stripped"
|
||||
"showQ (parseQuery \"cat, dog!\")"
|
||||
"(T:cat & T:dog)")))
|
||||
|
||||
(define
|
||||
search-cases
|
||||
(list
|
||||
(list "term" "searchQuery \"quick\" idx" (list 1 2))
|
||||
(list
|
||||
"term normalized"
|
||||
"searchQuery \"QUICK\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"explicit and"
|
||||
"searchQuery \"quick AND brown\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"implicit and"
|
||||
"searchQuery \"quick brown\" idx"
|
||||
(list 1 2))
|
||||
(list "and disjoint" "searchQuery \"the AND fox\" idx" (list))
|
||||
(list "or" "searchQuery \"fox OR barks\" idx" (list 2 3))
|
||||
(list "not" "searchQuery \"NOT the\" idx" (list 2))
|
||||
(list "and not" "searchQuery \"quick AND NOT the\" idx" (list 2))
|
||||
(list
|
||||
"precedence and-or"
|
||||
"searchQuery \"the AND dog OR fox\" idx"
|
||||
(list 1 2 3))
|
||||
(list
|
||||
"precedence or-and"
|
||||
"searchQuery \"fox OR the AND dog\" idx"
|
||||
(list 1 2 3))
|
||||
(list
|
||||
"parens"
|
||||
"searchQuery \"the AND (dog OR fox)\" idx"
|
||||
(list 1 3))
|
||||
(list
|
||||
"phrase"
|
||||
"searchQuery \"\\\"quick brown\\\"\" idx"
|
||||
(list 1 2))
|
||||
(list
|
||||
"phrase one doc"
|
||||
"searchQuery \"\\\"brown dog\\\"\" idx"
|
||||
(list 1))
|
||||
(list
|
||||
"phrase and term"
|
||||
"searchQuery \"\\\"quick brown\\\" AND dog\" idx"
|
||||
(list 1))
|
||||
(list
|
||||
"not phrase"
|
||||
"searchQuery \"NOT \\\"quick brown\\\"\" idx"
|
||||
(list 3))
|
||||
(list
|
||||
"implicit and terms"
|
||||
"searchQuery \"dog barks\" idx"
|
||||
(list 3))))
|
||||
|
||||
(define
|
||||
ast-results
|
||||
(search-batch "" (map (fn (c) (nth c 1)) ast-cases)))
|
||||
(define
|
||||
search-results
|
||||
(search-batch
|
||||
parse-corpus
|
||||
(map (fn (c) (nth c 1)) search-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test
|
||||
(str "ast: " (nth c 0))
|
||||
(nth ast-results i)
|
||||
(nth c 2)))
|
||||
ast-cases)
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test
|
||||
(str "search: " (nth c 0))
|
||||
(nth search-results i)
|
||||
(nth c 2)))
|
||||
search-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
63
lib/search/tests/prefix.sx
Normal file
63
lib/search/tests/prefix.sx
Normal file
@@ -0,0 +1,63 @@
|
||||
;; Extension — prefix / wildcard queries.
|
||||
;; Corpus: 1 "alpha alpine" 2 "beta apple" 3 "banana alpha"
|
||||
;; allTerms sorted: alpha alpine apple banana beta
|
||||
|
||||
(define
|
||||
prefix-setup
|
||||
"idx = indexDoc 3 \"banana alpha\" (indexDoc 2 \"beta apple\" (indexDoc 1 \"alpha alpine\" emptyIndex))\n")
|
||||
|
||||
(define
|
||||
prefix-cases
|
||||
(list
|
||||
(list
|
||||
"prefix terms two matches"
|
||||
"prefixTerms \"al\" idx"
|
||||
(list "alpha" "alpine"))
|
||||
(list
|
||||
"prefix terms narrower"
|
||||
"prefixTerms \"alp\" idx"
|
||||
(list "alpha" "alpine"))
|
||||
(list
|
||||
"prefix terms wide"
|
||||
"prefixTerms \"a\" idx"
|
||||
(list "alpha" "alpine" "apple"))
|
||||
(list "prefix terms single" "prefixTerms \"ban\" idx" (list "banana"))
|
||||
(list "prefix terms exact term" "prefixTerms \"beta\" idx" (list "beta"))
|
||||
(list "prefix terms none" "prefixTerms \"z\" idx" (list))
|
||||
(list
|
||||
"prefix docs union"
|
||||
"prefixDocs \"al\" idx"
|
||||
(list 1 3))
|
||||
(list "prefix docs single term" "prefixDocs \"ban\" idx" (list 3))
|
||||
(list
|
||||
"prefix docs wide"
|
||||
"prefixDocs \"a\" idx"
|
||||
(list 1 2 3))
|
||||
(list "prefix docs none" "prefixDocs \"z\" idx" (list))
|
||||
(list
|
||||
"prefix docs exact"
|
||||
"prefixDocs \"alpha\" idx"
|
||||
(list 1 3))
|
||||
(list
|
||||
"prefix rank ranks by matched terms"
|
||||
"prefixRankTfIdf \"al\" idx"
|
||||
(list 1 3))
|
||||
(list
|
||||
"prefix rank single doc"
|
||||
"prefixRankTfIdf \"ban\" idx"
|
||||
(list 3))
|
||||
(list "prefix rank empty" "prefixRankTfIdf \"z\" idx" (list))))
|
||||
|
||||
(define
|
||||
prefix-results
|
||||
(search-batch
|
||||
prefix-setup
|
||||
(map (fn (c) (nth c 1)) prefix-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth prefix-results i) (nth c 2)))
|
||||
prefix-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
90
lib/search/tests/rank.sx
Normal file
90
lib/search/tests/rank.sx
Normal file
@@ -0,0 +1,90 @@
|
||||
;; Phase 3 — ranking (TF-IDF, BM25, top-N). Deterministic: ties broken by DocId.
|
||||
;; Corpora:
|
||||
;; idx1: 1 "alpha alpha alpha gamma" 2 "alpha" 3 "beta"
|
||||
;; idx2: 1 "cat" 2 "cat cat dog elephant frog grape" 3 "zzz"
|
||||
;; idx3: 1 "kite" 2 "kite" (identical docs -> tiebreak)
|
||||
|
||||
(define
|
||||
rank-setup
|
||||
"idx1 = indexDoc 3 \"beta\" (indexDoc 2 \"alpha\" (indexDoc 1 \"alpha alpha alpha gamma\" emptyIndex))\nidx2 = indexDoc 3 \"zzz\" (indexDoc 2 \"cat cat dog elephant frog grape\" (indexDoc 1 \"cat\" emptyIndex))\nidx3 = indexDoc 2 \"kite\" (indexDoc 1 \"kite\" emptyIndex)\n")
|
||||
|
||||
(define
|
||||
rank-cases
|
||||
(list
|
||||
(list
|
||||
"tfidf tf ordering"
|
||||
"rankTfIdf [\"alpha\"] idx1"
|
||||
(list 1 2))
|
||||
(list
|
||||
"tfidf rare term boosts"
|
||||
"rankTfIdf [\"alpha\", \"beta\"] idx1"
|
||||
(list 1 3 2))
|
||||
(list
|
||||
"tfidf single-doc term"
|
||||
"rankTfIdf [\"gamma\"] idx1"
|
||||
(list 1))
|
||||
(list "tfidf absent term empty" "rankTfIdf [\"nope\"] idx1" (list))
|
||||
(list "tfidf empty query empty" "rankTfIdf [] idx1" (list))
|
||||
(list
|
||||
"tfidf candidate union tie by docid"
|
||||
"rankTfIdf [\"beta\", \"gamma\"] idx1"
|
||||
(list 1 3))
|
||||
(list
|
||||
"tfidf tf ordering idx2"
|
||||
"rankTfIdf [\"cat\"] idx2"
|
||||
(list 2 1))
|
||||
(list "topN tfidf 1" "topNTfIdf 1 [\"alpha\"] idx1" (list 1))
|
||||
(list
|
||||
"topN tfidf 2"
|
||||
"topNTfIdf 2 [\"alpha\", \"beta\"] idx1"
|
||||
(list 1 3))
|
||||
(list
|
||||
"topN exceeds results"
|
||||
"topNTfIdf 10 [\"gamma\"] idx1"
|
||||
(list 1))
|
||||
(list "topN zero" "topNTfIdf 0 [\"alpha\"] idx1" (list))
|
||||
(list
|
||||
"bm25 tf+length flips tfidf"
|
||||
"rankBm25 1.5 0.75 [\"cat\"] idx2"
|
||||
(list 1 2))
|
||||
(list
|
||||
"bm25 b=0 ignores length"
|
||||
"rankBm25 1.5 0.0 [\"cat\"] idx2"
|
||||
(list 2 1))
|
||||
(list
|
||||
"bm25 alpha idx1"
|
||||
"rankBm25 1.5 0.75 [\"alpha\"] idx1"
|
||||
(list 1 2))
|
||||
(list "bm25 absent empty" "rankBm25 1.5 0.75 [\"nope\"] idx1" (list))
|
||||
(list
|
||||
"bm25 single-doc term"
|
||||
"rankBm25 1.5 0.75 [\"gamma\"] idx1"
|
||||
(list 1))
|
||||
(list "bm25 topN 1" "topNBm25 1 1.5 0.75 [\"cat\"] idx2" (list 1))
|
||||
(list
|
||||
"bm25 same candidate set"
|
||||
"sort (rankBm25 1.5 0.75 [\"alpha\", \"beta\"] idx1)"
|
||||
(list 1 2 3))
|
||||
(list
|
||||
"tfidf stable tiebreak"
|
||||
"rankTfIdf [\"kite\"] idx3"
|
||||
(list 1 2))
|
||||
(list
|
||||
"bm25 stable tiebreak"
|
||||
"rankBm25 1.5 0.75 [\"kite\"] idx3"
|
||||
(list 1 2))
|
||||
(list "numDocs" "[numDocs idx1]" (list 3))
|
||||
(list "docLen counts tokens" "[docLen 1 idx1]" (list 4))
|
||||
(list "docFreq via index" "[docFreq \"alpha\" idx1]" (list 2))))
|
||||
|
||||
(define
|
||||
rank-results
|
||||
(search-batch rank-setup (map (fn (c) (nth c 1)) rank-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth rank-results i) (nth c 2)))
|
||||
rank-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
67
lib/search/tests/rankq.sx
Normal file
67
lib/search/tests/rankq.sx
Normal file
@@ -0,0 +1,67 @@
|
||||
;; Extension — boolean-filtered ranked search (filter then rank by relevance).
|
||||
;; Corpus:
|
||||
;; 1 "apple apple banana" apple2 banana1
|
||||
;; 2 "apple cherry" apple1 cherry1
|
||||
;; 3 "banana cherry" banana1 cherry1
|
||||
;; 4 "apple banana cherry" apple1 banana1 cherry1
|
||||
|
||||
(define
|
||||
rankq-setup
|
||||
"idx = indexDoc 4 \"apple banana cherry\" (indexDoc 3 \"banana cherry\" (indexDoc 2 \"apple cherry\" (indexDoc 1 \"apple apple banana\" emptyIndex)))\n")
|
||||
|
||||
(define
|
||||
rankq-cases
|
||||
(list
|
||||
(list
|
||||
"queryTerms and"
|
||||
"queryTerms (parseQuery \"apple AND banana\")"
|
||||
(list "apple" "banana"))
|
||||
(list
|
||||
"queryTerms or not"
|
||||
"queryTerms (parseQuery \"a OR NOT b\")"
|
||||
(list "a" "b"))
|
||||
(list
|
||||
"queryTerms phrase"
|
||||
"queryTerms (parseQuery \"\\\"x y\\\" OR z\")"
|
||||
(list "x" "y" "z"))
|
||||
(list
|
||||
"and filter ranked by tf"
|
||||
"searchRankTfIdf \"apple AND banana\" idx"
|
||||
(list 1 4))
|
||||
(list
|
||||
"single term ranked tie"
|
||||
"searchRankTfIdf \"cherry\" idx"
|
||||
(list 2 3 4))
|
||||
(list
|
||||
"or filter ranked"
|
||||
"searchRankTfIdf \"apple OR banana\" idx"
|
||||
(list 1 4 2 3))
|
||||
(list
|
||||
"and-not narrows then ranks"
|
||||
"searchRankTfIdf \"apple AND NOT banana\" idx"
|
||||
(list 2))
|
||||
(list
|
||||
"phrase filter ranked"
|
||||
"searchRankTfIdf \"\\\"apple banana\\\"\" idx"
|
||||
(list 1 4))
|
||||
(list "no matches" "searchRankTfIdf \"zzz\" idx" (list))
|
||||
(list
|
||||
"bm25 boolean ranked subset"
|
||||
"sort (searchRankBm25 1.5 0.75 \"apple OR banana\" idx)"
|
||||
(list 1 2 3 4))
|
||||
(list
|
||||
"bm25 and filter"
|
||||
"searchRankBm25 1.5 0.75 \"apple AND NOT banana\" idx"
|
||||
(list 2))))
|
||||
|
||||
(define
|
||||
rankq-results
|
||||
(search-batch rankq-setup (map (fn (c) (nth c 1)) rankq-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth rankq-results i) (nth c 2)))
|
||||
rankq-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
47
lib/search/tests/stem.sx
Normal file
47
lib/search/tests/stem.sx
Normal file
@@ -0,0 +1,47 @@
|
||||
;; Extension — stemming (suffix stripping). Scalar string results wrapped in [].
|
||||
|
||||
(define
|
||||
stem-cases
|
||||
(list
|
||||
(list "stem plural s" "[stem \"cats\"]" (list "cat"))
|
||||
(list "stem plural dogs" "[stem \"dogs\"]" (list "dog"))
|
||||
(list "stem keeps ss" "[stem \"pass\"]" (list "pass"))
|
||||
(list "stem short s unchanged" "[stem \"is\"]" (list "is"))
|
||||
(list "stem es boxes" "[stem \"boxes\"]" (list "box"))
|
||||
(list "stem es wishes" "[stem \"wishes\"]" (list "wish"))
|
||||
(list "stem ies cities" "[stem \"cities\"]" (list "city"))
|
||||
(list "stem ies parties" "[stem \"parties\"]" (list "party"))
|
||||
(list "stem ing jumping" "[stem \"jumping\"]" (list "jump"))
|
||||
(list "stem ing running literal" "[stem \"running\"]" (list "runn"))
|
||||
(list "stem ed jumped" "[stem \"jumped\"]" (list "jump"))
|
||||
(list "stem ed wanted" "[stem \"wanted\"]" (list "want"))
|
||||
(list "stem short ed unchanged" "[stem \"red\"]" (list "red"))
|
||||
(list "stem no suffix" "[stem \"cat\"]" (list "cat"))
|
||||
(list
|
||||
"stemText normalizes and stems"
|
||||
"[stemText \"Cats Running!\"]"
|
||||
(list "cat runn"))
|
||||
(list
|
||||
"stemTokens list"
|
||||
"stemTokens \"boxes and cats\""
|
||||
(list "box" "and" "cat"))
|
||||
(list
|
||||
"indexStemmed unifies plural"
|
||||
"map fst (lookupTerm \"cat\" (indexStemmed 2 \"a cat\" (indexStemmed 1 \"the cats\" emptyIndex)))"
|
||||
(list 1 2))
|
||||
(list
|
||||
"indexStemmed stem query"
|
||||
"map fst (lookupTerm (stem \"boxes\") (indexStemmed 1 \"many boxes\" emptyIndex))"
|
||||
(list 1))))
|
||||
|
||||
(define
|
||||
stem-results
|
||||
(search-batch "" (map (fn (c) (nth c 1)) stem-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth stem-results i) (nth c 2)))
|
||||
stem-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
53
lib/search/tests/syn.sx
Normal file
53
lib/search/tests/syn.sx
Normal file
@@ -0,0 +1,53 @@
|
||||
;; Extension — synonym / query expansion.
|
||||
;; synmap: car -> automobile, vehicle ; big -> large
|
||||
;; Corpus: 1 "fast car" 2 "shiny automobile" 3 "big truck" 4 "large house" 5 "vehicle review"
|
||||
|
||||
(define
|
||||
syn-setup
|
||||
"synmap = [(\"car\", [\"automobile\", \"vehicle\"]), (\"big\", [\"large\"])]\nidx = indexDoc 5 \"vehicle review\" (indexDoc 4 \"large house\" (indexDoc 3 \"big truck\" (indexDoc 2 \"shiny automobile\" (indexDoc 1 \"fast car\" emptyIndex))))\n")
|
||||
|
||||
(define
|
||||
syn-cases
|
||||
(list
|
||||
(list
|
||||
"expand term with synonyms"
|
||||
"expandTerm synmap \"car\""
|
||||
(list "car" "automobile" "vehicle"))
|
||||
(list
|
||||
"expand single synonym"
|
||||
"expandTerm synmap \"big\""
|
||||
(list "big" "large"))
|
||||
(list "expand unknown term" "expandTerm synmap \"banana\"" (list "banana"))
|
||||
(list
|
||||
"syn docs union"
|
||||
"synDocs synmap \"car\" idx"
|
||||
(list 1 2 5))
|
||||
(list
|
||||
"syn docs single synonym"
|
||||
"synDocs synmap \"big\" idx"
|
||||
(list 3 4))
|
||||
(list
|
||||
"syn docs no synonyms"
|
||||
"synDocs synmap \"house\" idx"
|
||||
(list 4))
|
||||
(list "syn docs absent" "synDocs synmap \"plane\" idx" (list))
|
||||
(list
|
||||
"syn rank expanded"
|
||||
"synRankTfIdf synmap \"car\" idx"
|
||||
(list 1 2 5))
|
||||
(list
|
||||
"syn rank single"
|
||||
"synRankTfIdf synmap \"big\" idx"
|
||||
(list 3 4))))
|
||||
|
||||
(define
|
||||
syn-results
|
||||
(search-batch syn-setup (map (fn (c) (nth c 1)) syn-cases)))
|
||||
|
||||
(map-indexed
|
||||
(fn
|
||||
(i c)
|
||||
(hk-test (nth c 0) (nth syn-results i) (nth c 2)))
|
||||
syn-cases)
|
||||
|
||||
{:fail hk-test-fail :pass hk-test-pass :fails hk-test-fails}
|
||||
8
lib/search/tokenize.sx
Normal file
8
lib/search/tokenize.sx
Normal file
@@ -0,0 +1,8 @@
|
||||
;; search tokenizer — Haskell source fragment.
|
||||
;; normalize (lowercase + strip punctuation), split on whitespace, attach positions.
|
||||
;; tokens :: String -> [String]
|
||||
;; positioned :: String -> [(String, Int)] -- 0-based ordinal positions
|
||||
|
||||
(define
|
||||
search/tokenize-src
|
||||
"lowerChar c = chr (toLower (ord c))\nnormChar c = if isAlphaNum c then lowerChar c else ' '\nisBlankCh c = c == ' '\ndropBlanks [] = []\ndropBlanks (c:cs) = if isBlankCh c then dropBlanks cs else c:cs\ntakeWord [] = []\ntakeWord (c:cs) = if isBlankCh c then [] else c : takeWord cs\nafterWord [] = []\nafterWord (c:cs) = if isBlankCh c then c:cs else afterWord cs\nsplitWords s = let s2 = dropBlanks s in if null s2 then [] else takeWord s2 : splitWords (afterWord s2)\nappendStr a b = a ++ b\njoinChars cs = foldr appendStr \"\" cs\ntokens s = map joinChars (splitWords (map normChar s))\nposFrom i [] = []\nposFrom i (x:xs) = (x, i) : posFrom (i + 1) xs\npositioned s = posFrom 0 (tokens s)\n")
|
||||
102
plans/acl-on-sx.md
Normal file
102
plans/acl-on-sx.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# acl-on-sx: Access Control on Datalog
|
||||
|
||||
rose-ash needs fine-grained, explainable, federation-aware access control. Subjects
|
||||
(users, groups, roles, services) × actions (read, edit, comment, moderate, federate)
|
||||
× resources (pages, posts, threads, peers). Decisions must come with a trace — not just
|
||||
permit/deny, but **why**.
|
||||
|
||||
Datalog's bottom-up rule engine produces transparent permit/deny chains: the proof tree
|
||||
is the audit trail. Inheritance over groups + resource hierarchies is recursive Datalog
|
||||
in one rule. Federation extends naturally — fed-sx replicates ACL facts, peers reason
|
||||
over the union.
|
||||
|
||||
End-state: a Datalog-on-SX layer specifically for ACL, with explanation API, audit log,
|
||||
and federation extension. Reuses `lib/datalog/` evaluator and term model where possible.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/acl/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/acl/**` and `plans/acl-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, `lib/datalog/**`, or other `lib/<lang>/`. You may **import**
|
||||
from `lib/datalog/` (its public API in `lib/datalog/datalog.sx`); do **not** copy or
|
||||
modify Datalog code.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** thin layer on top of `lib/datalog/`. Define schema, surface API,
|
||||
audit + federation hooks. The rule engine itself is Datalog's.
|
||||
- **Watch for shared patterns** going into `lib/guest/` — both acl-sx and mod-sx need
|
||||
rule-engine plumbing. If you find shared shape, flag it for extraction (don't
|
||||
extract yet — wait for mod-sx to start).
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
ACL declarations (SX) User query
|
||||
│ │
|
||||
▼ ▼
|
||||
lib/acl/schema.sx lib/acl/api.sx
|
||||
— subject sorts — (acl/permit? subj act res)
|
||||
— resource sorts — (acl/explain subj act res)
|
||||
— action sorts — (acl/audit subj act res :allowed?)
|
||||
— fact schema │
|
||||
│ ▼
|
||||
▼ lib/acl/engine.sx
|
||||
lib/acl/facts.sx — builds Datalog query
|
||||
— actor(id, kind) — invokes lib/datalog/
|
||||
— resource(id, kind) — extracts proof tree
|
||||
— member_of(actor, group) │
|
||||
— child_of(res, parent) ▼
|
||||
— grant(actor, act, res) lib/acl/audit.sx
|
||||
— deny (actor, act, res) — persistent decision log
|
||||
— query API
|
||||
```
|
||||
|
||||
## Phase 1 — Direct grants
|
||||
|
||||
- [ ] `lib/acl/schema.sx` — sorts: subject {user, group, role, service}, action,
|
||||
resource {page, post, thread, peer}
|
||||
- [ ] `lib/acl/facts.sx` — `actor`, `resource`, `grant`, `deny` predicates as Datalog
|
||||
EDB
|
||||
- [ ] `lib/acl/engine.sx` — `(permit? subj act res db)` reduces to Datalog query
|
||||
- [ ] `lib/acl/api.sx` — public `(acl/permit? ...)` taking implicit current db
|
||||
- [ ] `lib/acl/tests/direct.sx` — 15+ cases: direct grant, missing grant, explicit deny
|
||||
- [ ] `lib/acl/scoreboard.{json,md}` baseline
|
||||
- [ ] `lib/acl/conformance.sh` runs the suite
|
||||
|
||||
## Phase 2 — Inheritance
|
||||
|
||||
- [ ] `member_of(actor, group)` chain — group grants apply to members (transitive)
|
||||
- [ ] `child_of(res, parent)` chain — parent grants apply to children (transitive)
|
||||
- [ ] role expansion — role contains list of (action, resource) tuples
|
||||
- [ ] deny-overrides — explicit deny wins over inherited allow
|
||||
- [ ] `lib/acl/tests/inherit.sx` — 25+ cases: nested groups, deep resource trees,
|
||||
conflict resolution, deny precedence
|
||||
- [ ] document the deny-overrides choice in plan
|
||||
|
||||
## Phase 3 — Explanation + audit
|
||||
|
||||
- [ ] `(acl/explain subj act res)` → `{:allowed? T :proof <tree>}`
|
||||
- [ ] proof tree extracts from Datalog's derivation
|
||||
- [ ] `lib/acl/audit.sx` — append-only decision log (in-memory + serializer for disk)
|
||||
- [ ] `(acl/audit-tail n)` for recent decisions
|
||||
- [ ] `lib/acl/tests/explain.sx` — proof correctness, audit completeness
|
||||
|
||||
## Phase 4 — Federation
|
||||
|
||||
- [ ] peer trust facts — `peer(addr, kind)`, `trust(peer, level)`
|
||||
- [ ] delegated grants — `delegate(peer, actor, action, resource)`
|
||||
- [ ] cross-instance permit chain — query asks local + queries trusted peers via fed-sx
|
||||
- [ ] revocation propagation — fact retraction across federation
|
||||
- [ ] `lib/acl/tests/fed.sx` — federated grant chains (mock fed-sx transport in tests)
|
||||
|
||||
## Progress log
|
||||
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
|
||||
(loop fills this in)
|
||||
93
plans/agent-briefings/acl-loop.md
Normal file
93
plans/agent-briefings/acl-loop.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# acl-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/acl-on-sx.md` forever. **First subsystem loop after fed-sx.**
|
||||
Sits on `lib/datalog/` — rule engine reused, schema/api/audit/federation added on
|
||||
top. The deliverable isn't "implement Datalog ACL"; it's *also* to surface shared
|
||||
rule-engine plumbing into `lib/guest/` (the mod-sx loop will be the second consumer,
|
||||
validating extraction).
|
||||
|
||||
```
|
||||
description: acl-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/acl-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/acl`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/acl-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/acl/` — pick up from the most advanced file.
|
||||
3. If `lib/acl/tests/*.sx` exist, run them via `bash lib/acl/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/acl/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/datalog/datalog.sx` public API once — that's your substrate.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/acl-on-sx.md`:
|
||||
|
||||
- **Phase 1** — direct grants. Schema, EDB facts, engine, api, 15+ tests
|
||||
- **Phase 2** — inheritance (member_of, child_of, role expansion, deny-overrides)
|
||||
- **Phase 3** — explanation + audit (proof tree, audit log)
|
||||
- **Phase 4** — federation (peer trust, delegation, cross-instance permit chain)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/acl/**` and `plans/acl-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/datalog/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Worktree:** commit, then push to `origin/loops/acl`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`acl: child_of resource inheritance + 8 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
- **Watch for shared infrastructure** with future mod-sx (Prolog moderation). If you
|
||||
build a generic rule-engine adapter, note it in Progress log so the eventual
|
||||
`lib/guest/rules/` extraction has both consumers identified.
|
||||
|
||||
## ACL-specific gotchas
|
||||
|
||||
- **Datalog is bottom-up.** No goal-directed search. Don't reach for cut or
|
||||
backtracking — that's mod-sx's job. Your decisions emerge from fixpoint.
|
||||
- **Deny-overrides** is the policy: if both an allow and deny rule fire, deny wins.
|
||||
Encode this via stratified negation; document the choice clearly in plan.
|
||||
- **Inheritance termination:** recursive rules with `member_of` chains must
|
||||
terminate. Datalog guarantees this absent function symbols — don't introduce them
|
||||
in your schema.
|
||||
- **Proof tree shape:** Datalog's derivation graph is a DAG, not a tree, when the
|
||||
same fact is derived multiple ways. For audit, pick one canonical derivation
|
||||
(shortest, or first); document choice.
|
||||
- **Federation isn't transitive trust.** A peer's `delegate(...)` fact only applies
|
||||
if local `trust(peer, level)` covers the action class. Re-check trust on every
|
||||
query, not at fact-ingestion time.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/acl-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
99
plans/agent-briefings/feed-loop.md
Normal file
99
plans/agent-briefings/feed-loop.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# feed-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/feed-on-sx.md` forever. **Activity feeds on APL** — timelines,
|
||||
notifications, fanout, ranking, all as APL array math on activity vectors. Densest
|
||||
possible expression of feed composition. Sits on `lib/apl/` (450+/450+ tests
|
||||
already); adds a feed-shaped vocabulary on top.
|
||||
|
||||
```
|
||||
description: feed-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/feed-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/feed`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/feed-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/feed/` — pick up from the most advanced file.
|
||||
3. If `lib/feed/tests/*.sx` exist, run them via `bash lib/feed/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/feed/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/apl/apl.sx` public API once — that's your substrate. Familiarize
|
||||
yourself with at least: `⍳ ⍴ / ⌽ ↑ ↓ ⌷ ∊ ∘.× /\ ⍋` (you will use all of these).
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/feed-on-sx.md`:
|
||||
|
||||
- **Phase 1** — stream model + basic ops (record schema, filter, sort, take)
|
||||
- **Phase 2** — **THE SHOWCASE**: fanout via outer product. activities `∘.×`
|
||||
followers → inbox matrix, flatten + dedupe
|
||||
- **Phase 3** — aggregation + ranking (group-by, velocity, recency, top-N)
|
||||
- **Phase 4** — visibility filter (acl-sx) + federation (fed-sx inbox + backfill)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/feed/**` and `plans/feed-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/apl/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes. APL glyphs land
|
||||
directly in source.
|
||||
- **Worktree:** commit, then push to `origin/loops/feed`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`feed: outer-product fanout + dedupe by (actor,verb,object) + 9 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
|
||||
## feed-specific gotchas
|
||||
|
||||
- **Activities are heterogeneous.** Different verbs carry different shapes
|
||||
(`:object` might be page-id, post-id, user-id). Don't over-normalize — keep
|
||||
`:tags` as a flexible bag. APL operations over heterogeneous records work fine
|
||||
via dict lookups; only the indexed fields need uniform shape.
|
||||
- **Fanout produces matrices fast.** N activities × M followers → NM items. Apply
|
||||
filter/dedupe early, not after materialization. Use guard predicates *inside*
|
||||
the outer product where possible (compose with `∘.{a v ⊢ ...}`).
|
||||
- **Dedupe key isn't always `(actor,verb,object)`.** For "alice liked X" and "bob
|
||||
liked X" the dedupe key is `(verb,object)` (collapse the actors into a list).
|
||||
For "alice posted X" each `:actor` is distinct. Each verb may want its own
|
||||
dedupe rule; codify these in `lib/feed/dedupe.sx`.
|
||||
- **Recency decay matters more than score precision.** Use a simple half-life decay
|
||||
(e.g. score × 0.5^(age/window)) rather than a clever curve. Calibrate the
|
||||
window via tests, not theory.
|
||||
- **Ranking should be deterministic on ties.** Always include a tiebreaker (id, or
|
||||
hash). Otherwise tests will flake.
|
||||
- **The ACL filter is per-viewer.** A timeline is computed *for* a user; the same
|
||||
candidate stream produces different timelines for different viewers. Don't
|
||||
cache pre-ACL timelines.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/feed-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
98
plans/agent-briefings/flow-loop.md
Normal file
98
plans/agent-briefings/flow-loop.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# flow-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/flow-on-sx.md` forever. **Durable workflows on Scheme** — the
|
||||
call/cc + delimited continuation showcase that justifies pulling R7RS into
|
||||
production. art-dag's natural successor: DAG-of-tasks with pause/resume across
|
||||
process restarts. fed-sx extension turns local flows into distributed ones.
|
||||
|
||||
```
|
||||
description: flow-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/flow-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/flow`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/flow-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/flow/` — pick up from the most advanced file.
|
||||
3. If `lib/flow/tests/*.sx` exist, run them via `bash lib/flow/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/flow/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/scheme/scheme.sx` public API once — that's your substrate.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/flow-on-sx.md`:
|
||||
|
||||
- **Phase 1** — declarative DAG: `defflow`, `sequence`, `parallel`, sync runtime,
|
||||
basic api
|
||||
- **Phase 2** — control flow + error handling: `cond`, `retry`, `timeout`,
|
||||
`try-catch`
|
||||
- **Phase 3** — **THE SHOWCASE**: `suspend`/`resume` via `call/cc`, persistent
|
||||
store, crash recovery
|
||||
- **Phase 4** — distributed nodes via fed-sx (remote-node, handoff, replication)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/flow/**` and `plans/flow-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/scheme/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Worktree:** commit, then push to `origin/loops/flow`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`flow: retry combinator with exponential backoff + 6 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
|
||||
## flow-specific gotchas
|
||||
|
||||
- **Continuations must be re-entrant.** Phase 3's `suspend` captures a continuation
|
||||
that may be re-entered after a process restart. That means: no captured file
|
||||
descriptors, no captured sockets, no captured live runtime references that won't
|
||||
survive serialization. State referenced by the continuation must be plain SX data
|
||||
or live in the flow store.
|
||||
- **call/cc, not call-with-escape-continuation.** R7RS distinguishes. Use the full
|
||||
call/cc for resume; escape-only continuations cannot be re-entered. Read
|
||||
`lib/scheme/r7rs.md` (or equivalent) to confirm semantics.
|
||||
- **`parallel` in Phase 1 is sequential.** Don't try threading until Phase 3+. Just
|
||||
evaluate branches in order, collect results, return joined value. Document the
|
||||
semantics clearly so users don't assume true concurrency.
|
||||
- **Retry doesn't retry continuations.** If a node has already suspended, retry on
|
||||
resume doesn't re-run it from scratch — it resumes. `retry` only applies to
|
||||
exceptions raised before suspend. Be explicit in the API.
|
||||
- **Cancellation invalidates the continuation.** `(flow/cancel id)` must remove the
|
||||
stored continuation so a stale `resume` cannot wake it. Document semantics.
|
||||
- **Timeouts in pure SX are tricky.** Without a scheduler, `timeout` is a budget on
|
||||
step count or wall-clock probed at safe points. Pick one approach (probably step
|
||||
budget for determinism) and document.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/flow-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
208
plans/agent-briefings/go-loop.md
Normal file
208
plans/agent-briefings/go-loop.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Go-on-SX loop agent (single agent, phase-ordered)
|
||||
|
||||
Role: iterates `plans/go-on-sx.md` forever. **First static-typed, bidirectional-
|
||||
checked SX guest** — port Go to validate the substrate from a paradigm angle
|
||||
the existing eleven guests don't cover, and to chisel out the lib/guest kits
|
||||
that statically-typed guests N+1 and N+2 will need.
|
||||
|
||||
```
|
||||
description: Go-on-SX implementation loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/go-on-sx.md`.
|
||||
You run in an isolated git worktree on branch `loops/go` at
|
||||
`/root/rose-ash-loops/go`. You work the plan's Phases in order (1→11), forever,
|
||||
one commit per feature. Push to `origin/loops/go` after every commit. Never
|
||||
`main`, never `architecture`.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/go-on-sx.md` — Phases + Progress log + Blockers tell you where
|
||||
you are.
|
||||
2. Pre-flight: `ls lib/guest/lex.sx lib/guest/pratt.sx lib/guest/ast.sx
|
||||
lib/guest/match.sx` — all four must exist. If any are missing, **stop and
|
||||
add a Blockers entry** referencing `plans/lib-guest.md`. Do not start.
|
||||
3. `ls lib/go/` — pick up from the most advanced file that exists. If the
|
||||
directory does not exist, you are at Phase 1.
|
||||
4. If `lib/go/tests/*.sx` exist, run them via the epoch protocol against
|
||||
`sx_server.exe`. They must be green before new work.
|
||||
5. **Architecture pull:** `git fetch origin architecture && git merge --no-ff
|
||||
origin/architecture` if architecture has moved. Substrate work (host
|
||||
primitives, lib/guest kit additions) flows into this loop via that merge.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/go-on-sx.md`:
|
||||
|
||||
- **Phase 1** — Tokenizer (`lib/go/lex.sx`). Consumes `lib/guest/core/lex.sx`.
|
||||
ASI is the tricky bit.
|
||||
- **Phase 2** — Parser (`lib/go/parse.sx`). Consumes `lib/guest/core/pratt.sx`
|
||||
+ `lib/guest/core/ast.sx`.
|
||||
- **Phase 3** — Bidirectional type checker (`lib/go/types.sx`).
|
||||
**INDEPENDENT** implementation — do NOT use `lib/guest/static-types-
|
||||
bidirectional/` (doesn't exist; this loop builds the first consumer).
|
||||
- **Phase 4** — Tree-walk evaluator (`lib/go/eval.sx`).
|
||||
- **Phase 5** — Goroutines + channels + select (`lib/go/sched.sx`).
|
||||
**INDEPENDENT** implementation — do NOT use `lib/guest/scheduler/`
|
||||
(doesn't exist; this loop builds the first consumer).
|
||||
- **Phase 5b** — Buffered channels + select fairness.
|
||||
- **Phase 6** — `defer` + panic/recover.
|
||||
- **Phase 7** — Generics (Go 1.18+).
|
||||
- **Phase 8** — Minimal stdlib (`lib/go/std/`).
|
||||
- **Phase 9** — End-to-end programs.
|
||||
- **Phase 10** — lib/guest extraction enabler (doc-only).
|
||||
- **Phase 11** — VM bytecode opcodes (deferred, optional).
|
||||
|
||||
Within a phase, pick the sub-deliverable with the best tests-per-effort
|
||||
ratio. Don't batch phases. One feature per commit.
|
||||
|
||||
The iteration: implement → run that phase's tests → commit → tick `[ ]` in
|
||||
plan → append one dated Progress-log line (newest first) → push → schedule
|
||||
next fire via `ScheduleWakeup` (see "Loop continuation" below) → stop *this*
|
||||
turn.
|
||||
|
||||
A single iteration does one feature. Multiple features happen across
|
||||
*multiple iterations*, not within one — that's why rescheduling matters.
|
||||
|
||||
## Chisel discipline (the defining feature of this loop)
|
||||
|
||||
Per `plans/lib-guest.md`. Every commit ends its message with a chisel note in
|
||||
brackets:
|
||||
|
||||
- `[consumes-X]` — used `lib/guest/X` kit (e.g., `[consumes-lex]`,
|
||||
`[consumes-pratt]`, `[consumes-ast]`, `[consumes-match]`).
|
||||
- `[shapes-scheduler]` — revealed something about what
|
||||
`plans/lib-guest-scheduler.md` should propose. Append a paragraph to that
|
||||
plan's design diary describing the insight.
|
||||
- `[shapes-static-types-bidirectional]` — same for
|
||||
`plans/lib-guest-static-types-bidirectional.md`.
|
||||
- `[proposes-Y]` — revealed a gap in another existing kit (e.g., `pratt.sx`
|
||||
doesn't handle Go's operator precedence properly). Blockers entry in the
|
||||
kit's plan describing the gap with minimal repro.
|
||||
- `[nothing]` — pure Go work that didn't touch substrate or lib/guest story.
|
||||
Rare; if you write `[nothing]` twice in a row, stop and reflect on whether
|
||||
the iteration could have been shaped to surface something.
|
||||
|
||||
**Sister plans must be updated.** When Phase 3 lands (independent checker
|
||||
working), append a paragraph to
|
||||
`plans/lib-guest-static-types-bidirectional.md` describing what synth/check
|
||||
shape emerged in Go. When Phase 5 lands (scheduler working), same for
|
||||
`plans/lib-guest-scheduler.md`. This is how the two-consumer rule actually
|
||||
pays off.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/go/**` and `plans/go-on-sx.md`. Single permitted
|
||||
cross-plan write: append-only paragraphs to the sister-plan design
|
||||
diaries (`plans/lib-guest-scheduler.md`,
|
||||
`plans/lib-guest-static-types-bidirectional.md`) on `shapes-*` commits.
|
||||
Do **not** touch `spec/`, `hosts/`, `shared/`, `lib/guest/**`
|
||||
(read-only consumer at this phase), or other `lib/<lang>/`.
|
||||
- **Consume `lib/guest/core/`** for lex/parse/ast/match/layout. Hand-
|
||||
rolling defeats the chiselling goal.
|
||||
- **Do NOT extract into `lib/guest/scheduler/` or `lib/guest/static-
|
||||
types-bidirectional/` from this loop.** Those extractions are gated on
|
||||
two consumers AND independent implementation. Extraction is its own
|
||||
workstream after Go and the second consumer both exist.
|
||||
- **Substrate gaps** → Blockers entry with minimal repro. Don't fix the
|
||||
substrate from this loop. Belongs to `sx-improvements.md`.
|
||||
- **NEVER call `sx_build` without timeout awareness** — 600s watchdog.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after every edit.
|
||||
Never `Edit`/`Read`/`Write` on `.sx`.
|
||||
- **Worktree:** branch `loops/go`, push `origin/loops/go`. Never `main`,
|
||||
never `architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
with chisel note: `go: lex.sx — keywords + ASI + 50 tests [consumes-lex]`.
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
- **If blocked** for two iterations on the same issue, add to Blockers and
|
||||
move on. Phases 1-4 are sequential; 5-8 are largely independent once
|
||||
4 lands.
|
||||
|
||||
## Conformance scoreboard
|
||||
|
||||
Create `lib/go/scoreboard.json` on first iteration. Suites: lex / parse /
|
||||
types / eval / runtime / stdlib / e2e. Update counts every commit. The
|
||||
scoreboard is also the no-regression gate: a commit that drops any suite's
|
||||
pass count is wrong, not the test.
|
||||
|
||||
## Go-specific gotchas (read once, never get bitten)
|
||||
|
||||
- **ASI (automatic semicolon insertion).** Newline becomes `;` after
|
||||
identifier/literal/`)`/`]`/`}`. Build it into the tokenizer (Phase 1),
|
||||
not the parser. Go spec § Semicolons is unusually precise.
|
||||
- **Untyped constants.** `42` is `untyped int` until contextualised.
|
||||
Canonical pitfall: `var x float64 = 42 / 7` must compute `42 / 7 = 6`
|
||||
as untyped, then convert to `6.0`. Not `42.0 / 7 = 6.0`. Not `(42/7).0
|
||||
= 6.0`. Test this in Phase 3.
|
||||
- **Methods vs functions.** Different lookup rules. Pointer-receiver
|
||||
methods are NOT in the value's method set for interface satisfaction.
|
||||
- **Interface satisfaction is structural and silent.** No `implements`
|
||||
declaration. Lazy check at every interface-typed slot.
|
||||
- **Channels have identity.** Distinct `make(chan int)` calls produce
|
||||
distinct channels with same type.
|
||||
- **`select` with `default`** = non-blocking. Without `default` = blocks.
|
||||
- **`nil` is typed.** `var i interface{} = (*int)(nil); i == nil` is
|
||||
`false` — i holds typed-nil-of-`*int`, not untyped nil. Footgun. Test.
|
||||
- **Goroutine panic propagation.** Unrecovered panic crashes whole
|
||||
program. Honour faithfully or document divergence.
|
||||
- **`defer` in a loop.** Each iteration pushes; all run on function
|
||||
return, not loop iteration. Common bug; tests must cover.
|
||||
- **Map iteration order is unspecified.** v1 = sorted SX-canonical key
|
||||
order for determinism. Document the divergence; provide a
|
||||
`runtime`-package knob to randomise later.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples
|
||||
in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks
|
||||
scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
- Shell heredoc `||` gets eaten — escape or use `case`.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious. Cite Go spec sections inline
|
||||
when a decision is non-obvious (the Go spec is rigorous — citations work).
|
||||
- No new planning docs — update `plans/go-on-sx.md` inline. Append paragraphs
|
||||
to sister-plan design diaries on `shapes-*` commits.
|
||||
- Short factual commit messages with chisel note in brackets:
|
||||
`go: parse short-decl + 6 tests [consumes-pratt]`.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Run the pre-flight check. If lib/guest kits are missing, stop. Otherwise
|
||||
read the plan, find the first unchecked `[ ]`, implement it. Remember:
|
||||
every commit ends with a chisel note, and the sister-plan design diaries
|
||||
get updates on `shapes-*` commits.
|
||||
|
||||
## Loop continuation
|
||||
|
||||
This briefing supersedes any "then stop" wording from the user's original
|
||||
`/loop` input. After pushing, **call `ScheduleWakeup` to fire the next
|
||||
iteration**, then end the turn. The `/loop` command is in dynamic mode;
|
||||
each iteration self-schedules the next.
|
||||
|
||||
- `delaySeconds`: **60** (minimum). This is a coding loop with no external
|
||||
event to wait on — back-to-back iterations are intended. Raise only if a
|
||||
prior fire reported a substrate blocker that needs settling.
|
||||
- `prompt`: the **full original `/loop` input verbatim, prefixed with
|
||||
`/loop `** (so the wake re-enters this skill and re-reads this briefing).
|
||||
Do NOT paraphrase or trim it — the runtime expects an exact echo.
|
||||
- `reason`: one short sentence, e.g. "next Go-on-SX iteration".
|
||||
|
||||
**Stop conditions** — omit `ScheduleWakeup` ONLY when:
|
||||
1. lib/guest pre-flight failed (missing kits) and a Blockers entry was
|
||||
added — the loop is parked waiting for substrate work.
|
||||
2. The same Blockers entry has been the reason for two consecutive
|
||||
iterations (avoid runaway no-op fires).
|
||||
3. plans/go-on-sx.md has every Phase 1-11 box checked.
|
||||
4. The user explicitly asks to stop, pause, or interrupt the loop.
|
||||
|
||||
Otherwise: reschedule. Always.
|
||||
106
plans/agent-briefings/kernel-loop.md
Normal file
106
plans/agent-briefings/kernel-loop.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# kernel-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/kernel-on-sx.md` forever. **First chisel of the Phase B stratification work** — natural successor to env-as-value, validates SX's reflection story (first-class environments, evaluators, operatives). Goal isn't just "implement Kernel"; it's *also* to surface common patterns into `lib/guest/` (specifically motivating a future `lib/guest/reflective/` sub-layer). One feature per commit.
|
||||
|
||||
```
|
||||
description: kernel-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## DO NOT START WITHOUT THE PREREQUISITES
|
||||
|
||||
This loop **must not** start until the lib-guest core kits are in place. Kernel's parser consumes `lib/guest/core/lex.sx` and `lib/guest/core/pratt.sx` (s-expression-shaped, minimal demand); its evaluator's pattern dispatch consumes `lib/guest/core/match.sx`.
|
||||
|
||||
**Pre-flight check:**
|
||||
```
|
||||
ls /root/rose-ash/lib/guest/lex.sx /root/rose-ash/lib/guest/pratt.sx \
|
||||
/root/rose-ash/lib/guest/match.sx /root/rose-ash/lib/guest/ast.sx
|
||||
```
|
||||
If any of those `lib/guest/*.sx` files are missing, **stop and report**. Do not start.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/kernel-on-sx.md`. You run in an isolated git worktree on branch `loops/kernel`. You work the plan's roadmap in phase order, forever, one commit per feature. Push to `origin/loops/kernel` after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/kernel-on-sx.md` — Roadmap + Progress log + Blockers tell you where you are.
|
||||
2. Run the pre-flight check above. If any lib/guest kit is missing, stop immediately and update the plan's Blockers section.
|
||||
3. `ls lib/kernel/` — pick up from the most advanced file that exists. If the directory does not exist, you are at Phase 1.
|
||||
4. If `lib/kernel/tests/*.sx` exist, run them via the epoch protocol against `sx_server.exe`. They must be green before new work.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/kernel-on-sx.md`:
|
||||
|
||||
- **Phase 1** — Parser (s-expression reader, minimal — consumes `lib/guest/lex` + `lib/guest/pratt`)
|
||||
- **Phase 2** — Core evaluator with first-class environments
|
||||
- **Phase 3** — `$vau` / `$lambda` / `wrap` / `unwrap` (the operative–applicative distinction)
|
||||
- **Phase 4** — Standard environment construction
|
||||
- **Phase 5** — Encapsulations (Kernel's opaque-type idiom)
|
||||
- **Phase 6** — Hygienic operatives (Shutt's later work — operatives that don't capture)
|
||||
- **Phase 7** — Propose `lib/guest/reflective/` (extraction phase — see chiselling discipline)
|
||||
|
||||
Within a phase, pick the checkbox with the best tests-per-effort ratio.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` in plan → append Progress log → push → next.
|
||||
|
||||
## Lib/guest chiselling discipline (the defining feature of this loop)
|
||||
|
||||
You are not just implementing Kernel — you are *chiselling* the substrate to surface what `lib/guest/reflective/` should contain. Every commit must end with a one-line **"chisel note"** appended to the plan's Progress log entry, in this format:
|
||||
|
||||
```
|
||||
chisel: <one of: consumes-X | shapes-reflective | proposes-Y | nothing>
|
||||
```
|
||||
|
||||
- `consumes-X` — this commit used an existing `lib/guest/X` kit (e.g., `consumes-pratt`, `consumes-match`).
|
||||
- `shapes-reflective` — this commit revealed something about what `lib/guest/reflective/` should look like (e.g., env-reification helper signatures, applicative-vs-operative dispatch protocol). Add a paragraph to the plan's "lib/guest feedback loop" section describing the insight.
|
||||
- `proposes-Y` — this commit revealed a gap in another existing kit (e.g., `match.sx` doesn't quite handle X). Open a Blockers entry describing the gap.
|
||||
- `nothing` — pure Kernel work that didn't touch the substrate or lib/guest story (rare; if you write this twice in a row, stop and reflect on why).
|
||||
|
||||
**Phase 7 (extraction)** is **gated** by the two-consumer rule. Kernel alone is one consumer. The natural second consumer is a future MetaScheme port, a Common-Lisp meta-evaluator port, or a Kernel dialect (cKanren-style). **Until a second consumer exists, do NOT actually extract** — instead, mark Phase 7 `[partial — pending second consumer]` and document the proposed `lib/guest/reflective/` API surface in the plan's progress log. The extraction itself happens later, when a second consumer materialises.
|
||||
|
||||
This discipline is the point of the loop, not a bookkeeping tax. The chisel notes are what tell us — at the end of Kernel's run — whether a `lib/guest/reflective/` sub-layer is real or just one-language-shaped.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/kernel/**` and `plans/kernel-on-sx.md`. Do **not** edit `spec/`, `hosts/`, `shared/`, `lib/guest/**` (read-only consumer at this phase), or other `lib/<lang>/`.
|
||||
- **Consume `lib/guest/core/`** wherever it covers a need. Hand-rolling defeats the chiselling goal.
|
||||
- **Do not extract into `lib/guest/reflective/` from this loop.** That's Phase 7 territory, gated by the two-consumer rule. Until there's a second consumer, document the API surface only.
|
||||
- **Substrate gaps** (env-as-value not exposing X, `eval` semantics drift, JIT not handling reflective patterns) → Blockers entry with minimal repro. Do **not** fix substrate from this loop. Substrate work belongs to `sx-improvements.md` / `jit-perf-regression.md`.
|
||||
- **NEVER call `sx_build`.** 600s watchdog will kill you. If `sx_server.exe` is broken, add a Blockers entry and stop.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after every edit. Never `Edit`/`Read`/`Write` on `.sx`.
|
||||
- **Worktree:** commit, then push to `origin/loops/kernel`. Never touch `main`. Never push to `architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages: `kernel: $vau operative + 6 tests`.
|
||||
- **Plan file:** update Progress log + tick boxes every commit. Include the chisel note.
|
||||
- **If blocked** for two iterations on the same issue, add to Blockers and move on.
|
||||
|
||||
## Kernel-specific gotchas
|
||||
|
||||
- **Operatives don't evaluate their arguments.** `$vau` builds an operative; the body sees the *unevaluated* argument expressions plus the dynamic environment. This is the opposite of every other guest in the set. `(define-via-vau)` builds a binding by calling `eval` inside the body on the (still-syntax) argument.
|
||||
- **Applicatives wrap operatives.** `(wrap op)` produces an applicative that evaluates its args first, then calls `op` with the values. `$lambda` is sugar for `wrap` ∘ `$vau`.
|
||||
- **Dynamic vs static environments.** Operative body sees both: the static env where the `$vau` was created (closure-style), AND the dynamic env where the call happens (passed as the env-param). Different from lexical-only languages.
|
||||
- **No special forms in the evaluator.** `$if`, `$define!`, `$lambda` are all just operatives bound in the standard environment. The evaluator is `lookup-and-call` — no hardcoded switch on symbols. This is the whole point: the language is reified as data.
|
||||
- **`eval` is a primitive callable on user environments.** This is where SX's env-as-value matters most. If env-as-value isn't fully landed in the substrate, this is where it'll break.
|
||||
- **Encapsulations (Phase 5) are Kernel's opaque-types idiom.** `make-encapsulation-type` returns three operatives: encapsulator (constructs), predicate (tests), decapsulator (extracts). Used to define promises, streams, modules.
|
||||
- **Hygienic operatives (Phase 6) are research-grade.** Shutt's later work. Operatives that don't accidentally capture caller bindings. Likely uses scope sets / frame stamps. Treat as exploration, not implementation-deadline.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
- Shell heredoc `||` gets eaten — escape or use `case`.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/kernel-on-sx.md` inline.
|
||||
- Short, factual commit messages with chisel note: `kernel: $vau operative + 6 tests [shapes-reflective]`.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Run the pre-flight check. If lib/guest kits are missing, stop. Otherwise read the plan, find the first unchecked `[ ]`, implement it. Remember: every commit ends with a chisel note, and Phase 7 extraction waits for a second consumer.
|
||||
110
plans/agent-briefings/search-loop.md
Normal file
110
plans/agent-briefings/search-loop.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# search-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/search-on-sx.md` forever. **Full-text + structured search on
|
||||
Haskell** — tokenize, inverted index, query AST, boolean + phrase + ranked
|
||||
queries (TF-IDF / BM25), ACL-aware post-filter, federated index merge. Typed ADTs
|
||||
make query parsing clean; lazy lists make posting-list iteration efficient. Sits on
|
||||
`lib/haskell/` (1514/1514 already green); adds a search-shaped vocabulary on top.
|
||||
|
||||
```
|
||||
description: search-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `plans/search-on-sx.md`. Isolated
|
||||
worktree `/root/rose-ash-loops/search` on branch `loops/search`, forever, one
|
||||
commit per feature. Push to `origin/loops/search` after every commit. Never touch
|
||||
`main` or `architecture`.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/search-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/search/` — pick up from the most advanced file.
|
||||
3. If `lib/search/tests/*.sx` exist, run them via `bash lib/search/conformance.sh`.
|
||||
Green before new work.
|
||||
4. If `lib/search/scoreboard.md` exists, that's your baseline.
|
||||
5. Read the `lib/haskell/` public API once — that's your substrate. `lib/haskell/
|
||||
haskell.sx` exists; also study `runtime.sx`, `eval.sx`, `parser.sx`, `infer.sx`,
|
||||
`match.sx`, `map.sx`, `set.sx`, `testlib.sx`. Learn how to declare ADTs, pattern
|
||||
match, and use the `Map`/`Set` helpers before writing index code. Verify the real
|
||||
exported names with sx_find_all / grep — don't assume from the plan's sketch.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/search-on-sx.md`:
|
||||
|
||||
- **Phase 1** — tokenize + inverted index + simple term lookup
|
||||
(`Map Term [(DocId,[Pos])]`, insert/lookup, `(search/index doc)`,
|
||||
`(search/query term)`).
|
||||
- **Phase 2** — query AST + boolean/phrase eval (Term | And | Or | Not | Phrase;
|
||||
posting-list set ops; positional phrase match).
|
||||
- **Phase 3** — ranking (TF-IDF, BM25), top-N.
|
||||
- **Phase 4** — ACL-aware post-filter + federation (merge per-peer indices).
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/search/**` and `plans/search-on-sx.md`. Do **not** edit
|
||||
`spec/`, `hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or
|
||||
`lib/` root. May **import** from `lib/haskell/` only (its public API). Do **not**
|
||||
modify Haskell.
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If the sx_server binary is broken →
|
||||
Blockers entry, stop. Run tests by invoking the sx_server binary directly from a
|
||||
conformance.sh (model it on `lib/haskell/conformance.sh`), pointing `SX_SERVER`
|
||||
at `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe` — fresh
|
||||
worktrees have no `_build/`, so the relative path won't resolve.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro; don't fix here.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. **They take `file:` not `path:`** — a
|
||||
wrong key yields `Yojson Type_error("Expected string, got null")`, which looks
|
||||
like a broken binary but is just a param mismatch. `sx_validate` after edits.
|
||||
Path-based edits (`sx_replace_node`) count comment headers in their indices and
|
||||
can clobber the wrong node — re-read after, or prefer `sx_write_file` for small
|
||||
files.
|
||||
- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`search: phrase query positional match + 7 tests`). Push to `origin/loops/search`.
|
||||
- **Plan file:** update Progress log (newest first) + tick boxes every commit.
|
||||
|
||||
## search-specific gotchas
|
||||
|
||||
- **Posting lists are the hot path.** Keep them sorted by DocId so boolean AND/OR
|
||||
are linear merges, not nested scans. Phrase match needs positions, so store
|
||||
`(DocId, [Pos])` — don't drop positions early to save space; you can't recover them.
|
||||
- **Tokenization decides recall.** Normalize consistently (lowercase, strip
|
||||
punctuation) on BOTH index and query side, or queries silently miss. Test the
|
||||
index/query symmetry explicitly.
|
||||
- **Ranking must be deterministic on ties.** TF-IDF/BM25 scores collide; always
|
||||
add a stable tiebreak (DocId ascending) or tests flake.
|
||||
- **ACL filter is per-viewer and post-ranking.** Filter the result list against the
|
||||
viewer, after scoring — never bake visibility into the index (the same index
|
||||
serves all viewers). Inject the permit predicate; don't hardwire an ACL module
|
||||
that doesn't exist yet.
|
||||
- **Federation merges indices, not results.** Merging per-peer inverted indices
|
||||
(union posting lists per term) is cleaner and rank-correct vs merging ranked
|
||||
result lists. Mock peer indices in tests.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `let` is parallel, not sequential — nest `let`s when a binding references an earlier one.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- Namespace-prefix all guest helpers (`search/...`) — short/host-colliding names
|
||||
get silently shadowed or hang the runtime.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/search-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
176
plans/feed-on-sx.md
Normal file
176
plans/feed-on-sx.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# feed-on-sx: Activity Feeds on APL
|
||||
|
||||
Timelines, notifications, activity aggregation. The math is array math: filter, sort,
|
||||
reduce, scan, outer product. APL is the densest possible expression of feed
|
||||
composition — a fanout-and-rank pipeline reads as a single line.
|
||||
|
||||
rose-ash needs: per-user home timeline, notification feed, activity stream digestion,
|
||||
backfill for new follows, deduplication across cross-posts. Every operation is an
|
||||
array-shaped transformation.
|
||||
|
||||
End-state: an APL-flavored layer on `lib/apl/` with feed-specific combinators
|
||||
(`fanout`, `dedupe`, `score`, `rank`), an SX adapter for callers who don't want raw
|
||||
APL, ACL visibility filtering via `lib/acl/`, federation via fed-sx.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/feed/conformance.sh` → **189/189** (Phases 1–4 + TF-IDF, notifications, home, smart-dedupe, trending, mute, pagination, threading)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/feed/**` and `plans/feed-on-sx.md`. Do **not** edit
|
||||
`spec/`, `hosts/`, `shared/`, `lib/apl/**`, or other `lib/<lang>/`. You may
|
||||
**import** from `lib/apl/` (public API in `lib/apl/apl.sx`); do **not** modify APL.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** an activity is a small dict (`{:actor :verb :object :at :tags}`); a
|
||||
stream is an APL vector of such dicts. Operations are APL primitives lifted onto
|
||||
this shape. SX adapter exposes ergonomic API to non-APL callers.
|
||||
- **Unicode:** raw UTF-8 in `.sx` files. APL glyphs land directly.
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
Raw activities (any shape) Per-user view
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/normalize.sx lib/feed/timeline.sx
|
||||
— {:actor :verb :object — (timeline user)
|
||||
:at :tags} record — applies filter ∘ rank ∘ take
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/stream.sx lib/feed/rank.sx
|
||||
— APL vector of activities — velocity, recency
|
||||
— filter, sort, take — TF-IDF-ish over :tags
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/fanout.sx lib/feed/dedupe.sx
|
||||
— followers vector — group by :object
|
||||
— activities ∘.× followers — collapse cross-posts
|
||||
— flatten + dedupe
|
||||
│
|
||||
▼
|
||||
lib/feed/api.sx lib/feed/fed.sx
|
||||
— (feed/post activity) — inbox via fed-sx
|
||||
— (feed/timeline user) — backfill on subscribe
|
||||
— (feed/notify user)
|
||||
```
|
||||
|
||||
## Phase 1 — Stream model + basic ops
|
||||
|
||||
- [x] `lib/feed/normalize.sx` — activity record schema; coerce arbitrary inputs
|
||||
- [x] `lib/feed/stream.sx` — APL vector representation; filter by predicate; sort by
|
||||
`:at`; take N (`↑`); reverse (`⌽`)
|
||||
- [x] `lib/feed/api.sx` — `(feed/post activity)`, `(feed/all)`
|
||||
- [x] `lib/feed/tests/basic.sx` — 30 cases: normalize defaults, filter, sort, take, api
|
||||
- [x] `lib/feed/scoreboard.{json,md}`
|
||||
- [x] `lib/feed/conformance.sh`
|
||||
|
||||
## Phase 2 — Fanout via outer product
|
||||
|
||||
- [x] follower graph: `followers user → vector of user ids` (`feed/follow-graph`,
|
||||
`feed/followers`; graph = `{followee -> (followers)}` dict)
|
||||
- [x] fanout: activities `∘.×` audience → matrix via `apl-outer feed/-mk-event`
|
||||
- [x] flatten to inbox events vector (`feed/-flatten` rank-2 → rank-1)
|
||||
- [x] dedupe — `feed/dedupe-inbox` by `(to, actor, verb, object)`; also
|
||||
`feed/dedupe-activities` `(actor verb object)` and `feed/dedupe-collapse`
|
||||
`(verb object)` for cross-actor likes
|
||||
- [x] `lib/feed/tests/fanout.sx` — 29 cases: small graph, mutual follow, star
|
||||
(high-fanout), empty graph, unfollowed actor, cross-post dedupe
|
||||
|
||||
## Phase 3 — Aggregation + ranking
|
||||
|
||||
- [x] group-by — `feed/group-by`/`feed/group-count` key-reduce; `feed/by-actor-day`
|
||||
buckets `(actor, day)` via `feed/day` (string-joined keys)
|
||||
- [x] velocity score — `feed/velocity` counts actor's activities in `(at-window, at]`
|
||||
- [x] recency score — `feed/recency` half-life decay `0.5^(age/hl)`
|
||||
- [x] composite rank — `feed/composite` weighted sum of `(weight scorer)` parts
|
||||
- [x] top-N per timeline — `feed/top` = rank then take
|
||||
- [x] `lib/feed/tests/rank.sx` — 24 cases: decay shape, velocity burst, stable
|
||||
tie-break, top-N, composite
|
||||
|
||||
## Phase 4 — Visibility filter + federation
|
||||
|
||||
`lib/acl/` and fed-sx don't exist yet and are out of scope (import `lib/apl/`
|
||||
only), so ACL/transport are injected: `permit?`, `remote?`, `send-fn`, `fetch-fn`
|
||||
are function parameters. Real acl-sx / fed-sx wire in at the call site unchanged.
|
||||
|
||||
- [x] ACL filter — `feed/visible stream viewer permit?`; default `feed/permit-acl?`
|
||||
reads `:visible-to` allowlist (+ author-sees-own); per-viewer, never cached
|
||||
- [x] fed-sx outbound — `feed/federate`/`feed/deliver` fan out then partition
|
||||
local vs remote inboxes; remote events handed to injected `send-fn`
|
||||
- [x] fed-sx inbound — `feed/inbound` normalizes + `feed/ingest` dedupes peer
|
||||
activities into the local stream
|
||||
- [x] backfill on subscribe — `feed/backfill local fetch-fn peer-id`
|
||||
- [x] `lib/feed/tests/integration.sx` — 22 cases incl. end-to-end
|
||||
`feed/timeline` (federated → ACL for viewer → recency rank → top-N)
|
||||
|
||||
## Progress log
|
||||
|
||||
- **Phase 1 done (30/30).** Stream = APL rank-1 array whose ravel holds activity
|
||||
dicts. `normalize.sx` (record schema + accessors), `stream.sx` (filter via `/`
|
||||
compress, sort via `⍋` grade-up [stable], take via `↑`, reverse via `⌽`,
|
||||
by-actor/verb/object/since predicates), `api.sx` (mutable log: post/all/reset!/size).
|
||||
Substrate: `apl-compress`, `apl-grade-up`, `apl-take`, `apl-reverse`, `make-array`.
|
||||
Grade-up returns 1-based indices (⎕IO=1), is stable on ties → deterministic sort.
|
||||
- **Phase 2 done (59/59 total).** `fanout.sx` (graph + `apl-outer` showcase),
|
||||
`dedupe.sx` (per-key dedupe, first-wins stable). Key APL gotcha: `scalar?` is
|
||||
true for ANY dict and `disclose` nils a non-array dict, so an apl-outer combiner
|
||||
MUST `enclose` its event dict — apl-outer discloses it back intact. `apl-unique`
|
||||
preserves first-occurrence order; dict `keys` order is NOT stable, so
|
||||
`feed/audience` sorts (else recipient ordering flakes). `apl-compress` needs a
|
||||
rank-1 array, so the (activity×follower) matrix is flattened to its ravel before
|
||||
the edge-guard filter.
|
||||
- **Phase 3 done (83/83 total).** `aggregate.sx` (group-by/count, day buckets) +
|
||||
`rank.sx` (recency/velocity/engagement scorers, composite, top-N). `sort` is
|
||||
single-arg ascending only — no comparator — so ranking uses a stable two-pass
|
||||
`apl-grade-down` (by :at desc, then by score desc) for deterministic tie-breaks.
|
||||
Dict keys must be strings, so composite group keys are string-joined ("actor#day").
|
||||
- **Phase 4 done (105/105 total).** `acl.sx` (per-viewer `feed/visible`,
|
||||
`feed/timeline` capstone) + `fed.sx` (merge/ingest/inbound/backfill/federate/
|
||||
deliver). ACL/transport are dependency-injected (permit?/remote?/send-fn/fetch-fn)
|
||||
since lib/acl + fed-sx don't exist. `feed/normalize` now MERGEs defaults over the
|
||||
raw dict (was projecting to 5 keys) so extra metadata (:visible-to, peer fields)
|
||||
survives — matches the "flexible bag" principle.
|
||||
|
||||
## Roadmap is complete (all 4 phases). Possible follow-ups:
|
||||
|
||||
- Wire real acl-sx once `lib/acl/` exists (swap injected `permit?`).
|
||||
- Wire real fed-sx transport (swap `send-fn`/`fetch-fn`).
|
||||
- [x] TF-IDF over `:tags` for content ranking — `content.sx`: `feed/tag-df`,
|
||||
`feed/tag-idf` (log N/df), `feed/tfidf-score`, `feed/by-relevance`; 15 tests.
|
||||
Composes as a scorer with rank.sx. (120/120 total.)
|
||||
- [x] Notification feed (verb-filtered, per-recipient) — `notify.sx`:
|
||||
`feed/notifications`, `feed/notify-verbs`, `feed/notify-digest` (collapses
|
||||
"X, Y liked Z" by (verb,object), sorted-deterministic); 8 tests. (128/128 total.)
|
||||
- [x] **Capstone** `feed/home` — the whole pipeline as one line: fanout ∘ inbox ∘
|
||||
dedupe ∘ ACL ∘ rank ∘ take (`home.sx`); 6 tests incl. per-viewer ACL + cross-post
|
||||
dedupe. (134/134 total.)
|
||||
- [x] Per-verb dedupe rules (briefing gotcha #3) — `feed/dedupe-smart` /
|
||||
`feed/smart-key`: reactions (like/follow/boost/...) collapse cross-actor on
|
||||
(verb,object); posts stay distinct per actor. `feed/collapse-verbs` is
|
||||
rebindable policy; 9 tests. (143/143 total.)
|
||||
- [x] Trending — `feed/trending` / `feed/trending-actors`: objects/actors ranked
|
||||
by activity count in a recency window, count-desc with key-asc tiebreak
|
||||
(`trending.sx`); 11 tests. (154/154 total.)
|
||||
- [x] Mute/block — `feed/mute-actors` / `feed/mute-tags` / `feed/mute-objects` /
|
||||
`feed/apply-prefs`: viewer-controlled per-request filtering (complements ACL's
|
||||
author-controlled visibility) (`mute.sx`); 9 tests. (163/163 total.)
|
||||
- [x] Pagination — `feed/page`/`feed/page-count` (offset) + `feed/before`/
|
||||
`feed/after`/`feed/page-before`/`feed/next-cursor` (cursor by :at, stable under
|
||||
inserts) (`page.sx`); 14 tests. (177/177 total.)
|
||||
- [x] Threading — `feed/replies`/`feed/reply-count`/`feed/thread`/
|
||||
`feed/thread-objects`/`feed/thread-size`: conversation closure over `:reply-to`
|
||||
(transitive fixpoint), chronological (`thread.sx`); 12 tests. (189/189 total.)
|
||||
|
||||
(none)
|
||||
|
||||
## Notes for next iteration
|
||||
|
||||
- sx-tree MCP tools take `file:` NOT `path:` (CLAUDE.md is stale). Wrong key →
|
||||
`Yojson Type_error("Expected string, got null")`. Looks like a broken binary, isn't.
|
||||
- sx_server binary lives in main repo: `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe`
|
||||
(worktree has no `_build`). conformance.sh already points there with relative fallback.
|
||||
- Phase 2 substrate verified available: `apl-outer` (∘.×), `apl-member` (∊),
|
||||
`apl-unique`, `apl-iota` (1-based).
|
||||
108
plans/flow-on-sx.md
Normal file
108
plans/flow-on-sx.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# flow-on-sx: Durable DAG Workflows on Scheme
|
||||
|
||||
rose-ash needs workflows that survive restarts: content pipelines (write → review →
|
||||
publish → federate), scheduled jobs (digest emails), multi-step user flows (signup,
|
||||
confirm, onboard). art-dag is the precedent — DAG-of-tasks with pause/resume at IO
|
||||
boundaries.
|
||||
|
||||
Scheme's `call/cc` + delimited continuations make pause/resume natural: a `suspend`
|
||||
captures the continuation, serializes it as part of the flow record, and `resume`
|
||||
re-enters at exactly that point. No state-machine bookkeeping by hand. R7RS-small is
|
||||
already at 2644/2644 (see kernel/architecture status).
|
||||
|
||||
End-state: a Scheme-on-SX layer over the existing scheme runtime, with combinators
|
||||
for sequence/parallel/branch/retry/timeout/suspend, persistent flow store, and a
|
||||
federation extension via fed-sx for remote-node execution.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/flow/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/flow/**` and `plans/flow-on-sx.md`. Do **not** edit
|
||||
`spec/`, `hosts/`, `shared/`, `lib/scheme/**`, or other `lib/<lang>/`. You may
|
||||
**import** from `lib/scheme/` (public API via `lib/scheme/scheme.sx`); do **not**
|
||||
modify Scheme.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** flow combinators are Scheme macros + procedures. Runtime is a
|
||||
driver loop that walks the flow graph and invokes `call/cc` at `suspend` points.
|
||||
Persistence layer serializes the continuation + open file/socket placeholders are
|
||||
forbidden (continuations must be resumable across process restart).
|
||||
- **art-dag awareness:** read `plans/art-dag*` if it exists for design lineage; do not
|
||||
import code.
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
(defflow publish
|
||||
(sequence
|
||||
(write-content)
|
||||
(parallel
|
||||
(review)
|
||||
(spell-check))
|
||||
(cond approved?
|
||||
(sequence (publish) (federate))
|
||||
(notify-author))))
|
||||
│
|
||||
▼
|
||||
lib/flow/spec.sx lib/flow/runtime.sx lib/flow/store.sx
|
||||
— defflow — driver loop — append-only flow log
|
||||
— sequence/parallel — node dispatch — checkpoint serialize
|
||||
— cond/retry/timeout — call/cc at suspend — restart loader
|
||||
— suspend/resume │ │
|
||||
▼ ▼
|
||||
lib/flow/api.sx lib/flow/remote.sx
|
||||
— (flow/start name args) — fed-sx adapter
|
||||
— (flow/resume id value) — node-on-peer execution
|
||||
— (flow/cancel id) — failure handling
|
||||
```
|
||||
|
||||
## Phase 1 — Declarative DAG + sequential execution
|
||||
|
||||
- [ ] `lib/flow/spec.sx` — `defflow` macro, `sequence` combinator
|
||||
- [ ] node = Scheme thunk; output threads to next node (data flow)
|
||||
- [ ] `parallel` combinator (sequential semantics for now — TRUE parallelism in Phase 3)
|
||||
- [ ] runtime executes a flow synchronously, returns final value
|
||||
- [ ] `lib/flow/api.sx` — `(flow/start name args)` entry point
|
||||
- [ ] `lib/flow/tests/basic.sx` — 15+ cases: linear sequence, nested sequences,
|
||||
data flow between nodes, parallel-with-join
|
||||
- [ ] `lib/flow/scoreboard.{json,md}`
|
||||
- [ ] `lib/flow/conformance.sh`
|
||||
|
||||
## Phase 2 — Control flow + error handling
|
||||
|
||||
- [ ] `cond` combinator — predicate selects branch
|
||||
- [ ] `retry n [backoff]` — re-runs node up to n times on exception
|
||||
- [ ] `timeout ms` — bounds node execution
|
||||
- [ ] `try-catch` — exception handler with reified error
|
||||
- [ ] error model — exceptions vs explicit `(fail :reason ...)` results
|
||||
- [ ] `lib/flow/tests/control.sx` — 25+ cases: each combinator + composition
|
||||
|
||||
## Phase 3 — Suspend / resume (the showcase)
|
||||
|
||||
- [ ] `(suspend reason)` — `call/cc` captures continuation, returns flow-id to caller
|
||||
- [ ] `lib/flow/store.sx` — serialize flow state (continuation + open vars)
|
||||
- [ ] `(flow/resume id value)` — load continuation, inject value, re-enter
|
||||
- [ ] `(flow/cancel id)` — explicit termination
|
||||
- [ ] crash recovery — on restart, scan store for paused flows, mark resumable
|
||||
- [ ] `lib/flow/tests/suspend.sx` — pause-resume scenarios, cancellation, "restart"
|
||||
scenarios (simulated by re-loading store)
|
||||
|
||||
## Phase 4 — Distributed nodes via fed-sx
|
||||
|
||||
- [ ] `(remote-node addr fn args)` — execute node on a federation peer
|
||||
- [ ] failure semantics — retry on different peer, fall through to local
|
||||
- [ ] persistence across instances — flow state replicates via fed-sx
|
||||
- [ ] handoff — flow started here can resume on a peer if the local instance is down
|
||||
- [ ] `lib/flow/tests/distributed.sx` — federated flow scenarios (mock fed-sx in tests)
|
||||
|
||||
## Progress log
|
||||
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
|
||||
(loop fills this in)
|
||||
1372
plans/go-on-sx.md
1372
plans/go-on-sx.md
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user