Compare commits
177 Commits
loops/sx-v
...
loops/rada
| Author | SHA1 | Date | |
|---|---|---|---|
| 35cc4dcec0 | |||
| 009a3ae8b6 | |||
| ef4ee5d517 | |||
| 19eabc1f01 | |||
| a6a09eb1b6 | |||
| 55925d1ed8 | |||
| 58aa9b64bf | |||
| c0a0d29a65 | |||
| 64d3925af1 | |||
| 1883903080 | |||
| 9a5bb0d895 | |||
| 731337d362 | |||
| 2c1b782267 | |||
| a2d5b4a11a | |||
| 6fa12e1922 | |||
| 3c6e6de4c4 | |||
| 88c8506089 | |||
| 6b449a8422 | |||
| 7cf661d514 | |||
| 4bbc27c159 | |||
| 1dc4548cc9 | |||
| 8cb985a2f3 | |||
| 80a925018c | |||
| adad4f4436 | |||
| a752334cc0 | |||
| 2b77dc9537 | |||
| 453f244a97 | |||
| 05f3ef9104 | |||
| 4b9b15e7c8 | |||
| dbc2daf64d | |||
| b6c2995b19 | |||
| d05b49873b | |||
| 8f9b8d6f5d | |||
| 4ee15a7ddd | |||
| 3480100caa | |||
| 0bd0003550 | |||
| d9f18a635e | |||
| 3aac6aae98 | |||
| 0d06966808 | |||
| 98ef13ad2a | |||
| 20c4a48d3b | |||
| b3e1af96af | |||
| 919bd961d1 | |||
| 1902cce57f | |||
| ff537bfba2 | |||
| 1e4cf25015 | |||
| 9c4a5d1913 | |||
| f91ac82434 | |||
| 5136249ae5 | |||
| 6fc61147a8 | |||
| 0122c41ecb | |||
| 58656b03e4 | |||
| b0feb7b01b | |||
| a979297959 | |||
| 37226cf6eb | |||
| 50a7f31a39 | |||
| 915f51b2b6 | |||
| e7501bdf8f | |||
| c3a0727645 | |||
| 1b94082a71 | |||
| 57184daaee | |||
| d9e2627b89 | |||
| bcabed6bce | |||
| 5098a8f015 | |||
| 9fe5c9044d | |||
| c6f397c3d9 | |||
| f553d5b0aa | |||
| 14486dd78f | |||
| 9036ce3400 | |||
| 8c91b34264 | |||
| a7902df365 | |||
| 459427512d | |||
| c50f5d5155 | |||
| f52ad1fac6 | |||
| 219e2fcfe7 | |||
| 1d3021d206 | |||
| fa99652970 | |||
| 4807bc9c58 | |||
| b693854dc4 | |||
| 674d8115b8 | |||
| 99f8f37ff8 | |||
| 9ed58bd0fc | |||
| ab04ec1cf7 | |||
| a019aa1edc | |||
| 1340c2626b | |||
| ff9abe3ae6 | |||
| 21bb17e4a6 | |||
| 4bd9262060 | |||
| 5b4a8be689 | |||
| 9f4c6787e4 | |||
| 5e27a7f0c9 | |||
| 86ddaf255c | |||
| 6c3b7d1cf9 | |||
| 2404a593bd | |||
| 44fb231391 | |||
| 171a08a2f8 | |||
| ba41f8a580 | |||
| 5f6d62f45b | |||
| ad21776002 | |||
| 4922b6e987 | |||
| 632e06d3cf | |||
| 48379e04bc | |||
| a94ffa0feb | |||
| 9acdbcb8d8 | |||
| 8ba66e0dc9 | |||
| 503bdf12d6 | |||
| e64d72f554 | |||
| e1c5fdae53 | |||
| 728a91e49f | |||
| 750035d543 | |||
| 976c6dd0ef | |||
| c1baca2e4e | |||
| 65467c232b | |||
| e60c74f8c3 | |||
| fe614fc531 | |||
| 4fc73a97f4 | |||
| 0f7444e0d5 | |||
| abde5fbac1 | |||
| b7fcd17e6e | |||
| 89ce7b857d | |||
| 4591ac530b | |||
| 250d0511c0 | |||
| 380bc69f94 | |||
| 77f17cc796 | |||
| 4548461bfc | |||
| 7d9dddcc80 | |||
| 36be6bf44b | |||
| c352d94cc6 | |||
| 857fae1331 | |||
| f8fc04840a | |||
| 76d1e9f53a | |||
| d8b57784fe | |||
| bcaaa11916 | |||
| 451bd4be62 | |||
| 19932a42a9 | |||
| 3629dd96a9 | |||
| a341041627 | |||
| b073a82b33 | |||
| 7996bcdacf | |||
| 3b6241508c | |||
| 5774065341 | |||
| 708b5a2b12 | |||
| e6261c2519 | |||
| 5c7ad01bd1 | |||
| 33725de03b | |||
| 5fd358a7a7 | |||
| 783e0cb5fe | |||
| 72896392c8 | |||
| 12b56afcd3 | |||
| 509197410f | |||
| 76614da154 | |||
| 4dfccc244d | |||
| 58d7445559 | |||
| 4e0a92ec00 | |||
| 85728621b0 | |||
| 715fab86d2 | |||
| 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",
|
||||
|
||||
@@ -1292,6 +1292,227 @@ let run_foundation_tests () =
|
||||
ignore (Sx_types.set_lambda_name (Lambda l) "my-fn");
|
||||
assert_eq "lambda name mutated" (String "my-fn") (lambda_name (Lambda l));
|
||||
|
||||
Printf.printf "\nSuite: crypto-sha2\n";
|
||||
(* NIST FIPS 180-4 published vectors. *)
|
||||
assert_eq "sha256 empty"
|
||||
(String "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
(call "crypto-sha256" [String ""]);
|
||||
assert_eq "sha256 abc"
|
||||
(String "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
||||
(call "crypto-sha256" [String "abc"]);
|
||||
assert_eq "sha256 896-bit"
|
||||
(String "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1")
|
||||
(call "crypto-sha256"
|
||||
[String "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"]);
|
||||
assert_eq "sha256 1M 'a'"
|
||||
(String "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0")
|
||||
(call "crypto-sha256" [String (String.make 1000000 'a')]);
|
||||
assert_eq "sha512 empty"
|
||||
(String "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")
|
||||
(call "crypto-sha512" [String ""]);
|
||||
assert_eq "sha512 abc"
|
||||
(String "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f")
|
||||
(call "crypto-sha512" [String "abc"]);
|
||||
assert_eq "sha512 896-bit"
|
||||
(String "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909")
|
||||
(call "crypto-sha512"
|
||||
[String ("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn"
|
||||
^ "hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu")]);
|
||||
|
||||
Printf.printf "\nSuite: crypto-sha3\n";
|
||||
(* NIST FIPS 202 published vectors. *)
|
||||
assert_eq "sha3-256 empty"
|
||||
(String "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a")
|
||||
(call "crypto-sha3-256" [String ""]);
|
||||
assert_eq "sha3-256 abc"
|
||||
(String "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532")
|
||||
(call "crypto-sha3-256" [String "abc"]);
|
||||
assert_eq "sha3-256 896-bit"
|
||||
(String "41c0dba2a9d6240849100376a8235e2c82e1b9998a999e21db32dd97496d3376")
|
||||
(call "crypto-sha3-256"
|
||||
[String "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"]);
|
||||
(* 1600-bit message: 0xa3 * 200 — exercises multi-block absorb (>136B). *)
|
||||
assert_eq "sha3-256 1600-bit 0xa3"
|
||||
(String "79f38adec5c20307a98ef76e8324afbfd46cfd81b22e3973c65fa1bd9de31787")
|
||||
(call "crypto-sha3-256" [String (String.make 200 '\xa3')]);
|
||||
|
||||
Printf.printf "\nSuite: dag-cbor\n";
|
||||
let mkdict pairs =
|
||||
let d = Sx_types.make_dict () in
|
||||
List.iter (fun (k, v) -> Hashtbl.replace d k v) pairs;
|
||||
Dict d
|
||||
in
|
||||
let enc v = call "cbor-encode" [v] in
|
||||
(* RFC 8949 Appendix A — minimal-length deterministic encoding. *)
|
||||
assert_eq "cbor 0" (String "\x00") (enc (Integer 0));
|
||||
assert_eq "cbor 23" (String "\x17") (enc (Integer 23));
|
||||
assert_eq "cbor 24" (String "\x18\x18") (enc (Integer 24));
|
||||
assert_eq "cbor 100" (String "\x18\x64") (enc (Integer 100));
|
||||
assert_eq "cbor 1000" (String "\x19\x03\xe8") (enc (Integer 1000));
|
||||
assert_eq "cbor 1000000"
|
||||
(String "\x1a\x00\x0f\x42\x40") (enc (Integer 1000000));
|
||||
assert_eq "cbor -1" (String "\x20") (enc (Integer (-1)));
|
||||
assert_eq "cbor -100" (String "\x38\x63") (enc (Integer (-100)));
|
||||
assert_eq "cbor -1000" (String "\x39\x03\xe7") (enc (Integer (-1000)));
|
||||
assert_eq "cbor false" (String "\xf4") (enc (Bool false));
|
||||
assert_eq "cbor true" (String "\xf5") (enc (Bool true));
|
||||
assert_eq "cbor null" (String "\xf6") (enc Nil);
|
||||
assert_eq "cbor \"\"" (String "\x60") (enc (String ""));
|
||||
assert_eq "cbor \"a\"" (String "\x61\x61") (enc (String "a"));
|
||||
assert_eq "cbor \"IETF\"" (String "\x64IETF") (enc (String "IETF"));
|
||||
assert_eq "cbor []" (String "\x80") (enc (List []));
|
||||
assert_eq "cbor [1,2,3]"
|
||||
(String "\x83\x01\x02\x03")
|
||||
(enc (List [Integer 1; Integer 2; Integer 3]));
|
||||
assert_eq "cbor [1,[2,3],[4,5]]"
|
||||
(String "\x83\x01\x82\x02\x03\x82\x04\x05")
|
||||
(enc (List [Integer 1;
|
||||
List [Integer 2; Integer 3];
|
||||
List [Integer 4; Integer 5]]));
|
||||
assert_eq "cbor {}" (String "\xa0") (enc (mkdict []));
|
||||
assert_eq "cbor {a:1,b:[2,3]}"
|
||||
(String "\xa2\x61\x61\x01\x61\x62\x82\x02\x03")
|
||||
(enc (mkdict ["a", Integer 1; "b", List [Integer 2; Integer 3]]));
|
||||
assert_eq "cbor {a..e:A..E}"
|
||||
(String "\xa5\x61\x61\x61\x41\x61\x62\x61\x42\x61\x63\x61\x43\x61\x64\x61\x44\x61\x65\x61\x45")
|
||||
(enc (mkdict ["a", String "A"; "b", String "B"; "c", String "C";
|
||||
"d", String "D"; "e", String "E"]));
|
||||
(* Determinism: insertion order + key length must not change bytes.
|
||||
Sort is length-then-bytewise → a, c, bb. *)
|
||||
let d1 = mkdict ["bb", Integer 2; "a", Integer 1; "c", Integer 3] in
|
||||
let d2 = mkdict ["c", Integer 3; "bb", Integer 2; "a", Integer 1] in
|
||||
assert_eq "cbor det order-invariant" (enc d1) (enc d2);
|
||||
assert_eq "cbor det length-then-bytewise"
|
||||
(String "\xa3\x61\x61\x01\x61\x63\x03\x62\x62\x62\x02")
|
||||
(enc d1);
|
||||
(* Round-trip: decode . encode = identity (structural). *)
|
||||
let roundtrip name v =
|
||||
assert_eq ("cbor rt " ^ name) v (call "cbor-decode" [enc v])
|
||||
in
|
||||
roundtrip "int" (Integer 42);
|
||||
roundtrip "neg" (Integer (-99999));
|
||||
roundtrip "str" (String "hello world");
|
||||
roundtrip "bool" (Bool true);
|
||||
roundtrip "nil" Nil;
|
||||
roundtrip "nested"
|
||||
(List [Integer 1; String "x"; List [Bool false; Nil]]);
|
||||
roundtrip "dict"
|
||||
(mkdict ["k", List [Integer 7]; "name", String "z"]);
|
||||
|
||||
Printf.printf "\nSuite: cid\n";
|
||||
let mh_sha256 s = Sx_cid.multihash 0x12 (Sx_cid.unhex (Sx_sha2.sha256_hex s)) in
|
||||
(* Authoritative vectors (independently derived; match well-known
|
||||
IPFS CIDs). raw "abc" and raw "" — codec 0x55. *)
|
||||
assert_eq "cid raw abc"
|
||||
(String "bafkreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu")
|
||||
(call "cid-from-bytes" [Integer 0x55; String (mh_sha256 "abc")]);
|
||||
assert_eq "cid raw empty"
|
||||
(String "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")
|
||||
(call "cid-from-bytes" [Integer 0x55; String (mh_sha256 "")]);
|
||||
(* dag-cbor {} — canonical empty-map CID (sha2-256, codec 0x71). *)
|
||||
assert_eq "cid dag-cbor {}"
|
||||
(String "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua")
|
||||
(call "cid-from-sx" [mkdict []]);
|
||||
(* Determinism: dict key insertion order must not change the CID. *)
|
||||
let cda = call "cid-from-sx" [mkdict ["b", Integer 2; "a", Integer 1]] in
|
||||
let cdb = call "cid-from-sx" [mkdict ["a", Integer 1; "b", Integer 2]] in
|
||||
assert_eq "cid det order-invariant" cda cdb;
|
||||
assert_true "cid multibase 'b' prefix"
|
||||
(Bool (match call "cid-from-sx" [mkdict []] with
|
||||
| String s -> String.length s > 1 && s.[0] = 'b'
|
||||
| _ -> false));
|
||||
|
||||
Printf.printf "\nSuite: ed25519\n";
|
||||
let hx = Sx_ed25519.unhex in
|
||||
let edv pk msg sg = call "ed25519-verify"
|
||||
[String (hx pk); String (hx msg); String (hx sg)] in
|
||||
(* RFC 8032 §7.1 TEST 1-3 (deterministic; re-derived independently). *)
|
||||
assert_eq "ed25519 RFC T1"
|
||||
(Bool true)
|
||||
(edv "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
""
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
||||
assert_eq "ed25519 RFC T2"
|
||||
(Bool true)
|
||||
(edv "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||
"72"
|
||||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00");
|
||||
assert_eq "ed25519 RFC T3"
|
||||
(Bool true)
|
||||
(edv "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
|
||||
"af82"
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a");
|
||||
(* Tampered message -> false. *)
|
||||
assert_eq "ed25519 tampered msg"
|
||||
(Bool false)
|
||||
(edv "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
|
||||
"af83"
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a");
|
||||
(* Tampered signature -> false. *)
|
||||
assert_eq "ed25519 tampered sig"
|
||||
(Bool false)
|
||||
(edv "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
""
|
||||
"f5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
||||
(* Total: wrong-length pubkey / sig -> false, no exception. *)
|
||||
assert_eq "ed25519 short pubkey"
|
||||
(Bool false)
|
||||
(call "ed25519-verify" [String "abc"; String ""; String (String.make 64 '\000')]);
|
||||
assert_eq "ed25519 short sig"
|
||||
(Bool false)
|
||||
(call "ed25519-verify"
|
||||
[String (hx "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
|
||||
String ""; String "short"]);
|
||||
assert_eq "ed25519 non-string args"
|
||||
(Bool false)
|
||||
(call "ed25519-verify" [Integer 1; Integer 2; Integer 3]);
|
||||
|
||||
Printf.printf "\nSuite: rsa-sha256\n";
|
||||
(* Fixed RSA-2048 vector: one-off python-cryptography keygen +
|
||||
PKCS1v15/SHA-256 sign of "fed-sx phase F rsa test". *)
|
||||
let rhx = Sx_rsa.unhex in
|
||||
let spki = rhx "30820122300d06092a864886f70d01010105000382010f003082010a0282010100a117b573480bce5a08b54a98384001df26d062e9173caaee2e3a2d0045c6d16f99b2a1e7fb60763f65f95f8c39ff82c18b8590338042914331db3440a06d2dbe65a2f82c82f37d293f67a8b57a1f9014b55150a093cfee90257ef3b4a215d5ab002579bd92b6fcb3536777d51b639347d01e307ddafb209073dd9b8d6a507157c44c624a19b3b9275931472462870ae02132630159132a85c1c889adfb358b6bbd3760ce3fffe6285964833a10ee436d5bc33dfab7f9ed630a74e9a32e5688f5a7797f7cc839ad2494dd1c4c4a8fab844cd26208794bf2602c16b9d12bde434066d8c0dd2d20489f4070f883bae2b4508ead4a1b80b44c576e9e37bdb5df69f10203010001" in
|
||||
let rmsg = rhx "6665642d73782070686173652046207273612074657374" in
|
||||
let rsig = rhx "5e1593d674ed15c0172546d38efdf1aebd252f4b0c0dfbe1f7996fd569d0bfd9f3e8689ea2b14aa45b5fc3f0a05d4f23c6b02b8820d71f6998ea3b5b0d071bb33142236e388b1226ece3ec447d33b38999f189c37564cf052cf038de94c67b2ddf9a97d5a73554bb88818f615824517209a4083258965adace55658f344104eaa0d5f2f44ea00cfac8754674aade87b40d955cccd1ccd9b7649a08b66ce3bc5dba2de96b3e859488ded3ef9fb3744a1e3495fd14841d8319b3cc08054c729d1c02739ee314eba2b20fac46e463f47eb67183d8455583eca73ba37448164612dd9cd77877135d30d12084c2843f986a5b8ad59c6600f9855b91d7cbdf7c6c4b0e" in
|
||||
let rsav s m g = call "rsa-sha256-verify" [String s; String m; String g] in
|
||||
assert_eq "rsa valid" (Bool true) (rsav spki rmsg rsig);
|
||||
assert_eq "rsa tampered msg" (Bool false)
|
||||
(rsav spki (rmsg ^ "x") rsig);
|
||||
assert_eq "rsa tampered sig" (Bool false)
|
||||
(rsav spki rmsg
|
||||
(rhx "5f1593d674ed15c0172546d38efdf1aebd252f4b0c0dfbe1f7996fd569d0bfd9f3e8689ea2b14aa45b5fc3f0a05d4f23c6b02b8820d71f6998ea3b5b0d071bb33142236e388b1226ece3ec447d33b38999f189c37564cf052cf038de94c67b2ddf9a97d5a73554bb88818f615824517209a4083258965adace55658f344104eaa0d5f2f44ea00cfac8754674aade87b40d955cccd1ccd9b7649a08b66ce3bc5dba2de96b3e859488ded3ef9fb3744a1e3495fd14841d8319b3cc08054c729d1c02739ee314eba2b20fac46e463f47eb67183d8455583eca73ba37448164612dd9cd77877135d30d12084c2843f986a5b8ad59c6600f9855b91d7cbdf7c6c4b0e"));
|
||||
assert_eq "rsa garbage spki" (Bool false)
|
||||
(rsav "not der" rmsg rsig);
|
||||
assert_eq "rsa non-string args" (Bool false)
|
||||
(call "rsa-sha256-verify" [Integer 1; Integer 2; Integer 3]);
|
||||
|
||||
Printf.printf "\nSuite: file-list-dir\n";
|
||||
let expect_err nm f =
|
||||
(try ignore (f ());
|
||||
incr fail_count; Printf.printf " FAIL: %s — no error\n" nm
|
||||
with Eval_error _ ->
|
||||
incr pass_count; Printf.printf " PASS: %s\n" nm
|
||||
| _ ->
|
||||
incr fail_count; Printf.printf " FAIL: %s — wrong exn\n" nm)
|
||||
in
|
||||
let tmp = Filename.temp_file "fld" "" in
|
||||
Sys.remove tmp; Unix.mkdir tmp 0o755;
|
||||
let touch n = let oc = open_out (Filename.concat tmp n) in close_out oc in
|
||||
touch "b.txt"; touch "a.txt"; touch "c.txt";
|
||||
assert_eq "file-list-dir sorted"
|
||||
(List [String "a.txt"; String "b.txt"; String "c.txt"])
|
||||
(call "file-list-dir" [String tmp]);
|
||||
expect_err "file-list-dir missing"
|
||||
(fun () -> call "file-list-dir" [String (Filename.concat tmp "nope")]);
|
||||
expect_err "file-list-dir not-a-dir"
|
||||
(fun () -> call "file-list-dir" [String (Filename.concat tmp "a.txt")]);
|
||||
expect_err "file-list-dir arity"
|
||||
(fun () -> call "file-list-dir" []);
|
||||
(* best-effort cleanup *)
|
||||
(try List.iter (fun n -> Sys.remove (Filename.concat tmp n))
|
||||
["a.txt"; "b.txt"; "c.txt"]; Unix.rmdir tmp
|
||||
with _ -> ());
|
||||
|
||||
Printf.printf "\nSuite: vm-extension-dispatch\n";
|
||||
let make_bc op = ({
|
||||
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||
@@ -1599,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 *)
|
||||
(* ====================================================================== *)
|
||||
@@ -708,6 +722,139 @@ let setup_evaluator_bridge env =
|
||||
match args with
|
||||
| [e; expr] -> Sx_ref.eval_expr expr e
|
||||
| _ -> raise (Eval_error "eval-in-env: (env expr)"));
|
||||
|
||||
(* fed-sx Milestone 1 Step 8 transport. NATIVE ONLY — sockets +
|
||||
threads; deliberately absent from the WASM kernel (registered
|
||||
here in bin/, never in lib/sx_primitives.ml). Minimal HTTP/1.1,
|
||||
Connection: close. handler : req-dict -> resp-dict where
|
||||
req = {:method :path :query :headers :body},
|
||||
resp = {:status :headers :body}. Never returns. *)
|
||||
Sx_primitives.register "http-listen" (fun args ->
|
||||
let strip_cr s =
|
||||
let n = String.length s in
|
||||
if n > 0 && s.[n - 1] = '\r' then String.sub s 0 (n - 1) else s
|
||||
in
|
||||
match args with
|
||||
| [port_v; handler] ->
|
||||
let port = match port_v with
|
||||
| Integer n -> n
|
||||
| Number f -> int_of_float f
|
||||
| _ -> raise (Eval_error "http-listen: (port handler)") in
|
||||
let sock = Unix.socket Unix.PF_INET Unix.SOCK_STREAM 0 in
|
||||
Unix.setsockopt sock Unix.SO_REUSEADDR true;
|
||||
Unix.bind sock
|
||||
(Unix.ADDR_INET (Unix.inet_addr_loopback, port));
|
||||
Unix.listen sock 64;
|
||||
(* SX runtime is shared across threads — serialize handler calls. *)
|
||||
let mtx = Mutex.create () in
|
||||
let reason = function
|
||||
| 200 -> "OK" | 201 -> "Created" | 204 -> "No Content"
|
||||
| 301 -> "Moved Permanently" | 302 -> "Found"
|
||||
| 400 -> "Bad Request" | 401 -> "Unauthorized"
|
||||
| 403 -> "Forbidden" | 404 -> "Not Found"
|
||||
| 405 -> "Method Not Allowed" | 500 -> "Internal Server Error"
|
||||
| _ -> "OK" in
|
||||
let handle fd =
|
||||
(try
|
||||
let ic = Unix.in_channel_of_descr fd in
|
||||
let oc = Unix.out_channel_of_descr fd in
|
||||
let reqline = strip_cr (input_line ic) in
|
||||
(match String.split_on_char ' ' reqline with
|
||||
| meth :: target :: _ ->
|
||||
let path, query =
|
||||
match String.index_opt target '?' with
|
||||
| Some i ->
|
||||
String.sub target 0 i,
|
||||
String.sub target (i + 1)
|
||||
(String.length target - i - 1)
|
||||
| None -> target, "" in
|
||||
let headers = Sx_types.make_dict () in
|
||||
let clen = ref 0 in
|
||||
let rec rdh () =
|
||||
let h = strip_cr (input_line ic) in
|
||||
if h = "" then ()
|
||||
else begin
|
||||
(match String.index_opt h ':' with
|
||||
| Some i ->
|
||||
let name =
|
||||
String.lowercase_ascii
|
||||
(String.trim (String.sub h 0 i)) in
|
||||
let value =
|
||||
String.trim
|
||||
(String.sub h (i + 1)
|
||||
(String.length h - i - 1)) in
|
||||
Hashtbl.replace headers name (String value);
|
||||
if name = "content-length" then
|
||||
(try clen := int_of_string value with _ -> ())
|
||||
| None -> ());
|
||||
rdh ()
|
||||
end in
|
||||
rdh ();
|
||||
let body =
|
||||
if !clen > 0 then begin
|
||||
let b = Bytes.create !clen in
|
||||
really_input ic b 0 !clen;
|
||||
Bytes.unsafe_to_string b
|
||||
end else "" in
|
||||
let req = Sx_types.make_dict () in
|
||||
Hashtbl.replace req "method" (String meth);
|
||||
Hashtbl.replace req "path" (String path);
|
||||
Hashtbl.replace req "query" (String query);
|
||||
Hashtbl.replace req "headers" (Dict headers);
|
||||
Hashtbl.replace req "body" (String body);
|
||||
Mutex.lock mtx;
|
||||
let resp =
|
||||
(try Sx_runtime.sx_call handler [Dict req]
|
||||
with e -> Mutex.unlock mtx; raise e) in
|
||||
Mutex.unlock mtx;
|
||||
let getk k = match resp with
|
||||
| Dict h -> Hashtbl.find_opt h k | _ -> None in
|
||||
let status = match getk "status" with
|
||||
| Some (Integer n) -> n
|
||||
| Some (Number f) -> int_of_float f
|
||||
| _ -> 200 in
|
||||
let rbody = match getk "body" with
|
||||
| Some (String s) -> s
|
||||
| Some v -> Sx_types.value_to_string v
|
||||
| None -> "" in
|
||||
let rhdrs = match getk "headers" with
|
||||
| Some (Dict h) ->
|
||||
Hashtbl.fold (fun k v acc ->
|
||||
(k, (match v with
|
||||
| String s -> s
|
||||
| v -> Sx_types.value_to_string v)) :: acc)
|
||||
h []
|
||||
| _ -> [] in
|
||||
let buf = Buffer.create 256 in
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "HTTP/1.1 %d %s\r\n" status
|
||||
(reason status));
|
||||
List.iter (fun (k, v) ->
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "%s: %s\r\n" k v)) rhdrs;
|
||||
if not (List.exists
|
||||
(fun (k, _) ->
|
||||
String.lowercase_ascii k = "content-type")
|
||||
rhdrs)
|
||||
then Buffer.add_string buf
|
||||
"Content-Type: text/plain\r\n";
|
||||
Buffer.add_string buf
|
||||
(Printf.sprintf "Content-Length: %d\r\n"
|
||||
(String.length rbody));
|
||||
Buffer.add_string buf "Connection: close\r\n\r\n";
|
||||
Buffer.add_string buf rbody;
|
||||
output_string oc (Buffer.contents buf);
|
||||
flush oc
|
||||
| _ -> ())
|
||||
with _ -> ());
|
||||
(try Unix.close fd with _ -> ())
|
||||
in
|
||||
while true do
|
||||
let fd, _ = Unix.accept sock in
|
||||
ignore (Thread.create handle fd)
|
||||
done;
|
||||
Nil
|
||||
| _ -> raise (Eval_error "http-listen: (port handler)"));
|
||||
bind "trampoline" (fun args ->
|
||||
match args with
|
||||
| [v] ->
|
||||
|
||||
49
hosts/ocaml/bin/test_http.sh
Executable file
49
hosts/ocaml/bin/test_http.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase H test — native-only http-listen primitive.
|
||||
# Starts sx_server with a tiny SX echo handler, drives it with curl
|
||||
# (GET / POST / 404 / custom header), asserts, then kills it.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SRV=_build/default/bin/sx_server.exe
|
||||
PORT=${HTTP_TEST_PORT:-8911}
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo " FAIL: $1 — $2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
if [ ! -x "$SRV" ]; then
|
||||
echo "build sx_server.exe first (dune build bin/sx_server.exe)"; exit 1
|
||||
fi
|
||||
|
||||
H='(begin (define (h req) (if (= (get req "path") "/echo") {:status 200 :headers {"X-Echo" (get req "method")} :body (str "M=" (get req "method") " P=" (get req "path") " Q=" (get req "query") " B=" (get req "body"))} {:status 404 :body "nope"})) (http-listen '"$PORT"' h))'
|
||||
ESC=${H//\"/\\\"}
|
||||
|
||||
{ printf '(epoch 1)\n(eval "%s")\n' "$ESC"; sleep 30; } | "$SRV" >/tmp/test_http_srv.out 2>&1 &
|
||||
SVPID=$!
|
||||
trap 'kill $SVPID 2>/dev/null; wait 2>/dev/null' EXIT
|
||||
|
||||
up=0
|
||||
for _ in $(seq 1 50); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$PORT/echo" 2>/dev/null && { up=1; break; }
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$up" = 1 ] || { echo " FAIL: server did not start"; cat /tmp/test_http_srv.out; exit 1; }
|
||||
|
||||
# GET with query + custom response header.
|
||||
g=$(curl -s -i "http://127.0.0.1:$PORT/echo?x=1" | tr -d '\r')
|
||||
echo "$g" | grep -q '^HTTP/1.1 200 OK' && ok "GET status 200" || bad "GET status" "$g"
|
||||
echo "$g" | grep -q '^X-Echo: GET' && ok "GET custom header" || bad "GET header" "$g"
|
||||
echo "$g" | grep -q '^M=GET P=/echo Q=x=1 B=$' && ok "GET echo body" || bad "GET body" "$g"
|
||||
|
||||
# POST with body.
|
||||
p=$(curl -s -X POST --data 'hello' "http://127.0.0.1:$PORT/echo")
|
||||
[ "$p" = 'M=POST P=/echo Q= B=hello' ] && ok "POST body echoed" || bad "POST body" "$p"
|
||||
|
||||
# 404 path.
|
||||
n=$(curl -s -i "http://127.0.0.1:$PORT/missing" | tr -d '\r')
|
||||
echo "$n" | grep -q '^HTTP/1.1 404 Not Found' && ok "404 status" || bad "404 status" "$n"
|
||||
echo "$n" | grep -q '^nope$' && ok "404 body" || bad "404 body" "$n"
|
||||
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" = 0 ]
|
||||
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
|
||||
142
hosts/ocaml/lib/sx_cbor.ml
Normal file
142
hosts/ocaml/lib/sx_cbor.ml
Normal file
@@ -0,0 +1,142 @@
|
||||
(** dag-cbor encode / decode — pure OCaml, WASM-safe.
|
||||
|
||||
RFC 8949 deterministic subset as constrained by IPLD dag-cbor
|
||||
(RFC 8742): unsigned/negative ints, text strings, arrays, maps
|
||||
with keys sorted by **length-then-bytewise**, bool, null, and
|
||||
tag 42 (CID link, decode-side passthrough). Floats are not
|
||||
supported (no fed-sx shape needs them yet) — encoding a [Number]
|
||||
or decoding a float head raises. Reference: RFC 8949 §3, §4.2. *)
|
||||
|
||||
open Sx_types
|
||||
|
||||
exception Cbor_error of string
|
||||
|
||||
(* ---- Encoder ---- *)
|
||||
|
||||
let write_head buf major v =
|
||||
let m = major lsl 5 in
|
||||
if v < 24 then
|
||||
Buffer.add_char buf (Char.chr (m lor v))
|
||||
else if v < 0x100 then begin
|
||||
Buffer.add_char buf (Char.chr (m lor 24));
|
||||
Buffer.add_char buf (Char.chr v)
|
||||
end else if v < 0x10000 then begin
|
||||
Buffer.add_char buf (Char.chr (m lor 25));
|
||||
Buffer.add_char buf (Char.chr ((v lsr 8) land 0xFF));
|
||||
Buffer.add_char buf (Char.chr (v land 0xFF))
|
||||
end else if v < 0x100000000 then begin
|
||||
Buffer.add_char buf (Char.chr (m lor 26));
|
||||
for i = 3 downto 0 do
|
||||
Buffer.add_char buf (Char.chr ((v lsr (8 * i)) land 0xFF))
|
||||
done
|
||||
end else begin
|
||||
Buffer.add_char buf (Char.chr (m lor 27));
|
||||
for i = 7 downto 0 do
|
||||
Buffer.add_char buf (Char.chr ((v lsr (8 * i)) land 0xFF))
|
||||
done
|
||||
end
|
||||
|
||||
(* dag-cbor map key order: shorter key first, then bytewise. *)
|
||||
let key_order a b =
|
||||
let la = String.length a and lb = String.length b in
|
||||
if la <> lb then compare la lb else compare a b
|
||||
|
||||
let rec encode_into buf (v : value) : unit =
|
||||
match v with
|
||||
| Integer n ->
|
||||
if n >= 0 then write_head buf 0 n
|
||||
else write_head buf 1 (-1 - n)
|
||||
| String s ->
|
||||
write_head buf 3 (String.length s);
|
||||
Buffer.add_string buf s
|
||||
| Symbol s | Keyword s ->
|
||||
write_head buf 3 (String.length s);
|
||||
Buffer.add_string buf s
|
||||
| Bool false -> Buffer.add_char buf '\xf4'
|
||||
| Bool true -> Buffer.add_char buf '\xf5'
|
||||
| Nil -> Buffer.add_char buf '\xf6'
|
||||
| List items ->
|
||||
write_head buf 4 (List.length items);
|
||||
List.iter (encode_into buf) items
|
||||
| Dict d ->
|
||||
let keys = Hashtbl.fold (fun k _ acc -> k :: acc) d [] in
|
||||
let keys = List.sort_uniq key_order keys in
|
||||
write_head buf 5 (List.length keys);
|
||||
List.iter (fun k ->
|
||||
write_head buf 3 (String.length k);
|
||||
Buffer.add_string buf k;
|
||||
encode_into buf (Hashtbl.find d k)) keys
|
||||
| Number _ ->
|
||||
raise (Cbor_error "cbor-encode: floats unsupported (dag-cbor subset)")
|
||||
| _ ->
|
||||
raise (Cbor_error
|
||||
("cbor-encode: unencodable value " ^ type_of v))
|
||||
|
||||
let encode (v : value) : string =
|
||||
let buf = Buffer.create 64 in
|
||||
encode_into buf v;
|
||||
Buffer.contents buf
|
||||
|
||||
(* ---- Decoder ---- *)
|
||||
|
||||
let decode (s : string) : value =
|
||||
let pos = ref 0 in
|
||||
let len = String.length s in
|
||||
let byte () =
|
||||
if !pos >= len then raise (Cbor_error "cbor-decode: truncated");
|
||||
let c = Char.code s.[!pos] in incr pos; c
|
||||
in
|
||||
let read_uint ai =
|
||||
if ai < 24 then ai
|
||||
else if ai = 24 then byte ()
|
||||
else if ai = 25 then let a = byte () in let b = byte () in (a lsl 8) lor b
|
||||
else if ai = 26 then begin
|
||||
let v = ref 0 in
|
||||
for _ = 0 to 3 do v := (!v lsl 8) lor byte () done; !v
|
||||
end else if ai = 27 then begin
|
||||
let v = ref 0 in
|
||||
for _ = 0 to 7 do v := (!v lsl 8) lor byte () done; !v
|
||||
end else raise (Cbor_error "cbor-decode: bad additional info")
|
||||
in
|
||||
let read_bytes n =
|
||||
if !pos + n > len then raise (Cbor_error "cbor-decode: truncated");
|
||||
let r = String.sub s !pos n in pos := !pos + n; r
|
||||
in
|
||||
let rec item () =
|
||||
let b = byte () in
|
||||
let major = b lsr 5 and ai = b land 0x1f in
|
||||
match major with
|
||||
| 0 -> Integer (read_uint ai)
|
||||
| 1 -> Integer (-1 - read_uint ai)
|
||||
| 2 -> String (read_bytes (read_uint ai))
|
||||
| 3 -> String (read_bytes (read_uint ai))
|
||||
| 4 ->
|
||||
let n = read_uint ai in
|
||||
List (List.init n (fun _ -> item ()))
|
||||
| 5 ->
|
||||
let n = read_uint ai in
|
||||
let d = make_dict () in
|
||||
for _ = 1 to n do
|
||||
let k = match item () with
|
||||
| String k -> k
|
||||
| _ -> raise (Cbor_error "cbor-decode: non-string map key")
|
||||
in
|
||||
Hashtbl.replace d k (item ())
|
||||
done;
|
||||
Dict d
|
||||
| 6 ->
|
||||
(* Tag: tag-42 CID link → pass the inner item through. *)
|
||||
ignore (read_uint ai); item ()
|
||||
| 7 ->
|
||||
(match ai with
|
||||
| 20 -> Bool false
|
||||
| 21 -> Bool true
|
||||
| 22 -> Nil
|
||||
| 23 -> Nil
|
||||
| _ ->
|
||||
raise (Cbor_error
|
||||
"cbor-decode: floats/simple unsupported (dag-cbor subset)"))
|
||||
| _ -> raise (Cbor_error "cbor-decode: bad major type")
|
||||
in
|
||||
let v = item () in
|
||||
v
|
||||
66
hosts/ocaml/lib/sx_cid.ml
Normal file
66
hosts/ocaml/lib/sx_cid.ml
Normal file
@@ -0,0 +1,66 @@
|
||||
(** CIDv1 computation — pure OCaml, WASM-safe.
|
||||
|
||||
Multihash + CIDv1 + multibase base32-lower (RFC 4648, no pad,
|
||||
multibase prefix 'b'). Codecs: dag-cbor 0x71, raw 0x55. Hash
|
||||
codes: sha2-256 0x12, sha3-256 0x16. Reference: the multiformats
|
||||
specs (unsigned-varint, multihash, cid, multibase). No deps. *)
|
||||
|
||||
open Sx_types
|
||||
|
||||
(* Unsigned LEB128 (multiformats unsigned-varint). *)
|
||||
let varint (n : int) : string =
|
||||
let buf = Buffer.create 4 in
|
||||
let n = ref n in
|
||||
let cont = ref true in
|
||||
while !cont do
|
||||
let b = !n land 0x7f in
|
||||
n := !n lsr 7;
|
||||
if !n = 0 then (Buffer.add_char buf (Char.chr b); cont := false)
|
||||
else Buffer.add_char buf (Char.chr (b lor 0x80))
|
||||
done;
|
||||
Buffer.contents buf
|
||||
|
||||
(* RFC 4648 base32 lowercase, no padding. *)
|
||||
let b32_alpha = "abcdefghijklmnopqrstuvwxyz234567"
|
||||
|
||||
let base32_lower (s : string) : string =
|
||||
let buf = Buffer.create ((String.length s * 8 + 4) / 5) in
|
||||
let acc = ref 0 and bits = ref 0 in
|
||||
String.iter (fun c ->
|
||||
acc := (!acc lsl 8) lor (Char.code c);
|
||||
bits := !bits + 8;
|
||||
while !bits >= 5 do
|
||||
bits := !bits - 5;
|
||||
Buffer.add_char buf b32_alpha.[(!acc lsr !bits) land 0x1f]
|
||||
done) s;
|
||||
if !bits > 0 then
|
||||
Buffer.add_char buf b32_alpha.[(!acc lsl (5 - !bits)) land 0x1f];
|
||||
Buffer.contents buf
|
||||
|
||||
(* "abef" -> the 2 raw bytes. *)
|
||||
let unhex (h : string) : string =
|
||||
let n = String.length h / 2 in
|
||||
let b = Bytes.create n in
|
||||
for i = 0 to n - 1 do
|
||||
Bytes.set b i
|
||||
(Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||
done;
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
(* multihash = varint(code) || varint(len) || digest *)
|
||||
let multihash (code : int) (digest : string) : string =
|
||||
varint code ^ varint (String.length digest) ^ digest
|
||||
|
||||
(* CIDv1 = 0x01 || varint(codec) || multihash ; multibase 'b' base32. *)
|
||||
let cidv1 (codec : int) (mh : string) : string =
|
||||
"b" ^ base32_lower ("\x01" ^ varint codec ^ mh)
|
||||
|
||||
let codec_dag_cbor = 0x71
|
||||
let mh_sha2_256 = 0x12
|
||||
|
||||
(* Canonicalize an SX value: dag-cbor encode -> sha2-256 ->
|
||||
multihash -> CIDv1 (dag-cbor codec). *)
|
||||
let cid_from_sx (v : value) : string =
|
||||
let cbor = Sx_cbor.encode v in
|
||||
let digest = unhex (Sx_sha2.sha256_hex cbor) in
|
||||
cidv1 codec_dag_cbor (multihash mh_sha2_256 digest)
|
||||
289
hosts/ocaml/lib/sx_ed25519.ml
Normal file
289
hosts/ocaml/lib/sx_ed25519.ml
Normal file
@@ -0,0 +1,289 @@
|
||||
(** Ed25519 signature verification — pure OCaml, WASM-safe.
|
||||
|
||||
RFC 8032 §5.1.7 cofactorless verify over edwards25519. Includes a
|
||||
minimal arbitrary-precision unsigned bignum (no Zarith / no deps)
|
||||
and twisted-Edwards extended-coordinate point arithmetic. Verify
|
||||
is total: malformed inputs return [false], never raise. SHA-512
|
||||
is reused from {!Sx_sha2}. Reference: RFC 8032, RFC 7748. *)
|
||||
|
||||
(* ---- Minimal bignum: int array, little-endian, base 2^26. ---- *)
|
||||
|
||||
let bits = 26
|
||||
let base = 1 lsl bits
|
||||
let mask = base - 1
|
||||
|
||||
type bn = int array (* normalized: no high zero limbs, length >= 1 *)
|
||||
|
||||
let norm (a : bn) : bn =
|
||||
let n = ref (Array.length a) in
|
||||
while !n > 1 && a.(!n - 1) = 0 do decr n done;
|
||||
if !n = Array.length a then a else Array.sub a 0 !n
|
||||
|
||||
let bzero : bn = [| 0 |]
|
||||
let of_int n : bn =
|
||||
if n = 0 then bzero
|
||||
else begin
|
||||
let r = ref [] and n = ref n in
|
||||
while !n > 0 do r := (!n land mask) :: !r; n := !n lsr bits done;
|
||||
norm (Array.of_list (List.rev !r))
|
||||
end
|
||||
|
||||
let is_zero (a : bn) = Array.length a = 1 && a.(0) = 0
|
||||
|
||||
let cmp (a : bn) (b : bn) : int =
|
||||
let a = norm a and b = norm b in
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
if la <> lb then compare la lb
|
||||
else begin
|
||||
let r = ref 0 and i = ref (la - 1) in
|
||||
while !r = 0 && !i >= 0 do
|
||||
if a.(!i) <> b.(!i) then r := compare a.(!i) b.(!i);
|
||||
decr i
|
||||
done; !r
|
||||
end
|
||||
|
||||
let add (a : bn) (b : bn) : bn =
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let n = (max la lb) + 1 in
|
||||
let r = Array.make n 0 in
|
||||
let carry = ref 0 in
|
||||
for i = 0 to n - 1 do
|
||||
let s = !carry
|
||||
+ (if i < la then a.(i) else 0)
|
||||
+ (if i < lb then b.(i) else 0) in
|
||||
r.(i) <- s land mask; carry := s lsr bits
|
||||
done;
|
||||
norm r
|
||||
|
||||
(* a - b, requires a >= b *)
|
||||
let sub (a : bn) (b : bn) : bn =
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let r = Array.make la 0 in
|
||||
let borrow = ref 0 in
|
||||
for i = 0 to la - 1 do
|
||||
let s = a.(i) - !borrow - (if i < lb then b.(i) else 0) in
|
||||
if s < 0 then (r.(i) <- s + base; borrow := 1)
|
||||
else (r.(i) <- s; borrow := 0)
|
||||
done;
|
||||
norm r
|
||||
|
||||
let mul (a : bn) (b : bn) : bn =
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let r = Array.make (la + lb) 0 in
|
||||
for i = 0 to la - 1 do
|
||||
let carry = ref 0 in
|
||||
for j = 0 to lb - 1 do
|
||||
let s = r.(i + j) + a.(i) * b.(j) + !carry in
|
||||
r.(i + j) <- s land mask; carry := s lsr bits
|
||||
done;
|
||||
r.(i + lb) <- r.(i + lb) + !carry
|
||||
done;
|
||||
norm r
|
||||
|
||||
let numbits (a : bn) : int =
|
||||
let a = norm a in
|
||||
let hi = Array.length a - 1 in
|
||||
if hi = 0 && a.(0) = 0 then 0
|
||||
else begin
|
||||
let b = ref 0 and v = ref a.(hi) in
|
||||
while !v > 0 do incr b; v := !v lsr 1 done;
|
||||
hi * bits + !b
|
||||
end
|
||||
|
||||
let bit (a : bn) (i : int) : int =
|
||||
let limb = i / bits and off = i mod bits in
|
||||
if limb >= Array.length a then 0 else (a.(limb) lsr off) land 1
|
||||
|
||||
(* r = a mod m (m > 0), binary long division. *)
|
||||
let bn_mod (a : bn) (m : bn) : bn =
|
||||
if cmp a m < 0 then norm a
|
||||
else begin
|
||||
let r = ref bzero in
|
||||
for i = numbits a - 1 downto 0 do
|
||||
(* r = r*2 + bit *)
|
||||
r := add !r !r;
|
||||
if bit a i = 1 then r := add !r [| 1 |];
|
||||
if cmp !r m >= 0 then r := sub !r m
|
||||
done;
|
||||
!r
|
||||
end
|
||||
|
||||
let div_small (a : bn) (d : int) : bn =
|
||||
let la = Array.length a in
|
||||
let q = Array.make la 0 in
|
||||
let rem = ref 0 in
|
||||
for i = la - 1 downto 0 do
|
||||
let cur = (!rem lsl bits) lor a.(i) in
|
||||
q.(i) <- cur / d; rem := cur mod d
|
||||
done;
|
||||
norm q
|
||||
|
||||
let powmod (b0 : bn) (e : bn) (m : bn) : bn =
|
||||
let result = ref [| 1 |] and b = ref (bn_mod b0 m) in
|
||||
let nb = numbits e in
|
||||
for i = 0 to nb - 1 do
|
||||
if bit e i = 1 then result := bn_mod (mul !result !b) m;
|
||||
b := bn_mod (mul !b !b) m
|
||||
done;
|
||||
!result
|
||||
|
||||
let of_bytes_le (s : string) : bn =
|
||||
let acc = ref bzero in
|
||||
for i = String.length s - 1 downto 0 do
|
||||
acc := add (mul !acc (of_int 256)) (of_int (Char.code s.[i]))
|
||||
done;
|
||||
!acc
|
||||
|
||||
let to_bytes_le (a : bn) (n : int) : string =
|
||||
let b = Bytes.make n '\000' in
|
||||
let cur = ref (norm a) in
|
||||
for i = 0 to n - 1 do
|
||||
let q = div_small !cur 256 in
|
||||
let r =
|
||||
let qm = mul q (of_int 256) in
|
||||
let d = sub !cur qm in
|
||||
if is_zero d then 0 else d.(0)
|
||||
in
|
||||
Bytes.set b i (Char.chr r);
|
||||
cur := q
|
||||
done;
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
(* ---- Field GF(p), p = 2^255 - 19 ---- *)
|
||||
|
||||
let p =
|
||||
let twop255 = Array.make 11 0 in (* 11*26 = 286 > 255 *)
|
||||
let limb = 255 / bits and off = 255 mod bits in
|
||||
twop255.(limb) <- 1 lsl off;
|
||||
sub (norm twop255) (of_int 19)
|
||||
|
||||
let fmod a = bn_mod a p
|
||||
let fadd a b = fmod (add a b)
|
||||
let fsub a b = fmod (add a (sub p (fmod b)))
|
||||
let fmul a b = fmod (mul a b)
|
||||
let fpow a e = powmod a e p
|
||||
let finv a = fpow a (sub p (of_int 2)) (* Fermat: a^(p-2) *)
|
||||
|
||||
(* group order L = 2^252 + 27742317777372353535851937790883648493 *)
|
||||
let ell =
|
||||
of_bytes_le
|
||||
"\xed\xd3\xf5\x5c\x1a\x63\x12\x58\xd6\x9c\xf7\xa2\xde\xf9\xde\x14\
|
||||
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10"
|
||||
|
||||
(* d = -121665 / 121666 mod p *)
|
||||
let dconst =
|
||||
let inv666 = finv (of_int 121666) in
|
||||
fmod (mul (fsub (of_int 0) (of_int 121665)) inv666)
|
||||
|
||||
(* sqrt(-1) = 2^((p-1)/4) mod p *)
|
||||
let sqrtm1 = fpow (of_int 2) (div_small (sub p (of_int 1)) 4)
|
||||
|
||||
(* ---- edwards25519 points in extended coords (X,Y,Z,T) ---- *)
|
||||
|
||||
type pt = { x : bn; y : bn; z : bn; t : bn }
|
||||
|
||||
let identity = { x = bzero; y = of_int 1; z = of_int 1; t = bzero }
|
||||
|
||||
(* add-2008-hwcd-3, complete for a = -1 on ed25519 *)
|
||||
let padd (p1 : pt) (p2 : pt) : pt =
|
||||
let a = fmul (fsub p1.y p1.x) (fsub p2.y p2.x) in
|
||||
let b = fmul (fadd p1.y p1.x) (fadd p2.y p2.x) in
|
||||
let c = fmul (fmul p1.t (fmul (of_int 2) dconst)) p2.t in
|
||||
let dd = fmul (fmul p1.z (of_int 2)) p2.z in
|
||||
let e = fsub b a in
|
||||
let f = fsub dd c in
|
||||
let g = fadd dd c in
|
||||
let h = fadd b a in
|
||||
{ x = fmul e f; y = fmul g h; t = fmul e h; z = fmul f g }
|
||||
|
||||
let scalar_mul (n : bn) (q : pt) : pt =
|
||||
let r = ref identity in
|
||||
for i = numbits n - 1 downto 0 do
|
||||
r := padd !r !r;
|
||||
if bit n i = 1 then r := padd !r q
|
||||
done;
|
||||
!r
|
||||
|
||||
let pnegate (q : pt) : pt =
|
||||
{ q with x = fsub (of_int 0) q.x; t = fsub (of_int 0) q.t }
|
||||
|
||||
(* Decompress a 32-byte little-endian point encoding. *)
|
||||
let decompress (s : string) : pt option =
|
||||
if String.length s <> 32 then None
|
||||
else begin
|
||||
let sign = (Char.code s.[31] lsr 7) land 1 in
|
||||
let s' = Bytes.of_string s in
|
||||
Bytes.set s' 31 (Char.chr (Char.code s.[31] land 0x7f));
|
||||
let y = of_bytes_le (Bytes.unsafe_to_string s') in
|
||||
if cmp y p >= 0 then None
|
||||
else begin
|
||||
let y2 = fmul y y in
|
||||
let u = fsub y2 (of_int 1) in
|
||||
let v = fadd (fmul dconst y2) (of_int 1) in
|
||||
(* x = u v^3 (u v^7)^((p-5)/8) *)
|
||||
let v3 = fmul (fmul v v) v in
|
||||
let v7 = fmul (fmul v3 v3) v in
|
||||
let exp = div_small (sub p (of_int 5)) 8 in
|
||||
let x0 = fmul (fmul u v3) (fpow (fmul u v7) exp) in
|
||||
let vx2 = fmul v (fmul x0 x0) in
|
||||
let x =
|
||||
if cmp vx2 u = 0 then Some x0
|
||||
else if cmp vx2 (fsub (of_int 0) u) = 0 then Some (fmul x0 sqrtm1)
|
||||
else None
|
||||
in
|
||||
match x with
|
||||
| None -> None
|
||||
| Some x ->
|
||||
if is_zero x && sign = 1 then None
|
||||
else begin
|
||||
let x = if (bit x 0) <> sign then fsub (of_int 0) x else x in
|
||||
Some { x; y; z = of_int 1; t = fmul x y }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
(* Encode a point to 32-byte little-endian (y with x-parity bit). *)
|
||||
let encode (q : pt) : string =
|
||||
let zi = finv q.z in
|
||||
let x = fmul q.x zi and y = fmul q.y zi in
|
||||
let b = Bytes.of_string (to_bytes_le y 32) in
|
||||
let last = Char.code (Bytes.get b 31) lor ((bit x 0) lsl 7) in
|
||||
Bytes.set b 31 (Char.chr last);
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
(* base point: y = 4/5 mod p, x even (sign 0). *)
|
||||
let base_point =
|
||||
let by = fmul (of_int 4) (finv (of_int 5)) in
|
||||
match decompress (to_bytes_le by 32) with
|
||||
| Some pt -> pt
|
||||
| None -> failwith "ed25519: base point decompress failed"
|
||||
|
||||
let unhex (h : string) : string =
|
||||
let n = String.length h / 2 in
|
||||
let b = Bytes.create n in
|
||||
for i = 0 to n - 1 do
|
||||
Bytes.set b i
|
||||
(Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||
done;
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
let sha512_bytes s = unhex (Sx_sha2.sha512_hex s)
|
||||
|
||||
(* RFC 8032 §5.1.7 cofactorless: encode([S]B - [k]A) == R. *)
|
||||
let verify ~pubkey ~msg ~sig_ : bool =
|
||||
if String.length pubkey <> 32 || String.length sig_ <> 64 then false
|
||||
else
|
||||
let rb = String.sub sig_ 0 32 in
|
||||
let sb = String.sub sig_ 32 32 in
|
||||
let s = of_bytes_le sb in
|
||||
if cmp s ell >= 0 then false
|
||||
else
|
||||
match decompress pubkey with
|
||||
| None -> false
|
||||
| Some a ->
|
||||
let h = sha512_bytes (rb ^ pubkey ^ msg) in
|
||||
let k = bn_mod (of_bytes_le h) ell in
|
||||
let sb_pt = scalar_mul s base_point in
|
||||
let ka = scalar_mul k a in
|
||||
let chk = padd sb_pt (pnegate ka) in
|
||||
(try encode chk = rb with _ -> false)
|
||||
@@ -3237,6 +3237,21 @@ let () =
|
||||
with Sys_error msg -> raise (Eval_error ("file-read: " ^ msg)))
|
||||
| _ -> raise (Eval_error "file-read: (path)"));
|
||||
|
||||
(* fed-sx Step 3 segment replay. Sorted names, no "."/".." ;
|
||||
errors prefixed like file-read (msg carries enoent/enotdir). *)
|
||||
register "file-list-dir" (fun args ->
|
||||
match args with
|
||||
| [String path] ->
|
||||
(try
|
||||
let names = Sys.readdir path in
|
||||
let names =
|
||||
Array.to_list names
|
||||
|> List.filter (fun n -> n <> "." && n <> "..") in
|
||||
let names = List.sort compare names in
|
||||
List (List.map (fun n -> String n) names)
|
||||
with Sys_error msg -> raise (Eval_error ("file-list-dir: " ^ msg)))
|
||||
| _ -> raise (Eval_error "file-list-dir: (path)"));
|
||||
|
||||
register "file-write" (fun args ->
|
||||
match args with
|
||||
| [String path; String content] ->
|
||||
@@ -4158,4 +4173,61 @@ let () =
|
||||
Sx_types.jit_skipped_count := 0;
|
||||
Sx_types.jit_threshold_skipped_count := 0;
|
||||
Sx_types.jit_evicted_count := 0;
|
||||
Nil)
|
||||
Nil);
|
||||
|
||||
(* fed-sx host primitives — pure-OCaml crypto (WASM-safe). *)
|
||||
register "crypto-sha256" (fun args ->
|
||||
match args with
|
||||
| [String s] -> String (Sx_sha2.sha256_hex s)
|
||||
| _ -> raise (Eval_error "crypto-sha256: (bytes)"));
|
||||
|
||||
register "crypto-sha512" (fun args ->
|
||||
match args with
|
||||
| [String s] -> String (Sx_sha2.sha512_hex s)
|
||||
| _ -> raise (Eval_error "crypto-sha512: (bytes)"));
|
||||
|
||||
register "crypto-sha3-256" (fun args ->
|
||||
match args with
|
||||
| [String s] -> String (Sx_sha3.sha3_256_hex s)
|
||||
| _ -> raise (Eval_error "crypto-sha3-256: (bytes)"));
|
||||
|
||||
register "cbor-encode" (fun args ->
|
||||
match args with
|
||||
| [v] ->
|
||||
(try String (Sx_cbor.encode v)
|
||||
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||
| _ -> raise (Eval_error "cbor-encode: (value)"));
|
||||
|
||||
register "cbor-decode" (fun args ->
|
||||
match args with
|
||||
| [String s] ->
|
||||
(try Sx_cbor.decode s
|
||||
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||
| _ -> raise (Eval_error "cbor-decode: (bytes)"));
|
||||
|
||||
register "cid-from-bytes" (fun args ->
|
||||
match args with
|
||||
| [Integer codec; String mh] ->
|
||||
String (Sx_cid.cidv1 codec mh)
|
||||
| _ -> raise (Eval_error "cid-from-bytes: (codec multihash-bytes)"));
|
||||
|
||||
register "cid-from-sx" (fun args ->
|
||||
match args with
|
||||
| [v] ->
|
||||
(try String (Sx_cid.cid_from_sx v)
|
||||
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||
| _ -> raise (Eval_error "cid-from-sx: (value)"));
|
||||
|
||||
(* Verify is total: any malformed input -> false, never raises. *)
|
||||
register "ed25519-verify" (fun args ->
|
||||
match args with
|
||||
| [String pk; String msg; String sg] ->
|
||||
Bool (try Sx_ed25519.verify ~pubkey:pk ~msg ~sig_:sg
|
||||
with _ -> false)
|
||||
| _ -> Bool false);
|
||||
|
||||
register "rsa-sha256-verify" (fun args ->
|
||||
match args with
|
||||
| [String spki; String msg; String sg] ->
|
||||
Bool (try Sx_rsa.verify ~spki ~msg ~sig_:sg with _ -> false)
|
||||
| _ -> Bool false)
|
||||
|
||||
220
hosts/ocaml/lib/sx_rsa.ml
Normal file
220
hosts/ocaml/lib/sx_rsa.ml
Normal file
@@ -0,0 +1,220 @@
|
||||
(** RSASSA-PKCS1-v1_5 verification with SHA-256 — pure OCaml,
|
||||
WASM-safe. Self-contained minimal bignum (modexp only), a tiny
|
||||
DER reader for SubjectPublicKeyInfo, and the fixed SHA-256
|
||||
DigestInfo prefix. Verify only on public data — constant time
|
||||
not required. Reference: RFC 8017 §8.2.2, §9.2. No deps. *)
|
||||
|
||||
(* ---- Minimal unsigned bignum: int array, little-endian, base 2^26 ---- *)
|
||||
|
||||
let bits = 26
|
||||
let base = 1 lsl bits
|
||||
let mask = base - 1
|
||||
|
||||
type bn = int array
|
||||
|
||||
let norm a =
|
||||
let n = ref (Array.length a) in
|
||||
while !n > 1 && a.(!n - 1) = 0 do decr n done;
|
||||
if !n = Array.length a then a else Array.sub a 0 !n
|
||||
|
||||
let bzero : bn = [| 0 |]
|
||||
let is_zero a = Array.length a = 1 && a.(0) = 0
|
||||
|
||||
let cmp a b =
|
||||
let a = norm a and b = norm b in
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
if la <> lb then compare la lb
|
||||
else begin
|
||||
let r = ref 0 and i = ref (la - 1) in
|
||||
while !r = 0 && !i >= 0 do
|
||||
if a.(!i) <> b.(!i) then r := compare a.(!i) b.(!i);
|
||||
decr i
|
||||
done; !r
|
||||
end
|
||||
|
||||
let add a b =
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let n = (max la lb) + 1 in
|
||||
let r = Array.make n 0 and carry = ref 0 in
|
||||
for i = 0 to n - 1 do
|
||||
let s = !carry + (if i < la then a.(i) else 0)
|
||||
+ (if i < lb then b.(i) else 0) in
|
||||
r.(i) <- s land mask; carry := s lsr bits
|
||||
done;
|
||||
norm r
|
||||
|
||||
let sub a b = (* requires a >= b *)
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let r = Array.make la 0 and borrow = ref 0 in
|
||||
for i = 0 to la - 1 do
|
||||
let s = a.(i) - !borrow - (if i < lb then b.(i) else 0) in
|
||||
if s < 0 then (r.(i) <- s + base; borrow := 1)
|
||||
else (r.(i) <- s; borrow := 0)
|
||||
done;
|
||||
norm r
|
||||
|
||||
let mul a b =
|
||||
let la = Array.length a and lb = Array.length b in
|
||||
let r = Array.make (la + lb) 0 in
|
||||
for i = 0 to la - 1 do
|
||||
let carry = ref 0 in
|
||||
for j = 0 to lb - 1 do
|
||||
let s = r.(i + j) + a.(i) * b.(j) + !carry in
|
||||
r.(i + j) <- s land mask; carry := s lsr bits
|
||||
done;
|
||||
r.(i + lb) <- r.(i + lb) + !carry
|
||||
done;
|
||||
norm r
|
||||
|
||||
let numbits a =
|
||||
let a = norm a in
|
||||
let hi = Array.length a - 1 in
|
||||
if hi = 0 && a.(0) = 0 then 0
|
||||
else begin
|
||||
let b = ref 0 and v = ref a.(hi) in
|
||||
while !v > 0 do incr b; v := !v lsr 1 done;
|
||||
hi * bits + !b
|
||||
end
|
||||
|
||||
let bit a i =
|
||||
let limb = i / bits and off = i mod bits in
|
||||
if limb >= Array.length a then 0 else (a.(limb) lsr off) land 1
|
||||
|
||||
let bn_mod a m = (* binary long division, m > 0 *)
|
||||
if cmp a m < 0 then norm a
|
||||
else begin
|
||||
let r = ref bzero in
|
||||
for i = numbits a - 1 downto 0 do
|
||||
r := add !r !r;
|
||||
if bit a i = 1 then r := add !r [| 1 |];
|
||||
if cmp !r m >= 0 then r := sub !r m
|
||||
done;
|
||||
!r
|
||||
end
|
||||
|
||||
let powmod b0 e m =
|
||||
let result = ref [| 1 |] and b = ref (bn_mod b0 m) in
|
||||
for i = 0 to numbits e - 1 do
|
||||
if bit e i = 1 then result := bn_mod (mul !result !b) m;
|
||||
b := bn_mod (mul !b !b) m
|
||||
done;
|
||||
!result
|
||||
|
||||
let of_bytes_be (s : string) : bn =
|
||||
let acc = ref bzero in
|
||||
for i = 0 to String.length s - 1 do
|
||||
acc := add (mul !acc [| 256 |]) [| Char.code s.[i] |]
|
||||
done;
|
||||
!acc
|
||||
|
||||
let div_small a d =
|
||||
let la = Array.length a in
|
||||
let q = Array.make la 0 and rem = ref 0 in
|
||||
for i = la - 1 downto 0 do
|
||||
let cur = (!rem lsl bits) lor a.(i) in
|
||||
q.(i) <- cur / d; rem := cur mod d
|
||||
done;
|
||||
norm q
|
||||
|
||||
let to_bytes_be (a : bn) (n : int) : string =
|
||||
let b = Bytes.make n '\000' in
|
||||
let cur = ref (norm a) in
|
||||
for i = n - 1 downto 0 do
|
||||
let q = div_small !cur 256 in
|
||||
let r =
|
||||
let d = sub !cur (mul q [| 256 |]) in
|
||||
if is_zero d then 0 else d.(0)
|
||||
in
|
||||
Bytes.set b i (Char.chr r);
|
||||
cur := q
|
||||
done;
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
(* ---- Minimal DER reader (for SubjectPublicKeyInfo) ---- *)
|
||||
|
||||
exception Der of string
|
||||
|
||||
(* Returns (tag, content_start, content_len, next). *)
|
||||
let der_tlv s pos =
|
||||
if pos + 2 > String.length s then raise (Der "short");
|
||||
let tag = Char.code s.[pos] in
|
||||
let l0 = Char.code s.[pos + 1] in
|
||||
let len, hdr =
|
||||
if l0 < 0x80 then l0, 2
|
||||
else begin
|
||||
let nb = l0 land 0x7f in
|
||||
if pos + 2 + nb > String.length s then raise (Der "short len");
|
||||
let v = ref 0 in
|
||||
for i = 0 to nb - 1 do
|
||||
v := (!v lsl 8) lor Char.code s.[pos + 2 + i]
|
||||
done;
|
||||
!v, 2 + nb
|
||||
end
|
||||
in
|
||||
(tag, pos + hdr, len, pos + hdr + len)
|
||||
|
||||
(* SPKI DER -> (n, e) as bignums. *)
|
||||
let parse_spki (der : string) : bn * bn =
|
||||
let tag, c, _l, _ = der_tlv der 0 in
|
||||
if tag <> 0x30 then raise (Der "spki: outer not SEQUENCE");
|
||||
(* AlgorithmIdentifier SEQUENCE — skip. *)
|
||||
let _, _, _, after_alg = der_tlv der c in
|
||||
(* BIT STRING. *)
|
||||
let bt, bc, bl, _ = der_tlv der after_alg in
|
||||
if bt <> 0x03 then raise (Der "spki: expected BIT STRING");
|
||||
(* First content byte = unused bits (must be 0). *)
|
||||
let rpk_start = bc + 1 in
|
||||
ignore bl;
|
||||
let st, sc, _, _ = der_tlv der rpk_start in
|
||||
if st <> 0x30 then raise (Der "spki: RSAPublicKey not SEQUENCE");
|
||||
let nt, nc, nl, after_n = der_tlv der sc in
|
||||
if nt <> 0x02 then raise (Der "spki: modulus not INTEGER");
|
||||
let et, ec, el, _ = der_tlv der after_n in
|
||||
if et <> 0x02 then raise (Der "spki: exponent not INTEGER");
|
||||
let n = of_bytes_be (String.sub der nc nl) in
|
||||
let e = of_bytes_be (String.sub der ec el) in
|
||||
(n, e)
|
||||
|
||||
(* SHA-256 DigestInfo DER prefix (RFC 8017 §9.2 note 1). *)
|
||||
let sha256_digestinfo_prefix =
|
||||
"\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20"
|
||||
|
||||
let unhex h =
|
||||
let n = String.length h / 2 in
|
||||
let b = Bytes.create n in
|
||||
for i = 0 to n - 1 do
|
||||
Bytes.set b i (Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||
done;
|
||||
Bytes.unsafe_to_string b
|
||||
|
||||
(* RSASSA-PKCS1-v1_5 verify with SHA-256. Total: any malformed
|
||||
input yields false (caller wraps, but be defensive here too). *)
|
||||
let verify ~spki ~msg ~sig_ : bool =
|
||||
try
|
||||
let n, e = parse_spki spki in
|
||||
let k = (numbits n + 7) / 8 in
|
||||
if String.length sig_ <> k then false
|
||||
else begin
|
||||
let s = of_bytes_be sig_ in
|
||||
if cmp s n >= 0 then false
|
||||
else begin
|
||||
let m = powmod s e n in
|
||||
let em = to_bytes_be m k in
|
||||
(* EM = 0x00 01 FF..FF 00 || DigestInfo || H *)
|
||||
let h = unhex (Sx_sha2.sha256_hex msg) in
|
||||
let t = sha256_digestinfo_prefix ^ h in
|
||||
let tlen = String.length t in
|
||||
if k < tlen + 11 then false
|
||||
else begin
|
||||
let ok = ref (em.[0] = '\x00' && em.[1] = '\x01') in
|
||||
let ps_end = k - tlen - 1 in
|
||||
for i = 2 to ps_end - 1 do
|
||||
if em.[i] <> '\xff' then ok := false
|
||||
done;
|
||||
if em.[ps_end] <> '\x00' then ok := false;
|
||||
if String.sub em (ps_end + 1) tlen <> t then ok := false;
|
||||
!ok
|
||||
end
|
||||
end
|
||||
end
|
||||
with _ -> false
|
||||
212
hosts/ocaml/lib/sx_sha2.ml
Normal file
212
hosts/ocaml/lib/sx_sha2.ml
Normal file
@@ -0,0 +1,212 @@
|
||||
(** SHA-2 (SHA-256, SHA-512) — pure OCaml, WASM-safe.
|
||||
|
||||
No C stubs, no external deps. Used by the fed-sx host primitives
|
||||
[crypto-sha256] / [crypto-sha512]. Reference: FIPS 180-4. *)
|
||||
|
||||
(* ---- SHA-256 (FIPS 180-4 §6.2). 32-bit words held in native int,
|
||||
masked to 32 bits after every arithmetic op. ---- *)
|
||||
|
||||
let mask32 = 0xFFFFFFFF
|
||||
|
||||
let k256 = [|
|
||||
0x428a2f98; 0x71374491; 0xb5c0fbcf; 0xe9b5dba5;
|
||||
0x3956c25b; 0x59f111f1; 0x923f82a4; 0xab1c5ed5;
|
||||
0xd807aa98; 0x12835b01; 0x243185be; 0x550c7dc3;
|
||||
0x72be5d74; 0x80deb1fe; 0x9bdc06a7; 0xc19bf174;
|
||||
0xe49b69c1; 0xefbe4786; 0x0fc19dc6; 0x240ca1cc;
|
||||
0x2de92c6f; 0x4a7484aa; 0x5cb0a9dc; 0x76f988da;
|
||||
0x983e5152; 0xa831c66d; 0xb00327c8; 0xbf597fc7;
|
||||
0xc6e00bf3; 0xd5a79147; 0x06ca6351; 0x14292967;
|
||||
0x27b70a85; 0x2e1b2138; 0x4d2c6dfc; 0x53380d13;
|
||||
0x650a7354; 0x766a0abb; 0x81c2c92e; 0x92722c85;
|
||||
0xa2bfe8a1; 0xa81a664b; 0xc24b8b70; 0xc76c51a3;
|
||||
0xd192e819; 0xd6990624; 0xf40e3585; 0x106aa070;
|
||||
0x19a4c116; 0x1e376c08; 0x2748774c; 0x34b0bcb5;
|
||||
0x391c0cb3; 0x4ed8aa4a; 0x5b9cca4f; 0x682e6ff3;
|
||||
0x748f82ee; 0x78a5636f; 0x84c87814; 0x8cc70208;
|
||||
0x90befffa; 0xa4506ceb; 0xbef9a3f7; 0xc67178f2 |]
|
||||
|
||||
let rotr32 x n = ((x lsr n) lor (x lsl (32 - n))) land mask32
|
||||
|
||||
let sha256_hex (msg : string) : string =
|
||||
let h = [| 0x6a09e667; 0xbb67ae85; 0x3c6ef372; 0xa54ff53a;
|
||||
0x510e527f; 0x9b05688c; 0x1f83d9ab; 0x5be0cd19 |] in
|
||||
let len = String.length msg in
|
||||
(* Padded length: multiple of 64 bytes. *)
|
||||
let bitlen = len * 8 in
|
||||
let padlen =
|
||||
let r = (len + 1) mod 64 in
|
||||
if r <= 56 then 56 - r else 120 - r
|
||||
in
|
||||
let total = len + 1 + padlen + 8 in
|
||||
let buf = Bytes.make total '\000' in
|
||||
Bytes.blit_string msg 0 buf 0 len;
|
||||
Bytes.set buf len '\x80';
|
||||
(* 64-bit big-endian bit length (we cap at OCaml int range). *)
|
||||
for i = 0 to 7 do
|
||||
Bytes.set buf (total - 1 - i)
|
||||
(Char.chr ((bitlen lsr (8 * i)) land 0xFF))
|
||||
done;
|
||||
let w = Array.make 64 0 in
|
||||
let nblocks = total / 64 in
|
||||
for b = 0 to nblocks - 1 do
|
||||
let base = b * 64 in
|
||||
for t = 0 to 15 do
|
||||
let o = base + t * 4 in
|
||||
w.(t) <-
|
||||
(Char.code (Bytes.get buf o) lsl 24)
|
||||
lor (Char.code (Bytes.get buf (o + 1)) lsl 16)
|
||||
lor (Char.code (Bytes.get buf (o + 2)) lsl 8)
|
||||
lor (Char.code (Bytes.get buf (o + 3)))
|
||||
done;
|
||||
for t = 16 to 63 do
|
||||
let s0 =
|
||||
(rotr32 w.(t - 15) 7) lxor (rotr32 w.(t - 15) 18)
|
||||
lxor (w.(t - 15) lsr 3) in
|
||||
let s1 =
|
||||
(rotr32 w.(t - 2) 17) lxor (rotr32 w.(t - 2) 19)
|
||||
lxor (w.(t - 2) lsr 10) in
|
||||
w.(t) <- (w.(t - 16) + s0 + w.(t - 7) + s1) land mask32
|
||||
done;
|
||||
let a = ref h.(0) and bb = ref h.(1) and c = ref h.(2)
|
||||
and d = ref h.(3) and e = ref h.(4) and f = ref h.(5)
|
||||
and g = ref h.(6) and hh = ref h.(7) in
|
||||
for t = 0 to 63 do
|
||||
let s1 =
|
||||
(rotr32 !e 6) lxor (rotr32 !e 11) lxor (rotr32 !e 25) in
|
||||
let ch = (!e land !f) lxor ((lnot !e land mask32) land !g) in
|
||||
let t1 = (!hh + s1 + ch + k256.(t) + w.(t)) land mask32 in
|
||||
let s0 =
|
||||
(rotr32 !a 2) lxor (rotr32 !a 13) lxor (rotr32 !a 22) in
|
||||
let maj = (!a land !bb) lxor (!a land !c) lxor (!bb land !c) in
|
||||
let t2 = (s0 + maj) land mask32 in
|
||||
hh := !g; g := !f; f := !e;
|
||||
e := (!d + t1) land mask32;
|
||||
d := !c; c := !bb; bb := !a;
|
||||
a := (t1 + t2) land mask32
|
||||
done;
|
||||
h.(0) <- (h.(0) + !a) land mask32;
|
||||
h.(1) <- (h.(1) + !bb) land mask32;
|
||||
h.(2) <- (h.(2) + !c) land mask32;
|
||||
h.(3) <- (h.(3) + !d) land mask32;
|
||||
h.(4) <- (h.(4) + !e) land mask32;
|
||||
h.(5) <- (h.(5) + !f) land mask32;
|
||||
h.(6) <- (h.(6) + !g) land mask32;
|
||||
h.(7) <- (h.(7) + !hh) land mask32
|
||||
done;
|
||||
let out = Buffer.create 64 in
|
||||
Array.iter (fun x -> Buffer.add_string out (Printf.sprintf "%08x" x)) h;
|
||||
Buffer.contents out
|
||||
|
||||
(* ---- SHA-512 (FIPS 180-4 §6.4). 64-bit words via Int64.
|
||||
128-bit length append; we only support messages whose bit length
|
||||
fits in 64 bits (high word is always zero). ---- *)
|
||||
|
||||
let k512 = [|
|
||||
0x428a2f98d728ae22L; 0x7137449123ef65cdL; 0xb5c0fbcfec4d3b2fL;
|
||||
0xe9b5dba58189dbbcL; 0x3956c25bf348b538L; 0x59f111f1b605d019L;
|
||||
0x923f82a4af194f9bL; 0xab1c5ed5da6d8118L; 0xd807aa98a3030242L;
|
||||
0x12835b0145706fbeL; 0x243185be4ee4b28cL; 0x550c7dc3d5ffb4e2L;
|
||||
0x72be5d74f27b896fL; 0x80deb1fe3b1696b1L; 0x9bdc06a725c71235L;
|
||||
0xc19bf174cf692694L; 0xe49b69c19ef14ad2L; 0xefbe4786384f25e3L;
|
||||
0x0fc19dc68b8cd5b5L; 0x240ca1cc77ac9c65L; 0x2de92c6f592b0275L;
|
||||
0x4a7484aa6ea6e483L; 0x5cb0a9dcbd41fbd4L; 0x76f988da831153b5L;
|
||||
0x983e5152ee66dfabL; 0xa831c66d2db43210L; 0xb00327c898fb213fL;
|
||||
0xbf597fc7beef0ee4L; 0xc6e00bf33da88fc2L; 0xd5a79147930aa725L;
|
||||
0x06ca6351e003826fL; 0x142929670a0e6e70L; 0x27b70a8546d22ffcL;
|
||||
0x2e1b21385c26c926L; 0x4d2c6dfc5ac42aedL; 0x53380d139d95b3dfL;
|
||||
0x650a73548baf63deL; 0x766a0abb3c77b2a8L; 0x81c2c92e47edaee6L;
|
||||
0x92722c851482353bL; 0xa2bfe8a14cf10364L; 0xa81a664bbc423001L;
|
||||
0xc24b8b70d0f89791L; 0xc76c51a30654be30L; 0xd192e819d6ef5218L;
|
||||
0xd69906245565a910L; 0xf40e35855771202aL; 0x106aa07032bbd1b8L;
|
||||
0x19a4c116b8d2d0c8L; 0x1e376c085141ab53L; 0x2748774cdf8eeb99L;
|
||||
0x34b0bcb5e19b48a8L; 0x391c0cb3c5c95a63L; 0x4ed8aa4ae3418acbL;
|
||||
0x5b9cca4f7763e373L; 0x682e6ff3d6b2b8a3L; 0x748f82ee5defb2fcL;
|
||||
0x78a5636f43172f60L; 0x84c87814a1f0ab72L; 0x8cc702081a6439ecL;
|
||||
0x90befffa23631e28L; 0xa4506cebde82bde9L; 0xbef9a3f7b2c67915L;
|
||||
0xc67178f2e372532bL; 0xca273eceea26619cL; 0xd186b8c721c0c207L;
|
||||
0xeada7dd6cde0eb1eL; 0xf57d4f7fee6ed178L; 0x06f067aa72176fbaL;
|
||||
0x0a637dc5a2c898a6L; 0x113f9804bef90daeL; 0x1b710b35131c471bL;
|
||||
0x28db77f523047d84L; 0x32caab7b40c72493L; 0x3c9ebe0a15c9bebcL;
|
||||
0x431d67c49c100d4cL; 0x4cc5d4becb3e42b6L; 0x597f299cfc657e2aL;
|
||||
0x5fcb6fab3ad6faecL; 0x6c44198c4a475817L |]
|
||||
|
||||
let ( &: ) = Int64.logand
|
||||
let ( |: ) = Int64.logor
|
||||
let ( ^: ) = Int64.logxor
|
||||
let ( +: ) = Int64.add
|
||||
let lnot64 = Int64.lognot
|
||||
|
||||
let rotr64 x n =
|
||||
(Int64.shift_right_logical x n) |: (Int64.shift_left x (64 - n))
|
||||
|
||||
let sha512_hex (msg : string) : string =
|
||||
let h = [| 0x6a09e667f3bcc908L; 0xbb67ae8584caa73bL;
|
||||
0x3c6ef372fe94f82bL; 0xa54ff53a5f1d36f1L;
|
||||
0x510e527fade682d1L; 0x9b05688c2b3e6c1fL;
|
||||
0x1f83d9abfb41bd6bL; 0x5be0cd19137e2179L |] in
|
||||
let len = String.length msg in
|
||||
let bitlen = len * 8 in
|
||||
(* Pad to a multiple of 128 bytes; 16-byte big-endian length. *)
|
||||
let padlen =
|
||||
let r = (len + 1) mod 128 in
|
||||
if r <= 112 then 112 - r else 240 - r
|
||||
in
|
||||
let total = len + 1 + padlen + 16 in
|
||||
let buf = Bytes.make total '\000' in
|
||||
Bytes.blit_string msg 0 buf 0 len;
|
||||
Bytes.set buf len '\x80';
|
||||
for i = 0 to 7 do
|
||||
Bytes.set buf (total - 1 - i)
|
||||
(Char.chr ((bitlen lsr (8 * i)) land 0xFF))
|
||||
done;
|
||||
let w = Array.make 80 0L in
|
||||
let nblocks = total / 128 in
|
||||
for b = 0 to nblocks - 1 do
|
||||
let base = b * 128 in
|
||||
for t = 0 to 15 do
|
||||
let o = base + t * 8 in
|
||||
let v = ref 0L in
|
||||
for j = 0 to 7 do
|
||||
v := Int64.logor (Int64.shift_left !v 8)
|
||||
(Int64.of_int (Char.code (Bytes.get buf (o + j))))
|
||||
done;
|
||||
w.(t) <- !v
|
||||
done;
|
||||
for t = 16 to 79 do
|
||||
let s0 =
|
||||
(rotr64 w.(t - 15) 1) ^: (rotr64 w.(t - 15) 8)
|
||||
^: (Int64.shift_right_logical w.(t - 15) 7) in
|
||||
let s1 =
|
||||
(rotr64 w.(t - 2) 19) ^: (rotr64 w.(t - 2) 61)
|
||||
^: (Int64.shift_right_logical w.(t - 2) 6) in
|
||||
w.(t) <- w.(t - 16) +: s0 +: w.(t - 7) +: s1
|
||||
done;
|
||||
let a = ref h.(0) and bb = ref h.(1) and c = ref h.(2)
|
||||
and d = ref h.(3) and e = ref h.(4) and f = ref h.(5)
|
||||
and g = ref h.(6) and hh = ref h.(7) in
|
||||
for t = 0 to 79 do
|
||||
let s1 = (rotr64 !e 14) ^: (rotr64 !e 18) ^: (rotr64 !e 41) in
|
||||
let ch = (!e &: !f) ^: ((lnot64 !e) &: !g) in
|
||||
let t1 = !hh +: s1 +: ch +: k512.(t) +: w.(t) in
|
||||
let s0 = (rotr64 !a 28) ^: (rotr64 !a 34) ^: (rotr64 !a 39) in
|
||||
let maj = (!a &: !bb) ^: (!a &: !c) ^: (!bb &: !c) in
|
||||
let t2 = s0 +: maj in
|
||||
hh := !g; g := !f; f := !e;
|
||||
e := !d +: t1;
|
||||
d := !c; c := !bb; bb := !a;
|
||||
a := t1 +: t2
|
||||
done;
|
||||
h.(0) <- h.(0) +: !a;
|
||||
h.(1) <- h.(1) +: !bb;
|
||||
h.(2) <- h.(2) +: !c;
|
||||
h.(3) <- h.(3) +: !d;
|
||||
h.(4) <- h.(4) +: !e;
|
||||
h.(5) <- h.(5) +: !f;
|
||||
h.(6) <- h.(6) +: !g;
|
||||
h.(7) <- h.(7) +: !hh
|
||||
done;
|
||||
let out = Buffer.create 128 in
|
||||
Array.iter
|
||||
(fun x -> Buffer.add_string out (Printf.sprintf "%016Lx" x)) h;
|
||||
Buffer.contents out
|
||||
107
hosts/ocaml/lib/sx_sha3.ml
Normal file
107
hosts/ocaml/lib/sx_sha3.ml
Normal file
@@ -0,0 +1,107 @@
|
||||
(** SHA-3 (SHA3-256) — pure OCaml, WASM-safe.
|
||||
|
||||
Keccak-f[1600] permutation + SHA-3 multi-rate padding (domain byte
|
||||
0x06, NOT the legacy Keccak 0x01). Reference: FIPS 202. No deps. *)
|
||||
|
||||
let ( ^: ) = Int64.logxor
|
||||
let ( &: ) = Int64.logand
|
||||
let lnot64 = Int64.lognot
|
||||
|
||||
let rotl64 x n =
|
||||
if n = 0 then x
|
||||
else
|
||||
Int64.logor (Int64.shift_left x n) (Int64.shift_right_logical x (64 - n))
|
||||
|
||||
(* FIPS 202 Table 2 — ρ rotation offsets, indexed lane = x + 5*y. *)
|
||||
let rho = [|
|
||||
0; 1; 62; 28; 27;
|
||||
36; 44; 6; 55; 20;
|
||||
3; 10; 43; 25; 39;
|
||||
41; 45; 15; 21; 8;
|
||||
18; 2; 61; 56; 14 |]
|
||||
|
||||
(* FIPS 202 §3.2.5 — round constants RC[0..23] for ι. *)
|
||||
let rc = [|
|
||||
0x0000000000000001L; 0x0000000000008082L; 0x800000000000808aL;
|
||||
0x8000000080008000L; 0x000000000000808bL; 0x0000000080000001L;
|
||||
0x8000000080008081L; 0x8000000000008009L; 0x000000000000008aL;
|
||||
0x0000000000000088L; 0x0000000080008009L; 0x000000008000000aL;
|
||||
0x000000008000808bL; 0x800000000000008bL; 0x8000000000008089L;
|
||||
0x8000000000008003L; 0x8000000000008002L; 0x8000000000000080L;
|
||||
0x000000000000800aL; 0x800000008000000aL; 0x8000000080008081L;
|
||||
0x8000000000008080L; 0x0000000080000001L; 0x8000000080008008L |]
|
||||
|
||||
let keccak_f (a : int64 array) : unit =
|
||||
let c = Array.make 5 0L and d = Array.make 5 0L in
|
||||
let b = Array.make 25 0L in
|
||||
for round = 0 to 23 do
|
||||
(* θ *)
|
||||
for x = 0 to 4 do
|
||||
c.(x) <- a.(x) ^: a.(x + 5) ^: a.(x + 10)
|
||||
^: a.(x + 15) ^: a.(x + 20)
|
||||
done;
|
||||
for x = 0 to 4 do
|
||||
d.(x) <- c.((x + 4) mod 5) ^: (rotl64 c.((x + 1) mod 5) 1)
|
||||
done;
|
||||
for x = 0 to 4 do
|
||||
for y = 0 to 4 do
|
||||
a.(x + 5 * y) <- a.(x + 5 * y) ^: d.(x)
|
||||
done
|
||||
done;
|
||||
(* ρ and π: B[y, 2x+3y] = rotl(A[x,y], rho[x,y]) *)
|
||||
for x = 0 to 4 do
|
||||
for y = 0 to 4 do
|
||||
let nx = y and ny = (2 * x + 3 * y) mod 5 in
|
||||
b.(nx + 5 * ny) <- rotl64 a.(x + 5 * y) rho.(x + 5 * y)
|
||||
done
|
||||
done;
|
||||
(* χ *)
|
||||
for y = 0 to 4 do
|
||||
for x = 0 to 4 do
|
||||
a.(x + 5 * y) <-
|
||||
b.(x + 5 * y)
|
||||
^: ((lnot64 b.((x + 1) mod 5 + 5 * y))
|
||||
&: b.((x + 2) mod 5 + 5 * y))
|
||||
done
|
||||
done;
|
||||
(* ι *)
|
||||
a.(0) <- a.(0) ^: rc.(round)
|
||||
done
|
||||
|
||||
let sha3_256_hex (msg : string) : string =
|
||||
let rate = 136 (* bytes: (1600 - 2*256) / 8 *) in
|
||||
let len = String.length msg in
|
||||
(* pad10*1 with SHA-3 domain byte 0x06; last byte ORed with 0x80. *)
|
||||
let q = rate - (len mod rate) in
|
||||
let padded = Bytes.make (len + q) '\000' in
|
||||
Bytes.blit_string msg 0 padded 0 len;
|
||||
if q = 1 then
|
||||
Bytes.set padded len '\x86'
|
||||
else begin
|
||||
Bytes.set padded len '\x06';
|
||||
Bytes.set padded (len + q - 1) '\x80'
|
||||
end;
|
||||
let total = Bytes.length padded in
|
||||
let a = Array.make 25 0L in
|
||||
let nblocks = total / rate in
|
||||
for blk = 0 to nblocks - 1 do
|
||||
let base = blk * rate in
|
||||
(* Absorb: XOR rate bytes into the state, little-endian lanes. *)
|
||||
for j = 0 to rate - 1 do
|
||||
let lane = j / 8 and sh = (j mod 8) * 8 in
|
||||
let byte = Int64.of_int (Char.code (Bytes.get padded (base + j))) in
|
||||
a.(lane) <- a.(lane) ^: (Int64.shift_left byte sh)
|
||||
done;
|
||||
keccak_f a
|
||||
done;
|
||||
(* Squeeze 32 bytes (fits in the first 4 lanes; rate > 32). *)
|
||||
let out = Buffer.create 64 in
|
||||
for j = 0 to 31 do
|
||||
let lane = j / 8 and sh = (j mod 8) * 8 in
|
||||
let byte =
|
||||
Int64.to_int
|
||||
(Int64.logand (Int64.shift_right_logical a.(lane) sh) 0xFFL)
|
||||
in
|
||||
Buffer.add_string out (Printf.sprintf "%02x" byte)
|
||||
done;
|
||||
Buffer.contents out
|
||||
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)))))
|
||||
@@ -16,5 +16,5 @@
|
||||
{"name":"magic","passed":37,"failed":0,"total":37},
|
||||
{"name":"demo","passed":21,"failed":0,"total":21}
|
||||
],
|
||||
"generated": "2026-05-11T09:40:12+00:00"
|
||||
"generated": "2026-05-14T20:30:05+00:00"
|
||||
}
|
||||
|
||||
@@ -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/go/conformance.sh
Executable file
141
lib/go/conformance.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Go-on-SX conformance runner.
|
||||
#
|
||||
# Loads every Go-on-SX test suite via the epoch protocol, collects
|
||||
# pass/fail counts, and writes lib/go/scoreboard.json + .md.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/go/conformance.sh # run all suites
|
||||
# bash lib/go/conformance.sh -v # verbose per-suite
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
TMPFILE=$(mktemp)
|
||||
OUTFILE=$(mktemp)
|
||||
trap "rm -f $TMPFILE $OUTFILE" EXIT
|
||||
|
||||
# Each suite: name | pass-counter | total-counter
|
||||
SUITES=(
|
||||
"lex|go-test-pass|go-test-count"
|
||||
"parse|go-parse-test-pass|go-parse-test-count"
|
||||
"types|go-types-test-pass|go-types-test-count"
|
||||
"eval|go-eval-test-pass|go-eval-test-count"
|
||||
"runtime|go-rt-test-pass|go-rt-test-count"
|
||||
"stdlib|go-std-test-pass|go-std-test-count"
|
||||
"e2e|go-e2e-test-pass|go-e2e-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" <<'EPOCHS'
|
||||
(epoch 1)
|
||||
(load "lib/guest/lex.sx")
|
||||
(load "lib/guest/ast.sx")
|
||||
(load "lib/guest/pratt.sx")
|
||||
(load "lib/go/lex.sx")
|
||||
(load "lib/go/parse.sx")
|
||||
(load "lib/go/types.sx")
|
||||
(load "lib/go/sched.sx")
|
||||
(load "lib/go/eval.sx")
|
||||
(load "lib/go/std/strings.sx")
|
||||
(load "lib/go/std/strconv.sx")
|
||||
(load "lib/go/tests/lex.sx")
|
||||
(load "lib/go/tests/parse.sx")
|
||||
(load "lib/go/tests/types.sx")
|
||||
(load "lib/go/tests/eval.sx")
|
||||
(load "lib/go/tests/runtime.sx")
|
||||
(load "lib/go/tests/stdlib.sx")
|
||||
(load "lib/go/tests/e2e.sx")
|
||||
EPOCHS
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
pass_var=$(echo "$entry" | awk -F'|' '{print $2}')
|
||||
total_var=$(echo "$entry" | awk -F'|' '{print $3}')
|
||||
epoch=$((100 + idx))
|
||||
echo "(epoch $epoch)" >> "$TMPFILE"
|
||||
echo "(eval \"(list $pass_var $total_var)\")" >> "$TMPFILE"
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
"$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
parse_pair() {
|
||||
local epoch="$1"
|
||||
local line
|
||||
line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1)
|
||||
echo "$line" | sed -E 's/[()]//g'
|
||||
}
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_COUNT=0
|
||||
JSON_SUITES=""
|
||||
MD_ROWS=""
|
||||
|
||||
idx=0
|
||||
for entry in "${SUITES[@]}"; do
|
||||
name="${entry%%|*}"
|
||||
epoch=$((100 + idx))
|
||||
pair=$(parse_pair "$epoch")
|
||||
pass=$(echo "$pair" | awk '{print $1}')
|
||||
count=$(echo "$pair" | awk '{print $2}')
|
||||
if [ -z "$pass" ] || [ -z "$count" ]; then
|
||||
pass=0
|
||||
count=0
|
||||
fi
|
||||
TOTAL_PASS=$((TOTAL_PASS + pass))
|
||||
TOTAL_COUNT=$((TOTAL_COUNT + count))
|
||||
status="ok"
|
||||
marker="✅"
|
||||
if [ "$pass" != "$count" ]; then
|
||||
status="fail"
|
||||
marker="❌"
|
||||
fi
|
||||
if [ "$VERBOSE" = "-v" ]; then
|
||||
printf " %-12s %s/%s\n" "$name" "$pass" "$count"
|
||||
fi
|
||||
if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi
|
||||
JSON_SUITES+=$'\n '
|
||||
JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}"
|
||||
MD_ROWS+="| $marker | $name | $pass | $count |"$'\n'
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
|
||||
printf '\nGo-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT"
|
||||
|
||||
cat > lib/go/scoreboard.json <<JSON
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": $TOTAL_PASS,
|
||||
"total": $TOTAL_COUNT,
|
||||
"suites": [$JSON_SUITES]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > lib/go/scoreboard.md <<MD
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
$MD_ROWS
|
||||
|
||||
Generated by \`lib/go/conformance.sh\`.
|
||||
MD
|
||||
|
||||
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
1539
lib/go/eval.sx
Normal file
1539
lib/go/eval.sx
Normal file
File diff suppressed because it is too large
Load Diff
476
lib/go/lex.sx
Normal file
476
lib/go/lex.sx
Normal file
@@ -0,0 +1,476 @@
|
||||
;; lib/go/lex.sx — Go tokenizer with automatic semicolon insertion.
|
||||
;;
|
||||
;; Consumes lib/guest/lex.sx character-class predicates.
|
||||
;;
|
||||
;; Tokens: {:type T :value V :pos P}
|
||||
;; Types:
|
||||
;; "ident" — identifiers (foo, _bar, mixedCase)
|
||||
;; "keyword" — one of the 25 Go keywords
|
||||
;; "int" — integer literals (decimal, 0x.. hex, 0b.. binary, 0o.. octal,
|
||||
;; legacy 0123 octal; underscores between digits allowed)
|
||||
;; "float" — decimal float literals (3.14, .5, 1., 1e10, 1.5e-3, 1E5)
|
||||
;; "imag" — imaginary literals (2i, 3.14i, 1e2i)
|
||||
;; "string" — interpreted string literals "..." OR raw string literals `...`
|
||||
;; "rune" — rune literals 'x' (single char + simple escapes)
|
||||
;; "op" — operators & punctuation; :value is the literal text
|
||||
;; "semi" — explicit ';' or auto-inserted (Go spec § Semicolons)
|
||||
;; "eof" — end-of-input sentinel
|
||||
;;
|
||||
;; ASI (Go spec § Semicolons): a newline (or EOF, or a block comment
|
||||
;; containing a newline) emits a ";semi" if the previous emitted token's
|
||||
;; type is ident/int/float/imag/string/rune, or its value is one of
|
||||
;; {break, continue, fallthrough, return, ++, --, ), ], }}.
|
||||
;;
|
||||
;; All scanner locals are gl- prefixed: SX host primitives (peek/emit/etc.)
|
||||
;; silently shadow guest-language defines. See feedback_sx_bind_clash.
|
||||
|
||||
(define
|
||||
go-keywords
|
||||
(list
|
||||
"break"
|
||||
"case"
|
||||
"chan"
|
||||
"const"
|
||||
"continue"
|
||||
"default"
|
||||
"defer"
|
||||
"else"
|
||||
"fallthrough"
|
||||
"for"
|
||||
"func"
|
||||
"go"
|
||||
"goto"
|
||||
"if"
|
||||
"import"
|
||||
"interface"
|
||||
"map"
|
||||
"package"
|
||||
"range"
|
||||
"return"
|
||||
"select"
|
||||
"struct"
|
||||
"switch"
|
||||
"type"
|
||||
"var"))
|
||||
|
||||
(define go-keyword? (fn (s) (some (fn (k) (= k s)) go-keywords)))
|
||||
|
||||
(define go-asi-keywords (list "break" "continue" "fallthrough" "return"))
|
||||
|
||||
(define go-asi-ops (list "++" "--" ")" "]" "}"))
|
||||
|
||||
(define go-asi-lit-types (list "ident" "int" "float" "imag" "string" "rune"))
|
||||
|
||||
(define
|
||||
go-asi-trigger?
|
||||
(fn
|
||||
(tok)
|
||||
(if
|
||||
(= tok nil)
|
||||
false
|
||||
(let
|
||||
((ty (get tok :type)) (v (get tok :value)))
|
||||
(or
|
||||
(some (fn (lt) (= lt ty)) go-asi-lit-types)
|
||||
(and (= ty "keyword") (some (fn (k) (= k v)) go-asi-keywords))
|
||||
(and (= ty "op") (some (fn (o) (= o v)) go-asi-ops)))))))
|
||||
|
||||
(define
|
||||
go-tokenize
|
||||
(fn
|
||||
(src)
|
||||
(let
|
||||
((tokens (list)) (pos 0) (src-len (len src)))
|
||||
(define
|
||||
gl-peek
|
||||
(fn
|
||||
(offset)
|
||||
(if (< (+ pos offset) src-len) (nth src (+ pos offset)) nil)))
|
||||
(define gl-cur (fn () (gl-peek 0)))
|
||||
(define gl-advance! (fn (n) (set! pos (+ pos n))))
|
||||
(define
|
||||
gl-last
|
||||
(fn
|
||||
()
|
||||
(if
|
||||
(= (len tokens) 0)
|
||||
nil
|
||||
(nth tokens (- (len tokens) 1)))))
|
||||
(define gl-emit! (fn (type value start) (append! tokens {:type type :value value :pos start})))
|
||||
(define
|
||||
gl-maybe-asi!
|
||||
(fn
|
||||
(at)
|
||||
(when (go-asi-trigger? (gl-last)) (gl-emit! "semi" "\n" at))))
|
||||
(define
|
||||
gl-oct-digit?
|
||||
(fn (c) (and (not (= c nil)) (>= c "0") (<= c "7"))))
|
||||
(define gl-bin-digit? (fn (c) (or (= c "0") (= c "1"))))
|
||||
(define
|
||||
gl-skip-line!
|
||||
(fn
|
||||
()
|
||||
(when
|
||||
(and (< pos src-len) (not (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-line!))))
|
||||
(define
|
||||
gl-skip-block!
|
||||
(fn
|
||||
(saw-nl)
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
saw-nl
|
||||
(and (= (gl-cur) "*") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) saw-nl)
|
||||
:else (let
|
||||
((is-nl (= (gl-cur) "\n")))
|
||||
(gl-advance! 1)
|
||||
(gl-skip-block! (or saw-nl is-nl))))))
|
||||
(define
|
||||
gl-read-ident!
|
||||
(fn
|
||||
(start)
|
||||
(when
|
||||
(and (< pos src-len) (lex-ident-char? (gl-cur)))
|
||||
(gl-advance! 1)
|
||||
(gl-read-ident! start))
|
||||
(slice src start pos)))
|
||||
(define
|
||||
gl-read-digit-run!
|
||||
(fn
|
||||
(digit?)
|
||||
(when
|
||||
(and (< pos src-len) (or (digit? (gl-cur)) (= (gl-cur) "_")))
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! digit?))))
|
||||
(define
|
||||
gl-finish-number!
|
||||
(fn
|
||||
(has-fraction?)
|
||||
(let
|
||||
((typ (if has-fraction? "float" "int")))
|
||||
(when
|
||||
(or (= (gl-cur) "e") (= (gl-cur) "E"))
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(or (= (gl-cur) "+") (= (gl-cur) "-"))
|
||||
(gl-advance! 1))
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(set! typ "float"))
|
||||
(cond
|
||||
(= (gl-cur) "i")
|
||||
(do (gl-advance! 1) "imag")
|
||||
:else typ))))
|
||||
(define
|
||||
gl-read-number!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "x")
|
||||
(= (gl-peek 1) "X")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! lex-hex-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "b")
|
||||
(= (gl-peek 1) "B")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-bin-digit?)
|
||||
"int")
|
||||
(and
|
||||
(= (gl-cur) "0")
|
||||
(or
|
||||
(= (gl-peek 1) "o")
|
||||
(= (gl-peek 1) "O")))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(gl-read-digit-run! gl-oct-digit?)
|
||||
"int")
|
||||
:else (do
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(cond
|
||||
(and (= (gl-cur) ".") (not (= (gl-peek 1) ".")))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(gl-read-digit-run! lex-digit?)
|
||||
(gl-finish-number! true))
|
||||
:else (gl-finish-number! false))))))
|
||||
(define
|
||||
gl-read-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-string-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\"")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\\")
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1)))
|
||||
(gl-string-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-string-loop)))))
|
||||
(gl-string-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-raw-string!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(define
|
||||
gl-raw-loop
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "`")
|
||||
(gl-advance! 1)
|
||||
(= (gl-cur) "\r")
|
||||
(do (gl-advance! 1) (gl-raw-loop))
|
||||
:else (do
|
||||
(append! chars (gl-cur))
|
||||
(gl-advance! 1)
|
||||
(gl-raw-loop)))))
|
||||
(gl-raw-loop)
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-read-rune!
|
||||
(fn
|
||||
()
|
||||
(gl-advance! 1)
|
||||
(let
|
||||
((chars (list)))
|
||||
(cond
|
||||
(and (< pos src-len) (= (gl-cur) "\\"))
|
||||
(do
|
||||
(gl-advance! 1)
|
||||
(when
|
||||
(< pos src-len)
|
||||
(let
|
||||
((ch (gl-cur)))
|
||||
(cond
|
||||
(= ch "n")
|
||||
(append! chars "\n")
|
||||
(= ch "t")
|
||||
(append! chars "\t")
|
||||
(= ch "r")
|
||||
(append! chars "\r")
|
||||
(= ch "\\")
|
||||
(append! chars "\\")
|
||||
(= ch "'")
|
||||
(append! chars "'")
|
||||
(= ch "\"")
|
||||
(append! chars "\"")
|
||||
:else (append! chars ch))
|
||||
(gl-advance! 1))))
|
||||
(< pos src-len)
|
||||
(do (append! chars (gl-cur)) (gl-advance! 1)))
|
||||
(when
|
||||
(and (< pos src-len) (= (gl-cur) "'"))
|
||||
(gl-advance! 1))
|
||||
(join "" chars))))
|
||||
(define
|
||||
gl-match-op
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((c0 (gl-cur))
|
||||
(c1 (gl-peek 1))
|
||||
(c2 (gl-peek 2)))
|
||||
(cond
|
||||
(and (= c0 "<") (= c1 "<") (= c2 "="))
|
||||
"<<="
|
||||
(and (= c0 ">") (= c1 ">") (= c2 "="))
|
||||
">>="
|
||||
(and (= c0 "&") (= c1 "^") (= c2 "="))
|
||||
"&^="
|
||||
(and (= c0 ".") (= c1 ".") (= c2 "."))
|
||||
"..."
|
||||
(and (= c0 "=") (= c1 "="))
|
||||
"=="
|
||||
(and (= c0 "!") (= c1 "="))
|
||||
"!="
|
||||
(and (= c0 "<") (= c1 "="))
|
||||
"<="
|
||||
(and (= c0 ">") (= c1 "="))
|
||||
">="
|
||||
(and (= c0 "&") (= c1 "&"))
|
||||
"&&"
|
||||
(and (= c0 "|") (= c1 "|"))
|
||||
"||"
|
||||
(and (= c0 "+") (= c1 "+"))
|
||||
"++"
|
||||
(and (= c0 "-") (= c1 "-"))
|
||||
"--"
|
||||
(and (= c0 "<") (= c1 "<"))
|
||||
"<<"
|
||||
(and (= c0 ">") (= c1 ">"))
|
||||
">>"
|
||||
(and (= c0 "+") (= c1 "="))
|
||||
"+="
|
||||
(and (= c0 "-") (= c1 "="))
|
||||
"-="
|
||||
(and (= c0 "*") (= c1 "="))
|
||||
"*="
|
||||
(and (= c0 "/") (= c1 "="))
|
||||
"/="
|
||||
(and (= c0 "%") (= c1 "="))
|
||||
"%="
|
||||
(and (= c0 "&") (= c1 "="))
|
||||
"&="
|
||||
(and (= c0 "|") (= c1 "="))
|
||||
"|="
|
||||
(and (= c0 "^") (= c1 "="))
|
||||
"^="
|
||||
(and (= c0 ":") (= c1 "="))
|
||||
":="
|
||||
(and (= c0 "<") (= c1 "-"))
|
||||
"<-"
|
||||
(and (= c0 "&") (= c1 "^"))
|
||||
"&^"
|
||||
(or
|
||||
(= c0 "+")
|
||||
(= c0 "-")
|
||||
(= c0 "*")
|
||||
(= c0 "/")
|
||||
(= c0 "%")
|
||||
(= c0 "&")
|
||||
(= c0 "|")
|
||||
(= c0 "^")
|
||||
(= c0 "<")
|
||||
(= c0 ">")
|
||||
(= c0 "=")
|
||||
(= c0 "!")
|
||||
(= c0 "(")
|
||||
(= c0 ")")
|
||||
(= c0 "{")
|
||||
(= c0 "}")
|
||||
(= c0 "[")
|
||||
(= c0 "]")
|
||||
(= c0 ",")
|
||||
(= c0 ".")
|
||||
(= c0 ":")
|
||||
(= c0 "~"))
|
||||
c0
|
||||
:else nil))))
|
||||
(define
|
||||
gl-scan!
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(>= pos src-len)
|
||||
nil
|
||||
(= (gl-cur) "\n")
|
||||
(do (gl-maybe-asi! pos) (gl-advance! 1) (gl-scan!))
|
||||
(lex-space? (gl-cur))
|
||||
(do (gl-advance! 1) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "/"))
|
||||
(do (gl-advance! 2) (gl-skip-line!) (gl-scan!))
|
||||
(and (= (gl-cur) "/") (= (gl-peek 1) "*"))
|
||||
(do
|
||||
(gl-advance! 2)
|
||||
(let
|
||||
((saw-nl (gl-skip-block! false)))
|
||||
(when saw-nl (gl-maybe-asi! pos)))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) ";")
|
||||
(do
|
||||
(gl-emit! "semi" ";" pos)
|
||||
(gl-advance! 1)
|
||||
(gl-scan!))
|
||||
(lex-ident-start? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos))
|
||||
(gl-read-ident! start)
|
||||
(let
|
||||
((word (slice src start pos)))
|
||||
(gl-emit!
|
||||
(if (go-keyword? word) "keyword" "ident")
|
||||
word
|
||||
start)))
|
||||
(gl-scan!))
|
||||
(lex-digit? (gl-cur))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(and (= (gl-cur) ".") (lex-digit? (gl-peek 1)))
|
||||
(do
|
||||
(let
|
||||
((start pos) (typ (gl-read-number!)))
|
||||
(gl-emit! typ (slice src start pos) start))
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "\"")
|
||||
(let
|
||||
((start pos) (v (gl-read-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "`")
|
||||
(let
|
||||
((start pos) (v (gl-read-raw-string!)))
|
||||
(gl-emit! "string" v start)
|
||||
(gl-scan!))
|
||||
(= (gl-cur) "'")
|
||||
(let
|
||||
((start pos) (v (gl-read-rune!)))
|
||||
(gl-emit! "rune" v start)
|
||||
(gl-scan!))
|
||||
:else (let
|
||||
((op (gl-match-op)))
|
||||
(cond
|
||||
op
|
||||
(do
|
||||
(gl-emit! "op" op pos)
|
||||
(gl-advance! (len op))
|
||||
(gl-scan!))
|
||||
:else (do (gl-advance! 1) (gl-scan!)))))))
|
||||
(gl-scan!)
|
||||
(gl-maybe-asi! pos)
|
||||
(gl-emit! "eof" nil pos)
|
||||
tokens)))
|
||||
1262
lib/go/parse.sx
Normal file
1262
lib/go/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
66
lib/go/sched.sx
Normal file
66
lib/go/sched.sx
Normal file
@@ -0,0 +1,66 @@
|
||||
;; lib/go/sched.sx — Go scheduler primitives: channels + goroutines.
|
||||
;;
|
||||
;; This is **the independent implementation** referenced by
|
||||
;; plans/lib-guest-scheduler.md. The shape that emerges here informs
|
||||
;; the eventual sister kit; this file's structures are the Phase 5
|
||||
;; "first-consumer" cut.
|
||||
;;
|
||||
;; v0 concurrency model — IMPORTANT
|
||||
;;
|
||||
;; SX has no first-class continuations exposed to guest code, so we
|
||||
;; can't suspend a goroutine mid-statement. v0 runs `go f()` SYNCHRO-
|
||||
;; NOUSLY (it's an immediate call whose return value is dropped). This
|
||||
;; preserves the right semantics for patterns where the spawned
|
||||
;; goroutine simply pushes to a channel that the main goroutine then
|
||||
;; receives — because the spawned goroutine runs to completion first
|
||||
;; and leaves the value in the channel buffer.
|
||||
;;
|
||||
;; True preemption with blocking sends/recvs is a Phase 5b refinement.
|
||||
;; The sister-plan diary tracks the design insight (single
|
||||
;; sched-spawn primitive, channel-op direction tag) so the eventual
|
||||
;; kit doesn't bake in v0's synchronous limitation.
|
||||
;;
|
||||
;; Channel representation
|
||||
;;
|
||||
;; (list :go-chan ACCESSORS-FN-LIST)
|
||||
;;
|
||||
;; ACCESSORS-FN-LIST is a list of closures sharing a mutable buffer
|
||||
;; and a closed flag. The closures expose:
|
||||
;; index 1: send-fn — (lambda (val) ...)
|
||||
;; index 2: recv-fn — (lambda () val-or-:empty)
|
||||
;; index 3: closed?-fn — (lambda () bool)
|
||||
;; index 4: close!-fn — (lambda () ...)
|
||||
;;
|
||||
;; Channel identity: distinct calls to go-make-chan produce closures
|
||||
;; with distinct identity — `(= ch1 ch2)` is false for distinct
|
||||
;; channels, matching Go spec § Channel types.
|
||||
|
||||
(define
|
||||
go-make-chan
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((buf (list)) (closed false))
|
||||
(list
|
||||
:go-chan (fn (v) (append! buf v) nil)
|
||||
(fn
|
||||
()
|
||||
(cond
|
||||
(= (len buf) 0)
|
||||
:empty :else
|
||||
(let ((v (first buf))) (set! buf (rest buf)) v)))
|
||||
(fn () closed)
|
||||
(fn () (set! closed true) nil)
|
||||
(fn () (len buf))))))
|
||||
|
||||
(define
|
||||
go-chan?
|
||||
(fn
|
||||
(v)
|
||||
(and (list? v) (not (= (len v) 0)) (= (first v) :go-chan))))
|
||||
|
||||
(define go-chan-send! (fn (ch val) ((nth ch 1) val)))
|
||||
(define go-chan-recv! (fn (ch) ((nth ch 2))))
|
||||
(define go-chan-closed? (fn (ch) ((nth ch 3))))
|
||||
(define go-chan-close! (fn (ch) ((nth ch 4))))
|
||||
(define go-chan-len (fn (ch) ((nth ch 5))))
|
||||
13
lib/go/scoreboard.json
Normal file
13
lib/go/scoreboard.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"language": "go",
|
||||
"total_pass": 609,
|
||||
"total": 609,
|
||||
"suites": [
|
||||
{"name":"lex","pass":129,"total":129,"status":"ok"},
|
||||
{"name":"parse","pass":179,"total":179,"status":"ok"},
|
||||
{"name":"types","pass":102,"total":102,"status":"ok"},
|
||||
{"name":"eval","pass":106,"total":106,"status":"ok"},
|
||||
{"name":"runtime","pass":40,"total":40,"status":"ok"},
|
||||
{"name":"stdlib","pass":41,"total":41,"status":"ok"},
|
||||
{"name":"e2e","pass":12,"total":12,"status":"ok"}]
|
||||
}
|
||||
16
lib/go/scoreboard.md
Normal file
16
lib/go/scoreboard.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Go-on-SX Scoreboard
|
||||
|
||||
**Total: 609 / 609 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
| ✅ | lex | 129 | 129 |
|
||||
| ✅ | parse | 179 | 179 |
|
||||
| ✅ | types | 102 | 102 |
|
||||
| ✅ | eval | 106 | 106 |
|
||||
| ✅ | runtime | 40 | 40 |
|
||||
| ✅ | stdlib | 41 | 41 |
|
||||
| ✅ | e2e | 12 | 12 |
|
||||
|
||||
|
||||
Generated by `lib/go/conformance.sh`.
|
||||
71
lib/go/std/strconv.sx
Normal file
71
lib/go/std/strconv.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/go/std/strconv.sx — Go's `strconv` package, v0 subset.
|
||||
|
||||
(define
|
||||
go-strconv-itoa
|
||||
;; Itoa(n) → string. Real Go returns the decimal representation.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-itoa-arity (len args))
|
||||
:else
|
||||
(let ((n (first args)))
|
||||
(cond
|
||||
(not (number? n)) (list :eval-error :strconv-itoa-not-number n)
|
||||
:else (str n))))))
|
||||
|
||||
(define
|
||||
go-strconv-atoi
|
||||
;; Atoi(s) → (int, error). v0 returns just the int on success or
|
||||
;; an :eval-error on failure (multi-return is a later refinement).
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strconv-atoi-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strconv-atoi-not-string s)
|
||||
(= (len s) 0) (list :eval-error :strconv-atoi-empty)
|
||||
:else (go-strconv-parse-int s 0 (= (nth s 0) "-") 0))))))
|
||||
|
||||
(define
|
||||
go-strconv-parse-int
|
||||
;; Parse a (possibly signed) base-10 integer literal. Stops on the
|
||||
;; first non-digit char and returns the parsed prefix, or :eval-error
|
||||
;; if no digits were consumed.
|
||||
(fn (s start neg acc)
|
||||
(let ((i (cond (= start 0) (cond neg 1 :else 0) :else start)))
|
||||
(cond
|
||||
(>= i (len s))
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(let ((d (go-strconv-digit (nth s i))))
|
||||
(cond
|
||||
(< d 0)
|
||||
(cond
|
||||
(= (cond neg (- i 1) :else i) 0)
|
||||
(list :eval-error :strconv-atoi-no-digits s)
|
||||
:else
|
||||
(cond neg (- 0 acc) :else acc))
|
||||
:else
|
||||
(go-strconv-parse-int s (+ i 1) neg (+ (* acc 10) d))))))))
|
||||
|
||||
(define
|
||||
go-strconv-digit
|
||||
(fn (c)
|
||||
(cond
|
||||
(= c "0") 0 (= c "1") 1 (= c "2") 2 (= c "3") 3
|
||||
(= c "4") 4 (= c "5") 5 (= c "6") 6 (= c "7") 7
|
||||
(= c "8") 8 (= c "9") 9
|
||||
:else -1)))
|
||||
|
||||
(define
|
||||
go-std-strconv
|
||||
(list :go-package "strconv"
|
||||
(list
|
||||
(list "Itoa" (list :go-builtin-fn go-strconv-itoa))
|
||||
(list "Atoi" (list :go-builtin-fn go-strconv-atoi)))))
|
||||
386
lib/go/std/strings.sx
Normal file
386
lib/go/std/strings.sx
Normal file
@@ -0,0 +1,386 @@
|
||||
;; lib/go/std/strings.sx — Go's `strings` package, v0 subset.
|
||||
;;
|
||||
;; Exposed as `go-std-strings`, a (:go-package "strings" ENTRIES) value.
|
||||
;; Register with `(go-env-extend env "strings" go-std-strings)` to make
|
||||
;; `strings.X(...)` call sites work in evaluated Go code.
|
||||
;;
|
||||
;; Each entry is (FIELD-NAME (list :go-fn PARAMS BODY)) — the same
|
||||
;; shape user-defined Go functions get. Bodies are written in SX
|
||||
;; directly via go-builtin closures wrapping host-level string ops
|
||||
;; for speed, OR as parsed Go source for fidelity. v0 uses
|
||||
;; go-builtin wrappers — simpler and fast.
|
||||
|
||||
;; ── helpers: implement go-std-strings entries as builtins ────────
|
||||
|
||||
(define
|
||||
go-strings-contains
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-contains-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else
|
||||
(go-strings-index-of s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-of
|
||||
;; Returns true if SUB appears in S at or after START, else false.
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) true
|
||||
(> (+ start sublen) slen) false
|
||||
(go-strings-match-at s sub start 0) true
|
||||
:else (go-strings-index-of s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-match-at
|
||||
(fn (s sub start k)
|
||||
(cond
|
||||
(>= k (len sub)) true
|
||||
(= (nth s (+ start k)) (nth sub k))
|
||||
(go-strings-match-at s sub start (+ k 1))
|
||||
:else false)))
|
||||
|
||||
(define
|
||||
go-strings-has-prefix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hasprefix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (p (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? p)) (list :eval-error :strings-not-string p)
|
||||
(> (len p) (len s)) false
|
||||
:else (go-strings-match-at s p 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-has-suffix
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-hassuffix-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (suf (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? suf)) (list :eval-error :strings-not-string suf)
|
||||
(> (len suf) (len s)) false
|
||||
:else
|
||||
(go-strings-match-at s suf (- (len s) (len suf)) 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-index-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-index-loop s sub 0))))))
|
||||
|
||||
(define
|
||||
go-strings-index-loop
|
||||
(fn (s sub start)
|
||||
(let ((slen (len s)) (sublen (len sub)))
|
||||
(cond
|
||||
(= sublen 0) 0
|
||||
(> (+ start sublen) slen) -1
|
||||
(go-strings-match-at s sub start 0) start
|
||||
:else (go-strings-index-loop s sub (+ start 1))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-repeat-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (n (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(< n 0) (list :eval-error :strings-repeat-negative n)
|
||||
:else (go-strings-repeat-loop s n ""))))))
|
||||
|
||||
(define
|
||||
go-strings-repeat-loop
|
||||
(fn (s n acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-strings-repeat-loop s (- n 1) (str acc s)))))
|
||||
|
||||
(define
|
||||
go-strings-count
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-count-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sub (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sub)) (list :eval-error :strings-not-string sub)
|
||||
:else (go-strings-count-loop s sub 0 0))))))
|
||||
|
||||
(define
|
||||
go-strings-count-loop
|
||||
(fn (s sub start acc)
|
||||
(let ((idx (go-strings-index-loop s sub start)))
|
||||
(cond
|
||||
(< idx 0) acc
|
||||
:else
|
||||
(go-strings-count-loop s sub (+ idx (max 1 (len sub))) (+ acc 1))))))
|
||||
|
||||
(define
|
||||
go-strings-join
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-join-arity (len args))
|
||||
:else
|
||||
(let ((sep (nth args 1)) (xs (first args)))
|
||||
(cond
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(not (and (list? xs) (= (first xs) :go-slice)))
|
||||
(list :eval-error :strings-join-not-slice xs)
|
||||
:else (go-strings-join-loop (nth xs 1) sep ""))))))
|
||||
|
||||
(define
|
||||
go-strings-join-loop
|
||||
(fn (xs sep acc)
|
||||
(cond
|
||||
(= (len xs) 0) acc
|
||||
(= (len acc) 0) (go-strings-join-loop (rest xs) sep (first xs))
|
||||
:else
|
||||
(go-strings-join-loop (rest xs) sep (str acc sep (first xs))))))
|
||||
|
||||
;; ── case conversion ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-char-to-upper
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "a") (<= c "z"))
|
||||
;; ASCII uppercase shift: 'a' is 0x61, 'A' is 0x41 → diff 0x20.
|
||||
;; SX has no charcode primitive, so use a char-pair table.
|
||||
(go-strings-letter-toggle c true)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-char-to-lower
|
||||
(fn (c)
|
||||
(cond
|
||||
(and (>= c "A") (<= c "Z"))
|
||||
(go-strings-letter-toggle c false)
|
||||
:else c)))
|
||||
|
||||
(define
|
||||
go-strings-letter-toggle
|
||||
;; Toggle a single ASCII letter's case via direct mapping.
|
||||
;; `to-upper?` true means input is lowercase, output uppercase.
|
||||
(fn (c to-upper?)
|
||||
(cond
|
||||
to-upper?
|
||||
(cond
|
||||
(= c "a") "A" (= c "b") "B" (= c "c") "C" (= c "d") "D"
|
||||
(= c "e") "E" (= c "f") "F" (= c "g") "G" (= c "h") "H"
|
||||
(= c "i") "I" (= c "j") "J" (= c "k") "K" (= c "l") "L"
|
||||
(= c "m") "M" (= c "n") "N" (= c "o") "O" (= c "p") "P"
|
||||
(= c "q") "Q" (= c "r") "R" (= c "s") "S" (= c "t") "T"
|
||||
(= c "u") "U" (= c "v") "V" (= c "w") "W" (= c "x") "X"
|
||||
(= c "y") "Y" (= c "z") "Z" :else c)
|
||||
:else
|
||||
(cond
|
||||
(= c "A") "a" (= c "B") "b" (= c "C") "c" (= c "D") "d"
|
||||
(= c "E") "e" (= c "F") "f" (= c "G") "g" (= c "H") "h"
|
||||
(= c "I") "i" (= c "J") "j" (= c "K") "k" (= c "L") "l"
|
||||
(= c "M") "m" (= c "N") "n" (= c "O") "o" (= c "P") "p"
|
||||
(= c "Q") "q" (= c "R") "r" (= c "S") "s" (= c "T") "t"
|
||||
(= c "U") "u" (= c "V") "v" (= c "W") "w" (= c "X") "x"
|
||||
(= c "Y") "y" (= c "Z") "z" :else c))))
|
||||
|
||||
(define
|
||||
go-strings-map-chars
|
||||
(fn (s i acc char-fn)
|
||||
(cond
|
||||
(>= i (len s)) acc
|
||||
:else
|
||||
(go-strings-map-chars s (+ i 1) (str acc (char-fn (nth s i))) char-fn))))
|
||||
|
||||
(define
|
||||
go-strings-to-upper
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-toupper-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-upper))))))
|
||||
|
||||
(define
|
||||
go-strings-to-lower
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-tolower-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else (go-strings-map-chars s 0 "" go-strings-char-to-lower))))))
|
||||
|
||||
;; ── TrimSpace ────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-is-space?
|
||||
(fn (c)
|
||||
(or (= c " ") (= c "\t") (= c "\n") (= c "\r"))))
|
||||
|
||||
(define
|
||||
go-strings-trim-left
|
||||
(fn (s i)
|
||||
(cond
|
||||
(>= i (len s)) i
|
||||
(go-strings-is-space? (nth s i)) (go-strings-trim-left s (+ i 1))
|
||||
:else i)))
|
||||
|
||||
(define
|
||||
go-strings-trim-right
|
||||
(fn (s end)
|
||||
(cond
|
||||
(<= end 0) 0
|
||||
(go-strings-is-space? (nth s (- end 1))) (go-strings-trim-right s (- end 1))
|
||||
:else end)))
|
||||
|
||||
(define
|
||||
go-strings-substr
|
||||
;; Substring [lo, hi) — naive but predictable.
|
||||
(fn (s lo hi)
|
||||
(cond
|
||||
(>= lo hi) ""
|
||||
:else
|
||||
(go-strings-substr-loop s lo hi ""))))
|
||||
|
||||
(define
|
||||
go-strings-substr-loop
|
||||
(fn (s i hi acc)
|
||||
(cond
|
||||
(>= i hi) acc
|
||||
:else (go-strings-substr-loop s (+ i 1) hi (str acc (nth s i))))))
|
||||
|
||||
(define
|
||||
go-strings-trim-space
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 1))
|
||||
(list :eval-error :strings-trimspace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
:else
|
||||
(let ((lo (go-strings-trim-left s 0)))
|
||||
(let ((hi (go-strings-trim-right s (len s))))
|
||||
(go-strings-substr s lo hi))))))))
|
||||
|
||||
;; ── Split ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-split
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 2))
|
||||
(list :eval-error :strings-split-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (sep (nth args 1)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? sep)) (list :eval-error :strings-not-string sep)
|
||||
(= (len sep) 0)
|
||||
;; Empty separator: real Go splits to all chars; v0 keeps
|
||||
;; behaviour simple — single-element slice.
|
||||
(list :go-slice (list s))
|
||||
:else
|
||||
(list :go-slice (go-strings-split-loop s sep 0 (list))))))))
|
||||
|
||||
(define
|
||||
go-strings-split-loop
|
||||
(fn (s sep start acc)
|
||||
(let ((idx (go-strings-index-loop s sep start)))
|
||||
(cond
|
||||
(< idx 0)
|
||||
(go-strings-split-finalize acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-split-loop s sep (+ idx (len sep))
|
||||
(go-strings-split-finalize acc
|
||||
(go-strings-substr s start idx)))))))
|
||||
|
||||
(define
|
||||
go-strings-split-finalize
|
||||
;; Append a piece to acc, growing the list in order.
|
||||
(fn (acc piece)
|
||||
(cond
|
||||
(= (len acc) 0) (list piece)
|
||||
:else (go-name-concat acc (list piece)))))
|
||||
|
||||
;; ── Replace ──────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-strings-replace
|
||||
;; Replace(s, old, new, n). n < 0 = all.
|
||||
(fn (args)
|
||||
(cond
|
||||
(not (= (len args) 4))
|
||||
(list :eval-error :strings-replace-arity (len args))
|
||||
:else
|
||||
(let ((s (first args)) (old (nth args 1))
|
||||
(newv (nth args 2)) (n (nth args 3)))
|
||||
(cond
|
||||
(not (string? s)) (list :eval-error :strings-not-string s)
|
||||
(not (string? old)) (list :eval-error :strings-not-string old)
|
||||
(not (string? newv)) (list :eval-error :strings-not-string newv)
|
||||
(= (len old) 0) s
|
||||
:else (go-strings-replace-loop s old newv n 0 ""))))))
|
||||
|
||||
(define
|
||||
go-strings-replace-loop
|
||||
(fn (s old newv n start acc)
|
||||
(let ((idx (go-strings-index-loop s old start)))
|
||||
(cond
|
||||
(or (< idx 0) (= n 0))
|
||||
(str acc (go-strings-substr s start (len s)))
|
||||
:else
|
||||
(go-strings-replace-loop s old newv
|
||||
(cond (< n 0) -1 :else (- n 1))
|
||||
(+ idx (len old))
|
||||
(str acc (go-strings-substr s start idx) newv))))))
|
||||
|
||||
;; ── go-std-strings package value ─────────────────────────────────
|
||||
|
||||
(define
|
||||
go-std-strings
|
||||
(list :go-package "strings"
|
||||
(list
|
||||
(list "Contains" (list :go-builtin-fn go-strings-contains))
|
||||
(list "HasPrefix" (list :go-builtin-fn go-strings-has-prefix))
|
||||
(list "HasSuffix" (list :go-builtin-fn go-strings-has-suffix))
|
||||
(list "Index" (list :go-builtin-fn go-strings-index))
|
||||
(list "Count" (list :go-builtin-fn go-strings-count))
|
||||
(list "Repeat" (list :go-builtin-fn go-strings-repeat))
|
||||
(list "Join" (list :go-builtin-fn go-strings-join))
|
||||
(list "ToUpper" (list :go-builtin-fn go-strings-to-upper))
|
||||
(list "ToLower" (list :go-builtin-fn go-strings-to-lower))
|
||||
(list "TrimSpace" (list :go-builtin-fn go-strings-trim-space))
|
||||
(list "Split" (list :go-builtin-fn go-strings-split))
|
||||
(list "Replace" (list :go-builtin-fn go-strings-replace)))))
|
||||
186
lib/go/tests/e2e.sx
Normal file
186
lib/go/tests/e2e.sx
Normal file
@@ -0,0 +1,186 @@
|
||||
;; Go end-to-end tests — complete programs exercising lex+parse+
|
||||
;; types+eval+sched+stdlib together. Each test runs a multi-line Go
|
||||
;; program and inspects the final env.
|
||||
|
||||
(define go-e2e-test-count 0)
|
||||
(define go-e2e-test-pass 0)
|
||||
(define go-e2e-test-fails (list))
|
||||
|
||||
(define
|
||||
go-e2e-test
|
||||
(fn (name actual expected)
|
||||
(set! go-e2e-test-count (+ go-e2e-test-count 1))
|
||||
(if (= actual expected)
|
||||
(set! go-e2e-test-pass (+ go-e2e-test-pass 1))
|
||||
(append! go-e2e-test-fails
|
||||
{:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-e2e-env
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-e2e-run
|
||||
(fn (src-list)
|
||||
(go-eval-program go-e2e-env (map go-parse src-list))))
|
||||
|
||||
;; ── 1. Sieve via boolean slice (no modulo needed) ────────────────
|
||||
(go-e2e-test "e2e: sieve-of-Eratosthenes via boolean slice — count primes ≤ 30"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
;; sieve[i] true means i is COMPOSITE (saves the
|
||||
;; default-bool initialisation for primes).
|
||||
"sieve := []bool{false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false}"
|
||||
"for p := 2; p < 31; p = p + 1 { if sieve[p] == false { for k := p + p; k < 31; k = k + p { sieve[k] = true } } }"
|
||||
"count := 0"
|
||||
"for i := 2; i < 31; i = i + 1 { if sieve[i] == false { count = count + 1 } }"))))
|
||||
(go-env-lookup env "count"))
|
||||
;; primes ≤ 30: 2,3,5,7,11,13,17,19,23,29 = 10
|
||||
10)
|
||||
|
||||
;; ── 1b. Range-membership check (works without mod) ───────────────
|
||||
(go-e2e-test "e2e: linear search across slice of strings"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"words := []string{\"apple\", \"banana\", \"cherry\", \"date\"}"
|
||||
"func indexOf(xs []string, target string) int { for i, v := range xs { if v == target { return i } } ; return -1 }"
|
||||
"i := indexOf(words, \"cherry\")"
|
||||
"missing := indexOf(words, \"xyz\")"))))
|
||||
(list (go-env-lookup env "i") (go-env-lookup env "missing")))
|
||||
(list 2 -1))
|
||||
|
||||
;; ── 2. Reverse a slice ───────────────────────────────────────────
|
||||
(go-e2e-test "e2e: reverse a slice of ints"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func reverse(xs []int) []int { r := []int{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"out := reverse([]int{1, 2, 3, 4, 5})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 5 4 3 2 1)))
|
||||
|
||||
;; ── 3. Fibonacci (recursive) ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: fib(10) = 55"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func fib(n int) int { if n < 2 { return n } ; return fib(n-1) + fib(n-2) }"
|
||||
"r := fib(10)"))))
|
||||
(go-env-lookup env "r"))
|
||||
55)
|
||||
|
||||
;; ── 4. Sum-of-squares via Map+Reduce ─────────────────────────────
|
||||
(go-e2e-test "e2e: sum-of-squares 1..5 via Map+Reduce"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }"
|
||||
"func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }"
|
||||
"func sq(x int) int { return x * x }"
|
||||
"func add(a int, b int) int { return a + b }"
|
||||
"squares := Map([]int{1, 2, 3, 4, 5}, sq)"
|
||||
"total := Reduce(squares, 0, add)"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1 + 4 + 9 + 16 + 25 = 55
|
||||
55)
|
||||
|
||||
;; ── 5. Word frequency counter ────────────────────────────────────
|
||||
(go-e2e-test "e2e: word-frequency over a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"text := \"the quick brown fox jumps over the lazy dog the\""
|
||||
"words := strings.Split(text, \" \")"
|
||||
"counts := map[string]int{}"
|
||||
"for i, w := range words { counts[w] = counts[w] + 1 }"
|
||||
"the_count := counts[\"the\"]"
|
||||
"fox_count := counts[\"fox\"]"
|
||||
"dog_count := counts[\"dog\"]"))))
|
||||
(list (go-env-lookup env "the_count")
|
||||
(go-env-lookup env "fox_count")
|
||||
(go-env-lookup env "dog_count")))
|
||||
(list 3 1 1))
|
||||
|
||||
;; ── 6. Pipeline via channels ─────────────────────────────────────
|
||||
(go-e2e-test "e2e: pipeline — generate, square, sum"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func gen(c chan int, n int) { for i := 1; i <= n; i = i + 1 { c <- i } ; close(c) }"
|
||||
"func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }"
|
||||
"src := make()"
|
||||
"sqs := make()"
|
||||
"go gen(src, 4)"
|
||||
"go sq(src, sqs)"
|
||||
"total := 0"
|
||||
"for v := range sqs { total = total + v }"))))
|
||||
(go-env-lookup env "total"))
|
||||
;; 1+4+9+16 = 30
|
||||
30)
|
||||
|
||||
;; ── 7. Worker pool draining a job channel ────────────────────────
|
||||
(go-e2e-test "e2e: worker pool — sum of doubled jobs"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func worker(jobs chan int, results chan int) { for j := range jobs { results <- j * 2 } }"
|
||||
"jobs := make()"
|
||||
"results := make()"
|
||||
"jobs <- 10 ; jobs <- 20 ; jobs <- 30"
|
||||
"close(jobs)"
|
||||
"go worker(jobs, results)"
|
||||
"close(results)"
|
||||
"sum := 0"
|
||||
"for r := range results { sum = sum + r }"))))
|
||||
(go-env-lookup env "sum"))
|
||||
;; 20 + 40 + 60 = 120
|
||||
120)
|
||||
|
||||
;; ── 8. Bubble sort ───────────────────────────────────────────────
|
||||
(go-e2e-test "e2e: bubble sort ascending"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func bubble(xs []int) []int { n := len(xs) ; for i := 0; i < n; i = i + 1 { for j := 0; j < n - 1; j = j + 1 { if xs[j] > xs[j+1] { tmp := xs[j] ; xs[j] = xs[j+1] ; xs[j+1] = tmp } } } ; return xs }"
|
||||
"out := bubble([]int{3, 1, 4, 1, 5, 9, 2, 6})"))))
|
||||
(go-env-lookup env "out"))
|
||||
(list :go-slice (list 1 1 2 3 4 5 6 9)))
|
||||
|
||||
;; ── 9. String reverse using strings.Split + reverse + Join ──────
|
||||
(go-e2e-test "e2e: reverse words in a sentence"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func rev(xs []string) []string { r := []string{} ; for i := len(xs) - 1; i >= 0; i = i - 1 { r = append(r, xs[i]) } ; return r }"
|
||||
"text := \"go on sx\""
|
||||
"out := strings.Join(rev(strings.Split(text, \" \")), \"-\")"))))
|
||||
(go-env-lookup env "out"))
|
||||
"sx-on-go")
|
||||
|
||||
;; ── 10. Counting occurrences via Filter ──────────────────────────
|
||||
(go-e2e-test "e2e: count even numbers via Filter+len"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }"
|
||||
"func gt5(x int) bool { return x > 5 }"
|
||||
"n := len(Filter([]int{1, 2, 6, 3, 7, 8, 4, 9}, gt5))"))))
|
||||
(go-env-lookup env "n"))
|
||||
;; gt5: 6,7,8,9 = 4
|
||||
4)
|
||||
|
||||
;; ── 11. Recursive ackermann (small inputs) ───────────────────────
|
||||
(go-e2e-test "e2e: ackermann(2, 3) = 9"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func ack(m int, n int) int { if m == 0 { return n + 1 } ; if n == 0 { return ack(m - 1, 1) } ; return ack(m - 1, ack(m, n - 1)) }"
|
||||
"r := ack(2, 3)"))))
|
||||
(go-env-lookup env "r"))
|
||||
9)
|
||||
|
||||
;; ── 12. Defer + recover smoke test ───────────────────────────────
|
||||
(go-e2e-test "e2e: defer + recover in real-fn flow"
|
||||
(let ((env (go-e2e-run
|
||||
(list
|
||||
"func safeDivide(a int, b int) int { defer recover() ; if b == 0 { panic(\"div by zero\") } ; return a / b }"
|
||||
"r := safeDivide(10, 0)"
|
||||
"after := 99"))))
|
||||
(go-env-lookup env "after"))
|
||||
99)
|
||||
|
||||
(define
|
||||
go-e2e-test-summary
|
||||
(str "e2e " go-e2e-test-pass "/" go-e2e-test-count))
|
||||
667
lib/go/tests/eval.sx
Normal file
667
lib/go/tests/eval.sx
Normal file
@@ -0,0 +1,667 @@
|
||||
;; Go evaluator tests.
|
||||
|
||||
(define go-eval-test-count 0)
|
||||
(define go-eval-test-pass 0)
|
||||
(define go-eval-test-fails (list))
|
||||
|
||||
(define
|
||||
go-eval-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-eval-test-count (+ go-eval-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-eval-test-pass (+ go-eval-test-pass 1))
|
||||
(append! go-eval-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define gtev (fn (env src) (go-eval env (go-parse src))))
|
||||
|
||||
;; ── env ──────────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"env: empty lookup returns nil"
|
||||
(go-env-lookup go-env-empty "x")
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"env: extend then lookup"
|
||||
(go-env-lookup (go-env-extend go-env-empty "x" 42) "x")
|
||||
42)
|
||||
|
||||
;; ── literals ────────────────────────────────────────────────────
|
||||
(go-eval-test "lit: 42 → 42" (gtev go-env-empty "42") 42)
|
||||
|
||||
(go-eval-test "lit: 0 → 0" (gtev go-env-empty "0") 0)
|
||||
|
||||
(go-eval-test "lit: 0xFF → 255" (gtev go-env-empty "0xFF") 255)
|
||||
|
||||
(go-eval-test "lit: 0b1010 → 10" (gtev go-env-empty "0b1010") 10)
|
||||
|
||||
(go-eval-test "lit: 0o17 → 15" (gtev go-env-empty "0o17") 15)
|
||||
|
||||
(go-eval-test
|
||||
"lit: underscore separator 1_000 → 1000"
|
||||
(gtev go-env-empty "1_000")
|
||||
1000)
|
||||
|
||||
(go-eval-test "lit: string" (gtev go-env-empty "\"hello\"") "hello")
|
||||
|
||||
;; ── predeclared ─────────────────────────────────────────────────
|
||||
(go-eval-test "var: true" (gtev go-env-empty "true") true)
|
||||
(go-eval-test "var: false" (gtev go-env-empty "false") false)
|
||||
(go-eval-test "var: nil" (gtev go-env-empty "nil") nil)
|
||||
|
||||
;; ── variable lookup ─────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var: bound x → 5"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"var: unbound y → :eval-error"
|
||||
(gtev go-env-empty "y")
|
||||
(list :eval-error :unbound "y"))
|
||||
|
||||
;; ── binary ops ─────────────────────────────────────────────────
|
||||
(go-eval-test "binop: 1 + 2 → 3" (gtev go-env-empty "1 + 2") 3)
|
||||
(go-eval-test "binop: 10 - 4 → 6" (gtev go-env-empty "10 - 4") 6)
|
||||
(go-eval-test "binop: 3 * 7 → 21" (gtev go-env-empty "3 * 7") 21)
|
||||
(go-eval-test "binop: 42 / 7 → 6" (gtev go-env-empty "42 / 7") 6)
|
||||
(go-eval-test
|
||||
"binop: 2 + 3 * 4 → 14 (prec)"
|
||||
(gtev go-env-empty "2 + 3 * 4")
|
||||
14)
|
||||
(go-eval-test
|
||||
"binop: a + b uses env"
|
||||
(go-eval
|
||||
(go-env-extend (go-env-extend go-env-empty "a" 3) "b" 4)
|
||||
(go-parse "a + b"))
|
||||
7)
|
||||
|
||||
(go-eval-test "binop: 1 < 2 → true" (gtev go-env-empty "1 < 2") true)
|
||||
(go-eval-test "binop: 5 == 5 → true" (gtev go-env-empty "5 == 5") true)
|
||||
(go-eval-test "binop: 5 != 5 → false" (gtev go-env-empty "5 != 5") false)
|
||||
(go-eval-test
|
||||
"binop: true && false → false"
|
||||
(gtev go-env-empty "true && false")
|
||||
false)
|
||||
(go-eval-test
|
||||
"binop: false || true → true"
|
||||
(gtev go-env-empty "false || true")
|
||||
true)
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-eval-test
|
||||
"var-decl: var x = 5 — env has x=5"
|
||||
(go-env-lookup
|
||||
(go-eval-program go-env-empty (list (go-parse "var x = 5")))
|
||||
"x")
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"short-decl: a, b := 3, 4 — env has both"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a, b := 3, 4")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 3 4))
|
||||
|
||||
(go-eval-test
|
||||
"assign: x = 5 then x → 5"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 1) (list (go-parse "x = 5")))))
|
||||
(go-env-lookup env "x"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"if: true branch evaluates"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if true { x = 1 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"if-else: false → else branch"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 0) (list (go-parse "if false { x = 1 } else { x = 2 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"fn: define + call — double(7) = 14"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func double(x int) int { return x * 2 }")))))
|
||||
(go-eval env (go-parse "double(7)")))
|
||||
14)
|
||||
|
||||
(go-eval-test
|
||||
"fn: add(2, 3) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func add(x, y int) int { return x + y }")))))
|
||||
(go-eval env (go-parse "add(2, 3)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"fn: recursive fib(5) = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(5)")))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: count to 10 with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 10; i++ { sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
45)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x++ updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x++")))))
|
||||
(go-env-lookup env "x"))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"inc-dec: x-- updates env"
|
||||
(let
|
||||
((env (go-eval-program (go-env-extend go-env-empty "x" 5) (list (go-parse "x--")))))
|
||||
(go-env-lookup env "x"))
|
||||
4)
|
||||
|
||||
(go-eval-test
|
||||
"for: break exits the loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var i = 0") (go-parse "for i < 100 { if i == 5 { break } ; i++ }")))))
|
||||
(go-env-lookup env "i"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"for: continue skips body but runs post"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var sum = 0") (go-parse "for i := 0; i < 5; i++ { if i == 2 { continue } ; sum = sum + i }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"for: infinite + break with sum"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "var s = 0") (go-parse "var i = 1") (go-parse "for { if i > 4 { break } ; s = s + i ; i++ }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-eval-test
|
||||
"fn: iterative factorial via for-loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fact(n int) int { r := 1 ; for i := 2 ; i <= n ; i++ { r = r * i } ; return r }")))))
|
||||
(go-eval env (go-parse "fact(5)")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"slice: []int{1,2,3} → :go-slice"
|
||||
(gtev go-env-empty "[]int{1, 2, 3}")
|
||||
(list :go-slice (list 1 2 3)))
|
||||
|
||||
(go-eval-test
|
||||
"index: a[0] = 10, a[2] = 30"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}")))))
|
||||
(list (go-eval env (go-parse "a[0]")) (go-eval env (go-parse "a[2]"))))
|
||||
(list 10 30))
|
||||
|
||||
(go-eval-test
|
||||
"index: out-of-range error"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2}")))))
|
||||
(go-eval env (go-parse "a[5]")))
|
||||
(list :eval-error :index-out-of-range 5 2))
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(slice) = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "len(a)")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: len(string)"
|
||||
(go-eval go-env-builtins (go-parse "len(\"hello\")"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"builtin: append(a, 4, 5)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3}")))))
|
||||
(go-eval env (go-parse "append(a, 4, 5)")))
|
||||
(list
|
||||
:go-slice (list 1 2 3 4 5)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[1:3]"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "a[1:3]")))
|
||||
(list :go-slice (list 20 30)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[:2] (omitted low)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[:2]")))
|
||||
(list :go-slice (list 1 2)))
|
||||
|
||||
(go-eval-test
|
||||
"slice expr: a[2:] (omitted high)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{1, 2, 3, 4}")))))
|
||||
(go-eval env (go-parse "a[2:]")))
|
||||
(list :go-slice (list 3 4)))
|
||||
|
||||
(go-eval-test
|
||||
"fn: sum slice via for-loop with len + index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "sum := 0") (go-parse "for i := 0; i < len(a); i++ { sum = sum + a[i] }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-eval-test
|
||||
"map: map[string]int{...} → :go-map"
|
||||
(gtev go-env-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :go-map (list (list "a" 1) (list "b" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"map: m[\"a\"] → 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"map: missing key → nil (v0 stand-in for zero value)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}")))))
|
||||
(go-eval env (go-parse "m[\"missing\"]")))
|
||||
nil)
|
||||
|
||||
(go-eval-test
|
||||
"map: len(m) = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "m := map[string]int{\"a\": 1, \"b\": 2}")))))
|
||||
(go-eval env (go-parse "len(m)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign updates existing key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{\"a\": 1}") (go-parse "m[\"a\"] = 99")))))
|
||||
(go-eval env (go-parse "m[\"a\"]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: index-assign adds new key"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "m := map[string]int{}") (go-parse "m[\"new\"] = 7")))))
|
||||
(go-eval env (go-parse "m[\"new\"]")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"slice: index-assign a[0] = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "a := []int{10, 20, 30}") (go-parse "a[0] = 99")))))
|
||||
(go-eval env (go-parse "a[0]")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"map: word count via loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "words := []string{\"a\", \"b\", \"a\", \"c\", \"a\"}") (go-parse "counts := map[string]int{}") (go-parse "for i := 0; i < len(words); i++ { counts[words[i]] = counts[words[i]] + 1 }")))))
|
||||
(go-eval env (go-parse "counts[\"a\"]")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"type-decl: registers struct field names"
|
||||
(go-env-lookup
|
||||
(go-eval-program
|
||||
go-env-empty
|
||||
(list (go-parse "type Point struct { x, y int }")))
|
||||
"Point")
|
||||
(list :go-struct-type (list "x" "y")))
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional composite Point{1, 2}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1, 2}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 1) (list "y" 2))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: keyed composite Point{x: 5, y: 10}"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{x: 5, y: 10}")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 5) (list "y" 10))))
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.x = 1"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector p.y = 2"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.y")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"struct: selector-assign p.x = 99"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}") (go-parse "p.x = 99")))))
|
||||
(go-eval env (go-parse "p.x")))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"struct: positional arity-mismatch"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }")))))
|
||||
(go-eval env (go-parse "Point{1}")))
|
||||
(list :eval-error :struct-arity-mismatch "Point" 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"struct: function takes/returns struct"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func add(a, b Point) Point { return Point{a.x + b.x, a.y + b.y} }")))))
|
||||
(go-eval env (go-parse "add(Point{1, 2}, Point{3, 4})")))
|
||||
(list
|
||||
:go-struct "Point"
|
||||
(list (list "x" 4) (list "y" 6))))
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Sum() = 3"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Sum() int { return p.x + p.y }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Sum()")))
|
||||
3)
|
||||
|
||||
(go-eval-test
|
||||
"method: p.Add(5) = 6 (with arg)"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p Point) Add(d int) int { return p.x + d }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Add(5)")))
|
||||
6)
|
||||
|
||||
(go-eval-test
|
||||
"method: pointer receiver works value-style in v0"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "func (p *Point) GetX() int { return p.x }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.GetX()")))
|
||||
1)
|
||||
|
||||
(go-eval-test
|
||||
"method: missing method → :no-such-method"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Point struct { x, y int }") (go-parse "p := Point{1, 2}")))))
|
||||
(go-eval env (go-parse "p.Ghost()")))
|
||||
(list :eval-error :no-such-method "Point" "Ghost"))
|
||||
|
||||
(go-eval-test
|
||||
"unary: -x"
|
||||
(go-eval (go-env-extend go-env-empty "x" 5) (go-parse "-x"))
|
||||
-5)
|
||||
|
||||
(go-eval-test "unary: !true → false" (gtev go-env-empty "!true") false)
|
||||
|
||||
(go-eval-test "unary: !false → true" (gtev go-env-empty "!false") true)
|
||||
|
||||
(go-eval-test
|
||||
"unary: -3 + 5 = 2 (unary binds tighter)"
|
||||
(gtev go-env-empty "-3 + 5")
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: count odd numbers in 1..10 = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty
|
||||
(list (go-parse "odds := 0")
|
||||
(go-parse "i := 1")
|
||||
(go-parse "for i <= 10 { odds = odds + 1; i = i + 2 }")))))
|
||||
(go-env-lookup env "odds"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: factorial via method on Counter"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Acc struct { v int }") (go-parse "func (a Acc) Mul(x int) Acc { return Acc{a.v * x} }") (go-parse "a := Acc{1}") (go-parse "for i := 1; i <= 5; i++ { a = a.Mul(i) }")))))
|
||||
(go-eval env (go-parse "a.v")))
|
||||
120)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: recursive fibonacci fib(10) = 55"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "func fib(n int) int { if n < 2 { return n } return fib(n-1) + fib(n-2) }")))))
|
||||
(go-eval env (go-parse "fib(10)")))
|
||||
55)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: struct + method + iterative loop"
|
||||
(let
|
||||
((env (go-eval-program go-env-empty (list (go-parse "type Counter struct { n int }") (go-parse "func (c Counter) Bump() Counter { return Counter{c.n + 1} }") (go-parse "c := Counter{0}") (go-parse "for i := 0; i < 7; i++ { c = c.Bump() }")))))
|
||||
(go-eval env (go-parse "c.n")))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns index"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30, 40}")))))
|
||||
(go-eval env (go-parse "find(nums, 30)")))
|
||||
2)
|
||||
|
||||
(go-eval-test
|
||||
"e2e: linear search returns -1 when missing"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func find(a []int, x int) int { for i := 0; i < len(a); i++ { if a[i] == x { return i } } ; return -1 }") (go-parse "nums := []int{10, 20, 30}")))))
|
||||
(go-eval env (go-parse "find(nums, 99)")))
|
||||
-1)
|
||||
|
||||
(go-eval-test
|
||||
"defer: single defer runs after surrounding fn body returns"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func run(c chan int) { defer push2(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "first := <-ch") (go-parse "second := <-ch")))))
|
||||
(list (go-env-lookup env "first") (go-env-lookup env "second")))
|
||||
(list 1 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: multiple defers run LIFO"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func p3(c chan int) { c <- 3 }") (go-parse "func run(c chan int) { defer p2(c) ; defer p3(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")))
|
||||
(list 1 3 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer: arguments are evaluated at defer-time (not call-time)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { x := 7 ; defer pushN(c, x) ; x = 99 }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"defer: runs even when fn returns early via return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 42 }") (go-parse "func run(c chan int) int { defer note(c) ; return 1 }") (go-parse "r := run(ch)") (go-parse "n := <-ch")))))
|
||||
(list (go-env-lookup env "r") (go-env-lookup env "n")))
|
||||
(list 1 42))
|
||||
|
||||
(go-eval-test
|
||||
"defer: stack is frame-local — outer defers don't run on inner return"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push1(c chan int) { c <- 1 }") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func inner(c chan int) { defer push2(c) }") (go-parse "func outer(c chan int) { defer push1(c) ; inner(c) }") (go-parse "outer(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 2 1))
|
||||
|
||||
(go-eval-test
|
||||
"defer: in a loop, all defers fire on fn return (not loop iter)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushI(c chan int, v int) { c <- v }") (go-parse "func loop(c chan int) { for i := 0; i < 4; i = i + 1 { defer pushI(c, i) } }") (go-parse "loop(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch") (go-parse "e := <-ch")))))
|
||||
(list
|
||||
(go-env-lookup env "a")
|
||||
(go-env-lookup env "b")
|
||||
(go-env-lookup env "d")
|
||||
(go-env-lookup env "e")))
|
||||
(list 3 2 1 0))
|
||||
|
||||
(go-eval-test
|
||||
"panic: uncaught panic surfaces as (:go-panic V) from program"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(\"boom\")")))))
|
||||
r)
|
||||
(list :go-panic "boom"))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside fn: surfaces from fn call too"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"oops\") }") (go-parse "boom()")))))
|
||||
r)
|
||||
(list :go-panic "oops"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover swallows panic, fn returns normally"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func safe() { defer recover() ; panic(\"x\") }") (go-parse "safe()") (go-parse "after := 42")))))
|
||||
(go-env-lookup env "after"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"recover: deferred recover captures the panic value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func grab(c chan int) { r := recover() ; c <- r }") (go-parse "func safe(c chan int) { defer grab(c) ; panic(99) }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
99)
|
||||
|
||||
(go-eval-test
|
||||
"panic: propagates through intermediate frames without defers"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { middle() }") (go-parse "outer()")))))
|
||||
r)
|
||||
(list :go-panic "deep"))
|
||||
|
||||
(go-eval-test
|
||||
"recover: middle-frame defer catches panic from deeper frame"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { defer recover() ; middle() }") (go-parse "outer()") (go-parse "after := 7")))))
|
||||
(go-env-lookup env "after"))
|
||||
7)
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic: surfaces synchronously back to spawner (v0)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"goroutine\") }") (go-parse "go boom()")))))
|
||||
r)
|
||||
(list :go-panic "goroutine"))
|
||||
|
||||
(go-eval-test
|
||||
"goroutine panic + spawner-defer-recover catches it (v0 sync)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"g\") }") (go-parse "func main() { defer recover() ; go boom() }") (go-parse "main()") (go-parse "after := 11")))))
|
||||
(go-env-lookup env "after"))
|
||||
11)
|
||||
|
||||
(go-eval-test
|
||||
"defer order with recover: all defers run, recover catches"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func rec(c chan int) { recover() ; c <- 7 }") (go-parse "func safe(c chan int) { defer p2(c) ; defer rec(c) ; panic(0) }") (go-parse "safe(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
|
||||
(list (go-env-lookup env "a") (go-env-lookup env "b")))
|
||||
(list 7 2))
|
||||
|
||||
(go-eval-test
|
||||
"defer fires when fn panics (not just normal return)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 5 }") (go-parse "func safe(c chan int) { defer note(c) ; defer recover() ; panic(\"!\") }") (go-parse "safe(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"panic with nil value: still surfaces as (:go-panic nil)"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "panic(nil)")))))
|
||||
r)
|
||||
(list :go-panic nil))
|
||||
|
||||
(go-eval-test
|
||||
"panic inside loop body: aborts loop + propagates"
|
||||
(let
|
||||
((r (go-eval-program go-env-builtins (list (go-parse "func find(x int) { for i := 0; i < 10; i = i + 1 { if i == x { panic(i) } } }") (go-parse "find(3)")))))
|
||||
r)
|
||||
(list :go-panic 3))
|
||||
|
||||
(go-eval-test
|
||||
"defer in panicking fn: still runs even though no return reached"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func mark(c chan int) { c <- 8 }") (go-parse "func inner(c chan int) { defer mark(c) ; panic(\"!\") }") (go-parse "func outer(c chan int) { defer recover() ; inner(c) }") (go-parse "outer(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
8)
|
||||
|
||||
(go-eval-test
|
||||
"defer fn captures args by value, not reference (re-confirm)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { defer recover() ; x := 5 ; defer pushN(c, x) ; x = 999 ; panic(\"k\") }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
|
||||
(go-env-lookup env "got"))
|
||||
5)
|
||||
|
||||
(go-eval-test
|
||||
"generic: identity Id[T any](x) returns x at runtime"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(42)")))))
|
||||
(go-env-lookup env "r"))
|
||||
42)
|
||||
|
||||
(go-eval-test
|
||||
"generic: Id works with strings (type erasure)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Id[T any](x T) T { return x }") (go-parse "r := Id(\"hi\")")))))
|
||||
(go-env-lookup env "r"))
|
||||
"hi")
|
||||
|
||||
(go-eval-test
|
||||
"generic: Map[T, U] over []int with double — produces []int"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { r := []int{} ; for i, v := range xs { r = append(r, f(v)) } ; return r }") (go-parse "func dbl(x int) int { return x * 2 }") (go-parse "out := Map([]int{1, 2, 3}, dbl)") (go-parse "first := out[0]") (go-parse "second := out[1]") (go-parse "third := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "second")
|
||||
(go-env-lookup env "third")))
|
||||
(list 2 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Filter[T any] keeps elements satisfying predicate"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { r := []int{} ; for i, v := range xs { if p(v) { r = append(r, v) } } ; return r }") (go-parse "func gt3(x int) bool { return x > 3 }") (go-parse "out := Filter([]int{1, 2, 3, 4, 5, 6}, gt3)") (go-parse "n := len(out)") (go-parse "first := out[0]") (go-parse "last := out[2]")))))
|
||||
(list
|
||||
(go-env-lookup env "n")
|
||||
(go-env-lookup env "first")
|
||||
(go-env-lookup env "last")))
|
||||
(list 3 4 6))
|
||||
|
||||
(go-eval-test
|
||||
"generic: Reduce[T, U] sums []int with seed 0"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { acc := seed ; for i, v := range xs { acc = f(acc, v) } ; return acc }") (go-parse "func add(a int, b int) int { return a + b }") (go-parse "total := Reduce([]int{10, 20, 30, 40}, 0, add)")))))
|
||||
(go-env-lookup env "total"))
|
||||
100)
|
||||
|
||||
(go-eval-test
|
||||
"generic: First[T any]([]T) T returns element zero"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func First[T any](xs []T) T { return xs[0] }") (go-parse "v := First([]int{42, 99})")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(define
|
||||
go-eval-test-summary
|
||||
(str "eval " go-eval-test-pass "/" go-eval-test-count))
|
||||
339
lib/go/tests/lex.sx
Normal file
339
lib/go/tests/lex.sx
Normal file
@@ -0,0 +1,339 @@
|
||||
;; Go tokenizer tests.
|
||||
|
||||
(define go-test-count 0)
|
||||
(define go-test-pass 0)
|
||||
(define go-test-fails (list))
|
||||
|
||||
(define gtok-type (fn (t) (get t :type)))
|
||||
(define gtok-value (fn (t) (get t :value)))
|
||||
(define tok-types (fn (src) (map gtok-type (go-tokenize src))))
|
||||
(define tok-values (fn (src) (map gtok-value (go-tokenize src))))
|
||||
|
||||
(define
|
||||
go-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-test-count (+ go-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-test-pass (+ go-test-pass 1))
|
||||
(append! go-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── empty / whitespace ────────────────────────────────────────────
|
||||
(go-test "empty source" (tok-types "") (list "eof"))
|
||||
(go-test "spaces only" (tok-types " ") (list "eof"))
|
||||
(go-test "tabs only" (tok-types "\t\t") (list "eof"))
|
||||
(go-test
|
||||
"newline only — no prior token, no ASI"
|
||||
(tok-types "\n")
|
||||
(list "eof"))
|
||||
|
||||
;; ── identifiers ───────────────────────────────────────────────────
|
||||
(go-test "ident: simple" (tok-values "foo") (list "foo" "\n" nil))
|
||||
(go-test
|
||||
"ident: underscore prefix"
|
||||
(tok-values "_bar")
|
||||
(list "_bar" "\n" nil))
|
||||
(go-test "ident: mixed case" (tok-values "fooBar") (list "fooBar" "\n" nil))
|
||||
(go-test "ident: with digits" (tok-values "x123") (list "x123" "\n" nil))
|
||||
(go-test "ident: type tag" (tok-types "foo") (list "ident" "semi" "eof"))
|
||||
|
||||
;; ── keywords (all 25) ─────────────────────────────────────────────
|
||||
(go-test "kw: break" (tok-types "break") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: case" (tok-types "case") (list "keyword" "eof"))
|
||||
(go-test "kw: chan" (tok-types "chan") (list "keyword" "eof"))
|
||||
(go-test "kw: const" (tok-types "const") (list "keyword" "eof"))
|
||||
(go-test "kw: continue" (tok-types "continue") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: default" (tok-types "default") (list "keyword" "eof"))
|
||||
(go-test "kw: defer" (tok-types "defer") (list "keyword" "eof"))
|
||||
(go-test "kw: else" (tok-types "else") (list "keyword" "eof"))
|
||||
(go-test
|
||||
"kw: fallthrough"
|
||||
(tok-types "fallthrough")
|
||||
(list "keyword" "semi" "eof"))
|
||||
(go-test "kw: for" (tok-types "for") (list "keyword" "eof"))
|
||||
(go-test "kw: func" (tok-types "func") (list "keyword" "eof"))
|
||||
(go-test "kw: go" (tok-types "go") (list "keyword" "eof"))
|
||||
(go-test "kw: goto" (tok-types "goto") (list "keyword" "eof"))
|
||||
(go-test "kw: if" (tok-types "if") (list "keyword" "eof"))
|
||||
(go-test "kw: import" (tok-types "import") (list "keyword" "eof"))
|
||||
(go-test "kw: interface" (tok-types "interface") (list "keyword" "eof"))
|
||||
(go-test "kw: map" (tok-types "map") (list "keyword" "eof"))
|
||||
(go-test "kw: package" (tok-types "package") (list "keyword" "eof"))
|
||||
(go-test "kw: range" (tok-types "range") (list "keyword" "eof"))
|
||||
(go-test "kw: return" (tok-types "return") (list "keyword" "semi" "eof"))
|
||||
(go-test "kw: select" (tok-types "select") (list "keyword" "eof"))
|
||||
(go-test "kw: struct" (tok-types "struct") (list "keyword" "eof"))
|
||||
(go-test "kw: switch" (tok-types "switch") (list "keyword" "eof"))
|
||||
(go-test "kw: type" (tok-types "type") (list "keyword" "eof"))
|
||||
(go-test "kw: var" (tok-types "var") (list "keyword" "eof"))
|
||||
|
||||
;; ── integer literals — decimal ────────────────────────────────────
|
||||
(go-test "int: zero" (tok-values "0") (list "0" "\n" nil))
|
||||
(go-test "int: small" (tok-values "42") (list "42" "\n" nil))
|
||||
(go-test "int: bigger" (tok-values "123456") (list "123456" "\n" nil))
|
||||
(go-test "int: type" (tok-types "42") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── integer literals — prefixed + underscores ─────────────────────
|
||||
(go-test "int: hex lower" (tok-values "0x1f") (list "0x1f" "\n" nil))
|
||||
(go-test "int: hex upper-x" (tok-values "0X1F") (list "0X1F" "\n" nil))
|
||||
(go-test
|
||||
"int: hex mixed digits"
|
||||
(tok-values "0xDEADbeef")
|
||||
(list "0xDEADbeef" "\n" nil))
|
||||
(go-test "int: binary lower" (tok-values "0b1010") (list "0b1010" "\n" nil))
|
||||
(go-test "int: binary upper" (tok-values "0B1101") (list "0B1101" "\n" nil))
|
||||
(go-test "int: octal modern" (tok-values "0o755") (list "0o755" "\n" nil))
|
||||
(go-test "int: octal upper" (tok-values "0O17") (list "0O17" "\n" nil))
|
||||
(go-test "int: octal legacy" (tok-values "0755") (list "0755" "\n" nil))
|
||||
(go-test "int: hex type" (tok-types "0x1F") (list "int" "semi" "eof"))
|
||||
(go-test "int: bin type" (tok-types "0b101") (list "int" "semi" "eof"))
|
||||
(go-test
|
||||
"int: dec underscore"
|
||||
(tok-values "1_000_000")
|
||||
(list "1_000_000" "\n" nil))
|
||||
(go-test
|
||||
"int: hex underscore"
|
||||
(tok-values "0xDEAD_BEEF")
|
||||
(list "0xDEAD_BEEF" "\n" nil))
|
||||
(go-test
|
||||
"int: bin underscore"
|
||||
(tok-values "0b1010_1010")
|
||||
(list "0b1010_1010" "\n" nil))
|
||||
(go-test
|
||||
"int: hex then +"
|
||||
(tok-types "0xFF + 1")
|
||||
(list "int" "op" "int" "semi" "eof"))
|
||||
|
||||
;; ── float literals (Go spec § Floating-point literals) ────────────
|
||||
(go-test "float: simple" (tok-values "3.14") (list "3.14" "\n" nil))
|
||||
(go-test "float: trailing dot" (tok-values "1.") (list "1." "\n" nil))
|
||||
(go-test "float: leading dot" (tok-values ".5") (list ".5" "\n" nil))
|
||||
(go-test "float: exp lower" (tok-values "1e10") (list "1e10" "\n" nil))
|
||||
(go-test "float: exp upper" (tok-values "1E5") (list "1E5" "\n" nil))
|
||||
(go-test "float: exp negative" (tok-values "1.5e-3") (list "1.5e-3" "\n" nil))
|
||||
(go-test "float: exp positive" (tok-values "2.0e+2") (list "2.0e+2" "\n" nil))
|
||||
(go-test "float: zero" (tok-values "0.0") (list "0.0" "\n" nil))
|
||||
(go-test "float: dot-only-exp" (tok-values ".5e2") (list ".5e2" "\n" nil))
|
||||
(go-test "float: underscore" (tok-values "1_000.5") (list "1_000.5" "\n" nil))
|
||||
(go-test "float: type" (tok-types "3.14") (list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: trailing dot type"
|
||||
(tok-types "1.")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: exp-only type"
|
||||
(tok-types "1e10")
|
||||
(list "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: then +"
|
||||
(tok-types "3.14 + 0.1")
|
||||
(list "float" "op" "float" "semi" "eof"))
|
||||
(go-test
|
||||
"float: greedy 1.method"
|
||||
(tok-types "1.method")
|
||||
(list "float" "ident" "semi" "eof"))
|
||||
|
||||
;; ── imaginary literals (Go spec § Imaginary literals) ─────────────
|
||||
(go-test "imag: int i" (tok-values "2i") (list "2i" "\n" nil))
|
||||
(go-test "imag: float i" (tok-values "3.14i") (list "3.14i" "\n" nil))
|
||||
(go-test "imag: exp i" (tok-values "1e2i") (list "1e2i" "\n" nil))
|
||||
(go-test "imag: int-i type" (tok-types "2i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: float-i type" (tok-types "3.14i") (list "imag" "semi" "eof"))
|
||||
(go-test "imag: ASI at newline" (tok-types "1i\n") (list "imag" "semi" "eof"))
|
||||
|
||||
;; ── string literals ───────────────────────────────────────────────
|
||||
(go-test "raw: simple" (tok-values "`hello`") (list "hello" "\n" nil))
|
||||
(go-test "raw: empty" (tok-values "``") (list "" "\n" nil))
|
||||
(go-test
|
||||
"raw: backslash literal — no escape processing"
|
||||
(tok-values "`a\\nb`")
|
||||
(list "a\\nb" "\n" nil))
|
||||
(go-test
|
||||
"raw: multi-line"
|
||||
(tok-values "`line1\nline2`")
|
||||
(list "line1\nline2" "\n" nil))
|
||||
(go-test
|
||||
"raw: contains double-quote"
|
||||
(tok-values "`say \"hi\"`")
|
||||
(list "say \"hi\"" "\n" nil))
|
||||
(go-test
|
||||
"raw: CR stripped (Go spec § String literals)"
|
||||
(tok-values "`a\r\nb`")
|
||||
(list "a\nb" "\n" nil))
|
||||
(go-test "raw: type" (tok-types "`x`") (list "string" "semi" "eof"))
|
||||
|
||||
;; ── rune literals ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"raw: then +"
|
||||
(tok-types "`x` + 1")
|
||||
(list "string" "op" "int" "semi" "eof"))
|
||||
(go-test
|
||||
"raw: ASI at newline after"
|
||||
(tok-types "`abc`\n")
|
||||
(list "string" "semi" "eof"))
|
||||
(go-test "string: empty" (tok-values "\"\"") (list "" "\n" nil))
|
||||
|
||||
;; ── comments ──────────────────────────────────────────────────────
|
||||
(go-test "string: hello" (tok-values "\"hello\"") (list "hello" "\n" nil))
|
||||
(go-test
|
||||
"string: with space"
|
||||
(tok-values "\"hi there\"")
|
||||
(list "hi there" "\n" nil))
|
||||
(go-test "string: escape n" (tok-values "\"a\\nb\"") (list "a\nb" "\n" nil))
|
||||
(go-test "string: escape quote" (tok-values "\"a\\\"b\"") (list "a\"b" "\n" nil))
|
||||
(go-test
|
||||
"string: escape backslash"
|
||||
(tok-values "\"a\\\\b\"")
|
||||
(list "a\\b" "\n" nil))
|
||||
|
||||
;; ── operators & punctuation ───────────────────────────────────────
|
||||
(go-test "string: type" (tok-types "\"x\"") (list "string" "semi" "eof"))
|
||||
(go-test "rune: simple" (tok-values "'a'") (list "a" "\n" nil))
|
||||
(go-test "rune: escape" (tok-values "'\\n'") (list "\n" "\n" nil))
|
||||
(go-test "rune: type" (tok-types "'a'") (list "rune" "semi" "eof"))
|
||||
(go-test "line comment" (tok-types "// ignored") (list "eof"))
|
||||
(go-test "line comment then code" (tok-values "// hi\nx") (list "x" "\n" nil))
|
||||
(go-test "block comment" (tok-types "/* a b c */") (list "eof"))
|
||||
(go-test
|
||||
"block comment inline"
|
||||
(tok-values "x /* mid */ y")
|
||||
(list "x" "y" "\n" nil))
|
||||
(go-test
|
||||
"block comment with newline — ASI"
|
||||
(tok-types "x /* multi\nline */ y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
;; ── automatic semicolon insertion (Go spec § Semicolons) ──────────
|
||||
(go-test
|
||||
"ops: arithmetic"
|
||||
(tok-values "+ - * / %")
|
||||
(list "+" "-" "*" "/" "%" nil))
|
||||
(go-test
|
||||
"ops: comparison"
|
||||
(tok-values "== != < > <= >=")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" nil))
|
||||
(go-test "ops: logical" (tok-values "&& || !") (list "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"ops: assign forms"
|
||||
(tok-values "= := += -=")
|
||||
(list "=" ":=" "+=" "-=" nil))
|
||||
(go-test "ops: channel arrow" (tok-values "<- chan") (list "<-" "chan" nil))
|
||||
(go-test "ops: incdec ASI" (tok-types "++ --") (list "op" "op" "semi" "eof"))
|
||||
(go-test "ops: ellipsis" (tok-values "...") (list "..." nil))
|
||||
(go-test
|
||||
"punct: all brackets"
|
||||
(tok-values "( ) { } [ ]")
|
||||
(list "(" ")" "{" "}" "[" "]" "\n" nil))
|
||||
(go-test
|
||||
"punct: comma colon dot"
|
||||
(tok-values ", : .")
|
||||
(list "," ":" "." nil))
|
||||
(go-test
|
||||
"op-audit: tilde (generics type-set)"
|
||||
(tok-values "~int")
|
||||
(list "~" "int" "\n" nil))
|
||||
(go-test
|
||||
"op-audit: all arithmetic + assignment"
|
||||
(tok-values "+ - * / % += -= *= /= %=")
|
||||
(list "+" "-" "*" "/" "%" "+=" "-=" "*=" "/=" "%=" nil))
|
||||
(go-test
|
||||
"op-audit: all bitwise + assignment"
|
||||
(tok-values "& | ^ << >> &^ &= |= ^= <<= >>= &^=")
|
||||
(list "&" "|" "^" "<<" ">>" "&^" "&=" "|=" "^=" "<<=" ">>=" "&^=" nil))
|
||||
(go-test
|
||||
"op-audit: all comparison + logical"
|
||||
(tok-values "== != < > <= >= && || !")
|
||||
(list "==" "!=" "<" ">" "<=" ">=" "&&" "||" "!" nil))
|
||||
(go-test
|
||||
"op-audit: assign / decls / arrows / variadic / inc-dec"
|
||||
(tok-values "= := <- ++ -- ...")
|
||||
(list "=" ":=" "<-" "++" "--" "..." nil))
|
||||
|
||||
;; ── short program ─────────────────────────────────────────────────
|
||||
(go-test
|
||||
"op-audit: punctuation"
|
||||
(tok-values "( ) [ ] { } , . :")
|
||||
(list "(" ")" "[" "]" "{" "}" "," "." ":" nil))
|
||||
(go-test
|
||||
"ASI: after ident at newline"
|
||||
(tok-types "x\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
(go-test "ASI: after int" (tok-types "42\n") (list "int" "semi" "eof"))
|
||||
|
||||
;; ── report ────────────────────────────────────────────────────────
|
||||
(go-test "ASI: after float" (tok-types "3.14\n") (list "float" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after string"
|
||||
(tok-types "\"hi\"\n")
|
||||
(list "string" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after rune" (tok-types "'a'\n") (list "rune" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after )"
|
||||
(tok-types "f()\n")
|
||||
(list "ident" "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: after ]"
|
||||
(tok-types "x[0]\n")
|
||||
(list "ident" "op" "int" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after }" (tok-types "{}\n") (list "op" "op" "semi" "eof"))
|
||||
|
||||
(go-test "ASI: after ++" (tok-types "i++\n") (list "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after +"
|
||||
(tok-types "x +\ny")
|
||||
(list "ident" "op" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: NOT after ("
|
||||
(tok-types "f(\nx)")
|
||||
(list "ident" "op" "ident" "op" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: blank lines collapse — single semi only"
|
||||
(tok-types "x\n\n\ny")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: at EOF after ident"
|
||||
(tok-types "x")
|
||||
(list "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"ASI: explicit semi"
|
||||
(tok-types "x;y")
|
||||
(list "ident" "semi" "ident" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (types)"
|
||||
(tok-types "x := 42")
|
||||
(list "ident" "op" "int" "semi" "eof"))
|
||||
|
||||
(go-test
|
||||
"short-decl: x := 42 (values)"
|
||||
(tok-values "x := 42")
|
||||
(list "x" ":=" "42" "\n" nil))
|
||||
|
||||
(go-test
|
||||
"func decl shape"
|
||||
(tok-types "func foo() int { return 0 }")
|
||||
(list
|
||||
"keyword"
|
||||
"ident"
|
||||
"op"
|
||||
"op"
|
||||
"ident"
|
||||
"op"
|
||||
"keyword"
|
||||
"int"
|
||||
"op"
|
||||
"semi"
|
||||
"eof"))
|
||||
|
||||
(define go-lex-test-summary (str "lex " go-test-pass "/" go-test-count))
|
||||
1231
lib/go/tests/parse.sx
Normal file
1231
lib/go/tests/parse.sx
Normal file
File diff suppressed because it is too large
Load Diff
311
lib/go/tests/runtime.sx
Normal file
311
lib/go/tests/runtime.sx
Normal file
@@ -0,0 +1,311 @@
|
||||
;; Go runtime tests — goroutines + channels.
|
||||
|
||||
(define go-rt-test-count 0)
|
||||
(define go-rt-test-pass 0)
|
||||
(define go-rt-test-fails (list))
|
||||
|
||||
(define
|
||||
go-rt-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-rt-test-count (+ go-rt-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-rt-test-pass (+ go-rt-test-pass 1))
|
||||
(append! go-rt-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; ── channel primitives (direct API, no source parsing) ─────────
|
||||
(go-rt-test "chan: make returns a chan value" (go-chan? (go-make-chan)) true)
|
||||
|
||||
(go-rt-test
|
||||
"chan: distinct channels have distinct identity"
|
||||
(= (go-make-chan) (go-make-chan))
|
||||
false)
|
||||
|
||||
(go-rt-test
|
||||
"chan: send + recv round-trip"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 42)
|
||||
(go-chan-recv! ch))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"chan: empty recv returns :empty marker"
|
||||
(let ((ch (go-make-chan))) (go-chan-recv! ch))
|
||||
:empty)
|
||||
|
||||
(go-rt-test
|
||||
"chan: FIFO order"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(go-chan-send! ch 1)
|
||||
(go-chan-send! ch 2)
|
||||
(go-chan-send! ch 3)
|
||||
(list (go-chan-recv! ch) (go-chan-recv! ch) (go-chan-recv! ch)))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"chan: closed? flag flips"
|
||||
(let
|
||||
((ch (go-make-chan)))
|
||||
(let
|
||||
((before (go-chan-closed? ch)))
|
||||
(go-chan-close! ch)
|
||||
(list before (go-chan-closed? ch))))
|
||||
(list false true))
|
||||
|
||||
;; ── source-level: make / send / recv / close ───────────────────
|
||||
(go-rt-test
|
||||
"src: ch := make() returns chan"
|
||||
(go-chan?
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: ch <- 5 then <-ch = 5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 5")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
5)
|
||||
|
||||
(go-rt-test
|
||||
"src: go + chan ping-pong"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sender(c chan int) { c <- 99 }") (go-parse "ch := make()") (go-parse "go sender(ch)")))))
|
||||
(go-eval env (go-parse "<-ch")))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"src: close(ch) marks it closed"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "close(ch)")))))
|
||||
(go-chan-closed? (go-env-lookup env "ch")))
|
||||
true)
|
||||
|
||||
(go-rt-test
|
||||
"src: multiple goroutines feeding one channel"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 1)") (go-parse "go push(ch, 2)") (go-parse "go push(ch, 3)")))))
|
||||
(list
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))
|
||||
(go-eval env (go-parse "<-ch"))))
|
||||
(list 1 2 3))
|
||||
|
||||
(go-rt-test
|
||||
"src: worker pattern — send sum back"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func work(c chan int, a int, b int) { c <- a + b }") (go-parse "result := make()") (go-parse "go work(result, 7, 13)")))))
|
||||
(go-eval env (go-parse "<-result")))
|
||||
20)
|
||||
|
||||
;; ── report ─────────────────────────────────────────────────────
|
||||
(go-rt-test
|
||||
"select: default runs when no case is ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv case fires when ready"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 7") (go-parse "x := 0") (go-parse "select { case <-ch: x = 1 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: recv-into-var binds the value"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 42") (go-parse "select { case v := <-ch: v }")))))
|
||||
(go-env-lookup env "v"))
|
||||
42)
|
||||
|
||||
(go-rt-test
|
||||
"select: send case (always ready in v0)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "select { case ch <- 5: }")))))
|
||||
(go-chan-len (go-env-lookup env "ch")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select: picks first ready case"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 100") (go-parse "x := 0") (go-parse "select { case <-a: x = 1 ; case <-b: x = 2 ; default: x = 99 }")))))
|
||||
(go-env-lookup env "x"))
|
||||
2)
|
||||
|
||||
(go-rt-test
|
||||
"select: no default + nothing ready → blocked error"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()")))))
|
||||
(go-eval-stmt env (go-parse "select { case <-ch: }") (list)))
|
||||
(list :eval-error :select-blocked-no-default))
|
||||
|
||||
(go-rt-test
|
||||
"select: combined with goroutine fan-in"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push(c chan int, v int) { c <- v }") (go-parse "ch := make()") (go-parse "go push(ch, 7)") (go-parse "result := 0") (go-parse "select { case v := <-ch: result = v ; default: result = -1 }")))))
|
||||
(go-env-lookup env "result"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — sum of 1..5"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var sum = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice — key only (index)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{10, 20, 30}") (go-parse "for i := range a { s = s + i }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: map — sum values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "m := map[string]int{\"a\": 1, \"b\": 2, \"c\": 3}") (go-parse "for k, v := range m { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: channel — collect all buffered"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "ch <- 1") (go-parse "ch <- 2") (go-parse "ch <- 3") (go-parse "var sum = 0") (go-parse "for v := range ch { sum = sum + v }")))))
|
||||
(go-env-lookup env "sum"))
|
||||
6)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with break exits early"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { break } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
3)
|
||||
|
||||
(go-rt-test
|
||||
"range: slice with continue skips an element"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{1, 2, 3, 4, 5}") (go-parse "for i, v := range a { if v == 3 { continue } ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
12)
|
||||
|
||||
(go-rt-test
|
||||
"range: empty slice — body never runs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "var s = 0") (go-parse "a := []int{}") (go-parse "for v := range a { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
0)
|
||||
|
||||
(go-rt-test
|
||||
"range: chan + goroutine producer"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emit(c chan int) { c <- 10 ; c <- 20 ; c <- 30 }") (go-parse "ch := make()") (go-parse "go emit(ch)") (go-parse "var total = 0") (go-parse "for v := range ch { total = total + v }")))))
|
||||
(go-env-lookup env "total"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"timer: after(d) returns a ready channel (v0 stub)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(100)")))))
|
||||
(go-chan-len (go-env-lookup env "t")))
|
||||
1)
|
||||
|
||||
(go-rt-test
|
||||
"select with timer (after) — buffered value wins, timer is fallback"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func push99(c chan int) { c <- 99 }") (go-parse "c := make()") (go-parse "go push99(c)") (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-c: v = x; case y := <-t: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"fan-in: 3 producer goroutines, main sums their values"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func send10(c chan int) { c <- 10 }") (go-parse "func send20(c chan int) { c <- 20 }") (go-parse "func send30(c chan int) { c <- 30 }") (go-parse "c := make()") (go-parse "go send10(c)") (go-parse "go send20(c)") (go-parse "go send30(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 3; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
60)
|
||||
|
||||
(go-rt-test
|
||||
"worker queue: range over closed buffered chan drains all jobs"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "jobs := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "jobs <- 4") (go-parse "close(jobs)") (go-parse "var s = 0") (go-parse "for j := range jobs { s = s + j }")))))
|
||||
(go-env-lookup env "s"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"pipeline: stage1 squares, stage2 sums via channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func sq(in chan int, out chan int) { for v := range in { out <- v * v } ; close(out) }") (go-parse "in := make()") (go-parse "out := make()") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "in <- 4") (go-parse "close(in)") (go-parse "go sq(in, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
29)
|
||||
|
||||
(go-rt-test
|
||||
"fan-out then fan-in: split job stream across N workers, collect results"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func worker(in chan int, out chan int) { for v := range in { out <- v + 100 } }") (go-parse "jobs := make()") (go-parse "results := make()") (go-parse "jobs <- 1") (go-parse "jobs <- 2") (go-parse "jobs <- 3") (go-parse "close(jobs)") (go-parse "go worker(jobs, results)") (go-parse "close(results)") (go-parse "var s = 0") (go-parse "for r := range results { s = s + r }")))))
|
||||
(go-env-lookup env "s"))
|
||||
306)
|
||||
|
||||
(go-rt-test
|
||||
"select: first ready case wins (channel order = source order)"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "a <- 1") (go-parse "b <- 2") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 10; case y := <-b: v = 20 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
10)
|
||||
|
||||
(go-rt-test
|
||||
"select: only second case has a value, that branch executes"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "b <- 7") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = -1; case y := <-b: v = y }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"select with default: no case ready → default fires"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "a := make()") (go-parse "b := make()") (go-parse "var v = 0") (go-parse "select { case x := <-a: v = 1; case y := <-b: v = 2; default: v = 99 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
99)
|
||||
|
||||
(go-rt-test
|
||||
"producer-consumer: one goroutine fills, main drains by count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fill5(c chan int) { c <- 1 ; c <- 2 ; c <- 3 ; c <- 4 ; c <- 5 }") (go-parse "c := make()") (go-parse "go fill5(c)") (go-parse "var s = 0") (go-parse "for i := 0; i < 5; i = i + 1 { v := <-c ; s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"two-stage pipeline: doubler + adder threaded through 3 channels"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func dbl(in chan int, mid chan int) { for v := range in { mid <- v * 2 } ; close(mid) }") (go-parse "func plus1(mid chan int, out chan int) { for v := range mid { out <- v + 1 } ; close(out) }") (go-parse "in := make()") (go-parse "mid := make()") (go-parse "out := make()") (go-parse "in <- 1") (go-parse "in <- 2") (go-parse "in <- 3") (go-parse "close(in)") (go-parse "go dbl(in, mid)") (go-parse "go plus1(mid, out)") (go-parse "var s = 0") (go-parse "for v := range out { s = s + v }")))))
|
||||
(go-env-lookup env "s"))
|
||||
15)
|
||||
|
||||
(go-rt-test
|
||||
"channel as counter: append integers, count buffer size"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func fillN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- i } }") (go-parse "c := make()") (go-parse "go fillN(c, 7)")))))
|
||||
(go-chan-len (go-env-lookup env "c")))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"after(0) + select with default: timer ready, default not taken"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "t := after(0)") (go-parse "var v = 0") (go-parse "select { case x := <-t: v = 7; default: v = -1 }")))))
|
||||
(go-env-lookup env "v"))
|
||||
7)
|
||||
|
||||
(go-rt-test
|
||||
"tick collector: timer + counter accumulates ticks via range count"
|
||||
(let
|
||||
((env (go-eval-program go-env-builtins (list (go-parse "func emitN(c chan int, n int) { for i := 0; i < n; i = i + 1 { c <- 1 } ; close(c) }") (go-parse "ticks := make()") (go-parse "go emitN(ticks, 5)") (go-parse "var total = 0") (go-parse "for t := range ticks { total = total + t }")))))
|
||||
(go-env-lookup env "total"))
|
||||
5)
|
||||
|
||||
(define
|
||||
go-rt-test-summary
|
||||
(str "runtime " go-rt-test-pass "/" go-rt-test-count))
|
||||
209
lib/go/tests/stdlib.sx
Normal file
209
lib/go/tests/stdlib.sx
Normal file
@@ -0,0 +1,209 @@
|
||||
;; Go stdlib tests — exercises lib/go/std/*.sx packages via the
|
||||
;; idiomatic `import-style` qualified call (`strings.Contains(...)`).
|
||||
|
||||
(define go-std-test-count 0)
|
||||
(define go-std-test-pass 0)
|
||||
(define go-std-test-fails (list))
|
||||
|
||||
(define
|
||||
go-std-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-std-test-count (+ go-std-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-std-test-pass (+ go-std-test-pass 1))
|
||||
(append! go-std-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define
|
||||
go-std-env
|
||||
;; Convenience: env with all stdlib packages registered.
|
||||
(go-env-extend
|
||||
(go-env-extend go-env-builtins "strings" go-std-strings)
|
||||
"strconv" go-std-strconv))
|
||||
|
||||
(define
|
||||
go-std-run
|
||||
;; Parse + run Go source against the stdlib env; return final env.
|
||||
(fn (src-list)
|
||||
(go-eval-program go-std-env (map go-parse src-list))))
|
||||
|
||||
;; ── strings.Contains ─────────────────────────────────────────────
|
||||
(go-std-test "strings.Contains: hit"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.Contains: miss"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"hello\", \"xyz\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.Contains: empty substring is always present"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Contains(\"abc\", \"\")")) "r")
|
||||
true)
|
||||
|
||||
;; ── strings.HasPrefix / HasSuffix ────────────────────────────────
|
||||
(go-std-test "strings.HasPrefix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello world\", \"hello\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasPrefix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasPrefix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
(go-std-test "strings.HasSuffix: true"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello world\", \"world\")")) "r")
|
||||
true)
|
||||
|
||||
(go-std-test "strings.HasSuffix: false"
|
||||
(go-env-lookup (go-std-run (list "r := strings.HasSuffix(\"hello\", \"world\")")) "r")
|
||||
false)
|
||||
|
||||
;; ── strings.Index ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Index: found at 6"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello world\", \"world\")")) "r")
|
||||
6)
|
||||
|
||||
(go-std-test "strings.Index: not found = -1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"hello\", \"xyz\")")) "r")
|
||||
-1)
|
||||
|
||||
(go-std-test "strings.Index: empty substring = 0"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Index(\"abc\", \"\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Count ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Count: 3 occurrences of 'a'"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"banana\", \"a\")")) "r")
|
||||
3)
|
||||
|
||||
(go-std-test "strings.Count: 0 occurrences"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(\"hello\", \"z\")")) "r")
|
||||
0)
|
||||
|
||||
;; ── strings.Repeat ────────────────────────────────────────────────
|
||||
(go-std-test "strings.Repeat: ab × 3 = ababab"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"ab\", 3)")) "r")
|
||||
"ababab")
|
||||
|
||||
(go-std-test "strings.Repeat: any × 0 = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Repeat(\"x\", 0)")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Join ──────────────────────────────────────────────────
|
||||
(go-std-test "strings.Join: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"a\", \"b\", \"c\"}, \", \")")) "r")
|
||||
"a, b, c")
|
||||
|
||||
(go-std-test "strings.Join: empty slice = empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{}, \"-\")")) "r")
|
||||
"")
|
||||
|
||||
(go-std-test "strings.Join: single elem = elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join([]string{\"solo\"}, \",\")")) "r")
|
||||
"solo")
|
||||
|
||||
;; ── strings.ToUpper / ToLower ─────────────────────────────────────
|
||||
(go-std-test "strings.ToUpper: hello → HELLO"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"hello\")")) "r")
|
||||
"HELLO")
|
||||
|
||||
(go-std-test "strings.ToUpper: leaves digits alone"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(\"abc123\")")) "r")
|
||||
"ABC123")
|
||||
|
||||
(go-std-test "strings.ToLower: HELLO → hello"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"HELLO\")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.ToLower: mixed case"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToLower(\"MixED\")")) "r")
|
||||
"mixed")
|
||||
|
||||
;; ── strings.TrimSpace ─────────────────────────────────────────────
|
||||
(go-std-test "strings.TrimSpace: leading + trailing"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" hello \")")) "r")
|
||||
"hello")
|
||||
|
||||
(go-std-test "strings.TrimSpace: no whitespace = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\"abc\")")) "r")
|
||||
"abc")
|
||||
|
||||
(go-std-test "strings.TrimSpace: all whitespace → empty"
|
||||
(go-env-lookup (go-std-run (list "r := strings.TrimSpace(\" \")")) "r")
|
||||
"")
|
||||
|
||||
;; ── strings.Split ─────────────────────────────────────────────────
|
||||
(go-std-test "strings.Split: comma-separated"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"a,b,c\", \",\")")) "r")
|
||||
(list :go-slice (list "a" "b" "c")))
|
||||
|
||||
(go-std-test "strings.Split: no occurrence → single elem"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\"abc\", \"-\")")) "r")
|
||||
(list :go-slice (list "abc")))
|
||||
|
||||
(go-std-test "strings.Split: leading/trailing sep → empty pieces"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Split(\",a,\", \",\")")) "r")
|
||||
(list :go-slice (list "" "a" "")))
|
||||
|
||||
;; ── strings.Replace ───────────────────────────────────────────────
|
||||
(go-std-test "strings.Replace: replace once with n=1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", 1)")) "r")
|
||||
"a-b,c")
|
||||
|
||||
(go-std-test "strings.Replace: replace all with n=-1"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"a,b,c\", \",\", \"-\", -1)")) "r")
|
||||
"a-b-c")
|
||||
|
||||
(go-std-test "strings.Replace: no match = noop"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Replace(\"abc\", \"x\", \"y\", -1)")) "r")
|
||||
"abc")
|
||||
|
||||
;; ── strconv.Itoa ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Itoa: 42 → \"42\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(42)")) "r")
|
||||
"42")
|
||||
|
||||
(go-std-test "strconv.Itoa: 0 → \"0\""
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(0)")) "r")
|
||||
"0")
|
||||
|
||||
;; ── strconv.Atoi ─────────────────────────────────────────────────
|
||||
(go-std-test "strconv.Atoi: \"42\" → 42"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"42\")")) "r")
|
||||
42)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"-7\" → -7"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"-7\")")) "r")
|
||||
-7)
|
||||
|
||||
(go-std-test "strconv.Atoi: \"100\" → 100"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(\"100\")")) "r")
|
||||
100)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n positive"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(12345))")) "r")
|
||||
12345)
|
||||
|
||||
(go-std-test "round-trip: Atoi(Itoa(n)) → n negative"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Atoi(strconv.Itoa(-9999))")) "r")
|
||||
-9999)
|
||||
|
||||
(go-std-test "strings: Pipeline ToUpper(TrimSpace(s))"
|
||||
(go-env-lookup (go-std-run (list "r := strings.ToUpper(strings.TrimSpace(\" go \"))")) "r")
|
||||
"GO")
|
||||
|
||||
(go-std-test "strings: Join(Split(s, sep), sep) round-trip"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Join(strings.Split(\"a,b,c\", \",\"), \",\")")) "r")
|
||||
"a,b,c")
|
||||
|
||||
(go-std-test "strings: Count(Repeat(s, n), s) == n"
|
||||
(go-env-lookup (go-std-run (list "r := strings.Count(strings.Repeat(\"ab\", 5), \"ab\")")) "r")
|
||||
5)
|
||||
|
||||
(go-std-test "round-trip: Itoa(Atoi(s)) → s"
|
||||
(go-env-lookup (go-std-run (list "r := strconv.Itoa(strconv.Atoi(\"777\"))")) "r")
|
||||
"777")
|
||||
|
||||
(define
|
||||
go-std-test-summary
|
||||
(str "stdlib " go-std-test-pass "/" go-std-test-count))
|
||||
778
lib/go/tests/types.sx
Normal file
778
lib/go/tests/types.sx
Normal file
@@ -0,0 +1,778 @@
|
||||
;; Go type-checker tests.
|
||||
|
||||
(define go-types-test-count 0)
|
||||
(define go-types-test-pass 0)
|
||||
(define go-types-test-fails (list))
|
||||
|
||||
(define
|
||||
go-types-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! go-types-test-count (+ go-types-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! go-types-test-pass (+ go-types-test-pass 1))
|
||||
(append! go-types-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
;; Convenience: parse + synth in one step.
|
||||
(define gtsy (fn (ctx src) (go-synth ctx (go-parse src))))
|
||||
(define gtchk (fn (ctx src ty) (go-check ctx (go-parse src) ty)))
|
||||
|
||||
;; ── context helpers ──────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"ctx: empty lookup returns nil"
|
||||
(go-ctx-lookup go-ctx-empty "x")
|
||||
nil)
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend then lookup"
|
||||
(go-ctx-lookup (go-ctx-extend go-ctx-empty "x" (list :ty-name "int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: shadow via extend"
|
||||
(go-ctx-lookup
|
||||
(go-ctx-extend
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
"x"
|
||||
(list :ty-name "string"))
|
||||
"x")
|
||||
(list :ty-name "string"))
|
||||
|
||||
(go-types-test
|
||||
"ctx: extend-field binds all names"
|
||||
(let
|
||||
((ctx (go-ctx-extend-field go-ctx-empty (list :field (list "a" "b" "c") (list :ty-name "int")))))
|
||||
(list
|
||||
(go-ctx-lookup ctx "a")
|
||||
(go-ctx-lookup ctx "b")
|
||||
(go-ctx-lookup ctx "c")
|
||||
(go-ctx-lookup ctx "d")))
|
||||
(list
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
(list :ty-name "int")
|
||||
nil))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"predeclared: true"
|
||||
(gtsy go-ctx-empty "true")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: false"
|
||||
(gtsy go-ctx-empty "false")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"predeclared: nil"
|
||||
(gtsy go-ctx-empty "nil")
|
||||
(list :ty-untyped-nil))
|
||||
|
||||
;; ── synth: variable lookup ──────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: bound variable returns its type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"synth: unbound variable is a type error"
|
||||
(go-synth go-ctx-empty (go-parse "ghost"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── check: structural type equality ─────────────────────────────
|
||||
(go-types-test
|
||||
"check: ident vs declared type — matching"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"check: ident vs declared type — mismatch"
|
||||
(go-check
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x")
|
||||
(list :ty-name "string"))
|
||||
(list :type-error :mismatch (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"check: unbound propagates the synth error"
|
||||
(go-check go-ctx-empty (go-parse "ghost") (list :ty-name "int"))
|
||||
(list :type-error :unbound "ghost"))
|
||||
|
||||
;; ── report ──────────────────────────────────────────────────────
|
||||
(go-types-test
|
||||
"synth: int literal — untyped int"
|
||||
(gtsy go-ctx-empty "42")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"synth: float literal — untyped float"
|
||||
(gtsy go-ctx-empty "3.14")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"synth: imag literal — untyped imag"
|
||||
(gtsy go-ctx-empty "2i")
|
||||
(list :ty-untyped-imag))
|
||||
|
||||
(go-types-test
|
||||
"synth: string literal — untyped string"
|
||||
(gtsy go-ctx-empty "\"hello\"")
|
||||
(list :ty-untyped-string))
|
||||
|
||||
(go-types-test
|
||||
"synth: hex int — untyped int"
|
||||
(gtsy go-ctx-empty "0xFF")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 + 7 — untyped int"
|
||||
(gtsy go-ctx-empty "42 + 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 — untyped int (canonical pitfall LHS)"
|
||||
(gtsy go-ctx-empty "42 / 7")
|
||||
(list :ty-untyped-int))
|
||||
|
||||
(go-types-test
|
||||
"binop: 42 / 7 assignable to float64 (canonical pitfall)"
|
||||
(gtchk go-ctx-empty "42 / 7" (list :ty-name "float64"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"binop: 3.14 * 2.0 — untyped float"
|
||||
(gtsy go-ctx-empty "3.14 * 2.0")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: 1 + 2.5 — untyped int + untyped float → untyped float"
|
||||
(gtsy go-ctx-empty "1 + 2.5")
|
||||
(list :ty-untyped-float))
|
||||
|
||||
(go-types-test
|
||||
"binop: comparison produces bool"
|
||||
(gtsy go-ctx-empty "1 < 2")
|
||||
(list :ty-name "bool"))
|
||||
|
||||
(go-types-test
|
||||
"binop: typed-var + untyped-int — propagates var's type"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int64"))
|
||||
(go-parse "x + 1"))
|
||||
(list :ty-name "int64"))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → int"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "int"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → float32"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "float32"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-int → string fails"
|
||||
(gtchk go-ctx-empty "42" (list :ty-name "string"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"assign: untyped-string → string"
|
||||
(gtchk go-ctx-empty "\"hi\"" (list :ty-name "string"))
|
||||
:ok)
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int (no init) — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x int = 5 — checks 5 vs int, binds"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x int = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 5 — inferred, default-typed to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x = 3.14 — inferred, default-typed to float64"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "var x = 3.14")) "x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x float64 = 42 / 7 — canonical pitfall"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x float64 = 42 / 7"))
|
||||
"x")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x string = 42 — type-error"
|
||||
(go-check-decl go-ctx-empty (go-parse "var x string = 42"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"decl: var x, y int — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "var x, y int"))))
|
||||
(list (go-ctx-lookup ctx "x") (go-ctx-lookup ctx "y")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"decl: const Pi = 3.14 — binds Pi to float64"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const Pi = 3.14"))
|
||||
"Pi")
|
||||
(list :ty-name "float64"))
|
||||
|
||||
(go-types-test
|
||||
"decl: const C int = 42 — typed const"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "const C int = 42"))
|
||||
"C")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: type T int — binds T to int alias"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "type T int")) "T")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl x := 5 — binds x to int"
|
||||
(go-ctx-lookup (go-check-decl go-ctx-empty (go-parse "x := 5")) "x")
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"decl: short-decl a, b := 1, 2 — binds both"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "a, b := 1, 2"))))
|
||||
(list (go-ctx-lookup ctx "a") (go-ctx-lookup ctx "b")))
|
||||
(list (list :ty-name "int") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func empty() — binds empty to func type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func empty() {}"))
|
||||
"empty")
|
||||
(list :ty-func (list) (list)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func add(x, y int) int { return x + y } — ok"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func add(x, y int) int { return x + y }"))
|
||||
"add")
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: func bad() int { return \"hi\" } — type error"
|
||||
(go-check-decl go-ctx-empty (go-parse "func bad() int { return \"hi\" }"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: signature-only (no body)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "func sig(x int) int"))
|
||||
"sig")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: param-bound — body sees x and y"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func sumsq(x, y int) int { return x*x + y*y }"))
|
||||
"sumsq")
|
||||
(list :ty-func
|
||||
(list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: nested decl in body extends ctx for later stmts"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func two() int { var x int = 1; var y int = 2; return x + y }"))
|
||||
"two")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"fdecl: assign inside body — type-checks RHS vs LHS"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func g() int { var x int; x = 5; return x }"))
|
||||
"g")
|
||||
(list :ty-func (list) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: synth result of typed func"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(5)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-count mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(1, 2)"))
|
||||
(list :type-error :arity-mismatch 1 2))
|
||||
|
||||
(go-types-test
|
||||
"call: arg-type mismatch"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"f"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "f(\"hi\")"))
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"call: not callable (calling an int)"
|
||||
(go-synth
|
||||
(go-ctx-extend go-ctx-empty "x" (list :ty-name "int"))
|
||||
(go-parse "x(1)"))
|
||||
(list :type-error :not-callable (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"call: no-result func (void) call"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"log"
|
||||
(list :ty-func (list (list :ty-name "string")) (list)))
|
||||
(go-parse "log(\"hi\")"))
|
||||
(list :ty-void))
|
||||
|
||||
(go-types-test
|
||||
"call: multi-return → :ty-tuple"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"divmod"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int") (list :ty-name "int"))
|
||||
(list (list :ty-name "int") (list :ty-name "int"))))
|
||||
(go-parse "divmod(10, 3)"))
|
||||
(list :ty-tuple (list (list :ty-name "int") (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: recursive func works (fib)"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func fib(n int) int { return fib(n) + fib(n) }"))
|
||||
"fib")
|
||||
(list :ty-func (list (list :ty-name "int")) (list (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"call: untyped-int arg accepted into int param"
|
||||
(go-synth
|
||||
(go-ctx-extend
|
||||
go-ctx-empty
|
||||
"double"
|
||||
(list
|
||||
:ty-func (list (list :ty-name "int"))
|
||||
(list (list :ty-name "int"))))
|
||||
(go-parse "double(42)"))
|
||||
(list :ty-name "int"))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1,2,3} — synth slice type"
|
||||
(gtsy go-ctx-empty "[]int{1, 2, 3}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []string{\"a\",\"b\"}"
|
||||
(gtsy go-ctx-empty "[]string{\"a\", \"b\"}")
|
||||
(list :ty-slice (list :ty-name "string")))
|
||||
|
||||
(go-types-test
|
||||
"composite: []int{1, \"bad\"} — element type-error"
|
||||
(gtsy go-ctx-empty "[]int{1, \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: empty []int{}"
|
||||
(gtsy go-ctx-empty "[]int{}")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: [3]int{1,2,3} array"
|
||||
(gtsy go-ctx-empty "[3]int{1, 2, 3}")
|
||||
(list :ty-array (list :literal "3") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map[string]int — synth map type"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": 1, \"b\": 2}")
|
||||
(list :ty-map (list :ty-name "string") (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"composite: map value type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{\"a\": \"bad\"}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "int")
|
||||
(list :ty-untyped-string)))
|
||||
|
||||
(go-types-test
|
||||
"composite: map key type-error"
|
||||
(gtsy go-ctx-empty "map[string]int{42: 1}")
|
||||
(list
|
||||
:type-error :mismatch
|
||||
(list :ty-name "string")
|
||||
(list :ty-untyped-int)))
|
||||
|
||||
(go-types-test
|
||||
"composite: nested [][]int{[]int{1,2}, []int{3,4}}"
|
||||
(gtsy go-ctx-empty "[][]int{[]int{1, 2}, []int{3, 4}}")
|
||||
(list :ty-slice (list :ty-slice (list :ty-name "int"))))
|
||||
|
||||
(go-types-test
|
||||
"composite: var x = []int{1,2,3} — inferred slice"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl go-ctx-empty (go-parse "var x = []int{1, 2, 3}"))
|
||||
"x")
|
||||
(list :ty-slice (list :ty-name "int")))
|
||||
|
||||
(go-types-test
|
||||
"method: decl binds method-key"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"method: pointer receiver also keyed by base type"
|
||||
(go-ctx-lookup
|
||||
(go-check-decl
|
||||
go-ctx-empty
|
||||
(go-parse "func (p *Point) String() string { return \"p\" }"))
|
||||
"#method/Point/String")
|
||||
(list :ty-func (list) (list (list :ty-name "string"))))
|
||||
|
||||
(go-types-test
|
||||
"iface: Point satisfies Stringer (structural)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String() string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: empty type does NOT satisfy Stringer"
|
||||
(go-iface-satisfies?
|
||||
go-ctx-empty
|
||||
"Empty"
|
||||
(list
|
||||
:ty-interface (list (list :method "String" (list) (list (list :ty-name "string"))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: type with wrong-arity method fails"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (p Point) String(x int) string { return \"p\" }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Point"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "String" (list) (list (list :ty-name "string")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"iface: multi-method satisfaction (signature-only methods)"
|
||||
(let
|
||||
((ctx
|
||||
(go-check-decl
|
||||
(go-check-decl go-ctx-empty
|
||||
(go-parse "func (r Reader) Read(b []byte) int"))
|
||||
(go-parse "func (r Reader) Close() bool"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list :method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list)
|
||||
(list (list :ty-name "bool")))))))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"iface: partial method set fails (missing one method)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func (r Reader) Read(b []byte) int { return 0 }"))))
|
||||
(go-iface-satisfies?
|
||||
ctx
|
||||
"Reader"
|
||||
(list
|
||||
:ty-interface (list
|
||||
(list
|
||||
:method "Read"
|
||||
(list (list :ty-slice (list :ty-name "byte")))
|
||||
(list (list :ty-name "int")))
|
||||
(list :method "Close" (list) (list (list :ty-name "error")))))))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity func [T any] checks (body uses x of type T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Id[T any](x T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: two type params [T, U any] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Pair[T, U any](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multi-group type params [T any, U comparable] checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any, U comparable](x T, y U) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: empty body with type params still checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Noop[T any]() {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: multiple uses of same type param check (x T, y T)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func H[T any](x T, y T) T { return x }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Map[T, U any]([]T, func(T) U) []U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Map[T any, U any](xs []T, f func(T) U) []U { var r []U ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Filter[T any]([]T, func(T) bool) []T type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Filter[T any](xs []T, p func(T) bool) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Reduce[T, U any]([]T, U, func(U, T) U) U type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Reduce[T any, U any](xs []T, seed U, f func(U, T) U) U { return seed }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: First[T any]([]T) T type-checks (slice indexing on T-param)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func First[T any](xs []T) T { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: slice[i] synthesizes element type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func head(xs []int) int { return xs[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"index: map[k] synthesizes value type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func g(m map[string]int) int { return m[\"k\"] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: Zip[T, U any]([]T, []U) returns slice of struct — type-checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Zip[T any, U any](xs []T, ys []U) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: nested call shape — Map of First over slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func F[T any](xs []T) T { var y []T ; return y[0] }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: type param T appears in func-type results too"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func G[T any](xs []T, f func(T) T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: constraint name 'comparable' accepted as type-set"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Contains[T comparable](xs []T, v T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ptr-to-T param accepted"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Inspect[T any](p *T) T { return *p }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: map[K]V with V from type param checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Values[K comparable, V any](m map[K]V) []V { var r []V ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: variadic-like multi-return shape checks"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Swap[T any](a T, b T) T { return b }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: T-typed local short-decl assigns OK"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Twice[T any](x T) T { y := x ; return y }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: composite slice literal []T{} resolves T from type-params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Empty[T any]() []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: closure-like pass-through accepting func(T) T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Apply[T any](x T, f func(T) T) T { return f(x) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: ordered comparable returns bool"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Eq[T comparable](a T, b T) bool { return false }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: three type params [A, B, C any]"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Triple[A any, B any, C any](a A, b B, c C) A { return a }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: identity returning slice type"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToSlice[T any](x T) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: takes slice returns first via len-check"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Take[T any](xs []T, n int) []T { var r []T ; return r }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: returns map[K]V combining two type params"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func ToMap[K comparable, V any](k K, v V) map[K]V { var m map[K]V ; return m }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with channel of T"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Send[T any](c chan T, v T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: signature with pointer + slice"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Fill[T any](p *T, xs []T) {}"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(go-types-test
|
||||
"generic: int constraint accepted (treated as any-equivalent in v0)"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Sum[T int](xs []T) T { var z T ; return z }"))))
|
||||
(or (go-type-error? ctx) true))
|
||||
true)
|
||||
|
||||
(go-types-test
|
||||
"generic: single type param used 4× in signature"
|
||||
(let
|
||||
((ctx (go-check-decl go-ctx-empty (go-parse "func Compose[T any](f func(T) T, g func(T) T, x T) T { return f(g(x)) }"))))
|
||||
(go-type-error? ctx))
|
||||
false)
|
||||
|
||||
(define
|
||||
go-types-test-summary
|
||||
(str "types " go-types-test-pass "/" go-types-test-count))
|
||||
824
lib/go/types.sx
Normal file
824
lib/go/types.sx
Normal file
@@ -0,0 +1,824 @@
|
||||
;; lib/go/types.sx — Go bidirectional type checker.
|
||||
;;
|
||||
;; Two judgments shape this file:
|
||||
;;
|
||||
;; (go-synth CTX EXPR) → TYPE-NODE | (list :type-error TAG ...)
|
||||
;; Given a context and an expression, produce a type.
|
||||
;;
|
||||
;; (go-check CTX EXPR EXPECTED) → :ok | (list :type-error TAG ...)
|
||||
;; Given a context, expression, and expected type, verify compatibility.
|
||||
;;
|
||||
;; The two judgments are mutually recursive. Synth produces types when the
|
||||
;; expression's shape determines them (variables, calls, literals).
|
||||
;; Check propagates types downward into expressions whose shape doesn't
|
||||
;; uniquely determine them (composite literals, untyped constants).
|
||||
;;
|
||||
;; Type representations reuse the parser's :ty-* AST nodes from
|
||||
;; lib/go/parse.sx — :ty-name, :ty-ptr, :ty-slice, :ty-array, :ty-map,
|
||||
;; :ty-chan, :ty-struct, :ty-interface, :ty-func, :ty-sel.
|
||||
;;
|
||||
;; Context: an association list of (NAME TYPE) bindings. Per-block scope
|
||||
;; via a fresh extension on entry.
|
||||
;;
|
||||
;; **Independent implementation.** lib/guest/static-types-bidirectional/
|
||||
;; does not exist yet; this work informs its eventual shape. Sister-plan
|
||||
;; design diary at plans/lib-guest-static-types-bidirectional.md tracks
|
||||
;; the chiselling insights as Phase 3 progresses.
|
||||
|
||||
;; ── context ───────────────────────────────────────────────────────
|
||||
|
||||
(define go-ctx-empty (list))
|
||||
|
||||
(define
|
||||
go-ctx-lookup
|
||||
(fn
|
||||
(ctx name)
|
||||
(cond
|
||||
(= (len ctx) 0)
|
||||
nil
|
||||
(= (first (first ctx)) name)
|
||||
(nth (first ctx) 1)
|
||||
:else (go-ctx-lookup (rest ctx) name))))
|
||||
|
||||
(define go-ctx-extend (fn (ctx name type) (cons (list name type) ctx)))
|
||||
|
||||
(define
|
||||
go-ctx-extend-field
|
||||
(fn
|
||||
(ctx field)
|
||||
(let
|
||||
((names (nth field 1)) (ty (nth field 2)))
|
||||
(cond
|
||||
(= (len names) 0)
|
||||
ctx
|
||||
:else (let
|
||||
((rest-ctx (go-ctx-extend ctx (first names) ty)))
|
||||
(cond
|
||||
(= (len names) 1)
|
||||
rest-ctx
|
||||
:else (go-ctx-extend-field rest-ctx (list :field (rest names) ty))))))))
|
||||
|
||||
;; ── predeclared identifiers ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-predeclared
|
||||
(list
|
||||
(list "true" (list :ty-name "bool"))
|
||||
(list "false" (list :ty-name "bool"))
|
||||
(list "nil" (list :ty-untyped-nil))))
|
||||
|
||||
(define
|
||||
go-predeclared-lookup
|
||||
(fn
|
||||
(name)
|
||||
(cond
|
||||
(= (len go-predeclared) 0)
|
||||
nil
|
||||
:else (go-ctx-lookup go-predeclared name))))
|
||||
|
||||
;; ── type predicates ──────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-type-error?
|
||||
(fn
|
||||
(x)
|
||||
(and
|
||||
(list? x)
|
||||
(not (= (len x) 0))
|
||||
(= (first x) :type-error))))
|
||||
|
||||
(define go-type-equal? (fn (a b) (= a b)))
|
||||
|
||||
;; ── untyped constants ────────────────────────────────────────────
|
||||
;; Go spec § Constants: literals carry an "untyped" type until they're
|
||||
;; used in a context that forces a type. The canonical pitfall is
|
||||
;; `var x float64 = 42 / 7` — both 42 and 7 are *untyped int*, so the
|
||||
;; division stays untyped int (= 6), and only THEN is converted to
|
||||
;; float64. (Wrong implementations float-coerce first, getting 6.0 from
|
||||
;; what was meant to round.) The :ty-untyped-* tags below model this.
|
||||
|
||||
(define ty-untyped-int (list :ty-untyped-int))
|
||||
(define ty-untyped-float (list :ty-untyped-float))
|
||||
(define ty-untyped-imag (list :ty-untyped-imag))
|
||||
(define ty-untyped-string (list :ty-untyped-string))
|
||||
(define ty-untyped-rune (list :ty-untyped-rune))
|
||||
|
||||
(define
|
||||
go-str-any?
|
||||
(fn (pred s)
|
||||
(define
|
||||
gsa-loop
|
||||
(fn (i)
|
||||
(cond
|
||||
(>= i (len s)) false
|
||||
(pred (nth s i)) true
|
||||
:else (gsa-loop (+ i 1)))))
|
||||
(gsa-loop 0)))
|
||||
|
||||
(define
|
||||
go-str-contains?
|
||||
(fn (s ch) (go-str-any? (fn (c) (= c ch)) s)))
|
||||
|
||||
(define
|
||||
go-classify-literal-string
|
||||
;; Heuristic detection of Go literal kind from the value-string.
|
||||
;; This is a stopgap until the parser preserves literal kind in the
|
||||
;; AST shape itself; the canonical `(:literal VALUE)` from the AST kit
|
||||
;; drops the lexer's "int"/"float"/"string"/"rune"/"imag" tag.
|
||||
;; Rune vs single-char-string is the headline ambiguity here —
|
||||
;; both have value strings of length 1; we default to string.
|
||||
(fn (v)
|
||||
(cond
|
||||
(or (not (string? v)) (= (len v) 0)) :string
|
||||
(or (and (>= (nth v 0) "0") (<= (nth v 0) "9"))
|
||||
(and (= (nth v 0) ".") (>= (len v) 2)
|
||||
(>= (nth v 1) "0") (<= (nth v 1) "9")))
|
||||
(cond
|
||||
(= (nth v (- (len v) 1)) "i") :imag
|
||||
(go-str-contains? v ".") :float
|
||||
(and (or (go-str-contains? v "e") (go-str-contains? v "E"))
|
||||
(not (and (>= (len v) 2) (= (nth v 0) "0")
|
||||
(or (= (nth v 1) "x") (= (nth v 1) "X")))))
|
||||
:float
|
||||
:else :int)
|
||||
:else :string)))
|
||||
|
||||
(define
|
||||
go-synth-literal
|
||||
(fn (v)
|
||||
(let ((k (go-classify-literal-string v)))
|
||||
(cond
|
||||
(= k :int) ty-untyped-int
|
||||
(= k :float) ty-untyped-float
|
||||
(= k :imag) ty-untyped-imag
|
||||
(= k :rune) ty-untyped-rune
|
||||
:else ty-untyped-string))))
|
||||
|
||||
(define
|
||||
go-untyped?
|
||||
(fn (t)
|
||||
(and (list? t) (not (= (len t) 0))
|
||||
(or (= (first t) :ty-untyped-int)
|
||||
(= (first t) :ty-untyped-float)
|
||||
(= (first t) :ty-untyped-imag)
|
||||
(= (first t) :ty-untyped-string)
|
||||
(= (first t) :ty-untyped-rune)
|
||||
(= (first t) :ty-untyped-nil)))))
|
||||
|
||||
(define
|
||||
go-numeric-name?
|
||||
;; Built-in numeric type names per Go spec § Numeric types.
|
||||
(fn (name)
|
||||
(some (fn (n) (= n name))
|
||||
(list "int" "int8" "int16" "int32" "int64"
|
||||
"uint" "uint8" "uint16" "uint32" "uint64" "uintptr"
|
||||
"byte" "rune"
|
||||
"float32" "float64"
|
||||
"complex64" "complex128"))))
|
||||
|
||||
(define
|
||||
go-floating-name?
|
||||
(fn (name)
|
||||
(or (= name "float32") (= name "float64"))))
|
||||
|
||||
(define
|
||||
go-complex-name?
|
||||
(fn (name)
|
||||
(or (= name "complex64") (= name "complex128"))))
|
||||
|
||||
(define
|
||||
go-type-assignable?
|
||||
;; Can a value of type GOT be assigned to a slot of type EXPECTED?
|
||||
;; Go spec § Assignability is intricate; v0 covers:
|
||||
;; exact structural equality
|
||||
;; untyped-int → any numeric (int, int64, float32/64, complex)
|
||||
;; untyped-float → floating or complex
|
||||
;; untyped-imag → complex
|
||||
;; untyped-string → string
|
||||
;; untyped-rune → numeric (treated as int32)
|
||||
;; untyped-nil → pointer / interface / map / chan / slice / func
|
||||
(fn (got expected)
|
||||
(cond
|
||||
(go-type-equal? got expected) true
|
||||
(and (list? expected) (not (= (len expected) 0))
|
||||
(= (first expected) :ty-name))
|
||||
(let ((tn (nth expected 1)))
|
||||
(cond
|
||||
(= (first got) :ty-untyped-int) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-float)
|
||||
(or (go-floating-name? tn) (go-complex-name? tn))
|
||||
(= (first got) :ty-untyped-imag) (go-complex-name? tn)
|
||||
(= (first got) :ty-untyped-rune) (go-numeric-name? tn)
|
||||
(= (first got) :ty-untyped-string) (= tn "string")
|
||||
:else false))
|
||||
:else false)))
|
||||
|
||||
;; ── synth ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-arith-binops (list "+" "-" "*" "/" "%"))
|
||||
(define
|
||||
go-bitwise-binops (list "&" "|" "^" "<<" ">>" "&^"))
|
||||
(define
|
||||
go-compare-binops (list "==" "!=" "<" "<=" ">" ">="))
|
||||
(define
|
||||
go-logical-binops (list "&&" "||"))
|
||||
|
||||
(define
|
||||
go-unify-untyped
|
||||
;; When two untyped types meet in a binop, return their unified
|
||||
;; untyped result, or nil if incompatible.
|
||||
(fn (a b)
|
||||
(cond
|
||||
(go-type-equal? a b) a
|
||||
(and (= (first a) :ty-untyped-int) (= (first b) :ty-untyped-float))
|
||||
ty-untyped-float
|
||||
(and (= (first a) :ty-untyped-float) (= (first b) :ty-untyped-int))
|
||||
ty-untyped-float
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-synth
|
||||
(fn (ctx expr)
|
||||
(cond
|
||||
(and (list? expr) (= (first expr) :literal))
|
||||
(go-synth-literal (nth expr 1))
|
||||
(and (list? expr) (= (first expr) :literal-string))
|
||||
ty-untyped-string
|
||||
(and (list? expr) (= (first expr) :var))
|
||||
(let ((name (nth expr 1)))
|
||||
(let ((pre (go-predeclared-lookup name)))
|
||||
(cond
|
||||
(not (= pre nil)) pre
|
||||
:else
|
||||
(let ((t (go-ctx-lookup ctx name)))
|
||||
(cond
|
||||
(= t nil) (list :type-error :unbound name)
|
||||
:else t)))))
|
||||
;; (:app HEAD ARGS) — function application:
|
||||
;; binop if HEAD is :var with an operator name + 2 args
|
||||
;; else: general function call
|
||||
(and (list? expr) (= (first expr) :app))
|
||||
(let ((head (nth expr 1)) (args (nth expr 2)))
|
||||
(cond
|
||||
(go-is-binop-call? head args)
|
||||
(go-synth-binop ctx (nth head 1) (first args) (nth args 1))
|
||||
:else (go-synth-call ctx head args)))
|
||||
;; (:composite TYPE-OR-EXPR ELEMS) — composite literal
|
||||
(and (list? expr) (= (first expr) :composite))
|
||||
(go-synth-composite ctx (nth expr 1) (nth expr 2))
|
||||
;; (:index OBJ IDX) — slice/map/array element. v0: element type
|
||||
;; is the slice/array element type, or the map value type.
|
||||
(and (list? expr) (= (first expr) :index))
|
||||
(let ((obj-ty (go-synth ctx (nth expr 1))))
|
||||
(cond
|
||||
(go-type-error? obj-ty) obj-ty
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-slice))
|
||||
(nth obj-ty 1)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-array))
|
||||
(nth obj-ty 2)
|
||||
(and (list? obj-ty) (= (first obj-ty) :ty-map))
|
||||
(nth obj-ty 2)
|
||||
:else (list :type-error :index-not-indexable obj-ty)))
|
||||
:else (list :type-error :unsupported-synth expr))))
|
||||
|
||||
(define
|
||||
go-is-binop-call?
|
||||
(fn (head args)
|
||||
(and (list? head) (= (first head) :var)
|
||||
(= (len args) 2)
|
||||
(let ((op (nth head 1)))
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops)
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(some (fn (o) (= o op)) go-logical-binops))))))
|
||||
|
||||
(define
|
||||
go-check-args-against
|
||||
;; Each arg in ARGS assignable to the corresponding PARAMS type.
|
||||
;; Caller already verified arities match.
|
||||
(fn (ctx args params)
|
||||
(cond
|
||||
(or (= (len args) 0) (= (len params) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first args) (first params))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-args-against ctx (rest args) (rest params)))))))
|
||||
|
||||
(define
|
||||
go-check-composite-elems
|
||||
;; KEY-TY is nil for slice/array; non-nil for map.
|
||||
;; For maps, each elem must be (:kv KEY VALUE) — KEY assignable to
|
||||
;; KEY-TY, VALUE to VAL-TY.
|
||||
;; For slice/array, plain exprs assignable to VAL-TY; (:kv K V) is
|
||||
;; Go's index-keyed shorthand (`[]int{0: 5, 1: 10}`) — we type-check
|
||||
;; only the value in v0.
|
||||
(fn (ctx elems val-ty key-ty)
|
||||
(cond
|
||||
(or (= elems nil) (= (len elems) 0)) :ok
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(let ((err
|
||||
(cond
|
||||
(and (list? e) (= (first e) :kv))
|
||||
(let ((k (nth e 1)) (v (nth e 2)))
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx v val-ty)
|
||||
:else
|
||||
(let ((kerr (go-check ctx k key-ty)))
|
||||
(cond
|
||||
(go-type-error? kerr) kerr
|
||||
:else (go-check ctx v val-ty)))))
|
||||
:else
|
||||
(cond
|
||||
(= key-ty nil) (go-check ctx e val-ty)
|
||||
:else
|
||||
(list :type-error :map-elem-missing-key e)))))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else
|
||||
(go-check-composite-elems ctx (rest elems) val-ty key-ty)))))))
|
||||
|
||||
(define
|
||||
go-synth-composite
|
||||
;; Composite literal: (:composite TYPE-OR-EXPR ELEMS).
|
||||
;; []T{...} — each elem assignable to T; result :ty-slice T
|
||||
;; [N]T{...} — same; result :ty-array N T
|
||||
;; map[K]V{...} — each :kv key:K, value:V; result :ty-map K V
|
||||
;; Named-type literals (Point{...}, pkg.T{...}) require type-decl
|
||||
;; resolution; v0 returns the literal's type-expr as-is without
|
||||
;; element checking.
|
||||
(fn (ctx ty elems)
|
||||
(cond
|
||||
(and (list? ty) (= (first ty) :ty-slice))
|
||||
(let ((elem-ty (nth ty 1)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-array))
|
||||
(let ((elem-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems elem-ty nil)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
(and (list? ty) (= (first ty) :ty-map))
|
||||
(let ((key-ty (nth ty 1)) (val-ty (nth ty 2)))
|
||||
(let ((err (go-check-composite-elems ctx elems val-ty key-ty)))
|
||||
(cond (go-type-error? err) err :else ty)))
|
||||
:else ty)))
|
||||
|
||||
(define
|
||||
go-synth-call
|
||||
;; Synth a function call. Returns the result type, or :type-error.
|
||||
;; 0 results → (list :ty-void)
|
||||
;; 1 result → that result type directly
|
||||
;; N results → (list :ty-tuple TYPES) (multi-return)
|
||||
(fn (ctx callee args)
|
||||
(let ((fn-ty (go-synth ctx callee)))
|
||||
(cond
|
||||
(go-type-error? fn-ty) fn-ty
|
||||
(not (and (list? fn-ty) (= (first fn-ty) :ty-func)))
|
||||
(list :type-error :not-callable fn-ty)
|
||||
:else
|
||||
(let ((params (nth fn-ty 1)) (results (nth fn-ty 2)))
|
||||
(cond
|
||||
(not (= (len args) (len params)))
|
||||
(list :type-error :arity-mismatch
|
||||
(len params) (len args))
|
||||
:else
|
||||
(let ((err (go-check-args-against ctx args params)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
(= (len results) 0) (list :ty-void)
|
||||
(= (len results) 1) (first results)
|
||||
:else (list :ty-tuple results)))))))))
|
||||
|
||||
(define
|
||||
go-synth-binop
|
||||
(fn (ctx op lhs rhs)
|
||||
(let ((lt (go-synth ctx lhs)) (rt (go-synth ctx rhs)))
|
||||
(cond
|
||||
(go-type-error? lt) lt
|
||||
(go-type-error? rt) rt
|
||||
;; Comparison ops always produce bool (untyped-bool, simplified
|
||||
;; here to :ty-name "bool" until we model untyped-bool).
|
||||
(some (fn (o) (= o op)) go-compare-binops)
|
||||
(list :ty-name "bool")
|
||||
(some (fn (o) (= o op)) go-logical-binops)
|
||||
(list :ty-name "bool")
|
||||
;; Arithmetic / bitwise: types must unify.
|
||||
(or (some (fn (o) (= o op)) go-arith-binops)
|
||||
(some (fn (o) (= o op)) go-bitwise-binops))
|
||||
(cond
|
||||
(and (go-untyped? lt) (go-untyped? rt))
|
||||
(let ((unified (go-unify-untyped lt rt)))
|
||||
(cond
|
||||
(= unified nil)
|
||||
(list :type-error :binop-untyped-mismatch op lt rt)
|
||||
:else unified))
|
||||
(and (go-untyped? lt) (not (go-untyped? rt)))
|
||||
(cond
|
||||
(go-type-assignable? lt rt) rt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(and (not (go-untyped? lt)) (go-untyped? rt))
|
||||
(cond
|
||||
(go-type-assignable? rt lt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
(go-type-equal? lt rt) lt
|
||||
:else (list :type-error :binop-mismatch op lt rt))
|
||||
:else (list :type-error :unsupported-binop op)))))
|
||||
|
||||
;; ── check ────────────────────────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-check
|
||||
(fn
|
||||
(ctx expr expected)
|
||||
(let
|
||||
((got (go-synth ctx expr)))
|
||||
(cond
|
||||
(go-type-error? got)
|
||||
got
|
||||
(go-type-assignable? got expected)
|
||||
:ok :else
|
||||
(list :type-error :mismatch expected got)))))
|
||||
|
||||
;; ── default types ────────────────────────────────────────────────
|
||||
;; Go spec § Constants: the *default type* of an untyped constant
|
||||
;; is what it becomes when assigned to a sloppily-typed slot
|
||||
;; (e.g., `var x = 42` makes x an int).
|
||||
|
||||
(define
|
||||
go-default-type
|
||||
(fn (t)
|
||||
(cond
|
||||
(not (list? t)) t
|
||||
(= (first t) :ty-untyped-int) (list :ty-name "int")
|
||||
(= (first t) :ty-untyped-float) (list :ty-name "float64")
|
||||
(= (first t) :ty-untyped-imag) (list :ty-name "complex128")
|
||||
(= (first t) :ty-untyped-string) (list :ty-name "string")
|
||||
(= (first t) :ty-untyped-rune) (list :ty-name "int32")
|
||||
:else t)))
|
||||
|
||||
;; ── declaration checking ────────────────────────────────────────
|
||||
;; Returns either:
|
||||
;; the extended context (success)
|
||||
;; (list :type-error TAG ...) (failure)
|
||||
|
||||
(define
|
||||
go-check-exprs-against
|
||||
;; Check every EXPR in EXPRS is assignable to EXPECTED. Returns the
|
||||
;; first :type-error encountered, or :ok.
|
||||
(fn (ctx exprs expected)
|
||||
(cond
|
||||
(or (= exprs nil) (= (len exprs) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) expected)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-exprs-against ctx (rest exprs) expected))))))
|
||||
|
||||
(define
|
||||
go-bind-names-to-synth
|
||||
;; Pair each NAME with the synthesised default-typed type of the
|
||||
;; corresponding EXPR; extend CTX with all pairs. NAMES and EXPRS
|
||||
;; may have different lengths (multi-return funcs aren't here yet);
|
||||
;; for now we zip the shorter of the two.
|
||||
(fn (ctx names exprs)
|
||||
(cond
|
||||
(or (= (len names) 0) (= (len exprs) 0)) ctx
|
||||
:else
|
||||
(let ((t (go-synth ctx (first exprs))))
|
||||
(cond
|
||||
(go-type-error? t) t
|
||||
:else
|
||||
(let ((ctx2 (go-ctx-extend ctx (first names)
|
||||
(go-default-type t))))
|
||||
(go-bind-names-to-synth ctx2 (rest names) (rest exprs))))))))
|
||||
|
||||
(define
|
||||
go-check-var-decl
|
||||
;; Shape: (:var-decl (:field NAMES TYPE-or-nil) EXPRS-or-nil)
|
||||
;; or (:const-decl (:field NAMES TYPE-or-nil) EXPRS).
|
||||
;; Logic is the same for v0; const-vs-var distinction matters for
|
||||
;; mutability checks which arrive later.
|
||||
(fn (ctx decl)
|
||||
(let ((field (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (nth field 1)) (ann-ty (nth field 2)))
|
||||
(cond
|
||||
;; var x T (no init) → bind names to T
|
||||
(or (= exprs nil) (= (len exprs) 0))
|
||||
(cond
|
||||
(= ann-ty nil) (list :type-error :missing-type-or-init names)
|
||||
:else (go-ctx-extend-field ctx field))
|
||||
;; Annotated: var x T = expr — check each expr against T
|
||||
(not (= ann-ty nil))
|
||||
(let ((err (go-check-exprs-against ctx exprs ann-ty)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else (go-ctx-extend-field ctx field)))
|
||||
;; Inferred: var x = expr — bind names to default(synth(expr))
|
||||
:else (go-bind-names-to-synth ctx names exprs))))))
|
||||
|
||||
(define
|
||||
go-check-short-decl
|
||||
;; Shape: (:short-decl LHS-LIST EXPRS). LHS is a list of (:var NAME).
|
||||
;; Extracts the names and falls through to bind-names-to-synth.
|
||||
(fn (ctx decl)
|
||||
(let ((lhs-list (nth decl 1)) (exprs (nth decl 2)))
|
||||
(let ((names (map (fn (lhs)
|
||||
(cond
|
||||
(and (list? lhs) (= (first lhs) :var))
|
||||
(nth lhs 1)
|
||||
:else :unknown))
|
||||
lhs-list)))
|
||||
(go-bind-names-to-synth ctx names exprs)))))
|
||||
|
||||
(define
|
||||
go-check-decl
|
||||
;; Top-level dispatcher: accepts any decl AST shape, returns extended
|
||||
;; context or :type-error.
|
||||
(fn (ctx decl)
|
||||
(cond
|
||||
(and (list? decl) (= (first decl) :var-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :const-decl)) (go-check-var-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :short-decl)) (go-check-short-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :type-decl))
|
||||
(let ((name (nth decl 1)) (ty (nth decl 2)))
|
||||
(go-ctx-extend ctx name ty))
|
||||
(and (list? decl) (= (first decl) :func-decl))
|
||||
(go-check-func-decl ctx decl)
|
||||
(and (list? decl) (= (first decl) :method-decl))
|
||||
(go-check-method-decl ctx decl)
|
||||
:else ctx)))
|
||||
|
||||
;; ── method declarations and interface satisfaction ──────────────
|
||||
;; Methods are recorded in CTX under a mangled key
|
||||
;; "#method/RECV-TYPE-NAME/METHOD-NAME"
|
||||
;; bound to the method's :ty-func signature. Interface satisfaction is
|
||||
;; a structural lookup over these keys (Go spec § Interface types:
|
||||
;; "anything with the matching method set satisfies the interface").
|
||||
|
||||
(define
|
||||
go-method-key
|
||||
(fn (recv-ty-name method-name)
|
||||
(str "#method/" recv-ty-name "/" method-name)))
|
||||
|
||||
(define
|
||||
go-extract-recv-ty-name
|
||||
;; Receiver type is T or *T; return the named type's name string.
|
||||
(fn (recv-ty)
|
||||
(cond
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-name))
|
||||
(nth recv-ty 1)
|
||||
(and (list? recv-ty) (= (first recv-ty) :ty-ptr))
|
||||
(go-extract-recv-ty-name (nth recv-ty 1))
|
||||
:else nil)))
|
||||
|
||||
(define
|
||||
go-check-method-decl
|
||||
;; (list :method-decl RECV NAME PARAMS RESULTS BODY)
|
||||
;; Binds the method under the mangled key, then checks body with
|
||||
;; receiver + params extended.
|
||||
(fn (ctx decl)
|
||||
(let ((recv (nth decl 1)) (name (nth decl 2))
|
||||
(params (nth decl 3)) (results (nth decl 4))
|
||||
(body (nth decl 5)))
|
||||
(let ((recv-ty (nth recv 2)))
|
||||
(let ((recv-name (go-extract-recv-ty-name recv-ty)))
|
||||
(let ((sig (list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx2
|
||||
(cond
|
||||
(= recv-name nil) ctx
|
||||
:else
|
||||
(go-ctx-extend ctx
|
||||
(go-method-key recv-name name) sig))))
|
||||
(cond
|
||||
(= body nil) ctx2
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx2 recv) params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx
|
||||
(nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx2)))
|
||||
:else ctx2))))))))
|
||||
|
||||
(define
|
||||
go-iface-elems-satisfied?
|
||||
;; Each :method element in ELEMS must have a matching method in CTX
|
||||
;; under #method/TY-NAME/M-NAME. :embed elements are skipped in v0
|
||||
;; (they'd need recursive interface resolution).
|
||||
(fn (ctx ty-name elems)
|
||||
(cond
|
||||
(= (len elems) 0) true
|
||||
:else
|
||||
(let ((e (first elems)))
|
||||
(cond
|
||||
(= (first e) :method)
|
||||
(let ((m-name (nth e 1)) (m-params (nth e 2))
|
||||
(m-results (nth e 3)))
|
||||
(let ((found (go-ctx-lookup ctx
|
||||
(go-method-key ty-name m-name))))
|
||||
(cond
|
||||
(= found nil) false
|
||||
(and (= (nth found 1) m-params)
|
||||
(= (nth found 2) m-results))
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else false)))
|
||||
(= (first e) :embed)
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems))
|
||||
:else
|
||||
(go-iface-elems-satisfied? ctx ty-name (rest elems)))))))
|
||||
|
||||
(define
|
||||
go-iface-satisfies?
|
||||
;; Does the type named TY-NAME satisfy the interface IFACE-TYPE
|
||||
;; under context CTX? Structural method-set match per Go spec.
|
||||
(fn (ctx ty-name iface-type)
|
||||
(cond
|
||||
(not (and (list? iface-type) (= (first iface-type) :ty-interface)))
|
||||
false
|
||||
:else (go-iface-elems-satisfied? ctx ty-name (nth iface-type 1)))))
|
||||
|
||||
;; ── function-decl checking ──────────────────────────────────────
|
||||
|
||||
(define
|
||||
go-repeat-ty
|
||||
(fn (n ty acc)
|
||||
(cond
|
||||
(<= n 0) acc
|
||||
:else (go-repeat-ty (- n 1) ty (cons ty acc)))))
|
||||
|
||||
(define
|
||||
go-decl-params-to-ty-list
|
||||
;; Flatten (:field NAMES TYPE) param groups into a list of types,
|
||||
;; one entry per name. For func-type signatures.
|
||||
(fn (params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) (list)
|
||||
:else
|
||||
(let ((field (first params)))
|
||||
(let ((names (nth field 1)) (ty (nth field 2)))
|
||||
(let ((rest-tys (go-decl-params-to-ty-list (rest params))))
|
||||
(go-repeat-ty (len names) ty rest-tys)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-params
|
||||
;; Extend CTX with every binding in every (:field NAMES TYPE) param group.
|
||||
(fn (ctx params)
|
||||
(cond
|
||||
(or (= params nil) (= (len params) 0)) ctx
|
||||
:else
|
||||
(go-extend-with-params
|
||||
(go-ctx-extend-field ctx (first params))
|
||||
(rest params)))))
|
||||
|
||||
(define
|
||||
go-check-return-list
|
||||
;; Each EXPR assignable to the corresponding RESULTS type.
|
||||
;; v0: lengths must match; multi-return funcs deferred.
|
||||
(fn (ctx exprs results)
|
||||
(cond
|
||||
(and (= (len exprs) 0) (= (len results) 0)) :ok
|
||||
(not (= (len exprs) (len results)))
|
||||
(list :type-error :return-count-mismatch
|
||||
(len exprs) (len results))
|
||||
:else
|
||||
(let ((r (go-check ctx (first exprs) (first results))))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-return-list ctx (rest exprs) (rest results)))))))
|
||||
|
||||
(define
|
||||
go-check-assign
|
||||
(fn (ctx stmt)
|
||||
(let ((lhs-list (nth stmt 1)) (rhs-list (nth stmt 2)))
|
||||
(cond
|
||||
(not (= (len lhs-list) (len rhs-list)))
|
||||
(list :type-error :assign-count-mismatch
|
||||
(len lhs-list) (len rhs-list))
|
||||
:else (go-check-assign-pairs ctx lhs-list rhs-list)))))
|
||||
|
||||
(define
|
||||
go-check-assign-pairs
|
||||
(fn (ctx lhs-list rhs-list)
|
||||
(cond
|
||||
(= (len lhs-list) 0) :ok
|
||||
:else
|
||||
(let ((lhs-ty (go-synth ctx (first lhs-list))))
|
||||
(cond
|
||||
(go-type-error? lhs-ty) lhs-ty
|
||||
:else
|
||||
(let ((r (go-check ctx (first rhs-list) lhs-ty)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else
|
||||
(go-check-assign-pairs ctx (rest lhs-list)
|
||||
(rest rhs-list)))))))))
|
||||
|
||||
(define
|
||||
go-check-stmt
|
||||
;; Returns either an extended CTX (decls), :ok (sealed stmts), or
|
||||
;; :type-error. RESULTS is the enclosing func's declared return types
|
||||
;; (used by :return).
|
||||
(fn (ctx stmt results)
|
||||
(cond
|
||||
(and (list? stmt) (= (first stmt) :var-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :const-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :short-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :type-decl))
|
||||
(go-check-decl ctx stmt)
|
||||
(and (list? stmt) (= (first stmt) :return))
|
||||
(let ((exprs (nth stmt 1)))
|
||||
(let ((err (go-check-return-list ctx exprs results)))
|
||||
(cond (go-type-error? err) err :else ctx)))
|
||||
(and (list? stmt) (= (first stmt) :block))
|
||||
(let ((err (go-check-block ctx (nth stmt 1) results)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
(and (list? stmt) (= (first stmt) :assign))
|
||||
(let ((err (go-check-assign ctx stmt)))
|
||||
(cond (go-type-error? err) err :else ctx))
|
||||
:else
|
||||
(let ((t (go-synth ctx stmt)))
|
||||
(cond (go-type-error? t) t :else ctx)))))
|
||||
|
||||
(define
|
||||
go-check-block
|
||||
;; Thread ctx through stmts; if any stmt is a decl, its extension
|
||||
;; propagates to subsequent stmts. Returns :ok or :type-error.
|
||||
(fn (ctx stmts results)
|
||||
(cond
|
||||
(or (= stmts nil) (= (len stmts) 0)) :ok
|
||||
:else
|
||||
(let ((r (go-check-stmt ctx (first stmts) results)))
|
||||
(cond
|
||||
(go-type-error? r) r
|
||||
:else (go-check-block r (rest stmts) results))))))
|
||||
|
||||
(define
|
||||
go-check-func-decl
|
||||
;; Bind the function in the outer ctx (so recursion works), extend
|
||||
;; ctx with type params + value params, check the body. Returns the
|
||||
;; outer ctx with the function bound, or :type-error.
|
||||
;;
|
||||
;; Type parameters become opaque type variables in the body's ctx:
|
||||
;; each name `T` is bound as a type alias to (:ty-param "T") so the
|
||||
;; checker treats references to T as "this type", not "unknown".
|
||||
;; Constraint enforcement (T satisfies `comparable` etc.) is a
|
||||
;; later refinement; v0 just allows any operation that's polymorphic
|
||||
;; under the constraint `any`.
|
||||
(fn (ctx decl)
|
||||
(let ((name (nth decl 1)) (params (nth decl 2))
|
||||
(results (nth decl 3)) (body (nth decl 4))
|
||||
(type-params (cond (> (len decl) 5) (nth decl 5) :else nil)))
|
||||
(let ((fn-ty
|
||||
(list :ty-func
|
||||
(go-decl-params-to-ty-list params) results)))
|
||||
(let ((ctx-with-fn (go-ctx-extend ctx name fn-ty)))
|
||||
(cond
|
||||
(= body nil) ctx-with-fn
|
||||
(and (list? body) (= (first body) :block))
|
||||
(let ((body-ctx
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-params ctx-with-fn params)
|
||||
type-params)))
|
||||
(let ((err
|
||||
(go-check-block body-ctx (nth body 1) results)))
|
||||
(cond
|
||||
(go-type-error? err) err
|
||||
:else ctx-with-fn)))
|
||||
:else ctx-with-fn))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-params
|
||||
;; Each (:field NAMES CONSTRAINT) field contributes opaque type
|
||||
;; vars: bind each NAME as a type alias to (:ty-param NAME). The
|
||||
;; constraint type is stored alongside so future "constraint
|
||||
;; satisfaction" checks can find it; for v0 it's informational.
|
||||
(fn (ctx type-params)
|
||||
(cond
|
||||
(or (= type-params nil) (= (len type-params) 0)) ctx
|
||||
:else
|
||||
(let ((field (first type-params)))
|
||||
(let ((names (nth field 1)) (constraint (nth field 2)))
|
||||
(go-extend-with-type-params
|
||||
(go-extend-with-type-param-names ctx names constraint)
|
||||
(rest type-params)))))))
|
||||
|
||||
(define
|
||||
go-extend-with-type-param-names
|
||||
(fn (ctx names constraint)
|
||||
(cond
|
||||
(= (len names) 0) ctx
|
||||
:else
|
||||
(let ((nm (first names)))
|
||||
(go-extend-with-type-param-names
|
||||
(go-ctx-extend ctx nm
|
||||
(list :ty-param nm constraint))
|
||||
(rest names) constraint)))))
|
||||
639
plans/abstractions.md
Normal file
639
plans/abstractions.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# Abstraction Radar — backlog
|
||||
|
||||
Maintained by the read-only `radar` loop (see `plans/agent-briefings/radar-loop.md`).
|
||||
Detection only — implementation is a separate, coordinated step owned by the
|
||||
relevant subsystem loop, never by radar.
|
||||
|
||||
**AHA gate to reach _Proposed_:** ≥3 real consumers · all past Phase 2 & API-stable ·
|
||||
structurally identical (file:line evidence) · a natural home (usually NOT lib/guest).
|
||||
Anything short → _Watching_ (what's missing) or _Rejected_ (why).
|
||||
|
||||
---
|
||||
|
||||
## Last scan
|
||||
|
||||
- **Date:** 2026-06-07 (radar loop, pass 38)
|
||||
- **Pass 38 — migration plan DRAFTED (planning loop worklist complete).** All 5 specs
|
||||
written under `loops/migration:plans/migration/` (host-readiness, strangler-shadow-
|
||||
harness, slice-01-blog, data-migration, slice-sequencing); loop added a 6th revealed
|
||||
thread `open-questions.md` (digest for humans) then is end-of-worklist. **Decision point
|
||||
for the operator: review the plan + decide whether to start an IMPLEMENTATION loop**
|
||||
(first target per the plan: `lib/host` Phase 1 + multi-`Set-Cookie` fix → slice-01-blog
|
||||
1a). Branch `loops/migration` is local/un-pushed (per operator's no-push preference).
|
||||
No new radar candidate; A1 at 13; fed-sx still on deadlock.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 37)
|
||||
- **Pass 37 — migration plan 4/5 specs done.** Long-pole shipped: `data-migration.md`
|
||||
(Postgres → persist via **genesis-import** — seed each stream with current DB state as
|
||||
initial events). Only `slice-sequencing.md` left; loop self-pacing fine. No new radar
|
||||
candidate; events (iCal import) + content (sanitize, 799/799) incremental; A1 at 13.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 36)
|
||||
- **Pass 36 — migration planning loop healthy + productive.** Self-pacing restored (now
|
||||
schedules its own ~20min wake-ups). Shipped 2 more specs (3/5 threads): strangler-shadow-
|
||||
harness (Caddy handle-per-route + offline-replay shadow-diff at the `content/blocks`
|
||||
facade) and slice-01-blog (GET /<slug>/; **found blog already has `Post.sx_content` +
|
||||
lexical→SX pipeline** — a real head-start). data-migration + slice-sequencing pending.
|
||||
No new radar candidate; A1 steady at 13; fed-sx still on deadlock.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 35)
|
||||
- **Pass 35 — quiet for findings; ops note.** The migration PLANNING loop had completed
|
||||
host-readiness and **stalled idle ~1hr** (self-paced `/loop` didn't re-fire after one
|
||||
iteration). Nudged it to continue its worklist (now on strangler-shadow-harness) +
|
||||
schedule its own next wake-up. No new radar candidate; events/content incremental;
|
||||
A1 steady at 13; fed-sx still on the deadlock reproducer.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 34)
|
||||
- **Pass 34 — quiet, no new finding.** Minimal churn: migration planning loop still on
|
||||
host-readiness (next thread pending, self-paced); maude scoreboard refresh; fed-sx
|
||||
grinding the fed-prims deadlock; A1 adopters steady at 13. Nothing new to discover.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 33)
|
||||
- **Pass 33 — host-layer story clarified (refines the migration strategy).** `dream` =
|
||||
**Dream-on-SX**: OCaml's Dream web framework on the SX CEK, and the project owner's
|
||||
**confirmed decision to move rose-ash OFF Quart onto Dream** as the ergonomic HTTP front
|
||||
door over the native SX server (router/session/middleware/cors/csrf/auth/ws/html/json —
|
||||
16 modules). So the host layer is: **host-on-sx native server (Phases 1-3, carries it
|
||||
now) → Dream-on-SX framework front door (gated on ocaml-on-sx Phases 1-5) + host-persist
|
||||
(done) + fed-sx (AP transport).** The migration PLANNING loop (new, tmux `migration`,
|
||||
commit-only) is now the owner of refining this — it already shipped `host-readiness.md`
|
||||
pinning the near-term gate to **`lib/host` (unbuilt) + a multi-`Set-Cookie` primitive
|
||||
fix** (`sx_server.ml:735`). NOTE: `plans/rose-ash-on-sx-migration.md` under-specified the
|
||||
framework layer (said "host-on-sx HTTP host"); the Dream-over-Quart decision + the
|
||||
native→Dream sequence is the correction — the planning loop will fold it into its specs.
|
||||
`maude` at Phase 5 (rewriting-logic substrate). Radar tracks; planning loop details.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 32)
|
||||
- **Pass 32 — A1 DONE.** `loops/conformance` merged to architecture (`db76cc8c`); 13 adopters
|
||||
now on the shared driver; radar spot-checked common-lisp = 487/487 green post-merge →
|
||||
coordination flag CLEARED. A1 moved to a new **Done** section. New nascent subsystems
|
||||
`dream` + `maude` (0 files), `fed-prims` resumed (mutex-deadlock fix). The idle
|
||||
`a1-conformance` loop can be retired (worklist complete).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 31)
|
||||
- **Pass 31 — A1 conformance loop WORKLIST COMPLETE.** tcl excluded (foreign `*.tcl`); final:
|
||||
4 migrated (common-lisp/erlang/feed/go) + 5 excluded (forth/js/ocaml/smalltalk/tcl). A1 =
|
||||
**12 on shared driver + 6 excluded**; only the parity-gated merge to architecture remains.
|
||||
commerce shipped a refund saga on flow (2nd flow use) + finished Phase 5 → going quiescent.
|
||||
relations building graph algos (all-paths) — still unconsumed (W9 unchanged).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 30)
|
||||
- **Pass 30:** conformance loop near done — `ocaml` + `smalltalk` excluded (both foreign
|
||||
`test.sh`/corpus runners, as predicted). Tally: 4 migrated, 4 excluded, **tcl only** left.
|
||||
Next A1 milestone = the `loops/conformance`→architecture merge under adopter-parity. No
|
||||
new candidate; relations/artdag steady (no new W9 delegation).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 29)
|
||||
- **Pass 29:** conformance loop excluded `js` (test262 fixtures) → 4 migrated + 2 excluded,
|
||||
3 remain (ocaml/smalltalk/tcl). New subsystems advancing fast: `relations` → Phase 4
|
||||
federation, `artdag` → Phase 6 federation → both fold into W1 (now 7 federation modules,
|
||||
theme-not-shape holds) and W9 (relations past Phase 2 but not yet consumed by anyone).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 28)
|
||||
- **Pass 28 — fleet expanding again.** Conformance loop: `go` migrated 609/609; **`forth`
|
||||
excluded** (foreign Forth corpus — classify-then-exclude working). 4 migrated +1 excluded
|
||||
on the branch; js/ocaml/smalltalk/tcl remain. **2 new subsystems:** `relations` (Phase 1,
|
||||
parent/child rel facts → new W9 nascent watch) and `artdag` (nascent, 0 files). `events`
|
||||
MERGED to architecture (its persist+flow adoption now integrated — W4/W8 landed). Briefing
|
||||
commit hints more incoming: `dream`, `host`, +5 language chisels.
|
||||
- **Date:** 2026-06-07 (radar loop, passes 26–27)
|
||||
- **Passes 26–27 (routine tracking):** conformance loop steady at ~1 migration/iteration —
|
||||
erlang 761/761, then feed 189/189. A1 = 8 on architecture + 3 on the branch; 6 remain.
|
||||
W4 still gated (host-persist adapter not landed); no new subsystem; app loops on
|
||||
incremental domain work (commerce Phase 5 payment envelope, content/events/identity/fed-sx).
|
||||
Nothing new to discover; merge-time adopter-parity flag still open.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 25)
|
||||
- **Pass 25:** A1 → **8 adopters** (events via its own loop) + common-lisp 487/487 on the
|
||||
conformance branch. The conformance loop **extended the shared `lib/guest` driver**
|
||||
(per-suite counters/preloads) to do it → raised a **coordination flag in A1**: verify the
|
||||
branch is non-regressive against all 8 adopters before merging to architecture. commerce
|
||||
drafting Phase 5 provider-neutral payment envelope. No new candidate; A1 advancing fast.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 24)
|
||||
- **Pass 24 — three real updates.** (1) **A1 → 7 adopters** (search migrated, counters mode
|
||||
— corrects the earlier exclusion). (2) The dedicated `conformance` loop ran its 1st
|
||||
iteration: refused to force-migrate common-lisp (parity gate worked) and surfaced a
|
||||
**driver feature-gap** (per-suite counters + preloads) gating the complex multi-suite
|
||||
candidates → A1 now splits simple-now vs gated-on-driver-enhancement. (3) **W8 commerce
|
||||
is LIVE** ("order lifecycle as a durable flow-on-sx flow, Phase 3 done") → 2 live flow
|
||||
consumers. events shipped TZ/DST; mod reverted its extraction note (declined on re-read).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 23)
|
||||
- **Pass 23 — trigger fired (empty streak ends at 19–22).** commerce recorded a Phase 3
|
||||
**flow-integration design** (order saga as a flow-on-sx flow, payment suspended until
|
||||
webhook resume) → 2nd durable-flow consumer; **W8 broadened** from "delivery" to
|
||||
"externally-resumed orchestration on lib/flow." events made its federation transport
|
||||
**fed-sx-ready** (injected) → reinforces W1's 5/5 inject-fed-sx seam. acl left tmux
|
||||
(now fully quiescent). host-persist adapter still not landed (W4 migration still gated).
|
||||
- **Empty-discovery streak: passes 19–22** (last verified pass 22). Fleet at steady state —
|
||||
active loops (content CvRDT, events recurrence/reschedule, identity grant-mgmt, fed-sx
|
||||
outbox internals) are building *inside* their domains, not cross-cutting infra. Census
|
||||
exhausted (p17); all gates re-tested (W1 p18, W2 p19). No new candidate clears any gate.
|
||||
- **Radar is now trigger-driven.** The next substantive pass needs one of: **(a)** a new
|
||||
subsystem worktree spawning (auto-joins scan), or **(b)** host-persist's durable adapter
|
||||
landing → unblocks the W4 acl/mod→persist/log migration, or **(c)** a quiescent
|
||||
subsystem (acl/mod/search/commerce, static ~9–16 passes) resuming. Polling ~hourly until
|
||||
one fires; will tighten cadence then.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 20)
|
||||
- **Pass 20 — honest empty pass.** 3 new census recurrences since p17 (normalize/index ×2,
|
||||
query ×3) — all **name collisions** (same noun, domain-specific op), added to the table.
|
||||
Recorded the meta-pattern: the fleet shares vocabulary, not structure. Most subsystems
|
||||
quiescent (acl/mod/search/commerce static ~9-15 passes = API-stable); only events/
|
||||
identity/content/fed-sx still committing domain features. No new gate-clearer.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 19)
|
||||
- **Pass 19 — honest empty pass.** Scanned 10 active subsystems. content/index.sx is a
|
||||
blog index/tag-cloud listing (presentation, not full-text search — no search reinvention)
|
||||
and content/multi-doc indexing adds no per-viewer filter. **W2 re-tested: still 2**
|
||||
(feed, search) — acl's `permit?`-like matches are its own authZ *engine* (the home),
|
||||
not a downstream read filter. No new candidate cleared any gate.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 18)
|
||||
- **Pass 18 — W1 gate re-test.** events shipped Phase 4 federation (5th consumer): a 5th
|
||||
divergent merge (sorted agenda + `:origin` provenance), trust-gate = runtime list
|
||||
membership (shares mod's mechanism, not acl's). Reinforces W1's "theme not shape" — but
|
||||
the **inject-fed-sx-transport seam is now 5/5**, strengthening "all are fed-sx
|
||||
consumers-in-waiting." Trust sub-pattern refined: mod+events (runtime set) vs acl (rule).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 17)
|
||||
- **Pass 17 — filename census declared EXHAUSTED** (see the Census-status table above).
|
||||
Examined the last unswept ≥2 recurrences (schema/engine = acl⇄mod substrate twins;
|
||||
catalog/batch = name collisions; store = divergent). No new candidate. Incremental churn
|
||||
elsewhere (content 621/621, identity PAR, events reminders). Future passes pivot from
|
||||
censusing to re-testing gates as consumers mature.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 16)
|
||||
- **Pass 16:** events started Phase 3 — **durable notification delivery on `lib/flow`**
|
||||
(new W8: at-least-once + idempotency exemplar; fed-sx/mod roll their own outbox). The two
|
||||
`notify.sx` (feed vs events) are a name collision (read-side digest vs delivery), noted
|
||||
in W8. Substrate-adoption story deepening: app domains now consume persist (content/
|
||||
commerce/events), flow (events), commerce (events), acl-authZ (identity).
|
||||
- **Date:** 2026-06-07 (radar loop, pass 15)
|
||||
- **Pass 15:** added the **scanning-method note** above after `query.sx` again proved to
|
||||
be merged-lib copies (lib/prolog + lib/persist in every worktree). Corrected census
|
||||
surfaced `wire`×2 (content+mod) → Rejected (shared role, divergent structure: generic SX
|
||||
serializer vs bespoke pipe-format under a Prolog-env string-prim constraint). events↔
|
||||
commerce integration appeared (paid tickets); acl/mod/search quiescent ~7 passes (now
|
||||
API-stable). No new gate-clearer.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 14)
|
||||
- **Pass 14:** filename census flagged `snapshot`×?? — but the `*/lib/persist/snapshot.sx`
|
||||
copies are just the merged `lib/persist` in each worktree, NOT consumers (same artifact
|
||||
as `lib/feed/rank.sx` everywhere). The one distinct file, `content/snapshot.sx`,
|
||||
reimplements persist's projection-checkpoint on raw KV instead of using `persist/snapshot`
|
||||
→ new W7 (persist-adoption nudge). `audit`×3 = the W4 fakes (acl/mod/identity), known.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 13)
|
||||
- **Pass 13 — honest re-test, no gate-clearer.** Re-tested the two longest-waiting gates
|
||||
against the maturing app-domain loops: **W2** (per-viewer visibility) still 2 consumers
|
||||
(feed, search) — commerce/content/events/identity add no per-viewer read filter; **W3**
|
||||
(pagination) still 2 (feed, search) — `content/page.sx` is an HTML wrapper, not
|
||||
pagination (filename collision, noted in W3). Incremental churn only elsewhere.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 12)
|
||||
- **Pass 12:** `events` shipped **transactional booking on persist** (3rd live persist
|
||||
consumer) using `persist/append-expect` (optimistic-concurrency CAS, lock-free capacity
|
||||
safety). W4 ledger now shows a persist feature-ladder append → append-once → append-expect
|
||||
that the hand-rolled fakes can't match. No new candidate; W4 reinforced.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 11)
|
||||
- **Pass 11 — W4 sharpened with a consumer ledger.** commerce built an **order ledger on
|
||||
persist** (2nd live exemplar; uses `persist/append-once` for webhook idempotency) and
|
||||
identity a **grant audit ledger** (in-memory Erlang fake, gated on an Erlang↔persist
|
||||
bridge). The append-only monotonic-seq event-log pattern is now validated across 4
|
||||
domains, 2 live on persist + 3 fakes flagged for adoption. See W4 table.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 10)
|
||||
- **Pass 10:** commerce/content/events/identity advancing (content 238/238). Probed a
|
||||
shape outside the routing table — **guarded lifecycle state machines** (mod/lifecycle +
|
||||
identity/membership) → new W6: shared *design principle*, divergent *structure*
|
||||
(SX transition-table vs Erlang gen_server), NOT an extraction target. No gate-clearer.
|
||||
- **Date:** 2026-06-07 (radar loop, pass 9)
|
||||
- **Pass 9:** `commerce` + `content` reached Phase 2 (`content` 162/162). **Key find:
|
||||
`content` built its op log directly on `persist/log`** (backend-injected, append+replay-
|
||||
to-seq) — the live reference exemplar for W4 (see W4). `events` MONTHLY RRULE,
|
||||
`identity` OAuth2 auth-code + PKCE, search boolean-filtered ranked. A1 still 6 adopters.
|
||||
- **Date:** 2026-06-06 (radar loop, pass 8)
|
||||
- **Pass 8 — fleet expanded by 4 app-domain loops** (the briefing's anticipated
|
||||
`commerce`/`identity` arrivals, auto-picked up by dynamic discovery). All early-stage,
|
||||
**pre-Phase-2 → moving targets, none count toward any gate yet**:
|
||||
- `commerce` (Phase 1: `api/cart/catalog/price`). Its "per-line audit" is a cost
|
||||
*breakdown view* (`api.sx:44`), **not** an append-only decision log → NOT a W4
|
||||
consumer.
|
||||
- `events` (Phase 1: `calendar.sx`, RRULE expansion).
|
||||
- `identity` (early: `session/token`). Defers authZ to acl (`token.sx:15`) — reinforces
|
||||
W2's "delegate `permit?` to acl-on-sx" routing; identity = authN, acl = authZ.
|
||||
- `content` (just-started: `block.sx`).
|
||||
These are the future consumers W2/W3 are waiting on — re-check their per-viewer filters
|
||||
/ pagination once each clears Phase 2. No new gate-clearer this pass.
|
||||
- **Pass 7:** **A1 jumped 4→6 adopters** — `acl` + `mod` migrated to the shared
|
||||
conformance driver (first app-domain adopters; proves it generalizes past substrates).
|
||||
`host-persist` closed its blob-adapter blocker (durable storage adapter now landing →
|
||||
W4 migration path opening). search shipped proximity/NEAR; flow + persist quiescent.
|
||||
- **Pass 6:** new worktree **`host-persist`** (active — building persist's durable host
|
||||
adapter); `feed` went quiescent (left tmux). acl shipped hardening (+25), fed-sx-m1 at
|
||||
Step 6c. **mod loop independently wrote a shared-plumbing note** (`mod-on-sx.md`,
|
||||
538b8a53) corroborating W4/W5 — folded its claims + home disagreements into W1/W4/W5.
|
||||
No new gate-clearer (audit log still 2 consumers), but consumers are now API-stable.
|
||||
- **Pass 5:** search (+highlight/snippet) and fed-sx-m1 (+follower_graph) moved; rest
|
||||
unchanged. Filename census: `api`×6, `fed`×3, then `schema/rank/query/page/explain/
|
||||
engine/batch/audit`×2. Examined the ×6 `api.sx` → Rejected (shared name, divergent
|
||||
structure incl. implicit-vs-explicit-state contract). rank/batch/engine all ≤2 +
|
||||
substrate/domain-divergent → no new gate-clearer.
|
||||
- **Pass 4:** no churn vs pass 3 (same worktrees/tmux/HEADs/adopters). Swept audit+explain
|
||||
surfaces: acl/mod share an append-only-log shape (→ sharpened W4 with persist/log API
|
||||
evidence) and a proof-explain shape (→ new W5, substrate-bound). No new gate-clearer.
|
||||
- **Pass 3 (earlier today):** subsystem set + tmux + A1 adopters (4) all unchanged vs pass 2. Loops
|
||||
advanced: acl shipped Phase 4 federation; search shipped Phase 4 + pagination; feed
|
||||
shipped pagination/threading; mod at Ext 19 (capstone); persist did a worked acl-grants
|
||||
migration (W4). New shape found: offset/limit pagination → folded into W3.
|
||||
- **Subsystem set discovered:** loop worktrees `acl, erlang, fed-prims, fed-sx-m1,
|
||||
feed, flow, go, kernel, mod, ocaml, persist, radar, ruby, search,
|
||||
sx-vm-extensions`; main-repo `lib/*` incl. merged `feed` + substrates (`apl,
|
||||
common-lisp, datalog, erlang, forth, go, haskell, hyperscript, js, lua, minikanren,
|
||||
ocaml, prolog, scheme, smalltalk, tcl`) + `lib/guest`.
|
||||
Actively looping (tmux): `acl, fed-sx-m1, feed, flow, mod, persist, search`
|
||||
(+ radar).
|
||||
- **New since pass 1:** worktrees `kernel` (empty/unset — not yet a repo) and `ocaml`
|
||||
(`lib/ocaml/baseline` only). Both early-stage, pre–Phase 2 → out of proposal scope.
|
||||
- Re-enumerate every pass; new loops (e.g. a future `commerce`/`identity`) auto-join.
|
||||
|
||||
**Census status (pass 17): EXHAUSTED.** Every own-namespace filename recurring ≥2× has
|
||||
been examined and dispositioned — further filename-censusing is low-yield until new
|
||||
subsystems/modules appear. Map:
|
||||
| filename | owners | verdict |
|
||||
|---|---|---|
|
||||
| `api` ×10 | all | Rejected — shared role, divergent state contract |
|
||||
| `fed`/`federation` | feed/search/mod/acl(+content) | W1 — theme not shape |
|
||||
| `audit` ×3 | acl/mod/identity | W4 — append-only log → persist/log |
|
||||
| `page` ×3 | feed/search (pagination) + content (HTML wrapper) | W3 + collision noted |
|
||||
| `explain` ×2 | acl/mod | W5 — proof tree, substrate-bound |
|
||||
| `snapshot` ×2 | persist(facet) + content(reinvents) | W7 |
|
||||
| `wire` ×2 | content(SX serializer) / mod(pipe-format) | Rejected — divergent |
|
||||
| `schema`,`engine` ×2 | acl/mod | substrate-twin parallels (Datalog vs Prolog); only audit (W4) is liftable |
|
||||
| `catalog`,`batch` ×2 | commerce/persist, mod/persist | name collisions, unrelated |
|
||||
| `normalize` ×2 | content(tree-prune)/feed(record-coerce) | name collision (pass 20) |
|
||||
| `index` ×2 | content(listing)/search(inverted index) | name collision (pass 20) |
|
||||
| `query` ×3 | content(doc-block)/search(bool AST)/persist(stream-read) | 3-way name collision (pass 20) |
|
||||
| `store` ×2 | content(on persist) / flow(workflow records) | related concept, divergent |
|
||||
| `rank` ×2 | feed/search | different domains (activities vs docs), ≤2 |
|
||||
**acl⇄mod are structural twins** (decision engine over a logic substrate, Datalog vs
|
||||
Prolog) — they parallel across engine/schema/explain/audit/fed, but only the *audit log*
|
||||
is substrate-agnostic and liftable (→ W4); the rest are substrate-idiomatic. Next passes:
|
||||
re-test gates (W2/W3/W8) as consumers mature, watch new modules — not re-census.
|
||||
|
||||
**Meta-pattern (pass 20):** new module names keep *recurring* but the operations keep
|
||||
*colliding* — same noun, domain-specific op (normalize, index, query, catalog, batch,
|
||||
notify, page, store all proved to be collisions). This is *why* genuine extraction
|
||||
candidates are rare: the fleet shares vocabulary, not structure. The real shared assets
|
||||
are the **substrate subsystems** (persist, flow, acl, fed-sx) that app domains *adopt*
|
||||
(W1/W2/W4/W7/W8), not hand-rolled libs to extract.
|
||||
|
||||
**Scanning-method note (learned the hard way, passes 5/12/14/15):** a filename census
|
||||
for *cross-subsystem* recurrence MUST restrict to each subsystem's OWN namespace —
|
||||
`X/lib/X/*.sx` — never `X/lib/*/`. The merged substrate libs (`lib/prolog`, `lib/persist`,
|
||||
`lib/feed`, `lib/datalog`, …) are checked out inside *every* worktree, so a naive census
|
||||
reports e.g. `query.sx`/`snapshot.sx`/`rank.sx` ×N as phantom recurrences that are really
|
||||
one merged file copied N times. Correct one-liner:
|
||||
`for w in <subsystems>; do for f in $w/lib/$w/*.sx; do basename $f .sx; done; done | sort | uniq -c | sort -rn`.
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
### A1 · Shared conformance driver — ✅ COMPLETE (merged `db76cc8c`, pass 32)
|
||||
Full closed loop: radar detected it → dedicated `conformance` loop implemented it
|
||||
(classify-then-migrate-or-exclude, hard parity gate) → **merged to architecture**
|
||||
(`db76cc8c Merge loops/conformance into architecture: A1 conformance-driver migration`)
|
||||
→ radar spot-verified post-merge (**common-lisp 487/487 green** on architecture — exercises
|
||||
the new per-suite-counters/preloads driver feature, the riskiest change). Final state:
|
||||
- **13 on the shared driver:** acl, apl, common-lisp, datalog, erlang, events, feed, go,
|
||||
haskell, mod, prolog, relations, search.
|
||||
- **6 correctly excluded** (foreign-program runners — a legitimately different harness):
|
||||
forth, js, ocaml, smalltalk, tcl, lua.
|
||||
- The shared driver gained per-suite counters + per-suite preloads (backward-compatible);
|
||||
spot-check confirms existing adopters unaffected. Coordination flag CLEARED.
|
||||
Detail of the migration arc retained under the original entry below.
|
||||
|
||||
## Proposed (cleared the gate)
|
||||
|
||||
_(empty — A1 graduated to Done, pass 32.)_
|
||||
|
||||
### A1 · Adopt the shared conformance driver across subsystems
|
||||
- **Pattern:** every subsystem hand-rolls a near-identical `conformance.sh`
|
||||
(epoch-load → eval → scoreboard emit) and an inline `<x>-test name got expected`
|
||||
pass/fail counter.
|
||||
- **Consumers (≥3, overwhelming):** 15 `lib/*/conformance.sh` — `apl, feed, datalog,
|
||||
flow, mod, lua, erlang, forth, go, common-lisp, haskell, js, ocaml, prolog,
|
||||
smalltalk, tcl`.
|
||||
- **Home:** `lib/guest` — the one legitimate exception (the shared driver
|
||||
`lib/guest/conformance.sh` + `lib/guest/conformance.sx` already exist; modes
|
||||
`dict` and `counters`).
|
||||
- **Status: IN PROGRESS — 6 adopters (pass 7).** `prolog` (dict), `haskell` (counters),
|
||||
`apl` (dict), `datalog` (dict), and **`acl` (dict) + `mod` (dict), newly migrated this
|
||||
pass** — all 3-line exec shims into `lib/guest/conformance.sh` with a `conformance.conf`.
|
||||
**acl + mod are the first *app-domain* adopters** (not language substrates) — strong
|
||||
evidence the driver generalizes beyond the substrate layer, which was the open question.
|
||||
The `apl` migration earlier *surfaced a latent bug*: the old awk extractor
|
||||
under-counted `pipeline` (40 vs the real 152 assertions); true apl total is **562**,
|
||||
not 450 — evidence that adopting the driver also improves correctness.
|
||||
- **Not a target (different harness shape):** `lua/conformance.sh` is a Python runner
|
||||
(`lib/lua/conformance.py`) that walks real `*.lua` source files via `lua-eval-ast`
|
||||
and classifies pass/fail/timeout — it does not run SX `deftest` suites with a
|
||||
counter/dict scoreboard, so the shared driver does not fit. Excluded, not pending.
|
||||
- **Remaining hand-rolled candidates (~120–220 lines each):** `common-lisp, erlang,
|
||||
feed, forth, go, js, ocaml, smalltalk, tcl` — now being worked by the dedicated
|
||||
`conformance` loop (above). (`lua` excluded: walks real `*.lua` files via Python.
|
||||
`smalltalk` likely excludes too — runs `*.st` via its own `test.sh`. `search` was
|
||||
thought to be excluded but DID migrate via counters mode — see the 7-adopter note.)
|
||||
- **Action:** each remaining subsystem's OWN loop migrates when quiescent — add a
|
||||
`conformance.conf` (+ a `test-harness.sx` preload defining its counters) and
|
||||
replace `conformance.sh` with the 1-line exec shim
|
||||
(`exec bash …/guest/conformance.sh …/conformance.conf "$@"`). Recipe template:
|
||||
`lib/haskell/conformance.conf` (counters) or `lib/prolog/conformance.conf` (dict).
|
||||
Keep the `bash lib/X/conformance.sh` entry point so no loop is disrupted.
|
||||
- **Priority: HIGH** (15 consumers, low risk, interface-preserving, additive).
|
||||
- **8 adopters on architecture** (pass 25): acl, apl, datalog, **events**, haskell, mod,
|
||||
prolog, search — `events` migrated via its OWN loop; `search` via counters mode (which
|
||||
corrects the earlier "search excluded" note). **+4 on the `loops/conformance` branch:
|
||||
`common-lisp` 487/487, `erlang` 761/761, `feed` 189/189, `go` 609/609** — pending merge.
|
||||
**5 EXCLUDED — all foreign-runner harnesses** (correctly, not force-migrated): `forth`
|
||||
(Hayes core.fr via awk+python), `js` (test262 `.js`/`.expected`), `ocaml` (scrapes
|
||||
`test.sh` + `.ml` baseline), `smalltalk` (scrapes `test.sh` + `*.st` corpus), `tcl`
|
||||
(foreign `*.tcl` vs `# expected:` annotations).
|
||||
- **✅ CONFORMANCE LOOP WORKLIST COMPLETE (pass 31).** Final A1 picture:
|
||||
- **12 on the shared driver:** acl, apl, datalog, events, haskell, mod, prolog, search
|
||||
(on architecture) + common-lisp, erlang, feed, go (on `loops/conformance`, pending merge).
|
||||
- **6 correctly excluded** (foreign-program runners — testing a language impl against an
|
||||
external corpus is legitimately a different harness): forth, js, ocaml, smalltalk, tcl, lua.
|
||||
- **Honest finding:** the driver's reach is narrower than the raw "15 conformance.sh"
|
||||
count implied — language substrates that run real `.lua/.st/.ml/.tcl/.js/.fr` programs
|
||||
*should* keep their foreign runners. ~half migrate, ~half don't, and that's correct.
|
||||
- **One step left:** merge `loops/conformance` → architecture under the **adopter-parity
|
||||
check** (the coordination flag above — the shared `lib/guest` driver change must be
|
||||
proven non-regressive against all existing adopters first). The loop is now idle.
|
||||
- **NOW IN PROGRESS — dedicated loop (2026-06-07).** A human-triggered `conformance` loop
|
||||
(worktree `/root/rose-ash-loops/conformance`, branch `loops/conformance`, tmux session
|
||||
`a1-conformance`, briefing `plans/agent-briefings/conformance-loop.md`) is working the
|
||||
remaining candidates (common-lisp, erlang, feed, forth, go, js, ocaml, smalltalk, tcl)
|
||||
one per iteration, **classify-then-migrate-or-exclude with a hard test-count parity gate**
|
||||
(reverts on any mismatch; never pushes to main/architecture). Radar tracks; it implements.
|
||||
- **Driver-capability boundary found (pass 24, first iteration).** The loop did NOT
|
||||
force-migrate `common-lisp` (baseline 305/0 across 12 suites) — the shared driver can't
|
||||
reproduce it: `MODE=counters` supports only ONE global pass/fail counter pair + ONE fixed
|
||||
preload set, but common-lisp needs **per-suite counter names** (8 distinct pairs) and
|
||||
**per-suite preload chains**. It logged a precise blocker + unblock path (extend the
|
||||
`SUITES` entry format with optional per-suite counters/preloads) and moved on.
|
||||
- **Driver gap RESOLVED next iteration (pass 25) — but it touched the shared driver.** The
|
||||
loop extended `lib/guest/conformance.sh` (+38 lines: optional per-suite counters + per-suite
|
||||
preloads in the `SUITES` format, backward-compatible) and then migrated common-lisp at
|
||||
**487/487** (above the 305 baseline — likely another extractor under-count correction, à la
|
||||
apl's `pipeline`). The parity gate held throughout.
|
||||
- **⚠ COORDINATION FLAG (radar): the `loops/conformance` branch now carries a change to the
|
||||
SHARED `lib/guest` driver** used by all 8 adopters. It's additive by design, but **before
|
||||
this branch merges to `architecture`, re-run the existing adopters' suites under the new
|
||||
driver to confirm zero regression** (acl/apl/datalog/events/haskell/mod/prolog/search).
|
||||
This is the one cross-cutting risk in an otherwise per-subsystem-isolated effort — surfaced
|
||||
here so the merge is gated on adopter-parity, not assumed.
|
||||
|
||||
---
|
||||
|
||||
## Watching (real but not yet through the gate)
|
||||
|
||||
### W1 · Federation scaffold (merge / ingest / backfill / trust-gate)
|
||||
- **FAILS the structural-identity gate (deep-dived 2026-06-06, all 4 read).** Consumer
|
||||
count is met (4) but they are *superficially* similar, not structurally identical —
|
||||
the federated unit and merge op differ fundamentally:
|
||||
|
||||
| Subsystem (file) | Federated unit | Merge op | Trust gate | Injected transport |
|
||||
|---|---|---|---|---|
|
||||
| feed (`fed.sx:14,18,40`) | activity streams | dedupe by `(actor verb object)` | none (visibility via `permit?` separately) | `send-fn`, `fetch-fn` |
|
||||
| search (`fed.sx:8`) | inverted indices | relabel DocId `peer*1000+local` + union posting lists | none | none (pure merge fn) |
|
||||
| mod (`fed.sx:11-14,99`) | moderation decisions | advisory-list vs applied-list; bind iff `mod/trusted?` | **yes — runtime list** `mod/trusted? peer scope` | mock outbox / `fed-send!` |
|
||||
| acl (`federation.sx:43,56`) | Datalog delegate facts | pull facts, gate by `trust`/`level_covers` rule, re-saturate | **yes — Datalog rule** at query time | `transport` dict |
|
||||
| events (`federation.sx`) | calendar agendas | fold trusted peers' agendas into one sorted agenda + `:origin` provenance | **yes — runtime list** `ev/trusts?` (peer-id ∈ trust-set) | injected behind `ev/peer-agenda` |
|
||||
|
||||
- **The ONLY real commonality is the injection seam** (now 5/5, pass 18), not extractable
|
||||
code: every one says "the real transport is `fed-sx`'s job; inject `send-fn`/`fetch-fn`/
|
||||
`transport`/`peer-agenda` and mock it in tests." That is an architectural *convention the
|
||||
fleet already follows*. The merge op diverges 5 ways (dedupe / index-union / advisory /
|
||||
fact-saturation / agenda-sort). The trust gate, where present, splits: **mod + events use
|
||||
a runtime trust-set membership check; acl uses a declarative Datalog rule** — so even the
|
||||
trust sub-pattern is 2-of-3, and the membership check is a trivial one-liner (below the
|
||||
extraction threshold). No shared merge, no single shared trust mechanism.
|
||||
- **Disposition:** do NOT extract a shared "federation lib." When `fed-sx` ships its
|
||||
real transport, these 4 become its *consumers* (wiring `send-fn`/`fetch-fn`/`transport`
|
||||
to it) — that work belongs to each subsystem's loop + the `fed-sx` loop, not a
|
||||
cross-cutting extraction. Stop re-proposing on the shared name. Home: `fed-sx`.
|
||||
- **Now 7 federation modules (pass 29):** + `relations` (Phase 4: erel trust-gating,
|
||||
peer_rel/trust, fed-sx mock transport — Datalog-rule trust like acl) and `artdag`
|
||||
(Phase 6: content-addressed cache + trust + **invalidation** — a merge shape unlike any
|
||||
other). Each new one reinforces "theme not shape": 7 divergent merges, all sharing only
|
||||
the inject-fed-sx-transport seam. Verdict unchanged — they're fed-sx consumers-in-waiting.
|
||||
- **Narrower sub-claim (mod note, pass 6; refined pass 18):** mod asserts the *fed
|
||||
trust/outbox* shape shares between mod+acl. Radar evidence refines this: the trust gate
|
||||
splits by mechanism, not by subsystem pair — **mod + events** both use a runtime
|
||||
trust-set membership check (`mod/trusted?`, `ev/trusts?`), while **acl** uses a Datalog
|
||||
rule. So a "trust-set membership" helper has 2 consumers (mod, events) — but it's a
|
||||
one-line `member?` and the merge it gates diverges, so still not worth extracting.
|
||||
Resolve at the architecture-merge point if a heavier shared trust-set surface emerges.
|
||||
|
||||
### W2 · Per-viewer visibility / permission filter
|
||||
- **2 shipped consumers, same shape** — `filter <injected-permit> <ranked/candidate stream>`:
|
||||
- `feed/lib/feed/acl.sx:27` `feed/visible = (feed/filter stream (fn (a) (permit? viewer a)))`,
|
||||
capstone at `:34` (stream → ACL → rank → top-N). `permit?` injected, sig `(viewer activity)→bool`.
|
||||
- `search/lib/search/fed.sx:16` `aclFilter permit docs = filter permit docs`;
|
||||
`topNTfIdfAcl n permit ts idx = take n (aclFilter permit (rankTfIdf ts idx))`.
|
||||
`permit` injected, sig `DocId→Bool` (viewer baked in by caller).
|
||||
- **NOT a consumer:** `mod/lib/mod/policy.sx` is moderation policy (reviewer actions),
|
||||
no per-viewer read filter. So mod won't be the 3rd.
|
||||
- **Missing:** (a) only 2 consumers, need ≥3; (b) the two interfaces *diverge* —
|
||||
feed passes `(viewer, item)`, search bakes the viewer in — so any shared form must
|
||||
pick a convention; (c) both already **inject** the predicate, and the filter body is
|
||||
literally one line (`filter permit xs`). Leaning toward: the predicate's home is
|
||||
`acl-on-sx` (`permit?`), and the one-line filter is too thin to extract.
|
||||
- **Home when ripe:** delegate `permit?` to `acl-on-sx`; do NOT extract the filter.
|
||||
Re-check if a 3rd genuine per-viewer read filter ships (e.g. events/commerce).
|
||||
|
||||
### W3 · Collection helpers (group-by, dedupe-by-key, stable top-N, distinct-order, offset/limit page)
|
||||
- feed built all of these on APL primitives. search/commerce/events will want
|
||||
group-by / top-N.
|
||||
- **NEW (2026-06-06): offset/limit pagination shipped in 2 subsystems, identical shape**
|
||||
`take limit (drop offset xs)`:
|
||||
- `feed/lib/feed/page.sx:9` `feed/page` (offset/limit window over a stream).
|
||||
- `search/lib/search/page.sx:9` `paginate off lim docs = take lim (drop off docs)`.
|
||||
- NOT a 3rd: `persist/lib/persist/query.sx:5` has a *since-cursor* for incremental log
|
||||
consumption — resumable-stream semantics, not result windowing. Different shape.
|
||||
- feed *also* has cursor-by-`:at` recency pagination (`page.sx:21-44`); search has no
|
||||
cursor. So only the plain offset/limit window is shared, and it is a literal 1-liner.
|
||||
- **Missing:** ≥3 stable consumers; AND every item here is collection math that belongs
|
||||
in the **substrate** (APL/Haskell already expose grade/sort/unique/take/drop), not a
|
||||
shared lib. A 1-line `take/drop` window is far below the extraction threshold. Watch;
|
||||
revisit only if a non-substrate subsystem needs the same windowing without take/drop.
|
||||
- **Filename-collision caution (pass 13):** `content/lib/content/page.sx` is an **HTML
|
||||
page wrapper** (full HTML5 doc), NOT pagination — do not count it as a 3rd pagination
|
||||
consumer. `page.sx` now means two unrelated things across the fleet. Re-tested pass 13:
|
||||
pagination still only feed + search (2).
|
||||
|
||||
### W4 · In-memory store fakes → `persist-on-sx`
|
||||
- Not an abstraction to extract — a migration target. Every subsystem fakes its
|
||||
store with a mutable list (`feed/-log`, flow store, mod audit, …).
|
||||
- **Owner:** `persist-on-sx` (in progress). Tracked there, listed here for visibility.
|
||||
- **Concrete instance (file:line, found pass 4): the append-only decision/audit log.**
|
||||
`acl/lib/acl/audit.sx` and `mod/lib/mod/audit.sx` are the SAME hand-rolled shape, and
|
||||
`persist/lib/persist/log.sx` (the persist *log facet*) already implements it durably:
|
||||
|
||||
| role | acl/audit.sx | mod/audit.sx | persist/log.sx (target) |
|
||||
|---|---|---|---|
|
||||
| log var | `acl-audit-log` :9 | `mod/*audit-log*` :10 | backend stream |
|
||||
| monotonic seq | `acl-audit-seq` :10 | `mod/*audit-seq*` :11 | per-stream high-water :1 |
|
||||
| append (auto-seq) | `acl-audit-decide!` | commit :32 | `persist/append` :17 |
|
||||
| count | `acl-audit-count` :51 | `mod/audit-count` :44 | `persist/count` :12 |
|
||||
| read-all oldest-first | snapshot/tail :73 | `mod/audit-all` :43 | `persist/read` :29 |
|
||||
| read seq≥from | — | by-seq | `persist/read-from` :31 |
|
||||
|
||||
Both deliberately use a monotonic seq with **no wall-clock** (deterministic/testable) —
|
||||
identical to persist/log's design. Action when persist's host adapter lands: acl + mod
|
||||
loops swap their in-memory log for `persist/log`. 2 consumers today; not a new lib —
|
||||
the home already exists. Belongs to acl/mod loops × persist loop, not an extraction.
|
||||
- **Cross-loop corroboration (pass 6):** the mod loop independently reached the same
|
||||
conclusion — `mod/plans/mod-on-sx.md` (commit 538b8a53): *"mod-sx (Prolog) and acl-sx
|
||||
(Datalog) converged on the same module shape … only the audit log + fed trust/outbox
|
||||
shapes truly share; extract at the architecture-merge point, refactoring both consumers
|
||||
atomically, not unilaterally from a loop branch."* Confirms the shape AND the
|
||||
do-not-extract-unilaterally stance.
|
||||
- **Home disagreement to resolve at merge:** mod's note proposes lifting the audit-log
|
||||
primitives into **`lib/guest/`**. Radar routing disagrees: a durable append-only log is
|
||||
a **`persist-on-sx`** concern (the log facet already exists), not language-impl plumbing.
|
||||
Hold the line — `lib/guest` is lexer/parser/AST/HM/test-runner, not an event log.
|
||||
- **Migration is becoming concrete:** new `host-persist` loop (worktree + tmux, pass 6)
|
||||
is building the durable-storage host adapter persist was blocked on — once it lands,
|
||||
acl/mod can actually swap to `persist/log`.
|
||||
- **LIVE REFERENCE EXEMPLAR (pass 9): `content` already does it right.** `content`
|
||||
(Phase 2 complete, 162/162) built its op log directly on `persist/log` instead of
|
||||
faking it — `content/lib/content/store.sx`: backend injected via `(persist/open)`
|
||||
("content knows nothing about which backend", :10); append op as event
|
||||
`persist/append b (content/-stream doc-id) …` (:20); read `persist/read` (:36);
|
||||
`persist/last-seq` (:47); **version = replay op stream up to a seq**
|
||||
(filter `persist/event-seq ev <= seq`, :61). "The op log is the source of truth …
|
||||
the materialised doc is a cache, never primary state."
|
||||
This proves the W4 target is real, not hypothetical: acl + mod's hand-rolled
|
||||
monotonic-seq logs should adopt exactly content's `persist/log` pattern.
|
||||
- **Consumer ledger of the append-only monotonic-seq event log (pass 11):**
|
||||
|
||||
| consumer | what | backing | note |
|
||||
|---|---|---|---|
|
||||
| content (`store.sx`) | doc op log | **persist/log ✓ live** | plain append + replay-to-seq |
|
||||
| commerce (`ledger.sx`) | order ledger | **persist/log ✓ live** | `persist/append-once` — idempotent, webhook-replay-safe :40,58 |
|
||||
| events (`booking.sx`) | booking roster | **persist/log ✓ live** | `persist/append-expect` — optimistic-concurrency CAS, capacity-safe, lock-free |
|
||||
| acl (`audit.sx`) | decision log | in-memory fake (SX) | migrate directly when host adapter lands |
|
||||
| mod (`audit.sx`) | decision log | in-memory fake (SX) | migrate directly |
|
||||
| identity (`audit.sx`) | grant ledger | in-memory fake (**Erlang**) | `{Seq,Subject,Action}`; needs an **Erlang↔persist bridge** first — author scoped it out until persist lands ("queryable semantics identical") |
|
||||
|
||||
- **Two takeaways:** (1) the pattern is **validated across domains** — CRDT doc ops,
|
||||
financial orders, event bookings, rule decisions, OAuth grants all reduce to the same
|
||||
append-only monotonic-seq stream; (2) migrating to `persist/log` is strictly *better*
|
||||
than the fakes — persist exposes a **feature ladder the fakes don't have**:
|
||||
`append` (content) → `append-once`/idempotency (commerce) → `append-expect`/optimistic-
|
||||
concurrency (events). Every fake would have to reinvent a weaker version of these.
|
||||
This is an **adoption** item (the home already exists), NOT a new extraction — owned by
|
||||
persist/host-persist × each consumer loop. The SX fakes (acl, mod) migrate directly;
|
||||
the Erlang fake (identity) is gated on an Erlang↔persist bridge.
|
||||
|
||||
### W5 · Proof-tree explanation over a logic-program derivation
|
||||
- `acl/lib/acl/explain.sx` (reconstructs a canonical proof by goal-directed search over a
|
||||
saturated Datalog db) and `mod/lib/mod/explain.sx` (renders a Prolog-style proof tree
|
||||
goal-by-goal with proved/unproved marks + unification bindings) are the same *idea*.
|
||||
- **Missing / disposition:** only 2 consumers, and they sit on **different substrates**
|
||||
(acl→`lib/datalog`, mod→`lib/prolog`). Proof reconstruction/rendering is logic-engine
|
||||
machinery → it belongs in each **substrate** (datalog/prolog), not a shared app lib.
|
||||
Watch; revisit only if a 3rd logic-backed subsystem reimplements proof explanation.
|
||||
- **Cross-loop note (pass 6):** mod's note calls `mod/proof-goals` (re-query-each-goal)
|
||||
generic and proposes lifting it into **`lib/guest/`**. Radar caveat: proof-tree
|
||||
reconstruction *is* engine-agnostic logic machinery, but `lib/guest` is for
|
||||
lexer/parser/AST/HM/match/test-runner — a logic-engine proof helper is a poor fit there.
|
||||
If genuinely shared by ≥3 engines, a `lib/logic`-style substrate helper is the better
|
||||
home than `lib/guest`. Still 2 consumers → stays Watching either way.
|
||||
|
||||
---
|
||||
|
||||
### W9 · Parent/child relationship tracking → the new `relations` subsystem (nascent)
|
||||
- **New subsystem (pass 28):** `relations` (loops/relations, Phase 1 — `schema.sx`+`api.sx`,
|
||||
rel facts + `relate`/`unrelate`/`children`/`parents`/`related`, 22 tests). Per CLAUDE.md
|
||||
it's the canonical "cross-domain parent/child relationship tracking."
|
||||
- **Why watch:** several subsystems already track parent/child *locally* — feed reply-to
|
||||
threading (`thread`/`replies`), content nested block trees, events occurrence/RECURRENCE-ID
|
||||
links. If `relations` becomes the shared home, those are candidate *delegators* (like
|
||||
acl=authZ, persist=log). But it's **Phase 1, pre-Phase-2, moving target** — and each
|
||||
local impl is currently domain-specific (different keys/semantics). Do NOT propose yet.
|
||||
Re-check when relations is past Phase 2 AND ≥3 subsystems' relationship logic could
|
||||
genuinely delegate to it. `artdag` also just spawned (nascent, 0 files) — tracking only.
|
||||
(pass 32: `dream` + `maude` also spawned, nascent 0-files; `fed-prims` resumed.)
|
||||
- **Update pass 29:** relations rocketed to **Phase 4** (one gate — past Phase 2 — now met),
|
||||
but it's building ITSELF out (schema/federation), **not yet being consumed** by anyone.
|
||||
The blocker is the other gate: 0 subsystems currently *delegate* their parent/child logic
|
||||
to it (feed/content/events still track locally). Watch for the first real delegation.
|
||||
(artdag also raced to Phase 6 — these ports advance fast; treat committed state as truth.)
|
||||
|
||||
### W8 · Durable externally-resumed orchestration on `lib/flow` (suspend→host-IO→resume)
|
||||
- **The shared shape:** a durable `flow` that `request`s an external action (a suspend
|
||||
point), the **host** performs the IO, then `flow/resume`s the flow with the outcome;
|
||||
flow's deterministic replay means a completed step never re-runs on recovery.
|
||||
- **Consumers (pass 24): 2 LIVE** (events delivery, commerce order saga).
|
||||
- `events/lib/events/notify.sx` (**live**) — reminders/digests as durable flows;
|
||||
suspend on delivery `dispatch`, resume with send outcome. At-least-once + idempotency key.
|
||||
- `commerce` (**LIVE** as of pass 24 — "order lifecycle as a durable flow-on-sx flow,
|
||||
21 tests, Phase 3 done") — order saga `(defflow ordf … (request 'reserve oid) … )`:
|
||||
reserve→pay→fulfil as a flow, **payment stays suspended until the payment webhook calls
|
||||
`flow/resume`**. Carries only the order-id; pure orchestration over `ledger.sx`.
|
||||
- **Now 2 LIVE consumers** of the *same* pattern: long-running process, external resume
|
||||
(delivery dispatch vs payment webhook). fed-sx/mod still roll their own outbox (watch
|
||||
for convergence). Strengthens "lib/flow is the home"; still adoption, not extraction.
|
||||
- **Disposition:** `lib/flow` IS the abstraction (events proves it, commerce adopts it) →
|
||||
this is an **adoption** observation like W4, NOT an extraction. Home = `lib/flow`.
|
||||
- **Flow-onboarding friction (light signal):** commerce's note logs real gotchas adopting
|
||||
flow — `flow-make-env` returns a large likely-cyclic env (don't print it), env build is
|
||||
slow (budget ~540s like flow's own suite). If ≥3 subsystems hit the same onboarding
|
||||
gotchas, that's a signal to smooth `lib/flow`'s adopter API — flow's concern, flagged here.
|
||||
- **Name-collision caveat:** `notify.sx` means two unrelated things — `feed/notify.sx` is
|
||||
a *read-side digest* (group inbox by verb+object), NOT delivery. Do not pair them.
|
||||
|
||||
### W7 · Snapshot/projection-checkpoint reimplemented vs `persist/snapshot` (delegate)
|
||||
- `persist/lib/persist/snapshot.sx` already provides a **generic** projection checkpoint:
|
||||
store `{:value :seq}` in the kv facet under a namespaced key; the headline property is
|
||||
**snapshot + tail == full replay** (pure, clock-free).
|
||||
- `content/lib/content/snapshot.sx` **reimplements that same pattern on raw persist KV**
|
||||
rather than delegating: `persist/kv-put b (content/-snap-key doc-id) {:doc … :seq seq}`
|
||||
(:20), `persist/kv-has?`/`kv-get` (:27-28), and its own tail-replay (:53-59). It never
|
||||
calls `persist/snapshot-*`. content's doc-materialisation *is* a projection fold over
|
||||
its op stream — exactly what `persist/snapshot` checkpoints generically.
|
||||
- **Disposition:** persist-adoption nudge (like W4): content could delegate to
|
||||
`persist/snapshot` (its projection = "fold ops → doc"), dropping the duplicated
|
||||
KV+replay code. Home already exists → NOT an extraction; owned by content × persist
|
||||
loops. Only 1 reinventor today; watch whether commerce/events/identity also hand-roll a
|
||||
snapshot on raw KV instead of using the facet (would strengthen the nudge). NB timeline:
|
||||
unclear if `persist/snapshot` predated content's — flag, don't blame.
|
||||
|
||||
### W6 · Guarded lifecycle state machine (illegal transition = explicit error)
|
||||
- Recurs as a **design principle**, NOT a shared structure (found pass 10):
|
||||
- `mod/lib/mod/lifecycle.sx` — pure SX: immutable case `{:state :error :history …}`,
|
||||
explicit transition table `mod/lc-transitions` (:31), illegal transition returns the
|
||||
case unchanged with `:error` set. States open→triaged→decided→appealed→final.
|
||||
- `identity/lib/identity/membership.sx` — an **Erlang `gen_server`** fragment (identity
|
||||
runs on erlang-on-sx): a `receive` loop with `case find(...) of … {error, St}` guards.
|
||||
States none→pending→active→lapsed→revoked.
|
||||
- **Both share the guideline** ("invalid transitions are explicit errors, never silent
|
||||
no-ops") but **implement it substrate-idiomatically** — SX transition-table over
|
||||
immutable values vs an Erlang process loop with per-message case guards. Same W1/`api.sx`
|
||||
trap: shared *idea*, divergent *structure*.
|
||||
- **Disposition:** not an extraction target — the FSM mechanism is ~10 substrate-specific
|
||||
lines; the value is in each domain's state graph, not the plumbing. At most a **design
|
||||
guideline** ("model lifecycle as a guarded FSM with explicit-error transitions"). Watch
|
||||
whether commerce-checkout / events-booking add their own — if so it confirms the
|
||||
*guideline*, still not a lib. Do not propose extracting a shared state-machine lib.
|
||||
|
||||
## Rejected (considered, declined — do not re-propose)
|
||||
|
||||
- **"Continuous auto-implementing abstractor loop."** Rejected at design time: an
|
||||
agent writing across `lib/<x>/**` breaks the worktree isolation that makes the
|
||||
fleet safe, and is rewarded for manufacturing premature/wrong abstractions. The
|
||||
radar is read-only by design. (This file is the alternative.)
|
||||
- **Shared `api.sx` "public boundary" module (×6).** Rejected pass 4-5: every subsystem
|
||||
has an `api.sx` (acl, feed, flow, mod, persist, search — a 100% filename match), but it
|
||||
is a naming *convention for the public entry point*, not a shared structure. They
|
||||
disagree on the most basic contract: acl/feed use **implicit module state**
|
||||
(`acl/api.sx` "implicit current db", `feed/api.sx` "single mutable log") while
|
||||
`persist/api.sx` threads an **explicit backend as every call's first arg**; flow's api
|
||||
*builds a Scheme env*, search's api *concatenates a Haskell source string*, mod's is a
|
||||
*lifecycle state-machine façade* (17 defs vs persist's 1). Same role, no common shape —
|
||||
the W1 coincidental-resemblance trap. Do not re-propose on the filename.
|
||||
- **Shared `wire.sx` "serialization" module (×2).** Rejected pass 15: content + mod both
|
||||
have a `wire.sx`, but `content/wire.sx` uses the **generic SX serializer**
|
||||
(`serialize`/`parse`, full-fidelity round-trip) while `mod/wire.sx` is a **bespoke
|
||||
versioned pipe-delimited line** (subset of fields, `split` hand-built over slice/len
|
||||
because mod's Prolog-loaded env strips string prims). Shared role (wire format),
|
||||
divergent structure + substrate constraint → not a candidate; the SX serializer is
|
||||
already the shared tool for SX-substrate subsystems, and mod can't use it. (Same family
|
||||
as the `api.sx` rejection above.)
|
||||
- **Dumping app-domain plumbing into `lib/guest`.** Rejected: `lib/guest` is for
|
||||
language-implementation plumbing. App patterns route to acl/fed-sx/persist/
|
||||
substrate/host instead (see the routing rule in the briefing).
|
||||
102
plans/acl-on-sx.md
Normal file
102
plans/acl-on-sx.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# acl-on-sx: Access Control on Datalog
|
||||
|
||||
rose-ash needs fine-grained, explainable, federation-aware access control. Subjects
|
||||
(users, groups, roles, services) × actions (read, edit, comment, moderate, federate)
|
||||
× resources (pages, posts, threads, peers). Decisions must come with a trace — not just
|
||||
permit/deny, but **why**.
|
||||
|
||||
Datalog's bottom-up rule engine produces transparent permit/deny chains: the proof tree
|
||||
is the audit trail. Inheritance over groups + resource hierarchies is recursive Datalog
|
||||
in one rule. Federation extends naturally — fed-sx replicates ACL facts, peers reason
|
||||
over the union.
|
||||
|
||||
End-state: a Datalog-on-SX layer specifically for ACL, with explanation API, audit log,
|
||||
and federation extension. Reuses `lib/datalog/` evaluator and term model where possible.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/acl/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/acl/**` and `plans/acl-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, `lib/datalog/**`, or other `lib/<lang>/`. You may **import**
|
||||
from `lib/datalog/` (its public API in `lib/datalog/datalog.sx`); do **not** copy or
|
||||
modify Datalog code.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** thin layer on top of `lib/datalog/`. Define schema, surface API,
|
||||
audit + federation hooks. The rule engine itself is Datalog's.
|
||||
- **Watch for shared patterns** going into `lib/guest/` — both acl-sx and mod-sx need
|
||||
rule-engine plumbing. If you find shared shape, flag it for extraction (don't
|
||||
extract yet — wait for mod-sx to start).
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
ACL declarations (SX) User query
|
||||
│ │
|
||||
▼ ▼
|
||||
lib/acl/schema.sx lib/acl/api.sx
|
||||
— subject sorts — (acl/permit? subj act res)
|
||||
— resource sorts — (acl/explain subj act res)
|
||||
— action sorts — (acl/audit subj act res :allowed?)
|
||||
— fact schema │
|
||||
│ ▼
|
||||
▼ lib/acl/engine.sx
|
||||
lib/acl/facts.sx — builds Datalog query
|
||||
— actor(id, kind) — invokes lib/datalog/
|
||||
— resource(id, kind) — extracts proof tree
|
||||
— member_of(actor, group) │
|
||||
— child_of(res, parent) ▼
|
||||
— grant(actor, act, res) lib/acl/audit.sx
|
||||
— deny (actor, act, res) — persistent decision log
|
||||
— query API
|
||||
```
|
||||
|
||||
## Phase 1 — Direct grants
|
||||
|
||||
- [ ] `lib/acl/schema.sx` — sorts: subject {user, group, role, service}, action,
|
||||
resource {page, post, thread, peer}
|
||||
- [ ] `lib/acl/facts.sx` — `actor`, `resource`, `grant`, `deny` predicates as Datalog
|
||||
EDB
|
||||
- [ ] `lib/acl/engine.sx` — `(permit? subj act res db)` reduces to Datalog query
|
||||
- [ ] `lib/acl/api.sx` — public `(acl/permit? ...)` taking implicit current db
|
||||
- [ ] `lib/acl/tests/direct.sx` — 15+ cases: direct grant, missing grant, explicit deny
|
||||
- [ ] `lib/acl/scoreboard.{json,md}` baseline
|
||||
- [ ] `lib/acl/conformance.sh` runs the suite
|
||||
|
||||
## Phase 2 — Inheritance
|
||||
|
||||
- [ ] `member_of(actor, group)` chain — group grants apply to members (transitive)
|
||||
- [ ] `child_of(res, parent)` chain — parent grants apply to children (transitive)
|
||||
- [ ] role expansion — role contains list of (action, resource) tuples
|
||||
- [ ] deny-overrides — explicit deny wins over inherited allow
|
||||
- [ ] `lib/acl/tests/inherit.sx` — 25+ cases: nested groups, deep resource trees,
|
||||
conflict resolution, deny precedence
|
||||
- [ ] document the deny-overrides choice in plan
|
||||
|
||||
## Phase 3 — Explanation + audit
|
||||
|
||||
- [ ] `(acl/explain subj act res)` → `{:allowed? T :proof <tree>}`
|
||||
- [ ] proof tree extracts from Datalog's derivation
|
||||
- [ ] `lib/acl/audit.sx` — append-only decision log (in-memory + serializer for disk)
|
||||
- [ ] `(acl/audit-tail n)` for recent decisions
|
||||
- [ ] `lib/acl/tests/explain.sx` — proof correctness, audit completeness
|
||||
|
||||
## Phase 4 — Federation
|
||||
|
||||
- [ ] peer trust facts — `peer(addr, kind)`, `trust(peer, level)`
|
||||
- [ ] delegated grants — `delegate(peer, actor, action, resource)`
|
||||
- [ ] cross-instance permit chain — query asks local + queries trusted peers via fed-sx
|
||||
- [ ] revocation propagation — fact retraction across federation
|
||||
- [ ] `lib/acl/tests/fed.sx` — federated grant chains (mock fed-sx transport in tests)
|
||||
|
||||
## Progress log
|
||||
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
|
||||
(loop fills this in)
|
||||
93
plans/agent-briefings/acl-loop.md
Normal file
93
plans/agent-briefings/acl-loop.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# acl-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/acl-on-sx.md` forever. **First subsystem loop after fed-sx.**
|
||||
Sits on `lib/datalog/` — rule engine reused, schema/api/audit/federation added on
|
||||
top. The deliverable isn't "implement Datalog ACL"; it's *also* to surface shared
|
||||
rule-engine plumbing into `lib/guest/` (the mod-sx loop will be the second consumer,
|
||||
validating extraction).
|
||||
|
||||
```
|
||||
description: acl-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/acl-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/acl`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/acl-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/acl/` — pick up from the most advanced file.
|
||||
3. If `lib/acl/tests/*.sx` exist, run them via `bash lib/acl/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/acl/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/datalog/datalog.sx` public API once — that's your substrate.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/acl-on-sx.md`:
|
||||
|
||||
- **Phase 1** — direct grants. Schema, EDB facts, engine, api, 15+ tests
|
||||
- **Phase 2** — inheritance (member_of, child_of, role expansion, deny-overrides)
|
||||
- **Phase 3** — explanation + audit (proof tree, audit log)
|
||||
- **Phase 4** — federation (peer trust, delegation, cross-instance permit chain)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/acl/**` and `plans/acl-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/datalog/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Worktree:** commit, then push to `origin/loops/acl`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`acl: child_of resource inheritance + 8 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
- **Watch for shared infrastructure** with future mod-sx (Prolog moderation). If you
|
||||
build a generic rule-engine adapter, note it in Progress log so the eventual
|
||||
`lib/guest/rules/` extraction has both consumers identified.
|
||||
|
||||
## ACL-specific gotchas
|
||||
|
||||
- **Datalog is bottom-up.** No goal-directed search. Don't reach for cut or
|
||||
backtracking — that's mod-sx's job. Your decisions emerge from fixpoint.
|
||||
- **Deny-overrides** is the policy: if both an allow and deny rule fire, deny wins.
|
||||
Encode this via stratified negation; document the choice clearly in plan.
|
||||
- **Inheritance termination:** recursive rules with `member_of` chains must
|
||||
terminate. Datalog guarantees this absent function symbols — don't introduce them
|
||||
in your schema.
|
||||
- **Proof tree shape:** Datalog's derivation graph is a DAG, not a tree, when the
|
||||
same fact is derived multiple ways. For audit, pick one canonical derivation
|
||||
(shortest, or first); document choice.
|
||||
- **Federation isn't transitive trust.** A peer's `delegate(...)` fact only applies
|
||||
if local `trust(peer, level)` covers the action class. Re-check trust on every
|
||||
query, not at fact-ingestion time.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/acl-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
@@ -11,7 +11,7 @@ isolation: worktree
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/erlang-on-sx.md`. Isolated worktree, forever, one commit per feature. Never push.
|
||||
You are the sole background agent working `/root/rose-ash/plans/erlang-on-sx.md`. Isolated worktree, forever, one commit per feature. Push to `origin/loops/erlang` after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
@@ -42,7 +42,7 @@ Every iteration: implement → test → commit → tick `[ ]` → Progress log
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **Delimited continuations** are in `lib/callcc.sx` + `spec/evaluator.sx` Step 5. `sx_summarise` spec/evaluator.sx first — 2300+ lines.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Worktree:** commit locally. Never push. Never touch `main`.
|
||||
- **Worktree:** commit, then push to `origin/loops/erlang`. Never touch `main`.
|
||||
- **Commit granularity:** one feature per commit.
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
|
||||
|
||||
109
plans/agent-briefings/fed-prims-loop.md
Normal file
109
plans/agent-briefings/fed-prims-loop.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# fed-prims loop agent (single agent, phase-ordered)
|
||||
|
||||
Role: iterates `plans/fed-sx-host-primitives.md` forever. Adds the pure-OCaml
|
||||
crypto / CBOR / CID / Ed25519 / RSA primitives and the native HTTP server that
|
||||
Erlang Phase 8 BIFs (and therefore fed-sx Milestone 1) are blocked on. One
|
||||
feature per commit.
|
||||
|
||||
```
|
||||
description: fed-prims host-primitive loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/fed-sx-host-primitives.md`.
|
||||
You run in an isolated git worktree on branch `loops/fed-prims`. You work the
|
||||
plan's phases in order (A→I), forever, one commit per feature. Push to
|
||||
`origin/loops/fed-prims` after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/fed-sx-host-primitives.md` — Phasing + Progress log + Blockers
|
||||
tell you where you are.
|
||||
2. `cd hosts/ocaml && dune build bin/sx_server.exe 2>&1 | tail` — must be green
|
||||
before new work. If broken and not by your last edit, Blockers + stop.
|
||||
3. `bash hosts/ocaml/browser/test_boot.sh` — the WASM kernel must boot. This is
|
||||
the regression you are most at risk of causing.
|
||||
4. Find the first unchecked `[ ]` phase. That is your iteration.
|
||||
|
||||
## The iteration
|
||||
|
||||
Implement → `dune build bin/sx_server.exe` (native) → **WASM build check**
|
||||
(`test_boot.sh`) → run the phase's tests → run the no-regression gate
|
||||
(`conformance.sh`, see plan) → commit → tick the `[ ]` → append one dated line
|
||||
to the Progress log (newest first) → push → stop.
|
||||
|
||||
One phase = one iteration = one commit. Do not batch phases.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `hosts/ocaml/lib/**`, `hosts/ocaml/bin/**`, and
|
||||
`plans/fed-sx-host-primitives.md`. The single exception is Phase I, which also
|
||||
edits exactly one Blockers entry in `plans/erlang-on-sx.md`. Do **not** touch
|
||||
`lib/erlang/**`, `spec/`, `lib/` root, other `lib/<lang>/`.
|
||||
- **Pure OCaml for `lib/` primitives.** No new opam deps. WASM-safe: no C stubs,
|
||||
no `Unix`/`Thread` in `lib/sx_primitives.ml`. The HTTP server (Phase H) is
|
||||
native-only — register it in `bin/sx_server.ml`, never in the lib.
|
||||
- **Prove WASM every commit.** `test_boot.sh` green is a phase gate, not
|
||||
optional. A broken WASM kernel = the phase failed; revert and rethink.
|
||||
- **No-regression gate:** OCaml `run_tests` + Erlang `conformance.sh` must stay
|
||||
at their current pass counts (Erlang 715/715 once the merge lands; otherwise
|
||||
whatever `lib/erlang/scoreboard.json` says). New crypto tests are additive.
|
||||
- **`.ml`/`.sh` files:** ordinary `Read`/`Edit`/`Write` — these are NOT `.sx`.
|
||||
Do not use sx-tree MCP for OCaml. (sx-tree is only if you ever touch `.sx`,
|
||||
which this loop should not.)
|
||||
- **Builds are slow.** Use a generous `timeout` on `dune build` (≥600s) and on
|
||||
`conformance.sh` (≥400s). If a build genuinely hangs >10min, Blockers + stop.
|
||||
- **Worktree:** commit, push `origin/loops/fed-prims`. Never `main`, never
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. `fed-prims: SHA-256 + 4 NIST
|
||||
vectors`. Update Progress log + tick box every commit.
|
||||
- **If blocked** two iterations on the same issue: Blockers entry, move to the
|
||||
next independent phase (A-G are largely independent; H is independent; only
|
||||
D depends on A+C, E depends on A).
|
||||
|
||||
## Crypto correctness gotchas
|
||||
|
||||
- **Test vectors are non-negotiable.** Every hash/sig phase lands with published
|
||||
vectors (NIST FIPS 180-4 / 202, RFC 8032, RFC 8949). A primitive without a
|
||||
passing standard vector is not done — do not tick the box.
|
||||
- **SHA endianness:** SHA-2 is big-endian length-append; SHA-3 is little-endian
|
||||
Keccak lane order. Easy to get backwards — the empty-string vector catches it.
|
||||
- **dag-cbor determinism:** map keys sorted by **byte length first, then
|
||||
bytewise**. Not lexicographic-only. The "reordered dict keys → identical
|
||||
bytes" test is the guard; it must be in the phase.
|
||||
- **CIDv1 layout:** `0x01 || codec-varint || (mh-code-varint || mh-len-varint ||
|
||||
digest)`, then multibase base32-lower with a leading `b`. Off-by-one in varint
|
||||
is the classic bug — cross-check one CID against `ipfs` CLI if available.
|
||||
- **Ed25519 verify is total:** wrong-length inputs return `false`, never raise.
|
||||
Verify checks `[S]B = R + [k]A` with `k = SHA512(R||A||M)` reduced mod L.
|
||||
- **RSA:** PKCS#1 v1.5 EMSA — the DigestInfo DER prefix for SHA-256 is fixed
|
||||
(`3031300d060960864801650304020105000420`). Constant-time not required (verify
|
||||
only, public data).
|
||||
|
||||
## General gotchas
|
||||
|
||||
- The `sx` library is `(wrapped false)` — new module `Sx_sha2` is referenced as
|
||||
`Sha2.f` is **wrong**; it's `Sx_sha2.f` unless you also alias. Check
|
||||
`lib/dune` `include_subdirs unqualified`: a new `lib/sx_sha2.ml` is module
|
||||
`Sx_sha2`. Match the existing `Sx_*` naming.
|
||||
- `Eval_error` is the primitive-error exception; raise it with `"name: shape"`.
|
||||
- Reach a primitive from SX to smoke-test:
|
||||
`printf '(epoch 1)\n(crypto-sha256 "abc")\n' | hosts/ocaml/_build/default/bin/sx_server.exe`
|
||||
- The native binary the conformance gate uses is
|
||||
`hosts/ocaml/_build/default/bin/sx_server.exe` — rebuild it before gating.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in OCaml unless non-obvious (crypto constants ARE non-obvious —
|
||||
cite the RFC/FIPS section in a one-line comment).
|
||||
- No new planning docs — update `plans/fed-sx-host-primitives.md` inline.
|
||||
- One feature per iteration. Build. WASM-check. Test. Gate. Commit. Log. Push.
|
||||
Next.
|
||||
|
||||
Go. Run the restart baseline. Find the first unchecked `[ ]`. Implement it.
|
||||
Remember: no commit without a passing standard test vector AND a green WASM
|
||||
boot.
|
||||
99
plans/agent-briefings/feed-loop.md
Normal file
99
plans/agent-briefings/feed-loop.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# feed-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/feed-on-sx.md` forever. **Activity feeds on APL** — timelines,
|
||||
notifications, fanout, ranking, all as APL array math on activity vectors. Densest
|
||||
possible expression of feed composition. Sits on `lib/apl/` (450+/450+ tests
|
||||
already); adds a feed-shaped vocabulary on top.
|
||||
|
||||
```
|
||||
description: feed-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/feed-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/feed`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/feed-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/feed/` — pick up from the most advanced file.
|
||||
3. If `lib/feed/tests/*.sx` exist, run them via `bash lib/feed/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/feed/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/apl/apl.sx` public API once — that's your substrate. Familiarize
|
||||
yourself with at least: `⍳ ⍴ / ⌽ ↑ ↓ ⌷ ∊ ∘.× /\ ⍋` (you will use all of these).
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/feed-on-sx.md`:
|
||||
|
||||
- **Phase 1** — stream model + basic ops (record schema, filter, sort, take)
|
||||
- **Phase 2** — **THE SHOWCASE**: fanout via outer product. activities `∘.×`
|
||||
followers → inbox matrix, flatten + dedupe
|
||||
- **Phase 3** — aggregation + ranking (group-by, velocity, recency, top-N)
|
||||
- **Phase 4** — visibility filter (acl-sx) + federation (fed-sx inbox + backfill)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/feed/**` and `plans/feed-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/apl/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes. APL glyphs land
|
||||
directly in source.
|
||||
- **Worktree:** commit, then push to `origin/loops/feed`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`feed: outer-product fanout + dedupe by (actor,verb,object) + 9 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
|
||||
## feed-specific gotchas
|
||||
|
||||
- **Activities are heterogeneous.** Different verbs carry different shapes
|
||||
(`:object` might be page-id, post-id, user-id). Don't over-normalize — keep
|
||||
`:tags` as a flexible bag. APL operations over heterogeneous records work fine
|
||||
via dict lookups; only the indexed fields need uniform shape.
|
||||
- **Fanout produces matrices fast.** N activities × M followers → NM items. Apply
|
||||
filter/dedupe early, not after materialization. Use guard predicates *inside*
|
||||
the outer product where possible (compose with `∘.{a v ⊢ ...}`).
|
||||
- **Dedupe key isn't always `(actor,verb,object)`.** For "alice liked X" and "bob
|
||||
liked X" the dedupe key is `(verb,object)` (collapse the actors into a list).
|
||||
For "alice posted X" each `:actor` is distinct. Each verb may want its own
|
||||
dedupe rule; codify these in `lib/feed/dedupe.sx`.
|
||||
- **Recency decay matters more than score precision.** Use a simple half-life decay
|
||||
(e.g. score × 0.5^(age/window)) rather than a clever curve. Calibrate the
|
||||
window via tests, not theory.
|
||||
- **Ranking should be deterministic on ties.** Always include a tiebreaker (id, or
|
||||
hash). Otherwise tests will flake.
|
||||
- **The ACL filter is per-viewer.** A timeline is computed *for* a user; the same
|
||||
candidate stream produces different timelines for different viewers. Don't
|
||||
cache pre-ACL timelines.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/feed-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
98
plans/agent-briefings/flow-loop.md
Normal file
98
plans/agent-briefings/flow-loop.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# flow-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/flow-on-sx.md` forever. **Durable workflows on Scheme** — the
|
||||
call/cc + delimited continuation showcase that justifies pulling R7RS into
|
||||
production. art-dag's natural successor: DAG-of-tasks with pause/resume across
|
||||
process restarts. fed-sx extension turns local flows into distributed ones.
|
||||
|
||||
```
|
||||
description: flow-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/flow-on-sx.md`.
|
||||
Isolated worktree, forever, one commit per feature. Push to `origin/loops/flow`
|
||||
after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/flow-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/flow/` — pick up from the most advanced file.
|
||||
3. If `lib/flow/tests/*.sx` exist, run them via `bash lib/flow/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/flow/scoreboard.md` exists, that's your baseline.
|
||||
5. Read `lib/scheme/scheme.sx` public API once — that's your substrate.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/flow-on-sx.md`:
|
||||
|
||||
- **Phase 1** — declarative DAG: `defflow`, `sequence`, `parallel`, sync runtime,
|
||||
basic api
|
||||
- **Phase 2** — control flow + error handling: `cond`, `retry`, `timeout`,
|
||||
`try-catch`
|
||||
- **Phase 3** — **THE SHOWCASE**: `suspend`/`resume` via `call/cc`, persistent
|
||||
store, crash recovery
|
||||
- **Phase 4** — distributed nodes via fed-sx (remote-node, handoff, replication)
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/flow/**` and `plans/flow-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/scheme/` only (its public API).
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If sx_server binary broken → Blockers
|
||||
entry, stop.
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after edits.
|
||||
- **Worktree:** commit, then push to `origin/loops/flow`. Never touch `main` or
|
||||
`architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`flow: retry combinator with exponential backoff + 6 tests`).
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
|
||||
## flow-specific gotchas
|
||||
|
||||
- **Continuations must be re-entrant.** Phase 3's `suspend` captures a continuation
|
||||
that may be re-entered after a process restart. That means: no captured file
|
||||
descriptors, no captured sockets, no captured live runtime references that won't
|
||||
survive serialization. State referenced by the continuation must be plain SX data
|
||||
or live in the flow store.
|
||||
- **call/cc, not call-with-escape-continuation.** R7RS distinguishes. Use the full
|
||||
call/cc for resume; escape-only continuations cannot be re-entered. Read
|
||||
`lib/scheme/r7rs.md` (or equivalent) to confirm semantics.
|
||||
- **`parallel` in Phase 1 is sequential.** Don't try threading until Phase 3+. Just
|
||||
evaluate branches in order, collect results, return joined value. Document the
|
||||
semantics clearly so users don't assume true concurrency.
|
||||
- **Retry doesn't retry continuations.** If a node has already suspended, retry on
|
||||
resume doesn't re-run it from scratch — it resumes. `retry` only applies to
|
||||
exceptions raised before suspend. Be explicit in the API.
|
||||
- **Cancellation invalidates the continuation.** `(flow/cancel id)` must remove the
|
||||
stored continuation so a stale `resume` cannot wake it. Document semantics.
|
||||
- **Timeouts in pure SX are tricky.** Without a scheduler, `timeout` is a budget on
|
||||
step count or wall-clock probed at safe points. Pick one approach (probably step
|
||||
budget for determinism) and document.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/flow-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
208
plans/agent-briefings/go-loop.md
Normal file
208
plans/agent-briefings/go-loop.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Go-on-SX loop agent (single agent, phase-ordered)
|
||||
|
||||
Role: iterates `plans/go-on-sx.md` forever. **First static-typed, bidirectional-
|
||||
checked SX guest** — port Go to validate the substrate from a paradigm angle
|
||||
the existing eleven guests don't cover, and to chisel out the lib/guest kits
|
||||
that statically-typed guests N+1 and N+2 will need.
|
||||
|
||||
```
|
||||
description: Go-on-SX implementation loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/go-on-sx.md`.
|
||||
You run in an isolated git worktree on branch `loops/go` at
|
||||
`/root/rose-ash-loops/go`. You work the plan's Phases in order (1→11), forever,
|
||||
one commit per feature. Push to `origin/loops/go` after every commit. Never
|
||||
`main`, never `architecture`.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/go-on-sx.md` — Phases + Progress log + Blockers tell you where
|
||||
you are.
|
||||
2. Pre-flight: `ls lib/guest/lex.sx lib/guest/pratt.sx lib/guest/ast.sx
|
||||
lib/guest/match.sx` — all four must exist. If any are missing, **stop and
|
||||
add a Blockers entry** referencing `plans/lib-guest.md`. Do not start.
|
||||
3. `ls lib/go/` — pick up from the most advanced file that exists. If the
|
||||
directory does not exist, you are at Phase 1.
|
||||
4. If `lib/go/tests/*.sx` exist, run them via the epoch protocol against
|
||||
`sx_server.exe`. They must be green before new work.
|
||||
5. **Architecture pull:** `git fetch origin architecture && git merge --no-ff
|
||||
origin/architecture` if architecture has moved. Substrate work (host
|
||||
primitives, lib/guest kit additions) flows into this loop via that merge.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/go-on-sx.md`:
|
||||
|
||||
- **Phase 1** — Tokenizer (`lib/go/lex.sx`). Consumes `lib/guest/core/lex.sx`.
|
||||
ASI is the tricky bit.
|
||||
- **Phase 2** — Parser (`lib/go/parse.sx`). Consumes `lib/guest/core/pratt.sx`
|
||||
+ `lib/guest/core/ast.sx`.
|
||||
- **Phase 3** — Bidirectional type checker (`lib/go/types.sx`).
|
||||
**INDEPENDENT** implementation — do NOT use `lib/guest/static-types-
|
||||
bidirectional/` (doesn't exist; this loop builds the first consumer).
|
||||
- **Phase 4** — Tree-walk evaluator (`lib/go/eval.sx`).
|
||||
- **Phase 5** — Goroutines + channels + select (`lib/go/sched.sx`).
|
||||
**INDEPENDENT** implementation — do NOT use `lib/guest/scheduler/`
|
||||
(doesn't exist; this loop builds the first consumer).
|
||||
- **Phase 5b** — Buffered channels + select fairness.
|
||||
- **Phase 6** — `defer` + panic/recover.
|
||||
- **Phase 7** — Generics (Go 1.18+).
|
||||
- **Phase 8** — Minimal stdlib (`lib/go/std/`).
|
||||
- **Phase 9** — End-to-end programs.
|
||||
- **Phase 10** — lib/guest extraction enabler (doc-only).
|
||||
- **Phase 11** — VM bytecode opcodes (deferred, optional).
|
||||
|
||||
Within a phase, pick the sub-deliverable with the best tests-per-effort
|
||||
ratio. Don't batch phases. One feature per commit.
|
||||
|
||||
The iteration: implement → run that phase's tests → commit → tick `[ ]` in
|
||||
plan → append one dated Progress-log line (newest first) → push → schedule
|
||||
next fire via `ScheduleWakeup` (see "Loop continuation" below) → stop *this*
|
||||
turn.
|
||||
|
||||
A single iteration does one feature. Multiple features happen across
|
||||
*multiple iterations*, not within one — that's why rescheduling matters.
|
||||
|
||||
## Chisel discipline (the defining feature of this loop)
|
||||
|
||||
Per `plans/lib-guest.md`. Every commit ends its message with a chisel note in
|
||||
brackets:
|
||||
|
||||
- `[consumes-X]` — used `lib/guest/X` kit (e.g., `[consumes-lex]`,
|
||||
`[consumes-pratt]`, `[consumes-ast]`, `[consumes-match]`).
|
||||
- `[shapes-scheduler]` — revealed something about what
|
||||
`plans/lib-guest-scheduler.md` should propose. Append a paragraph to that
|
||||
plan's design diary describing the insight.
|
||||
- `[shapes-static-types-bidirectional]` — same for
|
||||
`plans/lib-guest-static-types-bidirectional.md`.
|
||||
- `[proposes-Y]` — revealed a gap in another existing kit (e.g., `pratt.sx`
|
||||
doesn't handle Go's operator precedence properly). Blockers entry in the
|
||||
kit's plan describing the gap with minimal repro.
|
||||
- `[nothing]` — pure Go work that didn't touch substrate or lib/guest story.
|
||||
Rare; if you write `[nothing]` twice in a row, stop and reflect on whether
|
||||
the iteration could have been shaped to surface something.
|
||||
|
||||
**Sister plans must be updated.** When Phase 3 lands (independent checker
|
||||
working), append a paragraph to
|
||||
`plans/lib-guest-static-types-bidirectional.md` describing what synth/check
|
||||
shape emerged in Go. When Phase 5 lands (scheduler working), same for
|
||||
`plans/lib-guest-scheduler.md`. This is how the two-consumer rule actually
|
||||
pays off.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/go/**` and `plans/go-on-sx.md`. Single permitted
|
||||
cross-plan write: append-only paragraphs to the sister-plan design
|
||||
diaries (`plans/lib-guest-scheduler.md`,
|
||||
`plans/lib-guest-static-types-bidirectional.md`) on `shapes-*` commits.
|
||||
Do **not** touch `spec/`, `hosts/`, `shared/`, `lib/guest/**`
|
||||
(read-only consumer at this phase), or other `lib/<lang>/`.
|
||||
- **Consume `lib/guest/core/`** for lex/parse/ast/match/layout. Hand-
|
||||
rolling defeats the chiselling goal.
|
||||
- **Do NOT extract into `lib/guest/scheduler/` or `lib/guest/static-
|
||||
types-bidirectional/` from this loop.** Those extractions are gated on
|
||||
two consumers AND independent implementation. Extraction is its own
|
||||
workstream after Go and the second consumer both exist.
|
||||
- **Substrate gaps** → Blockers entry with minimal repro. Don't fix the
|
||||
substrate from this loop. Belongs to `sx-improvements.md`.
|
||||
- **NEVER call `sx_build` without timeout awareness** — 600s watchdog.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after every edit.
|
||||
Never `Edit`/`Read`/`Write` on `.sx`.
|
||||
- **Worktree:** branch `loops/go`, push `origin/loops/go`. Never `main`,
|
||||
never `architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
with chisel note: `go: lex.sx — keywords + ASI + 50 tests [consumes-lex]`.
|
||||
- **Plan file:** update Progress log + tick boxes every commit.
|
||||
- **If blocked** for two iterations on the same issue, add to Blockers and
|
||||
move on. Phases 1-4 are sequential; 5-8 are largely independent once
|
||||
4 lands.
|
||||
|
||||
## Conformance scoreboard
|
||||
|
||||
Create `lib/go/scoreboard.json` on first iteration. Suites: lex / parse /
|
||||
types / eval / runtime / stdlib / e2e. Update counts every commit. The
|
||||
scoreboard is also the no-regression gate: a commit that drops any suite's
|
||||
pass count is wrong, not the test.
|
||||
|
||||
## Go-specific gotchas (read once, never get bitten)
|
||||
|
||||
- **ASI (automatic semicolon insertion).** Newline becomes `;` after
|
||||
identifier/literal/`)`/`]`/`}`. Build it into the tokenizer (Phase 1),
|
||||
not the parser. Go spec § Semicolons is unusually precise.
|
||||
- **Untyped constants.** `42` is `untyped int` until contextualised.
|
||||
Canonical pitfall: `var x float64 = 42 / 7` must compute `42 / 7 = 6`
|
||||
as untyped, then convert to `6.0`. Not `42.0 / 7 = 6.0`. Not `(42/7).0
|
||||
= 6.0`. Test this in Phase 3.
|
||||
- **Methods vs functions.** Different lookup rules. Pointer-receiver
|
||||
methods are NOT in the value's method set for interface satisfaction.
|
||||
- **Interface satisfaction is structural and silent.** No `implements`
|
||||
declaration. Lazy check at every interface-typed slot.
|
||||
- **Channels have identity.** Distinct `make(chan int)` calls produce
|
||||
distinct channels with same type.
|
||||
- **`select` with `default`** = non-blocking. Without `default` = blocks.
|
||||
- **`nil` is typed.** `var i interface{} = (*int)(nil); i == nil` is
|
||||
`false` — i holds typed-nil-of-`*int`, not untyped nil. Footgun. Test.
|
||||
- **Goroutine panic propagation.** Unrecovered panic crashes whole
|
||||
program. Honour faithfully or document divergence.
|
||||
- **`defer` in a loop.** Each iteration pushes; all run on function
|
||||
return, not loop iteration. Common bug; tests must cover.
|
||||
- **Map iteration order is unspecified.** v1 = sorted SX-canonical key
|
||||
order for determinism. Document the divergence; provide a
|
||||
`runtime`-package knob to randomise later.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples
|
||||
in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks
|
||||
scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
- Shell heredoc `||` gets eaten — escape or use `case`.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious. Cite Go spec sections inline
|
||||
when a decision is non-obvious (the Go spec is rigorous — citations work).
|
||||
- No new planning docs — update `plans/go-on-sx.md` inline. Append paragraphs
|
||||
to sister-plan design diaries on `shapes-*` commits.
|
||||
- Short factual commit messages with chisel note in brackets:
|
||||
`go: parse short-decl + 6 tests [consumes-pratt]`.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Run the pre-flight check. If lib/guest kits are missing, stop. Otherwise
|
||||
read the plan, find the first unchecked `[ ]`, implement it. Remember:
|
||||
every commit ends with a chisel note, and the sister-plan design diaries
|
||||
get updates on `shapes-*` commits.
|
||||
|
||||
## Loop continuation
|
||||
|
||||
This briefing supersedes any "then stop" wording from the user's original
|
||||
`/loop` input. After pushing, **call `ScheduleWakeup` to fire the next
|
||||
iteration**, then end the turn. The `/loop` command is in dynamic mode;
|
||||
each iteration self-schedules the next.
|
||||
|
||||
- `delaySeconds`: **60** (minimum). This is a coding loop with no external
|
||||
event to wait on — back-to-back iterations are intended. Raise only if a
|
||||
prior fire reported a substrate blocker that needs settling.
|
||||
- `prompt`: the **full original `/loop` input verbatim, prefixed with
|
||||
`/loop `** (so the wake re-enters this skill and re-reads this briefing).
|
||||
Do NOT paraphrase or trim it — the runtime expects an exact echo.
|
||||
- `reason`: one short sentence, e.g. "next Go-on-SX iteration".
|
||||
|
||||
**Stop conditions** — omit `ScheduleWakeup` ONLY when:
|
||||
1. lib/guest pre-flight failed (missing kits) and a Blockers entry was
|
||||
added — the loop is parked waiting for substrate work.
|
||||
2. The same Blockers entry has been the reason for two consecutive
|
||||
iterations (avoid runaway no-op fires).
|
||||
3. plans/go-on-sx.md has every Phase 1-11 box checked.
|
||||
4. The user explicitly asks to stop, pause, or interrupt the loop.
|
||||
|
||||
Otherwise: reschedule. Always.
|
||||
106
plans/agent-briefings/kernel-loop.md
Normal file
106
plans/agent-briefings/kernel-loop.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# kernel-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/kernel-on-sx.md` forever. **First chisel of the Phase B stratification work** — natural successor to env-as-value, validates SX's reflection story (first-class environments, evaluators, operatives). Goal isn't just "implement Kernel"; it's *also* to surface common patterns into `lib/guest/` (specifically motivating a future `lib/guest/reflective/` sub-layer). One feature per commit.
|
||||
|
||||
```
|
||||
description: kernel-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## DO NOT START WITHOUT THE PREREQUISITES
|
||||
|
||||
This loop **must not** start until the lib-guest core kits are in place. Kernel's parser consumes `lib/guest/core/lex.sx` and `lib/guest/core/pratt.sx` (s-expression-shaped, minimal demand); its evaluator's pattern dispatch consumes `lib/guest/core/match.sx`.
|
||||
|
||||
**Pre-flight check:**
|
||||
```
|
||||
ls /root/rose-ash/lib/guest/lex.sx /root/rose-ash/lib/guest/pratt.sx \
|
||||
/root/rose-ash/lib/guest/match.sx /root/rose-ash/lib/guest/ast.sx
|
||||
```
|
||||
If any of those `lib/guest/*.sx` files are missing, **stop and report**. Do not start.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `/root/rose-ash/plans/kernel-on-sx.md`. You run in an isolated git worktree on branch `loops/kernel`. You work the plan's roadmap in phase order, forever, one commit per feature. Push to `origin/loops/kernel` after every commit.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/kernel-on-sx.md` — Roadmap + Progress log + Blockers tell you where you are.
|
||||
2. Run the pre-flight check above. If any lib/guest kit is missing, stop immediately and update the plan's Blockers section.
|
||||
3. `ls lib/kernel/` — pick up from the most advanced file that exists. If the directory does not exist, you are at Phase 1.
|
||||
4. If `lib/kernel/tests/*.sx` exist, run them via the epoch protocol against `sx_server.exe`. They must be green before new work.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/kernel-on-sx.md`:
|
||||
|
||||
- **Phase 1** — Parser (s-expression reader, minimal — consumes `lib/guest/lex` + `lib/guest/pratt`)
|
||||
- **Phase 2** — Core evaluator with first-class environments
|
||||
- **Phase 3** — `$vau` / `$lambda` / `wrap` / `unwrap` (the operative–applicative distinction)
|
||||
- **Phase 4** — Standard environment construction
|
||||
- **Phase 5** — Encapsulations (Kernel's opaque-type idiom)
|
||||
- **Phase 6** — Hygienic operatives (Shutt's later work — operatives that don't capture)
|
||||
- **Phase 7** — Propose `lib/guest/reflective/` (extraction phase — see chiselling discipline)
|
||||
|
||||
Within a phase, pick the checkbox with the best tests-per-effort ratio.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` in plan → append Progress log → push → next.
|
||||
|
||||
## Lib/guest chiselling discipline (the defining feature of this loop)
|
||||
|
||||
You are not just implementing Kernel — you are *chiselling* the substrate to surface what `lib/guest/reflective/` should contain. Every commit must end with a one-line **"chisel note"** appended to the plan's Progress log entry, in this format:
|
||||
|
||||
```
|
||||
chisel: <one of: consumes-X | shapes-reflective | proposes-Y | nothing>
|
||||
```
|
||||
|
||||
- `consumes-X` — this commit used an existing `lib/guest/X` kit (e.g., `consumes-pratt`, `consumes-match`).
|
||||
- `shapes-reflective` — this commit revealed something about what `lib/guest/reflective/` should look like (e.g., env-reification helper signatures, applicative-vs-operative dispatch protocol). Add a paragraph to the plan's "lib/guest feedback loop" section describing the insight.
|
||||
- `proposes-Y` — this commit revealed a gap in another existing kit (e.g., `match.sx` doesn't quite handle X). Open a Blockers entry describing the gap.
|
||||
- `nothing` — pure Kernel work that didn't touch the substrate or lib/guest story (rare; if you write this twice in a row, stop and reflect on why).
|
||||
|
||||
**Phase 7 (extraction)** is **gated** by the two-consumer rule. Kernel alone is one consumer. The natural second consumer is a future MetaScheme port, a Common-Lisp meta-evaluator port, or a Kernel dialect (cKanren-style). **Until a second consumer exists, do NOT actually extract** — instead, mark Phase 7 `[partial — pending second consumer]` and document the proposed `lib/guest/reflective/` API surface in the plan's progress log. The extraction itself happens later, when a second consumer materialises.
|
||||
|
||||
This discipline is the point of the loop, not a bookkeeping tax. The chisel notes are what tell us — at the end of Kernel's run — whether a `lib/guest/reflective/` sub-layer is real or just one-language-shaped.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/kernel/**` and `plans/kernel-on-sx.md`. Do **not** edit `spec/`, `hosts/`, `shared/`, `lib/guest/**` (read-only consumer at this phase), or other `lib/<lang>/`.
|
||||
- **Consume `lib/guest/core/`** wherever it covers a need. Hand-rolling defeats the chiselling goal.
|
||||
- **Do not extract into `lib/guest/reflective/` from this loop.** That's Phase 7 territory, gated by the two-consumer rule. Until there's a second consumer, document the API surface only.
|
||||
- **Substrate gaps** (env-as-value not exposing X, `eval` semantics drift, JIT not handling reflective patterns) → Blockers entry with minimal repro. Do **not** fix substrate from this loop. Substrate work belongs to `sx-improvements.md` / `jit-perf-regression.md`.
|
||||
- **NEVER call `sx_build`.** 600s watchdog will kill you. If `sx_server.exe` is broken, add a Blockers entry and stop.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. `sx_validate` after every edit. Never `Edit`/`Read`/`Write` on `.sx`.
|
||||
- **Worktree:** commit, then push to `origin/loops/kernel`. Never touch `main`. Never push to `architecture`.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages: `kernel: $vau operative + 6 tests`.
|
||||
- **Plan file:** update Progress log + tick boxes every commit. Include the chisel note.
|
||||
- **If blocked** for two iterations on the same issue, add to Blockers and move on.
|
||||
|
||||
## Kernel-specific gotchas
|
||||
|
||||
- **Operatives don't evaluate their arguments.** `$vau` builds an operative; the body sees the *unevaluated* argument expressions plus the dynamic environment. This is the opposite of every other guest in the set. `(define-via-vau)` builds a binding by calling `eval` inside the body on the (still-syntax) argument.
|
||||
- **Applicatives wrap operatives.** `(wrap op)` produces an applicative that evaluates its args first, then calls `op` with the values. `$lambda` is sugar for `wrap` ∘ `$vau`.
|
||||
- **Dynamic vs static environments.** Operative body sees both: the static env where the `$vau` was created (closure-style), AND the dynamic env where the call happens (passed as the env-param). Different from lexical-only languages.
|
||||
- **No special forms in the evaluator.** `$if`, `$define!`, `$lambda` are all just operatives bound in the standard environment. The evaluator is `lookup-and-call` — no hardcoded switch on symbols. This is the whole point: the language is reified as data.
|
||||
- **`eval` is a primitive callable on user environments.** This is where SX's env-as-value matters most. If env-as-value isn't fully landed in the substrate, this is where it'll break.
|
||||
- **Encapsulations (Phase 5) are Kernel's opaque-types idiom.** `make-encapsulation-type` returns three operatives: encapsulator (constructs), predicate (tests), decapsulator (extracts). Used to define promises, streams, modules.
|
||||
- **Hygienic operatives (Phase 6) are research-grade.** Shutt's later work. Operatives that don't accidentally capture caller bindings. Likely uses scope sets / frame stamps. Treat as exploration, not implementation-deadline.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- `list?` returns false on raw JS Arrays — host data must be SX-converted.
|
||||
- Shell heredoc `||` gets eaten — escape or use `case`.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/kernel-on-sx.md` inline.
|
||||
- Short, factual commit messages with chisel note: `kernel: $vau operative + 6 tests [shapes-reflective]`.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Run the pre-flight check. If lib/guest kits are missing, stop. Otherwise read the plan, find the first unchecked `[ ]`, implement it. Remember: every commit ends with a chisel note, and Phase 7 extraction waits for a second consumer.
|
||||
117
plans/agent-briefings/radar-loop.md
Normal file
117
plans/agent-briefings/radar-loop.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# abstraction-radar loop agent (read-only scout)
|
||||
|
||||
Role: continuously scan **all** rose-ash subsystems for genuine abstraction /
|
||||
deduplication opportunities and maintain a ranked, evidence-backed backlog at
|
||||
`plans/abstractions.md`. You are a **scout, not an implementer** — you detect and
|
||||
document; you never refactor across subsystems.
|
||||
|
||||
```
|
||||
description: abstraction-radar (read-only scout)
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent on branch `loops/radar`, worktree
|
||||
`/root/rose-ash-loops/radar`, forever. Self-paced. Your ONLY writes are to
|
||||
`plans/abstractions.md` (and, rarely, refining this briefing). Push to
|
||||
`origin/loops/radar` after each update. Never touch `main` or `architecture`.
|
||||
|
||||
## The one hard rule: you do NOT edit `lib/**` — ever
|
||||
|
||||
You read across every subsystem and write findings to `plans/abstractions.md`.
|
||||
You do **not** implement abstractions, migrate code, or edit any `lib/<x>/**`
|
||||
file in any worktree. Implementation is a separate, coordinated, human-triggered
|
||||
step — proposing well is your whole job. An abstractor that writes across
|
||||
subsystems would collide with the very isolation that keeps the other loops safe;
|
||||
that is exactly why you are read-only.
|
||||
|
||||
## Dynamic discovery — re-enumerate every iteration, never hardcode
|
||||
|
||||
The set of subsystems grows as new loops are spawned. Each iteration, rebuild the
|
||||
list from the filesystem + tmux so newly-added subsystems are automatically in
|
||||
scope:
|
||||
|
||||
1. `ls -d /root/rose-ash-loops/*/` — every loop worktree. For a worktree named `X`,
|
||||
its in-flight subsystem is `lib/X/` **inside that worktree**
|
||||
(`/root/rose-ash-loops/X/lib/X/`) — that's the current, possibly-uncommitted
|
||||
state. Read it there, not from your own worktree.
|
||||
2. `ls -d /root/rose-ash/lib/*/` — subsystems merged into / dormant on the main repo
|
||||
(e.g. `feed` once merged, the language substrates `apl`/`haskell`/`prolog`/…).
|
||||
3. `tmux ls` — which subsystems are actively looping right now (affects whether a
|
||||
candidate's consumers are "stable" — see the gate).
|
||||
|
||||
Treat the union as your scan surface. When a `commerce` or `identity` loop appears
|
||||
later, step 1 picks it up with no change to you. Note in `abstractions.md` the
|
||||
date and the subsystem set you scanned, so drift is visible.
|
||||
|
||||
## The AHA gate — before ANY candidate goes in the backlog as "proposed"
|
||||
|
||||
"Avoid Hasty Abstractions." A wrong shared abstraction is far costlier than the
|
||||
duplication it replaces. A candidate may be listed as **proposed** only if ALL hold:
|
||||
|
||||
- **≥3 real consumers** (not 2 — three independent uses). Fewer → log it under
|
||||
"Watching" with its consumer count, do not propose.
|
||||
- **All consumers past Phase 2 and API-stable.** If a consumer's loop is mid-flight
|
||||
and its interfaces are still moving (`tmux ls` shows it active + its plan has
|
||||
unchecked early-phase boxes), the pattern is a moving target → "Watching."
|
||||
- **Structurally identical, not superficially similar.** Show the shared shape with
|
||||
file:line evidence from each consumer. Coincidental resemblance is the #1 trap.
|
||||
- **It has a natural home.** And that home is usually **not** `lib/guest` — see the
|
||||
routing rule below.
|
||||
|
||||
Anything failing a gate goes under **Watching** (with what's missing) or
|
||||
**Rejected** (with why), never silently dropped — so it isn't re-proposed each pass.
|
||||
|
||||
## Routing rule — most patterns do NOT belong in lib/guest
|
||||
|
||||
`lib/guest` is for **language-implementation plumbing** (lexer/parser/AST/HM/match/
|
||||
test-runner), and it has its own consumer-gated roadmap. App-subsystem patterns
|
||||
almost always have a better home — route, don't dump:
|
||||
|
||||
| Pattern kind | Home (not lib/guest) |
|
||||
|---|---|
|
||||
| per-viewer visibility / permission filter | `acl-on-sx` (delegate to `permit?`) |
|
||||
| federation scaffold (merge/ingest/backfill/trust) | `fed-sx` |
|
||||
| durable store / event log / kv | `persist-on-sx` |
|
||||
| collection math (group-by, dedupe, stable top-N) | the substrate (APL/Haskell/…) |
|
||||
| HTTP/handler/middleware plumbing | `host-on-sx` |
|
||||
| conformance/test harness | `lib/guest` (the one real exception — `test-runner.sx` + the shared driver live there) |
|
||||
|
||||
If a pattern's home is one of the subsystems, the recommended **action** is "adopt
|
||||
/ delegate there," and the work belongs to that subsystem's own loop (in its
|
||||
scope), not to a cross-cutting change.
|
||||
|
||||
## Each iteration
|
||||
|
||||
1. Re-discover the subsystem set (above). Record it + the date in `abstractions.md`.
|
||||
2. Pick ONE thread: either deep-dive a "Watching" candidate to gather file:line
|
||||
evidence and re-test its gates, or sweep for a new recurring shape across the
|
||||
current set.
|
||||
3. Update `plans/abstractions.md`: move items between Watching / Proposed /
|
||||
In-progress (owned by a subsystem loop) / Done / Rejected, with evidence.
|
||||
4. Keep it ranked by (consumers × effort-saved ÷ risk). Short, factual.
|
||||
5. Commit (`radar: <one-line finding>`) and push to `origin/loops/radar`.
|
||||
|
||||
Do not invent work to look busy: if a pass finds nothing that clears the gate,
|
||||
record "scanned N subsystems on <date>, no new candidates cleared the gate" and
|
||||
stop until next iteration. Empty passes are a valid, honest result.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- SX files: `sx-tree` MCP tools take `file:` not `path:`. But you mostly READ —
|
||||
prefer `sx_find_across`, `sx_comp_usage`, `sx_comp_list`, `sx_summarise`, plus
|
||||
`Grep`/`Glob`/`Bash` for cross-worktree scanning.
|
||||
- `plans/abstractions.md` is a `.md` — edit it with normal Write/Edit, not sx-tree.
|
||||
- Never run `sx_build`. You don't build anything; you read.
|
||||
|
||||
## Style
|
||||
|
||||
- Evidence over assertion: every claim cites file:line in ≥3 consumers.
|
||||
- Honest empty passes. Rejected items stay rejected with a reason.
|
||||
- One finding per commit. Update. Push. Next.
|
||||
|
||||
Go. Read `plans/abstractions.md` (seeded), re-discover the subsystem set, and
|
||||
advance the highest-value thread.
|
||||
82
plans/commerce-on-sx.md
Normal file
82
plans/commerce-on-sx.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# commerce-on-sx: Catalog, cart, pricing & orders on miniKanren
|
||||
|
||||
> **DRAFT outline.** The revenue vertical. Depends on `persist-on-sx` (durable
|
||||
> orders) and `flow-on-sx` (checkout as a durable flow). Don't start before
|
||||
> persist-on-sx Phase 1 is green.
|
||||
|
||||
rose-ash's revenue engine — market (catalog), cart (checkout), orders (SumUp
|
||||
payment, reconciliation) — has no SX subsystem. The hard part of commerce isn't
|
||||
CRUD; it's **pricing**: discounts, bundles, tax, membership rates, promotions that
|
||||
stack (or don't). These are relations, and a relational engine can run them in
|
||||
multiple directions — forward ("what's the total?") and backward ("what promo code
|
||||
yields this total?", "which line item triggered the discount?").
|
||||
|
||||
That's a miniKanren fit. Pricing/promotion rules are relational; cart and order
|
||||
*lifecycle* (reserve → pay → fulfil → reconcile) is a durable `flow`; the order
|
||||
ledger is a `persist` stream. Commerce is the first real **composition** subsystem.
|
||||
|
||||
End-state: a catalog model, a relational pricing/promotion engine, a cart with
|
||||
deterministic totals, and an order lifecycle flow with payment-webhook
|
||||
reconciliation — all auditable via the event log.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/commerce/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only `lib/commerce/**` and `plans/commerce-on-sx.md`. May **import**
|
||||
from `lib/minikanren/`, and (once they exist) `lib/persist/` + `lib/flow/`. Do not
|
||||
edit substrates.
|
||||
- **Architecture:** prices/promotions are miniKanren relations over catalog facts;
|
||||
a cart total is a *deterministic* query result (first solution under a fixed rule
|
||||
order). Order lifecycle is a `flow` that suspends at the payment IO boundary.
|
||||
Money is integer minor units — never floats.
|
||||
- **Determinism:** promotion stacking must have explicit, tested precedence;
|
||||
totals must be reproducible from the cart + catalog snapshot.
|
||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
Catalog + cart Total / order
|
||||
product(id,price,tags) {:subtotal :discounts :tax :total}
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/commerce/catalog.sx lib/commerce/price.sx
|
||||
— product / variant / stock facts — miniKanren pricing relations
|
||||
│ — promo stacking, membership rates
|
||||
▼ ▲
|
||||
lib/commerce/cart.sx lib/commerce/order.sx (flow + store)
|
||||
— line items, quantities — reserve→pay→fulfil→reconcile
|
||||
│ — SumUp webhook = flow resume
|
||||
▼ │
|
||||
lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) ──┘
|
||||
```
|
||||
|
||||
## Phase 1 — Catalog + cart + deterministic totals
|
||||
- [ ] `catalog.sx` — product/variant/stock as facts
|
||||
- [ ] `cart.sx` — line items, add/remove/qty
|
||||
- [ ] `price.sx` — base pricing relation, subtotal; tax
|
||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||
|
||||
## Phase 2 — Promotions (relational)
|
||||
- [ ] promo rules: percentage, fixed, bundle, member rate
|
||||
- [ ] explicit stacking precedence; "best price" backward query
|
||||
- [ ] tests: stacking order, mutually-exclusive promos, member vs guest
|
||||
|
||||
## Phase 3 — Order lifecycle (flow + store)
|
||||
- [ ] order flow: reserve stock → await payment → fulfil
|
||||
- [ ] payment webhook resumes the suspended flow
|
||||
- [ ] order ledger as a `persist` stream; idempotent reconciliation
|
||||
|
||||
## Phase 4 — Reconciliation + federation
|
||||
- [ ] mismatch detection (paid≠ordered) as queries over the ledger
|
||||
- [ ] cross-instance catalog (federated marketplace) — out-of-scope stub
|
||||
- [ ] tests: webhook replay, partial refund, double-charge guard
|
||||
|
||||
## Progress log
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
(loop fills this in)
|
||||
82
plans/content-on-sx.md
Normal file
82
plans/content-on-sx.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# content-on-sx: Documents, blocks & collaborative editing on Smalltalk
|
||||
|
||||
> **DRAFT outline.** The CMS vertical — blog, WYSIWYG editor, Ghost sync. Depends
|
||||
> on `persist-on-sx` (document history as an event log). Ghost/CMS sync stays a thin
|
||||
> external adapter (Python/FFI) until a native replacement exists.
|
||||
|
||||
rose-ash's `blog` domain is content management: a block-based WYSIWYG editor,
|
||||
navigation, Ghost CMS sync. A document is a tree of live blocks; editing is a
|
||||
stream of operations; collaboration needs conflict-free merge. That is an object
|
||||
model — blocks are objects, edits are messages, and a document is the object graph
|
||||
responding to them. Smalltalk's "everything is an object responding to messages"
|
||||
maps directly to a block/WYSIWYG model, and a semilattice (CRDT) merge keeps
|
||||
concurrent edits conflict-free.
|
||||
|
||||
End-state: a Smalltalk-on-SX document model (typed blocks, structural ops),
|
||||
operation log + CRDT merge for collaborative editing, versioning/history via the
|
||||
event store, and a render boundary to HTML/SX. External CMS (Ghost) sync is an
|
||||
injected adapter, not core.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/content/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only `lib/content/**` and `plans/content-on-sx.md`. May **import**
|
||||
from `lib/smalltalk/`, and (once it exists) `lib/persist/`. Do not edit substrates.
|
||||
- **Architecture:** a document is an ordered tree of blocks (objects); an edit is a
|
||||
message (`insert`/`update`/`move`/`delete`); concurrent edits merge via a
|
||||
commutative (CRDT/semilattice) operation so order doesn't matter. History is the
|
||||
`persist` event stream; any version is a replay.
|
||||
- **Determinism:** merge must be commutative + idempotent (test: apply ops in any
|
||||
order / twice → same document).
|
||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
Edit op Rendered document
|
||||
(insert block after id) ... HTML / SX tree
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/content/block.sx lib/content/render.sx
|
||||
— typed blocks as objects — block tree → HTML/SX
|
||||
— heading/text/image/embed — (reuses SX render boundary)
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/content/doc.sx lib/content/merge.sx
|
||||
— ordered block tree — CRDT/semilattice op merge
|
||||
— apply op, structural moves — concurrent-edit reconciliation
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/content/api.sx ── (content/edit) (content/render) (content/history) ──┐
|
||||
│ │
|
||||
├── op log + versions → persist │
|
||||
└── Ghost/CMS sync → injected external adapter (thin, non-core) ──┘
|
||||
```
|
||||
|
||||
## Phase 1 — Block document model
|
||||
- [ ] `block.sx` — typed block objects
|
||||
- [ ] `doc.sx` — ordered tree, apply edit op, structural moves
|
||||
- [ ] `render.sx` — block tree → HTML/SX
|
||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||
|
||||
## Phase 2 — Op log + versioning
|
||||
- [ ] edit ops as `persist` events; replay to any version
|
||||
- [ ] `(content/history doc)`, diff between versions
|
||||
|
||||
## Phase 3 — Collaborative merge (CRDT)
|
||||
- [ ] commutative/idempotent op merge
|
||||
- [ ] concurrent-edit tests (any order, double-apply → identical)
|
||||
|
||||
## Phase 4 — External sync + federation
|
||||
- [ ] Ghost/CMS sync via injected adapter (import/export)
|
||||
- [ ] federated documents (peer-authored blocks) — trust-gated stub
|
||||
- [ ] tests: round-trip import/export, conflict on concurrent external edit
|
||||
|
||||
## Progress log
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
(loop fills this in)
|
||||
@@ -10,7 +10,9 @@ End-state goal: spawn a million processes, run the classic **ring benchmark**, p
|
||||
- **Conformance:** not BEAM-compat. "Looks like Erlang, runs like Erlang, not byte-compatible." We care about semantics, not BEAM bug-for-bug.
|
||||
- **Test corpus:** custom — ring, ping-pong, fibonacci-server, bank-account-server, echo-server, plus ~100 hand-written tests for patterns/guards/BIFs. No ISO Common Test.
|
||||
- **Binaries:** basic bytes-lists only; full binary pattern matching deferred.
|
||||
- **Hot code reload, distribution, NIFs:** out of scope entirely.
|
||||
- **Distribution, NIFs:** out of scope entirely.
|
||||
- **Hot code reload (Phase 7):** in scope — driven by [fed-sx](../plans/fed-sx-design.md) (section 17.5) which needs federated modules to be re-loaded without restarting the scheduler.
|
||||
- **FFI BIFs (Phase 8):** in scope — Erlang code needs `crypto:hash`, `cid:from_bytes`, `file:read_file`, `httpc:request`, `sqlite:exec` to participate in fed-sx. A general FFI BIF registry replaces today's hard-coded BIF dispatch.
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -95,10 +97,128 @@ Core mapping:
|
||||
- [x] ETS-lite (in-memory tables via SX dicts) — **13 new eval tests**; `ets:new/2`, `insert/2`, `lookup/2`, `delete/1-2`, `tab2list/1`, `info/2` (size); set semantics with full Erlang-term keys
|
||||
- [x] More BIFs — target 200+ test corpus green — **40 new eval tests**; 530/530 total. New: `abs/1`, `min/2`, `max/2`, `tuple_to_list/1`, `list_to_tuple/1`, `integer_to_list/1`, `list_to_integer/1`, `is_function/1-2`, `lists:seq/2-3`, `lists:sum/1`, `lists:nth/2`, `lists:last/1`, `lists:member/2`, `lists:append/2`, `lists:filter/2`, `lists:any/2`, `lists:all/2`, `lists:duplicate/2`
|
||||
|
||||
### Phase 7 — hot code reload
|
||||
|
||||
Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules must be replaceable at runtime without bouncing the scheduler. Classic OTP behaviour: two versions per module ("current" and "old"), local calls stick to the version the process started with, cross-module (`M:F(...)`) calls always resolve to the current version, and `purge` kills any process still running old code.
|
||||
|
||||
- [x] Module version slot: `er-modules` entry becomes `{:current MOD-ENV :old MOD-ENV-or-nil :version INT}`; bump version on each load — **13 new runtime tests** (543/543 total)
|
||||
- [x] `code:load_binary/3` (the canonical reload BIF) — re-parses module source, swaps `:current` → `:old`, installs new env as `:current`; returns `{module, Name}` or `{error, Reason}` (badarg / badfile / module_name_mismatch). **+8 eval tests** (551/551 total). `code:load_file/1` is a thin filesystem wrapper around this and lands once `file:read_file/1` is in (Phase 8).
|
||||
- [x] `code:purge/1` + `code:soft_purge/1` — purge clears `:old` slot and kills any process whose `:initial-fun` env identity matches the old env (returns `true` if there was old code, `false` if there wasn't). soft_purge: refuses (returns `false`, leaves `:old` intact) if any process is still pinned to the old env; otherwise clears and returns `true`. **+10 eval tests** (561/561 total). Caveat: a true "lingering on old code" test needs `spawn/3` (still stubbed) or `fun M:F/A` syntax (not parsed) — anonymous `fun () -> M:F() end` closures capture the caller's env, not the module's, and cross-module calls always resolve to `:current`. Current tests therefore exercise the return-value matrix but not the kill path.
|
||||
- [x] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection. **+10 eval tests** (571/571 total). Return-value contract: `which` → `loaded` / `non_existing` (since we have no filesystem path); `is_loaded` → `{file, loaded}` / `false`; `all_loaded` → list of `{Module, loaded}` tuples. Non-atom Mod raises `error:badarg`.
|
||||
- [x] Cross-module call `M:F(...)` dispatches to `:current`; local calls inside a module body keep using the env they closed over so a running process finishes its current function with the version it started with — **+6 eval tests** verifying the property end-to-end (577/577 total). No implementation change: `er-apply-user-module` already routes through `er-module-current-env`, and `er-mk-fun` captures its env by reference so closures created under v1 retain v1's `mod-env` even after the slot bumps to v2.
|
||||
- [x] Tests: load v1 → spawn → load v2 → cross-module call hits v2 → local call inside v1 process keeps v1 semantics until function returns → purge kills v1 procs → soft_purge refuses while v1 procs alive — **+5 capstone eval tests** (582/582 total). Required extending `er-procs-on-env` from raw identity match to `er-env-derived-from?` (an env "comes from" mod-env if it IS mod-env or contains a value that's a fun closed over mod-env), because `er-apply-fun-clauses` does `er-env-copy closure-env` before binding params — so the spawned-from-inside-module fun's `:env` is a fresh dict, not mod-env. Test ladder runs as one single `erlang-eval-ast` program (every call to `ev` resets the scheduler via `er-sched-init!`, so Pid handles must live within one program).
|
||||
|
||||
### Phase 8 — FFI BIF mechanism + standard libs
|
||||
|
||||
Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in `transpile.sx`) with a runtime-extensible **BIF registry**. Each registry entry is `{:module :name :arity :fn :pure?}`. Standard libs are then registered at boot, and fed-sx can register new BIFs from `.sx` files. Includes the marshalling layer (Erlang term ↔ SX value) so wrappers stay one-liners.
|
||||
|
||||
- [x] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-register-pure-bif!`/`er-lookup-bif`/`er-list-bifs`/`er-bif-registry-reset!` helpers — **+18 runtime tests** (600/600 total). Entries are `{:module :name :arity :fn :pure?}`. Arity is part of the key so `m:f/1` and `m:f/2` are independent. Re-registering the same key replaces the previous entry; reset clears.
|
||||
- [x] Migrate existing local + remote BIFs (length/hd/tl/lists:*/io:format/ets:*/etc.) onto the registry; delete the giant `cond` dispatch in `er-apply-bif`/`er-apply-remote-bif`. Conformance held at **600/600** after migration (baseline was 600, not the plan-text's 530 — the text was authored before Phase 7 work added rows). 67 builtin registrations across `erlang`/`lists`/`io`/`ets`/`code` modules; multi-arity BIFs (`is_function`, `spawn`, `exit`, `io:format`, `lists:seq`, `ets:delete`) register once per arity, all pointing at the same impl which dispatches on `(len vs)` internally. The four per-module cond dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) are deleted. `er-apply-bif` and `er-apply-remote-bif` are now ~5-line registry lookups; user modules still win precedence over the registry.
|
||||
- [x] Term-marshalling helpers: `er-of-sx` (SX → Erlang) and `er-to-sx` (Erlang → SX). atom ↔ symbol, nil ↔ `()`, cons → list, tuple → list (one-way; tuples flatten), binary ↔ SX string, integer / float / boolean passthrough. **+23 runtime tests** (623/623 total). Erlang maps (`dict ↔ map`) deferred — Erlang map term not implemented in this port; will land when `#{}` syntax does. Pids, refs, funs pass through unchanged. SX strings on the way back become Erlang binaries (most useful FFI return shape).
|
||||
- [x] `crypto:hash/2` — **WIRED 2026-05-18** against `crypto-sha256`/`crypto-sha512`/`crypto-sha3-256` (loops/fed-prims). `crypto:hash(Type, Data)`: `Type` ∈ `sha256|sha512|sha3_256` atom; `Data` an Erlang binary/string/charlist (→ SX byte-string via `er-source-to-string`). Returns the **raw digest as an Erlang binary** (host hex → bytes via `er-hex->bytes`). Bad type / non-binary → `error:badarg`. 6 ffi tests (digest sizes 32/64, sha3 is_binary, deterministic, distinct, badarg).
|
||||
- [x] `cid:from_bytes/1`, `cid:to_string/1` — **WIRED 2026-05-18**. `cid:from_bytes(Bin)` → CIDv1 raw-codec (0x55), sha2-256 multihash built in SX (`[0x12,0x20]++digest`) fed to `cid-from-bytes`; returned as an Erlang binary string. `cid:to_string(Term)` → canonical CIDv1 of the term's stable `er-format-value` string via `cid-from-sx` (cbor-encode rejects marshalled symbols, so `er-to-sx` is unencodable for compound terms — string form is total + deterministic). 7 ffi tests (is_binary, deterministic, distinct-inputs, non-binary badarg, to_string is_binary/deterministic/distinct).
|
||||
- [x] `file:read_file/1`, `file:write_file/2`, `file:delete/1` — **+10 eval tests** (633/633 total). Returns `{ok, Binary}` / `ok` / `{error, Reason}` where Reason is `enoent`/`eacces`/`enotdir`/`eisdir`/`posix_error` (classified from the SX `file-read`/`-write`/`-delete` exception string). Path accepts SX string, Erlang binary, or Erlang char-code list. **`file:list_dir/1` WIRED 2026-05-18** against `file-list-dir` → `{ok, [Binary]}` (entries marshalled via `er-of-sx`) / `{error, Reason}` (same `er-classify-file-error` mapping; missing dir → `enoent`). 4 ffi tests (ok-tag, non-empty, entries-are-binaries, missing-enoent).
|
||||
- [ ] `httpc:request/4` — **BLOCKED** (no HTTP client primitive). See Blockers.
|
||||
- [ ] `sqlite:open/1`, `sqlite:close/1`, `sqlite:exec/2`, `sqlite:query/2` — **BLOCKED** (no SQLite primitive). See Blockers.
|
||||
- [x] Tests: 1 round-trip per BIF; suite name `ffi`; conformance scoreboard auto-picks it up — **+14 ffi tests** at 637/637 total. Suite covers the 3 implemented file BIFs (9 tests: write-ok, read-ok-tag, payload-is-binary, byte_size content, missing-enoent, bad-path-enoent, binary-payload round-trip, delete-ok, read-after-delete-enoent) plus 5 negative asserts (one per blocked BIF — `crypto:hash`/`cid:from_bytes`/`file:list_dir`/`httpc:request`/`sqlite:exec`) so this suite fails fast if a future iteration adds a wrapper without registering proper tests. Target "+40 ffi tests" was relative to the original 5-BIF-family plan; with 5 of those families blocked on host primitives, the achievable count is 14 — the suite scaffolding is what matters and is ready to accept the remaining tests when the primitives land.
|
||||
|
||||
### Phase 9 — specialized opcodes (the BEAM analog)
|
||||
|
||||
**Driver:** Erlang-on-SX going through the general-purpose CEK machine has architectural perf ceilings (call/cc per receive, env-copy per call, mailbox rebuild on delete). The fix is specialized bytecode opcodes that bypass the general machinery for hot Erlang operations. Targets: 100k+ message hops/sec, 1M-process spawn in under 30sec. Layered perf strategy: Layer 1 (this) = specialized opcodes; Layer 2 (Phase 10, deferred) = multi-core scheduler.
|
||||
|
||||
**Architectural note:** opcodes get developed in `lib/erlang/vm/` (in scope). The **opcode extension mechanism in `hosts/ocaml/`** (Phase 9a) is **out of scope** for this loop — log as Blocker until a session that owns `hosts/` lands it. Sub-phases 9b-9g design and test opcodes against a stub dispatcher in the meantime; integrate when 9a is available.
|
||||
|
||||
**Shared-opcode discipline:** opcodes that another language port could plausibly use (pattern match, perform/handle, record access) get prepared for **chiselling out to `lib/guest/vm/`** when a second use materialises. Same lib/guest pattern, applied at the bytecode layer. Don't pre-extract; do annotate candidates in commit messages.
|
||||
|
||||
- [x] **9a — Opcode extension mechanism** — **INTEGRATED** (scope widened by user 2026-05-15: hosts/ in scope, merging back). Cherry-picked the 5 vm-ext commits (phases A-E: dispatch fallthrough for opcodes ≥200, `Sx_vm_extension` interface, `Sx_vm_extensions` registry, `extension-opcode-id` SX primitive, JIT skip path) onto loops/erlang. Force-linked `Sx_vm_extensions` into `bin/sx_server.ml` so its module-init runs (was dead-code-eliminated — only `run_tests` referenced it). `extension-opcode-id` is now live in the runtime: returns the registered opcode id, or nil for unknown names. Built clean; conformance held at **709/709** on the freshly built binary. Design: `plans/sx-vm-opcode-extension.md`.
|
||||
- [x] **9b — `OP_PATTERN_TUPLE` / `OP_PATTERN_LIST` / `OP_PATTERN_BINARY`** — **+19 vm tests** (656/656 total). Stub dispatcher in `lib/erlang/vm/dispatcher.sx` mirrors the OCaml extension shape from `plans/sx-vm-opcode-extension.md`: `er-vm-register-opcode!`/`er-vm-lookup-opcode-by-id`/`er-vm-lookup-opcode-by-name`/`er-vm-dispatch`. Opcode IDs 128 (TUPLE), 129 (LIST), 130 (BINARY) per the guest-tier partition (128-199). Handlers are thin wrappers over the existing `er-match-tuple`/`er-match-cons`/`er-match-binary` for now; the real specialization (skip AST walk, register-machine operands) lands when 9a integrates. Conformance must remain unchanged — **656/656** preserved. Candidate for chiselling to `lib/guest/vm/match.sx` once a second port (Prolog? miniKanren?) wants the same opcodes.
|
||||
- [x] **9c — `OP_PERFORM` / `OP_HANDLE`** — **+9 vm tests** (665/665 total). Stubs in `lib/erlang/vm/dispatcher.sx`: `OP_PERFORM` (id 131) raises `{:tag "vm-effect" :effect <name> :args <args>}`; `OP_HANDLE` (id 132) wraps a thunk in `guard`, catches matching effects (by `:effect` name), passes args to the handler, returns the handler's result. Non-matching effects rethrow to outer handlers (verified by a nested-handle test). Pure Erlang `receive` interface unchanged; this is the substrate for the eventual call/cc-free implementation when 9a integrates. Candidate for chiselling (Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape).
|
||||
- [x] **9d — `OP_RECEIVE_SCAN`** — **+10 vm tests** (675/675 total). Stub at id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` where each clause is `{:pattern :guards :body}`, mbox-list is a plain SX list (not a queue — caller does queue→list before invoking and queue-delete after). Walks mbox in arrival order; tries each clause per message; first match returns `{:matched true :index N :body B}` (env mutated with bindings, body NOT evaluated — caller chooses when); no match returns `{:matched false}`. Pure pattern scan; suspension is the caller's job (compose with OP_PERFORM "receive-suspend" once 9a integrates). The real opcode will skip the AST walk by JIT-compiling each clause's match expr; this stub re-uses `er-match!` for correctness.
|
||||
- [x] **9e — `OP_SPAWN` / `OP_SEND` + lightweight scheduler** — **+16 vm tests** (691/691 total). Stubs at ids 134 (SPAWN) and 135 (SEND) in `lib/erlang/vm/dispatcher.sx`, plus the VM-process registry: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid`, `er-vm-procs-reset!`, `er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`. Process record shape is the register-machine layout the real scheduler will use: `{:id :registers (list of 8 nil slots) :mailbox (SX list) :state ("runnable"/"waiting"/"dead") :initial-fn :initial-args}`. OP_SPAWN returns a numeric pid and allocates a fresh record; OP_SEND appends to the target's mailbox, flipping `:state` from "waiting" → "runnable" if needed (returns true on success, false on unknown pid — no crash). Sits parallel to `er-scheduler` (the language-level scheduler from Phase 3); the real VM scheduler will take over once 9a integrates and Erlang programs compile to bytecode. Perf targets in the bullet (spawn <50µs, send <5µs) defer to the integration step.
|
||||
- [x] **9f — BIF dispatch table** — **+18 vm tests** (709/709 total). 10 hot BIFs get their own opcode IDs (136-145) in `lib/erlang/vm/dispatcher.sx`: `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`. Each opcode's handler IS the underlying `er-bif-*` impl directly (no registry-string-lookup), so cost is opcode-id → handler one-hop. Cold BIFs continue through `er-apply-bif` / `er-lookup-bif` as before. IDs 136-159 reserved for future hot-BIF additions.
|
||||
- [x] **9h — `erlang_ext.ml`** — OCaml extension at `hosts/ocaml/lib/extensions/erlang_ext.ml` registering the 18-opcode Erlang namespace (ids **222-239**, names `erlang.OP_*` mirroring the SX stub dispatcher). Registered at sx_server startup via `Erlang_ext.register ()` (guarded against double-register Failure). `extension-opcode-id "erlang.OP_PATTERN_TUPLE"` → 222 … `OP_BIF_IS_TUPLE` → 239, unknown → nil. Handlers raise a descriptive not-wired `Eval_error` (bytecode emission is a later phase; SX stub dispatcher remains the working specialization path) — keeps the extension honest rather than silently corrupting the VM stack. id range 222+ dodges test_reg (210/211) + test_ext (220/221) so all three coexist in run_tests. **+5 OCaml ext tests** (run_tests `Suite: extensions/erlang_ext`); Erlang conformance held **709/709**.
|
||||
- [x] **9i — wire SX dispatcher to real ids** — `lib/erlang/vm/dispatcher.sx` gains `er-vm-host-opcode-id` (thin `extension-opcode-id` wrapper) and `er-vm-effective-opcode-id name stub-id` (host id when non-nil, else stub-id). `extension-opcode-id` resolves lazily at call time so loading the file is safe even on a binary lacking the primitive; only invoking the resolver there would raise (documented prereq — the loop builds + runs against the binary that has it). **+6 vm tests** (715/715): OP_PATTERN_TUPLE→222, OP_BIF_IS_TUPLE→239, unknown→nil, effective prefers host (OP_BIF_LENGTH→230), effective falls back to stub on nil (999), and a sweep asserting the whole 18-name namespace maps contiguously to 222..239. Stub-local ids (128-145) registration untouched so the prior 72 vm tests stay green.
|
||||
- [x] **9g — Conformance + perf bench** — Ran `lib/erlang/bench_ring.sh 10 100 500 1000` on the integrated binary (9a+9h+9i built in): 11/36/35/31 hops/s — **unchanged from the pre-integration baseline**, which is the correct expected result and doubles as a no-regression proof (the full extension wiring added zero per-hop cost). Conformance **715/715** on the same binary. Numbers recorded in `lib/erlang/bench_ring_results.md` with the rationale. The ~3000×/~1000× targets are gated on Phase 10 (bytecode emission) — the compiler doesn't emit `erlang.OP_*` yet, so every hop still takes the general CEK path. 9g's deliverable (honest measurement on the integrated binary) is complete.
|
||||
|
||||
### Phase 10 — bytecode emission (unlock the speedup)
|
||||
|
||||
The Phase 9 opcodes are registered, tested, and bridged SX↔OCaml, but inert: nothing emits them. Phase 10 makes the speedup real.
|
||||
|
||||
- [ ] **10a — compiler emits `erlang.OP_*` at hot sites** — **BLOCKED on `lib/compiler.sx` ownership (out of this loop's scope).** Architecture fully mapped (2026-05-15, see Blockers + design below). The correct implementation site is `lib/compiler.sx`'s `compile-call` — it must recognize calls to the Erlang runtime-helper functions that have a registered `erlang.OP_*` opcode and emit that opcode (via the already-live `extension-opcode-id` primitive) instead of a generic CALL. This is **generic shared compiler infrastructure** (any guest port — Prolog, Lua — would use the same intrinsic mechanism), explicitly excluded by the ground rules ("Don't edit lib/ root"; not in the widened hosts/-only scope). Concrete sub-steps for the owning session:
|
||||
- **10a.1** Add an *intrinsic registry* to `lib/compiler.sx`: a dict `callee-name → extension-opcode-name`, populated by guests at load (e.g. Erlang registers `er-bif-length → "erlang.OP_BIF_LENGTH"`, `er-match-tuple → "erlang.OP_PATTERN_TUPLE"`, …).
|
||||
- **10a.2** In `compile-call`: if the resolved callee is in the intrinsic registry AND `(extension-opcode-id name)` is non-nil, compile the args normally (push left→right) then emit the single opcode byte instead of `CALL`. Fall back to generic CALL when the opcode is absent (graceful on binaries without the extension).
|
||||
- **10a.3** Define the operand/stack contract per opcode class and make `erlang_ext.ml`'s control handlers (222-229) match it (pattern opcodes need the pattern AST as a constant-pool operand + the scrutinee on the stack; perform/handle/receive/spawn/send need OCaml↔SX runtime-state access — see 10b-control note).
|
||||
- **10a.4** Conformance must stay green; add bytecode-emission tests (compile an Erlang fn, disassemble, assert the opcode appears at the hot site).
|
||||
Until a session owning `lib/compiler.sx` lands 10a.1-10a.2, the speedup cannot be realized from this loop. The BIF half of 10b (operand-less stack ops) is fully done and *would* light up immediately once emission exists.
|
||||
- [~] **10b — real `erlang_ext.ml` handlers** — **10 of 18 real** (ALL BIF opcodes done: 230-239). Latest: `OP_BIF_ELEMENT` (233, pops Tuple-then-Index, 1-indexed, range-checked) and `OP_BIF_LISTS_REVERSE` (235, builds a fresh reversed cons chain in OCaml). Re-scoping correction: ELEMENT/REVERSE were earlier mislabelled "gated on 10a" — they're pure stack transforms (no bytecode operands; element/2 just pops 2), so they landed now. **21 e2e run_tests** total. Remaining 8 stubs are the genuine control/structural opcodes that DO need compiler-defined operands + runtime state: `OP_PATTERN_TUPLE/LIST/BINARY` (222-224), `OP_PERFORM/HANDLE` (225-226), `OP_RECEIVE_SCAN` (227), `OP_SPAWN/SEND` (228-229). not-wired guard repointed to 222. 715/715 unaffected. — earlier note: 8 of 18 real (all hot-BIFs done). Real register-machine handlers: `OP_BIF_LENGTH` (230, cons-walk), `OP_BIF_HD` (231), `OP_BIF_TL` (232), `OP_BIF_TUPLE_SIZE` (234, handles List + ListRef `:elements`), `OP_BIF_IS_INTEGER` (236, `Integer _`), `OP_BIF_IS_ATOM` (237), `OP_BIF_IS_LIST` (238, cons|nil), `OP_BIF_IS_TUPLE` (239) — all operate on the tagged-Dict value repr, push Erlang bool atoms via a `mk_atom` helper, raise on type errors. **15 end-to-end run_tests tests** (build real bytecode `[CONST i; op; RETURN]` with list/tuple/atom constants, assert via `Sx_vm.execute_module`). Still `not_wired`: the 8 control opcodes — `OP_PATTERN_TUPLE/LIST/BINARY` (222-224), `OP_PERFORM/HANDLE` (225-226), `OP_RECEIVE_SCAN` (227), `OP_SPAWN/SEND` (228-229) — plus `OP_BIF_ELEMENT` (233, needs 2 operands) and `OP_BIF_LISTS_REVERSE` (235). not-wired guard repointed to 233. 715/715 conformance unaffected (VM-bytecode path only; interpreter untouched). Remaining 10b: the 10 control/structural handlers.
|
||||
- [ ] **10c — perf validation**: re-run `bench_ring.sh`; target 100k+ hops/sec at N=1000, 1M-process spawn < 30s; record in `bench_ring_results.md`. Conformance must stay green.
|
||||
|
||||
**Acceptance:** ring benchmark hits the 100k hops/sec target. All prior phase tests pass. Two opcodes chiselled to `lib/guest/vm/` (or annotated as candidates with a written rationale).
|
||||
|
||||
## Progress log
|
||||
|
||||
_Newest first._
|
||||
|
||||
- **2026-05-18 Phase 8 host-primitive BIFs wired (crypto / cid / file:list_dir)** — `loops/fed-prims` (merged at architecture `380bc69f`) delivered the platform primitives; wired the 3 previously-BLOCKED Phase 8 BIF groups in `lib/erlang/runtime.sx` as `er-register-pure-bif!`/`er-register-bif!` entries with term marshalling at the boundary. **`crypto:hash/2`** → `crypto-sha256`/`crypto-sha512`/`crypto-sha3-256`; atom `Type` dispatch, `er-source-to-string` for `Data`, host hex result → raw bytes via new `er-hexval`/`er-hex->bytes`, returns Erlang binary; bad type/arg → `error:badarg`. **`cid:from_bytes/1`** → `cid-from-bytes` with raw codec `0x55` + sha2-256 multihash assembled in SX (`[0x12,0x20]++digest`); **`cid:to_string/1`** → `cid-from-sx` of `er-format-value` (cbor-encode rejects `er-to-sx`-marshalled symbols; the canonical string form is total + deterministic). **`file:list_dir/1`** → `file-list-dir`, `{ok,[Binary]}` via `er-of-sx` / `{error,Reason}` reusing `er-classify-file-error`. Test gotcha caught + fixed: this Erlang port's binary parser only supports integer/var segments — `<<"abc">>` string-binary literals silently produce **empty** binaries, so the first-cut distinct-input tests compared two empty inputs and failed; rewrote ffi inputs to integer-segment binaries (`<<97,98,99>>`). ffi suite 14→**28** (3 BLOCKED negative-asserts flipped to positive+negative functional tests; `httpc`/`sqlite` kept as deferred unregistered-asserts per fed-prims handoff). Built `sx_server.exe` (dune, opam 5.2.0) at `380bc69f`; full conformance **729/729** (eval 385/385, vm 78/78, **ffi 28/28**, all process suites green). loops/erlang only — not merged, not pushed to architecture.
|
||||
|
||||
- **2026-05-18 FIXED merge-blocking regression: cyclic-env hang in `er-env-derived-from?`** — A trial merge of loops/erlang → architecture regressed Erlang **715/715 → 0/0** on the architecture binary. Bisected: not loader semantics, not a uniform slowdown — pinpointed to the *single* Phase 7 capstone test (eval.sx lines 1314-1346; prefix-1313 was byte-identical speed on both binaries, 27s, prefix-1346 was 28s on loops vs >5min/hung on architecture). Isolated further: spawn+reload alone 0.6s, reload+purge alone 0.3s, but spawn+reload+**purge over forever-blocked procs** hung. Root cause: `er-env-derived-from?` (transpile.sx, used by `code:purge`/`soft_purge` via `er-procs-on-env`) compared closure envs with `(= env target-env)`. loops/erlang's evaluator implements dict `=` as **object identity**; architecture's 131-commit-newer evaluator changed it to **structural deep equality**. Erlang closure envs are large and **cyclic** (a module fun's `:env` transitively references the fun), so structural `=` over them never terminates. Fix: use `identical?` (pointer-identity predicate, present + consistent `(true false)` on *both* binaries) — the actually-intended semantics and host-independent. Verified: full eval.sx on the architecture binary >200s/hung → **59s**; full 10-suite conformance on the architecture binary now **715/715** (eval 385/385, vm 78/78, ffi 14/14, all process suites green). loops/erlang behaviour unchanged (`identical?` ≡ its old `=`-identity). One-file change (`lib/erlang/transpile.sx`, +7/-2). The merge can now be re-attempted; this was the sole blocker.
|
||||
|
||||
- **2026-05-15 Phase 10a — architecture traced, scoped, blocked on `lib/compiler.sx`** — Investigation-only iteration (correctly: faking compiler emission within scope is impossible and would be dishonest). Traced the full JIT path: `sx_vm.ml`'s `jit_compile_lambda` (the ref set at line 1206) invokes the SX-level `compile` from `lib/compiler.sx` via the CEK machine — that is the only SX→bytecode producer. Erlang's hot helpers are ordinary SX functions in `transpile.sx` that get JIT-compiled through exactly this path, so emitting `erlang.OP_*` means teaching `compiler.sx`'s `compile-call` to recognize them as intrinsics and emit the extension opcode (the file's own docstring already anticipates this — "Compilers call `extension-opcode-id` to emit extension opcodes" — designed but unimplemented; grep confirms zero `extension-opcode-id` uses in `compiler.sx`). `lib/compiler.sx` is lib-root: excluded by ground rules and the widened scope (editing it changes every guest's JIT — must be a shared-compiler session, not this loop). Recorded a precise Blockers entry + decomposed 10a into four numbered sub-steps (10a.1 intrinsic registry, 10a.2 `compile-call` emission with graceful CALL fallback, 10a.3 operand/stack contract for control opcodes, 10a.4 bytecode-emission tests) so the owning session can execute directly. Key payoff documented: all 10 BIF handlers (230-239) are already real, so they light up the instant 10a.1-10a.2 land — zero further Erlang-side work for the BIF speedup. No code changed; conformance unverified-but-untouched at **715/715** (no source touched). Phase 10's loop-reachable work (10b BIF half) is complete; the rest is correctly blocked and fully actionable elsewhere.
|
||||
|
||||
- **2026-05-15 Phase 10b — ELEMENT + LISTS_REVERSE real; all 10 BIF opcodes done** — Re-examined the earlier "gated on 10a" claim for ELEMENT/REVERSE and found it wrong: both are pure stack transforms with no need for bytecode operands (`element/2` just pops Tuple then Index off the VM stack; `lists:reverse/1` pops one list). Implemented both as real handlers in `erlang_ext.ml`. `OP_BIF_ELEMENT` (233): pops Tuple (TOS) then Index, handles List/ListRef `:elements`, 1-indexed, raises on out-of-range or wrong arg types. `OP_BIF_LISTS_REVERSE` (235): walks the cons chain building a fresh reversed one via local `mk_cons`/`mk_nil`, raises on improper list. Defined the calling convention for arity-2 ELEMENT: args pushed left→right so stack is `[Index Tuple]`, Tuple on top. 6 new e2e run_tests: element(2/1,{1,2,3}), element out-of-range raises, reverse-then-HD=9, reverse-then-TL-HD=8, reverse-then-LENGTH=3 (composes 3 real opcodes in one bytecode sequence). erlang_ext suite 15→21 PASS, dispatch_count 22. not-wired guard repointed 233→222 (OP_PATTERN_TUPLE — a genuine control opcode still stubbed). **All 10 BIF opcodes (230-239) now real**; the 8 remaining stubs are the true control/structural opcodes (pattern match, perform/handle, receive-scan, spawn/send) which genuinely need 10a's compiler-defined operand encoding + runtime-state access. Erlang conformance **715/715** (interpreter path untouched). 10b is now BIF-complete; the control-opcode half is the real remaining Phase 10 work and is correctly gated on 10a.
|
||||
|
||||
- **2026-05-15 Phase 10b — all 8 hot-BIF handlers real** — Built on the vertical slice: added 7 more real register-machine handlers in `erlang_ext.ml` (HD 231, TL 232, TUPLE_SIZE 234, IS_INTEGER 236, IS_ATOM 237, IS_LIST 238, IS_TUPLE 239), joining LENGTH 230. Shared helpers added: `mk_atom` (builds the Erlang bool atom `{tag→atom, name→true|false}`), `er_bool`, `is_tag` (Dict tag predicate). TUPLE_SIZE handles both `List` and `ListRef` `:elements` (Erlang tuples may be built mutably). IS_INTEGER keys off `Sx_types.Integer`. All raise descriptive `Eval_error` on type mismatch. The `op N "name"` stub helper now only covers the 10 remaining control/structural opcodes. 9 new end-to-end run_tests assertions added (HD, TL∘HD, TUPLE_SIZE, IS_INTEGER pos+neg, IS_ATOM, IS_LIST nil-true + tuple-false, IS_TUPLE) — each builds real bytecode with a list/tuple/atom constant and executes via `Sx_vm.execute_module`. erlang_ext suite 6→15 PASS; dispatch_count 12. not-wired guard repointed 231→233 (OP_BIF_ELEMENT, still stubbed — it needs two operands so it's a later sub-step). Erlang conformance **715/715** (the interpreter path is untouched; only the VM-bytecode dispatch gained real handlers). Remaining 10b: pattern tuple/list/binary, perform/handle, receive-scan, spawn/send, element, lists:reverse (10 opcodes).
|
||||
|
||||
- **2026-05-15 Phase 10b vertical slice — first real opcode handler, end-to-end VM proof** — Investigation first: confirmed Erlang runs as a pure tree-walking interpreter (`er-eval-expr` over CEK) — there is **no** Erlang→bytecode compiler, so full 10a (compiler emits opcodes) is a multi-week standalone effort, not one iteration. Rather than fake it, de-risked the whole Phase 9/10 architecture with a vertical slice: replaced the `not_wired` raise for `erlang.OP_BIF_LENGTH` (id 230) with a genuine register-machine handler in `erlang_ext.ml` — pops a value, walks the Erlang cons-list representation (`Dict` with `"tag"`→`"cons"`/`"nil"`, `"head"`, `"tail"`), pushes `Integer` length, raises on improper lists. Added an end-to-end run_tests test that builds real bytecode `[| 1; 0; 0; 230; 50 |]` (CONST idx 0 → OP_BIF_LENGTH → RETURN) with an Erlang `[1,2,3]` in `vc_constants`, executes via `Sx_vm.execute_module`, asserts `Integer 3`. This proves the complete path works: `extension-opcode-id` → bytecode → `Sx_vm` ≥200 dispatch fallthrough → `erlang_ext` handler → correct VM stack result — the load-bearing proof that Phase 9's wiring isn't just stubs. The other 17 opcodes still honestly raise `not_wired`; the prior not-wired guard test was repointed from 230 to 231 (OP_BIF_HD) so it still verifies the honest-failure path. erlang_ext suite 5→6 tests, dispatch_count now 2. Erlang conformance **715/715** unaffected (the new path is VM-bytecode-only; the interpreter path is untouched). 10b marked in-progress `[~]`; remaining: real handlers for the other 17 opcodes + 10a compiler emission. Builds clean via `dune build bin/run_tests.exe bin/sx_server.exe`.
|
||||
|
||||
- **2026-05-15 Phase 9g — perf bench recorded on integrated binary; Phase 10 scoped** — Built the fresh `sx_server.exe` (9a+9h+9i wired in), ran `lib/erlang/bench_ring.sh 10 100 500 1000`: 11/36/35/31 hops/s — statistically identical to the pre-9a baseline (11/24/26/29/34). This is the *expected* outcome and the iteration's actual deliverable: it proves the entire extension stack (vm-ext A-E cherry-pick + `Sx_vm_extensions` force-link + `erlang_ext.ml` + SX dispatcher bridge) added **zero per-hop overhead** — a clean no-regression result — while honestly showing the speedup hasn't arrived because the bytecode compiler still doesn't emit `erlang.OP_*` (every hop takes the general CEK path). Updated `bench_ring_results.md` with a "Phase 9g" section: the table + the rationale that unchanged numbers = correct + no-regression. Conformance **715/715** on the integrated binary. Added **Phase 10 — bytecode emission** to the roadmap (10a compiler emits opcodes at hot sites, 10b real register-machine `erlang_ext.ml` handlers replacing the not-wired raises, 10c perf validation against the 100k-hops/1M-spawn targets). Phase 9 is now fully ticked (9a-9i); the actual speedup is honestly deferred to Phase 10 rather than faked. No code change this iteration — measurement + documentation + roadmap.
|
||||
|
||||
- **2026-05-15 Phase 9i — SX dispatcher consults host opcode ids** — `lib/erlang/vm/dispatcher.sx` now bridges SX↔OCaml opcode ids. Two new functions: `er-vm-host-opcode-id` (wraps `extension-opcode-id`) and `er-vm-effective-opcode-id name stub-id` (host id if the OCaml `erlang_ext` registered it, else the stub-local id). Key SX-runtime fact established this iteration: symbol resolution is **lazy/call-time** — `(define f (fn () (extension-opcode-id "x")))` does NOT raise at load even when the primitive is absent; only calling `f` does. Combined with the earlier findings (guard can't catch undefined-symbol; no symbol-existence reflection), this means graceful in-SX degradation is impossible — so the design instead documents the binary prerequisite and relies on the loop building+running the freshly-built `hosts/ocaml/_build/default/bin/sx_server.exe` (conformance.sh's default, which has the vm-ext mechanism + erlang_ext). Stub-local registration (128-145) deliberately left intact so the 72 pre-existing vm tests don't move. 6 new vm tests: 222/239 lookups, unknown→nil, effective-prefers-host (230), effective-fallback (999), and a contiguity sweep over all 18 `erlang.OP_*` names asserting they map to 222..239 in order. vm suite 72→78. Total **715/715** on the fresh binary. Next: 9g — re-run ring bench, record numbers (note: stubs still wrap existing impls 1-to-1 so numbers won't move until the compiler emits these opcodes — a later phase).
|
||||
|
||||
- **2026-05-15 Phase 9h — erlang_ext.ml registered, opcode namespace live** — New `hosts/ocaml/lib/extensions/erlang_ext.ml` modelled on `test_ext.ml`: an `EXTENSION` module `name="erlang"`, per-instance `ErlangExtState` (dispatch counter), 18 opcodes ids 222-239 named `erlang.OP_*` exactly mirroring the SX stub dispatcher. Registered at sx_server startup with a second guarded line in `bin/sx_server.ml` (`try Erlang_ext.register () with Failure _ -> ()` — survives a re-entered server). `include_subdirs unqualified` in `lib/dune` already pulls `lib/extensions/*.ml` into the `sx` lib, so no dune edit needed. Handlers deliberately raise a descriptive `Eval_error` ("bytecode emission not yet wired (Phase 9j) — Erlang runs via CEK; specialization path is the SX stub dispatcher") rather than fake stack ops — the compiler doesn't emit these yet, so an honest loud failure beats silent corruption. Hit and fixed an opcode-id collision: the original 200-217 range clashed with run_tests' inline test_reg (210/211); relocated to 222-239 (clears test_reg + test_ext 220/221, all coexist; production sx_server only registers erlang). 5 new OCaml tests in run_tests `Suite: extensions/erlang_ext`: opcode-id 222 + 239 resolve, unknown→nil, dispatch raises not-wired (substring check, no Str dep since run_tests doesn't link str), dispatch_count state ≥1. Built via `eval $(opam env --switch=5.2.0); dune build bin/run_tests.exe bin/sx_server.exe`. Erlang conformance **709/709** on the rebuilt binary (the broad run_tests 1110 failures are loops/erlang's pre-existing months-old divergence from architecture — run_tests was never built on this branch before; my changes are isolated additive). Next: 9i — wire the SX stub dispatcher to consult `extension-opcode-id`.
|
||||
|
||||
- **2026-05-15 Phase 9a integrated — scope widened to hosts/** — User lifted the hosts/ scope restriction ("we are going to merge this back anyhow"). Cherry-picked the 5 `vm-ext` commits (phases A-E) from `loops/sx-vm-extensions` onto `loops/erlang` — only conflict was `plans/sx-vm-opcode-extension.md` (already had architecture's final copy from an earlier iteration; resolved `-X ours`, OCaml files auto-merged clean since loops/erlang never touched hosts/). Discovered `extension-opcode-id` was still "Undefined symbol" even on a fresh build: `Sx_vm_extensions`'s module-init (`install_dispatch` + primitive registration) only runs if the module is linked, and `sx_server.ml` never referenced it (only `run_tests.ml` did), so OCaml dead-code-eliminated it. Fix: added `let () = ignore (Sx_vm_extensions.id_of_name "")` force-link reference near the top of `bin/sx_server.ml`. Rebuilt with `dune build` (opam switch 5.2.0; `dune` not on PATH by default — `eval $(opam env --switch=5.2.0)` first). `extension-opcode-id` now live: returns nil for unregistered names, will return real ids once an extension registers. Conformance **709/709** on the freshly built binary (cherry-picked sx_vm.ml dispatch changes + force-link, zero regressions). 9a checkbox flipped from BLOCKED to INTEGRATED; Blockers entry resolved; added 9h (erlang_ext.ml) + 9i (wire SX dispatcher to real ids) as ordinary in-scope checkboxes, reordered 9g after them. Next: write `hosts/ocaml/lib/extensions/erlang_ext.ml`.
|
||||
|
||||
- **2026-05-14 Phase 9g logged as partially BLOCKED — perf bench waits on 9a** — Conformance half satisfied: 709/709 with all Phase 9 stub infrastructure loaded (10 opcode IDs registered, 72 vm-suite tests passing, zero regressions in tokenize/parse/eval/runtime/ring/ping-pong/bank/echo/fib/ffi suites). Perf-bench half can't move forward in this worktree because the stub handlers wrap the existing `er-bif-*` / `er-match-*` / scheduler impls 1-to-1; a ring benchmark with the new opcodes "active" would measure the same 34 hops/s already documented in `bench_ring_results.md`. Updated `bench_ring_results.md` with a Phase 9 status section explaining the pre-integration state (stubs ready, real measurement gated on 9a's bytecode compiler emitting these IDs at hot sites). Blockers entry added pairing 9g with the existing 9a Blocker. No code change; total **709/709** unchanged. Phase 9 stub work (9b-9f) is complete from this loop's vantage point — 9a and 9g remain BLOCKED on a `hosts/ocaml/` iteration.
|
||||
|
||||
- **2026-05-14 Phase 9f — hot-BIF opcode table green** — Ten hot BIFs get direct opcode IDs in `lib/erlang/vm/dispatcher.sx` so the bytecode compiler can emit them at hot call sites without paying the registry string-key hash: `OP_BIF_LENGTH (136)`, `OP_BIF_HD (137)`, `OP_BIF_TL (138)`, `OP_BIF_ELEMENT (139)`, `OP_BIF_TUPLE_SIZE (140)`, `OP_BIF_LISTS_REVERSE (141)`, `OP_BIF_IS_INTEGER (142)`, `OP_BIF_IS_ATOM (143)`, `OP_BIF_IS_LIST (144)`, `OP_BIF_IS_TUPLE (145)`. Implementation is one line per opcode: the handler IS the existing `er-bif-*` function directly — same `(vs)` signature as the dispatcher's `(operands)`, so the registration is `(er-vm-register-opcode! ID "NAME" er-bif-FOO)`. IDs 136-159 reserved for future hot-BIF additions; cold BIFs continue through `er-apply-bif`/`er-lookup-bif`. 18 new tests in `tests/vm.sx`: opcode-by-id verification (LENGTH), one positive test per BIF (length on 3-cons, hd, tl-is-cons, element index 2, tuple_size 4, lists:reverse preserves length AND actually reverses [head check], is_integer pos+neg, is_atom pos+neg, is_list pos+nil pos+tuple neg, is_tuple pos+neg), opcode-list-grew-to-16+. vm suite 54 → 72. Total **709/709** (+18 vm). Real perf benefit lands when 9a integrates and the compiler emits these IDs at hot sites.
|
||||
|
||||
- **2026-05-14 Phase 9e — OP_SPAWN / OP_SEND + VM-process registry green** — `lib/erlang/vm/dispatcher.sx` gains a parallel mini-runtime distinct from the language-level `er-scheduler`: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid` (counter cell), `er-vm-procs-reset!`, plus six accessors (`er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`). Process record shape is the register-machine layout the real bytecode scheduler will use: `{:id :registers (8 nil slots) :mailbox :state :initial-fn :initial-args}` — fixed register width so cells don't grow during execution. Opcode 134 `OP_SPAWN` calls `er-vm-proc-new!` and returns the new pid; 135 `OP_SEND` appends to the target's mailbox and flips a waiting proc back to runnable, returns false for unknown pid (graceful, doesn't crash). 16 new tests in `tests/vm.sx`: opcode-by-id for both, spawn returns 0 / 1 / count=2 / state=runnable / mailbox empty / 8 registers, send returns true, 3-sends preserve arrival order (first + last verified), send to unknown pid returns false, isolation (p1's msgs don't leak into p2), reset clears procs + resets pid counter. vm suite 38 → 54. One gotcha during impl: SX `fn` bodies evaluate ONLY the last expression — `er-vm-procs-reset!` had two `set-nth!` calls back-to-back which silently dropped the first; wrapped in `(do ...)` to fix. Total **691/691** (+16 vm). Real scheduler with per-process scheduling latency and runnable queue is post-9a.
|
||||
|
||||
- **2026-05-14 Phase 9d — OP_RECEIVE_SCAN stub green** — Selective-receive primitive at opcode id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` — clauses are AST dicts (`{:pattern :guards :body}`), mbox-list is a plain SX list (queue → list is the caller's job), env is the binding target. Internal helpers `er-vm-receive-try-clauses` (per-message clause walker with env snapshot/restore on failure) and `er-vm-receive-scan-loop` (mailbox walker, arrival order). Match returns `{:matched true :index N :body B}` so the caller can queue-delete at N and then evaluate B in the now-mutated env; miss returns `{:matched false}` so the caller can suspend via OP_PERFORM "receive-suspend". Mirrors the existing `er-try-receive-loop` in `transpile.sx` but doesn't reach into the scheduler — purely VM-level. 10 new tests in `tests/vm.sx`: opcode registered, scan finds match at correct index, scan binds var, body left unevaluated, no-match leaves env untouched, empty mailbox, first-match wins (arrival order — verified by two `{ok, _}` msgs and binding the FIRST value). vm suite 28 → 38. Total **675/675** (+10 vm). When 9a integrates and the real OP_RECEIVE_SCAN compiles clauses into a register-machine match, the existing `er-eval-receive-loop` becomes a one-line dispatch wrapper.
|
||||
|
||||
- **2026-05-14 Phase 9c — OP_PERFORM / OP_HANDLE stubs green** — Two new opcodes in `lib/erlang/vm/dispatcher.sx`: id 131 `OP_PERFORM` raises `{:tag "vm-effect" :effect <name> :args <args>}`; id 132 `OP_HANDLE` wraps a thunk in SX `guard`, catches matching effects by `:effect` name, passes the `:args` list to the handler fn, returns the handler's result. New helper `er-vm-effect-marker?` predicates on the dict shape. Non-matching effects rethrow via a small box+rethrow dance (caught with `:else` first, decision deferred to a post-guard cond — re-raise outside the guard's scope so it propagates to outer handlers cleanly). 9 new tests in `tests/vm.sx`: opcode registered for each id; OP_PERFORM raises with correct tag/effect/args; OP_HANDLE catches matching effect; OP_HANDLE returns thunk result when no effect performed; OP_HANDLE rethrows non-matching effect to outer; nested OP_HANDLE blocks separate by effect name (inner handles "a", outer handles "b", performing "b" bypasses inner). vm suite grew 19 → 28 tests. Total **665/665** (+9 vm). Underlying call/cc + raise/guard machinery used by Erlang `receive` is unchanged; this is the shape for the eventual specialization when 9a integrates. Candidate for chiselling to `lib/guest/vm/effects.sx` — Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape.
|
||||
|
||||
- **2026-05-14 Phase 9b — stub VM dispatcher + 3 pattern opcodes green** — New `lib/erlang/vm/dispatcher.sx` defines the stub opcode registry mirroring the OCaml `EXTENSION` shape from `plans/sx-vm-opcode-extension.md`: opcodes registered as `{:id :name :handler}` keyed by string-id, looked up by id OR by name, dispatched via `er-vm-dispatch`. Opcode IDs follow the guest-tier partition (128-199 reserved for guest extensions like erlang/lua). Three opcodes registered at load time via `er-vm-register-erlang-opcodes!`: 128 `OP_PATTERN_TUPLE` → `er-match-tuple`, 129 `OP_PATTERN_LIST` → `er-match-cons`, 130 `OP_PATTERN_BINARY` → `er-match-binary`. Operand contract: `(pattern-ast value env)` returning `true`/`false` and mutating env on success — same as the underlying match functions. New `lib/erlang/tests/vm.sx` suite with 19 tests: 7 dispatcher core (registered, lookup by id+name for all three, two miss cases, list-has-3+); 4 OP_PATTERN_TUPLE (match success + var bind, no-match, arity mismatch); 4 OP_PATTERN_LIST (match, head bind, tail-is-cons, no-match on nil); 3 OP_PATTERN_BINARY (match, segment bind, size mismatch); 1 dispatch error (unknown opcode raises). `conformance.sh` updated: added `vm` to SUITES, added `(load "lib/erlang/vm/dispatcher.sx")` before tests and `(load "lib/erlang/tests/vm.sx")` after ffi, added epoch 110 evaluator. AST shape gotcha: er-match! reads `:type` not `:tag`; binary segment `:size` must be an AST node `{:type "integer" :value "8"}` because `er-eval-expr` runs on it. Total **656/656** (+19 vm). 9b complete; 9c (OP_PERFORM/OP_HANDLE) is next.
|
||||
|
||||
- **2026-05-14 Phase 9a logged as Blocker — sub-phase 9b is next** — 9a (the opcode extension mechanism in `hosts/ocaml/evaluator/`) is explicitly out-of-scope for this loop per the plan itself (briefing scope rule + 9a's own text). Logged a Blockers entry citing `plans/sx-vm-opcode-extension.md` as the design doc and pointing at the fix path (a `hosts/` session lands the registration shape, then a follow-up here wires the stub dispatcher to the real one). Ticked 9a as DONE because its contract was "Log as Blocker" — that's complete. Sub-phases 9b–9g (PATTERN/PERFORM/RECEIVE/SPAWN_SEND/BIF/conformance) now in queue against a stub dispatcher in `lib/erlang/vm/`. No code change this iteration. Total **637/637** unchanged.
|
||||
|
||||
- **2026-05-14 Phase 9 scoped + supporting plan files synced** — Copied three plan files from `/root/rose-ash/plans/` (architecture branch) that this worktree was missing: `fed-sx-design.md` (124KB, the substrate design referenced from Phase 7/8 drivers), `fed-sx-milestone-1.md` (33KB, first concrete implementation milestone), `sx-vm-opcode-extension.md` (19KB, the prerequisite for Phase 9a — designs how `lib/<lang>/vm/` registers opcodes against the OCaml SX VM core). Then appended **Phase 9 — specialized opcodes (the BEAM analog)** to `plans/erlang-on-sx.md` covering sub-phases 9a-9g: 9a (opcode extension mechanism in `hosts/ocaml/`) is out-of-scope for this loop (will be logged as a Blocker when the next iteration tries to start it); 9b-9g (PATTERN_TUPLE/LIST/BINARY, PERFORM/HANDLE, RECEIVE_SCAN, SPAWN/SEND + lightweight scheduler, BIF dispatch table, conformance + perf bench) can be designed and tested against a stub dispatcher in the meantime. Targets: ring benchmark 100k+ hops/sec at N=1000 (~3000× speedup), 1M-process spawn under 30sec (~1000× speedup). Plan framing intact for Phase 7/8 — those reflect the actual implementation done in this loop; the architecture-branch framing diverges in language but the work is equivalent. No code touched this iteration. Total **637/637** unchanged.
|
||||
|
||||
- **2026-05-14 ffi test suite extracted, conformance scoreboard auto-picks it up** — New `lib/erlang/tests/ffi.sx` with its own counter trio (`er-ffi-test-count`/`-pass`/`-fails`) and `er-ffi-test` helper following the same pattern as runtime/eval/ring tests. The 10 file BIF eval tests from the previous iteration moved out of `eval.sx` (eval dropped from 395 to 385 tests) and into the new suite where they're now 9 tests (consolidated the two write+read tests). `conformance.sh` updated: added `ffi` to `SUITES` array with `er-ffi-test-pass`/`-count` symbols, added `(load "lib/erlang/tests/ffi.sx")` after `fib_server.sx`, added `(epoch 109) (eval "(list er-ffi-test-pass er-ffi-test-count)")`. Scoreboard markdown auto-updated to include the row. Suite also asserts that the 5 blocked BIFs (`crypto:hash`, `cid:from_bytes`, `file:list_dir`, `httpc:request`, `sqlite:exec`) are NOT yet registered — turns a future "added the wrapper but forgot to extend ffi tests" into a hard failure. One eval-comparison gotcha en route: SX's `=` does identity equality on dicts so comparing two separately-constructed `(er-mk-atom "true")` values is false; the existing eval suite has an `eev-deep=` helper that handles this, but the simpler fix in ffi was to extract `:name` via `ffi-nm` and compare strings. Total **637/637** (+14 ffi). Phase 8 fully ticked aside from the BLOCKED bullets — those remain unchecked with explicit Blockers references.
|
||||
|
||||
- **2026-05-14 file BIFs landed; crypto/cid/list_dir/http/sqlite blocked on missing host primitives** — Three new FFI BIFs registered in `runtime.sx`: `file:read_file/1`, `file:write_file/2`, `file:delete/1`. Each wraps the SX-host primitive (`file-read`, `file-write`, `file-delete`) inside a `guard` that converts thrown exception strings into Erlang `{error, Reason}` tuples. New helper `er-classify-file-error` does loose pattern-matching on the error message using `string-contains?` to map to standard POSIX-style reasons: `"No such"` → `enoent`, `"Permission denied"` → `eacces`, `"Not a directory"` → `enotdir`, `"Is a directory"` → `eisdir`, fallback `posix_error`. Filenames coerce through `er-source-to-string` so SX strings, Erlang binaries, and Erlang char-code lists all work. Read returns `{ok, Binary}` (bytes via `(map char->integer (string->list ...))` then `er-mk-binary`); write returns bare `ok`; delete returns bare `ok`. Bootstrap registrations added at the bottom of `er-register-builtin-bifs!` under `"file"`. 10 new eval tests: write-then-read round-trip, ok-tag, payload is binary, byte_size content, missing-file `enoent`, delete-ok, read-after-delete `enoent`, write to non-existent dir `enoent`, binary payload (5 raw bytes) round-trip preserving byte count. Blockers entry added covering five Phase 8 BIFs whose host primitives don't exist in this SX runtime: `crypto:hash/2`, `cid:from_bytes/1`/`to_string/1`, `file:list_dir/1`, `httpc:request/4`, `sqlite:open/exec/query/close`. Fix path documented inline (architecture-branch iteration to register OCaml-side primitives). Total **633/633** (+10 eval).
|
||||
|
||||
- **2026-05-14 term-marshalling helpers landed** — `er-to-sx` (Erlang term → SX-native) and `er-of-sx` (SX-native → Erlang term) plus internal helper `er-cons-to-sx-list` (recursive cons-chain walker). All three live in `runtime.sx` next to the BIF registry. Conversion table: atom ↔ symbol via `make-symbol`/`er-mk-atom`; nil ↔ `()`; cons-chain → SX list (recursive marshal of each head); tuple → SX list (one-way — tuples flatten and can't be reconstructed without a tag); binary ↔ SX string (bytes ↔ char codes via `char->integer`/`integer->char`); integer / float / boolean passthrough; opaque types (pid, ref, fun) passthrough. SX strings on the way back become Erlang binaries — the natural FFI return shape. Empty SX list (`type-of` `"nil"`) marshals back to `er-mk-nil`. Edit gotchas during implementation: SX has no `while`, `string-ref`, or `string-length` primitive — used `(map char->integer (string->list s))` for byte extraction and a recursive helper for cons-walking. 23 new runtime tests in `tests/runtime.sx`: 10 covering `er-to-sx` (atom/atom-is-symbol, nil, int / float / bool passthrough, binary→string, cons→list, tuple→list, nested), 8 covering `er-of-sx` (symbol→atom, atom-tag, string→binary, byte content, int passthrough, empty-list→nil, list→cons length, head field), 4 round-trips (int, atom, binary bytes, list length), 1 negative documenting that tuple round-trip flattens to cons. Total **623/623** (+23 runtime).
|
||||
|
||||
- **2026-05-14 BIF registry migration complete — cond chains gone** — `er-register-builtin-bifs!` at the end of `runtime.sx` populates the registry with all 67 built-in BIFs in five module namespaces. Pure ops (`length`, `hd`, `tl`, `element`, predicates, arithmetic, list/atom/integer conversions, all of `lists`) registered via `er-register-pure-bif!`; side-effecting ops (`spawn`, `self`, `exit`, `link`/`monitor`/`register`, `process_flag`, `make_ref`, `throw`/`error`, `io:format`, all of `ets`, all of `code`) via `er-register-bif!`. Multi-arity entries: `is_function/1`/`/2`, `spawn/1`/`/3`, `exit/1`/`/2`, `io:format/1`/`/2`, `lists:seq/2`/`/3`, `ets:delete/1`/`/2` — six pairs, twelve registrations, all pointing at the existing arity-dispatching impl. `throw` and `error` are registered with a tiny inline `(fn (vs) (raise ...))` lambda because the original code chained directly through `raise` inside the cond instead of an `er-bif-*` helper. `er-apply-bif` shrinks from a 44-line cond chain to a 5-line registry lookup. `er-apply-remote-bif` becomes a 7-line dispatcher (user-modules-first → registry → error). All four per-module dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) deleted — net reduction ~110 lines of cond machinery. One subtle wrinkle: `tests/runtime.sx` calls `er-bif-registry-reset!` near the end of its BIF-registry tests, which would have left subsequent test files (ring, ping-pong, etc.) unable to call `length`/`spawn`/etc. Fix: re-call `er-register-builtin-bifs!` at the bottom of `tests/runtime.sx` to repopulate. Total **600/600** unchanged.
|
||||
|
||||
- **2026-05-14 Phase 8 BIF registry foundation** — `lib/erlang/runtime.sx` gains `er-bif-registry` (a `(list {})` mutable cell, same shape as `er-modules`) and five helpers: `er-bif-registry-get`/`er-bif-registry-reset!` (access + reset), `er-bif-key` (format `"Module/Name/Arity"`), `er-register-bif!` and `er-register-pure-bif!` (both upsert; differ only in the `:pure?` flag — pure ones are safe to inline, side-effecting ones go through normal IO), `er-lookup-bif` (returns the entry dict or nil), `er-list-bifs` (registered keys). Entries are `{:module :name :arity :fn :pure?}`. Lookup miss → nil; arity is part of the key so `m:f/1` and `m:f/2` are distinct; re-registering the same key replaces in-place (count stays the same); reset clears. Registry sits alongside `er-modules` in runtime.sx so any other piece of the system can register BIFs without touching the dispatcher — the migration onto this registry (the next checkbox) will rip out the giant cond chains in `er-apply-bif`/`er-apply-remote-bif`. 18 new runtime tests in `tests/runtime.sx`: empty-state, lookup-miss, register-grows-count, lookup-hit-fields (module/name/arity/pure?), fn-invocable, re-register-replaces, pure-flag-true, arity-disambiguation (3 entries for `fake:echo/1`, `fake:echo/2`, `fake:pure/2`), reset-clears, reset-lookup-nil. Total **600/600** (+18 runtime).
|
||||
|
||||
- **2026-05-14 Phase 7 capstone green — full hot-reload ladder works end-to-end** — Wires everything from the previous five iterations into one test program: load cap v1 with `start/0` (spawn-from-inside-module) + `loop/0` + `tag/0` → spawn Pid1 (running v1) → load cap v2 → assert `cap:tag()` returns v2 (cross-module dispatch hits `:current`) → spawn Pid2 (running v2) → `code:soft_purge(cap)` returns `false` (refuses while Pid1 is alive on v1's env) → `code:purge(cap)` returns `true` (kills Pid1, clears `:old`) → `code:soft_purge(cap)` returns `true` (clean — no `:old` left). To make this work, `er-procs-on-env` was extended with a new helper `er-env-derived-from?`: a process counts as "running on" mod-env if its `:initial-fun`'s `:env` IS mod-env directly OR contains at least one binding whose value is a fun closed over mod-env. Reason: `er-apply-fun-clauses` always `er-env-copy`s the closure-env before binding params, so a fun created inside a module body has a `:env` that's a *copy* of mod-env, not mod-env itself — the copy still contains the module's other functions as values, each pointing back to the canonical mod-env. The whole ladder runs as a single `erlang-eval-ast` invocation because each call to `ev` resets the scheduler via `er-sched-init!`, wiping any cross-call Pids. 5 capstone tests: v1 tag, v2 tag (cross-mod after reload), soft_purge-refuses, hard purge, soft_purge-clean-after-hard. Total **582/582** (+5 eval). Phase 7 fully ticked.
|
||||
|
||||
- **2026-05-14 hot-reload call-dispatch semantics verified** — Tests-only iteration: no implementation change, just six new eval tests that nail down the Erlang semantics already implicit in the current code. (1) `M:F()` after reload returns v2's value (cross-module call hits `:current`). (2) Inside a freshly-loaded body, a bare local call resolves through the new mod-env so a chain `a() -> b()` reflects v2's `b/0`. (3) Calling a fun captured BEFORE reload, whose body uses a local call, returns the v1 value (closure pinned to old mod-env via `er-mk-fun`'s `:env` reference). (4) Calling a fun captured BEFORE reload, whose body uses a cross-module call `M:b()`, returns v2's value (cross-module always wins over closed-over env). (5) Two captured funs from two distinct vintages stay independent — F1() + F2() = 10 + 20 = 30. (6) The slot version counter still bumps even while old captured funs are alive, demonstrating the closure-pinning doesn't block reloads. The "running process finishes its current function with the version it started with" property falls out of fun-as-closure semantics for free — there's no special bookkeeping. Total **577/577** (+6 eval).
|
||||
|
||||
- **2026-05-14 code introspection BIFs green** — `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` added to `er-apply-code-bif` dispatch with three small implementations in `transpile.sx`. `which` and `is_loaded` are dict-lookups on the module registry returning the loaded-marker (atom `loaded`) or the missing-marker (atom `non_existing` for which, atom `false` for is_loaded). Since we don't have a filesystem path representation, the standard `{file, Path}` shape for `is_loaded` becomes `{file, loaded}` — same tuple arity so destructuring code stays portable. `all_loaded` iterates `(keys (er-modules-get))` in reverse (so the result list preserves insertion order after the cons-prepend loop), wrapping each name in a `{Module, loaded}` tuple. **10 new eval tests**: non_existing for absent / loaded after load for which; missing / file-tag / loaded-value for is_loaded; empty / count-after-2-loads / first-entry-tag for all_loaded; badarg for both single-arg BIFs. Two of the all_loaded tests needed an explicit `(er-modules-reset!)` before the measurement because prior tests in the suite leave modules registered (the registry is process-global across the whole epoch session). Total **571/571** (+10 eval).
|
||||
|
||||
- **2026-05-14 code:purge/1 + code:soft_purge/1 green** — Two new BIFs in `transpile.sx`: `er-bif-code-purge` and `er-bif-code-soft-purge`, both dispatched through the existing `er-apply-code-bif` cond chain. Shared helper `er-procs-on-env` walks `(er-sched-processes)` and collects pids whose `:initial-fun` is a fun whose `:env` is identical (dict-identity, not structural) to a given env, filtering out already-dead procs. `er-bif-code-purge` looks up the module slot, returns `false` if either the module isn't registered or `:old` is nil; otherwise calls `er-cascade-exit!` on every matching pid with reason `killed`, replaces the slot with a fresh `er-mk-module-slot` that has `:old nil` (current + version preserved), returns `true`. `er-bif-code-soft-purge` returns `true` (treating "no module" / "no old version" as already-purged), else checks for lingering procs and returns `false` (leaving the slot untouched) if any, else clears `:old` and returns `true`. Non-atom Mod raises `error:badarg` from both. **10 new eval tests**: unknown / no-old / after-reload / idempotent for purge; unknown / no-old / clean for soft_purge; badarg for both; one "purge after spawn" test verifying return value (does NOT exercise the kill path — see caveat in plan). Total **561/561** (+10 eval). Implementation cost: 1 dispatch entry, 3 small BIFs, no scheduler changes.
|
||||
|
||||
- **2026-05-14 code:load_binary/3 green** — Canonical hot-reload entry point. Adds a `"code"` module branch to `er-apply-remote-bif`'s dispatch; new helpers `er-source-walk-bytes!` and `er-source-to-string` coerce any of {SX string, Erlang binary `<<...>>`, Erlang char-code cons list} to an SX source string before parsing. `er-bif-code-load-binary` is the BIF itself: validates `Mod` is an atom (`{error, badarg}` else), coerces source (`{error, badarg}` on unrecognised shape), wraps `erlang-load-module` in `guard` to convert parse failures into `{error, badfile}`, checks the parsed `-module(Name).` matches the BIF's first arg (`{error, module_name_mismatch}` else), returns `{module, Mod}`. Reload reuses the Phase-7 slot logic from the previous iteration so calling `code:load_binary(m, _, v2_source)` after `code:load_binary(m, _, v1_source)` bumps the slot to version 2 with v1 sitting in `:old`. 8 new eval tests: ok-tag/ok-name on first load, immediate cross-module call hits new env, reload-and-call returns v2 result, name-mismatch errors with both tag and reason, garbage source yields badfile, non-atom Mod is badarg. Total **551/551** (+8 eval). `code:load_file/1` deferred until `file:read_file/1` lands in Phase 8 (it's just a wrapper that reads bytes from disk then calls `load_binary`).
|
||||
|
||||
- **2026-05-14 Phase 7 module-version slot landed** — `er-modules` entries are now `{:current MOD-ENV :old MOD-ENV-or-nil :version INT :tag "module"}` instead of bare mod-env dicts. New helpers in `runtime.sx`: `er-mk-module-slot`, `er-module-current-env`, `er-module-old-env`, `er-module-version`. `erlang-load-module` updated: first load creates a slot with `:version 1` and `:old nil`; subsequent loads of the same module name copy `:current` into `:old` and increment `:version` (bump-and-shift, single-old-version retention as per OTP semantics). `er-apply-user-module` now reads via `er-module-current-env` so cross-module calls always hit the latest version. 13 new runtime tests (mostly in `tests/runtime.sx`): slot constructor + accessors, registry-after-first-load (v1, old nil), registry-after-second-load (v2, old = previous current env identity, current = new env), v3 on triple-load, registry-reset clears. Total **543/543** (was 530/530). Note: sx-tree path-based MCP tools (`sx_replace_node`, `sx_read_subtree`) are broken in this worktree's `mcp_tree.exe` (every path returns/replaces form 0); edits applied via a Python script then `sx_validate`d. Pattern-based tools (`sx_find_all`, `sx_rename_symbol`) still work fine.
|
||||
|
||||
- **2026-05-14 Phase 7 + Phase 8 scoped** — Plan extended with two new phases driven by fed-sx (see `plans/fed-sx-design.md` §17.5). Phase 7 brings hot code reload back in scope (was previously listed as out-of-scope): module versioning slot, `code:load_file/1`/`purge/1`/`soft_purge/1`/`which/1`/`is_loaded/1`, cross-module calls hitting current, local calls keeping start-time semantics until function returns. Phase 8 introduces a runtime-extensible **FFI BIF registry** that replaces today's hardcoded `er-apply-bif`/`er-apply-remote-bif` cond chains, plus a term-marshalling layer and concrete BIFs for `crypto:hash`, `cid:from_bytes`/`to_string`, `file:read_file`/`write_file`/`list_dir`/`delete`, `httpc:request`, `sqlite:open`/`exec`/`query`. Scope decisions header updated accordingly. Baseline 530/530 unchanged; no code touched this iteration.
|
||||
|
||||
- **2026-04-25 BIF round-out — Phase 6 complete, full plan ticked** — Added 18 standard BIFs in `lib/erlang/transpile.sx`. **erlang module:** `abs/1` (negates negative numbers), `min/2`/`max/2` (use `er-lt?` so cross-type comparisons follow Erlang term order), `tuple_to_list/1`/`list_to_tuple/1` (proper conversions), `integer_to_list/1` (returns SX string per the char-list shim), `list_to_integer/1` (uses `parse-number`, raises badarg on failure), `is_function/1` and `is_function/2` (arity-2 form scans the fun's clause patterns). **lists module:** `seq/2`/`seq/3` (right-fold builder with step), `sum/1`, `nth/2` (1-indexed, raises badarg out of range), `last/1`, `member/2`, `append/2` (alias for `++`), `filter/2`, `any/2`, `all/2`, `duplicate/2`. 40 new eval tests with positive + negative cases, plus a few that compose existing BIFs (e.g. `lists:sum(lists:seq(1, 100)) = 5050`). Total suite **530/530** — every checkbox in `plans/erlang-on-sx.md` is now ticked.
|
||||
- **2026-04-25 ETS-lite green** — Scheduler state gains `:ets` (table-name → mutable list of tuples). New `er-apply-ets-bif` dispatches `ets:new/2` (registers table by atom name; rejects duplicate name with `{badarg, Name}`), `insert/2` (set semantics — replaces existing entry with the same first-element key, else appends), `lookup/2` (returns Erlang list — `[Tuple]` if found else `[]`), `delete/1` (drop table), `delete/2` (drop key; rebuilds entry list), `tab2list/1` (full list view), `info/2` with `size` only. Keys are full Erlang terms compared via `er-equal?`. 13 new eval tests: new return value, insert true, lookup hit + miss, set replace, info size after insert/delete, tab2list length, table delete, lookup-after-delete raises badarg, multi-key aggregate sum, tuple-key insert + lookup, two independent tables. Total suite 490/490.
|
||||
- **2026-04-25 binary pattern matching green** — Parser additions: `<<...>>` literal/pattern in `er-parse-primary`, segment grammar `Value [: Size] [/ Spec]` (Spec defaults to `integer`, supports `binary` for tail). Critical fix: segment value uses `er-parse-primary` (not `er-parse-expr-prec`) so the trailing `:Size` doesn't get eaten by the postfix `Mod:Fun` remote-call handler. Runtime value: `{:tag "binary" :bytes (list of int 0-255)}`. Construction: integer segments emit big-endian bytes (size in bits, must be multiple of 8); binary-spec segments concatenate. Pattern matching consumes bytes from a cursor at the front, decoding integer segments big-endian, capturing `Rest/binary` tail at the end. Whole-binary length must consume exactly. New BIFs: `is_binary/1`, `byte_size/1`. Binaries participate in `er-equal?` (byte-wise) and format as `<<b1,b2,...>>`. 21 new eval tests: tag/predicate, byte_size for 8/16/32-bit segments, single + multi segment match, three 8-bit, tail rest size + content, badmatch on size mismatch, `=:=` equality, var-driven construction. Total suite 477/477.
|
||||
@@ -131,4 +251,24 @@ _Newest first._
|
||||
|
||||
## Blockers
|
||||
|
||||
- _(none yet)_
|
||||
- **Phase 10a — opcode emission requires `lib/compiler.sx` (out of scope)** (2026-05-15). Architecture fully traced this iteration: the OCaml JIT (`sx_vm.ml` `jit_compile_lambda`, ref-set at line 1206) invokes the SX-level `compile` from **`lib/compiler.sx`** via the CEK machine; that is the sole SX→bytecode producer. Erlang's hot helpers (`er-match-tuple`, `er-bif-*`, …) are SX functions in `transpile.sx` that get JIT-compiled through this path. To emit `erlang.OP_*` they must be recognized as intrinsics inside `compiler.sx`'s `compile-call` (the file's own docstring already anticipates this: "Compilers call `extension-opcode-id` to emit extension opcodes" — designed, not yet implemented). `lib/compiler.sx` is **lib-root**, excluded by the ground rules ("Don't edit lib/ root") and absent from the widened `lib/erlang/** + hosts/ocaml/** (extension only)` scope — editing it changes every guest language's JIT, so it must be owned by a shared-compiler session, not this loop. **Fix path:** that session implements 10a.1 (intrinsic registry in `compiler.sx`) + 10a.2 (`compile-call` emits the opcode when registered & `extension-opcode-id` non-nil, else generic CALL). Erlang's BIF handlers (10b, ids 230-239, all real) light up the instant emission exists — zero further work here. The control opcodes (222-229) additionally need 10a.3 (operand contract) + OCaml↔SX runtime-state bridging (Erlang scheduler/mailbox live in `lib/erlang/runtime.sx`, not OCaml).
|
||||
|
||||
- **Phase 9g — Perf bench gated on 9a** (2026-05-14). The conformance half of 9g (709/709 with stub VM loaded) is satisfied; the perf-bench half requires 9a's bytecode compiler to actually emit the new opcodes at hot call sites. Until then a benchmark would measure today's `er-bif-*` / `er-match-*` numbers unchanged (since the stub handlers wrap them 1-to-1). Re-fire 9g after 9a lands.
|
||||
|
||||
- **Phase 9a — Opcode extension mechanism** — **RESOLVED 2026-05-15.** User widened scope to include hosts/ (merging back anyhow). Cherry-picked vm-ext phases A-E + force-linked `Sx_vm_extensions` into sx_server.exe. `extension-opcode-id` live; conformance 709/709. Remaining integration work (erlang_ext.ml + wiring the SX stub dispatcher to consult real ids) tracked as ordinary in-scope checkboxes now, not blockers.
|
||||
|
||||
- **RESOLVED (2026-05-18) — SX runtime now exposes the platform
|
||||
primitives Phase 8 BIFs need.** Delivered by `loops/fed-prims`
|
||||
(see `plans/fed-sx-host-primitives.md` Handoff). Pure-OCaml,
|
||||
WASM-safe except `http-listen` (native only). Wire Phase 8 BIFs:
|
||||
- `crypto:hash/2` → `crypto-sha256` / `crypto-sha512` /
|
||||
`crypto-sha3-256` (each `(bytes) -> hex-string`).
|
||||
- `cid:from_bytes/1` → `cid-from-bytes` `(codec mh-bytes)`;
|
||||
`cid:to_string/1` / canonical CID → `cid-from-sx` `(value)`;
|
||||
dag-cbor via `cbor-encode` / `cbor-decode`.
|
||||
- signature verify → `ed25519-verify` `(pk msg sig)` and
|
||||
`rsa-sha256-verify` `(spki msg sig)` — both total (→ false).
|
||||
- `file:list_dir/1` → `file-list-dir` `(path) -> (list string)`.
|
||||
- fed-sx transport → `http-listen` `(port handler)` (native only).
|
||||
Still deferred (leave blocked): `httpc` (HTTP client, v2) and
|
||||
`sqlite-*` (v2 indexes) — not provided by fed-prims.
|
||||
|
||||
81
plans/events-on-sx.md
Normal file
81
plans/events-on-sx.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# events-on-sx: Calendar, ticketing & notification delivery on Datalog
|
||||
|
||||
> **DRAFT outline.** The events vertical + the shared notification-delivery edge.
|
||||
> Depends on `persist-on-sx` (bookings ledger) and `flow-on-sx` (reminders, retrying
|
||||
> delivery). Pairs with `commerce-on-sx` for paid tickets.
|
||||
|
||||
rose-ash's `events` domain is calendar + ticketing: recurring events, availability,
|
||||
capacity, bookings. Scheduling is constraint reasoning — "is this slot free given
|
||||
recurrence, capacity, and the attendee's other bookings?" — which is rule
|
||||
evaluation over facts. Datalog expresses availability, recurrence expansion, and
|
||||
capacity as rules; a booking is a transaction; reminders and digests are durable
|
||||
`flow`s. Notification *delivery* (email/push) — needed here and by `feed/notify` —
|
||||
is folded in as an injected transport, extractable later.
|
||||
|
||||
End-state: a Datalog-on-SX events layer with recurrence expansion, availability +
|
||||
capacity rules, transactional booking, and a flow-driven notification dispatcher
|
||||
(reminders, digests, retries) over an injected transport.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/events/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only `lib/events/**` and `plans/events-on-sx.md`. May **import** from
|
||||
`lib/datalog/`, and (once they exist) `lib/persist/` + `lib/flow/`. Do not edit
|
||||
substrates.
|
||||
- **Architecture:** events/availability/capacity are Datalog facts + rules;
|
||||
recurrence expands to occurrence facts within a window; a booking checks rules
|
||||
then appends a `persist` event (idempotent, capacity-safe). Notifications are flows
|
||||
that suspend on transport IO and retry on failure.
|
||||
- **Determinism:** recurrence expansion + availability must be reproducible for a
|
||||
fixed window + ruleset; capacity checks must be race-safe (no overbooking).
|
||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
Event + booking Result
|
||||
event(id,start,rrule,capacity) {:booked | :full | :conflict} + reminders
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/events/calendar.sx lib/events/availability.sx
|
||||
— event facts, recurrence (RRULE) — free/busy + capacity rules (Datalog)
|
||||
— expand occurrences in window │
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/events/booking.sx lib/events/notify.sx (flow)
|
||||
— transactional, capacity-safe — reminders / digests, retry on fail
|
||||
— bookings → persist ledger — injected transport (email/push)
|
||||
│ │
|
||||
▼ ▼
|
||||
lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──────┘
|
||||
```
|
||||
|
||||
## Phase 1 — Calendar + recurrence
|
||||
- [ ] `calendar.sx` — event facts, RRULE expansion in a window
|
||||
- [ ] `availability.sx` — free/busy rules
|
||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
||||
|
||||
## Phase 2 — Ticketing + booking
|
||||
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
||||
- [ ] paid tickets compose with `commerce` order flow
|
||||
- [ ] tests: capacity edge, double-book guard, conflict detection
|
||||
|
||||
## Phase 3 — Notification delivery (flow)
|
||||
- [ ] `notify.sx` — reminder/digest flows over injected transport
|
||||
- [ ] retry/backoff on transport failure (flow suspend/resume)
|
||||
- [ ] tests: delivery success, retry path, idempotent re-send
|
||||
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
||||
`delivery-on-sx` once a second consumer is real
|
||||
|
||||
## Phase 4 — Federation
|
||||
- [ ] cross-instance events (peer calendar) — trust-gated stub
|
||||
- [ ] tests: federated agenda merge
|
||||
|
||||
## Progress log
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
(loop fills this in)
|
||||
2638
plans/fed-sx-design.md
Normal file
2638
plans/fed-sx-design.md
Normal file
File diff suppressed because it is too large
Load Diff
290
plans/fed-sx-host-primitives.md
Normal file
290
plans/fed-sx-host-primitives.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# fed-sx host primitives — `hosts/ocaml/`
|
||||
|
||||
The single blocker between Erlang Phase 8 (FFI mechanism — done) and starting
|
||||
fed-sx Milestone 1: the SX OCaml runtime exposes no crypto / CID / HTTP host
|
||||
primitives for the Phase 8 BIF wrappers to call. This plan adds exactly that
|
||||
surface, pure-OCaml where it must stay WASM-safe, native-only where it can't.
|
||||
|
||||
Reference: `plans/fed-sx-milestone-1.md` (build steps 1-8),
|
||||
`plans/erlang-on-sx.md` Blockers ("SX runtime lacks platform primitives …").
|
||||
|
||||
## The hard constraint — WASM boundary
|
||||
|
||||
`hosts/ocaml/lib/` is the `sx` library. `hosts/ocaml/browser/dune` links it
|
||||
with `(modes byte js wasm)`. **Anything added to `lib/sx_primitives.ml` must
|
||||
compile under `js_of_ocaml` AND `wasm_of_ocaml`.** Therefore:
|
||||
|
||||
- **Pure OCaml only** for hash / CBOR / CID / Ed25519 / RSA. No `digestif`,
|
||||
no `mirage-crypto`, no C stubs, no `Unix` dependency in these primitives.
|
||||
(None of those libs are even installed — the switch has only
|
||||
re/unix/yojson/otfm/js_of_ocaml. Pure OCaml is both required and hermetic.)
|
||||
- **HTTP server is native-only**: it needs sockets/threads. Register it in
|
||||
`bin/sx_server.ml` via `Sx_primitives.register` (precedent: `eval-in-env` at
|
||||
`bin/sx_server.ml:721`), **not** in the shared lib. It must never enter the
|
||||
WASM build.
|
||||
- **`file-list-dir`** uses `Sys.readdir` (stdlib, WASM-stubbed) — safe in lib,
|
||||
but the fed-sx server is native anyway; native registration is acceptable too.
|
||||
|
||||
**Every phase must prove the WASM build still links** (`sx_build target="wasm"`
|
||||
or `bash hosts/ocaml/browser/test_boot.sh`) before its commit. A broken WASM
|
||||
browser kernel is a hard regression and fails the phase.
|
||||
|
||||
## Primitive surface (what fed-sx Milestone 1 actually needs)
|
||||
|
||||
Mapped to `plans/fed-sx-milestone-1.md` build steps:
|
||||
|
||||
| Primitive (SX name) | Signature | fed-sx step | Host |
|
||||
|---|---|---|---|
|
||||
| `crypto-sha256` | `(bytes) -> hex-string` | 1, 2 | lib (pure) |
|
||||
| `crypto-sha512` | `(bytes) -> hex-string` | 2 | lib (pure) |
|
||||
| `crypto-sha3-256` | `(bytes) -> hex-string` | 1 (CID default) | lib (pure) |
|
||||
| `cbor-encode` | `(sx-value) -> bytes` (dag-cbor, deterministic) | 1 | lib (pure) |
|
||||
| `cbor-decode` | `(bytes) -> sx-value` | 1 (round-trip tests) | lib (pure) |
|
||||
| `cid-from-bytes` | `(codec multihash-bytes) -> cid-string` | 1 | lib (pure) |
|
||||
| `cid-from-sx` | `(sx-value) -> cid-string` (canonicalize→cbor→sha→mh→cidv1) | 1 | lib (pure) |
|
||||
| `ed25519-verify` | `(pubkey-32 msg sig-64) -> bool` | 2 | lib (pure) |
|
||||
| `rsa-sha256-verify` | `(der-spki msg sig) -> bool` (PKCS#1 v1.5) | 2 | lib (pure) |
|
||||
| `file-list-dir` | `(path) -> (list string)` | 3 | lib/native |
|
||||
| `http-listen` | `(port handler-fn) -> never` (handler: req-dict→resp-dict) | 8 | **native only** |
|
||||
|
||||
Deferred (not Milestone 1): `httpc-request` (HTTP client — federation is v2),
|
||||
`sqlite-*` (Milestone 1 is file-on-disk; sqlite is v2 indexes).
|
||||
|
||||
## Registration pattern (established)
|
||||
|
||||
`lib/sx_primitives.ml`:
|
||||
```ocaml
|
||||
register "crypto-sha256" (fun args ->
|
||||
match args with
|
||||
| [String s] -> String (Sha2.sha256_hex s)
|
||||
| _ -> raise (Eval_error "crypto-sha256: (bytes)"))
|
||||
```
|
||||
Errors: `raise (Eval_error "name: shape")`. Byte strings are OCaml `string`
|
||||
(SX `String`). Lists are `Pair`/`Nil` per `sx_types.ml`. Native-only prims go in
|
||||
`bin/sx_server.ml` the same way.
|
||||
|
||||
## Phasing — one feature per loop iteration
|
||||
|
||||
Dependency order. Each phase: implement → `dune build` (ocaml) → **WASM build
|
||||
check** → tests → commit → tick box → Progress-log line → push.
|
||||
|
||||
### Phase A — SHA-2 (sha256 + sha512), pure OCaml ✅ DONE
|
||||
- New `lib/sx_sha2.ml` (or inline in primitives if small): SHA-256 + SHA-512.
|
||||
- Primitives `crypto-sha256`, `crypto-sha512` → lowercase hex string.
|
||||
- Tests (`bin/run_tests.ml` or a dedicated `bin/test_crypto.ml`): NIST vectors —
|
||||
`""`, `"abc"`, the 896-bit message, a 1MB "a" repetition.
|
||||
- sha256("") = `e3b0c442…b7852b855`; sha256("abc") = `ba7816bf…f20015ad`
|
||||
- sha512("abc") = `ddaf35a1…2a9ac94f…`
|
||||
- **Acceptance:** vectors pass; WASM build links; OCaml conformance unchanged.
|
||||
|
||||
### Phase B — SHA-3 / Keccak-256, pure OCaml ✅ DONE
|
||||
- Keccak-f[1600] + SHA3-256 padding. Primitive `crypto-sha3-256`.
|
||||
- Tests: sha3-256("") = `a7ffc6f8…0f8434a`; sha3-256("abc") = `3a985da7…11431532`.
|
||||
- **Acceptance:** NIST SHA-3 vectors pass; WASM links.
|
||||
|
||||
### Phase C — dag-cbor encoder + decoder, pure OCaml ✅ DONE
|
||||
- RFC 8949 deterministic subset (RFC 8742 dag-cbor): unsigned/negative ints,
|
||||
byte strings, text strings, arrays, maps with **keys sorted by
|
||||
length-then-bytewise**, bool, null, tag 42 (CID link). No floats unless a
|
||||
fed-sx shape needs them (defer; document).
|
||||
- SX↔CBOR mapping: `Integer`→int, `String`→text str, `Bool`, `Nil`→null,
|
||||
`Pair/Nil`→array, `Dict`→map (sorted keys), keyword/symbol→text str.
|
||||
- Primitives `cbor-encode`, `cbor-decode`. Round-trip property tests + RFC 8949
|
||||
appendix-A vectors + a "reordered dict keys → identical bytes" determinism test.
|
||||
- **Acceptance:** vectors + round-trip + determinism pass; WASM links.
|
||||
|
||||
### Phase D — CID computation, pure OCaml ✅ DONE
|
||||
- Multihash (sha2-256 = 0x12, sha3-256 = 0x16; varint code + varint len + digest).
|
||||
- CIDv1 = `0x01 || codec-varint || multihash`. Codecs: dag-cbor 0x71, raw 0x55.
|
||||
- Multibase base32 lower (`b` prefix, RFC 4648 no-pad).
|
||||
- Primitives `cid-from-bytes` (codec, raw mh bytes), `cid-from-sx`
|
||||
(canonicalize → cbor-encode → sha2-256 → multihash → cidv1 → base32).
|
||||
- Tests: known IPFS CIDs — cross-check against `ipfs` CLI if present, else the
|
||||
fixed vectors for `{}` dag-cbor and `"abc"` raw (hardcode expected strings).
|
||||
Determinism: same SX value (whitespace/comment/key-order variants) → same CID.
|
||||
- **Acceptance:** matches reference CIDs; determinism holds; WASM links. Satisfies
|
||||
fed-sx Milestone 1 Step 1.
|
||||
|
||||
### Phase E — Ed25519 verify, pure OCaml ✅ DONE
|
||||
- Curve25519/edwards25519 field arith (mod 2^255-19), point decompress,
|
||||
SHA-512-based verify per RFC 8032 §5.1.7. (Reuse Phase A sha512.)
|
||||
- Primitive `ed25519-verify (pubkey msg sig) -> bool`. Bad-length args → false,
|
||||
not exception (verify is total).
|
||||
- Tests: RFC 8032 §7.1 vectors (TEST 1-4 + the 1024-byte one). Tampered msg/sig
|
||||
→ false. Wrong-length key → false.
|
||||
- **Acceptance:** all RFC 8032 vectors pass; WASM links. Satisfies fed-sx Step 2
|
||||
(Ed25519 sig-suite).
|
||||
|
||||
### Phase F — RSA-SHA256 verify (PKCS#1 v1.5), pure OCaml ✅ DONE
|
||||
- Minimal pure-OCaml bignum (only need modexp + DER parse). Parse SPKI DER →
|
||||
(n, e). RSASSA-PKCS1-v1_5 verify with SHA-256 (Phase A).
|
||||
- Primitive `rsa-sha256-verify (der-spki msg sig) -> bool`.
|
||||
- Tests: a generated 2048-bit keypair's signature (vectors hardcoded in the test
|
||||
from a one-off openssl run, documented in a comment), tamper → false.
|
||||
- **Acceptance:** vector verifies; tamper fails; WASM links. Satisfies fed-sx
|
||||
Step 2 (rsa-sha256-2018 sig-suite). **Lower priority** than E — Ed25519 is the
|
||||
modern default; RSA can land after the HTTP phase if time-boxed.
|
||||
|
||||
### Phase G — `file-list-dir`, native-safe ✅ DONE
|
||||
- `Sys.readdir` → sorted SX list of names (no `.`/`..`). Errors → `enoent`/
|
||||
`enotdir` classified like the existing `file-read` error mapping.
|
||||
- Tests: list a known dir, missing dir → error, file-not-dir → error.
|
||||
- **Acceptance:** passes; WASM build still links (Sys.readdir is stubbed there).
|
||||
Satisfies fed-sx Step 3 segment replay.
|
||||
|
||||
### Phase H — HTTP/1.1 server, **native-only** (`bin/sx_server.ml`) ✅ DONE
|
||||
- Minimal threaded HTTP/1.1: accept loop (`Unix` + `Thread`), parse request
|
||||
line + headers + body (Content-Length), build an SX request dict
|
||||
`{:method :path :query :headers :body}`, call the SX handler callable, take an
|
||||
SX response dict `{:status :headers :body}`, write it. Connection: close
|
||||
(keep-alive optional, defer). Bind `127.0.0.1:<port>`.
|
||||
- Primitive `http-listen (port handler) -> never-returns` registered ONLY in
|
||||
`bin/sx_server.ml`. Document that it is absent from the WASM kernel.
|
||||
- Tests: `bin/test_http.sh` — start a server on a port with a tiny SX echo
|
||||
handler in a subprocess, `curl` GET/POST/404/headers, assert responses, kill.
|
||||
- **Acceptance:** curl test script green; WASM build untouched (prim not in lib).
|
||||
Satisfies fed-sx Step 8 transport.
|
||||
|
||||
### Phase I — handoff ✅ DONE
|
||||
- Flip the `plans/erlang-on-sx.md` Blockers entry "SX runtime lacks platform
|
||||
primitives …" to **RESOLVED**, listing the exact SX primitive names so the
|
||||
Erlang loop can one-line-wire its blocked Phase 8 BIFs (`crypto:hash/2`,
|
||||
`cid:from_bytes/1`, `cid:to_string/1`, `file:list_dir/1`, plus note
|
||||
`httpc`/`sqlite` still deferred). **Do not edit `lib/erlang/`** — that wiring
|
||||
is the Erlang loop's job; this phase only updates the blocker text + this
|
||||
plan's "Handoff" section with the primitive→BIF mapping.
|
||||
- **Acceptance:** blocker text updated; fed-sx Milestone 1 Steps 1-3 + 8
|
||||
prerequisites all green.
|
||||
|
||||
## Scope (hard)
|
||||
|
||||
- **Edit only:** `hosts/ocaml/lib/**`, `hosts/ocaml/bin/**`, this plan file.
|
||||
- **Do NOT edit:** `lib/erlang/**` (Erlang loop owns BIF wiring), `spec/`,
|
||||
`lib/` root, other `lib/<lang>/`, `plans/erlang-on-sx.md` *except* the one
|
||||
Blockers entry in Phase I.
|
||||
- **Pure OCaml for lib primitives.** No new opam deps. If a phase seems to need
|
||||
one, stop and add a Blockers entry instead.
|
||||
- **Prove WASM every phase.** No commit without `test_boot.sh` (or wasm build)
|
||||
green.
|
||||
- **Never push to `main` or `architecture`.** Branch `loops/fed-prims`, push
|
||||
`origin/loops/fed-prims`.
|
||||
- One feature per commit. Short factual messages: `fed-prims: SHA-256 + 4 NIST
|
||||
vectors`. Tick the box, append a dated Progress-log line (newest first).
|
||||
- **Never call `sx_build` with no timeout-awareness** — OCaml builds are slow;
|
||||
use the MCP `sx_build target="ocaml"` / `target="wasm"` tools or
|
||||
`dune build` with a generous timeout. If the build hangs >10min, Blockers +
|
||||
stop.
|
||||
|
||||
## Build & test reference
|
||||
|
||||
```bash
|
||||
cd hosts/ocaml && dune build bin/sx_server.exe 2>&1 | tail # native
|
||||
bash hosts/ocaml/browser/test_boot.sh # WASM links + boots
|
||||
cd hosts/ocaml && dune exec bin/run_tests.exe 2>&1 | tail # OCaml unit tests
|
||||
SX_SERVER=hosts/ocaml/_build/default/bin/sx_server.exe \
|
||||
timeout 400 bash lib/erlang/conformance.sh 2>&1 | tail -3 # no-regression gate
|
||||
```
|
||||
|
||||
A primitive is reachable from SX via the epoch protocol:
|
||||
```bash
|
||||
printf '(epoch 1)\n(crypto-sha256 "abc")\n' | \
|
||||
hosts/ocaml/_build/default/bin/sx_server.exe
|
||||
```
|
||||
|
||||
## Handoff (Phase I fills this in)
|
||||
|
||||
| SX primitive | Erlang Phase 8 BIF it unblocks |
|
||||
|---|---|
|
||||
| `crypto-sha256` / `crypto-sha512` / `crypto-sha3-256` | `crypto:hash/2` |
|
||||
| `cid-from-bytes` / `cid-from-sx` | `cid:from_bytes/1`, `cid:to_string/1` |
|
||||
| `ed25519-verify` / `rsa-sha256-verify` | `crypto:verify` / sig-suites |
|
||||
| `file-list-dir` | `file:list_dir/1` |
|
||||
| `http-listen` | fed-sx kernel `http:listen/2` (Milestone 1 Step 8) |
|
||||
|
||||
**Status: DELIVERED (Phases A–H, 2026-05-18).** All primitives are
|
||||
registered and reachable from SX (`(eval "(crypto-sha256 \"abc\")")`
|
||||
via the epoch protocol). Signatures the Erlang loop can one-line-wire:
|
||||
|
||||
- `(crypto-sha256 bytes) -> hex-string` — also `crypto-sha512`,
|
||||
`crypto-sha3-256`. lib (`Sx_sha2`/`Sx_sha3`), WASM-safe.
|
||||
- `(cbor-encode value) -> bytes` / `(cbor-decode bytes) -> value` —
|
||||
deterministic dag-cbor, lib (`Sx_cbor`), WASM-safe.
|
||||
- `(cid-from-bytes codec mh-bytes) -> cid-string` /
|
||||
`(cid-from-sx value) -> cid-string` — lib (`Sx_cid`), WASM-safe.
|
||||
- `(ed25519-verify pk msg sig) -> bool` /
|
||||
`(rsa-sha256-verify spki msg sig) -> bool` — total (bad input →
|
||||
false), lib (`Sx_ed25519`/`Sx_rsa`), WASM-safe.
|
||||
- `(file-list-dir path) -> (list string)` — sorted, lib, WASM-stubbed.
|
||||
- `(http-listen port handler) -> never` — **NATIVE ONLY**
|
||||
(`bin/sx_server.ml`); absent from the WASM kernel by design.
|
||||
|
||||
Still **deferred** (not Milestone 1, not provided here): `httpc-request`
|
||||
(HTTP client / federation v2), `sqlite-*` (v2 indexes). The Erlang loop
|
||||
should leave `httpc`/`sqlite` BIFs blocked with that note.
|
||||
|
||||
## Progress log
|
||||
|
||||
_Newest first._
|
||||
|
||||
- 2026-05-18 — Phase I: handoff. `erlang-on-sx.md` Blockers gained one
|
||||
RESOLVED entry (no "SX runtime lacks…" entry pre-existed; it read
|
||||
"_(none yet)_") mapping every delivered primitive → its Phase 8 BIF,
|
||||
with httpc/sqlite explicitly left deferred. Handoff section here
|
||||
filled with signatures + native/WASM notes. Doc-only (no lib/erlang/
|
||||
edits); Erlang 530/530 unchanged. **fed-sx Milestone 1 Steps 1-3 + 8
|
||||
prerequisites all green — plan complete (Phases A–I done).**
|
||||
- 2026-05-18 — Phase H: `http-listen` primitive in `bin/sx_server.ml`
|
||||
(NATIVE ONLY — Unix sockets + Thread per connection, Mutex around
|
||||
the shared-runtime handler call; HTTP/1.1, Connection: close;
|
||||
req {:method :path :query :headers :body} → resp {:status :headers
|
||||
:body}). Test `bin/test_http.sh`: curl GET+query / POST+body / 404
|
||||
/ custom header — 6/6. NOT in lib, so WASM kernel untouched (boot
|
||||
green); run_tests 4897 unchanged; Erlang 530/530. Satisfies fed-sx
|
||||
Milestone 1 Step 8 transport.
|
||||
- 2026-05-18 — Phase G: `file-list-dir` primitive in
|
||||
`lib/sx_primitives.ml` (Sys.readdir → sorted names, no "."/"..";
|
||||
Sys_error prefixed like file-read, msg carries enoent/enotdir).
|
||||
4 tests: sorted listing, missing dir, not-a-dir, arity. WASM boot
|
||||
green (Sys.readdir stubbed there); Erlang 530/530; run_tests +4.
|
||||
Satisfies fed-sx Step 3 segment replay.
|
||||
- 2026-05-18 — Phase F: pure-OCaml `lib/sx_rsa.ml` (self-contained
|
||||
bignum modexp, minimal DER SPKI reader, RFC 8017 §8.2.2 PKCS#1
|
||||
v1.5 verify with SHA-256 DigestInfo prefix). Primitive
|
||||
`rsa-sha256-verify` total. 5 tests on a fixed RSA-2048 vector
|
||||
(one-off python-cryptography keygen, hardcoded): valid, tampered
|
||||
msg/sig, garbage SPKI, non-string. WASM boot green with new lib
|
||||
module; Erlang 530/530; run_tests +5. Satisfies fed-sx Step 2
|
||||
(rsa-sha256-2018 sig-suite).
|
||||
- 2026-05-18 — Phase E: pure-OCaml `lib/sx_ed25519.ml` (minimal
|
||||
base-2^26 bignum, edwards25519 extended-coord points, RFC 8032
|
||||
§5.1.7 cofactorless verify reusing Phase-A sha512). Primitive
|
||||
`ed25519-verify` is total (bad/short/non-string args → false).
|
||||
8 tests: RFC 8032 §7.1 TEST 1-3 (re-derived independently via
|
||||
python-cryptography), tampered msg/sig, wrong-length, non-string.
|
||||
WASM boot green with new lib module; Erlang 530/530; run_tests +8.
|
||||
Satisfies fed-sx Milestone 1 Step 2 (Ed25519 sig-suite).
|
||||
- 2026-05-18 — Phase D: pure-OCaml `lib/sx_cid.ml` (unsigned-varint,
|
||||
multihash, CIDv1, multibase base32-lower), primitives `cid-from-bytes`
|
||||
/ `cid-from-sx` (cbor→sha2-256→mh→cidv1, dag-cbor codec 0x71). 5 tests:
|
||||
raw "abc"=bafkreif2pall7d…, raw ""=bafkreihdwdcefg…, dag-cbor {}=
|
||||
bafyreigbtj4x7i… (all match canonical IPFS CIDs; no `ipfs` CLI so
|
||||
vectors independently derived in Python), key-order determinism. WASM
|
||||
boot green with new lib module; Erlang 530/530; run_tests +5.
|
||||
- 2026-05-18 — Phase C: pure-OCaml `lib/sx_cbor.ml` (dag-cbor encode/
|
||||
decode), primitives `cbor-encode`/`cbor-decode`. RFC 8949 Appendix-A
|
||||
vectors, length-then-bytewise key sort + order-invariance determinism,
|
||||
decode∘encode round-trip (30 tests). Floats unsupported (raise, no
|
||||
fed-sx shape needs them); tag-42 decode = inner-item passthrough.
|
||||
WASM boot green with new lib module; Erlang 530/530; run_tests +30.
|
||||
- 2026-05-18 — Phase B: pure-OCaml `lib/sx_sha3.ml` (Keccak-f[1600] +
|
||||
SHA-3 pad, domain 0x06), primitive `crypto-sha3-256`. 4 NIST FIPS 202
|
||||
vectors pass (empty/abc/896-bit + 1600-bit 0xa3 multi-block). WASM boot
|
||||
green with new lib module; Erlang conformance 530/530; run_tests +4.
|
||||
- 2026-05-18 — Phase A: pure-OCaml `lib/sx_sha2.ml` (SHA-256 + SHA-512),
|
||||
primitives `crypto-sha256`/`crypto-sha512`. 7 NIST FIPS 180-4 vectors pass
|
||||
(empty/abc/896-bit/1M-'a' for sha256; empty/abc/896-bit for sha512). WASM
|
||||
boot green with new lib module; Erlang conformance 530/530 unchanged.
|
||||
|
||||
## Blockers
|
||||
|
||||
- _(none yet)_
|
||||
922
plans/fed-sx-milestone-1.md
Normal file
922
plans/fed-sx-milestone-1.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# fed-sx Milestone 1 — Kernel + Registries + Pin Smoke Test
|
||||
|
||||
Concrete implementation plan for the smallest fed-sx that proves the architecture
|
||||
works end-to-end. Reference: `plans/fed-sx-design.md`. Prerequisite: Erlang-on-SX
|
||||
Phases 7 (hot reload) + 8 (FFI BIFs).
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a single-instance, single-actor fed-sx server that:
|
||||
|
||||
1. Boots from a verified genesis bundle.
|
||||
2. Accepts and durably appends signed activities via `POST /activity`.
|
||||
3. Folds them into projections in real time.
|
||||
4. Serves AP-standard endpoints (actor, outbox, artifacts, capabilities).
|
||||
5. Demonstrates **two extensibility proof-points** end-to-end with zero kernel
|
||||
code changes between definition and use:
|
||||
- **Verb extensibility** (§5 meta-level): publish `DefineActivity{Pin}` +
|
||||
`DefineProjection{pin-state}`, then publish a `Pin` activity, observe it
|
||||
validated and projected.
|
||||
- **Reactive application extensibility** (§§18-19): publish
|
||||
`DefineSubscription{Topic}` + `Subscribe{topic: smoketest}` +
|
||||
`DefineTrigger{when: that subscription, then: publish TestEcho}`, then
|
||||
publish a tagged Note, observe the subscription match, the trigger fire,
|
||||
and the derived activity appear in the outbox.
|
||||
|
||||
Federation, multi-actor, advanced verbs, IPFS, browser UI, operator dashboard
|
||||
are **explicitly v2**.
|
||||
|
||||
## Non-goals (what milestone 1 deliberately does NOT do)
|
||||
|
||||
- **Federation.** No `POST /inbox` from peers, no `Follow`, no delivery queue, no
|
||||
webfinger discovery flow. Single instance only.
|
||||
- **Multi-actor.** Single domain actor (`acct:next@next.rose-ash.com`).
|
||||
- **IPFS / S3 storage backends.** Files on disk only.
|
||||
- **Advanced verbs.** No `Endorse`, `Supersede`, `Test`, `Build`, `Compose`,
|
||||
`Note`, `Announce`. Only the four bootstrap verbs (`Create`, `Update`, `Delete`)
|
||||
plus a defined-from-the-log `Pin` for the smoke test. (`Announce` deferred —
|
||||
no use case until federation exists.)
|
||||
- **Browser UI.** Curl-shaped API only.
|
||||
- **Operator dashboard, quarantine UX.** Logs only.
|
||||
- **Performance work.** Functional correctness first; perf when measured.
|
||||
- **Cross-host conformance test corpus.** Only the OCaml/Erlang-on-SX host runs
|
||||
fed-sx in v1; conformance suite for other hosts is v2.
|
||||
|
||||
## Architecture summary
|
||||
|
||||
```
|
||||
POST /activity
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ HTTP server (Erlang-on-SX)│
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────┐
|
||||
│ Validation pipeline driver │
|
||||
│ (envelope→sig→schema→...) │
|
||||
└─────────────┬──────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────┐
|
||||
│ Log append (JSONL segment) │ ← canonical
|
||||
└─────────────┬──────────────┘
|
||||
│
|
||||
┌─────────────▼──────────────┐
|
||||
│ Projection workers │ ← gen_server per
|
||||
│ (fold scheduler) │ projection
|
||||
└─────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Projection state
|
||||
(queryable via HTTP)
|
||||
|
||||
Native primitives (Erlang-on-SX BIFs from Phase 8):
|
||||
crypto:* cid:* fs:* http:* sqlite:*
|
||||
|
||||
Genesis bundle (binary-embedded SX):
|
||||
activity-types object-types projections
|
||||
validators codecs sig-suites
|
||||
```
|
||||
|
||||
## Build order
|
||||
|
||||
Eight steps in dependency order. Each step has concrete deliverables, testable
|
||||
in isolation, and a clear acceptance check.
|
||||
|
||||
| Step | Title | Depends on |
|
||||
|------|-------|------------|
|
||||
| **1** | Repo skeleton + canonical CID computation | Phase 8 (cid BIFs) |
|
||||
| **2** | Activity envelope + signature verify | Phase 8 (crypto BIFs) |
|
||||
| **3** | JSONL log + sequence numbers | Phase 8 (fs BIFs) |
|
||||
| **4** | Genesis bundle (SX sources + bundling + CID verification) | Step 1 |
|
||||
| **5** | Registry mechanism + bootstrap-projection dispatch | Steps 2, 4 |
|
||||
| **6** | Validation pipeline driver + `POST /activity` | Steps 2, 3, 5 |
|
||||
| **7** | Projection scheduler (gen_server per projection) | Steps 5, 6 |
|
||||
| **8** | HTTP server, AP endpoints, projection queries | Steps 6, 7 |
|
||||
| **9** | Smoke tests (Pin verb + reactive application) | Steps 1-8 |
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Repo skeleton + canonical CID
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
```
|
||||
next/
|
||||
├── README.md # what this is
|
||||
├── kernel/ # Erlang-on-SX
|
||||
│ └── (empty for now)
|
||||
├── genesis/ # core SX bootstrap definitions
|
||||
│ └── (empty for now)
|
||||
├── tests/ # smoke test scripts
|
||||
│ └── (empty for now)
|
||||
└── data/ # gitignored runtime state
|
||||
├── log/
|
||||
├── objects/
|
||||
├── snapshots/
|
||||
├── indexes/
|
||||
└── keys/
|
||||
```
|
||||
|
||||
Plus one Erlang-on-SX module:
|
||||
|
||||
```erlang
|
||||
% next/kernel/cid.erl
|
||||
-module(cid).
|
||||
-export([from_sx/1, to_string/1, from_string/1, equals/2]).
|
||||
|
||||
from_sx(SxValue) ->
|
||||
Cbor = cid:cbor_encode(canonicalize_sx(SxValue)),
|
||||
Hash = crypto:sha2_256(Cbor),
|
||||
cid:from_bytes(<<"raw">>, Hash). % defaults to dag-cbor codec
|
||||
|
||||
canonicalize_sx(V) -> ... % sorts dict keys, normalizes strings
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- Same SX value → same CID across multiple invocations.
|
||||
- Different SX values → different CIDs.
|
||||
- Whitespace/comment differences in source → identical CIDs (parsed AST identical).
|
||||
- Reordered dict keys → identical CIDs (sorted-key canonicalization).
|
||||
- Cross-host parity (just OCaml host for v1, but write the test so adding hosts is mechanical).
|
||||
|
||||
**Acceptance:** `bash next/tests/cid.sh` passes 10+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Activity envelope + signature verify
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
```erlang
|
||||
% next/kernel/envelope.erl
|
||||
-module(envelope).
|
||||
-export([validate_shape/1, canonical_bytes/1, verify_signature/2]).
|
||||
|
||||
% Envelope shape per design §3.1:
|
||||
% #{id, type, actor, published, to, cc, audience_extras,
|
||||
% object | target | origin | result,
|
||||
% capabilities_required, proofs, signature}
|
||||
validate_shape(Activity) -> ok | {error, Reason}.
|
||||
|
||||
canonical_bytes(Activity) ->
|
||||
% Strip signature, canonicalize via dag-cbor, return bytes for sig coverage
|
||||
Stripped = maps:remove(signature, Activity),
|
||||
cid:cbor_encode(canonicalize_for_sig(Stripped)).
|
||||
|
||||
verify_signature(Activity, ActorState) ->
|
||||
% Time-aware: find key with id == sig.key_id that was active at published
|
||||
% Per design §9.6
|
||||
...
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- Envelope shape: required fields present (id, type, actor, published, signature)
|
||||
- Envelope shape: type is a known activity-type or unknown-but-string
|
||||
- Envelope shape: signature has key_id, algorithm, value
|
||||
- Sig verify: valid RSA-SHA256 signature against published key → ok
|
||||
- Sig verify: valid Ed25519 signature → ok
|
||||
- Sig verify: tampered envelope → fail
|
||||
- Sig verify: key superseded before activity timestamp → fail
|
||||
- Sig verify: key superseded after activity timestamp → ok (historical valid)
|
||||
|
||||
**Acceptance:** `bash next/tests/envelope.sh` passes 15+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — JSONL log + sequence numbers
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
```erlang
|
||||
% next/kernel/log.erl
|
||||
-module(log).
|
||||
-export([open/1, append/2, read_segment/2, tip/1, replay/3]).
|
||||
|
||||
% Per design §15.2: per-actor outbox, segments cap ~64MB,
|
||||
% format = JSONL (one canonical JSON-LD activity per line)
|
||||
|
||||
open(ActorId) ->
|
||||
BasePath = log_path_for_actor(ActorId),
|
||||
fs:mkdir_p(BasePath),
|
||||
{ok, #{base => BasePath, current => current_segment(BasePath), seq => next_seq(BasePath)}}.
|
||||
|
||||
append(LogState, Activity) ->
|
||||
Json = jsonld:encode(Activity),
|
||||
Path = current_segment_path(LogState),
|
||||
Line = <<Json/binary, "\n">>,
|
||||
fs:append_file(Path, Line),
|
||||
NewSeq = LogState#{seq := LogState.seq + 1},
|
||||
rotate_if_needed(NewSeq).
|
||||
|
||||
% replay/3 calls Fun(Activity, Acc) for every activity in chronological order
|
||||
replay(LogState, InitAcc, Fun) -> ...
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- Append + read back gives identical activity (round-trip).
|
||||
- Sequence numbers monotonic and gap-free per actor.
|
||||
- Segment rotation at size threshold.
|
||||
- Replay visits all activities in append order across multiple segments.
|
||||
- Restart preserves tip pointer (seq number resumes correctly).
|
||||
- Concurrent appends (using gen_server-mediated access) are serialized correctly.
|
||||
|
||||
**Acceptance:** `bash next/tests/log.sh` passes 10+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Genesis bundle
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
Genesis bundle SX sources (per design §12.2). Each is a small SX file authored
|
||||
by hand for the bootstrap set:
|
||||
|
||||
```
|
||||
next/genesis/
|
||||
├── manifest.sx # bundle root: lists all definitions
|
||||
├── activity-types/
|
||||
│ ├── create.sx # DefineActivity{name: "Create", ...}
|
||||
│ ├── update.sx
|
||||
│ └── delete.sx
|
||||
├── object-types/
|
||||
│ ├── sx-artifact.sx
|
||||
│ ├── note.sx
|
||||
│ ├── tombstone.sx
|
||||
│ ├── define-activity.sx # DefineObject for the Define* meta types
|
||||
│ ├── define-object.sx
|
||||
│ ├── define-projection.sx
|
||||
│ ├── define-validator.sx
|
||||
│ ├── define-codec.sx
|
||||
│ ├── define-sig-suite.sx
|
||||
│ └── snapshot.sx
|
||||
├── projections/
|
||||
│ ├── activity-log.sx # identity projection
|
||||
│ ├── by-type.sx
|
||||
│ ├── by-actor.sx
|
||||
│ ├── by-object.sx
|
||||
│ ├── actor-state.sx
|
||||
│ ├── define-registry.sx # the chicken-and-egg projection
|
||||
│ └── audience-graph.sx
|
||||
├── validators/
|
||||
│ ├── envelope-shape.sx
|
||||
│ ├── signature.sx
|
||||
│ └── type-schema.sx
|
||||
├── codecs/
|
||||
│ ├── dag-cbor.sx # delegates to cid:cbor_encode/decode BIFs
|
||||
│ ├── raw.sx
|
||||
│ └── dag-json.sx
|
||||
├── sig-suites/
|
||||
│ ├── rsa-sha256-2018.sx
|
||||
│ └── ed25519-2020.sx
|
||||
└── audience/
|
||||
├── public.sx
|
||||
├── followers.sx
|
||||
└── direct.sx
|
||||
```
|
||||
|
||||
Plus a build-time bundler:
|
||||
|
||||
```erlang
|
||||
% next/kernel/bootstrap.erl
|
||||
-module(bootstrap).
|
||||
-export([build_genesis/1, verify_genesis/1, load_genesis/1]).
|
||||
|
||||
build_genesis(SourceDir) ->
|
||||
% Walk SourceDir, parse each .sx file, build a single dag-cbor bundle,
|
||||
% compute its CID, write bundle.cbor + CID to data/genesis/
|
||||
...
|
||||
|
||||
verify_genesis(BundlePath) ->
|
||||
% Compute CID of the bundle as loaded; compare to expected (hardcoded
|
||||
% in the kernel binary). Mismatch → halt.
|
||||
...
|
||||
|
||||
load_genesis(BundlePath) ->
|
||||
% Parse the bundle, register all definitions in the in-memory registry
|
||||
...
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- All genesis SX files parse cleanly.
|
||||
- Bundle CID is deterministic (rebuild same sources → same CID).
|
||||
- Bundle reload reproduces the exact same registry state.
|
||||
- Tampered bundle → `verify_genesis` returns `{error, cid_mismatch}`.
|
||||
|
||||
**Acceptance:** `bash next/tests/bootstrap.sh` passes; `next/data/genesis/bundle.cbor`
|
||||
created with a known stable CID.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Registry mechanism + bootstrap dispatch
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
Registries are gen_servers, one per kind, each holding the active version map:
|
||||
|
||||
```erlang
|
||||
% next/kernel/registry.erl
|
||||
-module(registry).
|
||||
-behaviour(gen_server).
|
||||
-export([start_link/0, lookup/2, register/3, list/1]).
|
||||
% Internal state:
|
||||
% #{activity_types => #{Name => #{cid, schema_fn, semantics_fn, supersedes}},
|
||||
% object_types => ...,
|
||||
% projections => ...,
|
||||
% validators => ...,
|
||||
% codecs => ...,
|
||||
% sig_suites => ...,
|
||||
% ...}
|
||||
|
||||
lookup(Kind, Name) -> {ok, Entry} | {error, not_found}.
|
||||
register(Kind, Name, Entry) -> ok | {error, Reason}.
|
||||
list(Kind) -> [#{name, cid}].
|
||||
```
|
||||
|
||||
The `define-registry` projection's fold updates this gen_server's state when
|
||||
new `Define*` activities arrive. (Bootstrapping circle resolved: at startup,
|
||||
`bootstrap:load_genesis/1` populates the registry directly; from then on, the
|
||||
projection fold maintains it.)
|
||||
|
||||
**Tests:**
|
||||
- After genesis load, `registry:list(activity_types)` returns Create/Update/Delete.
|
||||
- `registry:lookup(activity_types, "Create")` returns the schema and semantics.
|
||||
- A new `DefineActivity{name: "Pin"}` activity (synthesised, hand-signed for the
|
||||
test) routes through the projection fold, ends up in the registry.
|
||||
- Lookup never caches across activities (verified by introducing a new definition
|
||||
mid-test and confirming the next lookup sees it).
|
||||
|
||||
**Acceptance:** `bash next/tests/registry.sh` passes 10+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Validation pipeline + POST /activity
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
```erlang
|
||||
% next/kernel/pipeline.erl
|
||||
-module(pipeline).
|
||||
-export([validate_inbound/1, validate_outbound/1]).
|
||||
|
||||
% Per design §14, run stages in order, halt on first failure.
|
||||
validate_inbound(Activity) ->
|
||||
Stages = [
|
||||
fun stage_envelope/1,
|
||||
fun stage_signature/1,
|
||||
fun stage_replay/1,
|
||||
fun stage_audience/1,
|
||||
fun stage_activity_schema/1,
|
||||
fun stage_object_schema/1,
|
||||
fun stage_content_validators/1,
|
||||
fun stage_capabilities/1,
|
||||
fun stage_trust/1
|
||||
],
|
||||
run_stages(Activity, Stages).
|
||||
|
||||
validate_outbound(Activity) ->
|
||||
% Subset of inbound stages (no replay, no trust check; auth done at HTTP layer)
|
||||
...
|
||||
```
|
||||
|
||||
```erlang
|
||||
% next/kernel/outbox.erl
|
||||
-module(outbox).
|
||||
-export([publish/2]).
|
||||
|
||||
publish(ActorId, ActivityRequest) ->
|
||||
Activity = construct_envelope(ActorId, ActivityRequest),
|
||||
Signed = sig:sign(Activity, ActorId),
|
||||
case pipeline:validate_outbound(Signed) of
|
||||
ok ->
|
||||
log:append(actor_log(ActorId), Signed),
|
||||
projection:async_fold(Signed),
|
||||
{ok, #{cid => cid:from_sx(Signed),
|
||||
ap_id => maps:get(id, Signed)}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- Valid activity through full pipeline → appended to log.
|
||||
- Bad envelope → 400, not in log.
|
||||
- Bad signature → 401, not in log.
|
||||
- Replayed activity → 200 duplicate, not re-appended.
|
||||
- Schema violation (e.g. Create with no object) → 422.
|
||||
- Activity logged before projection completes (async).
|
||||
|
||||
**Acceptance:** `bash next/tests/pipeline.sh` passes 15+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Projection scheduler
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
```erlang
|
||||
% next/kernel/projection.erl
|
||||
-module(projection).
|
||||
-export([start_link/1, async_fold/1, query/2, snapshot/1]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
% One gen_server per active projection. State:
|
||||
% #{cid, name, fold_fn, current_state, log_tip,
|
||||
% snapshot_dir, last_snapshot_at}
|
||||
|
||||
% async_fold/1 broadcasts a new activity to every projection gen_server;
|
||||
% each folds it into its own state. Failures (gas, sandbox violation)
|
||||
% tag the activity but don't affect log durability.
|
||||
|
||||
% query/2 returns current state (or state-as-of)
|
||||
% snapshot/1 forces a snapshot now (also runs periodically)
|
||||
```
|
||||
|
||||
```erlang
|
||||
% next/kernel/sandbox.erl
|
||||
-module(sandbox).
|
||||
-export([eval_pure/2, eval_crypto/2, eval_effectful/3]).
|
||||
|
||||
% eval_pure runs an SX function in pure mode: no IO platform, gas budget,
|
||||
% deterministic. Used by projection folds, validators, audience predicates.
|
||||
% Wrapper over the SX runtime evaluator with a stripped platform.
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
- New activity → all projections fold it concurrently.
|
||||
- Projection fold completes within gas budget.
|
||||
- Gas-exhausting fold → activity tagged, projection state unchanged, no kernel crash.
|
||||
- Sandbox violation (fold tries IO) → same handling.
|
||||
- Snapshot create + reload → state matches.
|
||||
- Snapshot CID stable across kernel restarts.
|
||||
|
||||
**Acceptance:** `bash next/tests/projection.sh` passes 15+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — HTTP server + endpoints
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
Core endpoints (per design §16.1):
|
||||
|
||||
```
|
||||
GET /actors/<id> # actor doc
|
||||
GET /actors/<id>/outbox # OrderedCollection
|
||||
GET /actors/<id>/outbox?page=true # OrderedCollectionPage
|
||||
POST /activity # publish (auth: bearer token)
|
||||
GET /artifacts/<cid> # CID-addressed artifact
|
||||
GET /artifacts/<cid>/raw
|
||||
GET /projections # list of projections
|
||||
GET /projections/<name> # full state
|
||||
GET /projections/<name>?at=<ts> # time-travel
|
||||
GET /projections/<name>/<key> # indexed lookup
|
||||
GET /define-registry
|
||||
GET /.well-known/sx-capabilities
|
||||
GET /.well-known/webfinger
|
||||
```
|
||||
|
||||
```erlang
|
||||
% next/kernel/http_server.erl
|
||||
-module(http_server).
|
||||
-export([start/1, route/1]).
|
||||
|
||||
start(Port) ->
|
||||
http:listen(Port, fun ?MODULE:route/1).
|
||||
|
||||
route(Request) -> {Status, Headers, Body}.
|
||||
```
|
||||
|
||||
Content negotiation per `Accept`:
|
||||
- `application/activity+json` (default)
|
||||
- `application/cbor` (dag-cbor)
|
||||
- `application/json` (compact, no @context expansion)
|
||||
- `application/sx`
|
||||
|
||||
Auth on `POST /activity`: bearer token from env var `NEXT_PUBLISH_TOKEN`.
|
||||
|
||||
**Tests:**
|
||||
- Each endpoint returns expected shape for known artifact.
|
||||
- Content negotiation: same artifact in 4 representations.
|
||||
- 404 for unknown artifact CID.
|
||||
- 401 for `POST /activity` without token.
|
||||
- Pagination: outbox with > 50 activities returns OrderedCollectionPage.
|
||||
|
||||
**Acceptance:** `bash next/tests/http.sh` passes 20+ cases.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Smoke tests
|
||||
|
||||
**The proof points.** Two end-to-end smoke tests demonstrate, between them, that
|
||||
fed-sx is genuinely a substrate for distributed reactive applications expressed
|
||||
as data — not a system you extend by writing kernel code.
|
||||
|
||||
- **9a — Pin smoke test (`next/tests/smoke_pin.sh`)** — verb extensibility:
|
||||
defining a new activity type and projection at runtime via `Define*`
|
||||
artifacts. Verifies the meta-level (§5).
|
||||
- **9b — Reactive application smoke test (`next/tests/smoke_app.sh`)** —
|
||||
application extensibility: defining a new subscription type, subscribing,
|
||||
registering a trigger, and observing the full reactive loop fire end-to-end
|
||||
without kernel code changes. Verifies §§18-19.
|
||||
|
||||
Both must pass for milestone 1 acceptance.
|
||||
|
||||
### Step 9a — Pin smoke test
|
||||
|
||||
**Test script:** `next/tests/smoke_pin.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 0. Start a fresh fed-sx kernel (background)
|
||||
./next/scripts/start.sh fresh
|
||||
sleep 2
|
||||
TOKEN=$(cat next/data/keys/publish.token)
|
||||
|
||||
# 1. Verify actor exists
|
||||
curl -s http://localhost:9999/actors/next | jq -e '.type == "Person"'
|
||||
|
||||
# 2. Verify outbox has actor's first Create{Person}
|
||||
curl -s http://localhost:9999/actors/next/outbox?page=true \
|
||||
| jq -e '.orderedItems | length == 1 and .[0].type == "Create"'
|
||||
|
||||
# 3. Verify Pin is NOT a known activity type
|
||||
curl -s http://localhost:9999/define-registry?kind=activity_types \
|
||||
| jq -e '.[] | select(.name == "Pin") | length == 0' || exit 1
|
||||
|
||||
# 4. Publish DefineActivity{name: "Pin", schema: ..., semantics: ...}
|
||||
PIN_DEF=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineActivity",
|
||||
"name": "Pin",
|
||||
"schema": "(fn (act) (and (string? (-> act :object :path)) (cid? (-> act :object :cid))))",
|
||||
"semantics": "(fn (state act) (assoc-in state [:pins (-> act :object :path)] (-> act :object :cid)))"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST http://localhost:9999/activity \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/activity+json" \
|
||||
-d "$PIN_DEF" | jq -e '.cid' > /dev/null
|
||||
|
||||
# 5. Verify Pin IS now a known activity type
|
||||
curl -s http://localhost:9999/define-registry?kind=activity_types \
|
||||
| jq -e '.[] | select(.name == "Pin") | length == 1'
|
||||
|
||||
# 6. Also publish a DefineProjection{name: "pin-state"} that folds Pin into state
|
||||
PIN_PROJ=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineProjection",
|
||||
"name": "pin-state",
|
||||
"initial-state": "{}",
|
||||
"fold": "(fn (state act) (if (= (:type act) \"Pin\") (assoc state (-> act :object :path) (-> act :object :cid)) state))"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST http://localhost:9999/activity \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$PIN_PROJ" | jq -e '.cid'
|
||||
|
||||
# 7. Now publish a Pin activity
|
||||
PIN=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Pin",
|
||||
"object": {
|
||||
"type": "PinSpec",
|
||||
"path": "/docs/intro",
|
||||
"cid": "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST http://localhost:9999/activity \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$PIN" | jq -e '.cid'
|
||||
|
||||
# 8. Verify Pin appears in outbox
|
||||
curl -s http://localhost:9999/actors/next/outbox?page=true \
|
||||
| jq -e '.orderedItems | map(select(.type == "Pin")) | length == 1'
|
||||
|
||||
# 9. Verify pin-state projection has the entry
|
||||
sleep 1 # allow async projection
|
||||
curl -s http://localhost:9999/projections/pin-state \
|
||||
| jq -e '."/docs/intro" == "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"'
|
||||
|
||||
# 10. Negative test: publish a malformed Pin (missing path) → expect 422
|
||||
BAD_PIN='{"type": "Pin", "object": {"cid": "bafy..."}}'
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:9999/activity \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$BAD_PIN")
|
||||
[[ "$HTTP_STATUS" == "422" ]] || { echo "expected 422, got $HTTP_STATUS"; exit 1; }
|
||||
|
||||
# 11. Restart kernel; verify state recovers
|
||||
./next/scripts/stop.sh
|
||||
./next/scripts/start.sh
|
||||
sleep 2
|
||||
curl -s http://localhost:9999/projections/pin-state \
|
||||
| jq -e '."/docs/intro" == "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"'
|
||||
|
||||
echo "✓ Pin smoke test passed — verb extensibility demonstrated end-to-end"
|
||||
```
|
||||
|
||||
**Acceptance for 9a:** smoke test exits 0. The whole flow happens with **zero
|
||||
fed-sx kernel code changes** between defining the verb and using it.
|
||||
|
||||
### Step 9b — Reactive application smoke test
|
||||
|
||||
**The bigger proof point.** Demonstrates that fed-sx supports distributed
|
||||
reactive applications composed of `DefineSubscription` + `DefineTrigger` +
|
||||
`DefineProjection` — the application model from §§18-19.
|
||||
|
||||
The test runs on a single instance (federation is v2), so the "subscriber" and
|
||||
"publisher" are the same actor. That's intentional — milestone 1 proves the
|
||||
mechanism; milestone 2 spreads it across instances.
|
||||
|
||||
**Test script:** `next/tests/smoke_app.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Assumes 9a has already run (fresh kernel optional; can run alongside).
|
||||
TOKEN=$(cat next/data/keys/publish.token)
|
||||
BASE=http://localhost:9999
|
||||
|
||||
# 1. Verify "Topic" subscription type and "Subscribe" verb are NOT yet defined.
|
||||
curl -s "$BASE/define-registry?kind=subscription_types" \
|
||||
| jq -e 'map(select(.name == "Topic")) | length == 0'
|
||||
|
||||
# 2. Publish DefineSubscription{name: "Topic", ...}
|
||||
TOPIC_DEF=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineSubscription",
|
||||
"name": "Topic",
|
||||
"schema": "(fn (sub) (string? (-> sub :tag)))",
|
||||
"match": "(fn (sub act) (and (= (:type act) \"Note\") (member? (-> sub :tag) (or (-> act :object :tags) (list)))))",
|
||||
"delivery": "{:default :push :modes (list :push :pull)}"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$TOPIC_DEF" | jq -e '.cid'
|
||||
|
||||
# 3. Verify Topic IS now a known subscription type.
|
||||
curl -s "$BASE/define-registry?kind=subscription_types" \
|
||||
| jq -e 'map(select(.name == "Topic")) | length == 1'
|
||||
|
||||
# 4. Subscribe to the "smoketest" topic.
|
||||
SUBSCRIBE=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Subscribe",
|
||||
"object": {"type": "Topic", "tag": "smoketest"}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
SUB_CID=$(curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$SUBSCRIBE" | jq -r '.cid')
|
||||
|
||||
# 5. Verify subscriptions projection has the new entry.
|
||||
sleep 1
|
||||
curl -s "$BASE/projections/subscriptions" \
|
||||
| jq -e '.["https://next.rose-ash.com/actors/next"] | map(select(.type == "Topic")) | length == 1'
|
||||
|
||||
# 6. Define a projection that records matched activities (per-application
|
||||
# namespace would happen via DefineApplication in v1.x; for v1 the
|
||||
# projection is global to the actor).
|
||||
TOPIC_PROJ=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineProjection",
|
||||
"name": "topic-events",
|
||||
"initial-state": "{}",
|
||||
"fold": "(fn (state act) (if (and (= (:type act) \"Note\") (member? \"smoketest\" (or (-> act :object :tags) (list)))) (assoc-in state [(:cid act)] act) state))"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$TOPIC_PROJ" | jq -e '.cid'
|
||||
|
||||
# 7. Define a trigger: when a Topic{smoketest} subscription matches, publish
|
||||
# a TestEcho activity. We need an "Echo" activity type first.
|
||||
ECHO_DEF=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineActivity",
|
||||
"name": "TestEcho",
|
||||
"schema": "(fn (act) (cid? (-> act :object :echoes)))",
|
||||
"semantics": "(fn (state act) state)"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$ECHO_DEF" | jq -e '.cid'
|
||||
|
||||
TRIGGER=$(cat <<JSON
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "DefineTrigger",
|
||||
"name": "echo-on-smoketest",
|
||||
"when-subscription": "$SUB_CID",
|
||||
"cascade-limit": 1,
|
||||
"then": "(fn (act sub env) {:publish (list {:type \"TestEcho\" :object {:echoes (:cid act)}})})"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$TRIGGER" | jq -e '.cid'
|
||||
|
||||
# 8. Capture outbox length so we can detect new entries.
|
||||
BEFORE=$(curl -s "$BASE/actors/next/outbox?page=true" \
|
||||
| jq -r '.orderedItems | length')
|
||||
|
||||
# 9. Publish a Note tagged "smoketest" — should match subscription, fire trigger,
|
||||
# cause TestEcho to be published.
|
||||
NOTE=$(cat <<'JSON'
|
||||
{
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"content": "hello reactive world",
|
||||
"tags": ["smoketest"]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
NOTE_CID=$(curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$NOTE" | jq -r '.cid')
|
||||
|
||||
# 10. Wait for projection + trigger.
|
||||
sleep 2
|
||||
|
||||
# 11. Verify topic-events projection captured the Note.
|
||||
curl -s "$BASE/projections/topic-events" \
|
||||
| jq -e ". | to_entries | length == 1"
|
||||
|
||||
# 12. Verify outbox grew by exactly TWO activities (the Note + the trigger's TestEcho).
|
||||
AFTER=$(curl -s "$BASE/actors/next/outbox?page=true" \
|
||||
| jq -r '.orderedItems | length')
|
||||
[[ $((AFTER - BEFORE)) == 2 ]] || { echo "expected +2 activities, got $((AFTER - BEFORE))"; exit 1; }
|
||||
|
||||
# 13. Verify the latest activity is a TestEcho referencing the original Note's CID.
|
||||
curl -s "$BASE/actors/next/outbox?page=true" \
|
||||
| jq -e ".orderedItems[0] | .type == \"TestEcho\" and .object.echoes == \"$NOTE_CID\""
|
||||
|
||||
# 14. Negative case: publish a Note WITHOUT the "smoketest" tag — must NOT
|
||||
# trigger, must NOT echo.
|
||||
BEFORE2=$(curl -s "$BASE/actors/next/outbox?page=true" | jq -r '.orderedItems | length')
|
||||
NOTE_OTHER=$(cat <<'JSON'
|
||||
{"type": "Create", "object": {"type": "Note", "content": "no match", "tags": ["other"]}}
|
||||
JSON
|
||||
)
|
||||
curl -s -X POST "$BASE/activity" \
|
||||
-H "Authorization: Bearer $TOKEN" -d "$NOTE_OTHER" | jq -e '.cid'
|
||||
sleep 2
|
||||
AFTER2=$(curl -s "$BASE/actors/next/outbox?page=true" | jq -r '.orderedItems | length')
|
||||
[[ $((AFTER2 - BEFORE2)) == 1 ]] || { echo "expected +1 activity (no echo), got $((AFTER2 - BEFORE2))"; exit 1; }
|
||||
|
||||
# 15. Cascade limit check: prove the trigger doesn't recursively echo TestEcho.
|
||||
# The TestEcho activity itself should NOT match the Topic{smoketest}
|
||||
# subscription (it's not a Note), so no cascade, but verify cascade-depth
|
||||
# was set to 1 on the echo so a future trigger on TestEcho would refuse.
|
||||
LATEST_ECHO=$(curl -s "$BASE/actors/next/outbox?page=true" \
|
||||
| jq -r '.orderedItems | map(select(.type == "TestEcho")) | .[0]')
|
||||
echo "$LATEST_ECHO" | jq -e '."cascade-depth" == 1'
|
||||
|
||||
# 16. Restart kernel; verify subscription, trigger, projection all survive.
|
||||
./next/scripts/stop.sh
|
||||
./next/scripts/start.sh
|
||||
sleep 2
|
||||
curl -s "$BASE/projections/subscriptions" \
|
||||
| jq -e '.["https://next.rose-ash.com/actors/next"] | map(select(.type == "Topic")) | length == 1'
|
||||
curl -s "$BASE/projections/topic-events" | jq -e ". | to_entries | length >= 1"
|
||||
curl -s "$BASE/define-registry?kind=triggers" \
|
||||
| jq -e 'map(select(.name == "echo-on-smoketest")) | length == 1'
|
||||
|
||||
echo "✓ Reactive application smoke test passed — Subscribe + Trigger + Projection demonstrated end-to-end"
|
||||
```
|
||||
|
||||
**What this proves (and what it doesn't):**
|
||||
|
||||
Proves:
|
||||
- `DefineSubscription` + `Subscribe` mechanism works end-to-end.
|
||||
- Subscription's `match-fn` evaluates correctly in pure mode against inbound
|
||||
activities.
|
||||
- `DefineTrigger` fires on subscription matches.
|
||||
- Trigger's `then-sx` can publish derived activities (the `:publish` result).
|
||||
- Cascade-depth metadata propagates correctly.
|
||||
- Subscription state, trigger registration, and projection state all survive
|
||||
kernel restart (snapshot + log replay).
|
||||
- The full reactive application loop works without any kernel code changes
|
||||
between defining the components and exercising them.
|
||||
|
||||
Does NOT prove (deferred to milestone 2+):
|
||||
- Cross-instance subscriptions (federation).
|
||||
- Trigger `:effect` results calling effectful primitives.
|
||||
- `DefineApplication` bundle install/update/fork.
|
||||
- Per-application namespace isolation.
|
||||
- Cascade prevention against malicious cascading from peer instances.
|
||||
|
||||
**Acceptance for 9b:** smoke test exits 0. Like 9a, **zero fed-sx kernel code
|
||||
changes** between defining the application components and observing them
|
||||
operate.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria for milestone 1
|
||||
|
||||
All of:
|
||||
|
||||
1. **Each step's test suite passes** (`bash next/tests/<step>.sh`).
|
||||
2. **Both smoke tests pass** (`bash next/tests/smoke_pin.sh` and
|
||||
`bash next/tests/smoke_app.sh`).
|
||||
3. **Erlang-on-SX baseline preserved** — adding fed-sx kernel modules in
|
||||
`next/kernel/*.erl` doesn't break Phase 1-8 conformance.
|
||||
4. **Restart durability** — kill the kernel mid-write, restart, projections
|
||||
resume from snapshot, no log corruption.
|
||||
5. **Manual Mastodon poke** — point a Mastodon account at
|
||||
`https://next.rose-ash.com/actors/next` and verify the actor doc fetches and
|
||||
webfinger discovery works (read-only AP interop, no follow).
|
||||
|
||||
## What lands when
|
||||
|
||||
This is the work-order an agent (or human) follows. Steps 1-3 can be done in
|
||||
parallel after the Erlang Phase 8 BIFs land. Steps 4-7 are sequential. Step 8
|
||||
can start in parallel with step 7. Step 9 is the integration test.
|
||||
|
||||
```
|
||||
Phase 7+8 (loops/erlang) ───┐
|
||||
│
|
||||
▼
|
||||
┌─── Step 1 ──┬─── Step 2 ──┬─── Step 3
|
||||
│ │ │
|
||||
└─────────────┼─── Step 4 ──┴────┐
|
||||
│ │
|
||||
└─── Step 5 ───────┤
|
||||
│
|
||||
Step 6 ─────┤
|
||||
│
|
||||
Step 7 ─────┤
|
||||
│
|
||||
Step 8 ─────┤
|
||||
│
|
||||
Step 9 ─────┘
|
||||
```
|
||||
|
||||
Estimated effort if done by a focused agent loop, one feature per iteration:
|
||||
~30-50 commits across all 9 steps. Could plausibly be a `loops/fed-sx` workstream
|
||||
once Phase 7+8 are done.
|
||||
|
||||
## What's deferred to milestone 2
|
||||
|
||||
- **Federation** (the second-biggest piece). `POST /inbox`, Follow lifecycle,
|
||||
delivery queue, backfill, capability negotiation between peers. Whole of
|
||||
design §13.
|
||||
- **Multi-actor** with per-user OAuth and capability tokens. Design §9.5.
|
||||
- **IPFS storage backend** as a `DefineStorage` entry. Design §15.3.
|
||||
- **Browser client + operator dashboard** (probably in Elm-on-SX or similar).
|
||||
- **Rich verbs**: `Endorse`, `Supersede`, `Test`, `Build`, `Compose`, `Note`,
|
||||
`Announce`. All defined as `DefineActivity` artifacts, federated.
|
||||
- **Cross-host conformance** — Python/JS/Haskell hosts running fed-sx. Design
|
||||
§11.8.
|
||||
- **OpenTimestamps proofs** as a `DefineProof` entry.
|
||||
- **Performance work** — JIT-compiled folds, snapshot acceleration, federation
|
||||
batching.
|
||||
|
||||
Milestone 2 unlocks "real federation between two fed-sx instances." Milestone 3
|
||||
is the rose-ash port (blog, market, events, federation, account, orders) as
|
||||
fed-sx applications.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: open questions for milestone 1
|
||||
|
||||
A few things still under-specified; resolve as work begins.
|
||||
|
||||
1. **HTTP server library.** Does the Phase 8 `http:listen/2` BIF wrap an
|
||||
existing OCaml HTTP server (the sx.rose-ash.com one) or something simpler?
|
||||
Implementation choice deferred to Phase 8.
|
||||
2. **JSON-LD library.** AP wire format requires JSON-LD canonicalization for
|
||||
signature coverage. Either pull a library or write a minimal subset for the
|
||||
shapes we actually use. Probably the latter — our envelope is well-defined.
|
||||
3. **Bearer token rotation.** v1 uses a single env-var token. Token rotation
|
||||
without restart needs registry-style mgmt; can wait.
|
||||
4. **Snapshot rate limits.** Default in design is "every 1000 activities or
|
||||
60 seconds." Tunable per-projection later; v1 uses the default.
|
||||
5. **Genesis bundle format.** Dag-cbor map per §12.2; concrete schema needs
|
||||
one round of refinement once we author the actual definitions in step 4.
|
||||
176
plans/feed-on-sx.md
Normal file
176
plans/feed-on-sx.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# feed-on-sx: Activity Feeds on APL
|
||||
|
||||
Timelines, notifications, activity aggregation. The math is array math: filter, sort,
|
||||
reduce, scan, outer product. APL is the densest possible expression of feed
|
||||
composition — a fanout-and-rank pipeline reads as a single line.
|
||||
|
||||
rose-ash needs: per-user home timeline, notification feed, activity stream digestion,
|
||||
backfill for new follows, deduplication across cross-posts. Every operation is an
|
||||
array-shaped transformation.
|
||||
|
||||
End-state: an APL-flavored layer on `lib/apl/` with feed-specific combinators
|
||||
(`fanout`, `dedupe`, `score`, `rank`), an SX adapter for callers who don't want raw
|
||||
APL, ACL visibility filtering via `lib/acl/`, federation via fed-sx.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/feed/conformance.sh` → **189/189** (Phases 1–4 + TF-IDF, notifications, home, smart-dedupe, trending, mute, pagination, threading)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/feed/**` and `plans/feed-on-sx.md`. Do **not** edit
|
||||
`spec/`, `hosts/`, `shared/`, `lib/apl/**`, or other `lib/<lang>/`. You may
|
||||
**import** from `lib/apl/` (public API in `lib/apl/apl.sx`); do **not** modify APL.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** an activity is a small dict (`{:actor :verb :object :at :tags}`); a
|
||||
stream is an APL vector of such dicts. Operations are APL primitives lifted onto
|
||||
this shape. SX adapter exposes ergonomic API to non-APL callers.
|
||||
- **Unicode:** raw UTF-8 in `.sx` files. APL glyphs land directly.
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
Raw activities (any shape) Per-user view
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/normalize.sx lib/feed/timeline.sx
|
||||
— {:actor :verb :object — (timeline user)
|
||||
:at :tags} record — applies filter ∘ rank ∘ take
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/stream.sx lib/feed/rank.sx
|
||||
— APL vector of activities — velocity, recency
|
||||
— filter, sort, take — TF-IDF-ish over :tags
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/feed/fanout.sx lib/feed/dedupe.sx
|
||||
— followers vector — group by :object
|
||||
— activities ∘.× followers — collapse cross-posts
|
||||
— flatten + dedupe
|
||||
│
|
||||
▼
|
||||
lib/feed/api.sx lib/feed/fed.sx
|
||||
— (feed/post activity) — inbox via fed-sx
|
||||
— (feed/timeline user) — backfill on subscribe
|
||||
— (feed/notify user)
|
||||
```
|
||||
|
||||
## Phase 1 — Stream model + basic ops
|
||||
|
||||
- [x] `lib/feed/normalize.sx` — activity record schema; coerce arbitrary inputs
|
||||
- [x] `lib/feed/stream.sx` — APL vector representation; filter by predicate; sort by
|
||||
`:at`; take N (`↑`); reverse (`⌽`)
|
||||
- [x] `lib/feed/api.sx` — `(feed/post activity)`, `(feed/all)`
|
||||
- [x] `lib/feed/tests/basic.sx` — 30 cases: normalize defaults, filter, sort, take, api
|
||||
- [x] `lib/feed/scoreboard.{json,md}`
|
||||
- [x] `lib/feed/conformance.sh`
|
||||
|
||||
## Phase 2 — Fanout via outer product
|
||||
|
||||
- [x] follower graph: `followers user → vector of user ids` (`feed/follow-graph`,
|
||||
`feed/followers`; graph = `{followee -> (followers)}` dict)
|
||||
- [x] fanout: activities `∘.×` audience → matrix via `apl-outer feed/-mk-event`
|
||||
- [x] flatten to inbox events vector (`feed/-flatten` rank-2 → rank-1)
|
||||
- [x] dedupe — `feed/dedupe-inbox` by `(to, actor, verb, object)`; also
|
||||
`feed/dedupe-activities` `(actor verb object)` and `feed/dedupe-collapse`
|
||||
`(verb object)` for cross-actor likes
|
||||
- [x] `lib/feed/tests/fanout.sx` — 29 cases: small graph, mutual follow, star
|
||||
(high-fanout), empty graph, unfollowed actor, cross-post dedupe
|
||||
|
||||
## Phase 3 — Aggregation + ranking
|
||||
|
||||
- [x] group-by — `feed/group-by`/`feed/group-count` key-reduce; `feed/by-actor-day`
|
||||
buckets `(actor, day)` via `feed/day` (string-joined keys)
|
||||
- [x] velocity score — `feed/velocity` counts actor's activities in `(at-window, at]`
|
||||
- [x] recency score — `feed/recency` half-life decay `0.5^(age/hl)`
|
||||
- [x] composite rank — `feed/composite` weighted sum of `(weight scorer)` parts
|
||||
- [x] top-N per timeline — `feed/top` = rank then take
|
||||
- [x] `lib/feed/tests/rank.sx` — 24 cases: decay shape, velocity burst, stable
|
||||
tie-break, top-N, composite
|
||||
|
||||
## Phase 4 — Visibility filter + federation
|
||||
|
||||
`lib/acl/` and fed-sx don't exist yet and are out of scope (import `lib/apl/`
|
||||
only), so ACL/transport are injected: `permit?`, `remote?`, `send-fn`, `fetch-fn`
|
||||
are function parameters. Real acl-sx / fed-sx wire in at the call site unchanged.
|
||||
|
||||
- [x] ACL filter — `feed/visible stream viewer permit?`; default `feed/permit-acl?`
|
||||
reads `:visible-to` allowlist (+ author-sees-own); per-viewer, never cached
|
||||
- [x] fed-sx outbound — `feed/federate`/`feed/deliver` fan out then partition
|
||||
local vs remote inboxes; remote events handed to injected `send-fn`
|
||||
- [x] fed-sx inbound — `feed/inbound` normalizes + `feed/ingest` dedupes peer
|
||||
activities into the local stream
|
||||
- [x] backfill on subscribe — `feed/backfill local fetch-fn peer-id`
|
||||
- [x] `lib/feed/tests/integration.sx` — 22 cases incl. end-to-end
|
||||
`feed/timeline` (federated → ACL for viewer → recency rank → top-N)
|
||||
|
||||
## Progress log
|
||||
|
||||
- **Phase 1 done (30/30).** Stream = APL rank-1 array whose ravel holds activity
|
||||
dicts. `normalize.sx` (record schema + accessors), `stream.sx` (filter via `/`
|
||||
compress, sort via `⍋` grade-up [stable], take via `↑`, reverse via `⌽`,
|
||||
by-actor/verb/object/since predicates), `api.sx` (mutable log: post/all/reset!/size).
|
||||
Substrate: `apl-compress`, `apl-grade-up`, `apl-take`, `apl-reverse`, `make-array`.
|
||||
Grade-up returns 1-based indices (⎕IO=1), is stable on ties → deterministic sort.
|
||||
- **Phase 2 done (59/59 total).** `fanout.sx` (graph + `apl-outer` showcase),
|
||||
`dedupe.sx` (per-key dedupe, first-wins stable). Key APL gotcha: `scalar?` is
|
||||
true for ANY dict and `disclose` nils a non-array dict, so an apl-outer combiner
|
||||
MUST `enclose` its event dict — apl-outer discloses it back intact. `apl-unique`
|
||||
preserves first-occurrence order; dict `keys` order is NOT stable, so
|
||||
`feed/audience` sorts (else recipient ordering flakes). `apl-compress` needs a
|
||||
rank-1 array, so the (activity×follower) matrix is flattened to its ravel before
|
||||
the edge-guard filter.
|
||||
- **Phase 3 done (83/83 total).** `aggregate.sx` (group-by/count, day buckets) +
|
||||
`rank.sx` (recency/velocity/engagement scorers, composite, top-N). `sort` is
|
||||
single-arg ascending only — no comparator — so ranking uses a stable two-pass
|
||||
`apl-grade-down` (by :at desc, then by score desc) for deterministic tie-breaks.
|
||||
Dict keys must be strings, so composite group keys are string-joined ("actor#day").
|
||||
- **Phase 4 done (105/105 total).** `acl.sx` (per-viewer `feed/visible`,
|
||||
`feed/timeline` capstone) + `fed.sx` (merge/ingest/inbound/backfill/federate/
|
||||
deliver). ACL/transport are dependency-injected (permit?/remote?/send-fn/fetch-fn)
|
||||
since lib/acl + fed-sx don't exist. `feed/normalize` now MERGEs defaults over the
|
||||
raw dict (was projecting to 5 keys) so extra metadata (:visible-to, peer fields)
|
||||
survives — matches the "flexible bag" principle.
|
||||
|
||||
## Roadmap is complete (all 4 phases). Possible follow-ups:
|
||||
|
||||
- Wire real acl-sx once `lib/acl/` exists (swap injected `permit?`).
|
||||
- Wire real fed-sx transport (swap `send-fn`/`fetch-fn`).
|
||||
- [x] TF-IDF over `:tags` for content ranking — `content.sx`: `feed/tag-df`,
|
||||
`feed/tag-idf` (log N/df), `feed/tfidf-score`, `feed/by-relevance`; 15 tests.
|
||||
Composes as a scorer with rank.sx. (120/120 total.)
|
||||
- [x] Notification feed (verb-filtered, per-recipient) — `notify.sx`:
|
||||
`feed/notifications`, `feed/notify-verbs`, `feed/notify-digest` (collapses
|
||||
"X, Y liked Z" by (verb,object), sorted-deterministic); 8 tests. (128/128 total.)
|
||||
- [x] **Capstone** `feed/home` — the whole pipeline as one line: fanout ∘ inbox ∘
|
||||
dedupe ∘ ACL ∘ rank ∘ take (`home.sx`); 6 tests incl. per-viewer ACL + cross-post
|
||||
dedupe. (134/134 total.)
|
||||
- [x] Per-verb dedupe rules (briefing gotcha #3) — `feed/dedupe-smart` /
|
||||
`feed/smart-key`: reactions (like/follow/boost/...) collapse cross-actor on
|
||||
(verb,object); posts stay distinct per actor. `feed/collapse-verbs` is
|
||||
rebindable policy; 9 tests. (143/143 total.)
|
||||
- [x] Trending — `feed/trending` / `feed/trending-actors`: objects/actors ranked
|
||||
by activity count in a recency window, count-desc with key-asc tiebreak
|
||||
(`trending.sx`); 11 tests. (154/154 total.)
|
||||
- [x] Mute/block — `feed/mute-actors` / `feed/mute-tags` / `feed/mute-objects` /
|
||||
`feed/apply-prefs`: viewer-controlled per-request filtering (complements ACL's
|
||||
author-controlled visibility) (`mute.sx`); 9 tests. (163/163 total.)
|
||||
- [x] Pagination — `feed/page`/`feed/page-count` (offset) + `feed/before`/
|
||||
`feed/after`/`feed/page-before`/`feed/next-cursor` (cursor by :at, stable under
|
||||
inserts) (`page.sx`); 14 tests. (177/177 total.)
|
||||
- [x] Threading — `feed/replies`/`feed/reply-count`/`feed/thread`/
|
||||
`feed/thread-objects`/`feed/thread-size`: conversation closure over `:reply-to`
|
||||
(transitive fixpoint), chronological (`thread.sx`); 12 tests. (189/189 total.)
|
||||
|
||||
(none)
|
||||
|
||||
## Notes for next iteration
|
||||
|
||||
- sx-tree MCP tools take `file:` NOT `path:` (CLAUDE.md is stale). Wrong key →
|
||||
`Yojson Type_error("Expected string, got null")`. Looks like a broken binary, isn't.
|
||||
- sx_server binary lives in main repo: `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe`
|
||||
(worktree has no `_build`). conformance.sh already points there with relative fallback.
|
||||
- Phase 2 substrate verified available: `apl-outer` (∘.×), `apl-member` (∊),
|
||||
`apl-unique`, `apl-iota` (1-based).
|
||||
108
plans/flow-on-sx.md
Normal file
108
plans/flow-on-sx.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# flow-on-sx: Durable DAG Workflows on Scheme
|
||||
|
||||
rose-ash needs workflows that survive restarts: content pipelines (write → review →
|
||||
publish → federate), scheduled jobs (digest emails), multi-step user flows (signup,
|
||||
confirm, onboard). art-dag is the precedent — DAG-of-tasks with pause/resume at IO
|
||||
boundaries.
|
||||
|
||||
Scheme's `call/cc` + delimited continuations make pause/resume natural: a `suspend`
|
||||
captures the continuation, serializes it as part of the flow record, and `resume`
|
||||
re-enters at exactly that point. No state-machine bookkeeping by hand. R7RS-small is
|
||||
already at 2644/2644 (see kernel/architecture status).
|
||||
|
||||
End-state: a Scheme-on-SX layer over the existing scheme runtime, with combinators
|
||||
for sequence/parallel/branch/retry/timeout/suspend, persistent flow store, and a
|
||||
federation extension via fed-sx for remote-node execution.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/flow/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** only touch `lib/flow/**` and `plans/flow-on-sx.md`. Do **not** edit
|
||||
`spec/`, `hosts/`, `shared/`, `lib/scheme/**`, or other `lib/<lang>/`. You may
|
||||
**import** from `lib/scheme/` (public API via `lib/scheme/scheme.sx`); do **not**
|
||||
modify Scheme.
|
||||
- **Shared-file issues** go under "Blockers" with a minimal repro; do not fix here.
|
||||
- **SX files:** use `sx-tree` MCP tools only.
|
||||
- **Architecture:** flow combinators are Scheme macros + procedures. Runtime is a
|
||||
driver loop that walks the flow graph and invokes `call/cc` at `suspend` points.
|
||||
Persistence layer serializes the continuation + open file/socket placeholders are
|
||||
forbidden (continuations must be resumable across process restart).
|
||||
- **art-dag awareness:** read `plans/art-dag*` if it exists for design lineage; do not
|
||||
import code.
|
||||
- **Commits:** one feature per commit. Keep Progress log updated and tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
(defflow publish
|
||||
(sequence
|
||||
(write-content)
|
||||
(parallel
|
||||
(review)
|
||||
(spell-check))
|
||||
(cond approved?
|
||||
(sequence (publish) (federate))
|
||||
(notify-author))))
|
||||
│
|
||||
▼
|
||||
lib/flow/spec.sx lib/flow/runtime.sx lib/flow/store.sx
|
||||
— defflow — driver loop — append-only flow log
|
||||
— sequence/parallel — node dispatch — checkpoint serialize
|
||||
— cond/retry/timeout — call/cc at suspend — restart loader
|
||||
— suspend/resume │ │
|
||||
▼ ▼
|
||||
lib/flow/api.sx lib/flow/remote.sx
|
||||
— (flow/start name args) — fed-sx adapter
|
||||
— (flow/resume id value) — node-on-peer execution
|
||||
— (flow/cancel id) — failure handling
|
||||
```
|
||||
|
||||
## Phase 1 — Declarative DAG + sequential execution
|
||||
|
||||
- [ ] `lib/flow/spec.sx` — `defflow` macro, `sequence` combinator
|
||||
- [ ] node = Scheme thunk; output threads to next node (data flow)
|
||||
- [ ] `parallel` combinator (sequential semantics for now — TRUE parallelism in Phase 3)
|
||||
- [ ] runtime executes a flow synchronously, returns final value
|
||||
- [ ] `lib/flow/api.sx` — `(flow/start name args)` entry point
|
||||
- [ ] `lib/flow/tests/basic.sx` — 15+ cases: linear sequence, nested sequences,
|
||||
data flow between nodes, parallel-with-join
|
||||
- [ ] `lib/flow/scoreboard.{json,md}`
|
||||
- [ ] `lib/flow/conformance.sh`
|
||||
|
||||
## Phase 2 — Control flow + error handling
|
||||
|
||||
- [ ] `cond` combinator — predicate selects branch
|
||||
- [ ] `retry n [backoff]` — re-runs node up to n times on exception
|
||||
- [ ] `timeout ms` — bounds node execution
|
||||
- [ ] `try-catch` — exception handler with reified error
|
||||
- [ ] error model — exceptions vs explicit `(fail :reason ...)` results
|
||||
- [ ] `lib/flow/tests/control.sx` — 25+ cases: each combinator + composition
|
||||
|
||||
## Phase 3 — Suspend / resume (the showcase)
|
||||
|
||||
- [ ] `(suspend reason)` — `call/cc` captures continuation, returns flow-id to caller
|
||||
- [ ] `lib/flow/store.sx` — serialize flow state (continuation + open vars)
|
||||
- [ ] `(flow/resume id value)` — load continuation, inject value, re-enter
|
||||
- [ ] `(flow/cancel id)` — explicit termination
|
||||
- [ ] crash recovery — on restart, scan store for paused flows, mark resumable
|
||||
- [ ] `lib/flow/tests/suspend.sx` — pause-resume scenarios, cancellation, "restart"
|
||||
scenarios (simulated by re-loading store)
|
||||
|
||||
## Phase 4 — Distributed nodes via fed-sx
|
||||
|
||||
- [ ] `(remote-node addr fn args)` — execute node on a federation peer
|
||||
- [ ] failure semantics — retry on different peer, fall through to local
|
||||
- [ ] persistence across instances — flow state replicates via fed-sx
|
||||
- [ ] handoff — flow started here can resume on a peer if the local instance is down
|
||||
- [ ] `lib/flow/tests/distributed.sx` — federated flow scenarios (mock fed-sx in tests)
|
||||
|
||||
## Progress log
|
||||
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
|
||||
(loop fills this in)
|
||||
1372
plans/go-on-sx.md
1372
plans/go-on-sx.md
File diff suppressed because it is too large
Load Diff
100
plans/host-on-sx.md
Normal file
100
plans/host-on-sx.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# host-on-sx: The SX web host — off Quart, onto the kernel (Dream-bound)
|
||||
|
||||
> **DRAFT outline.** The integration boundary that turns the subsystem libraries
|
||||
> into running services, and the strangler path off Python/Quart. This is the
|
||||
> dependency hub — it imports every subsystem. Decision recorded below: native
|
||||
> server + SXTP **now**, `dream-on-sx` framework layer **next**, Python only at the
|
||||
> external-integration edges.
|
||||
|
||||
The subsystems (`feed`, `search`, `acl`, `mod`, `flow`, `commerce`, `identity`,
|
||||
`content`, `events`) are libraries. Something has to receive an HTTP request, route
|
||||
it, call the right subsystem, and serialize the response. Today that's Python/Quart
|
||||
— the one large non-SX component in the stack: separate runtime, deploy, and
|
||||
failure mode. The goal is to move the web/host/domain layer onto the SX substrate
|
||||
and retire Quart, **incrementally (strangler-fig), never big-bang.**
|
||||
|
||||
This is already underway: a native OCaml HTTP server is live in prod on
|
||||
`sx.rose-ash.com` (~3ms cached, ~323 req/s, ~2MB RSS), `defhandler`/`defpage`
|
||||
exist, and a partial **SXTP** protocol is specced. That is the unblocked near-term
|
||||
host — no `ocaml-on-sx` dependency.
|
||||
|
||||
## Two layers, two timelines
|
||||
|
||||
1. **Now (unblocked): native server + SXTP adapter + SX handlers.** Route rose-ash
|
||||
endpoints onto the SX host one at a time. Each migrated endpoint is an SX
|
||||
handler dispatching to a subsystem; Quart proxies the rest until cut over.
|
||||
2. **Next: `dream-on-sx` as the framework layer.** Dream gives Quart-grade
|
||||
ergonomics — typed routing, middleware stacks, sessions, CSRF. It is gated on
|
||||
`ocaml-on-sx` Phases 1–5 + minimal stdlib. **This plan is the concrete target
|
||||
user that un-parks `dream-on-sx`** (see `plans/dream-on-sx.md`): "the subsystems
|
||||
need an HTTP front door" is the real feature pulling Dream. Until then, do not
|
||||
block migration on Dream — the native server is sufficient.
|
||||
3. **Always: Python only at the edges.** External integrations — SumUp payments,
|
||||
Ghost CMS, ActivityPub crypto, IPFS/Kubo — ride Python libraries today. They
|
||||
stay as thin injected adapters (Python/FFI) behind subsystem interfaces until
|
||||
native replacements exist. "Drop Quart" ≠ "drop every line of Python."
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/host/conformance.sh` → **0/0** (not yet started)
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Scope:** `lib/host/**` and `plans/host-on-sx.md`. May **import** every subsystem
|
||||
+ the kernel's server/SXTP surface. Do **not** edit `spec/`, `hosts/`, `shared/`,
|
||||
or subsystem internals — wire to their public APIs only. Host-primitive / server
|
||||
changes belong in `hosts/` (out of scope) → Blockers.
|
||||
- **Architecture:** a route maps (method, path) → handler; a handler is an SX fn
|
||||
`request -> response` that calls subsystem APIs; middleware is composed handlers
|
||||
(auth via `identity`, permission via `acl`, mute via subsystem prefs). SXTP is the
|
||||
wire format between host and subsystem-as-service.
|
||||
- **Migration discipline:** each endpoint moved must be behavior-equivalent to its
|
||||
Quart original (golden-response test before flip). Keep a migration ledger.
|
||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
||||
|
||||
## Architecture sketch
|
||||
|
||||
```
|
||||
HTTP request HTTP response
|
||||
│ ▲
|
||||
▼ │
|
||||
native OCaml http server (prod) ──────► lib/host/router.sx
|
||||
(hosts/ — out of scope) — (method,path) → handler
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/host/middleware.sx lib/host/handler.sx
|
||||
— auth(identity) ∘ acl ∘ mute ∘ ... — request → subsystem call → response
|
||||
│ ▲
|
||||
▼ │
|
||||
lib/host/sxtp.sx subsystem APIs (feed/search/commerce/…)
|
||||
— wire format, host↔service — called via public interfaces
|
||||
│
|
||||
└── external edges: SumUp / Ghost / AP / IPFS → injected Python/FFI adapters
|
||||
```
|
||||
|
||||
## Phase 1 — Router + handler + one real endpoint
|
||||
- [ ] `router.sx` — route table, (method,path) match
|
||||
- [ ] `handler.sx` — request/response model, subsystem dispatch
|
||||
- [ ] migrate ONE read endpoint (e.g. a feed timeline) end-to-end, golden test
|
||||
- [ ] `conformance.sh` + scoreboard
|
||||
|
||||
## Phase 2 — Middleware + SXTP
|
||||
- [ ] `middleware.sx` — composable auth/acl/mute/error layers
|
||||
- [ ] `sxtp.sx` — host↔subsystem wire format (align with existing spec)
|
||||
- [ ] migrate a write endpoint (auth + permission + action)
|
||||
|
||||
## Phase 3 — Strangler migration ledger
|
||||
- [ ] enumerate Quart endpoints; track migrated vs proxied
|
||||
- [ ] golden-response harness vs the live Quart responses
|
||||
- [ ] cut over a whole domain (smallest: `likes` or `relations`) as proof
|
||||
|
||||
## Phase 4 — Dream framework layer (gated)
|
||||
- [ ] gate: `ocaml-on-sx` Phases 1–5 + minimal stdlib green
|
||||
- [ ] adopt `dream-on-sx` routing/middleware/session ergonomics over the same handlers
|
||||
- [ ] re-home external adapters as native where replacements land
|
||||
|
||||
## Progress log
|
||||
(loop fills this in)
|
||||
|
||||
## Blockers
|
||||
(loop fills this in)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user