Compare commits
226 Commits
loops/fed-
...
loops/iden
| Author | SHA1 | Date | |
|---|---|---|---|
| d466ca3414 | |||
| 3b782eba8a | |||
| 8130521f02 | |||
| 398209d484 | |||
| 3c3b09688a | |||
| ded7170540 | |||
| b1f9c6bef0 | |||
| db885e15bc | |||
| d2f5b49d3f | |||
| 226d755b57 | |||
| 3f3459d129 | |||
| 9860582b4a | |||
| a43825f25f | |||
| e951f23f14 | |||
| 21673b6731 | |||
| e448220b33 | |||
| a5c22c5a01 | |||
| 785faf2441 | |||
| dc00ed9786 | |||
| 56cf920041 | |||
| 20ba152e36 | |||
| baee67f561 | |||
| 27f43dbf10 | |||
| 064bbf18b3 | |||
| 938e90455d | |||
| ac63501266 | |||
| 1c6b80404e | |||
| d446562ed1 | |||
| 9f8e4d995d | |||
| 4c8e732803 | |||
| 9437f99e28 | |||
| 98f5e1bf14 | |||
| 538b8a53e0 | |||
| 7e732b1933 | |||
| 200b93c1f6 | |||
| 84d5732b38 | |||
| a37a158d01 | |||
| 739e743918 | |||
| c19f658cf2 | |||
| 2f75ab11fc | |||
| 9cfca1d008 | |||
| 82fbf01bb3 | |||
| 3e90c780e9 | |||
| 0f6dbdfc7d | |||
| 62a1485302 | |||
| 3cbf33d2d2 | |||
| 329b3c4903 | |||
| 4e521e3d7a | |||
| a00439da6e | |||
| 8e16ba6b04 | |||
| 919bd961d1 | |||
| b43901d297 | |||
| ecdaeea223 | |||
| 4be6988963 | |||
| 1c7b602978 | |||
| 90c2a57975 | |||
| 68c8e39508 | |||
| 92addf5146 | |||
| 8292607e38 | |||
| bf65de7b24 | |||
| 3764b62206 | |||
| 062a76e64f | |||
| aff7d1e84f | |||
| b0874b1282 | |||
| 156d6f12ec | |||
| c2d628e9c3 | |||
| 03da8d4328 | |||
| aabb950256 | |||
| a6864178c3 | |||
| 314cc37030 | |||
| 50eb7079e5 | |||
| c3668e4461 | |||
| b80cc32363 | |||
| 01be84b5d8 | |||
| 1902cce57f | |||
| 2b47b2925c | |||
| e53a292f1a | |||
| 3d2c1d94f2 | |||
| d9b9da3843 | |||
| 102c806451 | |||
| 0a1b89c975 | |||
| 779a592614 | |||
| 2ea87796a1 | |||
| 0e6ba55647 | |||
| ee9851c063 | |||
| c1d24eb9b3 | |||
| f4f34c1d33 | |||
| 16cb727406 | |||
| f8722b3b08 | |||
| e1f802cfff | |||
| ff537bfba2 | |||
| 6e825e1283 | |||
| 8dfc987095 | |||
| 97c7623743 | |||
| 1e4cf25015 | |||
| e896deffc8 | |||
| 72174941aa | |||
| 9c4a5d1913 | |||
| f91ac82434 | |||
| 5136249ae5 | |||
| 6fc61147a8 | |||
| 40be9cd074 | |||
| 0122c41ecb | |||
| 58656b03e4 | |||
| b0feb7b01b | |||
| a979297959 | |||
| 37226cf6eb | |||
| 15c97119e4 | |||
| 50a7f31a39 | |||
| e762cc2e32 | |||
| 915f51b2b6 | |||
| 4674620d7e | |||
| f3da3b975a | |||
| 9261d69cc5 | |||
| 1731476dc6 | |||
| 65cbdb8387 | |||
| fe47334e52 | |||
| e7501bdf8f | |||
| 91ffba9975 | |||
| 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 | |||
| abde5fbac1 | |||
| b7fcd17e6e | |||
| 89ce7b857d | |||
| 4591ac530b | |||
| 250d0511c0 | |||
| 380bc69f94 | |||
| 77f17cc796 | |||
| c352d94cc6 | |||
| 857fae1331 | |||
| b073a82b33 | |||
| 7996bcdacf | |||
| 3b6241508c | |||
| 5774065341 | |||
| 708b5a2b12 | |||
| e6261c2519 | |||
| 5c7ad01bd1 | |||
| 33725de03b | |||
| 5fd358a7a7 | |||
| 783e0cb5fe | |||
| 72896392c8 | |||
| 12b56afcd3 | |||
| 509197410f | |||
| 76614da154 | |||
| 4dfccc244d | |||
| 58d7445559 | |||
| 4e0a92ec00 | |||
| 85728621b0 | |||
| 64b7263c5f | |||
| e8a5c2e1ba | |||
| 3efd735283 | |||
| 10623da0b0 | |||
| 528b24a1cd | |||
| 25924d6212 | |||
| 0abf05ed83 | |||
| f6a6865635 | |||
| 6636f9c170 | |||
| 29fd70f17a | |||
| 3d092dd78e | |||
| 2ee5e45515 | |||
| 498d2533d8 | |||
| 925bbd0d42 | |||
| b5e93df82e | |||
| 582baf5bfd | |||
| cd45ebcc7a | |||
| 89a6b30501 | |||
| 0c389d4696 | |||
| 7602ec1a69 | |||
| 2db2d8e9f7 |
@@ -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",
|
||||
|
||||
@@ -1820,6 +1820,213 @@ let run_foundation_tests () =
|
||||
Printf.printf " FAIL: invocation_count: %s\n"
|
||||
(match other with Some n -> string_of_int n | None -> "None"));
|
||||
|
||||
Printf.printf "\nSuite: extensions/erlang_ext (Phase 9h)\n";
|
||||
(* Register the Erlang opcode namespace. Disjoint id range (200-217)
|
||||
from test_ext (220/221) so they coexist. *)
|
||||
Erlang_ext.register ();
|
||||
|
||||
(match prim [String "erlang.OP_PATTERN_TUPLE"] with
|
||||
| Integer 222 ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: extension-opcode-id erlang.OP_PATTERN_TUPLE = 222\n"
|
||||
| other ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE: got %s\n"
|
||||
(Sx_types.inspect other));
|
||||
|
||||
(match prim [String "erlang.OP_BIF_IS_TUPLE"] with
|
||||
| Integer 239 ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: extension-opcode-id erlang.OP_BIF_IS_TUPLE = 239\n"
|
||||
| other ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: erlang.OP_BIF_IS_TUPLE: got %s\n"
|
||||
(Sx_types.inspect other));
|
||||
|
||||
(match prim [String "erlang.OP_NONEXISTENT"] with
|
||||
| Nil ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: unknown erlang opcode -> nil\n"
|
||||
| other ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: unknown erlang opcode: got %s\n"
|
||||
(Sx_types.inspect other));
|
||||
|
||||
(* Phase 10b vertical slice: erlang.OP_BIF_LENGTH (230) is a REAL
|
||||
handler. Build [CONST 0; OP_BIF_LENGTH; RETURN] with an Erlang
|
||||
list [1,2,3] in the constant pool; expect Integer 3. Proves the
|
||||
full path: bytecode -> Sx_vm extension fallthrough -> erlang_ext
|
||||
handler -> correct stack result. *)
|
||||
(let mk_dict kvs =
|
||||
let h = Hashtbl.create 4 in
|
||||
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
|
||||
Sx_types.Dict h in
|
||||
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
|
||||
let er_cons hd tl =
|
||||
mk_dict [("tag", Sx_types.String "cons");
|
||||
("head", hd); ("tail", tl)] in
|
||||
let lst = er_cons (Sx_types.Integer 1)
|
||||
(er_cons (Sx_types.Integer 2)
|
||||
(er_cons (Sx_types.Integer 3) er_nil)) in
|
||||
let code = ({
|
||||
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||
vc_bytecode = [| 1; 0; 0; 230; 50 |];
|
||||
vc_constants = [| lst |];
|
||||
vc_bytecode_list = None; vc_constants_list = None;
|
||||
} : Sx_types.vm_code) in
|
||||
let globals = Hashtbl.create 1 in
|
||||
try
|
||||
match Sx_vm.execute_module code globals with
|
||||
| Integer 3 ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: erlang.OP_BIF_LENGTH [1,2,3] -> 3 (real handler, end-to-end)\n"
|
||||
| other ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: OP_BIF_LENGTH result: got %s\n"
|
||||
(Sx_types.inspect other)
|
||||
with exn ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: OP_BIF_LENGTH raised: %s\n"
|
||||
(Printexc.to_string exn));
|
||||
|
||||
(* More real handlers (Phase 10b batch): build a list/tuple constant
|
||||
and exercise HD/TL/TUPLE_SIZE/IS_* end-to-end through the VM. *)
|
||||
(let mk_dict kvs =
|
||||
let h = Hashtbl.create 4 in
|
||||
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
|
||||
Sx_types.Dict h in
|
||||
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
|
||||
let er_cons hd tl = mk_dict [("tag", Sx_types.String "cons");
|
||||
("head", hd); ("tail", tl)] in
|
||||
let er_tuple es = mk_dict [("tag", Sx_types.String "tuple");
|
||||
("elements", Sx_types.List es)] in
|
||||
let er_atom nm = mk_dict [("tag", Sx_types.String "atom");
|
||||
("name", Sx_types.String nm)] in
|
||||
let lst3 = er_cons (Sx_types.Integer 7)
|
||||
(er_cons (Sx_types.Integer 8)
|
||||
(er_cons (Sx_types.Integer 9) er_nil)) in
|
||||
let tup3 = er_tuple [Sx_types.Integer 1; Sx_types.Integer 2;
|
||||
Sx_types.Integer 3] in
|
||||
let run consts bc =
|
||||
let code = ({
|
||||
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||
vc_bytecode = bc; vc_constants = consts;
|
||||
vc_bytecode_list = None; vc_constants_list = None;
|
||||
} : Sx_types.vm_code) in
|
||||
Sx_vm.execute_module code (Hashtbl.create 1) in
|
||||
let nm = function
|
||||
| Sx_types.Dict d ->
|
||||
(match Hashtbl.find_opt d "name" with
|
||||
| Some (Sx_types.String s) -> s | _ -> "?")
|
||||
| _ -> "?" in
|
||||
let check label want got =
|
||||
if got = want then begin
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: %s\n" label
|
||||
end else begin
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: %s: got %s\n" label (Sx_types.inspect got)
|
||||
end in
|
||||
(* HD [7,8,9] -> 7 *)
|
||||
check "OP_BIF_HD [7,8,9] -> 7" (Sx_types.Integer 7)
|
||||
(run [| lst3 |] [| 1;0;0; 231; 50 |]);
|
||||
(* TL [7,8,9] -> [8,9], check its HD = 8 *)
|
||||
check "OP_BIF_TL then HD -> 8" (Sx_types.Integer 8)
|
||||
(run [| lst3 |] [| 1;0;0; 232; 231; 50 |]);
|
||||
(* TUPLE_SIZE {1,2,3} -> 3 *)
|
||||
check "OP_BIF_TUPLE_SIZE {1,2,3} -> 3" (Sx_types.Integer 3)
|
||||
(run [| tup3 |] [| 1;0;0; 234; 50 |]);
|
||||
(* IS_INTEGER 42 -> true ; IS_INTEGER [..] -> false *)
|
||||
(match run [| Sx_types.Integer 42 |] [| 1;0;0; 236; 50 |] with
|
||||
| v when nm v = "true" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER 42 -> true\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_INTEGER 42: got %s\n" (Sx_types.inspect v));
|
||||
(match run [| lst3 |] [| 1;0;0; 236; 50 |] with
|
||||
| v when nm v = "false" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER list -> false\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_INTEGER list: got %s\n" (Sx_types.inspect v));
|
||||
(* IS_ATOM atom -> true ; IS_LIST nil -> true ; IS_TUPLE tuple -> true *)
|
||||
(match run [| er_atom "ok" |] [| 1;0;0; 237; 50 |] with
|
||||
| v when nm v = "true" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_ATOM ok -> true\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_ATOM: got %s\n" (Sx_types.inspect v));
|
||||
(match run [| er_nil |] [| 1;0;0; 238; 50 |] with
|
||||
| v when nm v = "true" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST nil -> true\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_LIST nil: got %s\n" (Sx_types.inspect v));
|
||||
(match run [| tup3 |] [| 1;0;0; 239; 50 |] with
|
||||
| v when nm v = "true" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_TUPLE {..} -> true\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_TUPLE: got %s\n" (Sx_types.inspect v));
|
||||
(match run [| tup3 |] [| 1;0;0; 238; 50 |] with
|
||||
| v when nm v = "false" ->
|
||||
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST tuple -> false\n"
|
||||
| v -> incr fail_count;
|
||||
Printf.printf " FAIL: IS_LIST tuple: got %s\n" (Sx_types.inspect v));
|
||||
(* ELEMENT: element(2, {1,2,3}) -> 2. Calling convention: push
|
||||
Index then Tuple; opcode pops Tuple (TOS) then Index. *)
|
||||
check "OP_BIF_ELEMENT element(2,{1,2,3}) -> 2" (Sx_types.Integer 2)
|
||||
(run [| Sx_types.Integer 2; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
|
||||
check "OP_BIF_ELEMENT element(1,{1,2,3}) -> 1" (Sx_types.Integer 1)
|
||||
(run [| Sx_types.Integer 1; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
|
||||
(* ELEMENT out of range raises *)
|
||||
(let raised =
|
||||
(try ignore (run [| Sx_types.Integer 9; tup3 |]
|
||||
[| 1;0;0; 1;1;0; 233; 50 |]); false
|
||||
with Sx_types.Eval_error _ -> true) in
|
||||
if raised then begin
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: OP_BIF_ELEMENT out-of-range raises\n"
|
||||
end else begin
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: OP_BIF_ELEMENT out-of-range should raise\n"
|
||||
end);
|
||||
(* LISTS_REVERSE [7,8,9] -> [9,8,7]; verify HD = 9 then HD of TL = 8 *)
|
||||
check "OP_BIF_LISTS_REVERSE then HD -> 9" (Sx_types.Integer 9)
|
||||
(run [| lst3 |] [| 1;0;0; 235; 231; 50 |]);
|
||||
check "OP_BIF_LISTS_REVERSE then TL,HD -> 8" (Sx_types.Integer 8)
|
||||
(run [| lst3 |] [| 1;0;0; 235; 232; 231; 50 |]);
|
||||
(* reverse preserves length *)
|
||||
check "OP_BIF_LISTS_REVERSE then LENGTH -> 3" (Sx_types.Integer 3)
|
||||
(run [| lst3 |] [| 1;0;0; 235; 230; 50 |]));
|
||||
|
||||
(* A still-stubbed opcode (222 = erlang.OP_PATTERN_TUPLE) raises the
|
||||
not-wired Eval_error — confirms the honest-failure path remains
|
||||
for opcodes whose real handlers haven't landed. *)
|
||||
(let globals = Hashtbl.create 1 in
|
||||
try
|
||||
ignore (Sx_vm.execute_module (make_bc_seq [| 222; 50 |]) globals);
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE dispatch should have raised\n"
|
||||
with
|
||||
| Sx_types.Eval_error msg
|
||||
when (let needle = "not yet wired" in
|
||||
let nl = String.length needle and ml = String.length msg in
|
||||
let rec scan i =
|
||||
if i + nl > ml then false
|
||||
else if String.sub msg i nl = needle then true
|
||||
else scan (i + 1)
|
||||
in scan 0) ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: erlang opcode dispatch raises not-wired error\n"
|
||||
| exn ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: unexpected exn: %s\n" (Printexc.to_string exn));
|
||||
|
||||
(match Erlang_ext.dispatch_count () with
|
||||
| Some n when n >= 1 ->
|
||||
incr pass_count;
|
||||
Printf.printf " PASS: erlang_ext state recorded %d dispatch(es)\n" n
|
||||
| other ->
|
||||
incr fail_count;
|
||||
Printf.printf " FAIL: dispatch_count: %s\n"
|
||||
(match other with Some n -> string_of_int n | None -> "None"));
|
||||
|
||||
Printf.printf "\nSuite: jit extension-opcode awareness\n";
|
||||
let scan = Sx_vm.bytecode_uses_extension_opcodes in
|
||||
let no_consts = [||] in
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
|
||||
open Sx_types
|
||||
|
||||
(* Force-link Sx_vm_extensions so its module-init runs: installs the
|
||||
extension dispatch fallthrough and registers the `extension-opcode-id`
|
||||
SX primitive. Without a reference here OCaml dead-code-eliminates the
|
||||
module from sx_server.exe (it's only otherwise reached from run_tests),
|
||||
leaving guest-language opcode extensions (Erlang Phase 9, etc.)
|
||||
invisible to the runtime. The applied call is a harmless lookup. *)
|
||||
let () = ignore (Sx_vm_extensions.id_of_name "")
|
||||
|
||||
(* Register the Erlang opcode extension (Phase 9h) so
|
||||
`extension-opcode-id "erlang.OP_*"` resolves to the host ids the SX
|
||||
stub dispatcher consults. Guarded: a double-register raises Failure,
|
||||
which we swallow so a re-entered server process doesn't die. *)
|
||||
let () = try Erlang_ext.register () with Failure _ -> ()
|
||||
|
||||
(* ====================================================================== *)
|
||||
(* Font measurement via otfm — reads OpenType/TrueType font tables *)
|
||||
(* ====================================================================== *)
|
||||
|
||||
278
hosts/ocaml/lib/extensions/erlang_ext.ml
Normal file
278
hosts/ocaml/lib/extensions/erlang_ext.ml
Normal file
@@ -0,0 +1,278 @@
|
||||
(** {1 [erlang_ext] — Erlang-on-SX VM opcode extension (Phase 9h)}
|
||||
|
||||
Registers the Erlang opcode namespace in [Sx_vm_extensions] so that
|
||||
[extension-opcode-id "erlang.OP_*"] resolves to a stable id. The SX
|
||||
stub dispatcher in [lib/erlang/vm/dispatcher.sx] consults these ids
|
||||
(Phase 9i) and falls back to its own local ids when the host
|
||||
extension is absent.
|
||||
|
||||
Opcode ids occupy 222-239 in the extension partition (200-247).
|
||||
222+ is chosen to clear the test extensions' reserved ids
|
||||
(test_reg 210/211, test_ext 220/221) so all three coexist in
|
||||
run_tests; production sx_server only registers this one. Names
|
||||
mirror the SX stub dispatcher exactly:
|
||||
|
||||
- 222 erlang.OP_PATTERN_TUPLE - 231 erlang.OP_BIF_HD
|
||||
- 223 erlang.OP_PATTERN_LIST - 232 erlang.OP_BIF_TL
|
||||
- 224 erlang.OP_PATTERN_BINARY - 233 erlang.OP_BIF_ELEMENT
|
||||
- 225 erlang.OP_PERFORM - 234 erlang.OP_BIF_TUPLE_SIZE
|
||||
- 226 erlang.OP_HANDLE - 235 erlang.OP_BIF_LISTS_REVERSE
|
||||
- 227 erlang.OP_RECEIVE_SCAN - 236 erlang.OP_BIF_IS_INTEGER
|
||||
- 228 erlang.OP_SPAWN - 237 erlang.OP_BIF_IS_ATOM
|
||||
- 229 erlang.OP_SEND - 238 erlang.OP_BIF_IS_LIST
|
||||
- 230 erlang.OP_BIF_LENGTH - 239 erlang.OP_BIF_IS_TUPLE
|
||||
|
||||
{2 Handler status}
|
||||
|
||||
The bytecode compiler does not yet emit these opcodes — Erlang
|
||||
programs run through the general CEK path and the working
|
||||
specialization path is the SX stub dispatcher. So every handler
|
||||
here raises a descriptive [Eval_error] rather than silently
|
||||
corrupting the VM stack. This keeps the extension honest: the
|
||||
namespace is registered and disassembles by name, [extension-opcode-id]
|
||||
works, but actually dispatching an opcode (which only happens once a
|
||||
future phase teaches the compiler to emit them) fails loudly with a
|
||||
pointer to the phase that will wire it. Real stack-machine handlers
|
||||
land alongside compiler emission in a later phase. *)
|
||||
|
||||
open Sx_types
|
||||
|
||||
(** Per-instance state: invocation counter, purely to exercise the
|
||||
[extension_state] machinery (mirrors [test_ext]). *)
|
||||
type Sx_vm_extension.extension_state += ErlangExtState of {
|
||||
mutable dispatched : int;
|
||||
}
|
||||
|
||||
let not_wired name =
|
||||
raise (Eval_error
|
||||
(Printf.sprintf
|
||||
"%s: bytecode emission not yet wired (Phase 9j) — \
|
||||
Erlang runs via CEK; specialization path is the SX stub \
|
||||
dispatcher in lib/erlang/vm/dispatcher.sx"
|
||||
name))
|
||||
|
||||
module M : Sx_vm_extension.EXTENSION = struct
|
||||
let name = "erlang"
|
||||
let init () = ErlangExtState { dispatched = 0 }
|
||||
|
||||
let opcodes st =
|
||||
let bump () = match st with
|
||||
| ErlangExtState s -> s.dispatched <- s.dispatched + 1
|
||||
| _ -> ()
|
||||
in
|
||||
let op id nm =
|
||||
(id, nm, (fun (_vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
|
||||
bump (); not_wired nm))
|
||||
in
|
||||
(* Phase 10b vertical slice: one REAL register-machine handler.
|
||||
erlang.OP_BIF_LENGTH (230) — pops an Erlang list off the VM
|
||||
stack and pushes its length. Proves the full path works:
|
||||
extension-opcode-id -> bytecode -> Sx_vm dispatch fallthrough
|
||||
-> this handler -> correct stack result. The remaining 17
|
||||
opcodes still raise not_wired until their handlers + compiler
|
||||
emission land. Erlang lists are tagged dicts:
|
||||
nil = {"tag" -> String "nil"}
|
||||
cons = {"tag" -> String "cons"; "head" -> v; "tail" -> v} *)
|
||||
let er_tag d =
|
||||
match Hashtbl.find_opt d "tag" with
|
||||
| Some (String s) -> s | _ -> ""
|
||||
in
|
||||
let op_bif_length =
|
||||
(230, "erlang.OP_BIF_LENGTH",
|
||||
(fun (vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
let rec walk acc node =
|
||||
match node with
|
||||
| Dict d ->
|
||||
(match er_tag d with
|
||||
| "nil" -> acc
|
||||
| "cons" ->
|
||||
(match Hashtbl.find_opt d "tail" with
|
||||
| Some t -> walk (acc + 1) t
|
||||
| None -> raise (Eval_error
|
||||
"erlang.OP_BIF_LENGTH: cons cell without :tail"))
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_LENGTH: not a proper list"))
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_LENGTH: not a proper list")
|
||||
in
|
||||
Sx_vm.push vm (Integer (walk 0 v))))
|
||||
in
|
||||
(* Phase 10b — simple hot-BIF handlers. Erlang bool is the atom
|
||||
{"tag"->"atom"; "name"->"true"|"false"}; mk_atom builds it. *)
|
||||
let mk_atom nm =
|
||||
let h = Hashtbl.create 2 in
|
||||
Hashtbl.replace h "tag" (String "atom");
|
||||
Hashtbl.replace h "name" (String nm);
|
||||
Dict h
|
||||
in
|
||||
let er_bool b = mk_atom (if b then "true" else "false") in
|
||||
let is_tag v t = match v with
|
||||
| Dict d -> er_tag d = t
|
||||
| _ -> false
|
||||
in
|
||||
let op_bif_hd =
|
||||
(231, "erlang.OP_BIF_HD",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
match Sx_vm.pop vm with
|
||||
| Dict d when er_tag d = "cons" ->
|
||||
(match Hashtbl.find_opt d "head" with
|
||||
| Some h -> Sx_vm.push vm h
|
||||
| None -> raise (Eval_error "erlang.OP_BIF_HD: cons without :head"))
|
||||
| _ -> raise (Eval_error "erlang.OP_BIF_HD: not a cons")))
|
||||
in
|
||||
let op_bif_tl =
|
||||
(232, "erlang.OP_BIF_TL",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
match Sx_vm.pop vm with
|
||||
| Dict d when er_tag d = "cons" ->
|
||||
(match Hashtbl.find_opt d "tail" with
|
||||
| Some t -> Sx_vm.push vm t
|
||||
| None -> raise (Eval_error "erlang.OP_BIF_TL: cons without :tail"))
|
||||
| _ -> raise (Eval_error "erlang.OP_BIF_TL: not a cons")))
|
||||
in
|
||||
let op_bif_tuple_size =
|
||||
(234, "erlang.OP_BIF_TUPLE_SIZE",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
match Sx_vm.pop vm with
|
||||
| Dict d when er_tag d = "tuple" ->
|
||||
let n = match Hashtbl.find_opt d "elements" with
|
||||
| Some (List es) -> List.length es
|
||||
| Some (ListRef r) -> List.length !r
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_TUPLE_SIZE: tuple without :elements")
|
||||
in
|
||||
Sx_vm.push vm (Integer n)
|
||||
| _ -> raise (Eval_error "erlang.OP_BIF_TUPLE_SIZE: not a tuple")))
|
||||
in
|
||||
let op_bif_is_integer =
|
||||
(236, "erlang.OP_BIF_IS_INTEGER",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
Sx_vm.push vm (er_bool (match v with Integer _ -> true | _ -> false))))
|
||||
in
|
||||
let op_bif_is_atom =
|
||||
(237, "erlang.OP_BIF_IS_ATOM",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
Sx_vm.push vm (er_bool (is_tag v "atom"))))
|
||||
in
|
||||
let op_bif_is_list =
|
||||
(238, "erlang.OP_BIF_IS_LIST",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
Sx_vm.push vm (er_bool (is_tag v "cons" || is_tag v "nil"))))
|
||||
in
|
||||
let op_bif_is_tuple =
|
||||
(239, "erlang.OP_BIF_IS_TUPLE",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
Sx_vm.push vm (er_bool (is_tag v "tuple"))))
|
||||
in
|
||||
(* element/2 and lists:reverse/1 — pure stack transforms (no
|
||||
bytecode operands). Calling convention: args pushed left→right,
|
||||
so element/2 stack is [.. Index Tuple] (Tuple on top). Erlang
|
||||
element/2 is 1-indexed. *)
|
||||
let op_bif_element =
|
||||
(233, "erlang.OP_BIF_ELEMENT",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let tup = Sx_vm.pop vm in
|
||||
let idx = Sx_vm.pop vm in
|
||||
match tup, idx with
|
||||
| Dict d, Integer i when er_tag d = "tuple" ->
|
||||
let es = match Hashtbl.find_opt d "elements" with
|
||||
| Some (List es) -> es
|
||||
| Some (ListRef r) -> !r
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_ELEMENT: tuple without :elements")
|
||||
in
|
||||
let n = List.length es in
|
||||
if i < 1 || i > n then
|
||||
raise (Eval_error
|
||||
(Printf.sprintf
|
||||
"erlang.OP_BIF_ELEMENT: index %d out of range 1..%d" i n))
|
||||
else
|
||||
Sx_vm.push vm (List.nth es (i - 1))
|
||||
| _, Integer _ ->
|
||||
raise (Eval_error "erlang.OP_BIF_ELEMENT: 2nd arg not a tuple")
|
||||
| _ ->
|
||||
raise (Eval_error "erlang.OP_BIF_ELEMENT: 1st arg not an integer")))
|
||||
in
|
||||
let op_bif_lists_reverse =
|
||||
(235, "erlang.OP_BIF_LISTS_REVERSE",
|
||||
(fun (vm : Sx_vm.vm) _f ->
|
||||
bump ();
|
||||
let v = Sx_vm.pop vm in
|
||||
let mk_nil () =
|
||||
let h = Hashtbl.create 1 in
|
||||
Hashtbl.replace h "tag" (String "nil"); Dict h in
|
||||
let mk_cons hd tl =
|
||||
let h = Hashtbl.create 3 in
|
||||
Hashtbl.replace h "tag" (String "cons");
|
||||
Hashtbl.replace h "head" hd;
|
||||
Hashtbl.replace h "tail" tl;
|
||||
Dict h in
|
||||
let rec rev acc node =
|
||||
match node with
|
||||
| Dict d ->
|
||||
(match er_tag d with
|
||||
| "nil" -> acc
|
||||
| "cons" ->
|
||||
let hd = match Hashtbl.find_opt d "head" with
|
||||
| Some x -> x
|
||||
| None -> raise (Eval_error
|
||||
"erlang.OP_BIF_LISTS_REVERSE: cons without :head") in
|
||||
let tl = match Hashtbl.find_opt d "tail" with
|
||||
| Some x -> x
|
||||
| None -> raise (Eval_error
|
||||
"erlang.OP_BIF_LISTS_REVERSE: cons without :tail") in
|
||||
rev (mk_cons hd acc) tl
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_LISTS_REVERSE: not a proper list"))
|
||||
| _ -> raise (Eval_error
|
||||
"erlang.OP_BIF_LISTS_REVERSE: not a proper list")
|
||||
in
|
||||
Sx_vm.push vm (rev (mk_nil ()) v)))
|
||||
in
|
||||
[
|
||||
op 222 "erlang.OP_PATTERN_TUPLE";
|
||||
op 223 "erlang.OP_PATTERN_LIST";
|
||||
op 224 "erlang.OP_PATTERN_BINARY";
|
||||
op 225 "erlang.OP_PERFORM";
|
||||
op 226 "erlang.OP_HANDLE";
|
||||
op 227 "erlang.OP_RECEIVE_SCAN";
|
||||
op 228 "erlang.OP_SPAWN";
|
||||
op 229 "erlang.OP_SEND";
|
||||
op_bif_length;
|
||||
op_bif_hd;
|
||||
op_bif_tl;
|
||||
op_bif_element;
|
||||
op_bif_tuple_size;
|
||||
op_bif_lists_reverse;
|
||||
op_bif_is_integer;
|
||||
op_bif_is_atom;
|
||||
op_bif_is_list;
|
||||
op_bif_is_tuple;
|
||||
]
|
||||
end
|
||||
|
||||
(** Register [erlang] in [Sx_vm_extensions]. Idempotent only by failing
|
||||
loudly — calling twice raises [Failure]. sx_server calls this once
|
||||
at startup. *)
|
||||
let register () = Sx_vm_extensions.register (module M : Sx_vm_extension.EXTENSION)
|
||||
|
||||
(** Read the dispatch counter from the live registry state. [None] if
|
||||
[register] hasn't run. *)
|
||||
let dispatch_count () =
|
||||
match Sx_vm_extensions.state_of_extension "erlang" with
|
||||
| Some (ErlangExtState s) -> Some s.dispatched
|
||||
| _ -> None
|
||||
45
lib/acl/api.sx
Normal file
45
lib/acl/api.sx
Normal file
@@ -0,0 +1,45 @@
|
||||
;; lib/acl/api.sx — public ACL surface over an implicit current db.
|
||||
;;
|
||||
;; Callers load a fact set once, then issue decisions without threading the db
|
||||
;; through every call. The current db is module state; (acl/load! facts) rebuilds
|
||||
;; it. This is the boundary the rest of rose-ash imports.
|
||||
|
||||
(define acl-current-db nil)
|
||||
|
||||
;; Replace the current fact base. Rebuilds the Datalog db under the active
|
||||
;; ruleset (see lib/acl/engine.sx).
|
||||
(define
|
||||
acl/load!
|
||||
(fn
|
||||
(facts)
|
||||
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
|
||||
|
||||
;; Ensure a db exists, building an empty one on first use.
|
||||
(define
|
||||
acl-ensure-db!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(when
|
||||
(= acl-current-db nil)
|
||||
(set! acl-current-db (acl-build-db (list))))
|
||||
acl-current-db)))
|
||||
|
||||
;; Public decision against the current db (pure, no logging).
|
||||
(define
|
||||
acl/permit?
|
||||
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
|
||||
(define
|
||||
acl/explain
|
||||
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Audited decision: logs the outcome to the append-only audit log and returns
|
||||
;; the boolean. See lib/acl/audit.sx.
|
||||
(define
|
||||
acl/audit
|
||||
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Recent audited decisions (chronological).
|
||||
(define acl/audit-tail (fn (n) (acl-audit-tail n)))
|
||||
110
lib/acl/audit.sx
Normal file
110
lib/acl/audit.sx
Normal file
@@ -0,0 +1,110 @@
|
||||
;; lib/acl/audit.sx — append-only decision log.
|
||||
;;
|
||||
;; Every decision routed through acl-audit-decide! is appended to an in-memory
|
||||
;; log with a monotonic sequence number (no wall-clock — deterministic and
|
||||
;; testable; a host can stamp time at the serializer boundary). The log is
|
||||
;; append-only: there is no mutate or delete, only append, tail, clear,
|
||||
;; snapshot/restore, and serialize-for-disk.
|
||||
|
||||
(define acl-audit-log (list))
|
||||
(define acl-audit-seq 0)
|
||||
|
||||
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
|
||||
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
|
||||
;; log must always be a list built with `list` + `append!`.
|
||||
(define
|
||||
acl-audit-copy
|
||||
(fn
|
||||
(xs)
|
||||
(let
|
||||
((fresh (list)))
|
||||
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
|
||||
|
||||
(define
|
||||
acl-audit-clear!
|
||||
(fn
|
||||
()
|
||||
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
|
||||
|
||||
;; Append a decision record. Returns the record.
|
||||
(define
|
||||
acl-audit-record!
|
||||
(fn
|
||||
(subj act res allowed?)
|
||||
(let
|
||||
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
|
||||
(do
|
||||
(set! acl-audit-seq (+ acl-audit-seq 1))
|
||||
(append! acl-audit-log entry)
|
||||
entry))))
|
||||
|
||||
;; Decide against db, log the outcome, and return the boolean. This is the
|
||||
;; audited path; acl-permit? remains the pure, side-effect-free decision.
|
||||
(define
|
||||
acl-audit-decide!
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((allowed? (acl-permit? db subj act res)))
|
||||
(do (acl-audit-record! subj act res allowed?) allowed?))))
|
||||
|
||||
(define acl-audit-count (fn () (len acl-audit-log)))
|
||||
|
||||
;; Most recent n entries (in chronological order). n >= log size returns all.
|
||||
(define
|
||||
acl-audit-tail
|
||||
(fn
|
||||
(n)
|
||||
(let
|
||||
((total (len acl-audit-log)))
|
||||
(if
|
||||
(<= total n)
|
||||
acl-audit-log
|
||||
(acl-audit-drop acl-audit-log (- total n))))))
|
||||
|
||||
(define
|
||||
acl-audit-drop
|
||||
(fn
|
||||
(xs k)
|
||||
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
|
||||
|
||||
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
|
||||
;; copy of the log (so later appends don't mutate a held snapshot).
|
||||
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
|
||||
|
||||
;; Replace the live log from a snapshot. Restores both entries and the seq
|
||||
;; counter so subsequent records continue numbering correctly. The log is
|
||||
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
|
||||
(define
|
||||
acl-audit-restore!
|
||||
(fn
|
||||
(snap)
|
||||
(do
|
||||
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
|
||||
(set! acl-audit-seq (get snap :seq))
|
||||
nil)))
|
||||
|
||||
;; Serialize the whole log to a disk-ready string: one record per line,
|
||||
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
|
||||
;; snapshot/restore.
|
||||
(define
|
||||
acl-audit-serialize
|
||||
(fn
|
||||
()
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(str
|
||||
acc
|
||||
(get e :seq)
|
||||
"\t"
|
||||
(get e :subj)
|
||||
"\t"
|
||||
(get e :act)
|
||||
"\t"
|
||||
(get e :res)
|
||||
"\t"
|
||||
(get e :allowed?)
|
||||
"\n"))
|
||||
""
|
||||
acl-audit-log)))
|
||||
32
lib/acl/conformance.conf
Normal file
32
lib/acl/conformance.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
# ACL conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=acl
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/acl/schema.sx
|
||||
lib/acl/facts.sx
|
||||
lib/acl/engine.sx
|
||||
lib/acl/explain.sx
|
||||
lib/acl/audit.sx
|
||||
lib/acl/federation.sx
|
||||
lib/acl/api.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
|
||||
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
|
||||
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
|
||||
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
|
||||
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
|
||||
)
|
||||
3
lib/acl/conformance.sh
Executable file
3
lib/acl/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
72
lib/acl/engine.sx
Normal file
72
lib/acl/engine.sx
Normal file
@@ -0,0 +1,72 @@
|
||||
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
|
||||
;;
|
||||
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
|
||||
;; reduces a (subject, action, resource) decision to a Datalog query against a
|
||||
;; db built from EDB facts. The rule engine itself is Datalog's.
|
||||
;;
|
||||
;; Policy — inheritance + federation with deny-overrides:
|
||||
;;
|
||||
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
|
||||
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
|
||||
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
|
||||
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
|
||||
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
|
||||
;; trust(Peer,L), level_covers(L,A).
|
||||
;;
|
||||
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
|
||||
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
|
||||
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
|
||||
;;
|
||||
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
|
||||
;;
|
||||
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
|
||||
;; (S,A,R) defeats any effective grant — including federated grants. Deny
|
||||
;; inherits through the *same* group and resource chains as grant, so a
|
||||
;; group-level or ancestor-resource deny is authoritative for members/
|
||||
;; descendants. This is the principled, fail-safe reading of "deny wins".
|
||||
;;
|
||||
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
|
||||
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
|
||||
;; Trust is re-checked on every query (it is a body literal), never baked in at
|
||||
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
|
||||
;; immediately on the next decision.
|
||||
;;
|
||||
;; Termination & stratification:
|
||||
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
|
||||
;; EDB relations with no function symbols, so the closure is finite (cyclic
|
||||
;; membership/containment just reaches a fixpoint, never loops). The
|
||||
;; federation rule is non-recursive.
|
||||
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
|
||||
;; permit, so the program is stratifiable (permit sits in a higher stratum).
|
||||
|
||||
(define
|
||||
acl-rules
|
||||
(quote
|
||||
((eff_grant S A R <- (grant S A R))
|
||||
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
|
||||
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
|
||||
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
|
||||
(eff_grant
|
||||
S
|
||||
A
|
||||
R
|
||||
<-
|
||||
(delegate Peer S A R)
|
||||
(trust Peer L)
|
||||
(level_covers L A))
|
||||
(eff_deny S A R <- (deny S A R))
|
||||
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
|
||||
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
|
||||
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
|
||||
|
||||
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
|
||||
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
|
||||
|
||||
;; Core decision: does the db permit subject S to perform action A on
|
||||
;; resource R? Reduces to a ground Datalog query on the derived `permit`
|
||||
;; relation — non-empty result means permitted.
|
||||
(define
|
||||
acl-permit?
|
||||
(fn
|
||||
(db subj act res)
|
||||
(> (len (dl-query db (list (quote permit) subj act res))) 0)))
|
||||
125
lib/acl/explain.sx
Normal file
125
lib/acl/explain.sx
Normal file
@@ -0,0 +1,125 @@
|
||||
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
|
||||
;;
|
||||
;; lib/datalog/ records derived facts but not their provenance, so the proof is
|
||||
;; reconstructed here by goal-directed search over the *saturated* db: for a
|
||||
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
|
||||
;; the first solution binding its remaining variables, and recurse on each body
|
||||
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
|
||||
;;
|
||||
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
|
||||
;; many ways). We pick ONE canonical proof — first matching rule, first solution
|
||||
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
|
||||
;; depth cap guards against pathological cyclic data producing unbounded search.
|
||||
;;
|
||||
;; A proof node is one of:
|
||||
;; {:fact <lit> :via "edb"} — base EDB fact
|
||||
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
|
||||
;; {:neg-ok <lit>} — negation verified to fail
|
||||
;; {:fact <lit> :truncated true} — depth cap hit
|
||||
|
||||
(define acl-proof-max-depth 64)
|
||||
|
||||
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
|
||||
;; does not recurse into dicts, which would leak the neg's free vars).
|
||||
(define
|
||||
acl-subst-lit
|
||||
(fn
|
||||
(lit s)
|
||||
(if
|
||||
(and (dict? lit) (has-key? lit :neg))
|
||||
{:neg (dl-apply-subst (get lit :neg) s)}
|
||||
(dl-apply-subst lit s))))
|
||||
|
||||
(define
|
||||
acl-lit-edb?
|
||||
(fn
|
||||
(lit)
|
||||
(and
|
||||
(list? lit)
|
||||
(> (len lit) 0)
|
||||
(symbol? (first lit))
|
||||
(has-key? acl-edb-arity (symbol->string (first lit))))))
|
||||
|
||||
(define
|
||||
acl-subst-zip!
|
||||
(fn
|
||||
(d ks vs)
|
||||
(when
|
||||
(> (len ks) 0)
|
||||
(do
|
||||
(dict-set! d (symbol->string (first ks)) (first vs))
|
||||
(acl-subst-zip! d (rest ks) (rest vs))))))
|
||||
|
||||
;; Bind a rule head's variables to a ground goal's arguments (positional).
|
||||
(define
|
||||
acl-bind-head
|
||||
(fn
|
||||
(head goal)
|
||||
(let
|
||||
((d {}))
|
||||
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
|
||||
|
||||
(define
|
||||
acl-subst-union
|
||||
(fn
|
||||
(a b)
|
||||
(let
|
||||
((d {}))
|
||||
(do
|
||||
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
|
||||
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
|
||||
d))))
|
||||
|
||||
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
|
||||
|
||||
(define
|
||||
acl-prove-d
|
||||
(fn
|
||||
(db goal depth)
|
||||
(cond
|
||||
((> depth acl-proof-max-depth) {:truncated true :fact goal})
|
||||
((acl-lit-edb? goal)
|
||||
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
|
||||
(else (acl-prove-rules db goal acl-rules depth)))))
|
||||
|
||||
(define
|
||||
acl-prove-rules
|
||||
(fn
|
||||
(db goal rules depth)
|
||||
(if
|
||||
(= (len rules) 0)
|
||||
nil
|
||||
(let
|
||||
((p (dl-rule-from-list (first rules))))
|
||||
(if
|
||||
(= (first (get p :head)) (first goal))
|
||||
(let
|
||||
((hs (acl-bind-head (get p :head) goal)))
|
||||
(let
|
||||
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
|
||||
(let
|
||||
((sols (dl-query db qbody)))
|
||||
(if
|
||||
(> (len sols) 0)
|
||||
(acl-prove-build db goal p hs (first sols) depth)
|
||||
(acl-prove-rules db goal (rest rules) depth)))))
|
||||
(acl-prove-rules db goal (rest rules) depth))))))
|
||||
|
||||
(define
|
||||
acl-prove-build
|
||||
(fn
|
||||
(db goal p hs sol depth)
|
||||
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
|
||||
|
||||
;; Public decision-with-proof. Returns:
|
||||
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
|
||||
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
|
||||
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
|
||||
;; inherited deny), else nil (simply no grant).
|
||||
(define
|
||||
acl-explain
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((proof (acl-prove db (list (quote permit) subj act res))))
|
||||
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))
|
||||
47
lib/acl/facts.sx
Normal file
47
lib/acl/facts.sx
Normal file
@@ -0,0 +1,47 @@
|
||||
;; lib/acl/facts.sx — EDB fact constructors.
|
||||
;;
|
||||
;; Each constructor returns a Datalog fact tuple (a list whose head is the
|
||||
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
|
||||
;; lib/datalog/.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
|
||||
;; role_grant (role -> action,resource capability).
|
||||
;; Phase 4: peer/trust/delegate/level_covers (federation).
|
||||
|
||||
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
|
||||
|
||||
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
|
||||
|
||||
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
|
||||
|
||||
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
|
||||
|
||||
;; subject S is a member of group/role G (one hop; transitivity is derived).
|
||||
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
|
||||
|
||||
;; resource R is a child of parent P (one hop; transitivity is derived).
|
||||
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
|
||||
|
||||
;; role confers capability (act on res) to every member of the role.
|
||||
(define
|
||||
acl-role-grant
|
||||
(fn (role act res) (list (quote role_grant) role act res)))
|
||||
|
||||
;; --- federation ---
|
||||
|
||||
;; a known peer instance at addr, of some kind (e.g. peer).
|
||||
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
|
||||
|
||||
;; local trust in a peer at a named level. Gates delegated grants at query time.
|
||||
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
|
||||
|
||||
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
|
||||
;; that peer covers action A (see level_covers).
|
||||
(define
|
||||
acl-delegate
|
||||
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
|
||||
|
||||
;; local policy: trust `level` authorises delegated grants for action `act`.
|
||||
(define
|
||||
acl-level-covers
|
||||
(fn (level act) (list (quote level_covers) level act)))
|
||||
61
lib/acl/federation.sx
Normal file
61
lib/acl/federation.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
|
||||
;;
|
||||
;; fed-sx replicates ACL facts between instances; this module models the local
|
||||
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
|
||||
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
|
||||
;; authorise them (enforced by the engine rule, re-checked every query). The
|
||||
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
|
||||
;;
|
||||
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
|
||||
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
|
||||
|
||||
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
|
||||
;; name) to the list of delegate facts that peer asserts. Returns the facts for
|
||||
;; `addr`, or an empty list if the peer is unknown / unreachable.
|
||||
(define
|
||||
acl-fed-fetch
|
||||
(fn
|
||||
(transport addr)
|
||||
(let
|
||||
((k (if (symbol? addr) (symbol->string addr) addr)))
|
||||
(if (has-key? transport k) (get transport k) (list)))))
|
||||
|
||||
;; Gather delegate facts from every peer in `addrs` via the transport.
|
||||
(define
|
||||
acl-fed-collect
|
||||
(fn
|
||||
(transport addrs)
|
||||
(let
|
||||
((acc (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(addr)
|
||||
(for-each
|
||||
(fn (f) (append! acc f))
|
||||
(acl-fed-fetch transport addr)))
|
||||
addrs)
|
||||
acc))))
|
||||
|
||||
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
|
||||
;; facts must include the `trust`/`level_covers` policy; replicated delegate
|
||||
;; facts are gated against it by the engine rule at query time.
|
||||
(define
|
||||
acl-fed-build-db
|
||||
(fn
|
||||
(local-facts transport peers)
|
||||
(let
|
||||
((all (list)))
|
||||
(do
|
||||
(for-each (fn (f) (append! all f)) local-facts)
|
||||
(for-each
|
||||
(fn (f) (append! all f))
|
||||
(acl-fed-collect transport peers))
|
||||
(acl-build-db all)))))
|
||||
|
||||
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
|
||||
;; local trust) from a live db. The next decision re-saturates and reflects it.
|
||||
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
|
||||
|
||||
;; Propagated assertion: ingest a newly replicated fact into a live db.
|
||||
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))
|
||||
71
lib/acl/schema.sx
Normal file
71
lib/acl/schema.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
|
||||
;;
|
||||
;; Datalog is untyped; this module is the schema-as-data layer. It declares
|
||||
;; the subject/resource/action sorts and the arity of every EDB predicate the
|
||||
;; ACL engine recognises, plus light validators. Facts that pass these checks
|
||||
;; are well-formed inputs to lib/acl/engine.sx.
|
||||
|
||||
(define acl-subject-kinds (quote (user group role service)))
|
||||
(define acl-resource-kinds (quote (page post thread peer)))
|
||||
|
||||
;; Actions are open-ended (a grant may name any action symbol), but these are
|
||||
;; the platform's well-known verbs.
|
||||
(define acl-actions (quote (read edit comment moderate federate)))
|
||||
|
||||
;; EDB predicate name -> arity.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
|
||||
;; role_grant (role->action,resource).
|
||||
;; Phase 4: peer (addr->kind), trust (peer->level),
|
||||
;; delegate (peer->subj,action,resource), level_covers (level->action).
|
||||
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
|
||||
|
||||
(define
|
||||
acl-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((= (first xs) x) true)
|
||||
(else (acl-member? x (rest xs))))))
|
||||
|
||||
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
|
||||
|
||||
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
|
||||
|
||||
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
|
||||
|
||||
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
|
||||
;; is known and the argument count matches the declared arity.
|
||||
(define
|
||||
acl-fact-valid?
|
||||
(fn
|
||||
(f)
|
||||
(and
|
||||
(list? f)
|
||||
(> (len f) 0)
|
||||
(symbol? (first f))
|
||||
(let
|
||||
((pred (symbol->string (first f))))
|
||||
(and
|
||||
(has-key? acl-edb-arity pred)
|
||||
(= (- (len f) 1) (get acl-edb-arity pred)))))))
|
||||
|
||||
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
|
||||
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
|
||||
;; tuple, and custom action symbols are allowed); callers opt in to checking.
|
||||
(define
|
||||
acl-validate-facts
|
||||
(fn
|
||||
(facts)
|
||||
(let
|
||||
((bad (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
|
||||
facts)
|
||||
bad))))
|
||||
|
||||
(define
|
||||
acl-facts-valid?
|
||||
(fn (facts) (= (len (acl-validate-facts facts)) 0)))
|
||||
14
lib/acl/scoreboard.json
Normal file
14
lib/acl/scoreboard.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lang": "acl",
|
||||
"total_passed": 145,
|
||||
"total_failed": 0,
|
||||
"total": 145,
|
||||
"suites": [
|
||||
{"name":"direct","passed":24,"failed":0,"total":24},
|
||||
{"name":"inherit","passed":30,"failed":0,"total":30},
|
||||
{"name":"explain","passed":35,"failed":0,"total":35},
|
||||
{"name":"fed","passed":31,"failed":0,"total":31},
|
||||
{"name":"harden","passed":25,"failed":0,"total":25}
|
||||
],
|
||||
"generated": "2026-06-06T22:43:27+00:00"
|
||||
}
|
||||
11
lib/acl/scoreboard.md
Normal file
11
lib/acl/scoreboard.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acl scoreboard
|
||||
|
||||
**145 / 145 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| direct | 24 | 24 | ok |
|
||||
| inherit | 30 | 30 | ok |
|
||||
| explain | 35 | 35 | ok |
|
||||
| fed | 31 | 31 | ok |
|
||||
| harden | 25 | 25 | ok |
|
||||
170
lib/acl/tests/direct.sx
Normal file
170
lib/acl/tests/direct.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
|
||||
|
||||
(define acl-dt-pass 0)
|
||||
(define acl-dt-fail 0)
|
||||
(define acl-dt-failures (list))
|
||||
|
||||
(define
|
||||
acl-dt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-dt-pass (+ acl-dt-pass 1))
|
||||
(do
|
||||
(set! acl-dt-fail (+ acl-dt-fail 1))
|
||||
(append!
|
||||
acl-dt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A small fixture used by most cases: alice can read page1, is denied edit on
|
||||
;; page1, and a service may federate peer1.
|
||||
(define
|
||||
acl-dt-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-actor (quote alice) (quote user))
|
||||
(acl-actor (quote svc1) (quote service))
|
||||
(acl-resource-fact (quote page1) (quote page))
|
||||
(acl-resource-fact (quote peer1) (quote peer))
|
||||
(acl-grant (quote alice) (quote read) (quote page1))
|
||||
(acl-grant (quote alice) (quote edit) (quote page1))
|
||||
(acl-deny (quote alice) (quote edit) (quote page1))
|
||||
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
|
||||
|
||||
(define
|
||||
acl-dt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (acl-dt-fixture)))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"direct grant permits"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"service grant permits federate"
|
||||
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"missing action denied"
|
||||
(acl-permit? db (quote alice) (quote comment) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing resource denied"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page2))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing subject denied"
|
||||
(acl-permit? db (quote bob) (quote read) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"wrong subject for service grant denied"
|
||||
(acl-permit? db (quote alice) (quote federate) (quote peer1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"grant plus deny -> deny wins"
|
||||
(acl-permit? db (quote alice) (quote edit) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny alone still denies"
|
||||
(acl-permit?
|
||||
(acl-build-db
|
||||
(list (acl-deny (quote alice) (quote read) (quote page1))))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny on edit does not block read"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"empty db denies"
|
||||
(acl-permit?
|
||||
(acl-build-db (list))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(let
|
||||
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"subject a allowed"
|
||||
(acl-permit? db2 (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"subject b denied by override"
|
||||
(acl-permit? db2 (quote b) (quote read) (quote r))
|
||||
false)))
|
||||
(let
|
||||
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
|
||||
(acl-dt-check!
|
||||
"role subject direct grant"
|
||||
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
|
||||
true))
|
||||
(do
|
||||
(acl/load!
|
||||
(list
|
||||
(acl-grant (quote carol) (quote moderate) (quote thread1))))
|
||||
(acl-dt-check!
|
||||
"api permit via current db"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"api deny via current db"
|
||||
(acl/permit? (quote carol) (quote read) (quote thread1))
|
||||
false))
|
||||
(do
|
||||
(acl/load! (list))
|
||||
(acl-dt-check!
|
||||
"api reload clears prior grants"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
false))
|
||||
(acl-dt-check!
|
||||
"schema grant arity valid"
|
||||
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema bad arity invalid"
|
||||
(acl-fact-valid? (list (quote grant) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema unknown predicate invalid"
|
||||
(acl-fact-valid? (list (quote frobnicate) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema subject kind known"
|
||||
(acl-subject-kind? (quote service))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema resource kind unknown"
|
||||
(acl-resource-kind? (quote galaxy))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema known action"
|
||||
(acl-known-action? (quote moderate))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"grant constructor shape"
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
(acl-dt-check!
|
||||
"actor constructor shape"
|
||||
(acl-actor (quote u) (quote user))
|
||||
(list (quote actor) (quote u) (quote user)))))))
|
||||
|
||||
(define
|
||||
acl-direct-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-dt-pass 0)
|
||||
(set! acl-dt-fail 0)
|
||||
(set! acl-dt-failures (list))
|
||||
(acl-dt-run-all!)
|
||||
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))
|
||||
316
lib/acl/tests/explain.sx
Normal file
316
lib/acl/tests/explain.sx
Normal file
@@ -0,0 +1,316 @@
|
||||
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
|
||||
|
||||
(define acl-et-pass 0)
|
||||
(define acl-et-fail 0)
|
||||
(define acl-et-failures (list))
|
||||
|
||||
;; Name-based deep equality. The host `=` compares symbols by interned
|
||||
;; identity, which is unstable across substitution/saturation; comparing by
|
||||
;; name (as the datalog suite does) makes structural assertions deterministic.
|
||||
(define
|
||||
acl-et-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
|
||||
((and (dict? a) (dict? b))
|
||||
(let
|
||||
((ka (keys a)) (kb (keys b)))
|
||||
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
|
||||
(define
|
||||
acl-et-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-et-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-et-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-eq-d?
|
||||
(fn
|
||||
(a b ka i)
|
||||
(cond
|
||||
((>= i (len ka)) true)
|
||||
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
|
||||
false)
|
||||
(else (acl-et-eq-d? a b ka (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-et-eq? got expected)
|
||||
(set! acl-et-pass (+ acl-et-pass 1))
|
||||
(do
|
||||
(set! acl-et-fail (+ acl-et-fail 1))
|
||||
(append!
|
||||
acl-et-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; --- proof-tree walkers ---
|
||||
|
||||
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
|
||||
(define
|
||||
acl-et-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-et-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-leaf? (first nodes) target) true)
|
||||
(else (acl-et-any-leaf? (rest nodes) target)))))
|
||||
|
||||
;; True if the proof records a verified negation (deny did not fire).
|
||||
(define
|
||||
acl-et-has-negok?
|
||||
(fn
|
||||
(node)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :neg-ok)) true)
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-negok? (get node :body)))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-negok?
|
||||
(fn
|
||||
(nodes)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-negok? (first nodes)) true)
|
||||
(else (acl-et-any-negok? (rest nodes))))))
|
||||
|
||||
(define
|
||||
acl-et-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"direct: proof root fact"
|
||||
(get (get e :proof) :fact)
|
||||
(list (quote permit) (quote u) (quote read) (quote p)))
|
||||
(acl-et-check!
|
||||
"direct: grant leaf present"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: negation verified"
|
||||
(acl-et-has-negok? (get e :proof))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: reason nil when allowed"
|
||||
(get e :reason)
|
||||
nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check! "group: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"group: member_of alice leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: member_of team leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote team) (quote org)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: grant org leaf at base"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote org) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote sec))))
|
||||
(do
|
||||
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"resource: child_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote child_of) (quote sec) (quote book)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"resource: grant on parent leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote book)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(let
|
||||
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
|
||||
(do
|
||||
(acl-et-check! "role: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"role: member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote bob) (quote editor)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"role: role_grant leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote role_grant)
|
||||
(quote editor)
|
||||
(quote edit)
|
||||
(quote page1)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote edit) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "deny: no proof" (get e :proof) nil)
|
||||
(acl-et-check!
|
||||
"deny: reason root is eff_deny"
|
||||
(get (get e :reason) :fact)
|
||||
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
|
||||
(acl-et-check!
|
||||
"deny: reason has deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote u) (quote edit) (quote p)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check!
|
||||
"inherited deny: not allowed"
|
||||
(get e :allowed?)
|
||||
false)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has group deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote team) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
|
||||
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
|
||||
(acl-et-check!
|
||||
"audit decide allowed returns true"
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit decide denied returns false"
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
false)
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-et-check!
|
||||
"audit: count after three decisions"
|
||||
(acl-audit-count)
|
||||
3)
|
||||
(acl-et-check!
|
||||
"audit: tail size respects n"
|
||||
(len (acl-audit-tail 2))
|
||||
2)
|
||||
(acl-et-check!
|
||||
"audit: tail returns most recent"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote comment))
|
||||
(acl-et-check!
|
||||
"audit: first record seq is 0"
|
||||
(get (first (acl-audit-tail 3)) :seq)
|
||||
0)
|
||||
(acl-et-check!
|
||||
"audit: allowed flag recorded"
|
||||
(get (first (acl-audit-tail 3)) :allowed?)
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit: serialize line count"
|
||||
(len (acl-et-lines (acl-audit-serialize)))
|
||||
3)
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check!
|
||||
"audit: clear resets count"
|
||||
(acl-audit-count)
|
||||
0))))))
|
||||
|
||||
;; count newline-terminated lines in a serialized log
|
||||
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
|
||||
(define
|
||||
acl-et-count-nl
|
||||
(fn
|
||||
(s i n)
|
||||
(if
|
||||
(>= i (len s))
|
||||
(if (= n 0) (list) (acl-et-rangelist n))
|
||||
(acl-et-count-nl
|
||||
s
|
||||
(+ i 1)
|
||||
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
|
||||
(define
|
||||
acl-et-rangelist
|
||||
(fn
|
||||
(n)
|
||||
(if
|
||||
(<= n 0)
|
||||
(list)
|
||||
(cons n (acl-et-rangelist (- n 1))))))
|
||||
|
||||
(define
|
||||
acl-explain-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-et-pass 0)
|
||||
(set! acl-et-fail 0)
|
||||
(set! acl-et-failures (list))
|
||||
(acl-et-run-all!)
|
||||
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))
|
||||
273
lib/acl/tests/fed.sx
Normal file
273
lib/acl/tests/fed.sx
Normal file
@@ -0,0 +1,273 @@
|
||||
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
|
||||
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
|
||||
|
||||
(define acl-ft-pass 0)
|
||||
(define acl-ft-fail 0)
|
||||
(define acl-ft-failures (list))
|
||||
|
||||
;; Name-based deep equality (host `=` compares symbols by unstable interned
|
||||
;; identity; see lib/acl/tests/explain.sx).
|
||||
(define
|
||||
acl-ft-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
(define
|
||||
acl-ft-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-ft-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-ft-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-ft-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-ft-eq? got expected)
|
||||
(set! acl-ft-pass (+ acl-ft-pass 1))
|
||||
(do
|
||||
(set! acl-ft-fail (+ acl-ft-fail 1))
|
||||
(append!
|
||||
acl-ft-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; proof leaf walker (federated proofs reconstruct through the engine rule).
|
||||
(define
|
||||
acl-ft-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-ft-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-ft-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
(define
|
||||
acl-ft-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-ft-has-leaf? (first nodes) target) true)
|
||||
(else (acl-ft-any-leaf? (rest nodes) target)))))
|
||||
|
||||
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
;; A standard federation fixture: local trusts peer alpha at "readonly", which
|
||||
;; covers read+comment. alpha delegates several capabilities to alice.
|
||||
(define
|
||||
acl-ft-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-trust (quote alpha) (quote readonly))
|
||||
(acl-level-covers (quote readonly) (quote read))
|
||||
(acl-level-covers (quote readonly) (quote comment))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
|
||||
|
||||
(define
|
||||
acl-ft-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level covers action -> permit"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level does NOT cover action -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)
|
||||
(acl-ft-check!
|
||||
"delegated but action class uncovered (comment has no delegate)"
|
||||
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"untrusted peer delegate -> deny"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"trust but no level_covers -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trust is per-peer: alpha's delegate applies"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trust not transitive: beta's delegate does not apply"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"local deny overrides federated grant"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"federated grant to group reaches member"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
|
||||
(acl-ft-check!
|
||||
"federated grant on parent resource reaches child"
|
||||
(acl-ft-p? db (quote u) (quote read) (quote sec))
|
||||
true))
|
||||
(let
|
||||
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fetch known peer returns its delegates"
|
||||
(len (acl-fed-fetch transport (quote alpha)))
|
||||
1)
|
||||
(acl-ft-check!
|
||||
"fetch unknown peer returns empty"
|
||||
(len (acl-fed-fetch transport (quote delta)))
|
||||
0)
|
||||
(acl-ft-check!
|
||||
"collect across peers"
|
||||
(len
|
||||
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
|
||||
2)
|
||||
(let
|
||||
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fed-build-db: alpha delegate permits"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: gamma delegate permits"
|
||||
(acl-ft-p? db (quote carol) (quote read) (quote post))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: untrusted action still denied"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke!
|
||||
db
|
||||
(acl-delegate
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
(acl-ft-check!
|
||||
"after delegate revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before trust revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"after trust revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"delegate without trust: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"trust ingested then re-checked: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
|
||||
(acl-ft-check!
|
||||
"federated proof has delegate leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote delegate)
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has trust leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote trust) (quote alpha) (quote readonly)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has level_covers leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote level_covers) (quote readonly) (quote read)))
|
||||
true))))
|
||||
(acl-ft-check!
|
||||
"schema delegate arity valid"
|
||||
(acl-fact-valid?
|
||||
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema trust arity valid"
|
||||
(acl-fact-valid? (acl-trust (quote p) (quote l)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema peer arity valid"
|
||||
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema level_covers arity valid"
|
||||
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema delegate bad arity invalid"
|
||||
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
|
||||
false))))
|
||||
|
||||
(define
|
||||
acl-fed-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-ft-pass 0)
|
||||
(set! acl-ft-fail 0)
|
||||
(set! acl-ft-failures (list))
|
||||
(acl-ft-run-all!)
|
||||
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))
|
||||
228
lib/acl/tests/harden.sx
Normal file
228
lib/acl/tests/harden.sx
Normal file
@@ -0,0 +1,228 @@
|
||||
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
|
||||
;;
|
||||
;; Diamond hierarchies, conflict resolution where deny must win through every
|
||||
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
|
||||
;; validation, and audit save/restore.
|
||||
;;
|
||||
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
|
||||
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
|
||||
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
|
||||
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
|
||||
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
|
||||
;; reconstruction is covered by tests/explain.sx (and federated proofs by
|
||||
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
|
||||
|
||||
(define acl-hd-pass 0)
|
||||
(define acl-hd-fail 0)
|
||||
(define acl-hd-failures (list))
|
||||
|
||||
(define
|
||||
acl-hd-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-hd-pass (+ acl-hd-pass 1))
|
||||
(do
|
||||
(set! acl-hd-fail (+ acl-hd-fail 1))
|
||||
(append!
|
||||
acl-hd-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-hd-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote p1))
|
||||
(acl-child-of (quote r) (quote p2))
|
||||
(acl-grant (quote u) (quote read) (quote p1))
|
||||
(acl-grant (quote u) (quote read) (quote p2))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond resource: grant+deny parents -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond resource: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"diamond resource: deny does not leak to other parent"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
|
||||
true)))
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote alice) (quote g1))
|
||||
(acl-member-of (quote alice) (quote g2))
|
||||
(acl-grant (quote g1) (quote read) (quote doc))
|
||||
(acl-grant (quote g2) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond group: grant+deny groups -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond group: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
|
||||
(chain-deny
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote a0) (quote a1))
|
||||
(acl-member-of (quote a1) (quote a2))
|
||||
(acl-member-of (quote a2) (quote a3))
|
||||
(acl-member-of (quote a3) (quote a4))
|
||||
(acl-grant (quote a4) (quote read) (quote res))
|
||||
(acl-deny (quote a0) (quote read) (quote res))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"chain: top-group grant reaches leaf member"
|
||||
(acl-hd-p? chain (quote a0) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: intermediate also covered"
|
||||
(acl-hd-p? chain (quote a2) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: leaf-member deny overrides top grant"
|
||||
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"chain: deny on leaf does not block sibling level"
|
||||
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
|
||||
true)))
|
||||
(let
|
||||
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
|
||||
(self-child
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote r))
|
||||
(acl-grant (quote u) (quote read) (quote r)))))
|
||||
(two-cycle
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote x) (quote y))
|
||||
(acl-member-of (quote y) (quote x))
|
||||
(acl-grant (quote y) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"self-membership cycle terminates and grants"
|
||||
(acl-hd-p? self-member (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"self-child cycle terminates and grants"
|
||||
(acl-hd-p? self-child (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"two-node membership cycle terminates"
|
||||
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"federated group grant, local member deny -> deny wins"
|
||||
(acl-hd-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers delegate, one trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers both trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((empty (acl-build-db (list))))
|
||||
(acl-hd-check!
|
||||
"empty db: nothing permitted"
|
||||
(acl-hd-p? empty (quote u) (quote read) (quote r))
|
||||
false))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"validate: clean set has no bad facts"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(acl-member-of (quote u) (quote g))
|
||||
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
|
||||
0)
|
||||
(acl-hd-check!
|
||||
"validate: facts-valid? true on clean set"
|
||||
(acl-facts-valid?
|
||||
(list (acl-grant (quote u) (quote read) (quote p))))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"validate: surfaces wrong-arity and unknown predicate"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u))
|
||||
(list (quote bogus) (quote x) (quote y)))))
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"validate: empty set is valid"
|
||||
(acl-facts-valid? (list))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
(let
|
||||
((snap (acl-audit-snapshot)))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-hd-check!
|
||||
"audit: cleared count is 0"
|
||||
(acl-audit-count)
|
||||
0)
|
||||
(acl-audit-restore! snap)
|
||||
(acl-hd-check!
|
||||
"audit: restored count"
|
||||
(acl-audit-count)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: restored last act"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote edit))
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-hd-check!
|
||||
"audit: seq continues after restore"
|
||||
(get (first (acl-audit-tail 1)) :seq)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: snapshot is an immutable copy"
|
||||
(len (get snap :entries))
|
||||
2)
|
||||
(acl-audit-clear!))))))))
|
||||
|
||||
(define
|
||||
acl-harden-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-hd-pass 0)
|
||||
(set! acl-hd-fail 0)
|
||||
(set! acl-hd-failures (list))
|
||||
(acl-hd-run-all!)
|
||||
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))
|
||||
202
lib/acl/tests/inherit.sx
Normal file
202
lib/acl/tests/inherit.sx
Normal file
@@ -0,0 +1,202 @@
|
||||
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
|
||||
;; role expansion) with deny-overrides.
|
||||
|
||||
(define acl-it-pass 0)
|
||||
(define acl-it-fail 0)
|
||||
(define acl-it-failures (list))
|
||||
|
||||
(define
|
||||
acl-it-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-it-pass (+ acl-it-pass 1))
|
||||
(do
|
||||
(set! acl-it-fail (+ acl-it-fail 1))
|
||||
(append!
|
||||
acl-it-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-it-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group grant reaches member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group grant: non-member excluded"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group grant: wrong action"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deep nested group grant reaches leaf member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"intermediate group also covered"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"mid group org covered"
|
||||
(acl-it-p? db (quote org) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"cyclic membership terminates and grants"
|
||||
(acl-it-p? db (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"cyclic membership covers both"
|
||||
(acl-it-p? db (quote b) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"parent grant reaches direct child"
|
||||
(acl-it-p? db (quote u) (quote read) (quote chap))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant reaches deep descendant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant covers parent itself"
|
||||
(acl-it-p? db (quote u) (quote read) (quote book))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"child grant does not climb to parent"
|
||||
(acl-it-p?
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote sec) (quote book))
|
||||
(acl-grant (quote u) (quote read) (quote sec))))
|
||||
(quote u)
|
||||
(quote read)
|
||||
(quote book))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group + resource: member on child resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote post1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group + resource: member on parent resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote board))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"role confers edit to member"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role confers read to member"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role: capability not in tuple denied"
|
||||
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"role: non-member excluded"
|
||||
(acl-it-p? db (quote eve) (quote edit) (quote page1))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(acl-it-check!
|
||||
"role grant flows to child resource"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote draft))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"explicit deny beats inherited group allow"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group deny inherits and overrides direct grant"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group deny: another member also blocked"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
|
||||
(acl-it-check!
|
||||
"ancestor deny overrides descendant grant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on edit leaves inherited read intact"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"deny on edit blocks edit"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"inherited deny, no grant: denied"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on sibling a blocks a"
|
||||
(acl-it-p? db (quote u) (quote read) (quote a))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"deny on sibling a leaves b permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote b))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"root itself still permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote root))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
|
||||
(acl-it-check!
|
||||
"direct grant under inheritance ruleset"
|
||||
(acl-it-p? db (quote x) (quote read) (quote y))
|
||||
true)))))
|
||||
|
||||
(define
|
||||
acl-inherit-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-it-pass 0)
|
||||
(set! acl-it-fail 0)
|
||||
(set! acl-it-failures (list))
|
||||
(acl-it-run-all!)
|
||||
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))
|
||||
63
lib/apl/conformance.conf
Normal file
63
lib/apl/conformance.conf
Normal file
@@ -0,0 +1,63 @@
|
||||
# APL conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=apl
|
||||
MODE=counters
|
||||
COUNTERS_PASS=apl-test-pass
|
||||
COUNTERS_FAIL=apl-test-fail
|
||||
TIMEOUT_PER_SUITE=300
|
||||
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/r7rs.sx
|
||||
lib/apl/runtime.sx
|
||||
lib/apl/tokenizer.sx
|
||||
lib/apl/parser.sx
|
||||
lib/apl/transpile.sx
|
||||
lib/apl/test-harness.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"structural:lib/apl/tests/structural.sx"
|
||||
"operators:lib/apl/tests/operators.sx"
|
||||
"dfn:lib/apl/tests/dfn.sx"
|
||||
"tradfn:lib/apl/tests/tradfn.sx"
|
||||
"valence:lib/apl/tests/valence.sx"
|
||||
"programs:lib/apl/tests/programs.sx"
|
||||
"system:lib/apl/tests/system.sx"
|
||||
"idioms:lib/apl/tests/idioms.sx"
|
||||
"eval-ops:lib/apl/tests/eval-ops.sx"
|
||||
"pipeline:lib/apl/tests/pipeline.sx"
|
||||
)
|
||||
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i sep
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
sep=","; [ $i -eq $((n-1)) ] && sep=""
|
||||
printf ' "%s": {"pass": %d, "fail": %d}%s\n' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "$sep"
|
||||
done
|
||||
printf ' },\n'
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$GC_TOTAL"
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i
|
||||
printf '# APL Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
printf '| %s | %d | %d | %d |\n' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "${GC_TOTAL_S[$i]}"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
|
||||
printf '\n'
|
||||
printf '## Notes\n\n'
|
||||
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||
}
|
||||
@@ -1,116 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITES=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
|
||||
|
||||
OUT_JSON="lib/apl/scoreboard.json"
|
||||
OUT_MD="lib/apl/scoreboard.md"
|
||||
|
||||
run_suite() {
|
||||
local suite=$1
|
||||
local file="lib/apl/tests/${suite}.sx"
|
||||
local TMP
|
||||
TMP=$(mktemp)
|
||||
cat > "$TMP" << EPOCHS
|
||||
(epoch 1)
|
||||
(load "spec/stdlib.sx")
|
||||
(load "lib/r7rs.sx")
|
||||
(load "lib/apl/runtime.sx")
|
||||
(load "lib/apl/tokenizer.sx")
|
||||
(load "lib/apl/parser.sx")
|
||||
(load "lib/apl/transpile.sx")
|
||||
(epoch 2)
|
||||
(eval "(define apl-test-pass 0)")
|
||||
(eval "(define apl-test-fail 0)")
|
||||
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
|
||||
(epoch 3)
|
||||
(load "${file}")
|
||||
(epoch 4)
|
||||
(eval "(list apl-test-pass apl-test-fail)")
|
||||
EPOCHS
|
||||
|
||||
local OUTPUT
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
|
||||
local LINE
|
||||
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||
if [ -z "$LINE" ]; then
|
||||
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||
fi
|
||||
|
||||
local P F
|
||||
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||
P=${P:-0}
|
||||
F=${F:-0}
|
||||
echo "${P} ${F}"
|
||||
}
|
||||
|
||||
declare -A SUITE_PASS
|
||||
declare -A SUITE_FAIL
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
|
||||
echo "Running APL conformance suite..." >&2
|
||||
for s in "${SUITES[@]}"; do
|
||||
read -r p f < <(run_suite "$s")
|
||||
SUITE_PASS[$s]=$p
|
||||
SUITE_FAIL[$s]=$f
|
||||
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||
done
|
||||
|
||||
# scoreboard.json
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
first=1
|
||||
for s in "${SUITES[@]}"; do
|
||||
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||
first=0
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '}\n'
|
||||
} > "$OUT_JSON"
|
||||
|
||||
# scoreboard.md
|
||||
{
|
||||
printf '# APL Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for s in "${SUITES[@]}"; do
|
||||
p=${SUITE_PASS[$s]}
|
||||
f=${SUITE_FAIL[$s]}
|
||||
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '\n'
|
||||
printf '## Notes\n\n'
|
||||
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||
} > "$OUT_MD"
|
||||
|
||||
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
|
||||
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
|
||||
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"system": {"pass": 13, "fail": 0},
|
||||
"idioms": {"pass": 64, "fail": 0},
|
||||
"eval-ops": {"pass": 14, "fail": 0},
|
||||
"pipeline": {"pass": 40, "fail": 0}
|
||||
"pipeline": {"pass": 152, "fail": 0}
|
||||
},
|
||||
"total_pass": 450,
|
||||
"total_pass": 562,
|
||||
"total_fail": 0,
|
||||
"total": 450
|
||||
"total": 562
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
|
||||
| system | 13 | 0 | 13 |
|
||||
| idioms | 64 | 0 | 64 |
|
||||
| eval-ops | 14 | 0 | 14 |
|
||||
| pipeline | 40 | 0 | 40 |
|
||||
| **Total** | **450** | **0** | **450** |
|
||||
| pipeline | 152 | 0 | 152 |
|
||||
| **Total** | **562** | **0** | **562** |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
15
lib/apl/test-harness.sx
Normal file
15
lib/apl/test-harness.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
; lib/apl/test-harness.sx — counters + assertion fn for the shared conformance
|
||||
; driver (lib/guest/conformance.sh, MODE=counters). Loaded as a PRELOAD so each
|
||||
; suite starts from a fresh 0/0; suites call (apl-test name got expected).
|
||||
|
||||
(define apl-test-pass 0)
|
||||
(define apl-test-fail 0)
|
||||
|
||||
(define
|
||||
apl-test
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! apl-test-pass (+ apl-test-pass 1))
|
||||
(set! apl-test-fail (+ apl-test-fail 1)))))
|
||||
@@ -33,3 +33,54 @@ least: persistent (path-copying) envs, an inline scheduler that
|
||||
doesn't call/cc on the common path (msg-already-in-mailbox), and a
|
||||
linked-list mailbox. None of those are in scope for the Phase 3
|
||||
checkbox — captured here as the floor we're starting from.
|
||||
|
||||
## Phase 9 status (2026-05-14)
|
||||
|
||||
Specialized opcodes 9b–9f landed as **stub dispatchers** in
|
||||
`lib/erlang/vm/dispatcher.sx`: `OP_PATTERN_TUPLE/LIST/BINARY`,
|
||||
`OP_PERFORM/HANDLE`, `OP_RECEIVE_SCAN`, `OP_SPAWN/SEND`, and ten
|
||||
`OP_BIF_*` hot dispatch entries. Each opcode's handler is a thin
|
||||
wrapper over the existing `er-match-*` / `er-bif-*` / runtime impls,
|
||||
so **the perf numbers above are unchanged** — same per-hop cost, same
|
||||
scheduler. The stubs exist to nail down opcode IDs, operand contracts,
|
||||
and tests against `er-match!` parity *before* 9a (the OCaml
|
||||
opcode-extension mechanism in `hosts/ocaml/evaluator/`) lands.
|
||||
|
||||
When 9a integrates and the bytecode compiler can emit these opcodes
|
||||
at hot call sites, the real speedup story (~3000× ring throughput,
|
||||
~1000× spawn) starts. Until then this file documents the
|
||||
pre-integration ceiling. 72 vm-suite tests guard the stub correctness;
|
||||
full conformance is **709/709** with the stub infrastructure loaded.
|
||||
|
||||
## Phase 9g — post-integration bench (2026-05-15)
|
||||
|
||||
9a (vm-ext mechanism), 9h (`erlang_ext.ml` registering `erlang.OP_*`
|
||||
ids 222-239), and 9i (SX dispatcher consulting `extension-opcode-id`)
|
||||
are now integrated and built into `hosts/ocaml/_build/default/bin/sx_server.exe`.
|
||||
Re-ran the ring ladder on that binary:
|
||||
|
||||
| N (processes) | Hops | Wall-clock | Throughput |
|
||||
|---|---|---|---|
|
||||
| 10 | 10 | 938ms | 11 hops/s |
|
||||
| 100 | 100 | 2772ms | 36 hops/s |
|
||||
| 500 | 500 | 14190ms | 35 hops/s |
|
||||
| 1000 | 1000 | 31814ms | 31 hops/s |
|
||||
|
||||
**Numbers are unchanged from the pre-integration baseline** — and that
|
||||
is the expected, correct result. The opcode handlers (both the SX stub
|
||||
dispatcher and the OCaml `erlang_ext` module) wrap the existing
|
||||
`er-match-*` / `er-bif-*` / scheduler implementations 1-to-1, and the
|
||||
**bytecode compiler does not yet emit `erlang.OP_*` opcodes**, so every
|
||||
hop still goes through the general CEK path exactly as before. The
|
||||
unchanged numbers therefore double as a no-regression check: the full
|
||||
extension wiring (cherry-picked vm-ext A-E + force-link + erlang_ext +
|
||||
SX bridge) added zero per-hop cost. Conformance **715/715** on this
|
||||
binary.
|
||||
|
||||
The ~3000×/~1000× targets remain gated on a **future phase (Phase 10 —
|
||||
bytecode emission)**: teach `lib/compiler.sx` (or the Erlang
|
||||
transpiler) to emit `erlang.OP_PATTERN_TUPLE` etc. at hot call sites,
|
||||
then give `erlang_ext.ml` real register-machine handlers instead of the
|
||||
current honest not-wired raise. That is a substantial standalone phase,
|
||||
tracked in `plans/erlang-on-sx.md`. 9g's deliverable — *honest
|
||||
measurement + recorded numbers on the integrated binary* — is complete.
|
||||
|
||||
@@ -36,6 +36,8 @@ SUITES=(
|
||||
"bank|er-bank-test-pass|er-bank-test-count"
|
||||
"echo|er-echo-test-pass|er-echo-test-count"
|
||||
"fib|er-fib-test-pass|er-fib-test-count"
|
||||
"ffi|er-ffi-test-pass|er-ffi-test-count"
|
||||
"vm|er-vm-test-pass|er-vm-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
@@ -56,6 +58,9 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(load "lib/erlang/tests/programs/bank.sx")
|
||||
(load "lib/erlang/tests/programs/echo.sx")
|
||||
(load "lib/erlang/tests/programs/fib_server.sx")
|
||||
(load "lib/erlang/vm/dispatcher.sx")
|
||||
(load "lib/erlang/tests/ffi.sx")
|
||||
(load "lib/erlang/tests/vm.sx")
|
||||
(epoch 100)
|
||||
(eval "(list er-test-pass er-test-count)")
|
||||
(epoch 101)
|
||||
@@ -74,6 +79,10 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(eval "(list er-echo-test-pass er-echo-test-count)")
|
||||
(epoch 108)
|
||||
(eval "(list er-fib-test-pass er-fib-test-count)")
|
||||
(epoch 109)
|
||||
(eval "(list er-ffi-test-pass er-ffi-test-count)")
|
||||
(epoch 110)
|
||||
(eval "(list er-vm-test-pass er-vm-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
@@ -853,6 +853,112 @@
|
||||
(define er-modules-get (fn () (nth er-modules 0)))
|
||||
(define er-modules-reset! (fn () (set-nth! er-modules 0 {})))
|
||||
|
||||
(define er-mk-module-slot
|
||||
(fn (mod-env old-env version)
|
||||
{:current mod-env :old old-env :version version :tag "module"}))
|
||||
|
||||
(define er-module-current-env (fn (slot) (get slot :current)))
|
||||
(define er-module-old-env (fn (slot) (get slot :old)))
|
||||
(define er-module-version (fn (slot) (get slot :version)))
|
||||
|
||||
;; ── FFI BIF registry (Phase 8) ───────────────────────────────────
|
||||
;; Global dict from "Module/Name/Arity" key to {:module :name :arity :fn :pure?}.
|
||||
;; Replaces the giant cond chain in transpile.sx#er-apply-remote-bif over time —
|
||||
;; Phase 8 BIFs (crypto / cid / file / httpc / sqlite) all register here.
|
||||
(define er-bif-registry (list {}))
|
||||
(define er-bif-registry-get (fn () (nth er-bif-registry 0)))
|
||||
(define er-bif-registry-reset! (fn () (set-nth! er-bif-registry 0 {})))
|
||||
|
||||
(define er-bif-key
|
||||
(fn (module name arity)
|
||||
(str module "/" name "/" arity)))
|
||||
|
||||
(define er-register-bif!
|
||||
(fn (module name arity sx-fn)
|
||||
(dict-set! (er-bif-registry-get) (er-bif-key module name arity)
|
||||
{:module module :name name :arity arity :fn sx-fn :pure? false})
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(define er-register-pure-bif!
|
||||
(fn (module name arity sx-fn)
|
||||
(dict-set! (er-bif-registry-get) (er-bif-key module name arity)
|
||||
{:module module :name name :arity arity :fn sx-fn :pure? true})
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(define er-lookup-bif
|
||||
(fn (module name arity)
|
||||
(let ((reg (er-bif-registry-get)) (k (er-bif-key module name arity)))
|
||||
(if (dict-has? reg k) (get reg k) nil))))
|
||||
|
||||
(define er-list-bifs
|
||||
(fn () (keys (er-bif-registry-get))))
|
||||
|
||||
;; ── term marshalling (Phase 8) ───────────────────────────────────
|
||||
;; Bridge Erlang term values (tagged dicts) and SX-native values for
|
||||
;; FFI BIFs to call out into platform primitives. Conversions:
|
||||
;;
|
||||
;; Erlang SX-native
|
||||
;; ───────────────────────── ────────────────
|
||||
;; atom {:tag "atom" :name S} ↔ symbol (make-symbol S)
|
||||
;; nil {:tag "nil"} ↔ '()
|
||||
;; cons {:tag "cons" :head :tail} → list of marshalled elements
|
||||
;; tuple {:tag "tuple" :elements} → list of marshalled elements
|
||||
;; binary {:tag "binary" :bytes} ↔ SX string
|
||||
;; integer / float / boolean ↔ passthrough
|
||||
;; SX string on the way back → binary
|
||||
;;
|
||||
;; Pids, refs, funs pass through unchanged — they have no SX-native
|
||||
;; equivalent and are opaque to FFI primitives.
|
||||
|
||||
(define er-cons-to-sx-list
|
||||
(fn (v)
|
||||
(cond
|
||||
(er-nil? v) (list)
|
||||
(er-cons? v)
|
||||
(let ((tail (er-cons-to-sx-list (get v :tail)))
|
||||
(head (er-to-sx (get v :head))))
|
||||
(let ((out (list head)))
|
||||
(for-each
|
||||
(fn (i) (append! out (nth tail i)))
|
||||
(range 0 (len tail)))
|
||||
out))
|
||||
:else (list v))))
|
||||
|
||||
(define er-to-sx
|
||||
(fn (v)
|
||||
(cond
|
||||
(er-atom? v) (make-symbol (get v :name))
|
||||
(er-nil? v) (list)
|
||||
(er-cons? v) (er-cons-to-sx-list v)
|
||||
(er-tuple? v)
|
||||
(let ((out (list)) (es (get v :elements)))
|
||||
(for-each
|
||||
(fn (i) (append! out (er-to-sx (nth es i))))
|
||||
(range 0 (len es)))
|
||||
out)
|
||||
(er-binary? v) (list->string (map integer->char (get v :bytes)))
|
||||
:else v)))
|
||||
|
||||
(define er-of-sx
|
||||
(fn (v)
|
||||
(let ((ty (type-of v)))
|
||||
(cond
|
||||
(= ty "symbol") (er-mk-atom (str v))
|
||||
(= ty "string") (er-mk-binary (map char->integer (string->list v)))
|
||||
(= ty "list")
|
||||
(let ((out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(set! out
|
||||
(er-mk-cons (er-of-sx (nth v (- (- (len v) 1) i))) out)))
|
||||
(range 0 (len v)))
|
||||
out)
|
||||
(= ty "nil") (er-mk-nil)
|
||||
:else v))))
|
||||
|
||||
|
||||
|
||||
|
||||
;; Load an Erlang module declaration. Source must start with
|
||||
;; `-module(Name).` and contain function definitions. Functions
|
||||
;; sharing a name (different arities) get their clauses concatenated
|
||||
@@ -897,7 +1003,15 @@
|
||||
((all-clauses (get by-name k)))
|
||||
(er-env-bind! mod-env k (er-mk-fun all-clauses mod-env))))
|
||||
(keys by-name))
|
||||
(dict-set! (er-modules-get) mod-name mod-env)
|
||||
(let ((registry (er-modules-get)))
|
||||
(if (dict-has? registry mod-name)
|
||||
(let ((existing-slot (get registry mod-name)))
|
||||
(dict-set! registry mod-name
|
||||
(er-mk-module-slot mod-env
|
||||
(er-module-current-env existing-slot)
|
||||
(+ (er-module-version existing-slot) 1))))
|
||||
(dict-set! registry mod-name
|
||||
(er-mk-module-slot mod-env nil 1))))
|
||||
(er-mk-atom mod-name)))))
|
||||
|
||||
(define
|
||||
@@ -905,7 +1019,7 @@
|
||||
(fn
|
||||
(mod name vs)
|
||||
(let
|
||||
((mod-env (get (er-modules-get) mod)))
|
||||
((mod-env (er-module-current-env (get (er-modules-get) mod))))
|
||||
(if
|
||||
(not (dict-has? mod-env name))
|
||||
(raise
|
||||
@@ -1189,16 +1303,325 @@
|
||||
:else (er-mk-atom "undefined")))
|
||||
:else (error "Erlang: ets:info: arity"))))
|
||||
|
||||
(define
|
||||
er-apply-ets-bif
|
||||
(fn
|
||||
(name vs)
|
||||
|
||||
|
||||
;; ── file module (Phase 8 FFI) ────────────────────────────────────
|
||||
;; Synchronous file IO. Filenames must be SX strings (or Erlang
|
||||
;; binaries/char-code lists coercible to strings via er-source-to-string).
|
||||
;; Returns `{ok, Binary}` / `ok` on success, `{error, Reason}` on failure
|
||||
;; where Reason is one of `enoent`, `eacces`, `enotdir`, `posix_error`.
|
||||
|
||||
(define er-classify-file-error
|
||||
(fn (msg)
|
||||
(let ((s (str msg)))
|
||||
(cond
|
||||
(string-contains? s "No such") (er-mk-atom "enoent")
|
||||
(string-contains? s "Permission denied") (er-mk-atom "eacces")
|
||||
(string-contains? s "Not a directory") (er-mk-atom "enotdir")
|
||||
(string-contains? s "Is a directory") (er-mk-atom "eisdir")
|
||||
:else (er-mk-atom "posix_error")))))
|
||||
|
||||
(define er-bif-file-read-file
|
||||
(fn (vs)
|
||||
(let ((path (er-source-to-string (nth vs 0))))
|
||||
(cond
|
||||
(= path nil)
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((res (list nil)) (err (list nil)))
|
||||
(guard (c (:else (set-nth! err 0 c)))
|
||||
(set-nth! res 0 (file-read path)))
|
||||
(cond
|
||||
(not (= (nth err 0) nil))
|
||||
(er-mk-tuple (list (er-mk-atom "error")
|
||||
(er-classify-file-error (nth err 0))))
|
||||
:else
|
||||
(er-mk-tuple (list (er-mk-atom "ok")
|
||||
(er-mk-binary (map char->integer (string->list (nth res 0))))))))))))
|
||||
|
||||
(define er-bif-file-write-file
|
||||
(fn (vs)
|
||||
(let ((path (er-source-to-string (nth vs 0)))
|
||||
(data (er-source-to-string (nth vs 1))))
|
||||
(cond
|
||||
(or (= path nil) (= data nil))
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((err (list nil)))
|
||||
(guard (c (:else (set-nth! err 0 c)))
|
||||
(file-write path data))
|
||||
(cond
|
||||
(not (= (nth err 0) nil))
|
||||
(er-mk-tuple (list (er-mk-atom "error")
|
||||
(er-classify-file-error (nth err 0))))
|
||||
:else (er-mk-atom "ok")))))))
|
||||
|
||||
(define er-bif-file-delete
|
||||
(fn (vs)
|
||||
(let ((path (er-source-to-string (nth vs 0))))
|
||||
(cond
|
||||
(= path nil)
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((err (list nil)))
|
||||
(guard (c (:else (set-nth! err 0 c)))
|
||||
(file-delete path))
|
||||
(cond
|
||||
(not (= (nth err 0) nil))
|
||||
(er-mk-tuple (list (er-mk-atom "error")
|
||||
(er-classify-file-error (nth err 0))))
|
||||
:else (er-mk-atom "ok")))))))
|
||||
|
||||
|
||||
;; ── crypto / cid / file:list_dir (Phase 8 FFI — host primitives) ──
|
||||
;; Wired against loops/fed-prims host primitives (see plans Blockers
|
||||
;; "RESOLVED 2026-05-18"). Term marshalling at the boundary:
|
||||
;; Erlang binary/string/charlist -> SX byte-string via er-source-to-string;
|
||||
;; results -> Erlang binary via er-mk-binary.
|
||||
|
||||
(define er-hexval
|
||||
(fn (c)
|
||||
(let ((v (char->integer c)))
|
||||
(cond
|
||||
(and (>= v 48) (<= v 57)) (- v 48) ;; 0-9
|
||||
(and (>= v 97) (<= v 102)) (+ 10 (- v 97)) ;; a-f
|
||||
(and (>= v 65) (<= v 70)) (+ 10 (- v 65)) ;; A-F
|
||||
:else 0))))
|
||||
|
||||
(define er-hex->bytes
|
||||
(fn (hex)
|
||||
(let ((cs (string->list hex)) (out (list)) (n (string-length hex)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(append! out
|
||||
(+ (* 16 (er-hexval (nth cs (* i 2))))
|
||||
(er-hexval (nth cs (+ (* i 2) 1))))))
|
||||
(range 0 (truncate (/ n 2))))
|
||||
out)))
|
||||
|
||||
;; crypto:hash(Type, Data) -> raw digest binary. Type is an Erlang
|
||||
;; atom (sha256 | sha512 | sha3_256). Bad type / non-binary -> badarg.
|
||||
(define er-bif-crypto-hash
|
||||
(fn (vs)
|
||||
(let ((ty (nth vs 0)) (data (er-source-to-string (nth vs 1))))
|
||||
(cond
|
||||
(or (not (er-atom? ty)) (= data nil))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((name (get ty :name)))
|
||||
(let ((hex (cond
|
||||
(= name "sha256") (crypto-sha256 data)
|
||||
(= name "sha512") (crypto-sha512 data)
|
||||
(= name "sha3_256") (crypto-sha3-256 data)
|
||||
:else nil)))
|
||||
(cond
|
||||
(= hex nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else (er-mk-binary (er-hex->bytes hex)))))))))
|
||||
|
||||
;; cid:from_bytes(Bin) -> CIDv1 (raw codec 0x55, sha2-256 multihash)
|
||||
;; as an Erlang binary string.
|
||||
(define er-bif-cid-from-bytes
|
||||
(fn (vs)
|
||||
(let ((data (er-source-to-string (nth vs 0))))
|
||||
(cond
|
||||
(= data nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((digest (er-hex->bytes (crypto-sha256 data))))
|
||||
(let ((mh (list->string
|
||||
(map integer->char (append (list 18 32) digest)))))
|
||||
(er-mk-binary
|
||||
(map char->integer
|
||||
(string->list (cid-from-bytes 85 mh))))))))))
|
||||
|
||||
;; cid:to_string(Term) -> canonical CIDv1 (dag-cbor) of the term,
|
||||
;; as an Erlang binary string.
|
||||
(define er-bif-cid-to-string
|
||||
(fn (vs)
|
||||
;; Canonical CID of the term's stable string form. (cbor-encode
|
||||
;; rejects symbols, so er-to-sx of compound terms is unencodable;
|
||||
;; er-format-value yields a canonical SX string per term value.)
|
||||
(er-mk-binary
|
||||
(map char->integer
|
||||
(string->list (cid-from-sx (er-format-value (nth vs 0))))))))
|
||||
|
||||
;; file:list_dir(Path) -> {ok, [Binary]} | {error, Reason}
|
||||
(define er-bif-file-list-dir
|
||||
(fn (vs)
|
||||
(let ((path (er-source-to-string (nth vs 0))))
|
||||
(cond
|
||||
(= path nil)
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((res (list nil)) (err (list nil)))
|
||||
(guard (c (:else (set-nth! err 0 c)))
|
||||
(set-nth! res 0 (file-list-dir path)))
|
||||
(cond
|
||||
(not (= (nth err 0) nil))
|
||||
(er-mk-tuple (list (er-mk-atom "error")
|
||||
(er-classify-file-error (nth err 0))))
|
||||
:else
|
||||
(er-mk-tuple (list (er-mk-atom "ok")
|
||||
(er-of-sx (nth res 0))))))))))
|
||||
|
||||
;; ── builtin BIF registrations (Phase 8 migration) ────────────────
|
||||
;; Populates `er-bif-registry` with every existing built-in BIF. Each
|
||||
;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register
|
||||
;; once per arity. Called eagerly at the end of runtime.sx so the
|
||||
;; registry is ready before any erlang-eval-ast call.
|
||||
(define er-register-builtin-bifs!
|
||||
(fn ()
|
||||
;; erlang module — type predicates (all pure)
|
||||
(er-register-pure-bif! "erlang" "is_integer" 1 er-bif-is-integer)
|
||||
(er-register-pure-bif! "erlang" "is_atom" 1 er-bif-is-atom)
|
||||
(er-register-pure-bif! "erlang" "is_list" 1 er-bif-is-list)
|
||||
(er-register-pure-bif! "erlang" "is_tuple" 1 er-bif-is-tuple)
|
||||
(er-register-pure-bif! "erlang" "is_number" 1 er-bif-is-number)
|
||||
(er-register-pure-bif! "erlang" "is_float" 1 er-bif-is-float)
|
||||
(er-register-pure-bif! "erlang" "is_boolean" 1 er-bif-is-boolean)
|
||||
(er-register-pure-bif! "erlang" "is_pid" 1 er-bif-is-pid)
|
||||
(er-register-pure-bif! "erlang" "is_reference" 1 er-bif-is-reference)
|
||||
(er-register-pure-bif! "erlang" "is_binary" 1 er-bif-is-binary)
|
||||
(er-register-pure-bif! "erlang" "is_function" 1 er-bif-is-function)
|
||||
(er-register-pure-bif! "erlang" "is_function" 2 er-bif-is-function)
|
||||
;; erlang module — pure data ops
|
||||
(er-register-pure-bif! "erlang" "length" 1 er-bif-length)
|
||||
(er-register-pure-bif! "erlang" "hd" 1 er-bif-hd)
|
||||
(er-register-pure-bif! "erlang" "tl" 1 er-bif-tl)
|
||||
(er-register-pure-bif! "erlang" "element" 2 er-bif-element)
|
||||
(er-register-pure-bif! "erlang" "tuple_size" 1 er-bif-tuple-size)
|
||||
(er-register-pure-bif! "erlang" "byte_size" 1 er-bif-byte-size)
|
||||
(er-register-pure-bif! "erlang" "atom_to_list" 1 er-bif-atom-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_atom" 1 er-bif-list-to-atom)
|
||||
(er-register-pure-bif! "erlang" "abs" 1 er-bif-abs)
|
||||
(er-register-pure-bif! "erlang" "min" 2 er-bif-min)
|
||||
(er-register-pure-bif! "erlang" "max" 2 er-bif-max)
|
||||
(er-register-pure-bif! "erlang" "tuple_to_list" 1 er-bif-tuple-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_tuple" 1 er-bif-list-to-tuple)
|
||||
(er-register-pure-bif! "erlang" "integer_to_list" 1 er-bif-integer-to-list)
|
||||
(er-register-pure-bif! "erlang" "list_to_integer" 1 er-bif-list-to-integer)
|
||||
;; erlang module — process / runtime (side-effecting)
|
||||
(er-register-bif! "erlang" "self" 0 er-bif-self)
|
||||
(er-register-bif! "erlang" "spawn" 1 er-bif-spawn)
|
||||
(er-register-bif! "erlang" "spawn" 3 er-bif-spawn)
|
||||
(er-register-bif! "erlang" "exit" 1 er-bif-exit)
|
||||
(er-register-bif! "erlang" "exit" 2 er-bif-exit)
|
||||
(er-register-bif! "erlang" "make_ref" 0 er-bif-make-ref)
|
||||
(er-register-bif! "erlang" "link" 1 er-bif-link)
|
||||
(er-register-bif! "erlang" "unlink" 1 er-bif-unlink)
|
||||
(er-register-bif! "erlang" "monitor" 2 er-bif-monitor)
|
||||
(er-register-bif! "erlang" "demonitor" 1 er-bif-demonitor)
|
||||
(er-register-bif! "erlang" "process_flag" 2 er-bif-process-flag)
|
||||
(er-register-bif! "erlang" "register" 2 er-bif-register)
|
||||
(er-register-bif! "erlang" "unregister" 1 er-bif-unregister)
|
||||
(er-register-bif! "erlang" "whereis" 1 er-bif-whereis)
|
||||
(er-register-bif! "erlang" "registered" 0 er-bif-registered)
|
||||
;; erlang module — exception raising (modelled as side-effecting)
|
||||
(er-register-bif! "erlang" "throw" 1
|
||||
(fn (vs) (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))))
|
||||
(er-register-bif! "erlang" "error" 1
|
||||
(fn (vs) (raise (er-mk-error-marker (er-bif-arg1 vs "error")))))
|
||||
;; lists module — all pure
|
||||
(er-register-pure-bif! "lists" "reverse" 1 er-bif-lists-reverse)
|
||||
(er-register-pure-bif! "lists" "map" 2 er-bif-lists-map)
|
||||
(er-register-pure-bif! "lists" "foldl" 3 er-bif-lists-foldl)
|
||||
(er-register-pure-bif! "lists" "seq" 2 er-bif-lists-seq)
|
||||
(er-register-pure-bif! "lists" "seq" 3 er-bif-lists-seq)
|
||||
(er-register-pure-bif! "lists" "sum" 1 er-bif-lists-sum)
|
||||
(er-register-pure-bif! "lists" "nth" 2 er-bif-lists-nth)
|
||||
(er-register-pure-bif! "lists" "last" 1 er-bif-lists-last)
|
||||
(er-register-pure-bif! "lists" "member" 2 er-bif-lists-member)
|
||||
(er-register-pure-bif! "lists" "append" 2 er-bif-lists-append)
|
||||
(er-register-pure-bif! "lists" "filter" 2 er-bif-lists-filter)
|
||||
(er-register-pure-bif! "lists" "any" 2 er-bif-lists-any)
|
||||
(er-register-pure-bif! "lists" "all" 2 er-bif-lists-all)
|
||||
(er-register-pure-bif! "lists" "duplicate" 2 er-bif-lists-duplicate)
|
||||
;; io module — side-effecting (writes to io buffer)
|
||||
(er-register-bif! "io" "format" 1 er-bif-io-format)
|
||||
(er-register-bif! "io" "format" 2 er-bif-io-format)
|
||||
;; ets module — side-effecting (mutates table state)
|
||||
(er-register-bif! "ets" "new" 2 er-bif-ets-new)
|
||||
(er-register-bif! "ets" "insert" 2 er-bif-ets-insert)
|
||||
(er-register-bif! "ets" "lookup" 2 er-bif-ets-lookup)
|
||||
(er-register-bif! "ets" "delete" 1 er-bif-ets-delete)
|
||||
(er-register-bif! "ets" "delete" 2 er-bif-ets-delete)
|
||||
(er-register-bif! "ets" "tab2list" 1 er-bif-ets-tab2list)
|
||||
(er-register-bif! "ets" "info" 2 er-bif-ets-info)
|
||||
;; code module — side-effecting (mutates module registry, kills procs)
|
||||
(er-register-bif! "code" "load_binary" 3 er-bif-code-load-binary)
|
||||
(er-register-bif! "code" "purge" 1 er-bif-code-purge)
|
||||
(er-register-bif! "code" "soft_purge" 1 er-bif-code-soft-purge)
|
||||
(er-register-bif! "code" "which" 1 er-bif-code-which)
|
||||
(er-register-bif! "code" "is_loaded" 1 er-bif-code-is-loaded)
|
||||
(er-register-bif! "code" "all_loaded" 0 er-bif-code-all-loaded)
|
||||
;; file module
|
||||
(er-register-bif! "file" "read_file" 1 er-bif-file-read-file)
|
||||
(er-register-bif! "file" "write_file" 2 er-bif-file-write-file)
|
||||
(er-register-bif! "file" "delete" 1 er-bif-file-delete)
|
||||
;; Phase 8 FFI — host-primitive BIFs (loops/fed-prims)
|
||||
(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
|
||||
(= name "new") (er-bif-ets-new vs)
|
||||
(= name "insert") (er-bif-ets-insert vs)
|
||||
(= name "lookup") (er-bif-ets-lookup vs)
|
||||
(= name "delete") (er-bif-ets-delete vs)
|
||||
(= name "tab2list") (er-bif-ets-tab2list vs)
|
||||
(= name "info") (er-bif-ets-info vs)
|
||||
:else (error
|
||||
(str "Erlang: undefined 'ets:" name "/" (len vs) "'")))))
|
||||
(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.
|
||||
(er-register-builtin-bifs!)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"language": "erlang",
|
||||
"total_pass": 530,
|
||||
"total": 530,
|
||||
"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":346,"total":346,"status":"ok"},
|
||||
{"name":"runtime","pass":39,"total":39,"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":"fib","pass":8,"total":8,"status":"ok"},
|
||||
{"name":"ffi","pass":37,"total":37,"status":"ok"},
|
||||
{"name":"vm","pass":78,"total":78,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# Erlang-on-SX Scoreboard
|
||||
|
||||
**Total: 530 / 530 tests passing**
|
||||
**Total: 761 / 761 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | tokenize | 62 | 62 |
|
||||
| ✅ | parse | 52 | 52 |
|
||||
| ✅ | eval | 346 | 346 |
|
||||
| ✅ | runtime | 39 | 39 |
|
||||
| ✅ | eval | 408 | 408 |
|
||||
| ✅ | runtime | 93 | 93 |
|
||||
| ✅ | ring | 4 | 4 |
|
||||
| ✅ | ping-pong | 4 | 4 |
|
||||
| ✅ | bank | 8 | 8 |
|
||||
| ✅ | echo | 7 | 7 |
|
||||
| ✅ | fib | 8 | 8 |
|
||||
| ✅ | ffi | 37 | 37 |
|
||||
| ✅ | vm | 78 | 78 |
|
||||
|
||||
|
||||
Generated by `lib/erlang/conformance.sh`.
|
||||
|
||||
@@ -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")
|
||||
@@ -1125,6 +1128,258 @@
|
||||
(er-eval-test "lists:duplicate val"
|
||||
(nm (ev "hd(lists:duplicate(3, marker))")) "marker")
|
||||
|
||||
|
||||
;; ── Phase 7: code:load_binary/3 ───────────────────────────────
|
||||
(er-modules-reset!)
|
||||
|
||||
(er-eval-test "code:load_binary ok tag"
|
||||
(nm (ev "element(1, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
|
||||
"module")
|
||||
(er-eval-test "code:load_binary ok name"
|
||||
(nm (ev "element(2, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
|
||||
"cl1")
|
||||
(er-eval-test "code:load_binary then call"
|
||||
(ev "cl1:foo()") 1)
|
||||
|
||||
(er-eval-test "code:load_binary reload v2"
|
||||
(ev "code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 99.\"), cl1:foo()")
|
||||
99)
|
||||
|
||||
(er-eval-test "code:load_binary name mismatch tag"
|
||||
(nm (ev "element(1, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
|
||||
"error")
|
||||
(er-eval-test "code:load_binary name mismatch reason"
|
||||
(nm (ev "element(2, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
|
||||
"module_name_mismatch")
|
||||
|
||||
(er-eval-test "code:load_binary badfile on garbage"
|
||||
(nm (ev "element(2, code:load_binary(cl3, \"x.erl\", \"this is not erlang\"))"))
|
||||
"badfile")
|
||||
|
||||
(er-eval-test "code:load_binary non-atom mod is badarg"
|
||||
(nm (ev "element(2, code:load_binary(\"cl1\", \"x.erl\", \"-module(cl1). f() -> 0.\"))"))
|
||||
"badarg")
|
||||
|
||||
|
||||
;; ── Phase 7: code:purge/1 + code:soft_purge/1 ───────────────────
|
||||
(er-modules-reset!)
|
||||
|
||||
;; purge unknown module → false
|
||||
(er-eval-test "code:purge unknown"
|
||||
(nm (ev "code:purge(nope)")) "false")
|
||||
|
||||
;; load, then purge without old version → false (nothing to purge)
|
||||
(er-eval-test "code:purge no old"
|
||||
(nm (ev "code:load_binary(pg1, \"pg1\", \"-module(pg1). v() -> 1.\"), code:purge(pg1)"))
|
||||
"false")
|
||||
|
||||
;; load v1, load v2 (creates :old), purge with no live procs → true
|
||||
(er-eval-test "code:purge after reload"
|
||||
(nm (ev "code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 1.\"), code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 2.\"), code:purge(pg2)"))
|
||||
"true")
|
||||
|
||||
;; idempotent: purging again returns false (already purged)
|
||||
(er-eval-test "code:purge twice"
|
||||
(nm (ev "code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 1.\"), code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 2.\"), code:purge(pg3), code:purge(pg3)"))
|
||||
"false")
|
||||
|
||||
;; purge returns true whenever an :old slot exists, regardless of process tracking
|
||||
;; (proper "kill lingering" semantics requires spawn/3 which is still stubbed)
|
||||
(er-eval-test "code:purge with old slot present"
|
||||
(nm (ev "code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> ok end.\"),
|
||||
Pid = spawn(fun () -> pg4:loop() end),
|
||||
code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> done end.\"),
|
||||
code:purge(pg4)"))
|
||||
"true")
|
||||
|
||||
;; soft_purge unknown → true (nothing to purge)
|
||||
(er-eval-test "code:soft_purge unknown"
|
||||
(nm (ev "code:soft_purge(nope)")) "true")
|
||||
|
||||
;; soft_purge with no old version → true
|
||||
(er-eval-test "code:soft_purge no old"
|
||||
(nm (ev "code:load_binary(sp1, \"sp1\", \"-module(sp1). v() -> 1.\"), code:soft_purge(sp1)"))
|
||||
"true")
|
||||
|
||||
;; soft_purge with old + no lingering procs → true (clears :old)
|
||||
(er-eval-test "code:soft_purge clean"
|
||||
(nm (ev "code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 1.\"), code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 2.\"), code:soft_purge(sp2)"))
|
||||
"true")
|
||||
|
||||
;; non-atom Mod is badarg (raise)
|
||||
(er-eval-test "code:purge badarg"
|
||||
(nm (ev "try code:purge(\"str\") catch error:badarg -> ok end")) "ok")
|
||||
(er-eval-test "code:soft_purge badarg"
|
||||
(nm (ev "try code:soft_purge(123) catch error:badarg -> ok end")) "ok")
|
||||
|
||||
|
||||
;; ── Phase 7: code:which/1 + code:is_loaded/1 + code:all_loaded/0 ──
|
||||
(er-modules-reset!)
|
||||
|
||||
(er-eval-test "code:which non_existing"
|
||||
(nm (ev "code:which(nope)")) "non_existing")
|
||||
|
||||
(er-eval-test "code:which after load"
|
||||
(nm (ev "code:load_binary(wh1, \"wh1\", \"-module(wh1). v() -> 1.\"), code:which(wh1)"))
|
||||
"loaded")
|
||||
|
||||
(er-eval-test "code:is_loaded missing"
|
||||
(nm (ev "code:is_loaded(nope)")) "false")
|
||||
|
||||
(er-eval-test "code:is_loaded tag"
|
||||
(nm (ev "code:load_binary(il1, \"il1\", \"-module(il1). v() -> 1.\"), element(1, code:is_loaded(il1))"))
|
||||
"file")
|
||||
|
||||
(er-eval-test "code:is_loaded value"
|
||||
(nm (ev "code:load_binary(il2, \"il2\", \"-module(il2). v() -> 1.\"), element(2, code:is_loaded(il2))"))
|
||||
"loaded")
|
||||
|
||||
(er-modules-reset!)
|
||||
(er-eval-test "code:all_loaded empty"
|
||||
(ev "length(code:all_loaded())") 0)
|
||||
|
||||
(er-modules-reset!)
|
||||
(er-eval-test "code:all_loaded count"
|
||||
(ev "code:load_binary(al1, \"al1\", \"-module(al1). v() -> 1.\"),
|
||||
code:load_binary(al2, \"al2\", \"-module(al2). v() -> 1.\"),
|
||||
length(code:all_loaded())")
|
||||
2)
|
||||
|
||||
(er-eval-test "code:all_loaded first entry tag"
|
||||
(nm (ev "code:load_binary(al3, \"al3\", \"-module(al3). v() -> 1.\"),
|
||||
element(2, hd(code:all_loaded()))"))
|
||||
"loaded")
|
||||
|
||||
(er-eval-test "code:which badarg"
|
||||
(nm (ev "try code:which(\"str\") catch error:badarg -> ok end")) "ok")
|
||||
(er-eval-test "code:is_loaded badarg"
|
||||
(nm (ev "try code:is_loaded(123) catch error:badarg -> ok end")) "ok")
|
||||
|
||||
|
||||
;; ── Phase 7: hot-reload call dispatch semantics ──────────────────
|
||||
;; Cross-module M:F() calls always hit the CURRENT version;
|
||||
;; local F() calls inside a module body resolve through the env
|
||||
;; the function closed over (i.e. the version it was loaded with).
|
||||
|
||||
(er-modules-reset!)
|
||||
|
||||
;; M:F always hits current
|
||||
(er-eval-test "cross-mod after reload v2"
|
||||
(ev "code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 1.\"),
|
||||
code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 2.\"),
|
||||
hr1:f()")
|
||||
2)
|
||||
|
||||
;; Local call inside reloaded module body resolves via fresh mod-env
|
||||
;; (a() does a local b(); b() got upgraded too)
|
||||
(er-eval-test "local call inside reloaded module body"
|
||||
(ev "code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 1.\"),
|
||||
code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 99.\"),
|
||||
hr2:a()")
|
||||
99)
|
||||
|
||||
;; Fun captured BEFORE reload, with local-call body, keeps v1 semantics
|
||||
(er-eval-test "captured fun keeps closed-over env (local call)"
|
||||
(ev "code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 1.\"),
|
||||
Fn = hr3:get_fn(),
|
||||
code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 99.\"),
|
||||
Fn()")
|
||||
1)
|
||||
|
||||
;; Fun captured BEFORE reload, with CROSS-mod body, sees v2's current
|
||||
(er-eval-test "captured fun follows cross-mod to current"
|
||||
(ev "code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 1.\"),
|
||||
Fn = hr4:get_xref(),
|
||||
code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 99.\"),
|
||||
Fn()")
|
||||
99)
|
||||
|
||||
;; Two captured funs from two different vintages
|
||||
(er-eval-test "two funs from two vintages stay independent"
|
||||
(ev "code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 10.\"),
|
||||
F1 = hr5:gf(),
|
||||
code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 20.\"),
|
||||
F2 = hr5:gf(),
|
||||
F1() + F2()")
|
||||
30)
|
||||
|
||||
;; Version slot bumps correctly when a captured fun stays alive
|
||||
(er-eval-test "version bumps despite captured funs"
|
||||
(ev "code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 1.\"),
|
||||
_Pinned = hr6:gf(),
|
||||
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 2.\"),
|
||||
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 3.\"),
|
||||
hr6:v()")
|
||||
3)
|
||||
|
||||
|
||||
|
||||
;; ── Phase 7 capstone: full hot-reload ladder ───────────────────
|
||||
;; Load v1 → spawn from inside module → load v2 → cross-mod hits v2 →
|
||||
;; local call inside v1 process still resolves v1 → soft_purge refuses
|
||||
;; while v1 procs alive → purge kills them.
|
||||
;;
|
||||
;; All stages must run in a single erlang-eval-ast call: each call resets
|
||||
;; the scheduler (er-sched-init!) so cross-call Pid handles would point at
|
||||
;; reaped processes.
|
||||
(er-modules-reset!)
|
||||
|
||||
(define er-rt-cap-prog "code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v1}, loop(); stop -> done end. tag() -> v1.\"), Tag1 = cap:tag(), Pid1 = cap:start(), code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v2}, loop(); stop -> done end. tag() -> v2.\"), Tag2 = cap:tag(), _Pid2 = cap:start(), Soft1 = code:soft_purge(cap), Hard = code:purge(cap), Soft2 = code:soft_purge(cap), {Tag1, Tag2, Soft1, Hard, Soft2}")
|
||||
|
||||
(define er-rt-cap-result (ev er-rt-cap-prog))
|
||||
|
||||
(er-eval-test "capstone v1 tag direct"
|
||||
(get (nth (get er-rt-cap-result :elements) 0) :name) "v1")
|
||||
|
||||
(er-eval-test "capstone v2 tag"
|
||||
(get (nth (get er-rt-cap-result :elements) 1) :name) "v2")
|
||||
|
||||
(er-eval-test "capstone soft_purge while v1 alive = false"
|
||||
(get (nth (get er-rt-cap-result :elements) 2) :name) "false")
|
||||
|
||||
(er-eval-test "capstone hard purge = true"
|
||||
(get (nth (get er-rt-cap-result :elements) 3) :name) "true")
|
||||
|
||||
(er-eval-test "capstone soft_purge clean after hard = true"
|
||||
(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))
|
||||
|
||||
223
lib/erlang/tests/ffi.sx
Normal file
223
lib/erlang/tests/ffi.sx
Normal file
@@ -0,0 +1,223 @@
|
||||
;; Phase 8 FFI BIF tests — one round-trip per BIF.
|
||||
;; Each BIF lives in lib/erlang/runtime.sx (registered with
|
||||
;; er-bif-registry) and wraps an SX-host primitive.
|
||||
|
||||
(define er-ffi-test-count 0)
|
||||
(define er-ffi-test-pass 0)
|
||||
(define er-ffi-test-fails (list))
|
||||
|
||||
(define
|
||||
er-ffi-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! er-ffi-test-count (+ er-ffi-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! er-ffi-test-pass (+ er-ffi-test-pass 1))
|
||||
(append! er-ffi-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ffi-ev erlang-eval-ast)
|
||||
(define ffi-nm (fn (v) (get v :name)))
|
||||
|
||||
;; ── file:read_file/1 + file:write_file/2 ────────────────────────
|
||||
(er-ffi-test
|
||||
"file:write_file ok"
|
||||
(ffi-nm (ffi-ev "file:write_file(\"/tmp/er-ffi-1.txt\", \"hello\")"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"file:read_file ok tag"
|
||||
(ffi-nm (ffi-ev "element(1, file:read_file(\"/tmp/er-ffi-1.txt\"))"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"file:read_file payload is binary"
|
||||
(ffi-nm
|
||||
(ffi-ev
|
||||
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> is_binary(B) end"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"file:read_file content byte_size"
|
||||
(ffi-ev
|
||||
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> byte_size(B) end")
|
||||
5)
|
||||
|
||||
(er-ffi-test
|
||||
"file:read_file missing enoent"
|
||||
(ffi-nm (ffi-ev "element(2, file:read_file(\"/tmp/er-ffi-no-such-xyz\"))"))
|
||||
"enoent")
|
||||
|
||||
(er-ffi-test
|
||||
"file:write_file bad path enoent"
|
||||
(ffi-nm
|
||||
(ffi-ev "element(2, file:write_file(\"/tmp/er-ffi-no-dir-xyz/x\", \"y\"))"))
|
||||
"enoent")
|
||||
|
||||
(er-ffi-test
|
||||
"file:write_file binary payload"
|
||||
(ffi-ev
|
||||
"file:write_file(\"/tmp/er-ffi-2.bin\", <<1, 2, 3, 4, 5>>), case file:read_file(\"/tmp/er-ffi-2.bin\") of {ok, B} -> byte_size(B) end")
|
||||
5)
|
||||
|
||||
;; ── file:delete/1 ────────────────────────────────────────────────
|
||||
(er-ffi-test
|
||||
"file:delete ok"
|
||||
(ffi-nm
|
||||
(ffi-ev
|
||||
"file:write_file(\"/tmp/er-ffi-del.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del.txt\")"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"file:read_file after delete enoent"
|
||||
(ffi-nm
|
||||
(ffi-ev
|
||||
"file:write_file(\"/tmp/er-ffi-del2.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del2.txt\"), element(2, file:read_file(\"/tmp/er-ffi-del2.txt\"))"))
|
||||
"enoent")
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash sha256 -> 32-byte binary"
|
||||
(ffi-ev "byte_size(crypto:hash(sha256, <<97,98,99>>))")
|
||||
32)
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash sha512 -> 64-byte binary"
|
||||
(ffi-ev "byte_size(crypto:hash(sha512, <<97,98,99>>))")
|
||||
64)
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash sha3_256 is_binary"
|
||||
(ffi-nm (ffi-ev "is_binary(crypto:hash(sha3_256, <<120>>))"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash deterministic"
|
||||
(ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =:= crypto:hash(sha256, <<97>>)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash distinct inputs distinct digests"
|
||||
(ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =/= crypto:hash(sha256, <<98>>)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"crypto:hash bad type -> error:badarg"
|
||||
(ffi-nm (ffi-ev "try crypto:hash(md5, <<120>>) catch error:badarg -> ok end"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:from_bytes is_binary"
|
||||
(ffi-nm (ffi-ev "is_binary(cid:from_bytes(<<97,98,99>>))"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:from_bytes deterministic"
|
||||
(ffi-nm (ffi-ev "cid:from_bytes(<<97,98,99>>) =:= cid:from_bytes(<<97,98,99>>)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:from_bytes distinct inputs distinct CIDs"
|
||||
(ffi-nm (ffi-ev "cid:from_bytes(<<97,98,99>>) =/= cid:from_bytes(<<97,98,100>>)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:from_bytes non-binary -> error:badarg"
|
||||
(ffi-nm (ffi-ev "try cid:from_bytes(42) catch error:badarg -> ok end"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:to_string is_binary"
|
||||
(ffi-nm (ffi-ev "is_binary(cid:to_string({ok, 42}))"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:to_string deterministic"
|
||||
(ffi-nm (ffi-ev "cid:to_string(foo) =:= cid:to_string(foo)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"cid:to_string distinct terms distinct CIDs"
|
||||
(ffi-nm (ffi-ev "cid:to_string(foo) =/= cid:to_string(bar)"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"file:list_dir ok tag"
|
||||
(ffi-nm (ffi-ev "element(1, file:list_dir(\"lib/erlang\"))"))
|
||||
"ok")
|
||||
|
||||
(er-ffi-test
|
||||
"file:list_dir non-empty"
|
||||
(ffi-nm (ffi-ev "case file:list_dir(\"lib/erlang\") of {ok, L} -> length(L) > 3 end"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"file:list_dir entries are binaries"
|
||||
(ffi-nm (ffi-ev "case file:list_dir(\"lib/erlang\") of {ok, L} -> is_binary(hd(L)) end"))
|
||||
"true")
|
||||
|
||||
(er-ffi-test
|
||||
"file:list_dir missing enoent"
|
||||
(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.
|
||||
(er-ffi-test
|
||||
"httpc:request unregistered"
|
||||
(er-lookup-bif "httpc" "request" 4)
|
||||
nil)
|
||||
|
||||
(er-ffi-test
|
||||
"sqlite:exec unregistered"
|
||||
(er-lookup-bif "sqlite" "exec" 2)
|
||||
nil)
|
||||
|
||||
(define
|
||||
er-ffi-test-summary
|
||||
(str "ffi " er-ffi-test-pass "/" er-ffi-test-count))
|
||||
@@ -134,6 +134,144 @@
|
||||
(er-sched-current-pid)
|
||||
nil)
|
||||
|
||||
|
||||
|
||||
;; ── Phase 7: module-version slots ───────────────────────────────
|
||||
(er-modules-reset!)
|
||||
|
||||
(define er-rt-slot1 (er-mk-module-slot (er-env-new) nil 1))
|
||||
(er-rt-test "slot tag" (get er-rt-slot1 :tag) "module")
|
||||
(er-rt-test "slot version" (er-module-version er-rt-slot1) 1)
|
||||
(er-rt-test "slot old nil" (er-module-old-env er-rt-slot1) nil)
|
||||
(er-rt-test "slot current not nil" (= (er-module-current-env er-rt-slot1) nil) false)
|
||||
|
||||
(erlang-load-module "-module(hr1). a() -> 1.")
|
||||
(define er-rt-reg (er-modules-get))
|
||||
(er-rt-test "registry has hr1" (dict-has? er-rt-reg "hr1") true)
|
||||
(er-rt-test "v1 on first load" (er-module-version (get er-rt-reg "hr1")) 1)
|
||||
(er-rt-test "v1 old is nil" (er-module-old-env (get er-rt-reg "hr1")) nil)
|
||||
(er-rt-test "v1 current not nil" (= (er-module-current-env (get er-rt-reg "hr1")) nil) false)
|
||||
|
||||
(define er-rt-env-v1 (er-module-current-env (get er-rt-reg "hr1")))
|
||||
(erlang-load-module "-module(hr1). a() -> 2.")
|
||||
(er-rt-test "v2 on second load" (er-module-version (get er-rt-reg "hr1")) 2)
|
||||
(er-rt-test "v2 old is v1 env" (er-module-old-env (get er-rt-reg "hr1")) er-rt-env-v1)
|
||||
(er-rt-test "v2 current is new" (= (er-module-current-env (get er-rt-reg "hr1")) er-rt-env-v1) false)
|
||||
|
||||
(erlang-load-module "-module(hr1). a() -> 3.")
|
||||
(er-rt-test "v3 on third load" (er-module-version (get er-rt-reg "hr1")) 3)
|
||||
|
||||
(er-modules-reset!)
|
||||
(er-rt-test "registry-reset clears" (dict-has? (er-modules-get) "hr1") false)
|
||||
|
||||
|
||||
|
||||
|
||||
;; ── Phase 8: FFI BIF registry ──────────────────────────────────
|
||||
(er-bif-registry-reset!)
|
||||
|
||||
(er-rt-test "empty registry" (len (er-list-bifs)) 0)
|
||||
(er-rt-test "lookup miss" (er-lookup-bif "crypto" "hash" 2) nil)
|
||||
|
||||
(er-register-bif! "fake" "echo" 1 (fn (vs) (nth vs 0)))
|
||||
(er-rt-test "register grows registry" (len (er-list-bifs)) 1)
|
||||
|
||||
(define er-rt-bif-hit (er-lookup-bif "fake" "echo" 1))
|
||||
(er-rt-test "lookup hit module" (get er-rt-bif-hit :module) "fake")
|
||||
(er-rt-test "lookup hit name" (get er-rt-bif-hit :name) "echo")
|
||||
(er-rt-test "lookup hit arity" (get er-rt-bif-hit :arity) 1)
|
||||
(er-rt-test "lookup hit pure?" (get er-rt-bif-hit :pure?) false)
|
||||
|
||||
(er-rt-test "fn invocable" ((get er-rt-bif-hit :fn) (list 42)) 42)
|
||||
|
||||
;; Re-register replaces (same key)
|
||||
(er-register-bif! "fake" "echo" 1 (fn (vs) "replaced"))
|
||||
(er-rt-test "re-register same key, count unchanged" (len (er-list-bifs)) 1)
|
||||
(er-rt-test "re-register replaces fn"
|
||||
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 99)) "replaced")
|
||||
|
||||
;; Pure variant
|
||||
(er-register-pure-bif! "fake" "pure" 2 (fn (vs) (+ (nth vs 0) (nth vs 1))))
|
||||
(er-rt-test "pure registered separately, count 2" (len (er-list-bifs)) 2)
|
||||
(er-rt-test "pure flag true"
|
||||
(get (er-lookup-bif "fake" "pure" 2) :pure?) true)
|
||||
(er-rt-test "pure fn invocable"
|
||||
((get (er-lookup-bif "fake" "pure" 2) :fn) (list 7 8)) 15)
|
||||
|
||||
;; Arity disambiguation: same module+name, different arity = distinct entries
|
||||
(er-register-bif! "fake" "echo" 2 (fn (vs) (list (nth vs 0) (nth vs 1))))
|
||||
(er-rt-test "arity disambiguation count" (len (er-list-bifs)) 3)
|
||||
(er-rt-test "arity-1 lookup still works"
|
||||
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 11)) "replaced")
|
||||
(er-rt-test "arity-2 lookup independent"
|
||||
(len ((get (er-lookup-bif "fake" "echo" 2) :fn) (list 1 2))) 2)
|
||||
|
||||
;; Reset clears the registry
|
||||
(er-bif-registry-reset!)
|
||||
(er-rt-test "reset clears" (len (er-list-bifs)) 0)
|
||||
(er-rt-test "reset lookup nil" (er-lookup-bif "fake" "echo" 1) nil)
|
||||
|
||||
|
||||
|
||||
;; ── Phase 8: term marshalling (er-to-sx / er-of-sx) ─────────────
|
||||
|
||||
;; er-to-sx: Erlang → SX
|
||||
(er-rt-test "to-sx atom" (er-to-sx (er-mk-atom "foo")) (make-symbol "foo"))
|
||||
(er-rt-test "to-sx atom is symbol" (type-of (er-to-sx (er-mk-atom "x"))) "symbol")
|
||||
(er-rt-test "to-sx nil" (er-to-sx (er-mk-nil)) (list))
|
||||
(er-rt-test "to-sx integer passthrough" (er-to-sx 42) 42)
|
||||
(er-rt-test "to-sx float passthrough" (er-to-sx 3.14) 3.14)
|
||||
(er-rt-test "to-sx boolean passthrough" (er-to-sx true) true)
|
||||
(er-rt-test "to-sx binary → string"
|
||||
(er-to-sx (er-mk-binary (list 104 105 33))) "hi!")
|
||||
(er-rt-test "to-sx cons → list"
|
||||
(er-to-sx (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))) (list 1 2 3))
|
||||
(er-rt-test "to-sx tuple → list"
|
||||
(er-to-sx (er-mk-tuple (list 1 2 3))) (list 1 2 3))
|
||||
(er-rt-test "to-sx nested cons"
|
||||
(er-to-sx (er-mk-cons (er-mk-atom "a") (er-mk-cons 7 (er-mk-nil))))
|
||||
(list (make-symbol "a") 7))
|
||||
|
||||
;; er-of-sx: SX → Erlang
|
||||
(er-rt-test "of-sx symbol"
|
||||
(get (er-of-sx (make-symbol "ok")) :name) "ok")
|
||||
(er-rt-test "of-sx symbol is atom"
|
||||
(er-atom? (er-of-sx (make-symbol "x"))) true)
|
||||
(er-rt-test "of-sx string is binary"
|
||||
(er-binary? (er-of-sx "hi")) true)
|
||||
(er-rt-test "of-sx string bytes"
|
||||
(get (er-of-sx "hi") :bytes) (list 104 105))
|
||||
(er-rt-test "of-sx integer passthrough"
|
||||
(er-of-sx 42) 42)
|
||||
(er-rt-test "of-sx empty list → nil"
|
||||
(er-nil? (er-of-sx (list))) true)
|
||||
(er-rt-test "of-sx list → cons chain length"
|
||||
(er-list-length (er-of-sx (list 1 2 3 4))) 4)
|
||||
(er-rt-test "of-sx list head/tail"
|
||||
(get (er-of-sx (list 10 20)) :head) 10)
|
||||
|
||||
;; Round-trips
|
||||
(er-rt-test "rtrip integer" (er-to-sx (er-of-sx 99)) 99)
|
||||
(er-rt-test "rtrip atom"
|
||||
(get (er-of-sx (er-to-sx (er-mk-atom "abc"))) :name) "abc")
|
||||
(er-rt-test "rtrip binary bytes"
|
||||
(get (er-of-sx (er-to-sx (er-mk-binary (list 1 2 3)))) :bytes) (list 1 2 3))
|
||||
(er-rt-test "rtrip cons-of-ints length"
|
||||
(er-list-length (er-of-sx (er-to-sx
|
||||
(er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
|
||||
|
||||
;; Tuples don't round-trip exactly (er-to-sx flattens tuples to lists);
|
||||
;; documented one-way conversion.
|
||||
(er-rt-test "to-sx of tuple loses tag"
|
||||
(er-cons? (er-of-sx (er-to-sx (er-mk-tuple (list 1 2 3))))) true)
|
||||
|
||||
|
||||
;; Re-populate built-in BIFs so subsequent test files (ring, ping-pong, etc.)
|
||||
;; can call length/spawn/etc. The migration onto the registry means a reset
|
||||
;; here would otherwise break the rest of the conformance suite.
|
||||
(er-register-builtin-bifs!)
|
||||
|
||||
|
||||
(define
|
||||
er-rt-test-summary
|
||||
(str "runtime " er-rt-test-pass "/" er-rt-test-count))
|
||||
|
||||
403
lib/erlang/tests/vm.sx
Normal file
403
lib/erlang/tests/vm.sx
Normal file
@@ -0,0 +1,403 @@
|
||||
;; Phase 9 — stub VM opcode dispatcher tests.
|
||||
;; Verifies the dispatcher shape (mirrors plans/sx-vm-opcode-extension.md
|
||||
;; for when 9a integrates) and the three pattern-match opcodes (9b)
|
||||
;; route to the correct er-match-* impl.
|
||||
|
||||
(define er-vm-test-count 0)
|
||||
(define er-vm-test-pass 0)
|
||||
(define er-vm-test-fails (list))
|
||||
|
||||
(define
|
||||
er-vm-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! er-vm-test-count (+ er-vm-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! er-vm-test-pass (+ er-vm-test-pass 1))
|
||||
(append! er-vm-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── dispatcher core ─────────────────────────────────────────────
|
||||
(er-vm-test
|
||||
"tuple opcode registered"
|
||||
(= (er-vm-lookup-opcode-by-id 128) nil)
|
||||
false)
|
||||
|
||||
(er-vm-test
|
||||
"tuple opcode name"
|
||||
(get (er-vm-lookup-opcode-by-id 128) :name)
|
||||
"OP_PATTERN_TUPLE")
|
||||
|
||||
(er-vm-test
|
||||
"list opcode by name"
|
||||
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_LIST") :id)
|
||||
129)
|
||||
|
||||
(er-vm-test
|
||||
"binary opcode by name"
|
||||
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_BINARY") :id)
|
||||
130)
|
||||
|
||||
(er-vm-test "lookup miss by id" (er-vm-lookup-opcode-by-id 999) nil)
|
||||
|
||||
(er-vm-test "lookup miss by name" (er-vm-lookup-opcode-by-name "OP_NOPE") nil)
|
||||
|
||||
(er-vm-test
|
||||
"opcode list has 3+"
|
||||
(>= (len (er-vm-list-opcodes)) 3)
|
||||
true)
|
||||
|
||||
;; ── OP_PATTERN_TUPLE ────────────────────────────────────────────
|
||||
;; Pattern: {ok, X} matches value {ok, 42} → X bound to 42
|
||||
(define er-vm-t1-env (er-env-new))
|
||||
(define er-vm-t1-pat {:type "tuple" :elements (list {:type "atom" :value "ok"} {:name "X" :type "var"})})
|
||||
(define er-vm-t1-val (er-mk-tuple (list (er-mk-atom "ok") 42)))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_TUPLE match"
|
||||
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t1-val er-vm-t1-env))
|
||||
true)
|
||||
(er-vm-test "OP_PATTERN_TUPLE binds var" (get er-vm-t1-env "X") 42)
|
||||
|
||||
;; Same pattern against {error, ...} → false
|
||||
(define er-vm-t2-env (er-env-new))
|
||||
(define er-vm-t2-val (er-mk-tuple (list (er-mk-atom "error") 7)))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_TUPLE no-match"
|
||||
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t2-val er-vm-t2-env))
|
||||
false)
|
||||
|
||||
;; Wrong arity tuple — pattern has 2 elements, value has 3
|
||||
(define er-vm-t3-env (er-env-new))
|
||||
(define
|
||||
er-vm-t3-val
|
||||
(er-mk-tuple (list (er-mk-atom "ok") 1 2)))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_TUPLE arity mismatch"
|
||||
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t3-val er-vm-t3-env))
|
||||
false)
|
||||
|
||||
;; ── OP_PATTERN_LIST (cons) ──────────────────────────────────────
|
||||
;; Pattern: [H | T] matches [1, 2, 3] → H=1, T=[2,3]
|
||||
(define er-vm-l1-env (er-env-new))
|
||||
(define er-vm-l1-pat {:type "cons" :tail {:name "T" :type "var"} :head {:name "H" :type "var"}})
|
||||
(define
|
||||
er-vm-l1-val
|
||||
(er-mk-cons
|
||||
1
|
||||
(er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_LIST match"
|
||||
(er-vm-dispatch 129 (list er-vm-l1-pat er-vm-l1-val er-vm-l1-env))
|
||||
true)
|
||||
(er-vm-test "OP_PATTERN_LIST binds head" (get er-vm-l1-env "H") 1)
|
||||
(er-vm-test
|
||||
"OP_PATTERN_LIST tail is cons"
|
||||
(er-cons? (get er-vm-l1-env "T"))
|
||||
true)
|
||||
|
||||
;; [H|T] against empty list → false
|
||||
(define er-vm-l2-env (er-env-new))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_LIST no-match on nil"
|
||||
(er-vm-dispatch 129 (list er-vm-l1-pat (er-mk-nil) er-vm-l2-env))
|
||||
false)
|
||||
|
||||
;; ── OP_PATTERN_BINARY ───────────────────────────────────────────
|
||||
;; Pattern <<A:8>> against <<42>> → A bound to 42
|
||||
(define er-vm-b1-env (er-env-new))
|
||||
(define er-vm-b1-pat {:type "binary" :segments (list {:value {:name "A" :type "var"} :size {:type "integer" :value "8"} :spec "integer"})})
|
||||
(define er-vm-b1-val (er-mk-binary (list 42)))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_BINARY match"
|
||||
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b1-val er-vm-b1-env))
|
||||
true)
|
||||
(er-vm-test
|
||||
"OP_PATTERN_BINARY binds segment"
|
||||
(get er-vm-b1-env "A")
|
||||
42)
|
||||
|
||||
;; Same pattern against wrong-size binary (2 bytes) → false
|
||||
(define er-vm-b2-env (er-env-new))
|
||||
(define er-vm-b2-val (er-mk-binary (list 42 99)))
|
||||
(er-vm-test
|
||||
"OP_PATTERN_BINARY size mismatch"
|
||||
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b2-val er-vm-b2-env))
|
||||
false)
|
||||
|
||||
;; ── dispatch error path ────────────────────────────────────────
|
||||
(define er-vm-err-caught (list nil))
|
||||
(guard
|
||||
(c (:else (set-nth! er-vm-err-caught 0 (str c))))
|
||||
(er-vm-dispatch 999 (list)))
|
||||
(er-vm-test
|
||||
"unknown opcode raises"
|
||||
(string-contains? (str (nth er-vm-err-caught 0)) "unknown opcode")
|
||||
true)
|
||||
|
||||
|
||||
;; ── Phase 9c — OP_PERFORM / OP_HANDLE ───────────────────────────
|
||||
(er-vm-test "perform opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 131) :name) "OP_PERFORM")
|
||||
(er-vm-test "handle opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 132) :name) "OP_HANDLE")
|
||||
|
||||
(define er-vm-pf-caught (list nil))
|
||||
(guard (c (:else (set-nth! er-vm-pf-caught 0 c)))
|
||||
(er-vm-dispatch 131 (list "yield" (list 42))))
|
||||
(er-vm-test "perform raises tagged"
|
||||
(get (nth er-vm-pf-caught 0) :tag) "vm-effect")
|
||||
(er-vm-test "perform effect name"
|
||||
(get (nth er-vm-pf-caught 0) :effect) "yield")
|
||||
(er-vm-test "perform args carried"
|
||||
(nth (get (nth er-vm-pf-caught 0) :args) 0) 42)
|
||||
|
||||
(er-vm-test "handle catches matching effect"
|
||||
(er-vm-dispatch 132
|
||||
(list
|
||||
(fn () (er-vm-dispatch 131 (list "yield" (list 7))))
|
||||
"yield"
|
||||
(fn (args) (+ (nth args 0) 100))))
|
||||
107)
|
||||
|
||||
(er-vm-test "handle no-effect returns thunk result"
|
||||
(er-vm-dispatch 132
|
||||
(list
|
||||
(fn () 99)
|
||||
"yield"
|
||||
(fn (args) "handler ran")))
|
||||
99)
|
||||
|
||||
(define er-vm-rt-caught (list nil))
|
||||
(guard (c (:else (set-nth! er-vm-rt-caught 0 c)))
|
||||
(er-vm-dispatch 132
|
||||
(list
|
||||
(fn () (er-vm-dispatch 131 (list "other" (list))))
|
||||
"yield"
|
||||
(fn (args) "wrong"))))
|
||||
(er-vm-test "handle rethrows non-matching"
|
||||
(get (nth er-vm-rt-caught 0) :effect) "other")
|
||||
|
||||
(er-vm-test "nested handles separate effect names"
|
||||
(er-vm-dispatch 132
|
||||
(list
|
||||
(fn ()
|
||||
(er-vm-dispatch 132
|
||||
(list
|
||||
(fn () (er-vm-dispatch 131 (list "b" (list 5))))
|
||||
"a"
|
||||
(fn (args) "inner-handled"))))
|
||||
"b"
|
||||
(fn (args) (+ (nth args 0) 1000))))
|
||||
1005)
|
||||
|
||||
|
||||
;; ── Phase 9d — OP_RECEIVE_SCAN ──────────────────────────────────
|
||||
(er-vm-test "receive-scan opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 133) :name) "OP_RECEIVE_SCAN")
|
||||
|
||||
;; Pattern: receive {ok, X} -> X end against mailbox [{error, 1}, {ok, 42}, foo]
|
||||
(define er-vm-r1-env (er-env-new))
|
||||
(define er-vm-r1-clauses
|
||||
(list
|
||||
{:pattern {:type "tuple"
|
||||
:elements (list
|
||||
{:type "atom" :value "ok"}
|
||||
{:type "var" :name "X"})}
|
||||
:guards (list)
|
||||
:body (list {:type "var" :name "X"})}))
|
||||
(define er-vm-r1-mbox
|
||||
(list
|
||||
(er-mk-tuple (list (er-mk-atom "error") 1))
|
||||
(er-mk-tuple (list (er-mk-atom "ok") 42))
|
||||
(er-mk-atom "foo")))
|
||||
|
||||
(define er-vm-r1-result
|
||||
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r1-mbox er-vm-r1-env)))
|
||||
(er-vm-test "scan finds match"
|
||||
(get er-vm-r1-result :matched) true)
|
||||
(er-vm-test "scan reports correct index"
|
||||
(get er-vm-r1-result :index) 1)
|
||||
(er-vm-test "scan binds var"
|
||||
(get er-vm-r1-env "X") 42)
|
||||
(er-vm-test "scan leaves body unevaluated"
|
||||
(= (get er-vm-r1-result :body) nil) false)
|
||||
|
||||
;; No match case
|
||||
(define er-vm-r2-env (er-env-new))
|
||||
(define er-vm-r2-mbox (list (er-mk-atom "nope") 99))
|
||||
(define er-vm-r2-result
|
||||
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r2-mbox er-vm-r2-env)))
|
||||
(er-vm-test "scan no-match"
|
||||
(get er-vm-r2-result :matched) false)
|
||||
(er-vm-test "scan no-match leaves env clean"
|
||||
(dict-has? er-vm-r2-env "X") false)
|
||||
|
||||
;; Empty mailbox
|
||||
(define er-vm-r3-result
|
||||
(er-vm-dispatch 133 (list er-vm-r1-clauses (list) (er-env-new))))
|
||||
(er-vm-test "scan empty mailbox"
|
||||
(get er-vm-r3-result :matched) false)
|
||||
|
||||
;; First-match wins (arrival order)
|
||||
(define er-vm-r4-env (er-env-new))
|
||||
(define er-vm-r4-mbox
|
||||
(list
|
||||
(er-mk-tuple (list (er-mk-atom "ok") 1))
|
||||
(er-mk-tuple (list (er-mk-atom "ok") 2))))
|
||||
(define er-vm-r4-result
|
||||
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r4-mbox er-vm-r4-env)))
|
||||
(er-vm-test "scan first-match wins (index 0)"
|
||||
(get er-vm-r4-result :index) 0)
|
||||
(er-vm-test "scan binds first match's var"
|
||||
(get er-vm-r4-env "X") 1)
|
||||
|
||||
|
||||
;; ── Phase 9e — OP_SPAWN / OP_SEND ───────────────────────────────
|
||||
(er-vm-procs-reset!)
|
||||
|
||||
(er-vm-test "spawn opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 134) :name) "OP_SPAWN")
|
||||
(er-vm-test "send opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 135) :name) "OP_SEND")
|
||||
|
||||
(define er-vm-fn (fn () "body"))
|
||||
(define er-vm-p1 (er-vm-dispatch 134 (list er-vm-fn (list))))
|
||||
(define er-vm-p2 (er-vm-dispatch 134 (list er-vm-fn (list "arg"))))
|
||||
(er-vm-test "spawn returns pid 0 first"
|
||||
er-vm-p1 0)
|
||||
(er-vm-test "spawn returns pid 1 second"
|
||||
er-vm-p2 1)
|
||||
(er-vm-test "proc count is 2"
|
||||
(er-vm-proc-count) 2)
|
||||
(er-vm-test "spawned proc state runnable"
|
||||
(er-vm-proc-state er-vm-p1) "runnable")
|
||||
(er-vm-test "spawned proc mailbox empty"
|
||||
(len (er-vm-proc-mailbox er-vm-p1)) 0)
|
||||
(er-vm-test "spawned proc has 8 registers"
|
||||
(len (get (er-vm-proc-get er-vm-p1) :registers)) 8)
|
||||
|
||||
;; OP_SEND appends to target's mailbox, preserves arrival order.
|
||||
(er-vm-test "send returns true on valid pid"
|
||||
(er-vm-dispatch 135 (list er-vm-p1 "msg1")) true)
|
||||
(er-vm-dispatch 135 (list er-vm-p1 "msg2")
|
||||
)
|
||||
(er-vm-dispatch 135 (list er-vm-p1 "msg3"))
|
||||
(er-vm-test "mailbox length after 3 sends"
|
||||
(len (er-vm-proc-mailbox er-vm-p1)) 3)
|
||||
(er-vm-test "mailbox preserves order — first"
|
||||
(nth (er-vm-proc-mailbox er-vm-p1) 0) "msg1")
|
||||
(er-vm-test "mailbox preserves order — last"
|
||||
(nth (er-vm-proc-mailbox er-vm-p1) 2) "msg3")
|
||||
|
||||
;; send to nonexistent pid returns false (doesn't crash)
|
||||
(er-vm-test "send to unknown pid is false"
|
||||
(er-vm-dispatch 135 (list 99999 "x")) false)
|
||||
|
||||
;; Isolation: msgs to p1 don't appear in p2's mailbox
|
||||
(er-vm-test "isolation — p2 mailbox empty"
|
||||
(len (er-vm-proc-mailbox er-vm-p2)) 0)
|
||||
|
||||
;; reset clears
|
||||
(er-vm-procs-reset!)
|
||||
(er-vm-test "reset clears procs"
|
||||
(er-vm-proc-count) 0)
|
||||
(er-vm-test "reset resets pid counter"
|
||||
(er-vm-dispatch 134 (list er-vm-fn (list))) 0)
|
||||
|
||||
|
||||
;; ── Phase 9f — hot-BIF dispatch table ───────────────────────────
|
||||
;; Each opcode skips the registry lookup and calls the underlying
|
||||
;; er-bif-* directly. Verify each returns the same result as going
|
||||
;; through er-apply-bif.
|
||||
|
||||
(er-vm-test "BIF_LENGTH opcode by id"
|
||||
(get (er-vm-lookup-opcode-by-id 136) :name) "OP_BIF_LENGTH")
|
||||
(er-vm-test "BIF_LENGTH on 3-cons"
|
||||
(er-vm-dispatch 136
|
||||
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))
|
||||
3)
|
||||
|
||||
(er-vm-test "BIF_HD on cons"
|
||||
(er-vm-dispatch 137 (list (er-mk-cons 99 (er-mk-nil)))) 99)
|
||||
|
||||
(er-vm-test "BIF_TL is cons"
|
||||
(er-cons? (er-vm-dispatch 138
|
||||
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-nil)))))) true)
|
||||
|
||||
(er-vm-test "BIF_ELEMENT pulls index"
|
||||
(er-vm-dispatch 139 (list 2 (er-mk-tuple (list "a" "b" "c")))) "b")
|
||||
|
||||
(er-vm-test "BIF_TUPLE_SIZE on 4-tuple"
|
||||
(er-vm-dispatch 140 (list (er-mk-tuple (list 1 2 3 4)))) 4)
|
||||
|
||||
(er-vm-test "BIF_LISTS_REVERSE preserves elements"
|
||||
(er-list-length (er-vm-dispatch 141
|
||||
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
|
||||
|
||||
(er-vm-test "BIF_LISTS_REVERSE actually reverses"
|
||||
(get (er-vm-dispatch 141
|
||||
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))) :head) 3)
|
||||
|
||||
(er-vm-test "BIF_IS_INTEGER true on int"
|
||||
(get (er-vm-dispatch 142 (list 42)) :name) "true")
|
||||
(er-vm-test "BIF_IS_INTEGER false on float"
|
||||
(get (er-vm-dispatch 142 (list 3.14)) :name) "false")
|
||||
|
||||
(er-vm-test "BIF_IS_ATOM true"
|
||||
(get (er-vm-dispatch 143 (list (er-mk-atom "ok"))) :name) "true")
|
||||
(er-vm-test "BIF_IS_ATOM false on int"
|
||||
(get (er-vm-dispatch 143 (list 7)) :name) "false")
|
||||
|
||||
(er-vm-test "BIF_IS_LIST true on cons"
|
||||
(get (er-vm-dispatch 144
|
||||
(list (er-mk-cons 1 (er-mk-nil)))) :name) "true")
|
||||
(er-vm-test "BIF_IS_LIST true on nil"
|
||||
(get (er-vm-dispatch 144 (list (er-mk-nil))) :name) "true")
|
||||
(er-vm-test "BIF_IS_LIST false on tuple"
|
||||
(get (er-vm-dispatch 144 (list (er-mk-tuple (list)))) :name) "false")
|
||||
|
||||
(er-vm-test "BIF_IS_TUPLE true"
|
||||
(get (er-vm-dispatch 145 (list (er-mk-tuple (list 1)))) :name) "true")
|
||||
(er-vm-test "BIF_IS_TUPLE false on int"
|
||||
(get (er-vm-dispatch 145 (list 5)) :name) "false")
|
||||
|
||||
;; Sanity: total opcode count grew (3 patterns + perform + handle +
|
||||
;; receive-scan + spawn + send + 10 hot-BIFs = 16+ registered).
|
||||
(er-vm-test "opcode list has 16+"
|
||||
(>= (len (er-vm-list-opcodes)) 16) true)
|
||||
|
||||
|
||||
;; ── Phase 9i — host opcode-id resolution ────────────────────────
|
||||
;; Requires a binary with the erlang_ext extension registered (9h).
|
||||
;; The loop runs conformance against exactly that binary.
|
||||
(er-vm-test "host id: OP_PATTERN_TUPLE = 222"
|
||||
(er-vm-host-opcode-id "erlang.OP_PATTERN_TUPLE") 222)
|
||||
(er-vm-test "host id: OP_BIF_IS_TUPLE = 239"
|
||||
(er-vm-host-opcode-id "erlang.OP_BIF_IS_TUPLE") 239)
|
||||
(er-vm-test "host id: unknown name -> nil"
|
||||
(er-vm-host-opcode-id "erlang.OP_NOPE") nil)
|
||||
(er-vm-test "effective id prefers host when present"
|
||||
(er-vm-effective-opcode-id "erlang.OP_BIF_LENGTH" 136) 230)
|
||||
(er-vm-test "effective id falls back to stub on nil"
|
||||
(er-vm-effective-opcode-id "erlang.OP_NOPE" 999) 999)
|
||||
;; The full erlang.OP_* namespace resolves to the contiguous 222-239 block.
|
||||
(er-vm-test "host ids contiguous 222..239"
|
||||
(let ((names (list "erlang.OP_PATTERN_TUPLE" "erlang.OP_PATTERN_LIST"
|
||||
"erlang.OP_PATTERN_BINARY" "erlang.OP_PERFORM"
|
||||
"erlang.OP_HANDLE" "erlang.OP_RECEIVE_SCAN"
|
||||
"erlang.OP_SPAWN" "erlang.OP_SEND"
|
||||
"erlang.OP_BIF_LENGTH" "erlang.OP_BIF_HD"
|
||||
"erlang.OP_BIF_TL" "erlang.OP_BIF_ELEMENT"
|
||||
"erlang.OP_BIF_TUPLE_SIZE" "erlang.OP_BIF_LISTS_REVERSE"
|
||||
"erlang.OP_BIF_IS_INTEGER" "erlang.OP_BIF_IS_ATOM"
|
||||
"erlang.OP_BIF_IS_LIST" "erlang.OP_BIF_IS_TUPLE"))
|
||||
(ok (list true)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(when (not (= (er-vm-host-opcode-id (nth names i)) (+ 222 i)))
|
||||
(set-nth! ok 0 false)))
|
||||
(range 0 (len names)))
|
||||
(nth ok 0))
|
||||
true)
|
||||
|
||||
(define er-vm-test-summary (str "vm " er-vm-test-pass "/" er-vm-test-count))
|
||||
@@ -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)
|
||||
@@ -669,96 +674,23 @@
|
||||
|
||||
(define
|
||||
er-apply-bif
|
||||
(fn
|
||||
(name vs)
|
||||
(cond
|
||||
(= name "is_integer") (er-bif-is-integer vs)
|
||||
(= name "is_atom") (er-bif-is-atom vs)
|
||||
(= name "is_list") (er-bif-is-list vs)
|
||||
(= name "is_tuple") (er-bif-is-tuple vs)
|
||||
(= name "is_number") (er-bif-is-number vs)
|
||||
(= name "is_float") (er-bif-is-float vs)
|
||||
(= name "is_boolean") (er-bif-is-boolean vs)
|
||||
(= name "length") (er-bif-length vs)
|
||||
(= name "hd") (er-bif-hd vs)
|
||||
(= name "tl") (er-bif-tl vs)
|
||||
(= name "element") (er-bif-element vs)
|
||||
(= name "tuple_size") (er-bif-tuple-size vs)
|
||||
(= name "atom_to_list") (er-bif-atom-to-list vs)
|
||||
(= name "list_to_atom") (er-bif-list-to-atom vs)
|
||||
(= name "is_pid") (er-bif-is-pid vs)
|
||||
(= name "is_reference") (er-bif-is-reference vs)
|
||||
(= name "is_binary") (er-bif-is-binary vs)
|
||||
(= name "byte_size") (er-bif-byte-size vs)
|
||||
(= name "abs") (er-bif-abs vs)
|
||||
(= name "min") (er-bif-min vs)
|
||||
(= name "max") (er-bif-max vs)
|
||||
(= name "tuple_to_list") (er-bif-tuple-to-list vs)
|
||||
(= name "list_to_tuple") (er-bif-list-to-tuple vs)
|
||||
(= name "integer_to_list") (er-bif-integer-to-list vs)
|
||||
(= name "list_to_integer") (er-bif-list-to-integer vs)
|
||||
(= name "is_function") (er-bif-is-function vs)
|
||||
(= name "self") (er-bif-self vs)
|
||||
(= name "spawn") (er-bif-spawn vs)
|
||||
(= name "exit") (er-bif-exit vs)
|
||||
(= name "make_ref") (er-bif-make-ref vs)
|
||||
(= name "link") (er-bif-link vs)
|
||||
(= name "unlink") (er-bif-unlink vs)
|
||||
(= name "monitor") (er-bif-monitor vs)
|
||||
(= name "demonitor") (er-bif-demonitor vs)
|
||||
(= name "process_flag") (er-bif-process-flag vs)
|
||||
(= name "register") (er-bif-register vs)
|
||||
(= name "unregister") (er-bif-unregister vs)
|
||||
(= name "whereis") (er-bif-whereis vs)
|
||||
(= name "registered") (er-bif-registered vs)
|
||||
(= name "throw") (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))
|
||||
(= name "error") (raise (er-mk-error-marker (er-bif-arg1 vs "error")))
|
||||
:else (error
|
||||
(str "Erlang: undefined function '" name "/" (len vs) "'")))))
|
||||
(fn (name vs)
|
||||
(let ((entry (er-lookup-bif "erlang" name (len vs))))
|
||||
(if (not (= entry nil))
|
||||
((get entry :fn) vs)
|
||||
(error (str "Erlang: undefined function '" name "/" (len vs) "'"))))))
|
||||
|
||||
(define
|
||||
er-apply-remote-bif
|
||||
(fn
|
||||
(mod name vs)
|
||||
(fn (mod name vs)
|
||||
(cond
|
||||
(dict-has? (er-modules-get) mod)
|
||||
(er-apply-user-module mod name vs)
|
||||
(= mod "lists") (er-apply-lists-bif name vs)
|
||||
(= mod "io") (er-apply-io-bif name vs)
|
||||
(= mod "erlang") (er-apply-bif name vs)
|
||||
(= mod "ets") (er-apply-ets-bif name vs)
|
||||
:else (error
|
||||
(str "Erlang: undefined module '" mod "'")))))
|
||||
|
||||
(define
|
||||
er-apply-lists-bif
|
||||
(fn
|
||||
(name vs)
|
||||
(cond
|
||||
(= name "reverse") (er-bif-lists-reverse vs)
|
||||
(= name "map") (er-bif-lists-map vs)
|
||||
(= name "foldl") (er-bif-lists-foldl vs)
|
||||
(= name "seq") (er-bif-lists-seq vs)
|
||||
(= name "sum") (er-bif-lists-sum vs)
|
||||
(= name "nth") (er-bif-lists-nth vs)
|
||||
(= name "last") (er-bif-lists-last vs)
|
||||
(= name "member") (er-bif-lists-member vs)
|
||||
(= name "append") (er-bif-lists-append vs)
|
||||
(= name "filter") (er-bif-lists-filter vs)
|
||||
(= name "any") (er-bif-lists-any vs)
|
||||
(= name "all") (er-bif-lists-all vs)
|
||||
(= name "duplicate") (er-bif-lists-duplicate vs)
|
||||
:else (error
|
||||
(str "Erlang: undefined 'lists:" name "/" (len vs) "'")))))
|
||||
|
||||
(define
|
||||
er-apply-io-bif
|
||||
(fn
|
||||
(name vs)
|
||||
(cond
|
||||
(= name "format") (er-bif-io-format vs)
|
||||
:else (error
|
||||
(str "Erlang: undefined 'io:" name "/" (len vs) "'")))))
|
||||
(er-apply-user-module mod name vs)
|
||||
:else
|
||||
(let ((entry (er-lookup-bif mod name (len vs))))
|
||||
(if (not (= entry nil))
|
||||
((get entry :fn) vs)
|
||||
(error (str "Erlang: undefined remote function '" mod ":" name "/" (len vs) "'")))))))
|
||||
|
||||
(define
|
||||
er-bif-arg1
|
||||
@@ -894,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
|
||||
@@ -911,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
|
||||
@@ -1670,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
|
||||
@@ -1681,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
|
||||
@@ -1911,3 +1859,180 @@
|
||||
(fn (_) (set! out (er-mk-cons v out)))
|
||||
(range 0 n))
|
||||
out))))
|
||||
|
||||
|
||||
;; ── code module (Phase 7 hot-reload) ─────────────────────────────
|
||||
(define er-source-walk-bytes!
|
||||
(fn (n bytes-box)
|
||||
(cond
|
||||
(er-nil? n) true
|
||||
(er-cons? n)
|
||||
(let ((h (get n :head)))
|
||||
(cond
|
||||
(= (type-of h) "number")
|
||||
(do (append! (nth bytes-box 0) h)
|
||||
(er-source-walk-bytes! (get n :tail) bytes-box))
|
||||
:else (do (set-nth! bytes-box 0 nil) false)))
|
||||
:else (do (set-nth! bytes-box 0 nil) false))))
|
||||
|
||||
(define er-source-to-string
|
||||
(fn (v)
|
||||
(cond
|
||||
(= (type-of v) "string") v
|
||||
(er-binary? v) (list->string (map integer->char (get v :bytes)))
|
||||
(or (er-nil? v) (er-cons? v))
|
||||
(let ((box (list (list))))
|
||||
(er-source-walk-bytes! v box)
|
||||
(cond
|
||||
(= (nth box 0) nil) nil
|
||||
:else (list->string (map integer->char (nth box 0)))))
|
||||
:else nil)))
|
||||
|
||||
(define er-bif-code-load-binary
|
||||
(fn (vs)
|
||||
(let ((mod-arg (nth vs 0)) (src-arg (nth vs 2)))
|
||||
(cond
|
||||
(not (er-atom? mod-arg))
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((src-str (er-source-to-string src-arg)))
|
||||
(cond
|
||||
(= src-str nil)
|
||||
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((result-box (list nil)) (failed-box (list false)))
|
||||
(guard
|
||||
(c (:else (set-nth! failed-box 0 true)))
|
||||
(set-nth! result-box 0 (erlang-load-module src-str)))
|
||||
(cond
|
||||
(nth failed-box 0)
|
||||
(er-mk-tuple
|
||||
(list (er-mk-atom "error") (er-mk-atom "badfile")))
|
||||
(not (= (get (nth result-box 0) :name) (get mod-arg :name)))
|
||||
(er-mk-tuple
|
||||
(list (er-mk-atom "error") (er-mk-atom "module_name_mismatch")))
|
||||
:else
|
||||
(er-mk-tuple (list (er-mk-atom "module") mod-arg))))))))))
|
||||
|
||||
(define er-env-derived-from?
|
||||
(fn (env target-env)
|
||||
;; Object-identity check, NOT value `=`. On evaluators where dict `=`
|
||||
;; is structural/deep, comparing closure envs (which are large and
|
||||
;; cyclic — a module fun's env references the fun) does not terminate.
|
||||
;; `identical?` is pointer identity on every host and is the actual
|
||||
;; intended semantics: "is this the same env object".
|
||||
(cond
|
||||
(identical? env target-env) true
|
||||
:else
|
||||
(let ((ks (keys env)) (found-ref (list false)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(when (not (nth found-ref 0))
|
||||
(let ((v (get env (nth ks i))))
|
||||
(when (and (er-fun? v) (identical? (get v :env) target-env))
|
||||
(set-nth! found-ref 0 true)))))
|
||||
(range 0 (len ks)))
|
||||
(nth found-ref 0)))))
|
||||
|
||||
(define er-procs-on-env
|
||||
(fn (target-env)
|
||||
(let ((all-keys (keys (er-sched-processes)))
|
||||
(matches (list)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(let ((proc (get (er-sched-processes) (nth all-keys i))))
|
||||
(let ((init-fun (get proc :initial-fun)))
|
||||
(when (and (not (= init-fun nil))
|
||||
(er-fun? init-fun)
|
||||
(er-env-derived-from? (get init-fun :env) target-env)
|
||||
(not (= (get proc :state) "dead")))
|
||||
(append! matches (get proc :pid))))))
|
||||
(range 0 (len all-keys)))
|
||||
matches)))
|
||||
|
||||
(define er-bif-code-purge
|
||||
(fn (vs)
|
||||
(let ((mod-arg (nth vs 0)))
|
||||
(cond
|
||||
(not (er-atom? mod-arg))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
|
||||
(cond
|
||||
(not (dict-has? registry mod-name)) (er-mk-atom "false")
|
||||
:else
|
||||
(let ((slot (get registry mod-name)))
|
||||
(cond
|
||||
(= (er-module-old-env slot) nil) (er-mk-atom "false")
|
||||
:else
|
||||
(let ((procs (er-procs-on-env (er-module-old-env slot))))
|
||||
(for-each
|
||||
(fn (i) (er-cascade-exit! (nth procs i) (er-mk-atom "killed")))
|
||||
(range 0 (len procs)))
|
||||
(dict-set! registry mod-name
|
||||
(er-mk-module-slot (er-module-current-env slot) nil
|
||||
(er-module-version slot)))
|
||||
(er-mk-atom "true"))))))))))
|
||||
|
||||
(define er-bif-code-soft-purge
|
||||
(fn (vs)
|
||||
(let ((mod-arg (nth vs 0)))
|
||||
(cond
|
||||
(not (er-atom? mod-arg))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
:else
|
||||
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
|
||||
(cond
|
||||
(not (dict-has? registry mod-name)) (er-mk-atom "true")
|
||||
:else
|
||||
(let ((slot (get registry mod-name)))
|
||||
(cond
|
||||
(= (er-module-old-env slot) nil) (er-mk-atom "true")
|
||||
:else
|
||||
(let ((procs (er-procs-on-env (er-module-old-env slot))))
|
||||
(cond
|
||||
(> (len procs) 0) (er-mk-atom "false")
|
||||
:else
|
||||
(do
|
||||
(dict-set! registry mod-name
|
||||
(er-mk-module-slot (er-module-current-env slot) nil
|
||||
(er-module-version slot)))
|
||||
(er-mk-atom "true"))))))))))))
|
||||
|
||||
(define er-bif-code-which
|
||||
(fn (vs)
|
||||
(let ((mod-arg (nth vs 0)))
|
||||
(cond
|
||||
(not (er-atom? mod-arg))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
(dict-has? (er-modules-get) (get mod-arg :name))
|
||||
(er-mk-atom "loaded")
|
||||
:else (er-mk-atom "non_existing")))))
|
||||
|
||||
(define er-bif-code-is-loaded
|
||||
(fn (vs)
|
||||
(let ((mod-arg (nth vs 0)))
|
||||
(cond
|
||||
(not (er-atom? mod-arg))
|
||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||
(dict-has? (er-modules-get) (get mod-arg :name))
|
||||
(er-mk-tuple (list (er-mk-atom "file") (er-mk-atom "loaded")))
|
||||
:else (er-mk-atom "false")))))
|
||||
|
||||
(define er-bif-code-all-loaded
|
||||
(fn (vs)
|
||||
(let ((registry (er-modules-get))
|
||||
(ks (keys (er-modules-get)))
|
||||
(out (er-mk-nil)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(let ((k (nth ks (- (- (len ks) 1) i))))
|
||||
(set! out
|
||||
(er-mk-cons
|
||||
(er-mk-tuple
|
||||
(list (er-mk-atom k) (er-mk-atom "loaded")))
|
||||
out))))
|
||||
(range 0 (len ks)))
|
||||
out)))
|
||||
|
||||
|
||||
|
||||
313
lib/erlang/vm/dispatcher.sx
Normal file
313
lib/erlang/vm/dispatcher.sx
Normal file
@@ -0,0 +1,313 @@
|
||||
;; Erlang VM — stub opcode dispatcher (Phase 9).
|
||||
;;
|
||||
;; Mimics the OCaml-side EXTENSION shape from
|
||||
;; plans/sx-vm-opcode-extension.md so opcodes 9b-9g can be designed
|
||||
;; and tested in SX before 9a (`hosts/ocaml/`) lands the real
|
||||
;; registration plumbing. When 9a is available, these stubs become
|
||||
;; the cross-host SX-side mirror of the C/OCaml handlers and the
|
||||
;; bytecode compiler emits them directly.
|
||||
;;
|
||||
;; Opcode IDs follow the plan's tier partition:
|
||||
;; 0-127 reserved for SX core
|
||||
;; 128-199 guest extensions (e.g. erlang, lua)
|
||||
;; 200-247 port-/platform-specific
|
||||
;;
|
||||
;; Erlang owns 128-159 for now.
|
||||
|
||||
(define er-vm-opcodes (list {}))
|
||||
|
||||
(define er-vm-opcodes-get (fn () (nth er-vm-opcodes 0)))
|
||||
|
||||
(define
|
||||
er-vm-opcodes-reset!
|
||||
(fn () (set-nth! er-vm-opcodes 0 {})))
|
||||
|
||||
(define
|
||||
er-vm-register-opcode!
|
||||
(fn
|
||||
(id name handler)
|
||||
(dict-set! (er-vm-opcodes-get) (str id) {:name name :id id :handler handler})
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(define
|
||||
er-vm-lookup-opcode-by-id
|
||||
(fn
|
||||
(id)
|
||||
(let
|
||||
((reg (er-vm-opcodes-get)) (k (str id)))
|
||||
(if (dict-has? reg k) (get reg k) nil))))
|
||||
|
||||
(define
|
||||
er-vm-lookup-opcode-by-name
|
||||
(fn
|
||||
(name)
|
||||
(let
|
||||
((reg (er-vm-opcodes-get))
|
||||
(ks (keys (er-vm-opcodes-get)))
|
||||
(found (list nil)))
|
||||
(for-each
|
||||
(fn
|
||||
(i)
|
||||
(let
|
||||
((entry (get reg (nth ks i))))
|
||||
(when
|
||||
(= (get entry :name) name)
|
||||
(set-nth! found 0 entry))))
|
||||
(range 0 (len ks)))
|
||||
(nth found 0))))
|
||||
|
||||
(define er-vm-list-opcodes (fn () (keys (er-vm-opcodes-get))))
|
||||
|
||||
;; ── Phase 9i — host opcode-id resolution ────────────────────────
|
||||
;; When the OCaml `erlang_ext` extension is registered (Phase 9h), the
|
||||
;; runtime exposes `extension-opcode-id` which maps an "erlang.OP_*"
|
||||
;; name to the host-assigned id (222-239). We consult it so the SX
|
||||
;; side and the OCaml side agree on ids; when it returns nil (name not
|
||||
;; registered) we fall back to the stub-local id.
|
||||
;;
|
||||
;; NOTE: this requires a binary with the VM extension mechanism (the
|
||||
;; vm-ext phase-A..E cherry-pick + Sx_vm_extensions force-link). The
|
||||
;; loop builds and runs against exactly that binary
|
||||
;; (hosts/ocaml/_build/default/bin/sx_server.exe). `extension-opcode-id`
|
||||
;; resolves lazily at call time, so merely loading this file is safe;
|
||||
;; only invoking the resolver on a binary that lacks the primitive
|
||||
;; would raise.
|
||||
|
||||
(define er-vm-host-opcode-id
|
||||
(fn (ext-name)
|
||||
(extension-opcode-id ext-name)))
|
||||
|
||||
(define er-vm-effective-opcode-id
|
||||
(fn (ext-name stub-id)
|
||||
(let ((host (extension-opcode-id ext-name)))
|
||||
(cond
|
||||
(= host nil) stub-id
|
||||
:else host))))
|
||||
|
||||
(define
|
||||
er-vm-dispatch
|
||||
(fn
|
||||
(id operands)
|
||||
(let
|
||||
((entry (er-vm-lookup-opcode-by-id id)))
|
||||
(if
|
||||
(= entry nil)
|
||||
(error (str "Erlang VM: unknown opcode id " id))
|
||||
((get entry :handler) operands)))))
|
||||
|
||||
(define
|
||||
er-vm-dispatch-by-name
|
||||
(fn
|
||||
(name operands)
|
||||
(let
|
||||
((entry (er-vm-lookup-opcode-by-name name)))
|
||||
(if
|
||||
(= entry nil)
|
||||
(error (str "Erlang VM: unknown opcode name '" name "'"))
|
||||
((get entry :handler) operands)))))
|
||||
|
||||
;; ── Phase 9c — effect opcodes (perform / handle) ────────────────
|
||||
;; Stub algebraic-effects-style operators. OP_PERFORM raises a tagged
|
||||
;; exception; OP_HANDLE wraps a thunk in `guard` and catches matching
|
||||
;; effects, passing the args to the handler. The real specialization
|
||||
;; (constant-time effect dispatch, single-shot vs multi-shot continuations)
|
||||
;; lands when 9a integrates.
|
||||
|
||||
(define er-vm-effect-marker?
|
||||
(fn (c effect-name)
|
||||
(and (= (type-of c) "dict")
|
||||
(= (get c :tag) "vm-effect")
|
||||
(= (get c :effect) effect-name))))
|
||||
|
||||
(define er-vm-op-perform
|
||||
(fn (operands)
|
||||
(raise {:tag "vm-effect" :effect (nth operands 0) :args (nth operands 1)})))
|
||||
|
||||
(define er-vm-op-handle
|
||||
(fn (operands)
|
||||
(let ((thunk (nth operands 0))
|
||||
(effect-name (nth operands 1))
|
||||
(handler (nth operands 2))
|
||||
(result (list nil))
|
||||
(caught (list false))
|
||||
(rethrow (list nil)))
|
||||
(guard
|
||||
(c
|
||||
(:else
|
||||
(cond
|
||||
(er-vm-effect-marker? c effect-name)
|
||||
(do (set-nth! caught 0 true)
|
||||
(set-nth! result 0 (handler (get c :args))))
|
||||
:else (set-nth! rethrow 0 c))))
|
||||
(set-nth! result 0 (thunk)))
|
||||
(cond
|
||||
(not (= (nth rethrow 0) nil)) (raise (nth rethrow 0))
|
||||
:else (nth result 0)))))
|
||||
|
||||
;; ── Phase 9d — receive scan opcode ────────────────────────────
|
||||
;; Selective receive primitive. Scans a mailbox value-list in arrival
|
||||
;; order; for each value, tries each clause's pattern (binding into
|
||||
;; env on success); on match returns `{:matched true :index N :body B}`
|
||||
;; — the caller decides what to do with the index (queue-delete) and
|
||||
;; the body (eval in the now-mutated env). On miss returns
|
||||
;; `{:matched false}`, the caller arranges suspension (via OP_PERFORM).
|
||||
;;
|
||||
;; Operands: (clauses mbox-list env)
|
||||
;; clauses — list of {:pattern :guards :body} dicts
|
||||
;; mbox-list — SX list of message values
|
||||
;; env — env dict (mutated on match)
|
||||
|
||||
(define er-vm-receive-try-clauses
|
||||
(fn (clauses msg env i)
|
||||
(cond
|
||||
(>= i (len clauses)) {:matched false}
|
||||
:else
|
||||
(let ((c (nth clauses i)) (snap (er-env-copy env)))
|
||||
(cond
|
||||
(and
|
||||
(er-match! (get c :pattern) msg env)
|
||||
(er-eval-guards (get c :guards) env))
|
||||
{:matched true :body (get c :body)}
|
||||
:else
|
||||
(do (er-env-restore! env snap)
|
||||
(er-vm-receive-try-clauses clauses msg env (+ i 1))))))))
|
||||
|
||||
(define er-vm-receive-scan-loop
|
||||
(fn (clauses mbox env i)
|
||||
(cond
|
||||
(>= i (len mbox)) {:matched false}
|
||||
:else
|
||||
(let ((msg (nth mbox i))
|
||||
(cr (er-vm-receive-try-clauses clauses msg env 0)))
|
||||
(cond
|
||||
(get cr :matched) {:matched true :index i :body (get cr :body)}
|
||||
:else (er-vm-receive-scan-loop clauses mbox env (+ i 1)))))))
|
||||
|
||||
(define er-vm-op-receive-scan
|
||||
(fn (operands)
|
||||
(er-vm-receive-scan-loop (nth operands 0) (nth operands 1) (nth operands 2) 0)))
|
||||
|
||||
;; ── Phase 9e — spawn / send + lightweight scheduler ─────────────
|
||||
;; Stub register-machine process layout for the eventual fast scheduler.
|
||||
;; A VM-process is `{:id :registers :mailbox :state :initial-fn :initial-args}`.
|
||||
;; Registers is a vector (SX list, mutated via set-nth!) — fixed slot count
|
||||
;; per process so cells don't grow during execution. Mailbox is an SX list.
|
||||
;; State is one of "runnable" / "waiting" / "dead". This sits PARALLEL to
|
||||
;; the existing `er-scheduler` (which is the language-level scheduler) —
|
||||
;; the VM scheduler will eventually take over once 9a integrates and
|
||||
;; bytecode-compiled Erlang runs against it.
|
||||
|
||||
(define er-vm-procs (list {}))
|
||||
(define er-vm-procs-get (fn () (nth er-vm-procs 0)))
|
||||
(define er-vm-procs-reset!
|
||||
(fn () (do (set-nth! er-vm-procs 0 {}) (set-nth! er-vm-next-pid 0 0))))
|
||||
|
||||
(define er-vm-next-pid (list 0))
|
||||
|
||||
(define er-vm-proc-new!
|
||||
(fn (initial-fn initial-args)
|
||||
(let ((pid (nth er-vm-next-pid 0)))
|
||||
(set-nth! er-vm-next-pid 0 (+ pid 1))
|
||||
(let ((proc
|
||||
{:id pid
|
||||
:registers (list nil nil nil nil nil nil nil nil)
|
||||
:mailbox (list)
|
||||
:state "runnable"
|
||||
:initial-fn initial-fn
|
||||
:initial-args initial-args}))
|
||||
(dict-set! (er-vm-procs-get) (str pid) proc)
|
||||
pid))))
|
||||
|
||||
(define er-vm-proc-get (fn (pid) (get (er-vm-procs-get) (str pid))))
|
||||
|
||||
(define er-vm-proc-send!
|
||||
(fn (pid msg)
|
||||
(let ((proc (er-vm-proc-get pid)))
|
||||
(cond
|
||||
(= proc nil) false
|
||||
:else
|
||||
(do
|
||||
(dict-set! proc :mailbox (append (get proc :mailbox) (list msg)))
|
||||
(when (= (get proc :state) "waiting")
|
||||
(dict-set! proc :state "runnable"))
|
||||
true)))))
|
||||
|
||||
(define er-vm-proc-mailbox (fn (pid) (get (er-vm-proc-get pid) :mailbox)))
|
||||
(define er-vm-proc-state (fn (pid) (get (er-vm-proc-get pid) :state)))
|
||||
(define er-vm-proc-count (fn () (len (keys (er-vm-procs-get)))))
|
||||
|
||||
(define er-vm-op-spawn
|
||||
(fn (operands)
|
||||
(er-vm-proc-new! (nth operands 0) (nth operands 1))))
|
||||
|
||||
(define er-vm-op-send
|
||||
(fn (operands)
|
||||
(er-vm-proc-send! (nth operands 0) (nth operands 1))))
|
||||
|
||||
;; ── Phase 9f — hot-BIF dispatch table ──────────────────────────
|
||||
;; Specialized opcodes for the BIFs that the bytecode compiler emits
|
||||
;; on hot call sites. The handler is the underlying `er-bif-*` impl
|
||||
;; directly — same `(vs)` signature as the dispatcher uses for
|
||||
;; operands, so the cost is the opcode-id → handler hop with no
|
||||
;; registry-key string lookup. Cold BIFs continue going through the
|
||||
;; general path (`er-apply-bif` / `er-lookup-bif`).
|
||||
;;
|
||||
;; Opcodes 136-159 reserved for hot BIFs.
|
||||
|
||||
;; ── Phase 9b — pattern-match opcodes ────────────────────────────
|
||||
;; Each handler takes a list (pattern-ast value env) and returns
|
||||
;; true/false, mutating env on success (same contract as the
|
||||
;; existing er-match-tuple / er-match-cons / er-match-binary).
|
||||
;; Wire these as wrappers for now; the real opcodes will eventually
|
||||
;; have register-machine semantics and skip the AST-walk overhead.
|
||||
|
||||
(define
|
||||
er-vm-register-erlang-opcodes!
|
||||
(fn
|
||||
()
|
||||
(er-vm-register-opcode!
|
||||
128
|
||||
"OP_PATTERN_TUPLE"
|
||||
(fn
|
||||
(operands)
|
||||
(er-match-tuple
|
||||
(nth operands 0)
|
||||
(nth operands 1)
|
||||
(nth operands 2))))
|
||||
(er-vm-register-opcode!
|
||||
129
|
||||
"OP_PATTERN_LIST"
|
||||
(fn
|
||||
(operands)
|
||||
(er-match-cons
|
||||
(nth operands 0)
|
||||
(nth operands 1)
|
||||
(nth operands 2))))
|
||||
(er-vm-register-opcode!
|
||||
130
|
||||
"OP_PATTERN_BINARY"
|
||||
(fn
|
||||
(operands)
|
||||
(er-match-binary
|
||||
(nth operands 0)
|
||||
(nth operands 1)
|
||||
(nth operands 2))))
|
||||
(er-vm-register-opcode! 131 "OP_PERFORM" er-vm-op-perform)
|
||||
(er-vm-register-opcode! 132 "OP_HANDLE" er-vm-op-handle)
|
||||
(er-vm-register-opcode! 133 "OP_RECEIVE_SCAN" er-vm-op-receive-scan)
|
||||
(er-vm-register-opcode! 134 "OP_SPAWN" er-vm-op-spawn)
|
||||
(er-vm-register-opcode! 135 "OP_SEND" er-vm-op-send)
|
||||
;; Phase 9f — hot BIFs
|
||||
(er-vm-register-opcode! 136 "OP_BIF_LENGTH" er-bif-length)
|
||||
(er-vm-register-opcode! 137 "OP_BIF_HD" er-bif-hd)
|
||||
(er-vm-register-opcode! 138 "OP_BIF_TL" er-bif-tl)
|
||||
(er-vm-register-opcode! 139 "OP_BIF_ELEMENT" er-bif-element)
|
||||
(er-vm-register-opcode! 140 "OP_BIF_TUPLE_SIZE" er-bif-tuple-size)
|
||||
(er-vm-register-opcode! 141 "OP_BIF_LISTS_REVERSE" er-bif-lists-reverse)
|
||||
(er-vm-register-opcode! 142 "OP_BIF_IS_INTEGER" er-bif-is-integer)
|
||||
(er-vm-register-opcode! 143 "OP_BIF_IS_ATOM" er-bif-is-atom)
|
||||
(er-vm-register-opcode! 144 "OP_BIF_IS_LIST" er-bif-is-list)
|
||||
(er-vm-register-opcode! 145 "OP_BIF_IS_TUPLE" er-bif-is-tuple)
|
||||
(er-mk-atom "ok")))
|
||||
|
||||
(er-vm-register-erlang-opcodes!)
|
||||
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/flow/README.md
Normal file
141
lib/flow/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# flow — durable DAG workflows on Scheme
|
||||
|
||||
`flow` is a workflow engine for rose-ash: content pipelines (write → review →
|
||||
publish → federate), scheduled jobs, and multi-step user flows (signup, confirm,
|
||||
onboard) that **survive process restarts**. It is a thin Scheme prelude over the
|
||||
Scheme-on-SX guest (`lib/scheme/`); a flow runs *inside* the interpreter.
|
||||
|
||||
Run the suite: `bash lib/flow/conformance.sh` → **151/151 across 10 suites**.
|
||||
|
||||
## Model
|
||||
|
||||
A **flow** is just a Scheme procedure of one argument — the upstream value:
|
||||
|
||||
```
|
||||
node : input -> output
|
||||
```
|
||||
|
||||
Combinators build composite nodes out of child nodes. A node that ignores its
|
||||
argument is effectively a thunk. There is no separate "graph" object: composition
|
||||
*is* function composition, so flows are values you can name, pass, and nest.
|
||||
|
||||
```scheme
|
||||
(defflow publish
|
||||
(sequence
|
||||
(lambda (draft) (string-append draft "!"))
|
||||
(branch (lambda (post) (>= (string-length post) 3))
|
||||
(remote-node 'fed 'publish)
|
||||
(flow-const 'rejected))))
|
||||
|
||||
(flow/start publish "hello") ; => federated, or a (flow-suspended id tag) state
|
||||
```
|
||||
|
||||
## Building blocks (`spec.sx`)
|
||||
|
||||
| Combinator | Meaning |
|
||||
|---|---|
|
||||
| `(flow-node f)` / `(flow-id x)` / `(flow-const v)` | leaf nodes |
|
||||
| `(sequence n ...)` | thread input left-to-right |
|
||||
| `(parallel n ...)` | fan input to every child, join results into a list (sequential eval) |
|
||||
| `(map-flow node)` | run `node` over each item of a list input, join results |
|
||||
| `(flow-while pred body max)` / `(flow-until ...)` | bounded iteration (cap `max` steps) |
|
||||
| `(defflow name body)` | bind + register a named flow (so it survives restart) |
|
||||
|
||||
## Control flow + errors (`spec.sx`)
|
||||
|
||||
| Combinator | Meaning |
|
||||
|---|---|
|
||||
| `(branch pred then else)` | `pred` on input selects `then`/`else` (`cond` is a Scheme special form) |
|
||||
| `(retry n node)` | re-run on a *raised exception*, up to `n` attempts |
|
||||
| `(timeout budget node)` | cooperative **step budget**: nodes call `(tick)`; the `(budget+1)`-th tick raises `flow-timeout` |
|
||||
| `(try-catch node handler)` | catch a raised exception → `(handler error)` |
|
||||
| `(fail reason)` / `(failed? x)` / `(fail-reason x)` | explicit failure *values* (flow downstream as data) |
|
||||
| `(recover node handler)` | the fail-VALUE counterpart of try-catch |
|
||||
| `(attempt n ...)` | railway sequence: stop at the first node returning a `(fail ...)` |
|
||||
| `(tap effect)` | run a side effect, return input unchanged |
|
||||
|
||||
**Two error channels, on purpose.** Raised exceptions are for *bugs/transients*
|
||||
(caught by `retry`/`try-catch`). `(fail reason)` values are for *expected business
|
||||
outcomes* (validation rejected, declined) and compose via `attempt`/`recover`.
|
||||
|
||||
## Suspend / resume — the durable core (`spec.sx`, `store.sx`)
|
||||
|
||||
The guest Scheme's `call/cc` is **escape-only** — re-invoking a captured
|
||||
continuation after it returns *hangs* the runtime. So flow does **not** serialize
|
||||
continuations. Instead it uses **deterministic replay**:
|
||||
|
||||
- `(suspend tag)` — if `tag` is already in the replay log, return its logged value;
|
||||
otherwise escape to the driver as `(flow-suspended tag)`.
|
||||
- `resume` appends `(tag value)` to the log and **re-runs the flow from the start**.
|
||||
Already-resolved suspends replay their values; the first unresolved one escapes
|
||||
again (or the flow completes).
|
||||
|
||||
The entire persisted state is the replay log — plain data. No live continuation is
|
||||
ever stored, so flows survive process restarts and even moves between instances.
|
||||
|
||||
> **Author contract:** suspend `tag`s must be unique and deterministic across
|
||||
> replays, and **all** non-determinism / side effects must go through suspend
|
||||
> points (so their results are logged) — otherwise they re-run on every replay.
|
||||
|
||||
### Lifecycle (`store.sx`)
|
||||
|
||||
```scheme
|
||||
(flow/start flow input) ; raw result if it completes, else (flow-suspended id tag)
|
||||
(flow/resume id value) ; inject value at the waiting tag, continue
|
||||
(flow/cancel id) ; terminate; a later resume is rejected
|
||||
```
|
||||
|
||||
### Introspection & hygiene
|
||||
|
||||
```scheme
|
||||
(flow/status id) ; done | suspended | cancelled | unknown
|
||||
(flow/result id) ; result if done, else (flow-error reason)
|
||||
(flow/list) ; ((id status) ...)
|
||||
(flow/pending) ; ((id waiting-tag) ...) — what each suspended flow awaits
|
||||
(flow/gc) ; drop terminal records, keep live ones; returns count removed
|
||||
(flow/forget id) ; drop one terminal record (refuses live flows)
|
||||
```
|
||||
|
||||
### Crash recovery
|
||||
|
||||
```scheme
|
||||
(flow-store-export) ; the store as plain data (live procs nulled)
|
||||
(flow-store-import! d) ; restore the store from exported data
|
||||
(flow-resumable-ids) ; ids of suspended flows to wake on restart
|
||||
```
|
||||
|
||||
On restart the flow definitions are reloaded (`defflow` re-registers names) and the
|
||||
exported store reimported; `resume` re-resolves each flow's procedure **by name**.
|
||||
|
||||
## Distribution via fed-sx (`remote.sx`)
|
||||
|
||||
```scheme
|
||||
(flow-peer-register! addr table) ; mock a peer's exposed functions (fed-sx boundary)
|
||||
(remote-node addr fn) ; run a node on a peer
|
||||
(remote-failover addrs fn local) ; try peers in order, fall through to a local node
|
||||
(flow-replicate-to addr) ; copy this store to a peer's replica slot
|
||||
(flow-restore-from addr) ; import a peer's replica (handoff)
|
||||
```
|
||||
|
||||
**Handoff** is crash recovery across instances: replicate → local instance dies →
|
||||
peer restores the (plain-data) store and resumes. The replay log carries over, so
|
||||
all resolved suspends survive the move.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `spec.sx` | combinators (flow-combinators-src / flow-control-src / flow-suspend-src) |
|
||||
| `store.sx` | durable store, lifecycle, crash recovery, introspection, hygiene |
|
||||
| `remote.sx` | fed-sx transport (mock peer registry), failover, replication |
|
||||
| `api.sx` | `flow-make-env` / `flow-run` SX helpers (one cached env, per-test reset) |
|
||||
| `tests/*.sx` | 10 suites, 151 cases |
|
||||
| `conformance.sh` | loads substrate + flow layer, runs every suite |
|
||||
|
||||
## Notes on the substrate
|
||||
|
||||
The guest Scheme (`lib/scheme/`, imported read-only) lacks dotted-rest params
|
||||
`(a . rest)` and named `let`; combinators use `(lambda args ...)` variadics + top-
|
||||
level recursion. `cons` is list-only (no dotted pairs), so log/assoc entries are
|
||||
2-element lists. Strings box as `{:scm-string "..."}`. Timeout is a step budget
|
||||
because there is no wall clock; `parallel` is sequential for the same reason.
|
||||
65
lib/flow/api.sx
Normal file
65
lib/flow/api.sx
Normal file
@@ -0,0 +1,65 @@
|
||||
;; lib/flow/api.sx — flow runtime entry points.
|
||||
;;
|
||||
;; Builds a Scheme env preloaded with the flow combinators (lib/flow/spec.sx),
|
||||
;; the durable store + lifecycle (lib/flow/store.sx), the fed-sx remote layer
|
||||
;; (lib/flow/remote.sx), and the host integration ABI (lib/flow/host.sx), and
|
||||
;; provides SX helpers to run flow programs.
|
||||
;;
|
||||
;; Scheme-level API (available inside flow programs):
|
||||
;; (flow/start flow input) — run a flow; raw result if it completes, else
|
||||
;; (flow-suspended id tag). Defined in store.sx.
|
||||
;; (flow/resume id value) — resume a suspended flow (store.sx)
|
||||
;; (flow/cancel id) — cancel a flow (store.sx)
|
||||
;; (suspend tag) — suspension point (spec.sx)
|
||||
;; (request kind payload) — host request envelope over suspend (host.sx)
|
||||
;; (remote-node addr fn) — node executed on a federation peer (remote.sx)
|
||||
;;
|
||||
;; SX-level helpers (for hosts and tests):
|
||||
;; (flow-make-env) — fresh standard env + combinators + store + remote + host
|
||||
;; (flow-run src) — eval a Scheme program string in a reset shared env
|
||||
;; (flow-run-in env src) — eval a Scheme program string in a given env
|
||||
;;
|
||||
;; flow-run reuses ONE env (building the full standard env is expensive) and
|
||||
;; resets the mutable flow globals before each program, so tests stay isolated
|
||||
;; without paying for a fresh standard env each time. flow-registry persists (it
|
||||
;; models reloaded flow definitions surviving a restart).
|
||||
|
||||
(define
|
||||
flow-make-env
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((env (scheme-standard-env)))
|
||||
(flow-load-combinators! env)
|
||||
(flow-load-store! env)
|
||||
(flow-load-remote! env)
|
||||
(flow-load-host! env)
|
||||
env)))
|
||||
|
||||
(define
|
||||
flow-run-in
|
||||
(fn (env src) (scheme-eval-program (scheme-parse-all src) env)))
|
||||
|
||||
(define
|
||||
flow-reset-src
|
||||
"(set! flow-store (list)) (set! flow-next-id 0) (set! flow-replay-log (list)) (set! flow-suspend-k #f) (set! flow-timeout-budget -1) (set! flow-peers (list)) (set! flow-replicas (list))")
|
||||
|
||||
(define flow-env-cache false)
|
||||
|
||||
(define
|
||||
flow-shared-env
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(if flow-env-cache nil (set! flow-env-cache (flow-make-env)))
|
||||
flow-env-cache)))
|
||||
|
||||
(define
|
||||
flow-run
|
||||
(fn
|
||||
(src)
|
||||
(let
|
||||
((env (flow-shared-env)))
|
||||
(begin
|
||||
(scheme-eval-program (scheme-parse-all flow-reset-src) env)
|
||||
(scheme-eval-program (scheme-parse-all src) env)))))
|
||||
103
lib/flow/conformance.sh
Executable file
103
lib/flow/conformance.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# flow-on-sx conformance runner — runs all flow test suites in one sx_server process.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/flow/conformance.sh # run all suites
|
||||
# bash lib/flow/conformance.sh -v # verbose (list each 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:-}"
|
||||
|
||||
# Suites: NAME RUNNER-FN PATH
|
||||
SUITES=(
|
||||
"basic flow-basic-tests-run! lib/flow/tests/basic.sx"
|
||||
"control flow-ctl-tests-run! lib/flow/tests/control.sx"
|
||||
"suspend flow-sus-tests-run! lib/flow/tests/suspend.sx"
|
||||
"recovery flow-rec-tests-run! lib/flow/tests/recovery.sx"
|
||||
"distributed flow-dist-tests-run! lib/flow/tests/distributed.sx"
|
||||
"api flow-api-tests-run! lib/flow/tests/api.sx"
|
||||
"combinators flow-cmb-tests-run! lib/flow/tests/combinators.sx"
|
||||
"railway flow-rail-tests-run! lib/flow/tests/railway.sx"
|
||||
"integration flow-int-tests-run! lib/flow/tests/integration.sx"
|
||||
"hygiene flow-hyg-tests-run! lib/flow/tests/hygiene.sx"
|
||||
"host flow-hst-tests-run! lib/flow/tests/host.sx"
|
||||
)
|
||||
|
||||
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
|
||||
EPOCH=1
|
||||
|
||||
emit_load () { echo "(epoch $EPOCH)"; echo "(load \"$1\")"; EPOCH=$((EPOCH+1)); }
|
||||
emit_eval () { echo "(epoch $EPOCH)"; echo "(eval \"$1\")"; EPOCH=$((EPOCH+1)); }
|
||||
|
||||
{
|
||||
emit_load "lib/guest/lex.sx"
|
||||
emit_load "lib/guest/reflective/env.sx"
|
||||
emit_load "lib/guest/reflective/quoting.sx"
|
||||
emit_load "lib/scheme/parser.sx"
|
||||
emit_load "lib/scheme/eval.sx"
|
||||
emit_load "lib/scheme/runtime.sx"
|
||||
emit_load "lib/flow/spec.sx"
|
||||
emit_load "lib/flow/store.sx"
|
||||
emit_load "lib/flow/remote.sx"
|
||||
emit_load "lib/flow/host.sx"
|
||||
emit_load "lib/flow/api.sx"
|
||||
for SUITE in "${SUITES[@]}"; do
|
||||
read -r _NAME _RUNNER FILE <<< "$SUITE"
|
||||
emit_load "$FILE"
|
||||
emit_eval "($_RUNNER)"
|
||||
done
|
||||
} > "$TMPFILE"
|
||||
|
||||
OUTPUT=$(timeout 540 "$SX_SERVER" < "$TMPFILE" 2>&1 || true)
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
FAILED_SUITES=()
|
||||
|
||||
LAST_DICT_LINES=$(echo "$OUTPUT" | grep -E '^\{:' || true)
|
||||
|
||||
I=0
|
||||
while read -r LINE; do
|
||||
[ -z "$LINE" ] && continue
|
||||
P=$(echo "$LINE" | grep -oE ':passed [0-9]+' | awk '{print $2}')
|
||||
F=$(echo "$LINE" | grep -oE ':failed [0-9]+' | awk '{print $2}')
|
||||
[ -z "$P" ] && P=0
|
||||
[ -z "$F" ] && F=0
|
||||
SUITE_INFO="${SUITES[$I]}"
|
||||
SUITE_NAME=$(echo "$SUITE_INFO" | awk '{print $1}')
|
||||
TOTAL_PASS=$((TOTAL_PASS + P))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
||||
if [ "$F" -gt 0 ]; then
|
||||
FAILED_SUITES+=("$SUITE_NAME: $P/$((P+F))")
|
||||
printf 'X %-12s %d/%d\n' "$SUITE_NAME" "$P" "$((P+F))"
|
||||
echo "$LINE" | grep -oE ':name "[^"]*"' | sed 's/:name / fail: /'
|
||||
elif [ "$VERBOSE" = "-v" ]; then
|
||||
printf 'ok %-12s %d passed\n' "$SUITE_NAME" "$P"
|
||||
fi
|
||||
I=$((I+1))
|
||||
done <<< "$LAST_DICT_LINES"
|
||||
|
||||
TOTAL=$((TOTAL_PASS + TOTAL_FAIL))
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
echo "ERROR: no suite results parsed. Raw output:" >&2
|
||||
echo "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $TOTAL_FAIL -eq 0 ]; then
|
||||
echo "ok $TOTAL_PASS/$TOTAL flow-on-sx tests passed (${#SUITES[@]} suites)"
|
||||
else
|
||||
echo "FAIL $TOTAL_PASS/$TOTAL passed, $TOTAL_FAIL failed:"
|
||||
for S in "${FAILED_SUITES[@]}"; do echo " $S"; done
|
||||
exit 1
|
||||
fi
|
||||
42
lib/flow/host.sx
Normal file
42
lib/flow/host.sx
Normal file
@@ -0,0 +1,42 @@
|
||||
;; lib/flow/host.sx — the host integration ABI (Phase 8).
|
||||
;;
|
||||
;; `suspend` is flow's seam to the outside world, but a bare (suspend tag) is just a
|
||||
;; signal — every author would invent their own tag shape. This layer defines a
|
||||
;; stable request/response contract so a host (e.g. an art-dag driver, or a human
|
||||
;; review UI) can hook in WITHOUT reverse-engineering ad-hoc tags.
|
||||
;;
|
||||
;; A flow asks the host to do something and waits for the answer:
|
||||
;; (request kind payload) — suspend with a typed envelope (flow-request kind
|
||||
;; payload); evaluates to the host's resume value.
|
||||
;; (await-human prompt) — request kind=human (a decision point)
|
||||
;; (await-render recipe) — request kind=render (e.g. an art-dag job)
|
||||
;; (await-effect kind p) — request of an arbitrary kind
|
||||
;;
|
||||
;; The host drives flows by polling its work queue and resuming:
|
||||
;; (flow-host-requests) — ((id kind payload) ...) for every SUSPENDED flow whose
|
||||
;; waiting tag is a host request. The host dispatches by kind (render -> submit a
|
||||
;; Celery job; human -> show UI), then calls (flow/resume id answer).
|
||||
;; (request? tag) / (request-kind tag) / (request-payload tag) — parse one tag.
|
||||
;;
|
||||
;; Reference driver — the host only supplies `dispatch`, a (kind payload) -> answer:
|
||||
;; (flow-drive-host dispatch) — one tick: service every CURRENTLY pending
|
||||
;; request (snapshot), resuming each with (dispatch kind payload); returns the
|
||||
;; count serviced. Resumes may create new requests — serviced on the next tick.
|
||||
;; (flow-run-host dispatch maxticks) — tick until quiescent (no pending requests)
|
||||
;; or maxticks reached; returns total requests serviced. Bounded for determinism.
|
||||
;;
|
||||
;; Contract: the host owns IO and persistence. flow stays deterministic — a flow
|
||||
;; never performs IO itself, it only `request`s; the host performs the effect and
|
||||
;; feeds the result back via resume (which the replay log records, so the effect is
|
||||
;; not re-run on recovery). Persist with flow-store-export after each transition and
|
||||
;; flow-store-import! on boot.
|
||||
|
||||
(define
|
||||
flow-host-src
|
||||
"(define (request kind payload) (suspend (list (quote flow-request) kind payload)))\n (define (request? tag) (and (pair? tag) (eq? (car tag) (quote flow-request))))\n (define (request-kind tag) (car (cdr tag)))\n (define (request-payload tag) (car (cdr (cdr tag))))\n (define (await-human prompt) (request (quote human) prompt))\n (define (await-render recipe) (request (quote render) recipe))\n (define (await-effect kind payload) (request kind payload))\n (define (flow-host-req-step pend)\n (if (null? pend)\n (list)\n (let ((id (car (car pend))) (tag (car (cdr (car pend)))))\n (if (request? tag)\n (cons (list id (request-kind tag) (request-payload tag))\n (flow-host-req-step (cdr pend)))\n (flow-host-req-step (cdr pend))))))\n (define (flow-host-requests) (flow-host-req-step (flow/pending)))\n (define (flow-drive-host-step reqs dispatch)\n (if (null? reqs)\n 0\n (begin\n (flow/resume (car (car reqs)) (dispatch (car (cdr (car reqs))) (car (cdr (cdr (car reqs))))))\n (+ 1 (flow-drive-host-step (cdr reqs) dispatch)))))\n (define (flow-drive-host dispatch) (flow-drive-host-step (flow-host-requests) dispatch))\n (define (flow-run-host dispatch maxticks)\n (if (<= maxticks 0)\n 0\n (let ((n (flow-drive-host dispatch)))\n (if (= n 0) 0 (+ n (flow-run-host dispatch (- maxticks 1)))))))")
|
||||
|
||||
(define
|
||||
flow-load-host!
|
||||
(fn
|
||||
(env)
|
||||
(begin (scheme-eval-program (scheme-parse-all flow-host-src) env) env)))
|
||||
34
lib/flow/remote.sx
Normal file
34
lib/flow/remote.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
;; lib/flow/remote.sx — distributed nodes via fed-sx (Phase 4).
|
||||
;;
|
||||
;; A node can execute on a federation peer. The transport is the fed-sx boundary;
|
||||
;; it is MOCKED in tests by a peer registry mapping addr -> function table. In
|
||||
;; production flow-transport would issue a fed-sx call; here it dispatches locally.
|
||||
;;
|
||||
;; (flow-peer-register! addr table) — register a mock peer. table is a list of
|
||||
;; (fn-name proc) entries — the functions that peer exposes.
|
||||
;; (flow-transport addr fn input) — invoke fn on the peer with input. Raises
|
||||
;; (flow-remote-unreachable) if the addr is unknown, (flow-remote-no-fn) if the
|
||||
;; peer does not expose fn.
|
||||
;; (remote-node addr fn) — a node that runs fn on the peer at addr.
|
||||
;; (remote-failover addrs fn local) — try fn on each peer in addrs in order; on a
|
||||
;; raised error move to the next peer; if every peer fails, run the `local`
|
||||
;; node as a fallback.
|
||||
;;
|
||||
;; Persistence across instances + handoff. Each instance runs the same flow
|
||||
;; definitions, so the only thing that needs to cross the wire is the (plain-data)
|
||||
;; store — exactly flow-store-export from store.sx. Replication pushes that export
|
||||
;; to a peer's replica slot; handoff = restore the replica on the peer and resume.
|
||||
;;
|
||||
;; (flow-replicate-to addr) — copy this instance's store to peer addr's replica
|
||||
;; (flow-restore-from addr) — import the replica from peer addr (#t / #f)
|
||||
;; (flow-replica-get addr) — the raw replicated store at addr (or #f)
|
||||
|
||||
(define
|
||||
flow-remote-src
|
||||
"(define flow-peers (list))\n (define (flow-assoc key alist)\n (if (null? alist)\n #f\n (if (eq? (car (car alist)) key) (car (cdr (car alist))) (flow-assoc key (cdr alist)))))\n (define (flow-peer-register! addr table) (set! flow-peers (cons (list addr table) flow-peers)))\n (define (flow-transport addr fn input)\n (let ((table (flow-assoc addr flow-peers)))\n (if table\n (let ((proc (flow-assoc fn table)))\n (if proc (proc input) (raise (quote flow-remote-no-fn))))\n (raise (quote flow-remote-unreachable)))))\n (define (remote-node addr fn) (lambda (input) (flow-transport addr fn input)))\n (define (flow-failover-step addrs fn input local)\n (if (null? addrs)\n (local input)\n (guard (e (#t (flow-failover-step (cdr addrs) fn input local)))\n (flow-transport (car addrs) fn input))))\n (define (remote-failover addrs fn local)\n (lambda (input) (flow-failover-step addrs fn input local)))\n\n (define flow-replicas (list))\n (define (flow-replicas-remove addr reps)\n (if (null? reps)\n (list)\n (if (eq? (car (car reps)) addr)\n (flow-replicas-remove addr (cdr reps))\n (cons (car reps) (flow-replicas-remove addr (cdr reps))))))\n (define (flow-replicate-to addr)\n (set! flow-replicas (cons (list addr (flow-store-export)) (flow-replicas-remove addr flow-replicas))))\n (define (flow-replica-get addr) (flow-assoc addr flow-replicas))\n (define (flow-restore-from addr)\n (let ((data (flow-replica-get addr)))\n (if data (begin (flow-store-import! data) #t) #f)))")
|
||||
|
||||
(define
|
||||
flow-load-remote!
|
||||
(fn
|
||||
(env)
|
||||
(begin (scheme-eval-program (scheme-parse-all flow-remote-src) env) env)))
|
||||
19
lib/flow/scoreboard.json
Normal file
19
lib/flow/scoreboard.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"total": 166,
|
||||
"passed": 166,
|
||||
"failed": 0,
|
||||
"suites": {
|
||||
"basic": { "passed": 18, "total": 18 },
|
||||
"control": { "passed": 31, "total": 31 },
|
||||
"suspend": { "passed": 17, "total": 17 },
|
||||
"recovery": { "passed": 8, "total": 8 },
|
||||
"distributed": { "passed": 19, "total": 19 },
|
||||
"api": { "passed": 12, "total": 12 },
|
||||
"combinators": { "passed": 17, "total": 17 },
|
||||
"railway": { "passed": 10, "total": 10 },
|
||||
"integration": { "passed": 10, "total": 10 },
|
||||
"hygiene": { "passed": 9, "total": 9 },
|
||||
"host": { "passed": 15, "total": 15 }
|
||||
},
|
||||
"phases": { "phase1": "done", "phase2": "done", "phase3": "done", "phase4": "done", "phase5": "done", "phase6": "done", "phase7": "done", "phase8": "done" }
|
||||
}
|
||||
53
lib/flow/scoreboard.md
Normal file
53
lib/flow/scoreboard.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# flow-on-sx Scoreboard
|
||||
|
||||
**All tests pass: 166 / 166 across 11 suites. Phases 1-8 complete.**
|
||||
|
||||
`bash lib/flow/conformance.sh`
|
||||
|
||||
## Per-suite breakdown
|
||||
|
||||
| Suite | Passing | Covers |
|
||||
|-------|--------:|--------|
|
||||
| basic | 18 | Phase 1: single nodes, linear sequence, data-flow threading, defflow, parallel fan/join, nested composition, publish-shaped flow |
|
||||
| control | 31 | Phase 2: `branch` (6); error model `fail`/`failed?`/`fail-reason` (6); `try-catch` (6); `retry n` (6); `timeout` cooperative step budget (7) |
|
||||
| suspend | 17 | Phase 3: suspend/resume/cancel via deterministic replay; multi-step, replay determinism, lifecycle guards, suspend-in-branch |
|
||||
| recovery | 8 | Phase 3: crash recovery — store export/import, resumable scan, restart-at-every-step, replay-log survival |
|
||||
| distributed | 19 | Phase 4: `remote-node` (7); `remote-failover` (6); replication + handoff across instances (6) |
|
||||
| api | 12 | Phase 5: introspection — `flow/status`, `flow/result`, `flow/list`, `flow/pending` |
|
||||
| combinators | 17 | Phase 5: `tap`, `recover` (fail-value), `map-flow` fan-over-list, `flow-while`/`flow-until` bounded iteration |
|
||||
| railway | 10 | Phase 6: `attempt` — fail-value short-circuiting sequence + recover rejoin |
|
||||
| integration | 10 | Phase 7: end-to-end order + onboarding flows composing every phase (suspend, branch, federation, crash recovery, handoff, introspection) |
|
||||
| hygiene | 9 | Phase 5: `flow/gc` (prune terminal flows), `flow/forget` (drop one terminal record) |
|
||||
| host | 15 | Phase 8: host ABI — `request`/`await-human`/`await-render`, `flow-host-requests` queue, `flow-run-host` reference driver; art-dag-shaped render→review→publish loop |
|
||||
|
||||
## Architecture
|
||||
|
||||
Flow combinators are a **Scheme prelude** (`lib/flow/spec.sx`) loaded onto
|
||||
`scheme-standard-env`. A flow is a Scheme procedure `input -> output`. The whole
|
||||
flow executes inside the Scheme interpreter, so Phase 3's `suspend` (call/cc) will
|
||||
capture the flow continuation directly.
|
||||
|
||||
- `lib/flow/spec.sx` — combinators: `flow-node`, `flow-id`, `flow-const`,
|
||||
`sequence`, `parallel`, `defflow`; `flow-load-combinators!`.
|
||||
- `lib/flow/api.sx` — `flow/start` (Scheme); `flow-make-env`, `flow-run`,
|
||||
`flow-run-in` (SX helpers).
|
||||
- `lib/flow/tests/basic.sx` — 18 cases.
|
||||
- `lib/flow/conformance.sh` — loads substrate + flow layer, runs suites.
|
||||
|
||||
## Semantics notes
|
||||
|
||||
- **node** = 1-arg Scheme procedure; the upstream value is the argument. A node
|
||||
ignoring its argument is effectively a thunk.
|
||||
- **sequence** threads left-to-right; empty sequence = identity.
|
||||
- **parallel** fans the same input to every branch and joins results into a list.
|
||||
Evaluation is **sequential** for now; true concurrency arrives in Phase 3.
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1 — Declarative DAG + sequential execution (combinators + 18 tests, `flow/start`)
|
||||
- [x] Phase 2 — Control flow + error handling (branch, error model, try-catch, retry, timeout)
|
||||
- [x] Phase 3 — Suspend/resume (suspend/resume/cancel + crash recovery via deterministic replay)
|
||||
- [x] Phase 4 — Distributed nodes via fed-sx (remote-node, failover, replication + handoff)
|
||||
- [x] Phase 5 — Operational API + combinators (introspection, tap, recover, map-flow)
|
||||
- [ ] Phase 3 — Suspend / resume (the showcase)
|
||||
- [ ] Phase 4 — Distributed nodes via fed-sx
|
||||
61
lib/flow/spec.sx
Normal file
61
lib/flow/spec.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; lib/flow/spec.sx — flow combinators as a Scheme prelude.
|
||||
;;
|
||||
;; A flow is a Scheme procedure of one argument: the upstream value.
|
||||
;; node : input -> output
|
||||
;; A leaf node ignoring its argument is effectively a thunk. Combinators
|
||||
;; build composite nodes out of child nodes. The whole flow runs INSIDE the
|
||||
;; Scheme interpreter.
|
||||
;;
|
||||
;; Phase 1 combinators (flow-combinators-src):
|
||||
;; flow-node / flow-id / flow-const / sequence / parallel / defflow
|
||||
;; defflow both binds the flow and registers it by name (flow-register!, in
|
||||
;; store.sx) so it can be re-resolved after a process restart.
|
||||
;; map-flow (Phase 5): run a node over each item of a list input, join results.
|
||||
;; flow-while / flow-until (Phase 5): bounded iteration — re-run body, threading
|
||||
;; the value, while/until pred holds, up to `max` steps (deterministic bound; no
|
||||
;; unbounded loops in pure SX).
|
||||
;;
|
||||
;; Phase 2 combinators (flow-control-src):
|
||||
;; branch / fail / failed? / fail-reason / try-catch / retry / timeout / tick
|
||||
;; tap (Phase 5): side-effecting pass-through (returns input unchanged).
|
||||
;; recover (Phase 5): the fail-VALUE counterpart of try-catch.
|
||||
;; attempt (Phase 6): railway sequence — thread nodes left-to-right but stop at
|
||||
;; the first node that returns a (fail ...) value, returning that failure.
|
||||
;;
|
||||
;; Phase 3 suspend core (flow-suspend-src):
|
||||
;; The guest Scheme's call/cc is ESCAPE-ONLY (re-invoking a captured k after it
|
||||
;; returns hangs the runtime), so suspend/resume CANNOT re-enter a continuation.
|
||||
;; Instead, durability uses DETERMINISTIC REPLAY: a flow re-runs from the start
|
||||
;; on each resume; suspend points that have already been resolved replay their
|
||||
;; logged value, and the first unresolved suspend escapes back to the driver.
|
||||
;; The entire persisted state is the replay log (plain (tag value) data), which
|
||||
;; survives process restart — no live continuation is ever serialized.
|
||||
;;
|
||||
;; (suspend tag) — if tag is in the replay log, return its value; else escape
|
||||
;; to the driver as (flow-suspended tag). tags must be unique & deterministic
|
||||
;; across replays. ALL effects/non-determinism must go through suspend so their
|
||||
;; results are logged (otherwise they re-run on every replay).
|
||||
;; (flow-drive flow input log) — run flow with the given replay log; returns
|
||||
;; (flow-done result) or (flow-suspended tag).
|
||||
|
||||
(define
|
||||
flow-combinators-src
|
||||
"(define (flow-node f) f)\n (define (flow-id input) input)\n (define (flow-const v) (lambda (input) v))\n (define (flow-seq-step ns v)\n (if (null? ns) v (flow-seq-step (cdr ns) ((car ns) v))))\n (define sequence (lambda ns (lambda (input) (flow-seq-step ns input))))\n (define parallel (lambda ns (lambda (input) (map (lambda (n) (n input)) ns))))\n (define (map-flow node) (lambda (items) (map node items)))\n (define (flow-while-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) (flow-while-step pred body (body input) (- n 1)) input)))\n (define (flow-while pred body max) (lambda (input) (flow-while-step pred body input max)))\n (define (flow-until-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) input (flow-until-step pred body (body input) (- n 1)))))\n (define (flow-until pred body max) (lambda (input) (flow-until-step pred body input max)))\n (define-syntax defflow\n (syntax-rules ()\n ((defflow nm body)\n (begin (define nm body) (flow-register! (quote nm) nm)))))")
|
||||
|
||||
(define
|
||||
flow-control-src
|
||||
"(define (branch pred then else)\n (lambda (input) (if (pred input) (then input) (else input))))\n (define (fail reason) (list (quote flow-fail) reason))\n (define (failed? x) (and (pair? x) (eq? (car x) (quote flow-fail))))\n (define (fail-reason x) (car (cdr x)))\n (define (recover node handler)\n (lambda (input)\n (let ((r (node input)))\n (if (failed? r) (handler (fail-reason r)) r))))\n (define (tap effect)\n (lambda (input) (begin (effect input) input)))\n (define (flow-attempt-step ns v)\n (if (failed? v)\n v\n (if (null? ns) v (flow-attempt-step (cdr ns) ((car ns) v)))))\n (define attempt (lambda ns (lambda (input) (flow-attempt-step ns input))))\n (define (try-catch node handler)\n (lambda (input) (guard (e (#t (handler e))) (node input))))\n (define (flow-retry-step n node input)\n (guard (e (#t (if (<= n 1) (raise e) (flow-retry-step (- n 1) node input))))\n (node input)))\n (define (retry n node) (lambda (input) (flow-retry-step n node input)))\n (define flow-timeout-budget -1)\n (define (tick)\n (if (< flow-timeout-budget 0)\n 0\n (begin\n (set! flow-timeout-budget (- flow-timeout-budget 1))\n (if (< flow-timeout-budget 0)\n (raise (quote flow-timeout))\n flow-timeout-budget))))\n (define (timeout budget node)\n (lambda (input)\n (let ((saved flow-timeout-budget))\n (set! flow-timeout-budget budget)\n (guard (e (#t (begin (set! flow-timeout-budget saved) (raise e))))\n (let ((result (node input)))\n (set! flow-timeout-budget saved)\n result)))))")
|
||||
|
||||
(define
|
||||
flow-suspend-src
|
||||
"(define flow-replay-log (list))\n (define flow-suspend-k #f)\n (define (flow-log-lookup tag log)\n (if (null? log)\n (list #f #f)\n (if (eq? (car (car log)) tag)\n (list #t (car (cdr (car log))))\n (flow-log-lookup tag (cdr log)))))\n (define (suspend tag)\n (let ((hit (flow-log-lookup tag flow-replay-log)))\n (if (car hit)\n (car (cdr hit))\n (flow-suspend-k (list (quote flow-suspended) tag)))))\n (define (flow-drive flow input log)\n (set! flow-replay-log log)\n (call/cc\n (lambda (k)\n (set! flow-suspend-k k)\n (list (quote flow-done) (flow input)))))")
|
||||
|
||||
(define
|
||||
flow-load-combinators!
|
||||
(fn
|
||||
(env)
|
||||
(begin
|
||||
(scheme-eval-program (scheme-parse-all flow-combinators-src) env)
|
||||
(scheme-eval-program (scheme-parse-all flow-control-src) env)
|
||||
(scheme-eval-program (scheme-parse-all flow-suspend-src) env)
|
||||
env)))
|
||||
45
lib/flow/store.sx
Normal file
45
lib/flow/store.sx
Normal file
File diff suppressed because one or more lines are too long
79
lib/flow/tests/api.sx
Normal file
79
lib/flow/tests/api.sx
Normal file
@@ -0,0 +1,79 @@
|
||||
;; lib/flow/tests/api.sx — Phase 5: operational introspection API.
|
||||
|
||||
(define flow-api-pass 0)
|
||||
(define flow-api-fail 0)
|
||||
(define flow-api-fails (list))
|
||||
|
||||
(define
|
||||
flow-api-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-api-pass (+ flow-api-pass 1))
|
||||
(begin
|
||||
(set! flow-api-fail (+ flow-api-fail 1))
|
||||
(append! flow-api-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-a (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/status ─────────────────────────────────────────────────
|
||||
(flow-api-test "status: unknown id" (flow-a "(flow/status 999)") "unknown")
|
||||
(flow-api-test
|
||||
"status: suspended flow"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/status id)")
|
||||
"suspended")
|
||||
(flow-api-test
|
||||
"status: completed flow"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 5) (flow/status id)")
|
||||
"done")
|
||||
(flow-api-test
|
||||
"status: cancelled flow"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/status id)")
|
||||
"cancelled")
|
||||
|
||||
;; ── flow/result ─────────────────────────────────────────────────
|
||||
(flow-api-test
|
||||
"result: returns the value of a completed flow"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote got) v)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 9) (flow/result id)")
|
||||
(list "got" 9))
|
||||
(flow-api-test
|
||||
"result: a still-suspended flow has no result"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/result id)")
|
||||
(list "flow-error" "not-done"))
|
||||
(flow-api-test
|
||||
"result: unknown id errors"
|
||||
(flow-a "(flow/result 999)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── flow/list ───────────────────────────────────────────────────
|
||||
(flow-api-test "list: empty store" (flow-a "(flow/list)") (list))
|
||||
(flow-api-test
|
||||
"list: reports id + status for each flow (newest first)"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) (* x 2)) 5) (flow/list)")
|
||||
(list (list 2 "done") (list 1 "suspended")))
|
||||
|
||||
;; ── flow/pending ────────────────────────────────────────────────
|
||||
(flow-api-test
|
||||
"pending: lists suspended flows with their waiting tag"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote review)))) (flow/start w 0) (flow/pending)")
|
||||
(list (list 1 "review")))
|
||||
(flow-api-test
|
||||
"pending: excludes completed and cancelled flows"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (defflow v (sequence (lambda (x) (suspend (quote r))) (lambda (y) y))) (define i1 (car (cdr (flow/start w 0)))) (define i2 (car (cdr (flow/start v 0)))) (define i3 (car (cdr (flow/start w 0)))) (flow/resume i2 1) (flow/cancel i3) (flow/pending)")
|
||||
(list (list 1 "q")))
|
||||
(flow-api-test
|
||||
"pending: operator can drain all pending flows"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 10)))) (flow/start w 0) (flow/start w 0) (define ps (flow/pending)) (flow/resume (car (car ps)) 1) (flow/resume (car (car (cdr ps))) 2) (flow/list)")
|
||||
(list (list 1 "done") (list 2 "done")))
|
||||
|
||||
(define flow-api-tests-run! (fn () {:total (+ flow-api-pass flow-api-fail) :passed flow-api-pass :failed flow-api-fail :fails flow-api-fails}))
|
||||
121
lib/flow/tests/basic.sx
Normal file
121
lib/flow/tests/basic.sx
Normal file
@@ -0,0 +1,121 @@
|
||||
;; lib/flow/tests/basic.sx — Phase 1: declarative DAG + sequential execution.
|
||||
|
||||
(define flow-basic-pass 0)
|
||||
(define flow-basic-fail 0)
|
||||
(define flow-basic-fails (list))
|
||||
|
||||
(define
|
||||
flow-basic-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-basic-pass (+ flow-basic-pass 1))
|
||||
(begin
|
||||
(set! flow-basic-fail (+ flow-basic-fail 1))
|
||||
(append! flow-basic-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
;; Run a Scheme flow-program string and return its final value.
|
||||
(define flow-b (fn (src) (flow-run src)))
|
||||
|
||||
;; Scheme strings are boxed as {:scm-string "..."}; unwrap to a host string.
|
||||
(define flow-bs (fn (src) (get (flow-run src) :scm-string)))
|
||||
|
||||
;; ── single node ─────────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"node: identity passes input through"
|
||||
(flow-b "(flow/start flow-id 7)")
|
||||
7)
|
||||
(flow-basic-test
|
||||
"node: const ignores input"
|
||||
(flow-b "(flow/start (flow-const 99) 1)")
|
||||
99)
|
||||
(flow-basic-test
|
||||
"node: bare lambda is a node"
|
||||
(flow-b "(flow/start (lambda (x) (* x x)) 6)")
|
||||
36)
|
||||
|
||||
;; ── linear sequence ─────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"sequence: empty is identity"
|
||||
(flow-b "(flow/start (sequence) 42)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"sequence: single child"
|
||||
(flow-b "(flow/start (sequence (lambda (x) (+ x 1))) 41)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"sequence: two children thread"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
||||
50)
|
||||
(flow-basic-test
|
||||
"sequence: three children thread"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 5)")
|
||||
9)
|
||||
|
||||
;; ── data flow between nodes ─────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"data flow: string accumulation"
|
||||
(flow-bs
|
||||
"(flow/start (sequence (lambda (s) (string-append s \"-a\")) (lambda (s) (string-append s \"-b\"))) \"x\")")
|
||||
"x-a-b")
|
||||
(flow-basic-test
|
||||
"data flow: list build"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (cons x (list))) (lambda (xs) (cons 0 xs))) 7)")
|
||||
(list 0 7))
|
||||
|
||||
;; ── defflow ─────────────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"defflow: names a flow"
|
||||
(flow-b
|
||||
"(defflow inc2 (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1)))) (flow/start inc2 40)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"defflow: reusable"
|
||||
(flow-b
|
||||
"(defflow dbl (lambda (x) (* x 2))) (+ (flow/start dbl 3) (flow/start dbl 10))")
|
||||
26)
|
||||
|
||||
;; ── parallel (sequential semantics, join into list) ─────────────
|
||||
(flow-basic-test
|
||||
"parallel: fans input to all branches"
|
||||
(flow-b
|
||||
"(flow/start (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 10)")
|
||||
(list 11 20 7))
|
||||
(flow-basic-test
|
||||
"parallel: empty joins to empty list"
|
||||
(flow-b "(flow/start (parallel) 5)")
|
||||
(list))
|
||||
(flow-basic-test
|
||||
"parallel: single branch"
|
||||
(flow-b "(flow/start (parallel (lambda (x) (* x x))) 9)")
|
||||
(list 81))
|
||||
|
||||
;; ── nested composition ──────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"nested: sequence of sequences"
|
||||
(flow-b
|
||||
"(flow/start (sequence (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1))) (sequence (lambda (x) (* x 3)))) 0)")
|
||||
6)
|
||||
(flow-basic-test
|
||||
"nested: parallel inside sequence, join then reduce"
|
||||
(flow-b
|
||||
"(flow/start (sequence (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (xs) (apply + xs))) 10)")
|
||||
31)
|
||||
(flow-basic-test
|
||||
"nested: sequence inside parallel branch"
|
||||
(flow-b
|
||||
"(flow/start (parallel (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (x) x)) 5)")
|
||||
(list 12 5))
|
||||
|
||||
;; ── publish-shaped flow (the architecture sketch) ───────────────
|
||||
(flow-basic-test
|
||||
"publish: write -> (review | spell) -> join lengths"
|
||||
(flow-b
|
||||
"(defflow publish (sequence (lambda (draft) (string-append draft \"!\")) (parallel (lambda (c) (string-length c)) (lambda (c) (string-length (string-append c \"?\")))))) (flow/start publish \"hi\")")
|
||||
(list 3 4))
|
||||
|
||||
(define flow-basic-tests-run! (fn () {:total (+ flow-basic-pass flow-basic-fail) :passed flow-basic-pass :failed flow-basic-fail :fails flow-basic-fails}))
|
||||
108
lib/flow/tests/combinators.sx
Normal file
108
lib/flow/tests/combinators.sx
Normal file
@@ -0,0 +1,108 @@
|
||||
;; lib/flow/tests/combinators.sx — Phase 5: combinator library (tap, recover, map-flow, iteration).
|
||||
|
||||
(define flow-cmb-pass 0)
|
||||
(define flow-cmb-fail 0)
|
||||
(define flow-cmb-fails (list))
|
||||
|
||||
(define
|
||||
flow-cmb-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-cmb-pass (+ flow-cmb-pass 1))
|
||||
(begin
|
||||
(set! flow-cmb-fail (+ flow-cmb-fail 1))
|
||||
(append! flow-cmb-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-m (fn (src) (flow-run src)))
|
||||
|
||||
;; ── tap (side-effecting pass-through) ───────────────────────────
|
||||
(flow-cmb-test
|
||||
"tap: returns input unchanged"
|
||||
(flow-m "(flow/start (tap (lambda (x) (* x 999))) 7)")
|
||||
7)
|
||||
(flow-cmb-test
|
||||
"tap: runs the side effect"
|
||||
(flow-m
|
||||
"(define seen 0) (flow/start (tap (lambda (x) (set! seen x))) 42) seen")
|
||||
42)
|
||||
(flow-cmb-test
|
||||
"tap: value flows on while the effect observes it"
|
||||
(flow-m
|
||||
"(define log 0) (flow/start (sequence (lambda (x) (+ x 1)) (tap (lambda (x) (set! log x))) (lambda (x) (* x 2))) 10) (list log (flow/result 1))")
|
||||
(list 11 22))
|
||||
|
||||
;; ── recover (fail-value counterpart of try-catch) ───────────────
|
||||
(flow-cmb-test
|
||||
"recover: passes a non-fail value through"
|
||||
(flow-m "(flow/start (recover (lambda (x) (* x 2)) (lambda (r) -1)) 5)")
|
||||
10)
|
||||
(flow-cmb-test
|
||||
"recover: handles a fail value via the reason"
|
||||
(flow-m
|
||||
"(flow/start (recover (lambda (x) (fail (quote too-small))) (lambda (r) (list (quote recovered) r))) 1)")
|
||||
(list "recovered" "too-small"))
|
||||
(flow-cmb-test
|
||||
"recover: handler can supply a default value"
|
||||
(flow-m
|
||||
"(flow/start (sequence (recover (lambda (x) (if (> x 0) x (fail (quote neg))) ) (flow-const 0)) (lambda (x) (* x 10))) -3)")
|
||||
0)
|
||||
(flow-cmb-test
|
||||
"recover: does not catch raised exceptions (those are try-catch's job)"
|
||||
(flow-m
|
||||
"(flow/start (try-catch (recover (lambda (x) (raise (quote boom))) (flow-const 0)) (lambda (e) e)) 1)")
|
||||
"boom")
|
||||
|
||||
;; ── map-flow (run a node over a list, join) ─────────────────────
|
||||
(flow-cmb-test
|
||||
"map-flow: applies the node to each item"
|
||||
(flow-m "(flow/start (map-flow (lambda (x) (* x x))) (list 1 2 3 4))")
|
||||
(list 1 4 9 16))
|
||||
(flow-cmb-test
|
||||
"map-flow: empty list joins to empty"
|
||||
(flow-m "(flow/start (map-flow (lambda (x) (+ x 1))) (list))")
|
||||
(list))
|
||||
(flow-cmb-test
|
||||
"map-flow: each item runs an independent sub-flow"
|
||||
(flow-m
|
||||
"(flow/start (map-flow (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)))) (list 0 4 9))")
|
||||
(list 2 10 20))
|
||||
(flow-cmb-test
|
||||
"map-flow: composes — fan over a list then reduce the join"
|
||||
(flow-m
|
||||
"(flow/start (sequence (map-flow (lambda (x) (* x 10))) (lambda (xs) (apply + xs))) (list 1 2 3))")
|
||||
60)
|
||||
|
||||
;; ── flow-while / flow-until (bounded iteration) ─────────────────
|
||||
(flow-cmb-test
|
||||
"flow-while: iterates while the predicate holds"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 10)) (lambda (x) (+ x 1)) 100) 0)")
|
||||
10)
|
||||
(flow-cmb-test
|
||||
"flow-while: a false predicate leaves input unchanged"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 0)) (lambda (x) (+ x 1)) 100) 5)")
|
||||
5)
|
||||
(flow-cmb-test
|
||||
"flow-while: respects the max-iteration bound"
|
||||
(flow-m "(flow/start (flow-while (lambda (x) #t) (lambda (x) (+ x 1)) 3) 0)")
|
||||
3)
|
||||
(flow-cmb-test
|
||||
"flow-while: doubles until past a threshold"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 50)) (lambda (x) (* x 2)) 100) 3)")
|
||||
96)
|
||||
(flow-cmb-test
|
||||
"flow-until: iterates until the predicate becomes true"
|
||||
(flow-m
|
||||
"(flow/start (flow-until (lambda (x) (>= x 10)) (lambda (x) (+ x 3)) 100) 0)")
|
||||
12)
|
||||
(flow-cmb-test
|
||||
"flow-until: composes inside a sequence"
|
||||
(flow-m
|
||||
"(flow/start (sequence (flow-until (lambda (x) (> x 100)) (lambda (x) (* x 3)) 100) (lambda (x) (- x 100))) 5)")
|
||||
35)
|
||||
|
||||
(define flow-cmb-tests-run! (fn () {:total (+ flow-cmb-pass flow-cmb-fail) :passed flow-cmb-pass :failed flow-cmb-fail :fails flow-cmb-fails}))
|
||||
179
lib/flow/tests/control.sx
Normal file
179
lib/flow/tests/control.sx
Normal file
@@ -0,0 +1,179 @@
|
||||
;; lib/flow/tests/control.sx — Phase 2: control flow + error handling.
|
||||
|
||||
(define flow-ctl-pass 0)
|
||||
(define flow-ctl-fail 0)
|
||||
(define flow-ctl-fails (list))
|
||||
|
||||
(define
|
||||
flow-ctl-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-ctl-pass (+ flow-ctl-pass 1))
|
||||
(begin
|
||||
(set! flow-ctl-fail (+ flow-ctl-fail 1))
|
||||
(append! flow-ctl-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-c (fn (src) (flow-run src)))
|
||||
(define flow-cs (fn (src) (get (flow-run src) :scm-string)))
|
||||
|
||||
;; ── branch ──────────────────────────────────────────────────────
|
||||
(flow-ctl-test
|
||||
"branch: true selects then"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) 5)")
|
||||
500)
|
||||
(flow-ctl-test
|
||||
"branch: false selects else"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) -3)")
|
||||
3)
|
||||
(flow-ctl-test
|
||||
"branch: predicate sees the threaded input"
|
||||
(flow-c
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (branch (lambda (x) (> x 3)) (flow-const 100) (flow-const 0))) 3)")
|
||||
100)
|
||||
(flow-ctl-test
|
||||
"branch: branches are full nodes (sequence inside)"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (< x 10)) (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (flow-const 0)) 4)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"branch: nested branch (3-way sign)"
|
||||
(flow-c
|
||||
"(defflow sign (branch (lambda (x) (> x 0)) (flow-const 1) (branch (lambda (x) (< x 0)) (flow-const -1) (flow-const 0)))) (list (flow/start sign 7) (flow/start sign -7) (flow/start sign 0))")
|
||||
(list 1 -1 0))
|
||||
(flow-ctl-test
|
||||
"branch: publish-shaped approval gate"
|
||||
(flow-cs
|
||||
"(defflow publish (branch (lambda (post) (>= (string-length post) 3)) (lambda (post) (string-append post \" [published]\")) (lambda (post) (string-append post \" [rejected]\")))) (flow/start publish \"ok\")")
|
||||
"ok [rejected]")
|
||||
|
||||
;; ── error model — explicit (fail reason) values ─────────────────
|
||||
(flow-ctl-test
|
||||
"fail: failed? is true for a failure value"
|
||||
(flow-c "(failed? (fail 404))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"fail: fail-reason extracts the reason"
|
||||
(flow-c "(fail-reason (fail 404))")
|
||||
404)
|
||||
(flow-ctl-test
|
||||
"fail: failed? is false for a plain value"
|
||||
(flow-c "(failed? 7)")
|
||||
false)
|
||||
(flow-ctl-test
|
||||
"fail: failed? is false for an ordinary list"
|
||||
(flow-c "(failed? (list 1 2 3))")
|
||||
false)
|
||||
(flow-ctl-test
|
||||
"fail: a node may emit a failure as data"
|
||||
(flow-c
|
||||
"(defflow validate (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short))))) (failed? (flow/start validate \"hi\"))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"fail: failure flows downstream, branch recovers"
|
||||
(flow-c
|
||||
"(defflow guarded (sequence (lambda (s) (if (>= (string-length s) 3) (string-length s) (fail (quote too-short)))) (branch failed? (lambda (f) (list (quote recovered) (fail-reason f))) (lambda (n) (list (quote ok) n))))) (flow/start guarded \"hi\")")
|
||||
(list "recovered" "too-short"))
|
||||
|
||||
;; ── try-catch — reify raised exceptions ─────────────────────────
|
||||
(flow-ctl-test
|
||||
"try-catch: no exception returns node result"
|
||||
(flow-c "(flow/start (try-catch (lambda (x) (* x 2)) (lambda (e) -1)) 5)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler runs on raise"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (lambda (x) (raise (quote boom))) (flow-const 99)) 1)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler receives the reified error"
|
||||
(flow-c "(flow/start (try-catch (lambda (x) (raise 42)) (lambda (e) e)) 0)")
|
||||
42)
|
||||
(flow-ctl-test
|
||||
"try-catch: catches exception from deep inside a sequence"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (sequence (lambda (x) (+ x 1)) (lambda (x) (raise (quote deep)))) (flow-const -99)) 5)")
|
||||
-99)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler may convert to a failure value"
|
||||
(flow-c
|
||||
"(failed? (flow/start (try-catch (lambda (x) (raise (quote bad))) (lambda (e) (fail e))) 0))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"try-catch: composes — recover then continue"
|
||||
(flow-c
|
||||
"(flow/start (sequence (try-catch (lambda (x) (raise (quote x))) (flow-const 10)) (lambda (n) (* n 5))) 0)")
|
||||
50)
|
||||
|
||||
;; ── retry — re-run on raised exceptions ─────────────────────────
|
||||
(flow-ctl-test
|
||||
"retry: succeeds after transient failures"
|
||||
(flow-c
|
||||
"(define ctr 0) (defflow flaky (lambda (x) (set! ctr (+ ctr 1)) (if (< ctr 3) (raise (quote nope)) (* x 10)))) (list (flow/start (retry 5 flaky) 7) ctr)")
|
||||
(list 70 3))
|
||||
(flow-ctl-test
|
||||
"retry: exhausted re-raises (caught by try-catch)"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (retry 2 (lambda (x) (raise (quote always)))) (flow-const (quote gaveup))) 0)")
|
||||
"gaveup")
|
||||
(flow-ctl-test
|
||||
"retry: n=1 means a single attempt"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (try-catch (retry 1 (lambda (x) (set! ctr (+ ctr 1)) (raise (quote bad)))) (lambda (e) ctr)) 0)")
|
||||
1)
|
||||
(flow-ctl-test
|
||||
"retry: success on first attempt does not re-run"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (* x 2))) (lambda (n) ctr)) 21)")
|
||||
1)
|
||||
(flow-ctl-test
|
||||
"retry: does not retry explicit failure values"
|
||||
(flow-c
|
||||
"(define ctr 0) (failed? (flow/start (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) 0))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"retry: failure-value path runs node exactly once"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) (lambda (f) ctr)) 0)")
|
||||
1)
|
||||
|
||||
;; ── timeout — cooperative step budget ───────────────────────────
|
||||
(flow-ctl-test
|
||||
"timeout: work within budget completes"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"timeout: work exceeding budget raises flow-timeout"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 20)")
|
||||
"timed-out")
|
||||
(flow-ctl-test
|
||||
"timeout: exact budget boundary completes"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"timeout: one tick over the budget raises"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 6)")
|
||||
"timed-out")
|
||||
(flow-ctl-test
|
||||
"timeout: the raised error is identifiable"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 2 (lambda (x) (cd x))) (lambda (e) e)) 9)")
|
||||
"flow-timeout")
|
||||
(flow-ctl-test
|
||||
"timeout: a node that never ticks is unbounded"
|
||||
(flow-c "(flow/start (timeout 0 (lambda (x) (* x 2))) 5)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"timeout: budget is restored across sequential timeouts"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 1 (begin (tick) (cd (- n 1))))) (flow/start (sequence (timeout 4 (lambda (x) (cd x))) (timeout 4 (lambda (x) (cd 3))) (lambda (x) (begin (tick) (+ x 100)))) 3)")
|
||||
101)
|
||||
|
||||
(define flow-ctl-tests-run! (fn () {:total (+ flow-ctl-pass flow-ctl-fail) :passed flow-ctl-pass :failed flow-ctl-fail :fails flow-ctl-fails}))
|
||||
120
lib/flow/tests/distributed.sx
Normal file
120
lib/flow/tests/distributed.sx
Normal file
@@ -0,0 +1,120 @@
|
||||
;; lib/flow/tests/distributed.sx — Phase 4: distributed nodes via fed-sx (mocked).
|
||||
|
||||
(define flow-dist-pass 0)
|
||||
(define flow-dist-fail 0)
|
||||
(define flow-dist-fails (list))
|
||||
|
||||
(define
|
||||
flow-dist-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-dist-pass (+ flow-dist-pass 1))
|
||||
(begin
|
||||
(set! flow-dist-fail (+ flow-dist-fail 1))
|
||||
(append! flow-dist-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-d (fn (src) (flow-run src)))
|
||||
|
||||
;; ── remote-node ─────────────────────────────────────────────────
|
||||
(flow-dist-test
|
||||
"remote: a node executes on a peer"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (remote-node (quote edge) (quote double)) 21)")
|
||||
42)
|
||||
(flow-dist-test
|
||||
"remote: remote nodes compose in a sequence"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote inc) (lambda (x) (+ x 1))) (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (remote-node (quote edge) (quote inc)) (remote-node (quote edge) (quote double))) 4)")
|
||||
10)
|
||||
(flow-dist-test
|
||||
"remote: a remote node mixes with local nodes"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (lambda (x) (+ x 5)) (remote-node (quote edge) (quote double)) (lambda (x) (- x 1))) 10)")
|
||||
29)
|
||||
(flow-dist-test
|
||||
"remote: unreachable peer raises flow-remote-unreachable"
|
||||
(flow-d
|
||||
"(flow/start (try-catch (remote-node (quote ghost) (quote double)) (lambda (e) e)) 1)")
|
||||
"flow-remote-unreachable")
|
||||
(flow-dist-test
|
||||
"remote: unknown function on a peer raises flow-remote-no-fn"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (try-catch (remote-node (quote edge) (quote missing)) (lambda (e) e)) 1)")
|
||||
"flow-remote-no-fn")
|
||||
(flow-dist-test
|
||||
"remote: a remote node can suspend the flow (peer returns control)"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote review) (lambda (x) x)))) (flow/start (sequence (remote-node (quote edge) (quote review)) (lambda (x) (suspend (quote human))) (lambda (v) (list (quote published) v))) 7)")
|
||||
(list "flow-suspended" 1 "human"))
|
||||
(flow-dist-test
|
||||
"remote: a transient remote failure is recoverable with retry"
|
||||
(flow-d
|
||||
"(define hits 0) (flow-peer-register! (quote edge) (list (list (quote flaky) (lambda (x) (begin (set! hits (+ hits 1)) (if (< hits 2) (raise (quote down)) (* x 3))))))) (list (flow/start (retry 3 (remote-node (quote edge) (quote flaky))) 7) hits)")
|
||||
(list 21 2))
|
||||
|
||||
;; ── failover (retry on a different peer, fall through to local) ──
|
||||
(flow-dist-test
|
||||
"failover: first reachable peer serves the request"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote p2) (quote down)) (quote f) (flow-const (quote local))) 5)")
|
||||
105)
|
||||
(flow-dist-test
|
||||
"failover: skips an unreachable peer to the next one"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote down) (quote p2)) (quote f) (flow-const (quote local))) 5)")
|
||||
105)
|
||||
(flow-dist-test
|
||||
"failover: skips a peer whose function raises"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote bad) (list (list (quote f) (lambda (x) (raise (quote boom)))))) (flow-peer-register! (quote good) (list (list (quote f) (lambda (x) (* x 10))))) (flow/start (remote-failover (list (quote bad) (quote good)) (quote f) (flow-const 0)) 4)")
|
||||
40)
|
||||
(flow-dist-test
|
||||
"failover: all peers fail, the local fallback runs"
|
||||
(flow-d
|
||||
"(flow/start (remote-failover (list (quote down1) (quote down2)) (quote f) (lambda (x) (* x -1))) 9)")
|
||||
-9)
|
||||
(flow-dist-test
|
||||
"failover: threads the input through to the chosen peer"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (list (quote got) x))))) (flow/start (sequence (lambda (x) (+ x 1)) (remote-failover (list (quote p)) (quote f) (flow-const 0))) 41)")
|
||||
(list "got" 42))
|
||||
(flow-dist-test
|
||||
"failover: composes inside a larger sequence"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (* x 2))))) (flow/start (sequence (remote-failover (list (quote down) (quote p)) (quote f) (flow-const 1)) (lambda (x) (+ x 3))) 5)")
|
||||
13)
|
||||
|
||||
;; ── replication + handoff ───────────────────────────────────────
|
||||
(flow-dist-test
|
||||
"replicate: a peer holds the exported store"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 10) (flow-replicate-to (quote peerB)) (if (flow-replica-get (quote peerB)) (quote replicated) (quote missing))")
|
||||
"replicated")
|
||||
(flow-dist-test
|
||||
"handoff: a peer resumes a flow after the local instance dies"
|
||||
(flow-d
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote done) v)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 55)")
|
||||
(list "done" 55))
|
||||
(flow-dist-test
|
||||
"handoff: restored peer reports the flow as resumable"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow-resumable-ids)")
|
||||
(list 1))
|
||||
(flow-dist-test
|
||||
"handoff: without restore the dead instance has lost the flow"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow/resume id 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
(flow-dist-test
|
||||
"restore: from an unknown peer yields false"
|
||||
(flow-d "(flow-restore-from (quote nowhere))")
|
||||
false)
|
||||
(flow-dist-test
|
||||
"handoff: replication preserves the replay log across the move"
|
||||
(flow-d
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 22)")
|
||||
(list 22))
|
||||
|
||||
(define flow-dist-tests-run! (fn () {:total (+ flow-dist-pass flow-dist-fail) :passed flow-dist-pass :failed flow-dist-fail :fails flow-dist-fails}))
|
||||
106
lib/flow/tests/host.sx
Normal file
106
lib/flow/tests/host.sx
Normal file
@@ -0,0 +1,106 @@
|
||||
;; lib/flow/tests/host.sx — Phase 8: host integration ABI (request/await/host-queue/driver).
|
||||
|
||||
(define flow-hst-pass 0)
|
||||
(define flow-hst-fail 0)
|
||||
(define flow-hst-fails (list))
|
||||
|
||||
(define
|
||||
flow-hst-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-hst-pass (+ flow-hst-pass 1))
|
||||
(begin
|
||||
(set! flow-hst-fail (+ flow-hst-fail 1))
|
||||
(append! flow-hst-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-hst (fn (src) (flow-run src)))
|
||||
|
||||
;; ── request envelope ────────────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"request: suspends with a typed envelope"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (request (quote render) x)) 5))))")
|
||||
(list "flow-request" "render" 5))
|
||||
(flow-hst-test
|
||||
"request?: recognizes an envelope"
|
||||
(flow-hst "(request? (list (quote flow-request) (quote human) 1))")
|
||||
true)
|
||||
(flow-hst-test
|
||||
"request?: a plain tag is not a request"
|
||||
(flow-hst "(request? (list (quote review) 1))")
|
||||
false)
|
||||
(flow-hst-test
|
||||
"request-kind / request-payload: parse the envelope"
|
||||
(flow-hst
|
||||
"(define t (list (quote flow-request) (quote render) (list (quote recipe) 7))) (list (request-kind t) (request-payload t))")
|
||||
(list "render" (list "recipe" 7)))
|
||||
|
||||
;; ── named decision points ───────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"await-human: is a request of kind human"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (await-human x)) (quote approve?)))))")
|
||||
(list "flow-request" "human" "approve?"))
|
||||
(flow-hst-test
|
||||
"await-render: is a request of kind render"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (await-render x)) (quote recipe)))))")
|
||||
(list "flow-request" "render" "recipe"))
|
||||
(flow-hst-test
|
||||
"request: the host's resume value flows back into the flow"
|
||||
(flow-hst
|
||||
"(defflow f (sequence (lambda (x) (await-render x)) (lambda (art) (list (quote got) art)))) (define id (car (cdr (flow/start f 1)))) (flow/resume id (quote the-artifact))")
|
||||
(list "got" "the-artifact"))
|
||||
|
||||
;; ── host work queue ─────────────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"flow-host-requests: lists (id kind payload) for pending requests"
|
||||
(flow-hst
|
||||
"(flow/start (lambda (x) (await-render x)) 99) (flow-host-requests)")
|
||||
(list (list 1 "render" 99)))
|
||||
(flow-hst-test
|
||||
"flow-host-requests: excludes bare (non-request) suspends"
|
||||
(flow-hst
|
||||
"(defflow a (lambda (x) (await-render x))) (defflow b (lambda (x) (suspend (quote plain)))) (flow/start a 1) (flow/start b 2) (flow-host-requests)")
|
||||
(list (list 1 "render" 1)))
|
||||
|
||||
;; ── the art-dag-shaped host driver loop (manual resumes) ────────
|
||||
(flow-hst-test
|
||||
"host driver: render then human-review then publish"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define r1 (flow-host-requests)) (flow/resume id (list (quote art) 99)) (define r2 (flow-host-requests)) (flow/resume id (quote approve)) (list r1 r2 (flow/status id) (flow/result id))")
|
||||
(list
|
||||
(list (list 1 "render" 99))
|
||||
(list (list 1 "human" (list "review" (list "art" 99))))
|
||||
"done"
|
||||
"published"))
|
||||
(flow-hst-test
|
||||
"host driver: rejection at the human gate yields a failure"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 1)))) (flow/resume id (quote artifact)) (failed? (flow/resume id (quote reject)))")
|
||||
true)
|
||||
|
||||
;; ── reference driver: host supplies only a dispatch fn ──────────
|
||||
(flow-hst-test
|
||||
"flow-drive-host: one tick services every pending request"
|
||||
(flow-hst
|
||||
"(flow/start (lambda (x) (await-render x)) 5) (define n (flow-drive-host (lambda (k p) (list (quote done) p)))) (list n (flow/status 1) (flow/result 1))")
|
||||
(list 1 "done" (list "done" 5)))
|
||||
(flow-hst-test
|
||||
"flow-run-host: drives a render -> human pipeline to completion"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define serviced (flow-run-host (lambda (kind payload) (if (eq? kind (quote render)) (list (quote art) payload) (quote approve))) 10)) (list serviced (flow/status id) (flow/result id))")
|
||||
(list 2 "done" "published"))
|
||||
(flow-hst-test
|
||||
"flow-run-host: returns 0 when nothing is pending"
|
||||
(flow-hst "(flow-run-host (lambda (k p) p) 5)")
|
||||
0)
|
||||
(flow-hst-test
|
||||
"flow-run-host: respects the maxticks bound"
|
||||
(flow-hst
|
||||
"(defflow pipe2 (sequence (lambda (r) (await-render r)) (lambda (a) (await-human a)) (lambda (d) d))) (define id (car (cdr (flow/start pipe2 1)))) (define serviced (flow-run-host (lambda (k p) p) 1)) (list serviced (flow/status id))")
|
||||
(list 1 "suspended"))
|
||||
|
||||
(define flow-hst-tests-run! (fn () {:total (+ flow-hst-pass flow-hst-fail) :passed flow-hst-pass :failed flow-hst-fail :fails flow-hst-fails}))
|
||||
67
lib/flow/tests/hygiene.sx
Normal file
67
lib/flow/tests/hygiene.sx
Normal file
@@ -0,0 +1,67 @@
|
||||
;; lib/flow/tests/hygiene.sx — Phase 5: store hygiene (flow/gc, flow/forget).
|
||||
|
||||
(define flow-hyg-pass 0)
|
||||
(define flow-hyg-fail 0)
|
||||
(define flow-hyg-fails (list))
|
||||
|
||||
(define
|
||||
flow-hyg-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-hyg-pass (+ flow-hyg-pass 1))
|
||||
(begin
|
||||
(set! flow-hyg-fail (+ flow-hyg-fail 1))
|
||||
(append! flow-hyg-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-h (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/gc ─────────────────────────────────────────────────────
|
||||
(flow-hyg-test
|
||||
"gc: empty store removes nothing"
|
||||
(flow-h "(flow/gc)")
|
||||
0)
|
||||
(flow-hyg-test
|
||||
"gc: removes a done flow, keeps a suspended one"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) x) 5) (define removed (flow/gc)) (list removed (flow/list))")
|
||||
(list 1 (list (list 1 "suspended"))))
|
||||
(flow-hyg-test
|
||||
"gc: removes a cancelled flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/gc)")
|
||||
1)
|
||||
(flow-hyg-test
|
||||
"gc: a kept suspended flow is still resumable"
|
||||
(flow-h
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 2)))) (define id (car (cdr (flow/start w 0)))) (flow/start (lambda (x) x) 1) (flow/gc) (flow/resume id 21)")
|
||||
42)
|
||||
(flow-hyg-test
|
||||
"gc: counts every terminal flow it drops"
|
||||
(flow-h
|
||||
"(flow/start (lambda (x) x) 1) (flow/start (lambda (x) x) 2) (defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/gc)")
|
||||
2)
|
||||
|
||||
;; ── flow/forget ─────────────────────────────────────────────────
|
||||
(flow-hyg-test
|
||||
"forget: drops a completed flow"
|
||||
(flow-h
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 7) (list (flow/forget id) (flow/status id))")
|
||||
(list true "unknown"))
|
||||
(flow-hyg-test
|
||||
"forget: refuses to drop a live (suspended) flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (list (flow/forget id) (flow/status id))")
|
||||
(list false "suspended"))
|
||||
(flow-hyg-test
|
||||
"forget: drops a cancelled flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (list (flow/forget id) (flow/status id))")
|
||||
(list true "unknown"))
|
||||
(flow-hyg-test
|
||||
"forget: unknown id yields false"
|
||||
(flow-h "(flow/forget 999)")
|
||||
false)
|
||||
|
||||
(define flow-hyg-tests-run! (fn () {:total (+ flow-hyg-pass flow-hyg-fail) :passed flow-hyg-pass :failed flow-hyg-fail :fails flow-hyg-fails}))
|
||||
115
lib/flow/tests/integration.sx
Normal file
115
lib/flow/tests/integration.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; lib/flow/tests/integration.sx — Phase 7: end-to-end flows composing every phase.
|
||||
|
||||
(define flow-int-pass 0)
|
||||
(define flow-int-fail 0)
|
||||
(define flow-int-fails (list))
|
||||
|
||||
(define
|
||||
flow-int-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-int-pass (+ flow-int-pass 1))
|
||||
(begin
|
||||
(set! flow-int-fail (+ flow-int-fail 1))
|
||||
(append! flow-int-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-i (fn (src) (flow-run src)))
|
||||
|
||||
;; The order-processing flow, defined once per program via this prelude string:
|
||||
;; validate amount (attempt: fail if <= 0)
|
||||
;; -> suspend for payment confirmation (resume value = confirmed amount)
|
||||
;; -> branch: confirmed>0 ? record on the ledger peer : declined failure
|
||||
(define
|
||||
order-prelude
|
||||
"(flow-peer-register! (quote ledger) (list (list (quote record) (lambda (amt) (list (quote recorded) amt)))))\n (defflow order\n (attempt\n (lambda (amt) (if (> amt 0) amt (fail (quote invalid-amount))))\n (lambda (amt) (suspend (quote await-payment)))\n (branch (lambda (amt) (> amt 0))\n (remote-node (quote ledger) (quote record))\n (flow-const (fail (quote declined))))))")
|
||||
|
||||
;; ── happy path through every phase ──────────────────────────────
|
||||
(flow-int-test
|
||||
"order: validate -> suspend -> resume -> branch -> federate"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
(flow-int-test
|
||||
"order: starting suspends awaiting payment"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define s (flow/start order 100)) (list (car s) (car (cdr (cdr s))))"))
|
||||
(list "flow-suspended" "await-payment"))
|
||||
(flow-int-test
|
||||
"order: invalid amount fails up front and never suspends"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define r (flow/start order -5)) (list (failed? r) (fail-reason r))"))
|
||||
(list true "invalid-amount"))
|
||||
(flow-int-test
|
||||
"order: a declined payment yields a failure value"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (failed? (flow/resume id 0))"))
|
||||
true)
|
||||
|
||||
;; ── crash recovery mid-flow ─────────────────────────────────────
|
||||
(flow-int-test
|
||||
"order: survives a simulated crash between suspend and resume"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
|
||||
;; ── handoff to a peer mid-flow ──────────────────────────────────
|
||||
(flow-int-test
|
||||
"order: hands off to a peer that resumes and completes"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (flow-replicate-to (quote nodeB)) (set! flow-store (list)) (flow-restore-from (quote nodeB)) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
|
||||
;; ── introspection during the flow's life ────────────────────────
|
||||
(flow-int-test
|
||||
"order: pending shows what the flow awaits, then result after resume"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (define p (flow/pending)) (flow/resume id 250) (list p (flow/status id) (flow/result id))"))
|
||||
(list
|
||||
(list (list 1 "await-payment"))
|
||||
"done"
|
||||
(list "recorded" 250)))
|
||||
|
||||
;; ── onboarding: two human steps + cancellation ──────────────────
|
||||
(define
|
||||
onboard-prelude
|
||||
"(defflow onboard\n (sequence\n (lambda (user) (+ user 1))\n (lambda (x) (suspend (quote confirm-email)))\n (lambda (x) (suspend (quote complete-profile)))\n (lambda (x) (list (quote onboarded) x))))")
|
||||
|
||||
(flow-int-test
|
||||
"onboard: two suspends resume in order to completion"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (flow/resume id 9)"))
|
||||
(list "onboarded" 9))
|
||||
(flow-int-test
|
||||
"onboard: the second pending tag appears after the first resume"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (car (cdr (car (flow/pending))))"))
|
||||
"complete-profile")
|
||||
(flow-int-test
|
||||
"onboard: cancelling abandons the flow"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/cancel id) (list (flow/status id) (car (flow/resume id 7)))"))
|
||||
(list "cancelled" "flow-error"))
|
||||
|
||||
(define flow-int-tests-run! (fn () {:total (+ flow-int-pass flow-int-fail) :passed flow-int-pass :failed flow-int-fail :fails flow-int-fails}))
|
||||
73
lib/flow/tests/railway.sx
Normal file
73
lib/flow/tests/railway.sx
Normal file
@@ -0,0 +1,73 @@
|
||||
;; lib/flow/tests/railway.sx — Phase 6: railway-oriented composition (attempt).
|
||||
|
||||
(define flow-rail-pass 0)
|
||||
(define flow-rail-fail 0)
|
||||
(define flow-rail-fails (list))
|
||||
|
||||
(define
|
||||
flow-rail-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-rail-pass (+ flow-rail-pass 1))
|
||||
(begin
|
||||
(set! flow-rail-fail (+ flow-rail-fail 1))
|
||||
(append! flow-rail-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-r (fn (src) (flow-run src)))
|
||||
|
||||
;; ── attempt — short-circuit on the first (fail ...) ─────────────
|
||||
(flow-rail-test
|
||||
"attempt: threads like sequence when nothing fails"
|
||||
(flow-r
|
||||
"(flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
||||
50)
|
||||
(flow-rail-test
|
||||
"attempt: empty is identity"
|
||||
(flow-r "(flow/start (attempt) 7)")
|
||||
7)
|
||||
(flow-rail-test
|
||||
"attempt: returns the first failure"
|
||||
(flow-r
|
||||
"(failed? (flow/start (attempt (lambda (x) (fail (quote bad))) (lambda (x) (* x 10))) 4))")
|
||||
true)
|
||||
(flow-rail-test
|
||||
"attempt: the failure carries its reason"
|
||||
(flow-r
|
||||
"(fail-reason (flow/start (attempt (lambda (x) x) (lambda (x) (fail (quote rejected)))) 4))")
|
||||
"rejected")
|
||||
(flow-rail-test
|
||||
"attempt: nodes after a failure do not run"
|
||||
(flow-r
|
||||
"(define ran 0) (flow/start (attempt (lambda (x) (fail (quote stop))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 0) ran")
|
||||
0)
|
||||
(flow-rail-test
|
||||
"attempt: a failed input short-circuits immediately"
|
||||
(flow-r
|
||||
"(define ran 0) (fail-reason (flow/start (attempt (lambda (x) (begin (set! ran (+ ran 1)) x))) (fail (quote pre))))")
|
||||
"pre")
|
||||
(flow-rail-test
|
||||
"attempt: middle failure halts the chain"
|
||||
(flow-r
|
||||
"(define ran 0) (flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (fail (quote mid))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 5) ran")
|
||||
0)
|
||||
|
||||
;; ── attempt + recover (rejoin the happy track) ──────────────────
|
||||
(flow-rail-test
|
||||
"attempt + recover: recover turns a failure into a value"
|
||||
(flow-r
|
||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) -5)")
|
||||
0)
|
||||
(flow-rail-test
|
||||
"attempt + recover: happy path passes recover through"
|
||||
(flow-r
|
||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) 5)")
|
||||
10)
|
||||
(flow-rail-test
|
||||
"attempt: validation pipeline reports the failing stage"
|
||||
(flow-r
|
||||
"(defflow validate (attempt (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short)))) (lambda (s) (if (<= (string-length s) 8) s (fail (quote too-long)))) (lambda (s) (list (quote ok) (string-length s))))) (list (fail-reason (flow/start validate \"hi\")) (flow/start validate \"hello\"))")
|
||||
(list "too-short" (list "ok" 5)))
|
||||
|
||||
(define flow-rail-tests-run! (fn () {:total (+ flow-rail-pass flow-rail-fail) :passed flow-rail-pass :failed flow-rail-fail :fails flow-rail-fails}))
|
||||
71
lib/flow/tests/recovery.sx
Normal file
71
lib/flow/tests/recovery.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/flow/tests/recovery.sx — Phase 3: crash recovery (store export/import + restart).
|
||||
;;
|
||||
;; "restart" is simulated within one program: (set! flow-store (list)) wipes the
|
||||
;; in-memory store (process death), while flow-registry persists as it would after
|
||||
;; reloading flow definitions. Recovery = import the exported (plain-data) store and
|
||||
;; resume; the flow proc is re-resolved by name.
|
||||
|
||||
(define flow-rec-pass 0)
|
||||
(define flow-rec-fail 0)
|
||||
(define flow-rec-fails (list))
|
||||
|
||||
(define
|
||||
flow-rec-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-rec-pass (+ flow-rec-pass 1))
|
||||
(begin
|
||||
(set! flow-rec-fail (+ flow-rec-fail 1))
|
||||
(append! flow-rec-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-r (fn (src) (flow-run src)))
|
||||
|
||||
;; ── export / wipe / import ──────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"export nulls the live procedure"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (flow/start w 10) (car (cdr (car (cdr (car (flow-store-export))))))")
|
||||
false)
|
||||
(flow-rec-test
|
||||
"a wiped store loses the flow (process death)"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (set! flow-store (list)) (flow/resume id 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
(flow-rec-test
|
||||
"import restores a wiped store and resume completes"
|
||||
(flow-r
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 777)")
|
||||
(list "done" 777))
|
||||
|
||||
;; ── resumable scan ──────────────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"resumable-ids lists the suspended flow after import"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
||||
(list 1))
|
||||
(flow-rec-test
|
||||
"resumable-ids excludes completed flows"
|
||||
(flow-r
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) c))) (define id (car (cdr (flow/start w 10)))) (flow/resume id 5) (flow-resumable-ids)")
|
||||
(list))
|
||||
(flow-rec-test
|
||||
"resumable-ids excludes cancelled flows after import"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (flow/cancel id) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
||||
(list))
|
||||
|
||||
;; ── restart at every step ───────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"two suspends survive a restart between each step"
|
||||
(flow-r
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (define s1 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s1) (flow/resume id 100) (define s2 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s2) (flow/resume id 200)")
|
||||
(list "end" 200))
|
||||
(flow-rec-test
|
||||
"import preserves the replay log (earlier value survives restart)"
|
||||
(flow-r
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 22)")
|
||||
(list 22))
|
||||
|
||||
(define flow-rec-tests-run! (fn () {:total (+ flow-rec-pass flow-rec-fail) :passed flow-rec-pass :failed flow-rec-fail :fails flow-rec-fails}))
|
||||
114
lib/flow/tests/suspend.sx
Normal file
114
lib/flow/tests/suspend.sx
Normal file
@@ -0,0 +1,114 @@
|
||||
;; lib/flow/tests/suspend.sx — Phase 3: suspend / resume / cancel (deterministic replay).
|
||||
|
||||
(define flow-sus-pass 0)
|
||||
(define flow-sus-fail 0)
|
||||
(define flow-sus-fails (list))
|
||||
|
||||
(define
|
||||
flow-sus-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-sus-pass (+ flow-sus-pass 1))
|
||||
(begin
|
||||
(set! flow-sus-fail (+ flow-sus-fail 1))
|
||||
(append! flow-sus-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-s (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/start ──────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"start: non-suspending flow returns the raw result"
|
||||
(flow-s "(flow/start (lambda (x) (* x 2)) 5)")
|
||||
10)
|
||||
(flow-sus-test
|
||||
"start: a suspending flow returns a flow-suspended state"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) c))) (car (flow/start w 10))")
|
||||
"flow-suspended")
|
||||
(flow-sus-test
|
||||
"start: suspended state carries a numeric id"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (flow/start w 10)))")
|
||||
1)
|
||||
(flow-sus-test
|
||||
"start: suspended state carries the suspend tag"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (cdr (flow/start w 10))))")
|
||||
"await")
|
||||
|
||||
;; ── flow/resume ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"resume: injects the value and completes"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 777)")
|
||||
(list "done" 777))
|
||||
(flow-sus-test
|
||||
"resume: injected value threads into the next node"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote v))) (lambda (n) (* n 3)))) (define s (flow/start w 0)) (flow/resume (car (cdr s)) 14)")
|
||||
42)
|
||||
(flow-sus-test
|
||||
"resume: replays earlier suspends (recompute is deterministic)"
|
||||
(flow-s
|
||||
"(define runs 0) (defflow w (sequence (lambda (x) (begin (set! runs (+ runs 1)) (+ x 1))) (lambda (g) (suspend (quote await))) (lambda (c) c))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 99) runs")
|
||||
2)
|
||||
|
||||
;; ── multi-step suspension ───────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"multi: first resume suspends at the next tag"
|
||||
(flow-s
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define s (flow/start two 0)) (define s2 (flow/resume (car (cdr s)) 100)) (car (cdr (cdr s2)))")
|
||||
"b")
|
||||
(flow-sus-test
|
||||
"multi: second resume completes with the latest value"
|
||||
(flow-s
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 100) (flow/resume id 200)")
|
||||
(list "end" 200))
|
||||
|
||||
;; ── error / lifecycle guards ────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"resume: completed flow cannot be resumed again"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 1) (flow/resume id 2)")
|
||||
(list "flow-error" "not-suspended"))
|
||||
(flow-sus-test
|
||||
"resume: unknown id errors"
|
||||
(flow-s "(flow/resume 999 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── flow/cancel ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"cancel: returns a flow-cancelled state"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id)")
|
||||
(list "flow-cancelled" 1))
|
||||
(flow-sus-test
|
||||
"cancel: a cancelled flow cannot be resumed (stale resume rejected)"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/resume id 5)")
|
||||
(list "flow-error" "not-suspended"))
|
||||
(flow-sus-test
|
||||
"cancel: unknown id errors"
|
||||
(flow-s "(flow/cancel 999)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── composition ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"suspend inside a branch arm"
|
||||
(flow-s
|
||||
"(defflow gate (branch (lambda (x) (> x 0)) (lambda (x) (suspend (quote approve))) (flow-const (quote rejected)))) (define s (flow/start gate 5)) (flow/resume (car (cdr s)) (quote approved))")
|
||||
"approved")
|
||||
(flow-sus-test
|
||||
"two independent runs get independent ids"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (list (car (cdr (flow/start w 0))) (car (cdr (flow/start w 0))))")
|
||||
(list 1 2))
|
||||
(flow-sus-test
|
||||
"suspend reason may be a structured value"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (list (quote needs) (quote approval))))) (car (cdr (cdr (flow/start w 0))))")
|
||||
(list "needs" "approval"))
|
||||
|
||||
(define flow-sus-tests-run! (fn () {:total (+ flow-sus-pass flow-sus-fail) :passed flow-sus-pass :failed flow-sus-fail :fails flow-sus-fails}))
|
||||
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))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user