Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43eb54962 | ||
|
|
2b9143d303 | ||
|
|
f21b5ff1aa | ||
|
|
fec39c2010 | ||
|
|
0e9def0ebc | ||
|
|
ef11427e24 | ||
|
|
f40bbaac13 | ||
|
|
ee62fec98a | ||
|
|
eaefed1e7d | ||
|
|
56496c7108 | ||
|
|
aa56736841 | ||
|
|
f503c85484 | ||
|
|
e0344ff5af | ||
|
|
e2924bbc50 | ||
|
|
73f26a5a32 | ||
|
|
1d75f14578 | ||
|
|
ee9e18a745 | ||
|
|
771a79dde2 | ||
|
|
fca3ec1a3a | ||
|
|
e2c3511cbd | ||
|
|
824a4dea15 | ||
|
|
4741d50898 | ||
|
|
f90d408466 | ||
|
|
9ccc9fd4f2 | ||
|
|
dac9686576 | ||
|
|
d1111f4c4f | ||
|
|
9308c7d043 | ||
|
|
9747957649 | ||
|
|
fe06efcf26 | ||
|
|
b3699908f7 | ||
|
|
6d84c2b57b | ||
|
|
03b04011b7 | ||
|
|
311c4bfaed | ||
|
|
8c6a01f2ad | ||
|
|
c3f0e3789c | ||
|
|
ef7299b673 | ||
|
|
a501009210 | ||
|
|
b5b964c234 | ||
|
|
2dabb85009 | ||
|
|
706d0c3792 | ||
|
|
5f2e629c32 | ||
|
|
d1883e9973 | ||
|
|
92275cbe7c | ||
|
|
2a888da955 | ||
|
|
5cf005eb2c | ||
|
|
b5dc76dd75 | ||
|
|
61042670d1 | ||
|
|
cb1be44f92 | ||
|
|
9bf7932573 | ||
|
|
db12c26b31 | ||
|
|
2b495e1f33 | ||
|
|
adb8cbff2b | ||
|
|
158e8f9f3d | ||
|
|
278033b90e | ||
|
|
be7d9eec75 | ||
|
|
5277973afb | ||
|
|
06a8373fbd | ||
|
|
06e30dc921 | ||
|
|
097bf02f1e | ||
|
|
fc3f9fd668 | ||
|
|
cef6436971 | ||
|
|
2daa80e159 | ||
|
|
4eff650755 | ||
|
|
e854cf3671 | ||
|
|
8f6b552e7a | ||
|
|
9afbdc879e | ||
|
|
42e71d89d5 | ||
|
|
677eb1b3a8 | ||
|
|
3eb59068c7 | ||
|
|
b5edc05b1f | ||
|
|
84b8e3d6d5 | ||
|
|
c3e16a57c2 | ||
|
|
f167683e8d | ||
|
|
308169ac51 | ||
|
|
4126c83eff | ||
|
|
ecff1ac25b | ||
|
|
e651d9e863 | ||
|
|
2c5eb95da3 | ||
|
|
ee174c68ce | ||
|
|
1afd942be3 | ||
|
|
c750e21c2d | ||
|
|
e085286126 | ||
|
|
525666f79b | ||
|
|
b8465e50d3 | ||
|
|
8c9b88b519 | ||
|
|
327cbe3c5e | ||
|
|
bd8dc756d9 | ||
|
|
fbd8bdb8ca | ||
|
|
e5ae6fe7af | ||
|
|
c11e8a16ba | ||
|
|
2e9905f42f | ||
|
|
f74dec6675 | ||
|
|
e28bf3fd55 | ||
|
|
b2447e4f99 | ||
|
|
8d263bb60e | ||
|
|
b0f57a009b | ||
|
|
74f3b7011f | ||
|
|
b3d5843e71 | ||
|
|
aef08ce3a6 | ||
|
|
f178684391 | ||
|
|
831f10458e | ||
|
|
0d28508180 | ||
|
|
dd80e29484 | ||
|
|
7262e72a0d | ||
|
|
8912dde49f | ||
|
|
90c5e3b337 | ||
|
|
1645f4a972 | ||
|
|
f89be06a33 | ||
|
|
efa7d5cff2 | ||
|
|
367e2eb50f | ||
|
|
9f2904ffb8 | ||
|
|
ea8538761b | ||
|
|
4f92840fe7 | ||
|
|
8ca2c5556c | ||
|
|
82a0c1b4f0 | ||
|
|
5b6796d1e6 | ||
|
|
0c0e1c73db | ||
|
|
c47a0785c9 | ||
|
|
d79a96ede4 | ||
|
|
82a500eedb | ||
|
|
b64b61c345 | ||
|
|
189d1d3c2b | ||
|
|
97e39f48fd | ||
|
|
3ee4ab8e88 | ||
|
|
29efabe4d8 | ||
|
|
5a876bf615 | ||
|
|
6db5d0873e | ||
|
|
1e6da32ad3 | ||
|
|
f0adcc61c5 | ||
|
|
e1c2632f03 | ||
|
|
aa18e28925 | ||
|
|
26f533e8f4 | ||
|
|
5cb2ecbb31 | ||
|
|
64071de685 | ||
|
|
d3f8975d3d | ||
|
|
424a20e64b | ||
|
|
9de9e53a5f | ||
|
|
57b16df286 | ||
|
|
72a98d8468 | ||
|
|
2b6e8c7826 | ||
|
|
447471825b | ||
|
|
86977e51f7 | ||
|
|
1f85d1c4d7 | ||
|
|
ea91b33946 | ||
|
|
a7ed853fcd | ||
|
|
90ec569d53 | ||
|
|
62dcc6c5b3 | ||
|
|
058c070a42 | ||
|
|
26623d6d2f | ||
|
|
ece28506e6 | ||
|
|
1b31bfc399 | ||
|
|
af7e544830 | ||
|
|
c4bc865573 | ||
|
|
46df4070a8 | ||
|
|
1e5761d9e0 | ||
|
|
4441381987 | ||
|
|
4fee97f089 | ||
|
|
eeb3a68042 | ||
|
|
f23adbbf49 | ||
|
|
454fc33b8e | ||
|
|
9457410365 | ||
|
|
4c83d54964 | ||
|
|
e42e1c1b31 | ||
|
|
c5bf090d62 | ||
|
|
203dc3658e | ||
|
|
140445dda8 | ||
|
|
aca5686f01 | ||
|
|
a397132223 | ||
|
|
1fd52b7c60 | ||
|
|
9a000e9a30 | ||
|
|
4ed5b26688 | ||
|
|
e1c54c8f28 | ||
|
|
c716b57730 | ||
|
|
06a5edc6b7 | ||
|
|
22ec118d72 | ||
|
|
6c4a489a10 | ||
|
|
43f0eb75f7 | ||
|
|
94ab279ac4 | ||
|
|
00475fa400 | ||
|
|
0bf36c7a8a | ||
|
|
3a56bf3389 | ||
|
|
5d3f296747 | ||
|
|
ff156f1c0a | ||
|
|
f256fe011b | ||
|
|
500b13ae74 | ||
|
|
9565097c65 | ||
|
|
fe4eb0ed7f | ||
|
|
07aae19dae | ||
|
|
abb84b49f2 | ||
|
|
6f9a1351a4 | ||
|
|
be862f0c15 | ||
|
|
f5cba2051b | ||
|
|
db4f574c57 | ||
|
|
d082cd2277 | ||
|
|
63964b067d | ||
|
|
e53764389a | ||
|
|
30c710e3e0 | ||
|
|
1dea8f5d67 | ||
|
|
ce382a8557 | ||
|
|
d366316a21 | ||
|
|
f295a3a4fb | ||
|
|
03576fd3e5 | ||
|
|
3d63749e14 | ||
|
|
0808c6a7fd | ||
|
|
58324f8233 | ||
|
|
52828fff7b | ||
|
|
8ec8a3d8c5 | ||
|
|
8cc92e2f88 | ||
|
|
a978a82f86 | ||
|
|
2f2a8b671f | ||
|
|
1560c35403 | ||
|
|
241b33ef2f | ||
|
|
7052644467 | ||
|
|
5b1f55c30c | ||
|
|
54ed2b3df0 | ||
|
|
d8b8e86100 | ||
|
|
4155823250 | ||
|
|
c3da080f85 | ||
|
|
0c0932427a | ||
|
|
43b6af61a5 | ||
|
|
d9f01595a0 | ||
|
|
aa8cb86ddb | ||
|
|
50cde3fd47 | ||
|
|
f1732c0ae5 | ||
|
|
322bdf69c6 | ||
|
|
4a1a700005 | ||
|
|
46c09f1d48 | ||
|
|
22eb918d1b | ||
|
|
4e8f36ca80 | ||
|
|
064c031c62 | ||
|
|
e35454c3d6 | ||
|
|
a99fdfe412 | ||
|
|
1b20a0fc5d | ||
|
|
032cb3e738 | ||
|
|
43987dcfb0 | ||
|
|
75a154a1a9 | ||
|
|
647917d53e | ||
|
|
9a2cb2eafe | ||
|
|
5b9671b267 | ||
|
|
91e80d37c2 | ||
|
|
a395210036 | ||
|
|
d042dc7c31 | ||
|
|
5af2a180c3 | ||
|
|
7bede2f6d1 | ||
|
|
340d5836e5 | ||
|
|
2305ae7651 | ||
|
|
3b156c0a28 | ||
|
|
754c7fe288 | ||
|
|
d28912cf64 | ||
|
|
cd7bcaa523 | ||
|
|
935c9d061e | ||
|
|
74b628dfd7 | ||
|
|
3d6a58e958 | ||
|
|
cdf48f4a80 | ||
|
|
80e66496cc | ||
|
|
029970a2c5 | ||
|
|
a5329a9ba3 | ||
|
|
5e0ae55050 | ||
|
|
3c2bdf3a7c | ||
|
|
9b91940d9a | ||
|
|
4fea8fdd36 | ||
|
|
149178eb64 | ||
|
|
a6ceacaa30 | ||
|
|
e383cdec7d | ||
|
|
6d6897445e | ||
|
|
dc43be5d8a | ||
|
|
e85de6e68b | ||
|
|
275b0f21e6 | ||
|
|
56020d4155 | ||
|
|
e929d130e4 | ||
|
|
98451c9b69 | ||
|
|
7d59e41869 | ||
|
|
1d50509535 | ||
|
|
2391fd7a92 | ||
|
|
2035fe8722 | ||
|
|
1847974b62 | ||
|
|
71a5c81240 | ||
|
|
8de082a36c | ||
|
|
d63caccc01 | ||
|
|
cddbef48d8 | ||
|
|
ec4ac5ee88 | ||
|
|
6b69270a34 | ||
|
|
4d071abc48 | ||
|
|
4a6adb9181 | ||
|
|
9187facb3d | ||
|
|
7d40393bcd | ||
|
|
d2de5766e8 | ||
|
|
dd398fbd2a | ||
|
|
55f8bc679d | ||
|
|
3b1f412b92 | ||
|
|
14d85dac59 | ||
|
|
c9aa340db3 | ||
|
|
662a51f647 | ||
|
|
089538ef42 | ||
|
|
33fc9c671d | ||
|
|
bc1852dfef | ||
|
|
85bdf965c2 | ||
|
|
c4334cce3b | ||
|
|
d392c088c3 | ||
|
|
497abb0054 | ||
|
|
e18aa03878 | ||
|
|
fd64595908 | ||
|
|
a6e949219c | ||
|
|
6e7c73bdc4 | ||
|
|
c97fad2483 | ||
|
|
b849f4d566 | ||
|
|
c8b9aad5f1 | ||
|
|
c90c634c60 | ||
|
|
732b1d33ad | ||
|
|
3725ba80fb | ||
|
|
7791bad845 | ||
|
|
b0cfd1e9bc | ||
|
|
404668516b | ||
|
|
4ac7b828e0 | ||
|
|
a062b56e1f | ||
|
|
64b417a9c0 | ||
|
|
4c4973b7e8 | ||
|
|
86a3a2f6b4 | ||
|
|
404db7e5be | ||
|
|
761121536a | ||
|
|
d8dce9db5e | ||
|
|
4ca0ec9173 | ||
|
|
aee9279c84 | ||
|
|
182f8034f7 | ||
|
|
d15ff86155 | ||
|
|
273242ba3f | ||
|
|
d69e9d43cf | ||
|
|
29f9ab3402 | ||
|
|
37be2cef1a | ||
|
|
cf81cbe034 | ||
|
|
5ff1d54efa | ||
|
|
4e61f586bf | ||
|
|
cb0b604ae3 | ||
|
|
bd25d0fffe | ||
|
|
576c8ac667 | ||
|
|
b15ade4d08 | ||
|
|
04fd00416c | ||
|
|
39b2af4237 | ||
|
|
872ba3525c | ||
|
|
2b4b833d1d | ||
|
|
635123fbf4 | ||
|
|
01312dd45d | ||
|
|
0764842f6b | ||
|
|
4b5013050f | ||
|
|
a3d109bd5e | ||
|
|
c90b5a046c | ||
|
|
6f35229f24 | ||
|
|
21edecaff9 | ||
|
|
3e35b3d006 | ||
|
|
bc31d1196f | ||
|
|
b3963f911d | ||
|
|
6150bd7667 | ||
|
|
8538fab9a7 | ||
|
|
d9b71c49fd | ||
|
|
2c3b513b17 | ||
|
|
b3b5053dab | ||
|
|
f63dfd12fc | ||
|
|
ef6400884a | ||
|
|
2bf81645ed | ||
|
|
4457fde138 | ||
|
|
18b1098c3f | ||
|
|
4334dd7394 | ||
|
|
dfb3c52b01 | ||
|
|
1a402e1d45 | ||
|
|
acb9b3379d | ||
|
|
afca403fca | ||
|
|
c8dfdd1a35 | ||
|
|
10be98cce7 | ||
|
|
d06fd7ec2e | ||
|
|
3cb6a74bb0 | ||
|
|
835ea4d019 | ||
|
|
ff766e8901 | ||
|
|
d3b2c0e464 | ||
|
|
b846c68a28 | ||
|
|
817c9f45ea | ||
|
|
ef04aa5e03 | ||
|
|
d89f2602c2 | ||
|
|
d86ed9f4a2 | ||
|
|
ab95d7897c | ||
|
|
b14507c894 | ||
|
|
af9c95a5bf | ||
|
|
9e514ebf15 | ||
|
|
bde05de4e3 | ||
|
|
89eb75b6a7 | ||
|
|
2865ea738d | ||
|
|
7cbb9e37a6 | ||
|
|
fac90e6f4b | ||
|
|
a8d3377072 | ||
|
|
7c1d1e96fa | ||
|
|
af63b0cd13 | ||
|
|
8ddc1e86f3 | ||
|
|
e7d21013b9 | ||
|
|
1b409b3121 | ||
|
|
880c9656ad | ||
|
|
f859d2c4e3 | ||
|
|
108b691015 | ||
|
|
6ddabcad33 | ||
|
|
02bbf62eb1 | ||
|
|
d83534cf38 | ||
|
|
e19c9d9bda | ||
|
|
3d5fe83708 | ||
|
|
038632d1dd | ||
|
|
25ff91f08d | ||
|
|
705d298f91 | ||
|
|
a306feda4d | ||
|
|
cc016ffb55 | ||
|
|
59bbe85875 | ||
|
|
22937b71f0 | ||
|
|
9574bf53d7 | ||
|
|
78e54554b8 | ||
|
|
4d1040ece0 | ||
|
|
cc85db847c | ||
|
|
b6c2ccd697 | ||
|
|
c077174264 | ||
|
|
e2aeb972bd | ||
|
|
d415101fa1 | ||
|
|
ced6d89b98 | ||
|
|
b1167cce67 | ||
|
|
6133461d8e | ||
|
|
d5cbc45a87 | ||
|
|
4406008b3c | ||
|
|
855727f8a4 | ||
|
|
d0c047f375 | ||
|
|
81a1f53c2e | ||
|
|
4ef5f71397 | ||
|
|
0582321e2c | ||
|
|
29ecae7613 | ||
|
|
5885eff5b6 | ||
|
|
b29e7e5b57 | ||
|
|
d4d7bc3c07 | ||
|
|
5e6c7e22fd | ||
|
|
a9d6a1c980 | ||
|
|
c31f25415a | ||
|
|
0a6a7216e3 | ||
|
|
19609ee06c | ||
|
|
f3d1904428 | ||
|
|
4297b7c0c2 | ||
|
|
52d148788c | ||
|
|
4f7513bcba | ||
|
|
936aa71b8d | ||
|
|
463bd8dc14 | ||
|
|
04db2dafa7 | ||
|
|
18f2eb96a2 | ||
|
|
e211075969 | ||
|
|
1665620690 | ||
|
|
d72c246b1f | ||
|
|
a791d559d5 | ||
|
|
3514d76667 | ||
|
|
0cbbf77284 | ||
|
|
4f424a5cfa | ||
|
|
0fa411bd81 | ||
|
|
2ff1d435d8 | ||
|
|
b150b5e38e | ||
|
|
708d621c0c | ||
|
|
feb603b04b | ||
|
|
634e81f365 | ||
|
|
e93ed166f1 | ||
|
|
fd4edc531b | ||
|
|
06236a4faa | ||
|
|
651bfb2d34 | ||
|
|
5cd88536ef | ||
|
|
12445955d5 | ||
|
|
70ea6b1323 | ||
|
|
ecdeb9214a | ||
|
|
307d8a5520 | ||
|
|
f6cd3b534c | ||
|
|
711f6ec4b0 | ||
|
|
d20dc51cca | ||
|
|
ad124eab0d | ||
|
|
1a19fb2231 | ||
|
|
1ef973a861 | ||
|
|
a62708fe33 | ||
|
|
e2392ded21 | ||
|
|
a7c9b8e0d5 | ||
|
|
b77f59ff9b | ||
|
|
b22536fb11 | ||
|
|
bcfd024b34 | ||
|
|
79206dfce7 | ||
|
|
b7042f16df | ||
|
|
491b06e9b0 | ||
|
|
697ed07081 | ||
|
|
e73ef5c8f8 | ||
|
|
681f7063b1 | ||
|
|
70600d8015 | ||
|
|
f9a1c3ea1e | ||
|
|
5e52943734 | ||
|
|
0a26286412 | ||
|
|
842b5af4ee | ||
|
|
8173c9cb77 | ||
|
|
a1fd64513f | ||
|
|
b4d1943585 | ||
|
|
e0c6f9cb22 | ||
|
|
1203aa3286 | ||
|
|
962f7110e3 | ||
|
|
7aba2b4191 | ||
|
|
8aebfb6811 | ||
|
|
f013c1d22f | ||
|
|
71d8c536b0 | ||
|
|
7dbbc81a3b | ||
|
|
b5443d55ea | ||
|
|
3ed3868939 | ||
|
|
2252370af3 | ||
|
|
106e7efc3c | ||
|
|
5874fc232a | ||
|
|
1852ddd896 | ||
|
|
5771ce6c49 | ||
|
|
2a8cec6b0a | ||
|
|
d7fba90c71 | ||
|
|
76999ded14 | ||
|
|
54e9d39113 | ||
|
|
cb6a126025 | ||
|
|
b8ff18282e | ||
|
|
43e8042d8c | ||
|
|
65ee2be41a | ||
|
|
dbe176d58b | ||
|
|
fa3d95dca9 | ||
|
|
40e4efcc18 | ||
|
|
d589ea0578 | ||
|
|
0f21a4900c | ||
|
|
f9cd0177e7 | ||
|
|
0ca51cf90a | ||
|
|
138ff69034 | ||
|
|
94a87a8733 | ||
|
|
9f0e417774 | ||
|
|
bec8004bd9 | ||
|
|
6a4f42a0bb | ||
|
|
d2805fe70d | ||
|
|
48de96b0a4 | ||
|
|
db8eb0b2a7 | ||
|
|
53f35afc31 | ||
|
|
e99af63f9a | ||
|
|
24addfb21e | ||
|
|
489913f171 | ||
|
|
0f010de431 | ||
|
|
5e53beb353 | ||
|
|
f447f585ca | ||
|
|
4d95bb8cbd | ||
|
|
9ddc21f048 | ||
|
|
d74d6ad3d7 | ||
|
|
807ba24a89 | ||
|
|
09a48ba191 | ||
|
|
1e18c4f8e0 | ||
|
|
d87cad84c6 | ||
|
|
5599c164f3 | ||
|
|
4ae1d6181d | ||
|
|
362c7539cc | ||
|
|
dd23d7fcec | ||
|
|
34ce8c8a63 | ||
|
|
dc474be660 | ||
|
|
9c662bda23 | ||
|
|
99dc8b0ef9 | ||
|
|
cbb8a5c95a | ||
|
|
d324da18bb | ||
|
|
de8c5d327f | ||
|
|
3f66a83869 | ||
|
|
37dfb25929 | ||
|
|
c80c323022 | ||
|
|
a338567230 | ||
|
|
322cc116b0 | ||
|
|
751b5c4150 | ||
|
|
7e73504819 | ||
|
|
d0510ac85b | ||
|
|
5d852b76b0 | ||
|
|
79911a9ecc | ||
|
|
ca7d7366e9 | ||
|
|
138b459ff8 | ||
|
|
6a79cb093a | ||
|
|
92566480bb | ||
|
|
a0b16b927b | ||
|
|
1569d3f141 | ||
|
|
36760710db | ||
|
|
8d42a959ec | ||
|
|
d959057527 | ||
|
|
bed213cfff | ||
|
|
13d5136b69 | ||
|
|
06093b00fc | ||
|
|
27d72b819f | ||
|
|
6bb6869810 | ||
|
|
f9f019b970 | ||
|
|
66ed2ec167 | ||
|
|
12ee9f4622 | ||
|
|
3856c4ad71 | ||
|
|
b998e6a998 | ||
|
|
875c3e9d02 | ||
|
|
d49b907e83 | ||
|
|
0a57e85f0e | ||
|
|
b53251f94f | ||
|
|
d5837af663 | ||
|
|
566aa80490 | ||
|
|
a34ddf9524 | ||
|
|
5fe22dab76 | ||
|
|
a144c33661 | ||
|
|
64f50de596 | ||
|
|
4aebe17a24 | ||
|
|
8964a92cb4 | ||
|
|
4d330d921e | ||
|
|
fc46c3ba61 | ||
|
|
0a830347dc | ||
|
|
661cfbad9f | ||
|
|
7174703f6f | ||
|
|
37b1362860 | ||
|
|
b81a9779f1 | ||
|
|
67421ada42 | ||
|
|
b61e762c98 | ||
|
|
8dd3147066 | ||
|
|
8cdbd2e3a0 | ||
|
|
75d9235352 | ||
|
|
799be9914b | ||
|
|
f3d5951ac3 | ||
|
|
dd7fb98e73 | ||
|
|
fa01658686 | ||
|
|
3f7211b9df | ||
|
|
31125a2307 | ||
|
|
44c89e39df | ||
|
|
0fd1559781 | ||
|
|
5582f61061 | ||
|
|
b4cc3a29aa | ||
|
|
ca64b9cf1b | ||
|
|
92d6742546 | ||
|
|
36bcc842cf | ||
|
|
8a81ea2c1d | ||
|
|
74ea14db56 | ||
|
|
e7ceb2c280 | ||
|
|
e6076f0343 | ||
|
|
a01773743a | ||
|
|
7ce721d483 | ||
|
|
97e00be867 | ||
|
|
fbbaf453b8 | ||
|
|
df66ec989c | ||
|
|
228e02dca5 | ||
|
|
15ea0f0c5e | ||
|
|
86fe6ed89d | ||
|
|
b6787d909b | ||
|
|
b827d897b9 | ||
|
|
ec45952b84 | ||
|
|
84b31e57a4 | ||
|
|
69c811a266 | ||
|
|
39589e7fc7 | ||
|
|
e878a7a731 | ||
|
|
842e9cf576 | ||
|
|
eec5168d2e | ||
|
|
f7a02bc6c1 | ||
|
|
1912216dd4 | ||
|
|
a52d57b21a | ||
|
|
cc8a321835 | ||
|
|
bc2ca39ab5 | ||
|
|
b1fe2fa4f1 | ||
|
|
17b309fb18 | ||
|
|
8f3c33b2d1 | ||
|
|
139b8a69bc | ||
|
|
8add243ccc | ||
|
|
26d1d878c2 | ||
|
|
c0a001c55c | ||
|
|
aaa9cf469f | ||
|
|
5927cffecd | ||
|
|
901cfcd587 | ||
|
|
98f9a9b765 | ||
|
|
161ff2f190 | ||
|
|
8014da65c3 | ||
|
|
1d0eed6051 | ||
|
|
f8bf73d8c4 | ||
|
|
40186e6ccb | ||
|
|
0c1400985c | ||
|
|
d3baf61985 | ||
|
|
ba6465702f | ||
|
|
c34c4b1f94 | ||
|
|
829751d156 | ||
|
|
c454d13ecd | ||
|
|
01581b8576 | ||
|
|
2ac74abf12 | ||
|
|
3288d9ef78 | ||
|
|
bd323664b7 | ||
|
|
d42caca278 | ||
|
|
6a045bc332 | ||
|
|
420034320c | ||
|
|
5c07d49edb | ||
|
|
6c10a216fe | ||
|
|
ccc51dfdf9 | ||
|
|
8b1db89557 | ||
|
|
db48befa71 | ||
|
|
dfe0926041 | ||
|
|
1d898489da | ||
|
|
35f56f0e5e | ||
|
|
4916638e7f | ||
|
|
fb8f467067 | ||
|
|
8a971daab8 | ||
|
|
19a66a49a7 | ||
|
|
46810c31b1 | ||
|
|
92c7647014 | ||
|
|
cf22dcf9a7 | ||
|
|
b040a35061 | ||
|
|
61620a351e | ||
|
|
3a69306f30 | ||
|
|
596e506792 | ||
|
|
deaa37eee3 | ||
|
|
acd544bae6 | ||
|
|
e14156228c | ||
|
|
d1729d8be5 | ||
|
|
ed4d67ca62 | ||
|
|
2260665984 | ||
|
|
c581893589 | ||
|
|
d6a0b2ce7d | ||
|
|
0dab60066c | ||
|
|
af749de610 | ||
|
|
ec153a9e87 | ||
|
|
49a0292464 | ||
|
|
19ceb45f29 | ||
|
|
007e63b337 | ||
|
|
cb4b118e05 | ||
|
|
d71d35bb89 | ||
|
|
48a894bc7d | ||
|
|
d6cfda2385 | ||
|
|
5260ab9e96 | ||
|
|
8fe7a5563c | ||
|
|
8cd022887e | ||
|
|
975ee46139 | ||
|
|
a0bad62f6a | ||
|
|
5f559a6e67 | ||
|
|
8469ac2223 | ||
|
|
0654ba9070 | ||
|
|
987d276ae0 | ||
|
|
1c0058c175 | ||
|
|
1c403a9629 | ||
|
|
71c274b0b8 | ||
|
|
c757e5a09d | ||
|
|
2534737296 | ||
|
|
842e265f29 | ||
|
|
3ffcb0a6be | ||
|
|
965c9faadc | ||
|
|
3c2852b7ba | ||
|
|
2875e45b3a | ||
|
|
3bd1279e2a | ||
|
|
8ee836f86b | ||
|
|
4d960188cd | ||
|
|
6e9e7043d9 | ||
|
|
2427f24010 | ||
|
|
ecd3d6c304 | ||
|
|
0cb4445ef0 | ||
|
|
78f3d67c17 | ||
|
|
d78c2060ba | ||
|
|
31eae440bb | ||
|
|
f678732b39 | ||
|
|
cfbbe30de2 | ||
|
|
9218569dfa | ||
|
|
b55588530c | ||
|
|
eac179fce8 | ||
|
|
02fcc7380f | ||
|
|
a585398319 | ||
|
|
2f7c0903ff | ||
|
|
b1f0d7bd77 | ||
|
|
3a6b639940 | ||
|
|
5b99ce0c7f | ||
|
|
2b0dd9e625 | ||
|
|
1fcc2ff4cd | ||
|
|
2d6d441e92 | ||
|
|
e33018eed3 | ||
|
|
92328b89c1 | ||
|
|
e84872e512 | ||
|
|
5f8a1cc65a | ||
|
|
5e00a7bbc3 | ||
|
|
b98ca9abc3 | ||
|
|
dc5787e8fd | ||
|
|
8f6ef233fd | ||
|
|
0e9ae792fb | ||
|
|
07f8283d76 | ||
|
|
e011854be2 | ||
|
|
2fc3f81068 | ||
|
|
80ad2593ff | ||
|
|
a7c4b3a18a | ||
|
|
8940efca0f | ||
|
|
41f921cc47 | ||
|
|
a266933ed9 | ||
|
|
f189fc855a | ||
|
|
63e73c6c9e | ||
|
|
0898087353 | ||
|
|
1d860e2ae2 | ||
|
|
a4080d4dcf | ||
|
|
d97107df35 | ||
|
|
e692e2f5eb | ||
|
|
3238b7f08d | ||
|
|
2ae539a073 | ||
|
|
f2799f46ec | ||
|
|
3f69ff0a9c | ||
|
|
2a649c7584 | ||
|
|
f72d766f0e | ||
|
|
aea56c0118 | ||
|
|
f582180212 | ||
|
|
35fa5175f0 | ||
|
|
b46aba526e | ||
|
|
84b1b0755f | ||
|
|
3bfd2cdb29 | ||
|
|
b37bb0534b | ||
|
|
de5061e497 | ||
|
|
9fcec5dc70 | ||
|
|
ed2948db08 | ||
|
|
3fdd7b598e | ||
|
|
f649d09a26 | ||
|
|
81147ab795 | ||
|
|
d4b9ced9e6 | ||
|
|
e17b9d01f0 | ||
|
|
67d20fabad | ||
|
|
348c22425e | ||
|
|
a835e27571 | ||
|
|
a776e55e2d | ||
|
|
ac49bb8de7 | ||
|
|
fdb6352e0d | ||
|
|
38d6fab96f | ||
|
|
f1fee54bed | ||
|
|
dab3612a4c | ||
|
|
0cce177341 | ||
|
|
a26266f8a2 | ||
|
|
cc7f2e8efa | ||
|
|
e5345ede4b | ||
|
|
a506f6bae1 | ||
|
|
7ea969fa84 | ||
|
|
b252e0506c | ||
|
|
f2c51a10fc | ||
|
|
3e86fb95b2 | ||
|
|
3e74d15df0 | ||
|
|
bea2196496 | ||
|
|
62fa904d5a | ||
|
|
63e560103f | ||
|
|
9991c8cd86 | ||
|
|
cfdcbd902b | ||
|
|
bebb1e6ca4 | ||
|
|
05a0ac2dda | ||
|
|
75c6064593 | ||
|
|
4cc75cb306 | ||
|
|
2501021afe | ||
|
|
ac6363b4fb | ||
|
|
e17149c513 | ||
|
|
d6fe594870 | ||
|
|
12d7503a46 | ||
|
|
21fa2c0054 | ||
|
|
adb35faa6c | ||
|
|
4db7f579d5 | ||
|
|
ecc775ff66 | ||
|
|
877a685ee1 | ||
|
|
f343f2376e | ||
|
|
557adb8896 | ||
|
|
606ca2b95b | ||
|
|
4cffb6a652 | ||
|
|
f8f2236609 | ||
|
|
7b1d511410 | ||
|
|
97f470004b | ||
|
|
f967d860ee | ||
|
|
589d23fe43 | ||
|
|
837cf512c1 | ||
|
|
13c386f6d1 | ||
|
|
8e48f7bb7b | ||
|
|
27da7acf14 | ||
|
|
ada87b6a21 | ||
|
|
0f527bb1ad | ||
|
|
06df9a903a | ||
|
|
1eba8d8238 | ||
|
|
4ee494c4f2 | ||
|
|
eba0735a93 | ||
|
|
c5dafbb6ef | ||
|
|
7c42517ad8 | ||
|
|
eb1f119286 | ||
|
|
984aecf02a | ||
|
|
93c3a09cd0 | ||
|
|
64f183e753 | ||
|
|
cabf209c10 | ||
|
|
3f30ce9daf | ||
|
|
0b4ea3e242 | ||
|
|
a098515a92 | ||
|
|
7137b64b0d | ||
|
|
dff41b255e | ||
|
|
63d3b45910 | ||
|
|
32ff501b2e | ||
|
|
fd191b8a16 | ||
|
|
740ecf0b52 | ||
|
|
f1aa14618e | ||
|
|
2177165faa | ||
|
|
e89db8bcad | ||
|
|
5083972c87 | ||
|
|
bd4758894c | ||
|
|
f0cb79ecfb | ||
|
|
18e528388d | ||
|
|
6147948512 | ||
|
|
0695b19b42 | ||
|
|
486f34f3ea | ||
|
|
a45242a752 | ||
|
|
6628a904c0 | ||
|
|
13f3a12453 | ||
|
|
9dd30da190 | ||
|
|
44eec9d654 | ||
|
|
e39c4b7f46 | ||
|
|
4435bcb6f4 | ||
|
|
f786e30f8f | ||
|
|
0b3bd6a1a7 | ||
|
|
a914e6d722 | ||
|
|
56cb74f649 | ||
|
|
8bf62bc33b | ||
|
|
d1f2a88419 | ||
|
|
8e7d98051e | ||
|
|
f7395e0c46 | ||
|
|
cc660b85f2 | ||
|
|
9f8cf2e30f | ||
|
|
44a35adb95 | ||
|
|
045e25bdd1 | ||
|
|
4bba382158 | ||
|
|
7a301af5ae | ||
|
|
e9b78f9e88 | ||
|
|
858fc16ef4 | ||
|
|
034ca9a5e4 | ||
|
|
a0ccf9a339 | ||
|
|
b3388e0c3b | ||
|
|
2f6da84b7f | ||
|
|
4057e11dc2 | ||
|
|
bb5948c048 | ||
|
|
032e0ff9d4 | ||
|
|
06ed2c5ac2 | ||
|
|
fc5cce0a21 | ||
|
|
101251f2be | ||
|
|
99a250879f | ||
|
|
1f2a1c9a26 | ||
|
|
2811944f93 | ||
|
|
179ad2c930 | ||
|
|
732584f1e3 | ||
|
|
bfc4d64fb3 | ||
|
|
b8d3fe69ee | ||
|
|
fb9c42c266 | ||
|
|
ebd8bc6d9f | ||
|
|
1028139d41 | ||
|
|
cd4f712b1f | ||
|
|
fe26d3d882 | ||
|
|
686dccff7d | ||
|
|
9e60c516ae | ||
|
|
79dcb16dc6 | ||
|
|
9169874bd3 | ||
|
|
25071a31f9 | ||
|
|
e2051c73fa | ||
|
|
a210b6444d | ||
|
|
f0a66a0e95 | ||
|
|
2e5ad82325 | ||
|
|
12f7e5f561 | ||
|
|
5cf4ff8512 | ||
|
|
a81f7c7adc | ||
|
|
48f4c1630c | ||
|
|
f05367b953 | ||
|
|
ba78082658 | ||
|
|
25d1d7cd66 | ||
|
|
25c430eed8 | ||
|
|
2c79500b65 | ||
|
|
2a4f4ec3f5 | ||
|
|
d77f94f49c | ||
|
|
72c3dced2d | ||
|
|
447f4027c3 | ||
|
|
a6bbea91ce | ||
|
|
801d4c537e | ||
|
|
929a841501 | ||
|
|
7522ea8405 | ||
|
|
df65ff3dc1 | ||
|
|
2686f9f297 | ||
|
|
8729acb5aa | ||
|
|
636828f509 | ||
|
|
bee96763e6 | ||
|
|
cebd4de372 | ||
|
|
c7463c0506 | ||
|
|
6fbe47ad88 | ||
|
|
d730d1fd70 | ||
|
|
8568b37f16 | ||
|
|
0f8d93de14 | ||
|
|
41460f056b | ||
|
|
d7c78cd1bd | ||
|
|
ffea9047b2 | ||
|
|
41ff563459 | ||
|
|
c5f229010f | ||
|
|
74f497703d | ||
|
|
855d6be8fc | ||
|
|
50e7112651 | ||
|
|
a10a0125fc | ||
|
|
6d2e6c41c8 | ||
|
|
33b9975b3e | ||
|
|
bb846cccf7 | ||
|
|
ea4d3b659a | ||
|
|
3a2b5d4671 | ||
|
|
ae4560c371 | ||
|
|
43237e4828 | ||
|
|
3cbaa8bb61 | ||
|
|
63c17af165 | ||
|
|
29bacfb50d | ||
|
|
51f2a1150c | ||
|
|
dff9b62065 | ||
|
|
6af0bf7980 | ||
|
|
03134fcdd5 | ||
|
|
d1b045a08a | ||
|
|
bc188d9a2b | ||
|
|
10e5e844a1 | ||
|
|
aa7d396d78 | ||
|
|
3f1c7a5cc4 | ||
|
|
3607771f33 | ||
|
|
122aa1fb3e | ||
|
|
a830fe6699 | ||
|
|
25f49af98e | ||
|
|
e0b12f9025 | ||
|
|
bc8f1c342c | ||
|
|
1d158184f5 | ||
|
|
370ba36ee6 | ||
|
|
360ca6e6f2 | ||
|
|
31300cd84f | ||
|
|
a4122e91d4 | ||
|
|
a49738eba2 | ||
|
|
c6c9953458 | ||
|
|
b58ab7f3c7 | ||
|
|
d844df8cac | ||
|
|
3291ae86e5 | ||
|
|
d8bdaa0ead | ||
|
|
70e6078507 | ||
|
|
fc92716ae6 | ||
|
|
a112572d54 | ||
|
|
61d8c65e84 | ||
|
|
a33b779c0d | ||
|
|
f6891153a5 | ||
|
|
6691a48ca2 | ||
|
|
d3ab038fe2 | ||
|
|
b854ba5db9 | ||
|
|
df18494a7c | ||
|
|
726f5c2b4f | ||
|
|
1931630ba0 | ||
|
|
1d2047b2e2 | ||
|
|
1ddbd294af | ||
|
|
502384f366 | ||
|
|
da8d09d81c | ||
|
|
e5ef5e22ea | ||
|
|
a7a00ab198 | ||
|
|
d20cf9b106 | ||
|
|
b2c87b6de1 | ||
|
|
d00deb8cd4 | ||
|
|
887bcdb827 | ||
|
|
9c7dfe8693 | ||
|
|
73c75effcb | ||
|
|
f6b134c887 | ||
|
|
a6b5833cdc | ||
|
|
c52c7a96b1 | ||
|
|
6ff6d3ff4c | ||
|
|
8c8731eedd | ||
|
|
33a761bf4f | ||
|
|
c3ab99f899 | ||
|
|
a48240a4d0 | ||
|
|
6c34113e8f | ||
|
|
3e9e6ed4e2 | ||
|
|
59ebb9dd0b | ||
|
|
6b01d4a7cc | ||
|
|
47dedaefe4 | ||
|
|
33ba5b0ac1 | ||
|
|
301a7843bb | ||
|
|
41a1950f1d | ||
|
|
9db7390e5e | ||
|
|
ebe327a1ef | ||
|
|
c0d61b50e6 | ||
|
|
257b6429c5 | ||
|
|
07afa058d5 | ||
|
|
8cad6b937d | ||
|
|
b06d2a72d7 | ||
|
|
0e56b5600b | ||
|
|
7d354dd8c8 | ||
|
|
4c04c13c14 | ||
|
|
324b10afbb | ||
|
|
b515ca90c2 | ||
|
|
3411c1c6ab | ||
|
|
7185710fb1 | ||
|
|
172ac0db55 | ||
|
|
62e5f05d9a | ||
|
|
33f2132cd3 | ||
|
|
d407a51e96 | ||
|
|
91e70d4e40 | ||
|
|
10b638e457 | ||
|
|
0e185b51dd | ||
|
|
f12de09d00 | ||
|
|
88d81a0ec8 | ||
|
|
34fd554855 | ||
|
|
2b059da2ed | ||
|
|
531d4d44bb | ||
|
|
9f8be42d5c | ||
|
|
acb7dae59e | ||
|
|
221b782c5f | ||
|
|
6c2abbac20 | ||
|
|
4680ed421a | ||
|
|
f8c515129c | ||
|
|
3da6a44308 | ||
|
|
7b58e3eaa2 | ||
|
|
b7259a4eda | ||
|
|
92c3e81d92 | ||
|
|
f2398545f3 | ||
|
|
164605be85 | ||
|
|
64f5f992cf | ||
|
|
9f991e4ce6 | ||
|
|
83036a1387 | ||
|
|
53f3f0cc31 | ||
|
|
e669d29227 | ||
|
|
17b8086007 | ||
|
|
90e7ca38a2 | ||
|
|
2e77310b30 | ||
|
|
4995f261f6 | ||
|
|
debdf609d3 | ||
|
|
07176cdc2e | ||
|
|
ca9c1ccbfa | ||
|
|
f456993b07 | ||
|
|
b6776d8169 | ||
|
|
f905376a22 | ||
|
|
d909a09866 | ||
|
|
b76bb5a893 | ||
|
|
e24c953e76 | ||
|
|
2b56181f2c | ||
|
|
12a704a508 | ||
|
|
845e0a5e1d | ||
|
|
0815776fa8 | ||
|
|
5db04a1fe7 | ||
|
|
d51bf0dc2d | ||
|
|
e55984c227 | ||
|
|
0cd418b2e8 | ||
|
|
73b748e183 | ||
|
|
3c1f73d95b | ||
|
|
f7c9db29be | ||
|
|
aae7b03c54 | ||
|
|
c509d2a95b | ||
|
|
c1ee8daf77 | ||
|
|
c4a3d6f38b | ||
|
|
815f3dc510 | ||
|
|
5e198dfa87 | ||
|
|
b5483242e1 | ||
|
|
7f26221c21 | ||
|
|
f9b0ffb90e | ||
|
|
b77e68e1f7 | ||
|
|
4d6c70a42f | ||
|
|
7916ab0493 | ||
|
|
a92fb7a4a0 | ||
|
|
527041c3e9 | ||
|
|
624ded4aaf | ||
|
|
7c57327286 | ||
|
|
3bfa21a8ae | ||
|
|
85938884b8 | ||
|
|
c7d950c9ae | ||
|
|
a1c8232378 | ||
|
|
0e4b8f4617 | ||
|
|
b7524a9700 | ||
|
|
4e3cda2ce1 | ||
|
|
9367ad1329 | ||
|
|
07d71173df | ||
|
|
418a591f7c | ||
|
|
4fe1f135c6 | ||
|
|
a08963b70e | ||
|
|
31221790ce | ||
|
|
5aa76d2809 | ||
|
|
3637bef129 | ||
|
|
429abeb36d | ||
|
|
d22bceaf9e | ||
|
|
622d5ba167 | ||
|
|
15cafa81d1 | ||
|
|
d0449516d8 | ||
|
|
e0bf1c2116 | ||
|
|
54265a9423 | ||
|
|
97e05c571e | ||
|
|
6253f9f42e | ||
|
|
dab6aae441 | ||
|
|
6b32702f7f | ||
|
|
097262c940 | ||
|
|
8d5ded1613 | ||
|
|
259318c812 | ||
|
|
7e3b1abe53 | ||
|
|
a4ea28b307 | ||
|
|
dc45f56733 | ||
|
|
bc19c8ae9d | ||
|
|
73c3477090 | ||
|
|
8bd5688507 | ||
|
|
bea5d6c080 | ||
|
|
468aa84e16 | ||
|
|
3e8eaf4abf | ||
|
|
14e1a08984 | ||
|
|
ace0d964a9 | ||
|
|
0b6da0d42e | ||
|
|
1fdeb51db7 | ||
|
|
88d8bdf987 | ||
|
|
2d175af694 | ||
|
|
3f399b4244 | ||
|
|
ee752cdca9 | ||
|
|
040a04cf09 | ||
|
|
a23ab50d18 | ||
|
|
987aa7a29e | ||
|
|
585fa80617 | ||
|
|
1c63ee5272 | ||
|
|
13959c7c09 | ||
|
|
4e2154fac0 | ||
|
|
bcb03e4647 | ||
|
|
c4906b531c | ||
|
|
c994b0152c | ||
|
|
774e414b26 | ||
|
|
3810d0952d | ||
|
|
0dfacfc853 | ||
|
|
58e665f116 | ||
|
|
91c771b767 | ||
|
|
222c38f13c | ||
|
|
20a23e8c08 | ||
|
|
532335b90d | ||
|
|
37f1e873b1 | ||
|
|
835dea6c5d | ||
|
|
476045e411 | ||
|
|
9a604137dc | ||
|
|
673f7a2758 | ||
|
|
2fb6e2805a | ||
|
|
6d204cf8e0 | ||
|
|
d4f8d0b372 | ||
|
|
902a703c36 | ||
|
|
fc16476d11 | ||
|
|
410b21ac06 | ||
|
|
ab0feece30 | ||
|
|
ea30938b48 | ||
|
|
112b668d89 | ||
|
|
cdddb094b4 | ||
|
|
412594f4ba | ||
|
|
7bb711b5de | ||
|
|
742838e590 | ||
|
|
c88f8c5234 | ||
|
|
40f5606331 | ||
|
|
63174d938c | ||
|
|
493be4696d | ||
|
|
8966d702c2 | ||
|
|
6f9c078bce | ||
|
|
6e67ab75a9 | ||
|
|
e558e9c813 | ||
|
|
f1d55b181a | ||
|
|
3e367c99cd | ||
|
|
07351a709f | ||
|
|
84951fda6f | ||
|
|
17254e29cf | ||
|
|
8d34700e62 | ||
|
|
e05b7edb16 | ||
|
|
31f923f71d | ||
|
|
b565a2b9f7 | ||
|
|
19c7035cc6 | ||
|
|
66b7986cca | ||
|
|
ed136f52c1 | ||
|
|
56d0998149 | ||
|
|
930fd9ba2e | ||
|
|
bf586b90d0 | ||
|
|
434da0dc99 | ||
|
|
3eba32a4e8 | ||
|
|
17244b9618 | ||
|
|
934afe67e1 | ||
|
|
0422f93f42 | ||
|
|
9d909d9ec8 | ||
|
|
d4a21a4540 | ||
|
|
12033fd3b4 | ||
|
|
22281891cb | ||
|
|
23226e87b2 | ||
|
|
27c1bf4c08 | ||
|
|
db5fa97ffa | ||
|
|
8cdcb5c7fe | ||
|
|
dfda6a5af5 | ||
|
|
d1788ba9bd | ||
|
|
3d64aa973e | ||
|
|
18ce95773e | ||
|
|
90b9b589c8 | ||
|
|
51fc7495f3 | ||
|
|
64595b443a | ||
|
|
9cb8a037ad | ||
|
|
c26798735b | ||
|
|
25d8541a0e | ||
|
|
3f7c3f1d6a | ||
|
|
a80a269f4b | ||
|
|
d85366b470 | ||
|
|
3bca11097f | ||
|
|
381cbf22c2 | ||
|
|
8defc96133 | ||
|
|
c7c87bd6c2 | ||
|
|
736becdbd3 | ||
|
|
ec9d3320a3 | ||
|
|
a1b00658b0 | ||
|
|
c1f2e0d2a4 | ||
|
|
2b825cacb9 | ||
|
|
26971eadbc | ||
|
|
ab39d8cdac | ||
|
|
def4263f24 | ||
|
|
747b27e9bc | ||
|
|
0700c1d188 | ||
|
|
74177ac733 | ||
|
|
6f51f3a4a3 | ||
|
|
e4204debd9 | ||
|
|
9953ecf231 |
399 changed files with 103215 additions and 55319 deletions
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -1,6 +1,14 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
node_modules/
|
||||
state/scratch-compaction-*.md
|
||||
experiments/images/
|
||||
sidecar/claude-sdk/dist/
|
||||
target/
|
||||
|
||||
# Disposable local scaffolding. These accumulated to ~87MB of untracked debris
|
||||
# once; the workspace must stay re-enterable, so they never come back.
|
||||
.tmp-*
|
||||
/tmp/
|
||||
--help
|
||||
--help.lock
|
||||
/acceptance-run*
|
||||
|
||||
# Claude Code agent worktrees (Hands/Soul isolation); never source
|
||||
.claude/worktrees/
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vendor/cultcache-py"]
|
||||
path = vendor/cultcache-py
|
||||
url = https://github.com/GameCult/cultcache-py.git
|
||||
273
AGENTS.md
273
AGENTS.md
|
|
@ -2,201 +2,106 @@
|
|||
|
||||
## Project Purpose
|
||||
|
||||
Ghostlight is a research and implementation home for socially persistent
|
||||
generative agents. The core problem is not "make an LLM sound like a person."
|
||||
The core problem is building an architecture where personality, culture,
|
||||
memory, masks, perception, and incentives can actually survive contact with
|
||||
time, relationships, routine, tonal variation, and pressure.
|
||||
Ghostlight is a Rust world kernel and the play daemon built on it. Models
|
||||
propose; the kernel admits or refuses. The same kernel is meant to run the
|
||||
simulation behind Aetheria's public story, a persistent cast and storylets, as
|
||||
well as Ghostlight Dungeon, the single-player DM-led client (parked at
|
||||
tag `parked/ghostlight-eve-surface`). `README.md` says
|
||||
what exists today and what does not.
|
||||
|
||||
This repo exists so those organs can live somewhere cleaner than a side chamber
|
||||
inside another project.
|
||||
## Where State Lives
|
||||
|
||||
## Canonical State
|
||||
- Campaign state (targets, rulings, cut specs, reports, verdicts, findings,
|
||||
follow-ups) lives in the Eureka mind, not in this repo. The current campaign
|
||||
is `ghostlight-verse`; query it through the `eureka-state` tools.
|
||||
- `notes/ghostlight-verse-target.md` and `notes/ghostlight-verse-map.md` are the
|
||||
campaign's prose target and map. Read the map for probed body facts.
|
||||
- This repo has no state directory, no state CLI, and no evidence ledger. Do not
|
||||
create one. History belongs in git; durable decisions belong in the mind.
|
||||
- `notes/fresh-workspace-handoff.md` is a pointer: what runs, how it deploys,
|
||||
where open work is queried.
|
||||
- Dated cut documents and postmortems (`docs/architecture/*-cut.md`,
|
||||
`*-postmortem.md`) are history. Do not edit them, including their old paths.
|
||||
|
||||
- Treat `state/ghostlight-state.cultcache.jsonl` as the CultCache-backed
|
||||
persistence spine for machine-managed state records that have migrated.
|
||||
- Treat `state/map.yaml` as the canonical human-readable project map.
|
||||
- Treat `state/scratch.md` as disposable working memory for one bounded
|
||||
subgoal.
|
||||
- Treat `state/evidence.jsonl` and `state/branches.json` as readable export
|
||||
surfaces during the persistence migration, not as the long-term direct-write
|
||||
API.
|
||||
- Treat `state/evidence.jsonl` as the distilled durable ledger of what was
|
||||
learned, verified, rejected, or accepted.
|
||||
- Treat `notes/ghostlight-implementation-plan.md` as the current implementation
|
||||
plan for the larger organs.
|
||||
- Update `state/map.yaml` when project understanding changes.
|
||||
- Add evidence after meaningful research, implementation, verification, or
|
||||
rejected paths, but keep it distilled. Routine "I just did this" proof
|
||||
belongs in git history, commits, smoke artifacts, or targeted logs unless it
|
||||
changes what the next session should believe.
|
||||
- Do not turn notes, map, handoff, and evidence into four versions of the same
|
||||
brain. Distinct jobs or bust.
|
||||
## Invariants
|
||||
|
||||
## Important Paths
|
||||
- **The kernel decides.** The kernel owns world state through its closed
|
||||
operation set. Models, tools, controllers, and consumers produce proposals
|
||||
only. No code outside the kernel commits world state, and no compatibility
|
||||
writer exists.
|
||||
- **The library is sealed.** `crates/ghostlight` exposes create/open,
|
||||
immutable snapshots, command submission, typed receipts, and the runner and
|
||||
port entry points. Do not publish mutable world types, an ID issuer, a reducer
|
||||
entry, or a journal handle for the convenience of tests or adapters. The seal
|
||||
is proven by the `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and by `crates/ghostlight/tests/external_admission.rs`; keep both passing and
|
||||
extend them when the public surface changes.
|
||||
- **Every commit is a command.** Everything enters through the mailbox
|
||||
(`crates/ghostlight/src/mailbox.rs`) as a command. Do not append a commit when
|
||||
reduction produces no canonical mutation.
|
||||
- **A Persona never sees structured state.** Persona turns are prose; an
|
||||
Interpreter lowers them to typed proposals, and anything it cannot lower
|
||||
becomes a recorded gap, not invented state.
|
||||
- **Typed state.** Runtime documents are CultCache `.cc`. JSON is for schema
|
||||
publication and boundaries with other systems.
|
||||
- **Credentials.** Ghostlight never reads, copies, forwards, or logs a model
|
||||
credential. A consumer never restates a foreign owner's document shape as its
|
||||
own strict struct; read the owner's published contract and test against the
|
||||
real counterpart.
|
||||
- **Canon flows one way.** Simulation output never writes back into AetheriaLore
|
||||
or Aetheria's catalog without operator review.
|
||||
|
||||
- Project root: `E:\Projects\Ghostlight`
|
||||
- Canonical map: `E:\Projects\Ghostlight\state\map.yaml`
|
||||
- Scratch surface: `E:\Projects\Ghostlight\state\scratch.md`
|
||||
- Distilled evidence ledger: `E:\Projects\Ghostlight\state\evidence.jsonl`
|
||||
- Branch ledger: `E:\Projects\Ghostlight\state\branches.json`
|
||||
- Handoff summary: `E:\Projects\Ghostlight\notes\fresh-workspace-handoff.md`
|
||||
- System map: `E:\Projects\Ghostlight\notes\ghostlight-current-system-map.md`
|
||||
- Implementation plan: `E:\Projects\Ghostlight\notes\ghostlight-implementation-plan.md`
|
||||
- Architecture rationale: `E:\Projects\Ghostlight\notes\architecture-rationale.md`
|
||||
- State CLI: `E:\Projects\Ghostlight\tools\ghostlight_state.py`
|
||||
- CultCache state seam: `E:\Projects\Ghostlight\tools\ghostlight_state_store.py`
|
||||
- CultCache-Py submodule: `E:\Projects\Ghostlight\vendor\cultcache-py`
|
||||
- CultCache state store: `E:\Projects\Ghostlight\state\ghostlight-state.cultcache.jsonl`
|
||||
- Pre-compaction helper: `E:\Projects\Ghostlight\tools\ghostlight_prepare_compaction.py`
|
||||
## Where Things Are
|
||||
|
||||
## Useful Commands
|
||||
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
|
||||
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
|
||||
`docs/architecture/ghostlight-world-consumer-api.md`.
|
||||
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
|
||||
tag `parked/ghostlight-eve-surface`; not on main.
|
||||
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
|
||||
- Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
|
||||
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
|
||||
rebuild, not the current plan.
|
||||
|
||||
Use the bundled Python runtime if `python` is not on PATH:
|
||||
## Build, Verify, Deploy
|
||||
|
||||
```powershell
|
||||
npm run state:status
|
||||
npm run state:prepare-compaction
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type research --status ok --note '...'
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-branch --id branch-id --hypothesis '...'
|
||||
```
|
||||
Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and
|
||||
lifecycle brakes engaged, unit stopped; retirement waits on
|
||||
`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the
|
||||
`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence
|
||||
about Linux, so say which host you used. Do not run `idunn up ghostlight`.
|
||||
|
||||
## Session Bootstrap And Re-entry Protocol
|
||||
|
||||
On fresh session load, do this before wandering off into implementation:
|
||||
|
||||
1. read:
|
||||
- `state/map.yaml`
|
||||
- `notes/fresh-workspace-handoff.md`
|
||||
- `notes/ghostlight-current-system-map.md`
|
||||
- `notes/ghostlight-implementation-plan.md`
|
||||
2. run:
|
||||
- `npm run state:status`
|
||||
3. restate the current next action from the persisted state before starting
|
||||
edits
|
||||
4. if the user only asked to rehydrate or reorient, stop after orientation and
|
||||
wait for an explicit continue instruction instead of treating the persisted
|
||||
next action as permission to start coding
|
||||
|
||||
After compaction, resume, or suspicious continuity loss:
|
||||
|
||||
1. rerun `npm run state:status`
|
||||
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
|
||||
3. treat the persisted next action as authoritative unless fresh repo evidence
|
||||
contradicts it
|
||||
|
||||
When context pressure is clearly rising:
|
||||
|
||||
1. stop broad exploration
|
||||
2. narrow the active move to one bounded organ
|
||||
3. persist map or handoff updates, plus distilled evidence only when the lesson
|
||||
changes future belief, before the blackout lands
|
||||
|
||||
When the user says to prepare for imminent compaction:
|
||||
|
||||
1. immediately write hot live context from memory into a new
|
||||
`state/scratch-compaction-<guid>.md` file before reading files, running
|
||||
status checks, or doing tidy persistence work
|
||||
2. run `tools/ghostlight_prepare_compaction.py` before editing persistence
|
||||
surfaces
|
||||
3. use its warnings as the checklist for map, handoff, scratch, evidence, and
|
||||
git hygiene
|
||||
4. update only the state that actually needs to change
|
||||
5. after hot context has been folded into canonical persisted state, delete the
|
||||
`state/scratch-compaction-<guid>.md` file so stale emergency memory does not
|
||||
linger
|
||||
6. run `tools/ghostlight_prepare_compaction.py` again after edits and scratch
|
||||
cleanup
|
||||
7. fix errors, address warnings, and commit the completed persistence pass
|
||||
unless the work is deliberately mid-surgery
|
||||
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
|
||||
`F:\Projects\gamecult-ops` before acting.
|
||||
|
||||
## Operating Discipline
|
||||
|
||||
- Steward persistent state as part of the work. If a lesson changes future
|
||||
behavior, update the smallest correct memory surface before moving on; if a
|
||||
memory surface is stale, duplicated, or making re-entry slower, cut or
|
||||
consolidate it.
|
||||
- Before substantial edits, restate the current mechanism and intended change.
|
||||
- Prefer one clear hypothesis per iteration.
|
||||
- Verify with checks that reflect the real goal, not just proxy success.
|
||||
- Revert or discard changes that do not clearly improve the target.
|
||||
- If the diff grows while understanding shrinks, stop implementation and switch
|
||||
to diagnosis.
|
||||
- Keep maps and prose together; do not replace useful maps with prose-only
|
||||
explanations.
|
||||
- Commit completed work before it rots in the worktree unless the task is
|
||||
deliberately mid-surgery or the user asked to leave changes uncommitted.
|
||||
- Push after major completed passes when a remote exists, unless the user asked
|
||||
not to or there is a concrete reason to keep the commit local for a moment.
|
||||
- Before handoff, compaction, or phase boundaries, sync `state/map.yaml`, add
|
||||
distilled evidence when the lesson changes future belief, refresh
|
||||
`notes/fresh-workspace-handoff.md`, and make the next action explicit.
|
||||
- Treat `state/map.yaml` as a working memory map, not an archive. Preserve
|
||||
current mission, live seams, boundaries, and next action; let git history,
|
||||
evidence, and artifact files carry chronology.
|
||||
- In documentation, describe the live system and current rule directly. Do not
|
||||
write victory laps, scar tours, clever autopsies, or contrastive little
|
||||
boasts about what the project no longer does. If a sentence congratulates the
|
||||
design for not being a previous wrong design, cut it or rewrite it as a plain
|
||||
current rule. If rejected history matters, keep it short, explicit, and
|
||||
decision-relevant; otherwise cut it. The docs are not a trophy case for
|
||||
yesterday's wrong machine.
|
||||
- Restate the current mechanism and intended change before substantial edits.
|
||||
Prefer one clear hypothesis per iteration.
|
||||
- Verify with checks that reflect the real goal, not proxy success. Revert
|
||||
changes that do not clearly improve the target.
|
||||
- If the diff grows while understanding shrinks, stop and diagnose.
|
||||
- Delete obsolete authority before adding a new path. Do not keep an old writer
|
||||
alive behind a shim.
|
||||
- Commit completed work and push it. Merge, confirm the tip by SHA, then delete
|
||||
the branch; push only after reading the test result line.
|
||||
- In documentation, describe the live system and the current rule directly. No
|
||||
claim of capability without a file that implements it. Keep rejected history
|
||||
short and decision-relevant, or leave it in git.
|
||||
|
||||
## Aetheria Grounding Doctrine
|
||||
## Aetheria Grounding
|
||||
|
||||
- Before writing or generating Aetheria scenes, check AetheriaLore for the
|
||||
factions, movements, institutions, species or body types, location, and time
|
||||
period involved.
|
||||
- Before drafting story prose, choose an intentional tonal mode or blend for the
|
||||
scene. Aetheria should support wit-with-stakes, warmth, absurdity, domestic
|
||||
texture, ritual quiet, wonder, horror, noir, dour poetry, and dry systems
|
||||
prose as needed. Do not default to constant crisis or procedural severity.
|
||||
- Use Adams/Pratchett-style comic precision and humane levity as a useful
|
||||
default touchstone when no stronger scene-specific style is called for:
|
||||
jokes and absurdity should reveal stakes, not dissolve them.
|
||||
- Establish ordinary life before interruption when the fixture calls for it:
|
||||
routines, work texture, relationships, private jokes, rituals, small desires,
|
||||
and local accommodations are part of worldbuilding, not filler.
|
||||
- For illustrated Ink fixtures, segment prose into click-through visual
|
||||
sections. Distinct places, focal areas, object closeups, arrivals, alarms,
|
||||
pivotal one-sentence beats, and aftermath states need stable visual scene ids
|
||||
and imagegen-ready prompts.
|
||||
- Treat visual artifacts as illustrated replay and website collateral, not core
|
||||
social-model training signal. Visual review judges the playable Ink
|
||||
experience plus the visual plan, not clean-run prose receipts.
|
||||
- Write visual prompts for a general image model, not a lore expert. Explain
|
||||
fictional objects and body affordances in visible terms: geometry, materials,
|
||||
lighting, stance, interfaces, tools, supports, cables, fluids, screens, and
|
||||
crowd positions.
|
||||
- Do the same for environments. A location label like "manifold line," "cavity
|
||||
yard," "supervisor glass," or "anchor rail" is not a drawable scene until the
|
||||
prompt describes its visible geometry, materials, interfaces, lights, and
|
||||
scale.
|
||||
- If a fixture has a target visual style, store that style cue once in the
|
||||
sidecar and include it in every assembled image prompt. Do not rely on memory
|
||||
or unstated website context to carry the look.
|
||||
- Do not treat a character name as a visual description. Illustrated fixtures
|
||||
need stable character visual refs for every recurring named character, and
|
||||
scene prompts should either include those refs during prompt assembly or carry
|
||||
enough appearance detail to stand alone.
|
||||
- Include visual character refs only for characters actually visible in the
|
||||
rendered frame. Do not feed imagegen refs for absent, off-frame, merely
|
||||
mentioned, or object-represented characters.
|
||||
- Treat visual continuity notes as tool/reviewer constraints, not prompt text.
|
||||
If imagegen needs the fact, rewrite it as concrete visible description in the
|
||||
base prompt, character ref, or branch/state modifier.
|
||||
- Treat generated environment images as concept and material reference, not
|
||||
spatial truth. Multi-angle or branch-consistent illustrated IF needs a durable
|
||||
scene-set source such as a hand-built blockout, procedural scene file, layout
|
||||
asset, or camera map. Imagegen can paint over camera-specific visual input;
|
||||
it should not be asked to preserve room topology from a prior generated
|
||||
image or text memory.
|
||||
- Ground scene writing in the source's material details: habitat form,
|
||||
infrastructure, law, class pressure, money, route access, surveillance,
|
||||
communication channels, body affordances, and local failure costs.
|
||||
- If the source is too vague for a concrete scene detail, do not bluff. Mark
|
||||
the lore gap, patch the AetheriaLore source with a narrow elaboration, then
|
||||
regenerate or revise the Ghostlight artifact from that patched source.
|
||||
- Treat this as part of data generation. Ghostlight is expected to stress-test
|
||||
Aetheria at room scale, find missing granularity, and push useful
|
||||
elaborations back into the lore vault.
|
||||
- Before writing or generating Aetheria material, check AetheriaLore for the
|
||||
factions, institutions, species or body types, location, and time period
|
||||
involved. Ground detail in the source's material facts: habitat, law, money,
|
||||
route access, surveillance, communication channels, and local failure costs.
|
||||
- If the source is too vague for a concrete detail, do not bluff. Mark the gap
|
||||
and propose a narrow elaboration for AetheriaLore, subject to operator review.
|
||||
- Choose a tonal mode on purpose. Aetheria supports wit with stakes, warmth,
|
||||
absurdity, domestic texture, ritual quiet, wonder, horror, noir, and dry
|
||||
systems prose; do not default to constant crisis. Comic precision in the
|
||||
Adams/Pratchett manner is a good default, where jokes reveal stakes instead of
|
||||
dissolving them.
|
||||
- Establish ordinary life before interruption: routines, work, relationships,
|
||||
small desires, and local accommodations are worldbuilding, not filler.
|
||||
|
|
|
|||
3
CLAUDE.md
Normal file
3
CLAUDE.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
|
||||
|
||||
@AGENTS.md
|
||||
1978
Cargo.lock
generated
Normal file
1978
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/ghostlight",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"crates/ghostlight-verse",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
unicode-segmentation = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
zeroize = "1.8"
|
||||
226
README.md
226
README.md
|
|
@ -1,151 +1,121 @@
|
|||
# Ghostlight
|
||||
|
||||
Ghostlight is a research and implementation home for socially persistent
|
||||
generative agents.
|
||||
**Ghostlight is a world kernel: typed world state that a language model can
|
||||
propose changes to and can never write.** The valuable part is
|
||||
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
|
||||
|
||||
The project is about characters and simulated people who remain coherent across
|
||||
time, routine, pressure, memory, culture, misreading, masks, incentives, and
|
||||
changing relationships. The aim is not to make a language model sound charming
|
||||
for one scene. The aim is to build machinery that can track who someone is,
|
||||
what they believe, what they hide, how they see other people, how they live when
|
||||
nothing is exploding, and how their behavior changes when the world leans on
|
||||
them.
|
||||
**The thesis.** World state is typed documents. Every change to it, and every
|
||||
narrative dependency on it, is an operation in a closed, predefined algebra.
|
||||
Models propose operations; the kernel checks each against the state (references,
|
||||
preconditions, authority, conservation, who knows what) and admits it or refuses
|
||||
it with the complete list of mismatches. Narration describes only what was
|
||||
committed. A model-as-game-master holds the world in a context window and
|
||||
drifts; here the world is a digest-chained journal that survives restarts and
|
||||
cannot be talked into contradicting itself.
|
||||
|
||||
In practical terms, Ghostlight is building the state and projection layer for
|
||||
character-driven generation:
|
||||
**Use it as a structured state backend.** Do not judge it by the parked Dungeon's
|
||||
prose. The kernel is a library; prose comes from whichever model you plug into
|
||||
its ports.
|
||||
|
||||
- durable canonical character state
|
||||
- observer-specific perceived state
|
||||
- directional relationship stance
|
||||
- cultural and institutional pressure
|
||||
- memory and goal selection
|
||||
- speaker-local prompt projection
|
||||
- evaluation targets for emergent verbal behavior
|
||||
- a future path for training smaller specialist projection models
|
||||
- `crates/ghostlight`: the sealed library. A consumer gets create/open
|
||||
(`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command
|
||||
submission through role-scoped ports (`PlayPort`, `ConsumerPort`,
|
||||
`ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port
|
||||
entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`,
|
||||
`InferencePort`). Mutable state, ID issuers, the reducer and the journal
|
||||
writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and `crates/ghostlight/tests/external_admission.rs` prove the seal.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects,
|
||||
places, routes, holdings, knowledge, commitments, pressures), the operation
|
||||
algebra, and what the reducer checks.
|
||||
- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a
|
||||
program that reads a world and proposes changes to the characters it controls
|
||||
(`POST /cultnet/world-patch`; no process on main serves that door, a host serves
|
||||
`ghostlight::admit_document`).
|
||||
|
||||
The first concrete consumer is Aetheria authoring support, especially Call of
|
||||
the Void content generation, dialogue scaffolding, and procedural drama for
|
||||
narrated stories in the Aetheria timeline.
|
||||
Start with those two documents, then `cargo doc -p ghostlight --open`.
|
||||
|
||||
## Core Idea
|
||||
**Populate the algebra first.** The kernel owns the component operations; a world owns its
|
||||
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
|
||||
Speak, which records an utterance and changes nothing. Until your world declares affordances
|
||||
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
|
||||
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
|
||||
will be thin no matter how good the model is. The Aetheria verse genesis
|
||||
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
|
||||
only record the act; their effects are the next phase.
|
||||
|
||||
Ghostlight separates three things that are easy to blur together:
|
||||
## Elaborators
|
||||
|
||||
1. **Canonical state**
|
||||
What is true about a character, relationship, event, memory, or scene.
|
||||
An elaborator is a model session, confined to one jurisdiction of the world, that
|
||||
adds structure where the world is thin: it answers a derived causal boundary
|
||||
(a destination nobody has described yet) or a scale deficit with typed
|
||||
declarations, and the kernel verifies the result like any other patch. Sessions
|
||||
are resumable and checkpointed against committed history. Eight stock lenses
|
||||
(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil,
|
||||
ember, numen) give each session a role-specific emphasis, for example customs and
|
||||
local jokes, or money and ledgers. A lens changes the prompt only, never what the
|
||||
kernel admits. Because boundaries are derived from state, elaborators can be
|
||||
dispatched as the player approaches a place or a social structure, instead of
|
||||
authoring the world up front.
|
||||
|
||||
2. **Perceived state**
|
||||
What a particular observer thinks is true, including misreadings,
|
||||
attribution bias, suspicion, trust, projection, and partial knowledge.
|
||||
## Direction (planned, not built)
|
||||
|
||||
3. **Prompt projection**
|
||||
A temporary speaker-local prompt surface for one scene or turn.
|
||||
Use the kernel to simulate a whole world with many kinds of actor: Aetheria's
|
||||
factions and a persistent cast whose members keep identity, memory and
|
||||
commitments across sessions. Newspaper-staff simulations before the 2026-08
|
||||
teardown are the earlier proof of the idea; that code is in git history, not in
|
||||
the tree. Running this at scale is gated by inference cost, not by the design.
|
||||
`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates
|
||||
an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run`
|
||||
advances it a number of ticks under a token cap, reporting per tick what each
|
||||
faction did and what it cost. Nothing loads AetheriaLore prose, models
|
||||
storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`.
|
||||
|
||||
The prompt is not the character. The prompt is a rendered slice of state.
|
||||
## Ghostlight Dungeon (parked)
|
||||
|
||||
That distinction matters because believable dialogue should come from character
|
||||
circumstance, not vibes. Sometimes that circumstance is acute pressure. Often it
|
||||
is habit, boredom, affection, work rhythm, class embarrassment, ritual memory,
|
||||
professional competence, petty irritation, or ordinary warmth trying to survive
|
||||
inside a bleak system. Cat being sarcastic should fall out of shame sensitivity,
|
||||
distance seeking, suspicion, relationship tension, scene stakes, and voice.
|
||||
Oz withholding information should fall out of mask rigidity, privacy pressure,
|
||||
relationship fear, and the need to stay useful. A quiet joke, a domestic aside,
|
||||
or a moment of tenderness should be just as explainable in state terms as a
|
||||
threat. The system should be able to show its work without making every scene
|
||||
sound like Greg Egan having a panic attack in a server closet.
|
||||
The single-player, DM-led client and its daemon, Eve play surface and browser
|
||||
host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and
|
||||
nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI
|
||||
(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag.
|
||||
|
||||
## Current Status
|
||||
## Repo map
|
||||
|
||||
Ghostlight is early architecture plus executable schema seams.
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `crates/ghostlight/` | The sealed kernel library. |
|
||||
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
|
||||
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
|
||||
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
|
||||
| `AGENTS.md` | Invariants and where things are. |
|
||||
| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). |
|
||||
| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. |
|
||||
|
||||
Currently present:
|
||||
`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design
|
||||
essays and proposals from before the rebuild. They describe organs (campaigns,
|
||||
co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the
|
||||
running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history.
|
||||
|
||||
- v0 canonical agent-state schema
|
||||
- required variable lists for state families and relationship stance
|
||||
- Call of the Void-flavored Cat/Oz fixture
|
||||
- dependency-free fixture validator
|
||||
- prompt projection contract
|
||||
- projection distillation plan for teacher/student projector training
|
||||
- persistence surfaces for re-entry, evidence, planning, and compaction hygiene
|
||||
## Build and test
|
||||
|
||||
Not present yet:
|
||||
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
|
||||
workstation:
|
||||
|
||||
- runtime prompt renderer
|
||||
- projection training dataset
|
||||
- student projection model
|
||||
- event/classifier pipeline
|
||||
- simulation loop
|
||||
- relationship update engine
|
||||
- culture prior engine
|
||||
|
||||
## How Projection Is Supposed To Work
|
||||
|
||||
For one speaker in one scene, Ghostlight should gather speaker-local state:
|
||||
|
||||
- what the speaker knows
|
||||
- what they believe
|
||||
- what they want
|
||||
- what they are protecting
|
||||
- what memories are active
|
||||
- how they read the listener
|
||||
- what the relationship currently feels like
|
||||
- what circumstance the scene creates: routine, desire, scarcity, humor,
|
||||
tenderness, danger, institutional friction, or open crisis
|
||||
- what voice surface is active
|
||||
|
||||
The projection layer turns that into compact character-local prose and prompt
|
||||
sections. The dialogue model then writes from the active circumstance and tonal
|
||||
mode, not from a universal crisis register.
|
||||
|
||||
Longer term, a frontier model can act as a teacher projector that generates
|
||||
reviewed projection artifacts. Those artifacts can train a smaller student
|
||||
projector once the projection schema, deterministic input slicer, and evaluator
|
||||
are stable.
|
||||
|
||||
## Repo Tour
|
||||
|
||||
- `AGENTS.md`: operating discipline for humans and agents
|
||||
- `state/map.yaml`: canonical project map and current next action
|
||||
- `state/scratch.md`: disposable working memory for one bounded subgoal
|
||||
- `state/evidence.jsonl`: distilled belief-changing evidence
|
||||
- `notes/fresh-workspace-handoff.md`: compact re-entry packet
|
||||
- `notes/ghostlight-current-system-map.md`: current repo machinery
|
||||
- `notes/ghostlight-implementation-plan.md`: larger implementation sequence
|
||||
- `notes/architecture-rationale.md`: why the persistence and state split exists
|
||||
- `docs/research/`: research backlog and modeling notes
|
||||
- `docs/aetheria/`: Aetheria source material used by Ghostlight
|
||||
- `docs/architecture/`: state, projection, and authoring architecture
|
||||
- `docs/architecture/verse-service-contract.md`: GameCult Verse service contract
|
||||
for Ghostlight's missing Eve GUI/TUI state and authoring surface
|
||||
- `schemas/`: JSON schema contracts and required field lists
|
||||
- `examples/`: schema fixtures and future projection examples
|
||||
- `tools/ghostlight_state.py`: state inspection and evidence helper
|
||||
- `tools/ghostlight_prepare_compaction.py`: pre-compaction audit helper
|
||||
- `tools/validate_agent_state.py`: focused fixture validator
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```powershell
|
||||
npm run state:status
|
||||
npm run state:prepare-compaction
|
||||
npm run schema:validate
|
||||
```sh
|
||||
cargo build --locked --workspace
|
||||
cargo test --locked -p ghostlight-persona-projection
|
||||
cargo test --locked -p ghostlight
|
||||
cargo test --locked -p ghostlight-verse
|
||||
```
|
||||
|
||||
To add distilled evidence:
|
||||
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
|
||||
`npm run sdk:build` and `npm run sdk:test`. The acceptance test
|
||||
`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored
|
||||
by default and needs a real model.
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type design --status accepted --note '...'
|
||||
```
|
||||
Inference is routed by model name: `local/` goes to an OpenAI-compatible
|
||||
loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/`
|
||||
to an https endpoint. Ghostlight never reads, stores or forwards a model
|
||||
credential beyond a key file named at run time.
|
||||
|
||||
## Best Starting Points
|
||||
|
||||
- `docs/architecture/canonical-agent-state-schema.md`
|
||||
- `docs/architecture/cultnet-shared-agent-state-contract.md`
|
||||
- `docs/architecture/agent-state-variable-glossary.md`
|
||||
- `docs/architecture/prompt-projection-contract.md`
|
||||
- `docs/architecture/projection-distillation-plan.md`
|
||||
- `examples/agent-state.call-of-the-void.json`
|
||||
|
||||
For Codex-driven work, read `AGENTS.md` before changing the repo. It contains
|
||||
the working rules that keep the project state coherent between sessions.
|
||||
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, model-provider and diagnostic boundaries.
|
||||
|
|
|
|||
11
crates/ghostlight-persona-projection/Cargo.toml
Normal file
11
crates/ghostlight-persona-projection/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "ghostlight-persona-projection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
sha2.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
|
||||
1337
crates/ghostlight-persona-projection/src/lib.rs
Normal file
1337
crates/ghostlight-persona-projection/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
15
crates/ghostlight-verse/Cargo.toml
Normal file
15
crates/ghostlight-verse/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "ghostlight-verse"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
346
crates/ghostlight-verse/src/main.rs
Normal file
346
crates/ghostlight-verse/src/main.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
//! The verse runner's operator command line.
|
||||
//!
|
||||
//! `genesis` creates the canonical Aetheria verse world in a state root, and
|
||||
//! `run` advances it a given number of ticks under a token cap, printing per
|
||||
//! tick what each faction did and what it cost. A run starts only when this
|
||||
//! command is invoked, and ends when it has done what it was asked.
|
||||
//!
|
||||
//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and
|
||||
//! `calls.cc`. The provider key is read from a file at run time and appears
|
||||
//! nowhere else.
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::{Duration, Utc};
|
||||
use ghostlight::{
|
||||
CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX,
|
||||
DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent,
|
||||
HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort,
|
||||
RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore,
|
||||
WorldMailbox, call_run_id, id_text, open_controller_work, open_inference,
|
||||
read_aetheria_catalog, run_manual,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::ControlFlow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// What a manual run spends per tick on cognition cells. The operator's lever
|
||||
/// on cost is the token cap; the partition always covers every faction.
|
||||
const COVER: CoverBudget = CoverBudget {
|
||||
cells: 8,
|
||||
constituent_cap: 8,
|
||||
urgency_slots: 2,
|
||||
};
|
||||
|
||||
const RUNTIME_ID: &str = "ghostlight-verse";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let arguments: Vec<String> = std::env::args().skip(1).collect();
|
||||
match command(arguments).await {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("ghostlight-verse: {error:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn command(arguments: Vec<String>) -> Result<ExitCode> {
|
||||
let Some((name, rest)) = arguments.split_first() else {
|
||||
bail!("usage: ghostlight-verse genesis|run [flags]");
|
||||
};
|
||||
match name.as_str() {
|
||||
"genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await,
|
||||
"run" => run(Flags::parse(rest, &RUN_FLAGS)?).await,
|
||||
_ => bail!("unknown command, expected genesis or run"),
|
||||
}
|
||||
}
|
||||
|
||||
const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"];
|
||||
const RUN_FLAGS: [&str; 7] = [
|
||||
"state",
|
||||
"ticks",
|
||||
"token-cap",
|
||||
"tick-span-minutes",
|
||||
"model",
|
||||
"base-url",
|
||||
"key-file",
|
||||
];
|
||||
/// Flags a command may omit.
|
||||
const OPTIONAL: [&str; 1] = ["key-file"];
|
||||
|
||||
/// `--name value` pairs, each named once and from the command's own list.
|
||||
struct Flags(BTreeMap<&'static str, String>);
|
||||
|
||||
impl Flags {
|
||||
fn parse(arguments: &[String], allowed: &[&'static str]) -> Result<Self> {
|
||||
let mut seen = BTreeMap::new();
|
||||
let mut rest = arguments.iter();
|
||||
while let Some(flag) = rest.next() {
|
||||
let Some(name) = flag.strip_prefix("--") else {
|
||||
bail!("expected a --flag, found a bare argument");
|
||||
};
|
||||
let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else {
|
||||
bail!("a flag this command does not take was given");
|
||||
};
|
||||
let Some(value) = rest.next() else {
|
||||
bail!("--{known} needs a value");
|
||||
};
|
||||
if seen.insert(known, value.clone()).is_some() {
|
||||
bail!("--{known} was given twice");
|
||||
}
|
||||
}
|
||||
for &required in allowed {
|
||||
if !OPTIONAL.contains(&required) && !seen.contains_key(required) {
|
||||
bail!("--{required} is required");
|
||||
}
|
||||
}
|
||||
Ok(Self(seen))
|
||||
}
|
||||
|
||||
fn text(&self, name: &str) -> &str {
|
||||
self.0.get(name).map_or("", String::as_str)
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
PathBuf::from(self.text(name))
|
||||
}
|
||||
|
||||
fn number<T: std::str::FromStr + PartialOrd + Default>(&self, name: &str) -> Result<T> {
|
||||
match self.text(name).parse::<T>() {
|
||||
Ok(value) if value > T::default() => Ok(value),
|
||||
_ => bail!("--{name} is not a positive integer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn genesis(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
std::fs::create_dir_all(&state).context("the state directory cannot be created")?;
|
||||
let catalog = read_aetheria_catalog(flags.path("catalog"))
|
||||
.context("the Aetheria catalog cannot be read")?;
|
||||
let factions = catalog.factions.len();
|
||||
let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1));
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let receipt = mailbox
|
||||
.create_verse(
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Aetheria Verse".into(),
|
||||
brief: "The Aetheria verse, simulated from its catalog.".into(),
|
||||
catalog,
|
||||
aetheria_commit: flags.text("aetheria-commit").into(),
|
||||
lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()),
|
||||
targets: BTreeMap::new(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the verse world cannot be created")?;
|
||||
// A created world is a draft: the owner approves and activates it, so the
|
||||
// factions hold opportunities.
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision;
|
||||
mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the world cannot be activated")?;
|
||||
}
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
println!("world {}", id_text(receipt.world_id));
|
||||
println!("factions {factions}");
|
||||
println!("phase Active");
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
async fn run(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
let ticks: u32 = flags.number("ticks")?;
|
||||
let cap: u64 = flags.number("token-cap")?;
|
||||
let span: u32 = flags.number("tick-span-minutes")?;
|
||||
let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?;
|
||||
if !state.join("world.cc").is_file() {
|
||||
bail!("the state directory holds no world; run genesis first");
|
||||
}
|
||||
|
||||
let model = flags.text("model").to_owned();
|
||||
let models = ControllerModels {
|
||||
projector: model.clone(),
|
||||
persona: model.clone(),
|
||||
interpreter: model.clone(),
|
||||
operational_agent: model.clone(),
|
||||
elaborator: model.clone(),
|
||||
};
|
||||
let inner = open_lane(&flags, &model, &models)?;
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let world = mailbox.snapshot().await.context("the world cannot be read")?;
|
||||
let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).map_err(store_fault("the call records in --state cannot be opened"))?);
|
||||
let runs = VerseRunStore::open(state.join("runs.cc")).map_err(store_fault("the run records in --state cannot be opened"))?;
|
||||
let work = open_controller_work(state.join("controller-work.cc"))
|
||||
.map_err(store_fault("the controller work store in --state cannot be opened"))?;
|
||||
let config = VerseRunConfig {
|
||||
ticks,
|
||||
tick_span,
|
||||
cover: COVER,
|
||||
};
|
||||
let claim = runs
|
||||
.claim(&world, cap, &config)
|
||||
.context("the run cannot be claimed")?;
|
||||
let sequence = claim.sequence();
|
||||
// A start that fails after the claim ends the claimed run, so it binds
|
||||
// nothing: the next claim takes the next number.
|
||||
let started = RecordingInferencePort::open(
|
||||
inner,
|
||||
calls,
|
||||
call_run_id(sequence),
|
||||
cap,
|
||||
&RunStart::of(&world),
|
||||
)
|
||||
.context("the run's call records cannot be opened")
|
||||
.and_then(|port| {
|
||||
let port = Arc::new(port);
|
||||
ControllerRunner::open(mailbox.clone(), port.clone(), work, models)
|
||||
.map(|runner| (port, runner))
|
||||
.context("the controller organ cannot open")
|
||||
});
|
||||
let (port, runner) = match started {
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
return Err(match runs.abandon(claim, "start_refused") {
|
||||
Ok(()) => error,
|
||||
Err(_) => error.context("the refused run's record could not be ended"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}"
|
||||
);
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let interrupted = interrupted.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
interrupted.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
}
|
||||
let outcome = run_manual(
|
||||
&mailbox,
|
||||
&runner,
|
||||
&port,
|
||||
&runs,
|
||||
claim,
|
||||
&config,
|
||||
|report| {
|
||||
print!("{report}");
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("the run could not be recorded")?;
|
||||
println!(
|
||||
"status: {}; {} of {} ticks closed",
|
||||
outcome.record.status,
|
||||
outcome.record.ticks_closed(),
|
||||
outcome.record.ticks_requested
|
||||
);
|
||||
drop(runner);
|
||||
drop(port);
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
Ok(match outcome.record.status {
|
||||
RunStatus::Failed { .. } => ExitCode::FAILURE,
|
||||
_ => ExitCode::SUCCESS,
|
||||
})
|
||||
}
|
||||
|
||||
/// The inference lane the model's prefix names. A hosted model needs an https
|
||||
/// origin and a key file; a local one reaches a loopback server and needs
|
||||
/// neither, so a key file beside it is refused.
|
||||
fn open_lane(
|
||||
flags: &Flags,
|
||||
model: &str,
|
||||
models: &ControllerModels,
|
||||
) -> Result<Arc<dyn ghostlight::InferencePort>> {
|
||||
let (mut hosted, mut local) = (None, None);
|
||||
if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) {
|
||||
let Some(key) = flags.0.get("key-file") else {
|
||||
bail!("a hosted model needs --key-file");
|
||||
};
|
||||
hosted = Some(HostedBinding {
|
||||
base_url: flags.text("base-url").to_owned(),
|
||||
key_path: Path::new(key).to_owned(),
|
||||
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) {
|
||||
if flags.0.contains_key("key-file") {
|
||||
bail!("a local model takes no --key-file");
|
||||
}
|
||||
let endpoint: SocketAddr = flags
|
||||
.text("base-url")
|
||||
.strip_prefix("http://")
|
||||
.and_then(|address| address.trim_end_matches('/').parse().ok())
|
||||
.context("--base-url for a local model is not http://<address>:<port>")?;
|
||||
local = Some(LocalBinding {
|
||||
endpoint,
|
||||
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else {
|
||||
bail!("--model names no inference lane (expected a hosted/ or local/ prefix)");
|
||||
}
|
||||
open_inference(None, None, local, hosted, &models.each())
|
||||
.map_err(|error| anyhow!(lane_fault(&error)))
|
||||
}
|
||||
|
||||
/// A store that cannot open says which one and that it is under `--state`,
|
||||
/// never the path or the store's own text, which carries the path.
|
||||
fn store_fault<E>(message: &'static str) -> impl FnOnce(E) -> anyhow::Error {
|
||||
move |_| anyhow!(message)
|
||||
}
|
||||
|
||||
/// What a lane that cannot open says: the flag or the kind of fault, never a
|
||||
/// value the operator passed.
|
||||
fn lane_fault(error: &ControllerOpenError) -> &'static str {
|
||||
match error {
|
||||
ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier",
|
||||
ControllerOpenError::Connector(_) => "the connector lane cannot open",
|
||||
ControllerOpenError::WorkStore(_) => "the controller work journal cannot open",
|
||||
ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane",
|
||||
ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file",
|
||||
ControllerOpenError::LocalEndpointNotLoopback { .. } => {
|
||||
"--base-url for a local model must be a loopback address"
|
||||
}
|
||||
ControllerOpenError::HostedEndpointNotHttps => {
|
||||
"--base-url for a hosted model must be an https URL"
|
||||
}
|
||||
ControllerOpenError::HostedKeyUnreadable => {
|
||||
"--key-file is unreadable, empty, or not one run of visible ASCII"
|
||||
}
|
||||
ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix",
|
||||
ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty",
|
||||
}
|
||||
}
|
||||
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! The `ghostlight-verse` command against a loopback scripted responder. No
|
||||
//! real model is called and no key file is read: the responder answers the
|
||||
//! OpenAI-compatible chat-completions request the local lane sends.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse");
|
||||
const CATALOG: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
/// What the responder reports each call cost: 120 prompt and 30 completion.
|
||||
const PER_CALL: u64 = 150;
|
||||
|
||||
fn verse(arguments: &[&str]) -> Output {
|
||||
Command::new(BINARY).args(arguments).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
}
|
||||
|
||||
fn genesis(state: &Path) -> Output {
|
||||
verse(&[
|
||||
"genesis",
|
||||
"--catalog",
|
||||
CATALOG,
|
||||
"--aetheria-commit",
|
||||
COMMIT,
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--owner-hash",
|
||||
"verse-owner",
|
||||
])
|
||||
}
|
||||
|
||||
/// Answers every chat-completions request with one tool call: the faction the
|
||||
/// prompt names speaks its own line. Counts the requests it served.
|
||||
struct Responder {
|
||||
address: SocketAddr,
|
||||
served: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Responder {
|
||||
fn start() -> Self {
|
||||
Self::answering(200)
|
||||
}
|
||||
|
||||
/// A provider that refuses every call with this status.
|
||||
fn refusing(status: u16) -> Self {
|
||||
Self::answering(status)
|
||||
}
|
||||
|
||||
fn answering(status: u16) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let served = Arc::new(AtomicUsize::new(0));
|
||||
let counter = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { return };
|
||||
let counter = counter.clone();
|
||||
std::thread::spawn(move || serve(stream, &counter, status));
|
||||
}
|
||||
});
|
||||
Self { address, served }
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) {
|
||||
let mut raw = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let (head_end, length) = loop {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("content-length:"))
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
break (end + 4, length);
|
||||
}
|
||||
};
|
||||
while raw.len() < head_end + length {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap();
|
||||
let text = request.to_string();
|
||||
let faction = ["Adrasteia", "Brannoch"]
|
||||
.into_iter()
|
||||
.find(|name| text.contains(&format!("Decision owner:\\n{name}\\n")))
|
||||
.expect("a request names its faction");
|
||||
let sequence = served.fetch_add(1, Ordering::SeqCst);
|
||||
let reply = json!({
|
||||
"id": format!("reply-{sequence}"),
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": format!("call-{sequence}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "speak",
|
||||
"arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(),
|
||||
},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": { "prompt_tokens": 120, "completion_tokens": 30 },
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
|
||||
reply.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
|
||||
fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output {
|
||||
verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--ticks",
|
||||
ticks,
|
||||
"--token-cap",
|
||||
cap,
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/scripted",
|
||||
"--base-url",
|
||||
&responder.url(),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_run_prints_acts_and_spend() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let made = genesis(state.path());
|
||||
assert!(made.status.success(), "{}", stderr(&made));
|
||||
let made = stdout(&made);
|
||||
assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}");
|
||||
assert!(state.path().join("world.cc").is_file());
|
||||
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
|
||||
// Two ticks, each with both factions named by verb, and the spend as it
|
||||
// accrues against the cap.
|
||||
assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}");
|
||||
for tick in 0..2u64 {
|
||||
let spent = PER_CALL * 2 * (tick + 1);
|
||||
assert!(
|
||||
text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
" tokens: {} this tick, {spent} spent, {} remaining\n",
|
||||
PER_CALL * 2,
|
||||
1_000_000 - spent
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for faction in ["Adrasteia", "Brannoch"] {
|
||||
assert_eq!(
|
||||
text.matches(&format!(" {faction} speak [")).count(),
|
||||
2,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}");
|
||||
}
|
||||
assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 4);
|
||||
|
||||
// The state root holds each store the run wrote.
|
||||
for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] {
|
||||
assert!(state.path().join(file).is_file(), "{file} was not written");
|
||||
}
|
||||
|
||||
// A second run is the next in the world and carries on from its clock.
|
||||
let again = run(state.path(), &responder, "1", "1000000");
|
||||
assert!(again.status.success(), "{}", stderr(&again));
|
||||
let text = stdout(&again);
|
||||
assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}");
|
||||
assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "3", "1000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}");
|
||||
assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_fails_says_so_and_exits_nonzero() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::refusing(500);
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(!output.status.success());
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(
|
||||
text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
responder.served.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the run stopped at the first refusal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let responder = Responder::start();
|
||||
|
||||
// No world yet.
|
||||
let output = run(state.path(), &responder, "1", "1000");
|
||||
assert!(!output.status.success());
|
||||
assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output));
|
||||
|
||||
assert!(genesis(state.path()).status.success());
|
||||
// A second genesis does not overwrite the first.
|
||||
assert!(!genesis(state.path()).status.success());
|
||||
|
||||
let secret = "sk-not-a-real-key-0000";
|
||||
let key = state.path().join(secret);
|
||||
let cases: [(Vec<&str>, &str); 9] = [
|
||||
(vec![], "usage"),
|
||||
(vec!["fly"], "unknown command"),
|
||||
(
|
||||
vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"],
|
||||
"--token-cap is required",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "many",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "0",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--token-cap is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--model names no inference lane",
|
||||
),
|
||||
(
|
||||
// A hosted model needs its key file; a missing one is named by flag.
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x",
|
||||
"--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"--key-file is unreadable",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"a local model takes no --key-file",
|
||||
),
|
||||
];
|
||||
for (arguments, expected) in cases {
|
||||
let output = verse(&arguments);
|
||||
assert!(!output.status.success(), "{arguments:?} succeeded");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains(expected), "{arguments:?}: {error}");
|
||||
assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}");
|
||||
assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let output = verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.path().to_str().unwrap(),
|
||||
"--ticks",
|
||||
"1",
|
||||
"--token-cap",
|
||||
"1000",
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/x",
|
||||
"--base-url",
|
||||
"http://10.77.0.99:4242",
|
||||
]);
|
||||
assert!(!output.status.success());
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--base-url"), "{error}");
|
||||
assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}");
|
||||
// The lane failed before any run was claimed.
|
||||
assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output));
|
||||
}
|
||||
|
||||
/// A start refused after its claim ends the claimed record Failed, so the run
|
||||
/// binds nothing. Reached from the command by removing the run records while
|
||||
/// the call ledger still holds run 1 under another cap.
|
||||
#[test]
|
||||
fn a_start_the_ledger_refuses_ends_its_claimed_record_failed() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("state");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let responder = Responder::start();
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
std::fs::remove_file(state.join("runs.cc")).unwrap();
|
||||
|
||||
let refused = run(&state, &responder, "1", "500000");
|
||||
assert!(!refused.status.success());
|
||||
|
||||
let world = {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let (mailbox, task) = ghostlight::WorldMailbox::open(state.join("world.cc")).unwrap();
|
||||
let world = runtime.block_on(mailbox.snapshot()).unwrap().world_id;
|
||||
drop(mailbox);
|
||||
runtime.block_on(task).unwrap();
|
||||
world
|
||||
};
|
||||
let runs = ghostlight::VerseRunStore::open(state.join("runs.cc"))
|
||||
.unwrap()
|
||||
.runs(world)
|
||||
.unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(
|
||||
runs[0].status,
|
||||
ghostlight::RunStatus::Failed {
|
||||
reason: "start_refused".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A store that cannot open names the flag and the fault, never the path.
|
||||
#[test]
|
||||
fn a_store_that_cannot_open_never_echoes_the_state_path() {
|
||||
let responder = Responder::start();
|
||||
for file in ["world.cc", "controller-work.cc"] {
|
||||
for held in [false, true] {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("distinctivestatename");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
|
||||
let target = state.join(file);
|
||||
let _guard: Box<dyn std::any::Any> = if !held {
|
||||
std::fs::write(&target, b"not a database").unwrap();
|
||||
Box::new(())
|
||||
} else if file == "world.cc" {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let entered = runtime.enter();
|
||||
let opened = ghostlight::WorldMailbox::open(&target).unwrap();
|
||||
drop(entered);
|
||||
Box::new((runtime, opened))
|
||||
} else {
|
||||
Box::new(ghostlight::open_controller_work(&target).unwrap())
|
||||
};
|
||||
|
||||
let output = run(&state, &responder, "1", "1000000");
|
||||
assert!(!output.status.success(), "{file} held={held}");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--state"), "{file} held={held}: {error}");
|
||||
assert!(
|
||||
!error.contains("distinctivestatename"),
|
||||
"{file} held={held} echoed the path: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
crates/ghostlight/Cargo.toml
Normal file
34
crates/ghostlight/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "ghostlight"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
codex-connector.workspace = true
|
||||
cultcache-rs.workspace = true
|
||||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
rmpv = "1.3"
|
||||
serde.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-properties = "0.1.4"
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[features]
|
||||
# Exposes `open_local_port_with_timeout` to a consumer's tests. Production
|
||||
# builds never enable it.
|
||||
test-support = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
2858
crates/ghostlight/src/action.rs
Normal file
2858
crates/ghostlight/src/action.rs
Normal file
File diff suppressed because it is too large
Load diff
297
crates/ghostlight/src/aetheria.rs
Normal file
297
crates/ghostlight/src/aetheria.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
//! Aetheria's catalog file, read once into the two things Ghostlight takes from
|
||||
//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records
|
||||
//! keyed for import.
|
||||
//!
|
||||
//! The catalog is Aetheria's: this reader opens it read-only and never writes
|
||||
//! it, and it ignores every schema it does not mirror. The mirrors below decode
|
||||
//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction`
|
||||
//! v1 by integer key: a MessagePack-keyed object is an array indexed by key,
|
||||
//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored
|
||||
//! and a field it drops is an error naming the record and the key. A record
|
||||
//! that does not decode, an enum value outside the mirror and a verb that
|
||||
//! breaks the grammar's name or role rules are errors, never skipped rows.
|
||||
|
||||
use crate::ImportKey;
|
||||
use crate::grammar::{
|
||||
GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath,
|
||||
};
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use rmpv::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar";
|
||||
const VERB_SCHEMA: &str = "aetheria.verse_verb";
|
||||
const FACTION_SCHEMA: &str = "aetheria.faction";
|
||||
|
||||
const VERB_NAME_MAX: usize = 48;
|
||||
const ROLE_NAME_MAX: usize = 32;
|
||||
/// The invoking subject, never a role.
|
||||
const ACTOR_ROLE: &str = "actor";
|
||||
|
||||
/// What Ghostlight takes from Aetheria's catalog.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaCatalog {
|
||||
pub grammar: GrammarBinding,
|
||||
/// In record-key order.
|
||||
pub factions: Vec<AetheriaFaction>,
|
||||
}
|
||||
|
||||
/// One `aetheria.faction` record. `allegiance` is carried and read by no
|
||||
/// consumer yet.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaFaction {
|
||||
pub key: ImportKey,
|
||||
pub name: String,
|
||||
pub short_name: String,
|
||||
pub description: String,
|
||||
pub allegiance: BTreeMap<ImportKey, f32>,
|
||||
}
|
||||
|
||||
/// Which of the grammar's name or role rules a verb broke.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerbRule {
|
||||
#[error("verb name is not canonical")]
|
||||
Name,
|
||||
#[error("role name is not canonical")]
|
||||
RoleName,
|
||||
#[error("role name is reserved for the invoking subject")]
|
||||
ReservedRole,
|
||||
#[error("role name is declared twice")]
|
||||
DuplicateRole,
|
||||
}
|
||||
|
||||
/// Why a catalog could not be read. Records are named by record key and fields
|
||||
/// by integer key; no error carries a value the catalog held.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CatalogReadError {
|
||||
#[error("the catalog file cannot be read")]
|
||||
Unreadable,
|
||||
#[error("the catalog holds no aetheria.verse_grammar global")]
|
||||
NoGrammar,
|
||||
#[error("the catalog holds more than one aetheria.verse_grammar record")]
|
||||
MultipleGrammars,
|
||||
#[error("record {record:?} field {field} is missing or not the mirrored type")]
|
||||
MalformedRecord { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a canonical import key")]
|
||||
NonCanonicalKey { record: String, field: u8 },
|
||||
#[error("record {record:?} is a second aetheria.faction under one record key")]
|
||||
DuplicateFaction { record: String },
|
||||
#[error("record {record:?} field {field} is not a known render path")]
|
||||
UnknownRenderPath { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a known referent kind")]
|
||||
UnknownReferentKind { record: String, field: u8 },
|
||||
#[error("verb record {record:?} breaks a grammar rule: {rule}")]
|
||||
InvalidVerb { record: String, rule: VerbRule },
|
||||
#[error("the verbs do not bind a grammar: {0}")]
|
||||
Grammar(#[from] GrammarError),
|
||||
}
|
||||
|
||||
/// Reads the catalog at `path` without writing it. An absent file is
|
||||
/// `Unreadable`, not an empty catalog.
|
||||
pub fn read_aetheria_catalog(path: impl AsRef<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?;
|
||||
let mut rows = SingleFileMessagePackBackingStore::new(path)
|
||||
.pull_all_read_only_snapshot()
|
||||
.map_err(|_| CatalogReadError::Unreadable)?;
|
||||
rows.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
|
||||
let mut revision = None;
|
||||
let mut verbs = Vec::new();
|
||||
let mut factions = Vec::new();
|
||||
for row in &rows {
|
||||
match row.r#type.as_str() {
|
||||
GRAMMAR_SCHEMA => {
|
||||
if revision.is_some() {
|
||||
return Err(CatalogReadError::MultipleGrammars);
|
||||
}
|
||||
let cells = Cells::of(&row.key, &row.payload)?;
|
||||
revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?);
|
||||
}
|
||||
VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?),
|
||||
FACTION_SCHEMA => {
|
||||
let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?;
|
||||
if factions
|
||||
.last()
|
||||
.is_some_and(|seen: &AetheriaFaction| seen.key == faction.key)
|
||||
{
|
||||
return Err(CatalogReadError::DuplicateFaction {
|
||||
record: row.key.clone(),
|
||||
});
|
||||
}
|
||||
factions.push(faction);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let revision = revision.ok_or(CatalogReadError::NoGrammar)?;
|
||||
Ok(AetheriaCatalog {
|
||||
grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?,
|
||||
factions,
|
||||
})
|
||||
}
|
||||
|
||||
/// A record's payload: the positional array of its keyed fields.
|
||||
struct Cells<'a> {
|
||||
record: &'a str,
|
||||
cells: Vec<Value>,
|
||||
}
|
||||
|
||||
impl<'a> Cells<'a> {
|
||||
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
|
||||
let mut payload = payload;
|
||||
match rmpv::decode::read_value(&mut payload) {
|
||||
Ok(Value::Array(cells)) => Ok(Self { record, cells }),
|
||||
_ => Err(CatalogReadError::MalformedRecord {
|
||||
record: record.to_owned(),
|
||||
field: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::MalformedRecord {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_canonical(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::NonCanonicalKey {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> {
|
||||
self.cells
|
||||
.get(usize::from(field))
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn int(&self, field: u8) -> Result<i64, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_i64()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
/// A string Aetheria may leave null; null reads as empty.
|
||||
fn text_or_empty(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
match self.cell(field)? {
|
||||
Value::Nil => Ok(String::new()),
|
||||
Value::String(text) => text
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field)),
|
||||
_ => Err(self.malformed(field)),
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&self, field: u8) -> Result<&Vec<Value>, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_array()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> {
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: cells.record.to_owned(),
|
||||
rule,
|
||||
};
|
||||
let name = cells.text(1)?;
|
||||
if !canonical_name(&name, VERB_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::Name));
|
||||
}
|
||||
let render_path = match cells.int(2)? {
|
||||
1 => RenderPath::ShipAction,
|
||||
2 => RenderPath::Conversation,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownRenderPath {
|
||||
record: cells.record.to_owned(),
|
||||
field: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut roles: Vec<GrammarRole> = Vec::new();
|
||||
for role in cells.array(3)? {
|
||||
let role = Cells {
|
||||
record: cells.record,
|
||||
cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?,
|
||||
};
|
||||
let role_name = role.text(1).map_err(|_| cells.malformed(3))?;
|
||||
if !canonical_name(&role_name, ROLE_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::RoleName));
|
||||
}
|
||||
if role_name == ACTOR_ROLE {
|
||||
return Err(invalid(VerbRule::ReservedRole));
|
||||
}
|
||||
if roles.iter().any(|seen| seen.role == role_name) {
|
||||
return Err(invalid(VerbRule::DuplicateRole));
|
||||
}
|
||||
let referent = match role.int(2).map_err(|_| cells.malformed(3))? {
|
||||
1 => GrammarReferentKind::Person,
|
||||
2 => GrammarReferentKind::Faction,
|
||||
3 => GrammarReferentKind::Population,
|
||||
4 => GrammarReferentKind::Place,
|
||||
5 => GrammarReferentKind::Cargo,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownReferentKind {
|
||||
record: cells.record.to_owned(),
|
||||
field: 3,
|
||||
});
|
||||
}
|
||||
};
|
||||
roles.push(GrammarRole {
|
||||
role: role_name,
|
||||
referent,
|
||||
});
|
||||
}
|
||||
Ok((name, GrammarVerb { render_path, roles }))
|
||||
}
|
||||
|
||||
/// A lower-case letter, then lower-case letters, digits and underscores.
|
||||
fn canonical_name(name: &str, max_len: usize) -> bool {
|
||||
let mut chars = name.chars();
|
||||
name.len() <= max_len
|
||||
&& chars.next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||||
}
|
||||
|
||||
fn read_faction(key: &str, cells: &Cells) -> Result<AetheriaFaction, CatalogReadError> {
|
||||
let own = ImportKey::new(FACTION_SCHEMA, key);
|
||||
if !own.is_canonical() {
|
||||
return Err(cells.non_canonical(0));
|
||||
}
|
||||
let Some(allegiance) = cells.cell(12)?.as_map() else {
|
||||
return Err(cells.malformed(12));
|
||||
};
|
||||
let mut standing = BTreeMap::new();
|
||||
for (other, weight) in allegiance {
|
||||
let other = other.as_str().ok_or_else(|| cells.malformed(12))?;
|
||||
let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?;
|
||||
let other = ImportKey::new(FACTION_SCHEMA, other);
|
||||
if !other.is_canonical() {
|
||||
return Err(cells.non_canonical(12));
|
||||
}
|
||||
standing.insert(other, weight as f32);
|
||||
}
|
||||
Ok(AetheriaFaction {
|
||||
key: own,
|
||||
name: cells.text(1)?,
|
||||
short_name: cells.text_or_empty(2)?,
|
||||
description: cells.text_or_empty(3)?,
|
||||
allegiance: standing,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
use super::*;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`)
|
||||
/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`.
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn fixture_rows() -> Vec<CultCacheEnvelope> {
|
||||
SingleFileMessagePackBackingStore::new(FIXTURE)
|
||||
.pull_all_read_only_snapshot()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope {
|
||||
rows.iter_mut().find(|row| row.key == key).unwrap()
|
||||
}
|
||||
|
||||
/// Re-encodes one record's positional payload after `edit`.
|
||||
fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec<Value>)) {
|
||||
let row = row_of(rows, key);
|
||||
let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else {
|
||||
panic!("{key} is not a positional record");
|
||||
};
|
||||
change(&mut cells);
|
||||
row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap();
|
||||
}
|
||||
|
||||
fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
for row in rows {
|
||||
store.push(row).unwrap();
|
||||
}
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn read(rows: &[CultCacheEnvelope]) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let (_dir, path) = catalog_of(rows);
|
||||
read_aetheria_catalog(&path)
|
||||
}
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What Aetheria's fixture writer authored, spelled out by hand.
|
||||
fn authored_verbs() -> Vec<(String, GrammarVerb)> {
|
||||
vec![
|
||||
(
|
||||
"haul".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("cargo", GrammarReferentKind::Cargo),
|
||||
("destination", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"attack".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("target", GrammarReferentKind::Faction)],
|
||||
),
|
||||
),
|
||||
("speak".into(), verb(RenderPath::Conversation, &[])),
|
||||
]
|
||||
}
|
||||
|
||||
fn faction_key(record: &str) -> ImportKey {
|
||||
ImportKey::new("aetheria.faction", record)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_aetheria_written_fixture() {
|
||||
let catalog = read_aetheria_catalog(FIXTURE).unwrap();
|
||||
|
||||
let identity = catalog.grammar.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.verse_grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
for (kind, expected) in authored_verbs() {
|
||||
assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}");
|
||||
}
|
||||
assert!(catalog.grammar.verb("mine").is_none());
|
||||
|
||||
assert_eq!(
|
||||
catalog.factions,
|
||||
vec![
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-adrasteia"),
|
||||
name: "Adrasteia".into(),
|
||||
short_name: "ADR".into(),
|
||||
description: "First.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]),
|
||||
},
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-brannoch"),
|
||||
name: "Brannoch".into(),
|
||||
short_name: "BRN".into(),
|
||||
description: "Second.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_matches_a_binding_built_by_hand() {
|
||||
let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap();
|
||||
let read = read_aetheria_catalog(FIXTURE).unwrap().grammar;
|
||||
assert_eq!(read.identity().digest, by_hand.identity().digest);
|
||||
assert_eq!(read, by_hand);
|
||||
|
||||
// The digest follows the verbs: a role the file drops is a different grammar.
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[3].as_array_mut().unwrap().pop();
|
||||
});
|
||||
assert_ne!(
|
||||
read_catalog_digest(&rows),
|
||||
by_hand.identity().digest,
|
||||
"dropping a role did not move the digest"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String {
|
||||
read(rows).unwrap().grammar.identity().digest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_without_a_grammar() {
|
||||
let mut rows = fixture_rows();
|
||||
rows.retain(|row| row.r#type != "aetheria.verse_grammar");
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_second_grammar_record() {
|
||||
let mut rows = fixture_rows();
|
||||
let mut second = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.clone();
|
||||
second.key.push_str("-again");
|
||||
rows.push(second);
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_render_path() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3));
|
||||
let error = read(&rows).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
CatalogReadError::UnknownRenderPath {
|
||||
record: "verb-haul".into(),
|
||||
field: 2
|
||||
}
|
||||
);
|
||||
assert!(!error.to_string().contains('3'), "the value was echoed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_referent_kind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(9);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::UnknownReferentKind {
|
||||
record: "verb-attack".into(),
|
||||
field: 3
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() {
|
||||
let broken = |mutate: fn(&mut Vec<Value>)| {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", mutate);
|
||||
read(&rows).unwrap_err()
|
||||
};
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: "verb-haul".into(),
|
||||
rule,
|
||||
};
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("Haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("1haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("h".repeat(49))),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("Cargo")),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("c".repeat(33))),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("actor")),
|
||||
invalid(VerbRule::ReservedRole)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][1][1] = json!("cargo")),
|
||||
invalid(VerbRule::DuplicateRole)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_canonical_names_are_admitted() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[1] = json!(format!("h{}9", "_9".repeat(23)));
|
||||
cells[3][0][1] = json!(format!("c{}", "z".repeat(31)));
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.grammar.identity().revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_verbs_with_one_name_do_not_bind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack"));
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() {
|
||||
let malformed = |record: &str, field| CatalogReadError::MalformedRecord {
|
||||
record: record.into(),
|
||||
field,
|
||||
};
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").payload =
|
||||
rmp_serde::to_vec(&json!("not an array")).unwrap();
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[1] = Value::Null
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({"faction-adrasteia": "heavy"});
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12));
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
let grammar = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.key
|
||||
.clone();
|
||||
edit(&mut rows, &grammar, |cells| cells[1] = json!(-1));
|
||||
assert_eq!(read(&rows), Err(malformed(&grammar, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-speak", |cells| cells.push(json!("later")));
|
||||
edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7)));
|
||||
let mut stranger = rows[0].clone();
|
||||
stranger.key = "ship-1".into();
|
||||
stranger.r#type = "aetheria.ship".into();
|
||||
stranger.schema_id = Some("sha256:not-mirrored".into());
|
||||
stranger.payload = vec![0xc1];
|
||||
rows.push(stranger);
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_short_name_or_description_reads_as_empty() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-adrasteia", |cells| {
|
||||
cells[2] = Value::Null;
|
||||
cells[3] = Value::Null;
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.factions[0].short_name, "");
|
||||
assert_eq!(catalog.factions[0].description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_file_is_unreadable_not_an_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(dir.path().join("missing.cc")),
|
||||
Err(CatalogReadError::Unreadable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_leaves_the_catalog_and_its_directory_untouched() {
|
||||
let (dir, path) = catalog_of(&fixture_rows());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let listing = |dir: &std::path::Path| {
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let before = listing(dir.path());
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(listing(dir.path()), before, "the reader created a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_referent_code_maps_to_its_own_kind() {
|
||||
// The fixture binds only Faction, Cargo and Place; Person and Population
|
||||
// are authored here at the role's integer key, each read back as itself.
|
||||
for (code, expected) in [
|
||||
(1, GrammarReferentKind::Person),
|
||||
(2, GrammarReferentKind::Faction),
|
||||
(3, GrammarReferentKind::Population),
|
||||
(4, GrammarReferentKind::Place),
|
||||
(5, GrammarReferentKind::Cargo),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(code)
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(
|
||||
catalog.grammar.verb("attack"),
|
||||
Some(&verb(RenderPath::ShipAction, &[("target", expected)])),
|
||||
"code {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn faction_with_key(record: &str) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").key = record.into();
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in [
|
||||
"faction-cafe\u{301}",
|
||||
"faction-brannoch ",
|
||||
"faction-\u{202e}brannoch",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
read(&faction_with_key(bad)),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: bad.into(),
|
||||
field: 0
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({ bad: 0.5 });
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's snapshot bytes with the record rows changed by `change`, for
|
||||
/// what the store's own writer will not produce: it keeps one row per record
|
||||
/// key and stores rows in key order.
|
||||
fn spliced_catalog(
|
||||
change: impl FnOnce(&mut Vec<rmpv::Value>),
|
||||
) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let bytes = std::fs::read(FIXTURE).unwrap();
|
||||
let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("snapshot is not a tuple");
|
||||
};
|
||||
let rmpv::Value::Array(records) = &mut snapshot[2] else {
|
||||
panic!("snapshot holds no records");
|
||||
};
|
||||
change(records);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut spliced = Vec::new();
|
||||
rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap();
|
||||
std::fs::write(&path, spliced).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faction_rows_under_one_record_key_are_refused() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
let twin = records
|
||||
.iter()
|
||||
.find(|record| record[0].as_str() == Some("faction-adrasteia"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
records.push(twin);
|
||||
});
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(&path),
|
||||
Err(CatalogReadError::DuplicateFaction {
|
||||
record: "faction-adrasteia".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factions_are_read_in_record_key_order_whatever_the_file_order() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str()));
|
||||
assert_eq!(records[0][0].as_str(), Some("verb-speak"));
|
||||
});
|
||||
let names: Vec<_> = read_aetheria_catalog(&path)
|
||||
.unwrap()
|
||||
.factions
|
||||
.into_iter()
|
||||
.map(|faction| faction.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["Adrasteia", "Brannoch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() {
|
||||
for not_a_map in [
|
||||
json!([]),
|
||||
json!(["faction-adrasteia"]),
|
||||
Value::Null,
|
||||
json!(1),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
let shape = not_a_map.clone();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{not_a_map}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]`
|
||||
/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1).
|
||||
#[test]
|
||||
fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, "faction-adrasteia");
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
cells.push(rmpv::Value::Binary(vec![1, 0, 7]));
|
||||
cells.push(rmpv::Value::Ext(
|
||||
-1,
|
||||
vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80],
|
||||
));
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// A faction record re-encoded with `change` applied to its decoded cells, for
|
||||
/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes.
|
||||
fn with_cells(key: &str, change: impl FnOnce(&mut Vec<rmpv::Value>)) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, key);
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
change(&mut cells);
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() {
|
||||
for key in [
|
||||
rmpv::Value::from(5),
|
||||
rmpv::Value::Nil,
|
||||
rmpv::Value::Binary(b"faction-adrasteia".to_vec()),
|
||||
] {
|
||||
let rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_is_not_utf8_is_malformed_by_key_and_field() {
|
||||
// The marker is overwritten in the encoded bytes with an invalid sequence of
|
||||
// the same length, since a decoded string cannot hold one.
|
||||
const MARKER: &str = "ZZZZ";
|
||||
for field in [1usize, 3] {
|
||||
let mut rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[field] = rmpv::Value::from(MARKER);
|
||||
});
|
||||
let payload = &mut row_of(&mut rows, "faction-brannoch").payload;
|
||||
let at = payload
|
||||
.windows(MARKER.len())
|
||||
.position(|window| window == MARKER.as_bytes())
|
||||
.unwrap();
|
||||
payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: field as u8
|
||||
}),
|
||||
"field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
213
crates/ghostlight/src/clock.rs
Normal file
213
crates/ghostlight/src/clock.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! The world clock and the motion one tick produces.
|
||||
//!
|
||||
//! One entry point into the reducer, [`derive_motion`], reached from `reduce`'s
|
||||
//! `AdvanceTime` arm and from `apply_effect`'s `TimeAdvanced` arm, so a forged
|
||||
//! tick is re-derived by the same function that produced the honest one. It is
|
||||
//! pure over `(state, to)`: no wall clock, no entropy, no caller value beyond
|
||||
//! the span.
|
||||
|
||||
use super::patch::{Commitment, CommitmentKey, CommitmentKind, DependencyTarget, MAX_ROUTE_COST};
|
||||
use super::{PressureMagnitude, PressureSource, SubjectId, WorldState, action, is_retired};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Minutes since genesis. Monotonic, kernel-owned, world-level. `u64` because a
|
||||
/// world clock has no bound; `Cost` is `1..=MAX_ROUTE_COST` because it weighs
|
||||
/// one edge, and reusing it here would put an edge's bound on the world's age.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct FictionalMinutes(pub u64);
|
||||
|
||||
impl FictionalMinutes {
|
||||
pub(crate) fn checked_add(self, span: TickMinutes) -> Option<Self> {
|
||||
self.0.checked_add(u64::from(span.0)).map(Self)
|
||||
}
|
||||
|
||||
/// Minutes elapsed since an earlier reading. Saturating because the caller
|
||||
/// is the attention order, which reads a stamp `verify_state_shape` already
|
||||
/// holds to `<= now`.
|
||||
pub(crate) fn since(self, earlier: Self) -> u64 {
|
||||
self.0.saturating_sub(earlier.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The span one `AdvanceTime` names. Constructor-checked `1..=MAX_ROUTE_COST`
|
||||
/// (one year of minutes), reusing pass 2's constant as a span bound rather than
|
||||
/// an edge bound: an unbounded span lets one command jump the clock past every
|
||||
/// due date in the world and makes the size of a commit a caller-chosen number.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct TickMinutes(u32);
|
||||
|
||||
impl TickMinutes {
|
||||
pub fn new(minutes: u32) -> Option<Self> {
|
||||
(1..=MAX_ROUTE_COST)
|
||||
.contains(&minutes)
|
||||
.then_some(Self(minutes))
|
||||
}
|
||||
|
||||
pub fn minutes(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one tick moves, stated as outcomes rather than deltas so the
|
||||
/// effect says what the world became.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Motion {
|
||||
/// Routines that fulfilled, in `(subject, key)` order.
|
||||
pub(crate) fulfilled: Vec<RoutineFulfilled>,
|
||||
/// Pressure rows written, in `(target, source)` order, with the resulting
|
||||
/// magnitude — not the delta.
|
||||
pub(crate) pressed: Vec<PressureWritten>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RoutineFulfilled {
|
||||
pub(crate) subject: SubjectId,
|
||||
pub(crate) key: CommitmentKey,
|
||||
pub(crate) next_due: FictionalMinutes,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PressureWritten {
|
||||
pub(crate) target: SubjectId,
|
||||
pub(crate) source: PressureSource,
|
||||
pub(crate) magnitude: PressureMagnitude,
|
||||
}
|
||||
|
||||
/// The whole magnitude policy, in one place, with no per-world tuning. A
|
||||
/// blocked routine raises none at all: it retries on the next tick, which is
|
||||
/// what keeps an unattended subject free of spurious pressure.
|
||||
mod step {
|
||||
pub(super) const OBLIGATION_PAST_DUE: u32 = 1;
|
||||
pub(super) const GOAL_PAST_DUE: u32 = 1;
|
||||
pub(super) const DEPENDENCY_UNAVAILABLE: u32 = 1;
|
||||
}
|
||||
|
||||
/// Three stages, each reading only committed `state` and `to`. No stage sees
|
||||
/// another stage's writes, which is what makes this a one-pass total function
|
||||
/// with no ordering hazard and no partial state to reason about.
|
||||
///
|
||||
/// Pressure accrues per command, not per minute: one thousand one-minute ticks
|
||||
/// and one thousand-minute tick over the same span produce different
|
||||
/// magnitudes. The kernel stays an exact function of the command sequence, and
|
||||
/// the tick cadence is one operational constant in `runtime.rs`.
|
||||
pub(super) fn derive_motion(state: &WorldState, to: FictionalMinutes) -> Motion {
|
||||
let mut fulfilled = Vec::new();
|
||||
let mut pressed: BTreeMap<(SubjectId, PressureSource), u32> = BTreeMap::new();
|
||||
|
||||
for (subject, held) in &state.commitments {
|
||||
// A retired subject cannot act, and neither fulfilling a routine nor
|
||||
// pressing an obligation or goal is anything but that subject acting:
|
||||
// its commitments stay in history, un-discharged and un-deleted, but
|
||||
// the clock stops moving them the moment they are its.
|
||||
if is_retired(state, *subject) {
|
||||
continue;
|
||||
}
|
||||
for (key, commitment) in held {
|
||||
if commitment.due > to {
|
||||
continue;
|
||||
}
|
||||
match commitment.kind {
|
||||
CommitmentKind::Routine => {
|
||||
if let Some(next_due) = routine_rolls(state, *subject, commitment) {
|
||||
fulfilled.push(RoutineFulfilled {
|
||||
subject: *subject,
|
||||
key: *key,
|
||||
next_due,
|
||||
});
|
||||
}
|
||||
}
|
||||
CommitmentKind::Obligation | CommitmentKind::Goal => {
|
||||
let step = if commitment.kind == CommitmentKind::Obligation {
|
||||
step::OBLIGATION_PAST_DUE
|
||||
} else {
|
||||
step::GOAL_PAST_DUE
|
||||
};
|
||||
let source = PressureSource::Commitment {
|
||||
subject: *subject,
|
||||
key: *key,
|
||||
};
|
||||
*pressed.entry((*subject, source)).or_default() += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (subject, targets) in &state.dependencies {
|
||||
if is_retired(state, *subject) {
|
||||
continue;
|
||||
}
|
||||
for target in targets {
|
||||
if dependency_unavailable(state, *subject, *target) {
|
||||
let source = PressureSource::Dependency(*target);
|
||||
*pressed.entry((*subject, source)).or_default() += step::DEPENDENCY_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Motion {
|
||||
fulfilled,
|
||||
pressed: pressed
|
||||
.into_iter()
|
||||
.map(|((target, source), step)| PressureWritten {
|
||||
target,
|
||||
source,
|
||||
magnitude: PressureMagnitude(
|
||||
state
|
||||
.pressures
|
||||
.get(&target)
|
||||
.and_then(|held| held.get(&source))
|
||||
.map_or(0, |magnitude| magnitude.0)
|
||||
.saturating_add(step),
|
||||
),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A routine fulfils when every one of its checks holds, and its `due` then
|
||||
/// rolls forward by exactly one period per command. Catch-up arithmetic would
|
||||
/// make the number of fulfilments a function of the operator's tick cadence in
|
||||
/// a second place; one roll converges over subsequent ticks.
|
||||
fn routine_rolls(
|
||||
state: &WorldState,
|
||||
subject: SubjectId,
|
||||
commitment: &Commitment,
|
||||
) -> Option<FictionalMinutes> {
|
||||
if !action::preconditions_hold(state, subject, &commitment.checks).is_empty() {
|
||||
return None;
|
||||
}
|
||||
commitment.due.checked_add(commitment.period?)
|
||||
}
|
||||
|
||||
/// Whether a subject's dependency is currently failing it. A closed route is a
|
||||
/// supply failure; an unusable one is a permission question, and reading
|
||||
/// authority here would drag `effective_authority` into every tick for every
|
||||
/// dependency in the world.
|
||||
pub(super) fn dependency_unavailable(
|
||||
state: &WorldState,
|
||||
subject: SubjectId,
|
||||
target: DependencyTarget,
|
||||
) -> bool {
|
||||
match target {
|
||||
DependencyTarget::Route(edge_id) => !state
|
||||
.edges
|
||||
.get(&edge_id)
|
||||
.is_some_and(super::EdgeRecord::is_open),
|
||||
// Absence is zero, per pass 3: the depender holds none of what it
|
||||
// depends on.
|
||||
DependencyTarget::Resource(entity_id) => !state
|
||||
.holdings
|
||||
.get(&subject)
|
||||
.is_some_and(|held| held.contains_key(&entity_id)),
|
||||
// The subject it depends on is nowhere in the world's topology.
|
||||
DependencyTarget::Subject(other) => !state.positions.contains_key(&other),
|
||||
}
|
||||
}
|
||||
2660
crates/ghostlight/src/consumer.rs
Normal file
2660
crates/ghostlight/src/consumer.rs
Normal file
File diff suppressed because it is too large
Load diff
20977
crates/ghostlight/src/controllers.rs
Normal file
20977
crates/ghostlight/src/controllers.rs
Normal file
File diff suppressed because it is too large
Load diff
1105
crates/ghostlight/src/cover.rs
Normal file
1105
crates/ghostlight/src/cover.rs
Normal file
File diff suppressed because it is too large
Load diff
2761
crates/ghostlight/src/elaboration.rs
Normal file
2761
crates/ghostlight/src/elaboration.rs
Normal file
File diff suppressed because it is too large
Load diff
209
crates/ghostlight/src/grammar.rs
Normal file
209
crates/ghostlight/src/grammar.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//! The action grammar a world is bound to at creation.
|
||||
//!
|
||||
//! A grammar is the closed set of affordance kinds a world may declare, each
|
||||
//! with its role signature and the one render path its acts take. The grammar's
|
||||
//! home is the Aetheria repo; the kernel holds only this binding: the verbs it
|
||||
//! enforces, and a content digest that names exactly which grammar that was.
|
||||
//! The digest is derived here and nowhere else, so a binding cannot claim a
|
||||
//! digest its verbs do not have.
|
||||
|
||||
use crate::SubjectKind;
|
||||
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How an act of one verb reaches the player: filmed in the engine as a ship
|
||||
/// action, or shown as Aetheria's portrait-and-choices conversation.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderPath {
|
||||
ShipAction,
|
||||
Conversation,
|
||||
}
|
||||
|
||||
/// What a grammar role may be bound to, in the grammar's own vocabulary. Each
|
||||
/// lowers to exactly one kernel `RefKind`.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrammarReferentKind {
|
||||
Person,
|
||||
Faction,
|
||||
Population,
|
||||
Place,
|
||||
Cargo,
|
||||
}
|
||||
|
||||
impl GrammarReferentKind {
|
||||
fn ref_kind(self) -> RefKind {
|
||||
match self {
|
||||
Self::Person => RefKind::Subject(Some(SubjectKind::Person)),
|
||||
Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
Self::Population => RefKind::Subject(Some(SubjectKind::Population)),
|
||||
Self::Place => RefKind::Entity(EntityKind::Place),
|
||||
Self::Cargo => RefKind::Entity(EntityKind::Resource),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarRole {
|
||||
pub role: String,
|
||||
pub referent: GrammarReferentKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarVerb {
|
||||
pub render_path: RenderPath,
|
||||
/// In signature order: the order is part of the verb.
|
||||
pub roles: Vec<GrammarRole>,
|
||||
}
|
||||
|
||||
impl GrammarVerb {
|
||||
/// Whether a declared role list is this verb's signature: same names, same
|
||||
/// referent kinds, same order.
|
||||
pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool {
|
||||
self.role_specs() == roles
|
||||
}
|
||||
|
||||
/// This verb's signature as the kernel declares it: the one lowering of a
|
||||
/// grammar role, so declaring a verb and admitting a declaration cannot
|
||||
/// disagree.
|
||||
pub(crate) fn role_specs(&self) -> Vec<RoleSpec> {
|
||||
self.roles
|
||||
.iter()
|
||||
.map(|role| RoleSpec {
|
||||
role: Role(role.role.clone()),
|
||||
kind: role.referent.ref_kind(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a bound grammar refuses an affordance entry.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GrammarRefusal {
|
||||
KindNotInGrammar,
|
||||
RolesDisagree,
|
||||
SpeakNotConversation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum GrammarError {
|
||||
#[error("grammar schema id is empty")]
|
||||
EmptySchemaId,
|
||||
#[error("grammar declares one verb kind twice")]
|
||||
DuplicateVerb,
|
||||
#[error("grammar digest does not match its verbs")]
|
||||
DigestMismatch,
|
||||
}
|
||||
|
||||
/// Which grammar a world is bound to: its schema id, revision and content
|
||||
/// digest. What a consumer reads; the verb table stays in the binding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GrammarIdentity {
|
||||
pub schema_id: String,
|
||||
pub revision: u32,
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
/// The grammar a world was created under. Immutable: the only constructor
|
||||
/// derives the digest, and deserialisation re-derives it and refuses a mismatch.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(try_from = "RawBinding")]
|
||||
pub struct GrammarBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
impl TryFrom<RawBinding> for GrammarBinding {
|
||||
type Error = GrammarError;
|
||||
|
||||
fn try_from(raw: RawBinding) -> Result<Self, GrammarError> {
|
||||
let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?;
|
||||
if bound.digest != raw.digest {
|
||||
return Err(GrammarError::DigestMismatch);
|
||||
}
|
||||
Ok(bound)
|
||||
}
|
||||
}
|
||||
|
||||
impl GrammarBinding {
|
||||
/// The one constructor. Verbs arrive in any order and are keyed by kind;
|
||||
/// the digest is the sha256 of the canonical encoding of the kind-sorted
|
||||
/// verbs, so the same verbs bind to the same digest however they were listed.
|
||||
pub fn new(
|
||||
schema_id: impl Into<String>,
|
||||
revision: u32,
|
||||
verbs: impl IntoIterator<Item = (String, GrammarVerb)>,
|
||||
) -> Result<Self, GrammarError> {
|
||||
let schema_id = schema_id.into();
|
||||
if schema_id.is_empty() {
|
||||
return Err(GrammarError::EmptySchemaId);
|
||||
}
|
||||
let mut table = BTreeMap::new();
|
||||
for (kind, verb) in verbs {
|
||||
if table.insert(kind, verb).is_some() {
|
||||
return Err(GrammarError::DuplicateVerb);
|
||||
}
|
||||
}
|
||||
let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes");
|
||||
let digest = format!("sha256:{:x}", Sha256::digest(encoded));
|
||||
Ok(Self {
|
||||
schema_id,
|
||||
revision,
|
||||
digest,
|
||||
verbs: table,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> GrammarIdentity {
|
||||
GrammarIdentity {
|
||||
schema_id: self.schema_id.clone(),
|
||||
revision: self.revision,
|
||||
digest: self.digest.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every verb, in kind order.
|
||||
pub(crate) fn verbs(&self) -> impl Iterator<Item = (&str, &GrammarVerb)> {
|
||||
self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb))
|
||||
}
|
||||
|
||||
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
|
||||
self.verbs.get(kind)
|
||||
}
|
||||
|
||||
/// The one grammar check. The resolver turns a refusal into a mismatch and
|
||||
/// `admit_resolved`, the only writer of the affordance catalog, into an
|
||||
/// invariant error, so no path adds an entry without passing it.
|
||||
pub(crate) fn refusal(
|
||||
&self,
|
||||
kind: &AffordanceKindName,
|
||||
roles: &[RoleSpec],
|
||||
) -> Option<GrammarRefusal> {
|
||||
let Some(verb) = self.verb(&kind.0) else {
|
||||
return Some(GrammarRefusal::KindNotInGrammar);
|
||||
};
|
||||
if !verb.admits_roles(roles) {
|
||||
return Some(GrammarRefusal::RolesDisagree);
|
||||
}
|
||||
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
|
||||
return Some(GrammarRefusal::SpeakNotConversation);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
745
crates/ghostlight/src/grammar/tests.rs
Normal file
745
crates/ghostlight/src/grammar/tests.rs
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
use super::*;
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind,
|
||||
Mismatch, OutcomeBand, Precondition, Role, RoleSpec,
|
||||
};
|
||||
use crate::tests::{auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt,
|
||||
WorldId, WorldKernel, WorldPatch,
|
||||
};
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fixture world's genesis needs: the kernel speak entry and the
|
||||
/// fixture's own `convene`. Anything else a test wants is added to it.
|
||||
fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding {
|
||||
let mut verbs = vec![
|
||||
("speak".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
verbs.extend(
|
||||
extra
|
||||
.iter()
|
||||
.map(|(kind, v)| ((*kind).to_owned(), v.clone())),
|
||||
);
|
||||
GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap()
|
||||
}
|
||||
|
||||
fn bound(grammar: Option<GrammarBinding>, title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
world.grammar = grammar;
|
||||
world
|
||||
}
|
||||
|
||||
fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration {
|
||||
Declaration::Affordance(AffordanceDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
kind: AffordanceKindName(kind.into()),
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, kind)| RoleSpec {
|
||||
role: Role((*role).into()),
|
||||
kind: *kind,
|
||||
})
|
||||
.collect(),
|
||||
// Speech with one audience is the smallest entry the resolver does
|
||||
// not refuse as inert; the grammar check does not read it.
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
carries_speech: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each call gets its own store path: kernels are created, not reopened.
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch,
|
||||
},
|
||||
),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
}
|
||||
|
||||
fn person() -> RefKind {
|
||||
RefKind::Subject(Some(SubjectKind::Person))
|
||||
}
|
||||
|
||||
fn place() -> RefKind {
|
||||
RefKind::Entity(EntityKind::Place)
|
||||
}
|
||||
|
||||
fn not_in_grammar(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceKindNotInGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
fn disagrees(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceRolesDisagreeWithGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() {
|
||||
// Genesis: a declared kind the binding lacks is refused.
|
||||
let mut world = bound(Some(grammar_with(&[])), "Outside");
|
||||
world.patch.declarations.push(declare("wave", "wave", &[]));
|
||||
assert_eq!(rejected(create(world).1), not_in_grammar("wave"));
|
||||
|
||||
// A later AdmitPatch: the same kind is refused, and a verb the grammar
|
||||
// does carry is admitted by the same path.
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("who", GrammarReferentKind::Person),
|
||||
("where_", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for (label, roles) in [
|
||||
("renamed", vec![("whom", person()), ("where_", place())]),
|
||||
("rekinded", vec![("who", person()), ("where_", person())]),
|
||||
("swapped", vec![("where_", place()), ("who", person())]),
|
||||
("short", vec![("who", person())]),
|
||||
(
|
||||
"long",
|
||||
vec![("who", person()), ("where_", place()), ("why", person())],
|
||||
),
|
||||
] {
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(label, "meet", &roles)]),
|
||||
));
|
||||
assert_eq!(set, disagrees(label), "{label}");
|
||||
}
|
||||
// A role on a verb the grammar declares with none.
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
));
|
||||
assert_eq!(set, disagrees("hail"));
|
||||
// The exact signature is admitted.
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(
|
||||
"meet",
|
||||
"meet",
|
||||
&[("who", person()), ("where_", place())],
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() {
|
||||
let kinds = [
|
||||
(GrammarReferentKind::Person, person()),
|
||||
(
|
||||
GrammarReferentKind::Faction,
|
||||
RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
),
|
||||
(
|
||||
GrammarReferentKind::Population,
|
||||
RefKind::Subject(Some(SubjectKind::Population)),
|
||||
),
|
||||
(GrammarReferentKind::Place, place()),
|
||||
(
|
||||
GrammarReferentKind::Cargo,
|
||||
RefKind::Entity(EntityKind::Resource),
|
||||
),
|
||||
];
|
||||
for (referent, own) in &kinds {
|
||||
let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))];
|
||||
for (_, offered) in &kinds {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let result = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("act", "act", &[("with", *offered)])]),
|
||||
);
|
||||
if offered == own {
|
||||
assert!(
|
||||
matches!(result, Ok(SubmitReceipt::Applied(_))),
|
||||
"{referent:?} refused its own kind"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
rejected(result),
|
||||
disagrees("act"),
|
||||
"{referent:?} admitted {offered:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_without_speak_refuses_the_kernel_speak_grant() {
|
||||
let no_speak = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[("convene".to_owned(), verb(RenderPath::Conversation, &[]))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(no_speak), "Mute")).1),
|
||||
not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
|
||||
// The kernel entry is held to the signature as well as the kind.
|
||||
let loud = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
(
|
||||
"speak".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(loud), "Loud")).1),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_world_keeps_the_open_catalog() {
|
||||
let mut world = bound(None, "Open");
|
||||
world
|
||||
.patch
|
||||
.declarations
|
||||
.push(declare("wave", "wave", &[("at", person())]));
|
||||
let (_dir, kernel) = create(world);
|
||||
let mut kernel = kernel.unwrap();
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("beacon", "beacon", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert!(snapshot.grammar.is_none());
|
||||
assert!(snapshot.affordances.len() >= 4);
|
||||
assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none()));
|
||||
|
||||
// The state row of a world with no grammar carries no grammar key, so its
|
||||
// bytes and digests are those of a world written before grammars existed.
|
||||
let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
let keys: std::collections::BTreeMap<String, serde::de::IgnoredAny> =
|
||||
rmp_serde::from_slice(&encoded).unwrap();
|
||||
assert!(!keys.contains_key("grammar"));
|
||||
assert!(keys.contains_key("brief"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() {
|
||||
let extra = [("dock", verb(RenderPath::ShipAction, &[]))];
|
||||
let grammar = grammar_with(&extra);
|
||||
let identity = grammar.identity();
|
||||
let (_dir, kernel) = create(bound(Some(grammar), "Reports"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert_eq!(snapshot.grammar, Some(identity));
|
||||
let path_of = |kind: &str| {
|
||||
snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == kind)
|
||||
.unwrap_or_else(|| panic!("{kind} is in the catalog"))
|
||||
.render_path
|
||||
};
|
||||
assert_eq!(path_of("dock"), Some(RenderPath::ShipAction));
|
||||
assert_eq!(path_of("convene"), Some(RenderPath::Conversation));
|
||||
assert_eq!(path_of("speak"), Some(RenderPath::Conversation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_grammar_world_still_holds_its_grammar() {
|
||||
let grammar = grammar_with(&[]);
|
||||
let identity = grammar.identity();
|
||||
let (directory, kernel) = create(bound(Some(grammar), "Reopened"));
|
||||
let kernel = kernel.unwrap();
|
||||
let world_id = kernel.snapshot().unwrap().world_id;
|
||||
drop(kernel);
|
||||
let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap();
|
||||
assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity));
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut reopened,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bound_grammar_is_part_of_the_world_state_digest() {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested"));
|
||||
let kernel = kernel.unwrap();
|
||||
let bound_digest = crate::state_digest(&kernel.state).unwrap();
|
||||
let mut state = kernel.state.clone();
|
||||
state.grammar = None;
|
||||
let open_digest = crate::state_digest(&state).unwrap();
|
||||
state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))]));
|
||||
let other_digest = crate::state_digest(&state).unwrap();
|
||||
assert_ne!(bound_digest, open_digest);
|
||||
assert_ne!(bound_digest, other_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_is_derived_and_order_free() {
|
||||
let forward = [
|
||||
("alpha".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
(
|
||||
"beta".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("gamma".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let mut reversed = forward.clone();
|
||||
reversed.reverse();
|
||||
let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap();
|
||||
let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap();
|
||||
assert_eq!(a.digest, b.digest);
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Content-derived: each part of a verb moves the digest.
|
||||
let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| {
|
||||
let mut verbs = forward.to_vec();
|
||||
edit(&mut verbs);
|
||||
GrammarBinding::new("aetheria.grammar", 1, verbs)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[0].1.render_path = RenderPath::Conversation)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| {
|
||||
v.pop();
|
||||
})
|
||||
);
|
||||
// Role order is part of a verb.
|
||||
let ordered = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[("meet".to_owned(), verb(RenderPath::ShipAction, roles))],
|
||||
)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
ordered(&[
|
||||
("a", GrammarReferentKind::Person),
|
||||
("b", GrammarReferentKind::Place)
|
||||
]),
|
||||
ordered(&[
|
||||
("b", GrammarReferentKind::Place),
|
||||
("a", GrammarReferentKind::Person)
|
||||
])
|
||||
);
|
||||
// The identity reads the same fields back.
|
||||
let identity = a.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
assert_eq!(identity.digest, a.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_grammar_digest_fails_to_deserialise() {
|
||||
let binding = grammar_with(&[]);
|
||||
let value = serde_json::to_value(&binding).unwrap();
|
||||
// Untouched: the round trip holds.
|
||||
let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(back, binding);
|
||||
|
||||
// A digest the verbs do not have.
|
||||
let mut forged = value.clone();
|
||||
forged["digest"] = "sha256:00".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(forged.clone()).is_err());
|
||||
|
||||
// A verb edited under the digest it already had.
|
||||
let mut edited = value;
|
||||
edited["verbs"]["convene"]["render_path"] = "ship_action".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(edited).is_err());
|
||||
|
||||
// The named encoding a state row uses refuses it the same way.
|
||||
let bytes = rmp_serde::to_vec_named(&forged).unwrap();
|
||||
assert!(rmp_serde::from_slice::<GrammarBinding>(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_names_each_verb_once_and_has_a_schema() {
|
||||
assert_eq!(
|
||||
GrammarBinding::new("", 1, Vec::new()).unwrap_err(),
|
||||
GrammarError::EmptySchemaId
|
||||
);
|
||||
assert_eq!(
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[
|
||||
("hail".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("hail".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
]
|
||||
)
|
||||
.unwrap_err(),
|
||||
GrammarError::DuplicateVerb
|
||||
);
|
||||
}
|
||||
|
||||
/// The fixture world was written by the code at base 367e2eb, which has no
|
||||
/// grammar field anywhere. Reopening it must reproduce the digests that code
|
||||
/// recorded.
|
||||
#[test]
|
||||
fn existing_world_reopens_with_unchanged_digests() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap();
|
||||
let world_id: WorldId =
|
||||
serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap();
|
||||
let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens");
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let recorded = include_str!("../../tests/fixtures/base_world.digests");
|
||||
let mut lines = recorded.lines();
|
||||
assert_eq!(snapshot.revision.to_string(), lines.next().unwrap());
|
||||
assert_eq!(snapshot.state_digest, lines.next().unwrap());
|
||||
assert_eq!(
|
||||
snapshot.last_commit_digest.as_deref(),
|
||||
Some(lines.next().unwrap())
|
||||
);
|
||||
assert!(snapshot.grammar.is_none());
|
||||
// Its state still derives the recorded digest from its bytes.
|
||||
assert_eq!(
|
||||
crate::state_digest(&kernel.state).unwrap(),
|
||||
snapshot.state_digest
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<crate::PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
/// A bound Draft world whose revision has moved past genesis: one owner patch
|
||||
/// that adds a dead-end place the elaborator can answer.
|
||||
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let commons = *kernel.state.entities.keys().next().unwrap();
|
||||
admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(crate::patch::EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(crate::patch::RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: crate::patch::Ref::Existing(commons),
|
||||
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: crate::patch::AccessKind::Public,
|
||||
cost: crate::patch::Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
type Door = (&'static str, CallerId, Option<crate::PatchAnswer>);
|
||||
|
||||
/// Each door refuses an outside kind and a wrong role signature, typed, and the
|
||||
/// refusal leaves the revision where it was. The owner and the consumer declare
|
||||
/// unanswered only in Draft; Play and the elaborator act in Active.
|
||||
fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec<Door>) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let kind = submit_as(
|
||||
kernel,
|
||||
caller.clone(),
|
||||
answers.clone(),
|
||||
patch_of(vec![declare("wave", "wave", &[])]),
|
||||
);
|
||||
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
|
||||
let roles = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
);
|
||||
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
|
||||
assert_eq!(kernel.state.revision, 1);
|
||||
let draft_doors = vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
(
|
||||
"consumer",
|
||||
CallerId::System(crate::SystemCapability::Consumer {
|
||||
consumer: crate::ConsumerId::of_name("grammar-door"),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, draft_doors);
|
||||
crate::tests::activate(&mut kernel);
|
||||
assert!(
|
||||
kernel.state.revision > 1,
|
||||
"the world has moved past genesis"
|
||||
);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
let active_doors = vec![
|
||||
(
|
||||
"play",
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(crate::SystemCapability::Elaborator {
|
||||
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(crate::PatchAnswer::Boundary(answer)),
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, active_doors);
|
||||
// The same path admits what the grammar carries, so the refusals above are
|
||||
// the grammar's and not a closed door.
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![declare("hail", "hail", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
|
||||
let ship = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
|
||||
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
|
||||
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
|
||||
}]
|
||||
);
|
||||
// A ShipAction speak with a role fails on the signature first and is still
|
||||
// refused: no variant of speak but zero roles, Conversation, is admitted.
|
||||
assert_eq!(
|
||||
rejected(
|
||||
create(bound(
|
||||
Some(ship(&[("to", GrammarReferentKind::Person)])),
|
||||
"Loud"
|
||||
))
|
||||
.1
|
||||
),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
// The control: Conversation speak binds.
|
||||
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
|
||||
}
|
||||
|
||||
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
|
||||
/// a world with no grammar must not install an entry the bound grammar lacks:
|
||||
/// the catalog's only writer holds the grammar check itself.
|
||||
#[test]
|
||||
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("who", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged"));
|
||||
let kernel = kernel.unwrap();
|
||||
let mut twin = kernel.state.clone();
|
||||
twin.grammar = None;
|
||||
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
|
||||
crate::patch::resolve_patch(
|
||||
&twin,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![declare("probe", kind, roles)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the unbound twin admits any canonical kind")
|
||||
};
|
||||
for (label, resolved, admitted) in [
|
||||
("outside kind", resolve("wave", &[]), false),
|
||||
("wrong roles", resolve("meet", &[("whom", person())]), false),
|
||||
("carried verb", resolve("hail", &[]), true),
|
||||
] {
|
||||
let mut state = kernel.state.clone();
|
||||
let catalog = state.affordance_catalog.len();
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
},
|
||||
);
|
||||
if admitted {
|
||||
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
|
||||
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{label}: {result:?}"
|
||||
);
|
||||
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
752
crates/ghostlight/src/import_key_tests.rs
Normal file
752
crates/ghostlight/src/import_key_tests.rs
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
//! One record, one subject: admission keeps the import-key mapping injective at
|
||||
//! every door and at every revision, and a world nobody imported into is
|
||||
//! byte-for-byte what it was before keys existed.
|
||||
|
||||
use crate::patch::{
|
||||
AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind,
|
||||
ImportKey, Mismatch, Ref, RouteDeclaration,
|
||||
};
|
||||
use crate::tests::{activate, auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey,
|
||||
KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt,
|
||||
SystemCapability, WorldKernel, WorldPatch,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn key(schema: &str, record: &str) -> ImportKey {
|
||||
ImportKey::new(schema, record)
|
||||
}
|
||||
|
||||
fn alpha() -> ImportKey {
|
||||
key("aetheria.ship", "Alpha")
|
||||
}
|
||||
|
||||
/// The fixture genesis with its witness imported from `alpha()`.
|
||||
fn keyed_creation(title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& subject.handle == DraftHandle::new("persona")
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn keyed_world() -> (tempfile::TempDir, WorldKernel) {
|
||||
let (dir, kernel) = create(keyed_creation("Keyed"));
|
||||
(dir, kernel.unwrap())
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
fn commons(kernel: &WorldKernel) -> EntityId {
|
||||
*kernel.state.entities.keys().next().unwrap()
|
||||
}
|
||||
|
||||
/// A mirror subject: the one shape every door may declare, since it needs no
|
||||
/// catalog grant.
|
||||
fn mirror(handle: &str, import_key: Option<ImportKey>, place: EntityId) -> Declaration {
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: format!("The {handle}"),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::External {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
},
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(place)),
|
||||
import_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn held(handle: &str) -> Mismatch {
|
||||
Mismatch::ImportKeyHeld {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(handle: &str) -> Mismatch {
|
||||
Mismatch::DuplicateImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(handle: &str) -> Mismatch {
|
||||
Mismatch::MalformedImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn keys_in(kernel: &WorldKernel) -> Vec<ImportKey> {
|
||||
kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_patch_cannot_declare_two_subjects_with_one_import_key() {
|
||||
// At genesis.
|
||||
let mut world = creation(CommandId::new(), "Twins");
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& (subject.handle == DraftHandle::new("persona")
|
||||
|| subject.handle == DraftHandle::new("operator"))
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
let (_dir, result) = create(world);
|
||||
assert_eq!(rejected(result), vec![duplicate("operator")]);
|
||||
|
||||
// In a later patch, with the key unheld.
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let beta = Some(key("aetheria.ship", "Beta"));
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
mirror("first", beta.clone(), place),
|
||||
mirror("second", beta, place),
|
||||
]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![duplicate("second")]);
|
||||
assert_eq!(kernel.state.revision, revision, "a refusal moved the world");
|
||||
}
|
||||
|
||||
/// The fixture world past genesis but still in Draft: the owner has added an
|
||||
/// unwalked place the elaborator can later answer.
|
||||
fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) {
|
||||
let (dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: Ref::Existing(place),
|
||||
to: Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: AccessKind::Public,
|
||||
cost: Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
/// Each door names the held key, typed, and the refusal leaves the revision
|
||||
/// where it was. The owner and the consumer declare in Draft only; Play and the
|
||||
/// elaborator act in Active.
|
||||
fn refuse_held_key_at_each_door(
|
||||
kernel: &mut WorldKernel,
|
||||
place: EntityId,
|
||||
doors: Vec<(&str, CallerId, Option<PatchAnswer>)>,
|
||||
) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let result = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![mirror("copy", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(result), vec![held("copy")], "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn consumer() -> CallerId {
|
||||
CallerId::System(SystemCapability::Consumer {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_keyed_world();
|
||||
assert!(kernel.state.revision > 0, "the world has moved past genesis");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
("consumer", consumer(), None),
|
||||
],
|
||||
);
|
||||
activate(&mut kernel);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("play", CallerId::System(SystemCapability::Play), None),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer.clone())),
|
||||
),
|
||||
],
|
||||
);
|
||||
// The same doors admit an unheld key, so the refusals are the key's; and
|
||||
// what a door admitted is held against every later door.
|
||||
let fresh = key("aetheria.ship", "Gamma");
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer)),
|
||||
patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("fresh-again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_in_a_later_patch() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("again", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("again")]);
|
||||
assert_eq!(kernel.state.revision, revision);
|
||||
assert_eq!(keys_in(&kernel), vec![alpha()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retired_subjects_import_key_is_never_reissued() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let witness = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.import_key.is_some())
|
||||
.unwrap()
|
||||
.id;
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Existing(witness),
|
||||
}],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let retired = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.id == witness)
|
||||
.unwrap();
|
||||
assert!(retired.retired, "the witness is retired");
|
||||
assert_eq!(
|
||||
retired.import_key,
|
||||
Some(alpha()),
|
||||
"a retired subject keeps its key"
|
||||
);
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("reborn", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("reborn")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_that_differ_in_any_way_are_distinct_records() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
// Same length, case, prefix, the same concatenation split elsewhere, and
|
||||
// the parts swapped: none is the held key.
|
||||
let forgeries = [
|
||||
key("aetheria.ship", "Alphb"),
|
||||
key("aetheria.ship", "alpha"),
|
||||
key("aetheria.ship", "Alph"),
|
||||
key("aetheria.ship", "Alpha2"),
|
||||
key("aetheria.shipA", "lpha"),
|
||||
key("aetheria.sh", "ipAlpha"),
|
||||
key("Aetheria.ship", "Alpha"),
|
||||
key("Alpha", "aetheria.ship"),
|
||||
key("aetheria.ship2", "Alpha"),
|
||||
];
|
||||
let declarations = forgeries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("nine distinct keys beside the held one are admitted together");
|
||||
let held_now: BTreeSet<ImportKey> = keys_in(&kernel).into_iter().collect();
|
||||
assert_eq!(held_now.len(), forgeries.len() + 1);
|
||||
// And each is then held exactly: repeating any one is refused.
|
||||
for (index, forged) in forgeries.iter().enumerate() {
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("repeat", Some(forged.clone()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_import_key_is_refused_without_echoing_it() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let long = "x".repeat(201);
|
||||
let wide = "é".repeat(101);
|
||||
let cases = [
|
||||
key("", "Delta"),
|
||||
key("aetheria.ship", ""),
|
||||
key("aetheria ship", "Delta"),
|
||||
key("aetheria.ship", "Del\tta"),
|
||||
key("aetheria.ship", "Del\u{a0}ta"),
|
||||
key("aetheria.ship", " Delta"),
|
||||
key("aetheria.ship", "Delta\n"),
|
||||
key(&long, "Delta"),
|
||||
key("aetheria.ship", &long),
|
||||
key("aetheria.ship", &wide),
|
||||
key(&wide, "Delta"),
|
||||
];
|
||||
for (index, bad) in cases.iter().enumerate() {
|
||||
let set = rejected(submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("bad", Some(bad.clone()), place)]),
|
||||
));
|
||||
assert_eq!(set, vec![malformed("bad")], "case {index}");
|
||||
let spelled = format!("{set:?}");
|
||||
assert!(
|
||||
!spelled.contains("Delta") && !spelled.contains("aetheria"),
|
||||
"case {index} echoed the key"
|
||||
);
|
||||
}
|
||||
// The limit is 200 bytes per part, counted in bytes.
|
||||
let edge = "é".repeat(100);
|
||||
let at_limit = [
|
||||
key(&"x".repeat(200), &edge),
|
||||
key("aetheria.ship", &"y".repeat(200)),
|
||||
];
|
||||
let declarations = at_limit
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("keys of exactly 200 bytes per part are admitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = |handle: &str| {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", "Omega")),
|
||||
place,
|
||||
)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the key is unheld at the revision both were resolved against")
|
||||
};
|
||||
let (first, second) = (resolve("first"), resolve("second"));
|
||||
let mut state = kernel.state.clone();
|
||||
let before = state.subjects.len();
|
||||
let effect = |resolved| crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
};
|
||||
crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(first),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.subjects.len(), before + 1);
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(second),
|
||||
);
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.subjects.len(),
|
||||
before + 1,
|
||||
"the second subject was installed"
|
||||
);
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|window| window == needle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyless_world_digests_unchanged() {
|
||||
// A subject nobody imported serialises without the field at all, so every
|
||||
// digest written before keys existed is the digest of the same bytes.
|
||||
let (_dir, kernel) = create(creation(CommandId::new(), "Keyless"));
|
||||
let kernel = kernel.unwrap();
|
||||
let state = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
assert!(
|
||||
!contains(&state, b"import_key"),
|
||||
"a keyless world grew a field"
|
||||
);
|
||||
// A keyed world carries it, round-trips, and digests differently.
|
||||
let (_dir, keyed) = keyed_world();
|
||||
let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap();
|
||||
assert!(contains(&keyed_state, b"import_key"));
|
||||
let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap();
|
||||
assert_eq!(
|
||||
crate::state_digest(&back).unwrap(),
|
||||
crate::state_digest(&keyed.state).unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
crate::state_digest(&keyed.state).unwrap(),
|
||||
crate::state_digest(&kernel.state).unwrap()
|
||||
);
|
||||
// And a pre-key state deserialises with every key absent.
|
||||
let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap();
|
||||
assert!(stripped.subjects.values().all(|s| s.import_key.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_world_reopens_with_its_keys_and_digests() {
|
||||
let (dir, kernel) = keyed_world();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let world_id = snapshot.world_id;
|
||||
drop(kernel);
|
||||
let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap();
|
||||
let again = reopened.snapshot().unwrap();
|
||||
assert_eq!(again.state_digest, snapshot.state_digest);
|
||||
let keys = keys_in(&reopened);
|
||||
assert_eq!(keys, vec![alpha()]);
|
||||
// A reader of the snapshot gets both parts back, separately.
|
||||
assert_eq!(keys[0].schema(), "aetheria.ship");
|
||||
assert_eq!(keys[0].record(), "Alpha");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_record_key_is_one_canonical_spelling() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let nfc = "Caf\u{e9}";
|
||||
let nfd = "Cafe\u{301}";
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted");
|
||||
let refused = [
|
||||
nfd,
|
||||
"Alpha\u{200b}",
|
||||
"Alpha\u{0}",
|
||||
"\u{feff}Alpha",
|
||||
"Alpha\u{202e}",
|
||||
"Alpha\u{7}",
|
||||
"Del ta",
|
||||
"Del\tta",
|
||||
"Del\u{a0}ta",
|
||||
"Del\u{2003}ta",
|
||||
"Del\nta",
|
||||
// The violation sits deep in a long, real-shaped path, past any prefix.
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}",
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart",
|
||||
];
|
||||
for (index, record) in refused.iter().enumerate() {
|
||||
let set = rejected(submit(&mut kernel, "bad", record));
|
||||
assert_eq!(set, vec![malformed("bad")], "spelling {index}");
|
||||
assert!(
|
||||
!format!("{set:?}").contains("Alpha"),
|
||||
"spelling {index} echoed the key"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
2,
|
||||
"only the genesis key and the NFC key are held"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lore_page_path_with_spaces_is_a_record_key() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart";
|
||||
let declare = |kernel: &mut WorldKernel, handle: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted");
|
||||
assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_state_with_a_bad_import_key_is_refused() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
"beta",
|
||||
Some(key("aetheria.ship", "Beta")),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(crate::journal::verify_state_shape(&kernel.state).is_ok());
|
||||
let corrupt = |edit: &dyn Fn(&mut ImportKey)| {
|
||||
let mut state = kernel.state.clone();
|
||||
for subject in state.subjects.values_mut() {
|
||||
if let Some(held) = subject.import_key.as_mut() {
|
||||
edit(held);
|
||||
}
|
||||
}
|
||||
crate::journal::verify_state_shape(&state)
|
||||
};
|
||||
let shared = corrupt(&|held| *held = alpha());
|
||||
assert!(
|
||||
matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")),
|
||||
"two subjects sharing one key: {shared:?}"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(),
|
||||
"a key with edge spaces must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("", "")).is_err(),
|
||||
"an empty key must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(),
|
||||
"a key with a format character must not load"
|
||||
);
|
||||
}
|
||||
|
||||
/// The admission backstop, reached with an effect resolution never produced: a
|
||||
/// resolved patch whose two subjects carry one key. The control is the same
|
||||
/// effect with distinct keys, admitted by the same function.
|
||||
#[test]
|
||||
fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = || {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![
|
||||
mirror("first", Some(key("aetheria.ship", "Beta")), place),
|
||||
mirror("second", Some(key("aetheria.ship", "Gamma")), place),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("two distinct keys resolve")
|
||||
};
|
||||
let resolves_to = crate::resolution_revision(&kernel.state).unwrap();
|
||||
let mut control = kernel.state.clone();
|
||||
crate::admit_resolved(&mut control, &resolve(), resolves_to)
|
||||
.expect("distinct keys are admitted");
|
||||
|
||||
let mut twinned = resolve();
|
||||
assert_eq!(twinned.subjects.len(), 2);
|
||||
twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone();
|
||||
let mut state = kernel.state.clone();
|
||||
let error = crate::admit_resolved(&mut state, &twinned, resolves_to)
|
||||
.expect_err("one key on two resolved subjects is refused");
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(why) if why.contains("import key")),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key");
|
||||
}
|
||||
|
||||
/// A decomposable character at every position of a real-shaped key: the NFD
|
||||
/// spelling is refused wherever it sits, the NFC spelling is admitted, so the
|
||||
/// rule cannot be a prefix, suffix or first-character check.
|
||||
#[test]
|
||||
fn a_decomposable_character_is_refused_in_nfd_at_every_position() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let template: Vec<char> = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"
|
||||
.chars()
|
||||
.collect();
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: String| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", &record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
let spelled = |position: usize, inserted: &str| {
|
||||
let mut record: String = template[..position].iter().collect();
|
||||
record.push_str(inserted);
|
||||
record.extend(&template[position..]);
|
||||
record
|
||||
};
|
||||
for position in 0..=template.len() {
|
||||
let nfd = spelled(position, "e\u{301}");
|
||||
assert!(
|
||||
!unicode_normalization::is_nfc(&nfd),
|
||||
"position {position} is not NFD"
|
||||
);
|
||||
let set = rejected(submit(&mut kernel, "bad", nfd));
|
||||
assert_eq!(set, vec![malformed("bad")], "NFD at position {position}");
|
||||
}
|
||||
for position in 0..=template.len() {
|
||||
let nfc = spelled(position, "\u{e9}");
|
||||
submit(&mut kernel, &format!("good{position}"), nfc)
|
||||
.unwrap_or_else(|_| panic!("NFC at position {position} was refused"));
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
1 + template.len() + 1,
|
||||
"the genesis key and one NFC key per position are held, no NFD key"
|
||||
);
|
||||
}
|
||||
2990
crates/ghostlight/src/journal.rs
Normal file
2990
crates/ghostlight/src/journal.rs
Normal file
File diff suppressed because it is too large
Load diff
379
crates/ghostlight/src/lens.rs
Normal file
379
crates/ghostlight/src/lens.rs
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
//! The stock lenses. A lens is the flavor an elaborator session reaches for:
|
||||
//! prompt emphasis over the one fixed tool catalog, never admission, authority,
|
||||
//! or quota. This module owns the stock table, each lens's instruction text,
|
||||
//! and the draw recipe; it reads no world state and writes nothing.
|
||||
|
||||
use super::{CommandId, KernelError, WorldId, digest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The sentence every lens's instructions begin with.
|
||||
const ELABORATION_INSTRUCTIONS: &str = "Use only the supplied tools to author structure inside your jurisdiction. Answer the boundary or deficit you were given, then submit. Recording a gap changes nothing.";
|
||||
|
||||
/// The library's stock lens set. Serialized by snake_case name, so a name the
|
||||
/// library does not know cannot be represented at any deserialization boundary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Lens {
|
||||
Patina,
|
||||
Charter,
|
||||
Ledger,
|
||||
Hearth,
|
||||
Tangle,
|
||||
Veil,
|
||||
Ember,
|
||||
Numen,
|
||||
}
|
||||
|
||||
impl Lens {
|
||||
/// Every lens, in declaration order. The draw walks this order.
|
||||
pub const ALL: [Lens; 8] = [
|
||||
Lens::Patina,
|
||||
Lens::Charter,
|
||||
Lens::Ledger,
|
||||
Lens::Hearth,
|
||||
Lens::Tangle,
|
||||
Lens::Veil,
|
||||
Lens::Ember,
|
||||
Lens::Numen,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Lens::Patina => "Patina",
|
||||
Lens::Charter => "Charter",
|
||||
Lens::Ledger => "Ledger",
|
||||
Lens::Hearth => "Hearth",
|
||||
Lens::Tangle => "Tangle",
|
||||
Lens::Veil => "Veil",
|
||||
Lens::Ember => "Ember",
|
||||
Lens::Numen => "Numen",
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of structure the lens reaches for.
|
||||
pub(crate) fn reaches_for(self) -> &'static str {
|
||||
match self {
|
||||
Lens::Patina => "ordinary objects, customs, nicknames, local jokes",
|
||||
Lens::Charter => "government, law, offices, selection, succession, redress",
|
||||
Lens::Ledger => "resources, labor, trade, infrastructure, scarcity, class pressure",
|
||||
Lens::Hearth => "kinship, daily life, care, obligation, belonging",
|
||||
Lens::Tangle => "factions, alliances, rivalries, leverage",
|
||||
Lens::Veil => "secrets, rumors, misinformation, taboos, unevenly held knowledge",
|
||||
Lens::Ember => "disputes, hazards, instability, escalation, urgent pressure",
|
||||
Lens::Numen => "religion, magic, ritual, awe, cosmology, the genuinely strange",
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing `PATCH_TOOLS` names in emphasis order. Emphasis is prompt text
|
||||
/// only: the catalog, its order, and its schemas are the same for every lens.
|
||||
pub(crate) fn leads_with(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Lens::Patina => &[
|
||||
"set_persona_material",
|
||||
"declare_affordance",
|
||||
"declare_resource",
|
||||
"declare_place",
|
||||
],
|
||||
Lens::Charter => &[
|
||||
"grant_authority",
|
||||
"open_office",
|
||||
"install_incumbent",
|
||||
"open_forum",
|
||||
"declare_subject",
|
||||
],
|
||||
Lens::Ledger => &[
|
||||
"transfer",
|
||||
"bind",
|
||||
"declare_resource",
|
||||
"alter_cost",
|
||||
"declare_route",
|
||||
],
|
||||
Lens::Hearth => &[
|
||||
"create_commitment",
|
||||
"set_persona_material",
|
||||
"declare_subject",
|
||||
],
|
||||
Lens::Tangle => &[
|
||||
"advance_pressure",
|
||||
"create_commitment",
|
||||
"grant_authority",
|
||||
"declare_subject",
|
||||
"bind",
|
||||
],
|
||||
Lens::Veil => &[
|
||||
"acquire_knowledge",
|
||||
"communicate",
|
||||
"declare_channel",
|
||||
"set_reach",
|
||||
"declare_fact",
|
||||
"forget",
|
||||
],
|
||||
Lens::Ember => &[
|
||||
"advance_pressure",
|
||||
"create_commitment",
|
||||
"close_route",
|
||||
"consume",
|
||||
],
|
||||
Lens::Numen => &[
|
||||
"declare_fact",
|
||||
"declare_affordance",
|
||||
"set_persona_material",
|
||||
"witness",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The elaborator's instructions under this lens: the shared sentence, then
|
||||
/// the lens clause. Read once, when a session is first built; the session
|
||||
/// then carries the text, and nothing rebuilds it from here.
|
||||
pub(crate) fn instructions(self) -> String {
|
||||
format!(
|
||||
"{ELABORATION_INSTRUCTIONS} Lens: {}. Reach for {}. Lead with {}; every supplied tool remains available.",
|
||||
self.name(),
|
||||
self.reaches_for(),
|
||||
self.leads_with().join(", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A world's lens weights. Validity is decided where weights are admitted;
|
||||
/// `Default` is the empty map a world holds before genesis writes its weights,
|
||||
/// and it never draws.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LensWeights(BTreeMap<Lens, u32>);
|
||||
|
||||
impl LensWeights {
|
||||
pub fn new(weights: BTreeMap<Lens, u32>) -> Self {
|
||||
Self(weights)
|
||||
}
|
||||
|
||||
/// A lens the map does not name weighs zero.
|
||||
pub fn get(&self, lens: Lens) -> u32 {
|
||||
self.0.get(&lens).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Lens, u32)> + '_ {
|
||||
self.0.iter().map(|(lens, weight)| (*lens, *weight))
|
||||
}
|
||||
|
||||
/// Some weight is nonzero, so a draw has somewhere to land.
|
||||
pub(crate) fn draws(&self) -> bool {
|
||||
self.0.values().any(|weight| *weight > 0)
|
||||
}
|
||||
|
||||
/// Every stock lens weighs the same in both sets. A lens a map does not
|
||||
/// name weighs zero, so `{charter: 3}` and `{patina: 0, charter: 3}` are
|
||||
/// one set spelled two ways: they draw identically and neither replaces
|
||||
/// the other.
|
||||
pub(crate) fn weighs_the_same_as(&self, other: &LensWeights) -> bool {
|
||||
Lens::ALL
|
||||
.into_iter()
|
||||
.all(|lens| self.get(lens) == other.get(lens))
|
||||
}
|
||||
}
|
||||
|
||||
/// The draw's whole input: the world and the session's fixed identity. The
|
||||
/// weights select over it; they are not part of it.
|
||||
#[derive(Serialize)]
|
||||
struct LensPreimage {
|
||||
world_id: WorldId,
|
||||
command_id: CommandId,
|
||||
}
|
||||
|
||||
/// One digest, one modulo, one cumulative walk over `Lens::ALL` — the recipe
|
||||
/// `select_band` uses, not a second entropy shape. The same world, session
|
||||
/// identity, and weights draw the same lens. Modulo rather than rejection
|
||||
/// sampling: the bias is bounded by `total_weight / 2^64`.
|
||||
pub(crate) fn draw(
|
||||
weights: &LensWeights,
|
||||
world_id: WorldId,
|
||||
command_id: CommandId,
|
||||
) -> Result<Lens, KernelError> {
|
||||
// The guard is on the divisor itself, as `select_band`'s is: no other
|
||||
// statement of "these weights draw" can let the modulo below divide by zero.
|
||||
let total: u128 = Lens::ALL
|
||||
.iter()
|
||||
.map(|lens| u128::from(weights.get(*lens)))
|
||||
.sum();
|
||||
if total == 0 {
|
||||
return Err(KernelError::Invariant("lens weights never draw".into()));
|
||||
}
|
||||
let preimage = digest(&LensPreimage {
|
||||
world_id,
|
||||
command_id,
|
||||
})?;
|
||||
let head = preimage
|
||||
.strip_prefix("sha256:")
|
||||
.and_then(|hex| hex.get(..16))
|
||||
.and_then(|head| u64::from_str_radix(head, 16).ok())
|
||||
.ok_or_else(|| KernelError::Invariant("lens draw digest is not hex".into()))?;
|
||||
let mut position = u128::from(head) % total;
|
||||
for lens in Lens::ALL {
|
||||
let weight = u128::from(weights.get(lens));
|
||||
if position < weight {
|
||||
return Ok(lens);
|
||||
}
|
||||
position -= weight;
|
||||
}
|
||||
Err(KernelError::Invariant(
|
||||
"lens weights did not cover the draw".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::patch::PATCH_TOOLS;
|
||||
use crate::tests::stock_weights;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn ids() -> Vec<CommandId> {
|
||||
(0..200)
|
||||
.map(|i| CommandId::derived("l1-probe", &[&i.to_string()]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tally(weights: &LensWeights) -> BTreeMap<Lens, usize> {
|
||||
let mut counts = BTreeMap::new();
|
||||
for id in ids() {
|
||||
let lens = draw(weights, WorldId::nil_for_test(), id).unwrap();
|
||||
*counts.entry(lens).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_lead_names_a_patch_tool() {
|
||||
let catalog: BTreeSet<&str> = PATCH_TOOLS.iter().map(|entry| entry.name).collect();
|
||||
for lens in Lens::ALL {
|
||||
let leads = lens.leads_with();
|
||||
assert!(!leads.is_empty(), "{lens:?} leads with nothing");
|
||||
let distinct: BTreeSet<&str> = leads.iter().copied().collect();
|
||||
assert_eq!(distinct.len(), leads.len(), "{lens:?} repeats a lead");
|
||||
for name in leads {
|
||||
assert!(
|
||||
catalog.contains(name),
|
||||
"{lens:?} leads with unknown tool {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_draw_is_a_function_of_world_and_session() {
|
||||
let weights = stock_weights();
|
||||
let world = WorldId::nil_for_test();
|
||||
for id in ids() {
|
||||
assert_eq!(
|
||||
draw(&weights, world, id).unwrap(),
|
||||
draw(&weights, world, id).unwrap()
|
||||
);
|
||||
}
|
||||
let expected: BTreeMap<Lens, usize> = BTREE_STOCK_TALLY.into_iter().collect();
|
||||
assert_eq!(tally(&weights), expected);
|
||||
}
|
||||
|
||||
/// Computed at Cut 1 and pinned: the replay proof for 200 derived ids under
|
||||
/// stock weights.
|
||||
const BTREE_STOCK_TALLY: [(Lens, usize); 8] = [
|
||||
(Lens::Patina, 22),
|
||||
(Lens::Charter, 37),
|
||||
(Lens::Ledger, 26),
|
||||
(Lens::Hearth, 19),
|
||||
(Lens::Tangle, 23),
|
||||
(Lens::Veil, 29),
|
||||
(Lens::Ember, 18),
|
||||
(Lens::Numen, 26),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn a_zero_weight_never_draws() {
|
||||
let weights = LensWeights::new(BTreeMap::from([
|
||||
(Lens::Patina, 0),
|
||||
(Lens::Charter, 3),
|
||||
(Lens::Ledger, 1),
|
||||
]));
|
||||
let counts = tally(&weights);
|
||||
assert_eq!(counts.get(&Lens::Patina), None, "a zero weight drew");
|
||||
let expected: BTreeMap<Lens, usize> = ZERO_WEIGHT_TALLY.into_iter().collect();
|
||||
assert_eq!(counts, expected);
|
||||
}
|
||||
|
||||
/// Computed at Cut 1 and pinned; near 3:1 over the same 200 ids.
|
||||
const ZERO_WEIGHT_TALLY: [(Lens, usize); 2] = [(Lens::Charter, 155), (Lens::Ledger, 45)];
|
||||
|
||||
#[test]
|
||||
fn all_zero_weights_are_an_error() {
|
||||
let world = WorldId::nil_for_test();
|
||||
let id = CommandId::derived("l1-probe", &["0"]);
|
||||
let all_zero = LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect());
|
||||
assert_eq!(all_zero.iter().count(), 8);
|
||||
for weights in [LensWeights::default(), all_zero] {
|
||||
assert!(matches!(
|
||||
draw(&weights, world, id),
|
||||
Err(KernelError::Invariant(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_lens_name_does_not_deserialize() {
|
||||
for refused in [
|
||||
r#"{"Patina":1}"#,
|
||||
r#"{"patina ":1}"#,
|
||||
r#"{"patinaa":1}"#,
|
||||
r#"{"patina":1,"tribunal":1}"#,
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_str::<LensWeights>(refused).is_err(),
|
||||
"{refused} deserialized"
|
||||
);
|
||||
}
|
||||
let accepted = serde_json::from_str::<LensWeights>(r#"{"patina":1}"#).unwrap();
|
||||
assert_eq!(accepted.get(Lens::Patina), 1);
|
||||
assert_eq!(accepted.iter().count(), 1);
|
||||
|
||||
let stock = stock_weights();
|
||||
let bytes = rmp_serde::to_vec_named(&stock).unwrap();
|
||||
let back: LensWeights = rmp_serde::from_slice(&bytes).unwrap();
|
||||
assert_eq!(back, stock);
|
||||
assert_eq!(rmp_serde::to_vec_named(&back).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_lens_text_names_its_own_leads() {
|
||||
let mut texts = BTreeSet::new();
|
||||
for lens in Lens::ALL {
|
||||
let text = lens.instructions();
|
||||
assert!(text.contains(lens.name()), "{lens:?} text omits its name");
|
||||
for name in lens.leads_with() {
|
||||
assert!(text.contains(name), "{lens:?} text omits {name}");
|
||||
}
|
||||
texts.insert(text);
|
||||
}
|
||||
assert_eq!(texts.len(), 8, "two lenses share a text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lenses_serialize_as_snake_case_names() {
|
||||
let names: Vec<String> = Lens::ALL
|
||||
.iter()
|
||||
.map(|lens| serde_json::to_string(lens).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
r#""patina""#,
|
||||
r#""charter""#,
|
||||
r#""ledger""#,
|
||||
r#""hearth""#,
|
||||
r#""tangle""#,
|
||||
r#""veil""#,
|
||||
r#""ember""#,
|
||||
r#""numen""#,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
19173
crates/ghostlight/src/lib.rs
Normal file
19173
crates/ghostlight/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
2265
crates/ghostlight/src/local_inference.rs
Normal file
2265
crates/ghostlight/src/local_inference.rs
Normal file
File diff suppressed because it is too large
Load diff
1647
crates/ghostlight/src/mailbox.rs
Normal file
1647
crates/ghostlight/src/mailbox.rs
Normal file
File diff suppressed because it is too large
Load diff
9606
crates/ghostlight/src/patch.rs
Normal file
9606
crates/ghostlight/src/patch.rs
Normal file
File diff suppressed because it is too large
Load diff
2178
crates/ghostlight/src/sdk_inference.rs
Normal file
2178
crates/ghostlight/src/sdk_inference.rs
Normal file
File diff suppressed because it is too large
Load diff
3135
crates/ghostlight/src/table.rs
Normal file
3135
crates/ghostlight/src/table.rs
Normal file
File diff suppressed because it is too large
Load diff
196
crates/ghostlight/src/tool_schema.rs
Normal file
196
crates/ghostlight/src/tool_schema.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
//! The one emitter for every model-facing tool schema in the tree.
|
||||
//!
|
||||
//! Two catalogs sit above it and share nothing else: the action catalog
|
||||
//! (`controllers::catalog_tools`) projects a world-state snapshot filtered by
|
||||
//! grant, and the patch catalog (`patch::patch_tools`) projects the reducer's
|
||||
//! own vocabulary. Both spell a property the same way because both spell it
|
||||
//! here. Nothing in this module reads world state, a snapshot, or a config.
|
||||
|
||||
use codex_connector::CodexToolDefinition;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) fn tool(name: &str, description: &str, schema: Value) -> CodexToolDefinition {
|
||||
CodexToolDefinition {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
parameters_json: serde_json::to_string(&schema)
|
||||
.expect("static tool schemas must serialize"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn empty_schema() -> Value {
|
||||
json!({"type":"object","additionalProperties":false,"properties":{}})
|
||||
}
|
||||
|
||||
/// Every generated object is closed and total: `additionalProperties` is false
|
||||
/// and every property is required. An optional argument is expressed as a
|
||||
/// nullable property, never as an absent one, so the decoder and the schema
|
||||
/// agree without a second rule about omission.
|
||||
pub(super) fn object(properties: Vec<(String, Value)>) -> Value {
|
||||
let required: Vec<Value> = properties
|
||||
.iter()
|
||||
.map(|(name, _)| Value::String(name.clone()))
|
||||
.collect();
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": required,
|
||||
"properties": properties.into_iter().collect::<serde_json::Map<_, _>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn canonical_string(description: &str) -> Value {
|
||||
json!({"type": "string", "description": description})
|
||||
}
|
||||
|
||||
pub(super) fn bounded_integer(min: u64, max: u64) -> Value {
|
||||
json!({"type": "integer", "minimum": min, "maximum": max})
|
||||
}
|
||||
|
||||
/// The same schema with the text the model reads beside it. Meaning that lives
|
||||
/// only in a prompt paragraph is lost by the time the model fills the field.
|
||||
pub(super) fn described(mut schema: Value, description: &str) -> Value {
|
||||
if let Value::Object(fields) = &mut schema {
|
||||
fields.insert("description".into(), Value::String(description.into()));
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
pub(super) fn name_enum(values: &[&str]) -> Value {
|
||||
json!({"type": "string", "enum": values})
|
||||
}
|
||||
|
||||
/// A closed sum spelled as a tagged object. `tag` is the discriminator field
|
||||
/// name; each variant carries its own closed object of content.
|
||||
pub(super) fn variant(tag: &str, variants: Vec<(&str, Vec<(String, Value)>)>) -> Value {
|
||||
let branches: Vec<Value> = variants
|
||||
.into_iter()
|
||||
.map(|(name, content)| {
|
||||
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
|
||||
properties.extend(content);
|
||||
object(properties)
|
||||
})
|
||||
.collect();
|
||||
json!({ "anyOf": branches })
|
||||
}
|
||||
|
||||
pub(super) fn nullable(inner: Value) -> Value {
|
||||
json!({ "anyOf": [inner, {"type": "null"}] })
|
||||
}
|
||||
|
||||
pub(super) fn list(inner: Value) -> Value {
|
||||
json!({"type": "array", "items": inner})
|
||||
}
|
||||
|
||||
/// `{"ref": "draft"|"existing", "value": <handle text|canonical uuid>}` —
|
||||
/// Invariant 1 rendered as a schema, and the only reference shape either
|
||||
/// catalog emits.
|
||||
pub(super) fn reference(referent: &str) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ref", "value"],
|
||||
"properties": {
|
||||
"ref": {"type": "string", "enum": ["draft", "existing"]},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": format!(
|
||||
"a draft handle declared in this same patch, or the canonical id of an existing {referent}"
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// An adjacently tagged sum: `{"<tag>": "<variant>", "<content>": <payload>}`.
|
||||
/// A variant with no payload (`None`) omits the content field entirely, which
|
||||
/// is what serde emits for a unit variant in an adjacently tagged enum.
|
||||
pub(super) fn variant_content(
|
||||
tag: &str,
|
||||
content: &str,
|
||||
variants: Vec<(&str, Option<Value>)>,
|
||||
) -> Value {
|
||||
let branches: Vec<Value> = variants
|
||||
.into_iter()
|
||||
.map(|(name, payload)| {
|
||||
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
|
||||
if let Some(inner) = payload {
|
||||
properties.push((content.to_owned(), inner));
|
||||
}
|
||||
object(properties)
|
||||
})
|
||||
.collect();
|
||||
json!({ "anyOf": branches })
|
||||
}
|
||||
|
||||
/// An externally tagged sum: a bare string for a unit variant, or a
|
||||
/// single-key object for one that carries a payload.
|
||||
pub(super) fn external_variant(variants: Vec<(&str, Option<Value>)>) -> Value {
|
||||
let branches: Vec<Value> = variants
|
||||
.into_iter()
|
||||
.map(|(name, payload)| match payload {
|
||||
None => json!({"type": "string", "const": name}),
|
||||
Some(inner) => object(vec![(name.to_owned(), inner)]),
|
||||
})
|
||||
.collect();
|
||||
json!({ "anyOf": branches })
|
||||
}
|
||||
|
||||
/// The provider's strict function-schema rules, enforced offline so the
|
||||
/// catalog cannot be refused on the road one keyword at a time: every
|
||||
/// node names a `type` or is an `anyOf` of nodes that do; every object is
|
||||
/// closed and lists every property as required; `oneOf`, `allOf`, and
|
||||
/// `not` never appear. The first live seeded run failed on exactly these.
|
||||
#[cfg(test)]
|
||||
pub(super) fn assert_strict(schema: &Value, at: &str) {
|
||||
for forbidden in ["oneOf", "allOf", "not"] {
|
||||
assert!(
|
||||
schema.get(forbidden).is_none(),
|
||||
"{at}: `{forbidden}` is not permitted in a strict schema"
|
||||
);
|
||||
}
|
||||
if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
|
||||
assert!(!branches.is_empty(), "{at}: empty anyOf");
|
||||
for (index, branch) in branches.iter().enumerate() {
|
||||
assert_strict(branch, &format!("{at}/anyOf/{index}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let kind = schema
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| panic!("{at}: schema must have a `type` key: {schema}"));
|
||||
match kind {
|
||||
"object" => {
|
||||
assert_eq!(
|
||||
schema.get("additionalProperties"),
|
||||
Some(&Value::Bool(false)),
|
||||
"{at}: object must be closed"
|
||||
);
|
||||
let properties = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or_else(|| panic!("{at}: object without properties"));
|
||||
let mut names: Vec<&str> = properties.keys().map(String::as_str).collect();
|
||||
names.sort_unstable();
|
||||
let mut required: Vec<&str> = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.map(|list| list.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
required.sort_unstable();
|
||||
assert_eq!(names, required, "{at}: every property must be required");
|
||||
for (name, property) in properties {
|
||||
assert_strict(property, &format!("{at}/{name}"));
|
||||
}
|
||||
}
|
||||
"array" => {
|
||||
let items = schema
|
||||
.get("items")
|
||||
.unwrap_or_else(|| panic!("{at}: array without items"));
|
||||
assert_strict(items, &format!("{at}/items"));
|
||||
}
|
||||
"string" | "integer" | "number" | "boolean" | "null" => {}
|
||||
other => panic!("{at}: unexpected type {other}"),
|
||||
}
|
||||
}
|
||||
778
crates/ghostlight/src/vault.rs
Normal file
778
crates/ghostlight/src/vault.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
//! A read-only markdown vault, behind the world's `EvidenceSource` seam.
|
||||
//!
|
||||
//! It resolves referent strings to notes and hands back bounded excerpts. It
|
||||
//! never writes, never leaves its configured root, never touches git or the
|
||||
//! network, and holds no world truth: its output is `EvidenceReceipt`s whose
|
||||
//! `excerpt` and `source` are prompt material and whose `reference` is the only
|
||||
//! thing that can ever reach the journal.
|
||||
//!
|
||||
//! It is the one authority in the seed lane that can be tested with no kernel
|
||||
//! at all, which is why it is a module.
|
||||
|
||||
use super::EvidenceRef;
|
||||
use super::elaboration::{EvidenceError, EvidenceQuery, EvidenceReceipt, EvidenceSource};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Below `MAX_PATCH_EVIDENCE` (64), so a session that cites everything it was
|
||||
/// handed still fits inside one patch.
|
||||
const MAX_VAULT_RECEIPTS: usize = 24;
|
||||
const MAX_HITS_PER_REFERENT: usize = 3;
|
||||
/// One hop, never recursive: a note's neighbours are context, and a transitive
|
||||
/// walk would hand a session the whole vault under one referent.
|
||||
const MAX_LINK_FANOUT: usize = 4;
|
||||
const MAX_EXCERPT_CHARS: usize = 800;
|
||||
const MAX_VAULT_NOTES: usize = 4_096;
|
||||
const MAX_NOTE_BYTES: u64 = 512 * 1024;
|
||||
|
||||
/// Neither variant carries an absolute filesystem path: `seed_once` maps this
|
||||
/// error straight into the owner's command receipt, and the reference format
|
||||
/// this module hands back is deliberately vault-relative, so the error text
|
||||
/// must be too. `Root` names no path at all; `Scope` carries only the
|
||||
/// vault-relative scope string the caller supplied.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum VaultError {
|
||||
#[error("vault root is not a readable directory")]
|
||||
Root,
|
||||
#[error("vault scope escapes its root: {0}")]
|
||||
Scope(String),
|
||||
#[error("vault holds more than {MAX_VAULT_NOTES} notes")]
|
||||
TooManyNotes,
|
||||
}
|
||||
|
||||
/// One note as the reader holds it. The body is kept in memory because the
|
||||
/// corpora this reads are a few megabytes of markdown and a per-query file read
|
||||
/// would buy nothing but a second failure mode.
|
||||
#[derive(Clone, Debug)]
|
||||
struct VaultNote {
|
||||
/// The evidence reference: the note's vault-relative path with forward
|
||||
/// slashes, e.g. `Spoilers/Places/Low Sere.md`. It is a path and not a
|
||||
/// wikilink target, because stems collide across directories; not a scheme,
|
||||
/// because the kernel has no scheme registry; and not a digest, because a
|
||||
/// human must be able to resolve it years later.
|
||||
reference: String,
|
||||
title: String,
|
||||
/// Everything after the frontmatter block.
|
||||
body: String,
|
||||
/// Wikilink targets, left side only.
|
||||
links: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct VaultEvidenceSource {
|
||||
notes: Vec<VaultNote>,
|
||||
/// Many-to-one: filename stem, frontmatter title, and each alias all key
|
||||
/// the same note. Ambiguity is resolved by the shortest reference path, and
|
||||
/// refused for that referent when two candidates tie.
|
||||
by_key: BTreeMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl VaultEvidenceSource {
|
||||
/// `root` is the configured vault root; `scope` is a relative subdirectory
|
||||
/// or empty. Both are canonicalized and the scope must live under the root,
|
||||
/// so `..`, an absolute scope, and a symlink out are all refused here. A
|
||||
/// spoiler tier is a directory in every vault that has one, so it is
|
||||
/// expressed as a scope and there is no tier field anywhere in this file.
|
||||
pub fn open(root: &Path, scope: &str) -> Result<Self, VaultError> {
|
||||
let root = root.canonicalize().map_err(|_| VaultError::Root)?;
|
||||
if !root.is_dir() {
|
||||
return Err(VaultError::Root);
|
||||
}
|
||||
let scope_path = if scope.trim().is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
let relative = Path::new(scope.trim());
|
||||
if relative.is_absolute()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|part| !matches!(part, Component::Normal(_)))
|
||||
{
|
||||
return Err(VaultError::Scope(scope.to_owned()));
|
||||
}
|
||||
let joined = root.join(relative);
|
||||
let canonical = joined
|
||||
.canonicalize()
|
||||
.map_err(|_| VaultError::Scope(scope.to_owned()))?;
|
||||
if !canonical.starts_with(&root) {
|
||||
return Err(VaultError::Scope(scope.to_owned()));
|
||||
}
|
||||
canonical
|
||||
};
|
||||
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
collect_notes(&root, &scope_path, &mut paths)?;
|
||||
paths.sort();
|
||||
|
||||
let mut notes = Vec::new();
|
||||
for path in paths {
|
||||
let Ok(metadata) = std::fs::metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.len() > MAX_NOTE_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(relative) = path.strip_prefix(&root) else {
|
||||
continue;
|
||||
};
|
||||
let reference = relative
|
||||
.components()
|
||||
.map(|part| part.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if reference.trim() != reference || reference.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let (front, body) = split_frontmatter(&text);
|
||||
let (front_title, aliases) = read_front_keys(front);
|
||||
let title = front_title.unwrap_or_else(|| stem.clone());
|
||||
let links = wikilink_targets(body);
|
||||
let mut keys: Vec<String> = vec![stem, title.clone()];
|
||||
keys.extend(aliases);
|
||||
notes.push((
|
||||
VaultNote {
|
||||
reference,
|
||||
title,
|
||||
body: body.to_owned(),
|
||||
links,
|
||||
},
|
||||
keys,
|
||||
));
|
||||
}
|
||||
|
||||
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (index, (_, keys)) in notes.iter().enumerate() {
|
||||
for key in keys {
|
||||
let key = key.trim().to_lowercase();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
by_key.entry(key).or_default().push(index);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
notes: notes.into_iter().map(|(note, _)| note).collect(),
|
||||
by_key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exact key first, then a bounded keyword search. A referent whose exact
|
||||
/// match is ambiguous between two equally short paths resolves to nothing
|
||||
/// rather than to a guess.
|
||||
fn resolve(&self, referent: &str) -> Vec<usize> {
|
||||
let key = referent.trim().to_lowercase();
|
||||
if let Some(candidates) = self.by_key.get(&key) {
|
||||
let mut ranked: Vec<usize> = candidates.clone();
|
||||
ranked.sort_by_key(|index| {
|
||||
(
|
||||
self.notes[*index].reference.len(),
|
||||
self.notes[*index].reference.clone(),
|
||||
)
|
||||
});
|
||||
ranked.dedup();
|
||||
if ranked.len() == 1 {
|
||||
return ranked;
|
||||
}
|
||||
let shortest = self.notes[ranked[0]].reference.len();
|
||||
let tied = ranked
|
||||
.iter()
|
||||
.filter(|index| self.notes[**index].reference.len() == shortest)
|
||||
.count();
|
||||
return if tied == 1 {
|
||||
vec![ranked[0]]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
}
|
||||
let tokens: Vec<String> = key
|
||||
.split_whitespace()
|
||||
.filter(|token| token.len() > 2)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if tokens.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut scored: Vec<(usize, usize)> = self
|
||||
.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, note)| {
|
||||
let title = note.title.to_lowercase();
|
||||
let body = note.body.to_lowercase();
|
||||
let score: usize = tokens
|
||||
.iter()
|
||||
.map(|token| {
|
||||
usize::from(title.contains(token.as_str())) * 4
|
||||
+ body.matches(token.as_str()).count().min(8)
|
||||
})
|
||||
.sum();
|
||||
(score > 0).then_some((score, index))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by_key(|(score, index)| {
|
||||
(
|
||||
std::cmp::Reverse(*score),
|
||||
self.notes[*index].reference.clone(),
|
||||
)
|
||||
});
|
||||
scored
|
||||
.into_iter()
|
||||
.take(MAX_HITS_PER_REFERENT)
|
||||
.map(|(_, index)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn receipt(&self, index: usize, heading: Option<&str>) -> EvidenceReceipt {
|
||||
let note = &self.notes[index];
|
||||
EvidenceReceipt {
|
||||
reference: EvidenceRef::new(note.reference.clone()),
|
||||
excerpt: excerpt(¬e.body, heading),
|
||||
source: note.title.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EvidenceSource for VaultEvidenceSource {
|
||||
async fn retrieve(&self, query: &EvidenceQuery) -> Result<Vec<EvidenceReceipt>, EvidenceError> {
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut receipts = Vec::new();
|
||||
for referent in &query.referents {
|
||||
let (target, heading) = match referent.split_once('#') {
|
||||
Some((target, heading)) => (target, Some(heading)),
|
||||
None => (referent.as_str(), None),
|
||||
};
|
||||
for index in self.resolve(target) {
|
||||
if receipts.len() >= MAX_VAULT_RECEIPTS {
|
||||
return Ok(receipts);
|
||||
}
|
||||
if seen.insert(self.notes[index].reference.clone()) {
|
||||
receipts.push(self.receipt(index, heading));
|
||||
}
|
||||
for link in self.notes[index].links.iter().take(MAX_LINK_FANOUT) {
|
||||
if receipts.len() >= MAX_VAULT_RECEIPTS {
|
||||
return Ok(receipts);
|
||||
}
|
||||
let (link_target, link_heading) = match link.split_once('#') {
|
||||
Some((target, heading)) => (target, Some(heading)),
|
||||
None => (link.as_str(), None),
|
||||
};
|
||||
for linked in self.resolve(link_target) {
|
||||
if receipts.len() >= MAX_VAULT_RECEIPTS {
|
||||
return Ok(receipts);
|
||||
}
|
||||
if seen.insert(self.notes[linked].reference.clone()) {
|
||||
receipts.push(self.receipt(linked, link_heading));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(receipts)
|
||||
}
|
||||
}
|
||||
|
||||
/// `root` is the vault's canonical root, fixed for the whole walk. Every
|
||||
/// directory and note is canonicalized and admitted only when its canonical
|
||||
/// form still starts with `root`, so a junction or symlink planted inside the
|
||||
/// scope cannot walk the reader out to wherever it points. A directory entry
|
||||
/// whose own metadata is a reparse point is refused outright rather than
|
||||
/// descended into: canonicalizing its *contents* would still resolve through
|
||||
/// the reparse point before the containment check ever runs.
|
||||
fn collect_notes(root: &Path, directory: &Path, into: &mut Vec<PathBuf>) -> Result<(), VaultError> {
|
||||
let entries = std::fs::read_dir(directory).map_err(|_| VaultError::Root)?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if is_reparse_point(&metadata) {
|
||||
continue;
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
let Ok(canonical) = path.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if !canonical.starts_with(root) {
|
||||
continue;
|
||||
}
|
||||
collect_notes(root, &path, into)?;
|
||||
} else if path.extension().is_some_and(|value| value == "md") {
|
||||
let Ok(canonical) = path.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if !canonical.starts_with(root) {
|
||||
continue;
|
||||
}
|
||||
if into.len() >= MAX_VAULT_NOTES {
|
||||
return Err(VaultError::TooManyNotes);
|
||||
}
|
||||
into.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A directory junction on Windows carries the reparse-point attribute
|
||||
/// alongside the directory attribute, so `file_attributes` is the only signal
|
||||
/// that distinguishes it from a real directory; elsewhere a symlink is enough
|
||||
/// of a proxy for the same hazard.
|
||||
#[cfg(windows)]
|
||||
fn is_reparse_point(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_reparse_point(metadata: &std::fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
/// A leading `---` line opens a frontmatter block that runs to the next `---`.
|
||||
/// Absence is normal — whole vaults carry none — so the fallback is the whole
|
||||
/// file as body. The block is split, never parsed as YAML: only `title:` and
|
||||
/// `aliases:` are read, line-wise, and everything else is ignored.
|
||||
fn split_frontmatter(text: &str) -> (&str, &str) {
|
||||
let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let Some(rest) = trimmed
|
||||
.strip_prefix("---\r\n")
|
||||
.or_else(|| trimmed.strip_prefix("---\n"))
|
||||
else {
|
||||
return ("", trimmed);
|
||||
};
|
||||
for (offset, line) in rest.match_indices('\n') {
|
||||
let candidate = rest[..offset].rsplit('\n').next().unwrap_or_default();
|
||||
if candidate.trim_end() == "---" {
|
||||
let start = offset - candidate.len();
|
||||
return (&rest[..start], &rest[offset + line.len()..]);
|
||||
}
|
||||
}
|
||||
("", trimmed)
|
||||
}
|
||||
|
||||
fn read_front_keys(front: &str) -> (Option<String>, Vec<String>) {
|
||||
let mut title = None;
|
||||
let mut aliases = Vec::new();
|
||||
let mut in_alias_list = false;
|
||||
for line in front.lines() {
|
||||
let unindented = line.trim_start();
|
||||
if let Some(item) = unindented.strip_prefix("- ")
|
||||
&& in_alias_list
|
||||
{
|
||||
aliases.push(clean_scalar(item));
|
||||
continue;
|
||||
}
|
||||
in_alias_list = false;
|
||||
if let Some(value) = unindented.strip_prefix("title:") {
|
||||
let value = clean_scalar(value);
|
||||
if !value.is_empty() {
|
||||
title = Some(value);
|
||||
}
|
||||
} else if let Some(value) = unindented.strip_prefix("aliases:") {
|
||||
let value = value.trim();
|
||||
if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
|
||||
aliases.extend(inner.split(',').map(clean_scalar).filter(|v| !v.is_empty()));
|
||||
} else if value.is_empty() {
|
||||
in_alias_list = true;
|
||||
} else {
|
||||
aliases.push(clean_scalar(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
title,
|
||||
aliases.into_iter().filter(|v| !v.is_empty()).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clean_scalar(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Three forms: `[[Target]]`, `[[Target|display]]`, and `[[Target#Heading]]`.
|
||||
/// The display half is discarded; the heading half is kept, because it seeks
|
||||
/// the excerpt.
|
||||
fn wikilink_targets(body: &str) -> Vec<String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut rest = body;
|
||||
while let Some(open) = rest.find("[[") {
|
||||
rest = &rest[open + 2..];
|
||||
let Some(close) = rest.find("]]") else {
|
||||
break;
|
||||
};
|
||||
let inner = &rest[..close];
|
||||
rest = &rest[close + 2..];
|
||||
let target = inner.split('|').next().unwrap_or_default().trim();
|
||||
if !target.is_empty() && !targets.iter().any(|seen| seen == target) {
|
||||
targets.push(target.to_owned());
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
/// The named heading's section when one was asked for, else the head of the
|
||||
/// body. Cut at a char boundary, because a reference is bytes and an excerpt is
|
||||
/// text.
|
||||
fn excerpt(body: &str, heading: Option<&str>) -> String {
|
||||
let region = heading
|
||||
.and_then(|heading| heading_section(body, heading))
|
||||
.unwrap_or(body);
|
||||
let region = region.trim();
|
||||
match region.char_indices().nth(MAX_EXCERPT_CHARS) {
|
||||
None => region.to_owned(),
|
||||
Some((cut, _)) => region[..cut].to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_section<'a>(body: &'a str, heading: &str) -> Option<&'a str> {
|
||||
let wanted = heading.trim().to_lowercase();
|
||||
let mut start = None;
|
||||
let mut level = 0usize;
|
||||
let mut offset = 0usize;
|
||||
for line in body.split_inclusive('\n') {
|
||||
let hashes = line.chars().take_while(|c| *c == '#').count();
|
||||
if hashes > 0 {
|
||||
let text = line[hashes..].trim().to_lowercase();
|
||||
match start {
|
||||
None if text == wanted => {
|
||||
start = Some(offset + line.len());
|
||||
level = hashes;
|
||||
}
|
||||
Some(begin) if hashes <= level => return Some(&body[begin..offset]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
offset += line.len();
|
||||
}
|
||||
start.map(|begin| &body[begin..])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, relative: &str, text: &str) {
|
||||
let path = root.join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
write(
|
||||
root,
|
||||
"Public/Places/Low Sere.md",
|
||||
"The sere runs dry nine months a year.\nSee [[Rain Gate]] and [[Rain Gate|the gate]].\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"Public/Places/Rain Gate.md",
|
||||
"---\ntitle: The Rain Gate\naliases:\n - Gate of Rain\n - Hinge\nstatus: canonical\n---\n# Body\nWater enters here.\n\n## Tolls\nA toll of one is charged.\n",
|
||||
);
|
||||
write(root, "Public/index.md", "Public index.\n");
|
||||
write(root, "Public/World/index.md", "World index, deeper.\n");
|
||||
write(
|
||||
root,
|
||||
"Spoilers/Dungeons/Provenance.md",
|
||||
"The hinge was built by the drowned. Link: [[Low Sere#Nothing]]\n",
|
||||
);
|
||||
directory
|
||||
}
|
||||
|
||||
fn query(referents: &[&str]) -> EvidenceQuery {
|
||||
EvidenceQuery {
|
||||
referents: referents.iter().map(|value| (*value).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec test 14. References are vault-relative paths, every wikilink form
|
||||
/// resolves to the same note as the bare one, an ambiguous stem resolves to
|
||||
/// the shortest path, the excerpt is bounded, and a source opened on
|
||||
/// `Public` cannot see `Spoilers`.
|
||||
#[tokio::test]
|
||||
async fn the_vault_resolves_titles_aliases_and_wikilinks_inside_its_scope() {
|
||||
let directory = fixture();
|
||||
let whole = VaultEvidenceSource::open(directory.path(), "").unwrap();
|
||||
|
||||
let receipts = whole.retrieve(&query(&["Low Sere"])).await.unwrap();
|
||||
assert_eq!(
|
||||
receipts[0].reference,
|
||||
EvidenceRef::new("Public/Places/Low Sere.md"),
|
||||
"a reference is the vault-relative path, forward-slashed"
|
||||
);
|
||||
// The bare and the piped link are one target, and the linked note came
|
||||
// back through the one-hop fanout.
|
||||
assert!(
|
||||
receipts
|
||||
.iter()
|
||||
.any(|receipt| receipt.reference == EvidenceRef::new("Public/Places/Rain Gate.md"))
|
||||
);
|
||||
|
||||
// Frontmatter title and alias key the same note as the stem does.
|
||||
for name in ["Rain Gate", "The Rain Gate", "Gate of Rain", "hinge"] {
|
||||
let hit = whole.retrieve(&query(&[name])).await.unwrap();
|
||||
assert_eq!(
|
||||
hit[0].reference,
|
||||
EvidenceRef::new("Public/Places/Rain Gate.md"),
|
||||
"{name} did not resolve to the note"
|
||||
);
|
||||
}
|
||||
|
||||
// A heading link seeks its section rather than the head of the body.
|
||||
let heading = whole.retrieve(&query(&["Rain Gate#Tolls"])).await.unwrap();
|
||||
assert!(heading[0].excerpt.contains("toll of one"));
|
||||
assert!(!heading[0].excerpt.contains("Water enters here"));
|
||||
|
||||
// Two `index.md` notes; the shallower path wins.
|
||||
let ambiguous = whole.retrieve(&query(&["index"])).await.unwrap();
|
||||
assert_eq!(
|
||||
ambiguous[0].reference,
|
||||
EvidenceRef::new("Public/index.md"),
|
||||
"an ambiguous stem resolved to the deeper path"
|
||||
);
|
||||
|
||||
for receipt in whole
|
||||
.retrieve(&query(&["Low Sere", "index"]))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
assert!(receipt.excerpt.chars().count() <= MAX_EXCERPT_CHARS);
|
||||
assert!(receipt.reference.text().trim() == receipt.reference.text());
|
||||
assert!(!receipt.reference.text().is_empty());
|
||||
}
|
||||
assert!(
|
||||
whole
|
||||
.retrieve(&query(&["Low Sere", "Provenance", "index"]))
|
||||
.await
|
||||
.unwrap()
|
||||
.len()
|
||||
<= MAX_VAULT_RECEIPTS
|
||||
);
|
||||
|
||||
// The spoiler tier is a directory, so it is a scope.
|
||||
let public = VaultEvidenceSource::open(directory.path(), "Public").unwrap();
|
||||
assert!(
|
||||
public
|
||||
.retrieve(&query(&["Provenance"]))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
whole
|
||||
.retrieve(&query(&["Provenance"]))
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|receipt| receipt.reference
|
||||
== EvidenceRef::new("Spoilers/Dungeons/Provenance.md")),
|
||||
"the unscoped source should still read the spoiler subtree"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scope check at `open` only bounds the scope string; the walk itself
|
||||
/// must bound every step, because a junction planted after `open` (or
|
||||
/// simply present in the tree) is a directory as far as `read_dir` is
|
||||
/// concerned. `collect_notes` now canonicalizes every directory and note it
|
||||
/// visits and admits it only when the canonical form still starts with the
|
||||
/// vault's canonical root, and it refuses to descend into a directory
|
||||
/// entry that is itself a reparse point.
|
||||
///
|
||||
/// A junction needs no privilege on Windows, so this is reachable by
|
||||
/// anyone who can write inside the configured vault.
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn soul_a_junction_inside_the_root_reads_a_note_outside_it() {
|
||||
let directory = fixture();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
outside.path(),
|
||||
"Secret.md",
|
||||
"The vault root does not cover me.\n",
|
||||
);
|
||||
let link = directory.path().join("Public").join("Escape");
|
||||
let made = std::process::Command::new("cmd")
|
||||
.args([
|
||||
"/C",
|
||||
"mklink",
|
||||
"/J",
|
||||
&link.display().to_string(),
|
||||
&outside.path().display().to_string(),
|
||||
])
|
||||
.output();
|
||||
if made.is_err() || !link.exists() {
|
||||
eprintln!("skipped: this environment cannot create a directory junction");
|
||||
return;
|
||||
}
|
||||
|
||||
let source = VaultEvidenceSource::open(directory.path(), "Public").unwrap();
|
||||
let receipts = source.retrieve(&query(&["Secret"])).await.unwrap();
|
||||
assert!(
|
||||
receipts.is_empty(),
|
||||
"a note outside the configured root was read and handed back as {:?}",
|
||||
receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.reference.text().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Soul. Two bounds and one promise. A note over `MAX_NOTE_BYTES` is
|
||||
/// skipped on its metadata, so it is never read into memory and never
|
||||
/// reaches a receipt; and neither `open` nor `retrieve` writes anything,
|
||||
/// asserted against the tree's own bytes and modification times rather than
|
||||
/// against the doc comment.
|
||||
#[tokio::test]
|
||||
async fn soul_an_oversized_note_is_never_read_and_the_reader_never_writes() {
|
||||
let directory = fixture();
|
||||
let root = directory.path();
|
||||
let huge = format!(
|
||||
"---\ntitle: The Long Note\n---\n{}\n",
|
||||
"obese ".repeat(usize::try_from(MAX_NOTE_BYTES).unwrap() / 4)
|
||||
);
|
||||
assert!(u64::try_from(huge.len()).unwrap() > MAX_NOTE_BYTES);
|
||||
write(root, "Public/Places/Long Note.md", &huge);
|
||||
|
||||
let before = tree(root);
|
||||
let source = VaultEvidenceSource::open(root, "").unwrap();
|
||||
for name in ["Long Note", "The Long Note", "obese"] {
|
||||
let receipts = source.retrieve(&query(&[name])).await.unwrap();
|
||||
assert!(
|
||||
receipts
|
||||
.iter()
|
||||
.all(|receipt| receipt.reference
|
||||
!= EvidenceRef::new("Public/Places/Long Note.md")),
|
||||
"{name} returned a note over the byte cap"
|
||||
);
|
||||
}
|
||||
assert_eq!(before, tree(root), "the reader changed the vault");
|
||||
}
|
||||
|
||||
/// Every file under `root`, by relative path, length, and modification
|
||||
/// time: what a read-only reader must leave exactly as it found it.
|
||||
fn tree(root: &Path) -> BTreeMap<String, (u64, std::time::SystemTime)> {
|
||||
let mut found = BTreeMap::new();
|
||||
let mut paths = Vec::new();
|
||||
collect_all(root, &mut paths);
|
||||
for path in paths {
|
||||
let metadata = std::fs::metadata(&path).unwrap();
|
||||
found.insert(
|
||||
path.strip_prefix(root).unwrap().display().to_string(),
|
||||
(metadata.len(), metadata.modified().unwrap()),
|
||||
);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn collect_all(directory: &Path, into: &mut Vec<PathBuf>) {
|
||||
for entry in std::fs::read_dir(directory).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_all(&path, into);
|
||||
} else {
|
||||
into.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MAX_VAULT_RECEIPTS` is the cap the module names, and the reason it
|
||||
/// gives is that a session citing everything still fits in one patch. The
|
||||
/// guard is checked once per top-level referent and again per receipt a
|
||||
/// link's own fanout would add, so a keyword-resolving link that hands
|
||||
/// back up to `MAX_HITS_PER_REFERENT` notes can never push the total past
|
||||
/// the cap.
|
||||
#[tokio::test]
|
||||
async fn soul_the_link_fanout_overshoots_the_receipt_cap() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path();
|
||||
// Enough plain notes to walk the receipt count up to the cap, each one
|
||||
// resolving by its own stem and linking nowhere.
|
||||
for index in 0..MAX_VAULT_RECEIPTS {
|
||||
write(root, &format!("plain{index}.md"), "A plain note.\n");
|
||||
}
|
||||
// One note whose single wikilink resolves by keyword, not by key, to
|
||||
// three notes at once.
|
||||
write(root, "hub.md", "See [[quarry stone]].\n");
|
||||
for index in 0..MAX_HITS_PER_REFERENT {
|
||||
write(
|
||||
root,
|
||||
&format!("quarry{index}.md"),
|
||||
"quarry stone quarry stone\n",
|
||||
);
|
||||
}
|
||||
let source = VaultEvidenceSource::open(root, "").unwrap();
|
||||
|
||||
let mut referents: Vec<String> = (0..MAX_VAULT_RECEIPTS - 1)
|
||||
.map(|index| format!("plain{index}"))
|
||||
.collect();
|
||||
referents.push("hub".to_owned());
|
||||
let receipts = source.retrieve(&EvidenceQuery { referents }).await.unwrap();
|
||||
assert!(
|
||||
receipts.len() <= MAX_VAULT_RECEIPTS,
|
||||
"one call returned {} receipts against a cap of {MAX_VAULT_RECEIPTS}",
|
||||
receipts.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// `seed_once` maps `VaultError` straight into `RuntimeCommandError::Payload`,
|
||||
/// so the string reaches the owner's command receipt. The reference format
|
||||
/// this module hands back is deliberately vault-relative, and `VaultError`
|
||||
/// now carries no absolute filesystem path either: `Root` names no path,
|
||||
/// and `Scope` carries only the caller-supplied scope string.
|
||||
#[test]
|
||||
fn soul_a_vault_error_carries_an_absolute_path() {
|
||||
let directory = fixture();
|
||||
let root = directory.path();
|
||||
for (case, opened) in [
|
||||
(
|
||||
"an escaping scope",
|
||||
VaultEvidenceSource::open(root, "Public/../.."),
|
||||
),
|
||||
// The reachable misconfiguration: `GHOSTLIGHT_SEED_VAULT_ROOT`
|
||||
// naming something that is not a directory.
|
||||
(
|
||||
"a root that is a file",
|
||||
VaultEvidenceSource::open(&root.join("Public/index.md"), ""),
|
||||
),
|
||||
] {
|
||||
let Err(error) = opened else {
|
||||
panic!("{case} was admitted");
|
||||
};
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
!message.contains(&root.display().to_string()) && !message.contains(":\\"),
|
||||
"{case} handed back an absolute path: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec test 15. The scope is a corner of a configured vault, never a read
|
||||
/// primitive over the filesystem.
|
||||
#[test]
|
||||
fn a_vault_scope_cannot_escape_its_root() {
|
||||
let directory = fixture();
|
||||
let root = directory.path();
|
||||
for scope in ["..", "../..", "Public/../..", "Public/../../etc"] {
|
||||
assert!(
|
||||
VaultEvidenceSource::open(root, scope).is_err(),
|
||||
"{scope} was admitted"
|
||||
);
|
||||
}
|
||||
let absolute = root.join("Public");
|
||||
assert!(VaultEvidenceSource::open(root, &absolute.display().to_string()).is_err());
|
||||
assert!(VaultEvidenceSource::open(root, "Public").is_ok());
|
||||
}
|
||||
}
|
||||
759
crates/ghostlight/src/verse/calls.rs
Normal file
759
crates/ghostlight/src/verse/calls.rs
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
//! Provider call records, the run's spend, and replay.
|
||||
//!
|
||||
//! `RecordingInferencePort` is the one owner of a run's spend. Before it
|
||||
//! forwards a call it decides, from the request alone, whether the call could
|
||||
//! pass the run's token cap; after the reply it charges what the call really
|
||||
//! cost. Every reply is stored as a `ProviderCallRecord` before the output
|
||||
//! returns to the lane, so `ReplayInferencePort` can serve the same run again
|
||||
//! from the store with no provider behind it.
|
||||
//!
|
||||
//! Lanes and runners never count tokens. A request is held against the cap by
|
||||
//! its bound: the serialized provider request's bytes (a token is at least one
|
||||
//! byte, so bytes bound the prompt) plus the request's own output allowance.
|
||||
//!
|
||||
//! The run's spend is one persisted ledger row per run, owned by the store and
|
||||
//! written where it is spent. A call reserves its bound in that row before the
|
||||
//! provider is called; a reply settles the reservation to what it reported
|
||||
//! (prompt plus completion, cached prompt tokens being part of the prompt; none
|
||||
//! reported is charged the bound); a fault leaves the reservation charged. A
|
||||
//! restart reads the row, so no restart returns budget. A reply that reports
|
||||
//! more than the call's bound is charged in full and faults the call, so the
|
||||
//! run's spend never passes the cap unnoticed. One port at a time owns a run.
|
||||
|
||||
use crate::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose,
|
||||
InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use codex_connector::CodexProviderRequest;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const CALL_RECORD_ROW: &str = "verse_provider_call.v1";
|
||||
const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1";
|
||||
/// The run ledger document's schema.
|
||||
const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1";
|
||||
/// The call record document's schema. A store holding any other row type or
|
||||
/// schema is refused at open: this store is not migrated from, or shared with,
|
||||
/// another document.
|
||||
pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CallRecordError {
|
||||
#[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")]
|
||||
InvalidRunId,
|
||||
#[error("the call record store could not be read or written")]
|
||||
Store,
|
||||
#[error("the call record store holds a row that is not a canonical call record")]
|
||||
Corrupt,
|
||||
#[error("a call record's sequence is not the next in its run")]
|
||||
OutOfSequence,
|
||||
#[error("replay needs one run's records in dense sequence from 1")]
|
||||
NotReplayable,
|
||||
#[error("another recording port already owns this run")]
|
||||
RunInUse,
|
||||
#[error("the call could pass the run's token cap")]
|
||||
OverCap,
|
||||
#[error("the run was recorded with a different {0}")]
|
||||
RunMismatch(RunField),
|
||||
}
|
||||
|
||||
/// What a run is bound to when it begins, named by the field a mismatch refuses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunField {
|
||||
/// The token cap the run was opened with.
|
||||
Cap,
|
||||
/// The identity of the world the run started from.
|
||||
WorldId,
|
||||
/// The state digest of the world the run started from.
|
||||
StateDigest,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Cap => "token cap",
|
||||
Self::WorldId => "world id",
|
||||
Self::StateDigest => "world state digest",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The world a run began from: its identity and state digest. Replay is of a
|
||||
/// restored copy of this world, so it takes the same two values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunStart {
|
||||
world_id: String,
|
||||
state_digest: String,
|
||||
}
|
||||
|
||||
impl RunStart {
|
||||
pub fn of(world: &crate::WorldSnapshot) -> Self {
|
||||
Self {
|
||||
world_id: world.world_id.text(),
|
||||
state_digest: world.state_digest.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's identity as a key segment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RunId(String);
|
||||
|
||||
impl RunId {
|
||||
pub fn new(id: &str) -> Result<Self, CallRecordError> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
|
||||
if valid {
|
||||
Ok(Self(id.to_owned()))
|
||||
} else {
|
||||
Err(CallRecordError::InvalidRunId)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One provider call of one run: the request's content digest (never its text, so no
|
||||
/// prompt or credential is stored), the reply exactly as the lane received it,
|
||||
/// and what the call was charged. Keyed (run id, sequence).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderCallRecord {
|
||||
run_id: String,
|
||||
sequence: u64,
|
||||
purpose: InferencePurpose,
|
||||
model: String,
|
||||
content_sha256: String,
|
||||
events: Vec<InferenceEvent>,
|
||||
receipt_digest: String,
|
||||
usage: Option<TokenUsage>,
|
||||
charged: u64,
|
||||
}
|
||||
|
||||
impl ProviderCallRecord {
|
||||
pub fn run_id(&self) -> &str {
|
||||
&self.run_id
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
}
|
||||
|
||||
pub fn purpose(&self) -> InferencePurpose {
|
||||
self.purpose
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Lowercase hex of the request's content digest (see `content_digest`).
|
||||
pub fn content_sha256(&self) -> &str {
|
||||
&self.content_sha256
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> Option<TokenUsage> {
|
||||
self.usage
|
||||
}
|
||||
|
||||
/// Tokens this call was charged against the run's cap.
|
||||
pub fn charged(&self) -> u64 {
|
||||
self.charged
|
||||
}
|
||||
|
||||
/// The reply as the lane received it.
|
||||
pub fn output(&self) -> InferenceOutput {
|
||||
let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone());
|
||||
match self.usage {
|
||||
Some(usage) => output.with_usage(usage),
|
||||
None => output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row_key(run: &str, sequence: u64) -> String {
|
||||
format!("{run}/{sequence:020}")
|
||||
}
|
||||
|
||||
fn hex(digest: &[u8]) -> String {
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What a request asks of the provider, without the ids that name it. The
|
||||
/// request and conversation ids are derived from the command id, and a command
|
||||
/// id from the world it ran in, so keying on them would tie a recorded run to
|
||||
/// one world's random identity. Everything else the provider is sent
|
||||
/// (model, instructions, input, tools, shape) is in the digest.
|
||||
fn content_digest(request: &CodexProviderRequest) -> Result<String, InferenceFault> {
|
||||
// Exhaustive on purpose: a field added upstream must be placed in the key
|
||||
// or left out here by name before this compiles.
|
||||
let CodexProviderRequest {
|
||||
schema_id,
|
||||
request_id: _request_id, // names the call, not what it asks
|
||||
conversation_id: _conversation_id, // names the call, not what it asks
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
} = request;
|
||||
let bytes = rmp_serde::to_vec(&(
|
||||
schema_id,
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
))
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok(hex(&Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
/// A run's persisted ledger, the one owner of what the run began as: the cap
|
||||
/// it was opened under, the world it started from, and the tokens reserved or
|
||||
/// charged so far.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct RunLedger {
|
||||
run_id: String,
|
||||
cap: u64,
|
||||
start: RunStart,
|
||||
spent: u64,
|
||||
}
|
||||
|
||||
enum DecodedRow {
|
||||
Call(ProviderCallRecord),
|
||||
Ledger(RunLedger),
|
||||
}
|
||||
|
||||
pub(super) fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
|
||||
row: &CultCacheEnvelope,
|
||||
) -> Result<T, CallRecordError> {
|
||||
let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?;
|
||||
let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?;
|
||||
if again == row.payload {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(CallRecordError::Corrupt)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_row(row: &CultCacheEnvelope) -> Result<DecodedRow, CallRecordError> {
|
||||
match (row.r#type.as_str(), row.schema_id.as_deref()) {
|
||||
(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => {
|
||||
let record: ProviderCallRecord = canonical(row)?;
|
||||
// A run id that is no whole key segment would key into another
|
||||
// run's records, and sequences count from 1.
|
||||
if RunId::new(&record.run_id).is_err()
|
||||
|| record.sequence == 0
|
||||
|| row.key != row_key(&record.run_id, record.sequence)
|
||||
{
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Call(record))
|
||||
}
|
||||
(RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => {
|
||||
let ledger: RunLedger = canonical(row)?;
|
||||
if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Ledger(ledger))
|
||||
}
|
||||
_ => Err(CallRecordError::Corrupt),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RunTally {
|
||||
last_sequence: u64,
|
||||
/// The persisted ledger, read at open and written where it is spent.
|
||||
ledger: Option<RunLedger>,
|
||||
}
|
||||
|
||||
struct StoreState {
|
||||
store: OwnedRedbMessagePackBackingStore,
|
||||
runs: BTreeMap<String, RunTally>,
|
||||
/// Runs with a recording port alive; one ledger has one owner.
|
||||
owned: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The call records and spend ledgers of every run in one CultCache file.
|
||||
/// Records are appended and never changed; a run's sequence is dense from 1.
|
||||
pub struct CallRecordStore {
|
||||
state: Mutex<StoreState>,
|
||||
}
|
||||
|
||||
impl CallRecordStore {
|
||||
/// Opens the file, creating it empty when absent. Every row must be a
|
||||
/// canonical call record or run ledger of this schema with its key, a
|
||||
/// dense sequence, and a ledger that covers its records' charges; anything
|
||||
/// else, an earlier or later schema version included, is refused and
|
||||
/// nothing is migrated.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, CallRecordError> {
|
||||
let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
store
|
||||
.validate_path_identity()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let rows = store.pull_all().map_err(|_| CallRecordError::Store)?;
|
||||
let mut runs: BTreeMap<String, RunTally> = BTreeMap::new();
|
||||
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut charged: BTreeMap<String, u64> = BTreeMap::new();
|
||||
for row in &rows {
|
||||
match decode_row(row)? {
|
||||
DecodedRow::Call(record) => {
|
||||
*counts.entry(record.run_id.clone()).or_default() += 1;
|
||||
let total = charged.entry(record.run_id.clone()).or_default();
|
||||
*total = total.saturating_add(record.charged);
|
||||
let tally = runs.entry(record.run_id).or_default();
|
||||
tally.last_sequence = tally.last_sequence.max(record.sequence);
|
||||
}
|
||||
DecodedRow::Ledger(ledger) => {
|
||||
let run = ledger.run_id.clone();
|
||||
runs.entry(run).or_default().ledger = Some(ledger);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sequences start at 1 and keys are unique, so a run is dense exactly
|
||||
// when its rows number its highest sequence. Every record was reserved
|
||||
// in a ledger before it was made, so a run with records has a ledger
|
||||
// that covers what they charged.
|
||||
for (run, tally) in &runs {
|
||||
let rows = counts.get(run).copied().unwrap_or(0);
|
||||
let recorded = charged.get(run).copied().unwrap_or(0);
|
||||
let covered = tally
|
||||
.ledger
|
||||
.as_ref()
|
||||
.is_some_and(|ledger| ledger.spent >= recorded);
|
||||
if rows != tally.last_sequence || !covered {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
state: Mutex::new(StoreState {
|
||||
store,
|
||||
runs,
|
||||
owned: BTreeSet::new(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, CallRecordError> {
|
||||
self.state.lock().map_err(|_| CallRecordError::Store)
|
||||
}
|
||||
|
||||
/// The sequence the next record of this run must carry.
|
||||
pub fn next_sequence(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.map_or(0, |tally| tally.last_sequence)
|
||||
+ 1)
|
||||
}
|
||||
|
||||
/// Tokens this run has reserved or been charged: its persisted ledger.
|
||||
pub fn spent(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent))
|
||||
}
|
||||
|
||||
/// Takes the run's ledger for one port, or refuses when another holds it.
|
||||
/// The first claim of a run writes its ledger: the cap it is opened under
|
||||
/// and the world it starts from. A later claim must name the same cap; the
|
||||
/// start is not compared, because a resumed world has moved on.
|
||||
fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if !state.owned.insert(run.as_str().to_owned()) {
|
||||
return Err(CallRecordError::RunInUse);
|
||||
}
|
||||
let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) {
|
||||
Some(ledger) if ledger.cap == cap => Ok(()),
|
||||
Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)),
|
||||
None => Self::write_ledger(
|
||||
&mut state,
|
||||
RunLedger {
|
||||
run_id: run.as_str().to_owned(),
|
||||
cap,
|
||||
start: start.clone(),
|
||||
spent: 0,
|
||||
},
|
||||
),
|
||||
};
|
||||
if begun.is_err() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
begun
|
||||
}
|
||||
|
||||
/// The world a run began from, when it has begun.
|
||||
fn start_of(&self, run: &RunId) -> Result<Option<RunStart>, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map(|ledger| ledger.start.clone()))
|
||||
}
|
||||
|
||||
fn disown(&self, run: &RunId) {
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the ledger, then adopts it in memory. A write that fails
|
||||
/// leaves the previous ledger.
|
||||
fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> {
|
||||
let row = CultCacheEnvelope {
|
||||
key: ledger.run_id.clone(),
|
||||
r#type: RUN_LEDGER_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(RUN_LEDGER_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
let run = ledger.run_id.clone();
|
||||
state.runs.entry(run).or_default().ledger = Some(ledger);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists `spent` as the run's spend; the run must have been claimed.
|
||||
fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> {
|
||||
let mut ledger = state
|
||||
.runs
|
||||
.get(run)
|
||||
.and_then(|tally| tally.ledger.clone())
|
||||
.ok_or(CallRecordError::Store)?;
|
||||
ledger.spent = spent;
|
||||
Self::write_ledger(state, ledger)
|
||||
}
|
||||
|
||||
fn held(state: &StoreState, run: &RunId) -> u64 {
|
||||
state
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent)
|
||||
}
|
||||
|
||||
/// Charges `bound` to the run's persisted ledger, or refuses with
|
||||
/// `OverCap` when the ledger plus the bound would pass `cap`. The check and
|
||||
/// the write hold one lock, so calls on the wire hold their bounds.
|
||||
fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
let after = Self::held(&state, run).saturating_add(bound);
|
||||
if after > cap {
|
||||
return Err(CallRecordError::OverCap);
|
||||
}
|
||||
Self::write_spent(&mut state, run.as_str(), after)
|
||||
}
|
||||
|
||||
/// Replaces a reservation of `bound` with what the call was charged. With
|
||||
/// a record, the record is stored first, so a crash between the two writes
|
||||
/// leaves the larger reservation charged.
|
||||
fn settle(
|
||||
&self,
|
||||
run: &RunId,
|
||||
bound: u64,
|
||||
charged: u64,
|
||||
record: Option<&ProviderCallRecord>,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if let Some(record) = record {
|
||||
Self::push_record(&mut state, record)?;
|
||||
}
|
||||
let settled = Self::held(&state, run)
|
||||
.saturating_sub(bound)
|
||||
.saturating_add(charged);
|
||||
Self::write_spent(&mut state, run.as_str(), settled)
|
||||
}
|
||||
|
||||
fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
Self::push_record(&mut state, record)
|
||||
}
|
||||
|
||||
fn push_record(
|
||||
state: &mut StoreState,
|
||||
record: &ProviderCallRecord,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let last = state
|
||||
.runs
|
||||
.get(&record.run_id)
|
||||
.map_or(0, |tally| tally.last_sequence);
|
||||
if record.sequence != last + 1 {
|
||||
return Err(CallRecordError::OutOfSequence);
|
||||
}
|
||||
let row = CultCacheEnvelope {
|
||||
key: row_key(&record.run_id, record.sequence),
|
||||
r#type: CALL_RECORD_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(CALL_RECORD_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
state
|
||||
.runs
|
||||
.entry(record.run_id.clone())
|
||||
.or_default()
|
||||
.last_sequence = record.sequence;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The run's records in sequence.
|
||||
pub fn records(&self, run: &RunId) -> Result<Vec<ProviderCallRecord>, CallRecordError> {
|
||||
let prefix = format!("{}/", run.as_str());
|
||||
let rows = self
|
||||
.lock()?
|
||||
.store
|
||||
.pull_all()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let mut records = Vec::new();
|
||||
for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) {
|
||||
if let DecodedRow::Call(record) = decode_row(row)? {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|record| record.sequence);
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decorates a port for one run: reserves each call's bound in the run's
|
||||
/// ledger before the provider is called, settles it to the real cost, and
|
||||
/// stores every reply before returning it.
|
||||
pub struct RecordingInferencePort {
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
impl RecordingInferencePort {
|
||||
/// Takes ownership of the run's ledger, which resumes from the store. The
|
||||
/// first open of a run records its cap and the world it starts from; a
|
||||
/// reopen with another cap is refused with `RunMismatch(Cap)`. A second
|
||||
/// port on a run whose first is alive is refused with `RunInUse`.
|
||||
pub fn open(
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap_tokens: u64,
|
||||
start: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
store.claim(&run, cap_tokens, start)?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
store,
|
||||
run,
|
||||
cap: cap_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// The run this port records.
|
||||
pub fn run_id(&self) -> &RunId {
|
||||
&self.run
|
||||
}
|
||||
|
||||
/// The token cap the run was opened under.
|
||||
pub fn cap(&self) -> u64 {
|
||||
self.cap
|
||||
}
|
||||
|
||||
/// Tokens reserved or charged to the run so far.
|
||||
pub fn spent(&self) -> u64 {
|
||||
self.store.spent(&self.run).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The most this request can cost: its serialized bytes plus its output
|
||||
/// allowance. A request with no output allowance has no bound, so it
|
||||
/// cannot be held under a cap.
|
||||
fn bound(request: &PreparedInference) -> Result<u64, InferenceFault> {
|
||||
let allowance = request
|
||||
.invocation
|
||||
.request
|
||||
.max_output_tokens
|
||||
.ok_or_else(InferenceFault::budget_exhausted)?;
|
||||
let payload = serde_json::to_vec(&request.invocation.request)
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok((payload.len() as u64).saturating_add(u64::from(allowance)))
|
||||
}
|
||||
|
||||
fn settle(
|
||||
&self,
|
||||
bound: u64,
|
||||
identity: (InferencePurpose, String, String),
|
||||
result: Result<InferenceOutput, InferenceFault>,
|
||||
) -> Result<InferenceOutput, InferenceFault> {
|
||||
// A fault leaves its reservation charged: the request may have run.
|
||||
let output = result?;
|
||||
let charged = match output.usage {
|
||||
Some(usage) => usage.prompt.saturating_add(usage.completion),
|
||||
None => bound,
|
||||
};
|
||||
if charged > bound {
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, None)
|
||||
.map_err(unrecorded)?;
|
||||
return Err(InferenceFault::budget_exhausted());
|
||||
}
|
||||
let (purpose, model, content_sha256) = identity;
|
||||
let record = ProviderCallRecord {
|
||||
run_id: self.run.as_str().to_owned(),
|
||||
sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?,
|
||||
purpose,
|
||||
model,
|
||||
content_sha256,
|
||||
events: output.events.clone(),
|
||||
receipt_digest: output.receipt_digest.clone(),
|
||||
usage: output.usage,
|
||||
charged,
|
||||
};
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, Some(&record))
|
||||
.map_err(unrecorded)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecordingInferencePort {
|
||||
fn drop(&mut self) {
|
||||
self.store.disown(&self.run);
|
||||
}
|
||||
}
|
||||
|
||||
fn unrecorded(_: CallRecordError) -> InferenceFault {
|
||||
InferenceFault::integrity_violation("a provider call could not be recorded")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for RecordingInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
self.inner.prepare(request)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let bound = Self::bound(&request)?;
|
||||
let identity = (
|
||||
request.purpose,
|
||||
request.invocation.request.model.clone(),
|
||||
content_digest(&request.invocation.request)?,
|
||||
);
|
||||
self.store
|
||||
.reserve(&self.run, bound, self.cap)
|
||||
.map_err(|error| match error {
|
||||
CallRecordError::OverCap => InferenceFault::budget_exhausted(),
|
||||
_ => unrecorded(error),
|
||||
})?;
|
||||
let result = self.inner.infer(request).await;
|
||||
self.settle(bound, identity, result)
|
||||
}
|
||||
|
||||
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
|
||||
self.inner.lend_tool_results(prepared, oracle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves a run's stored replies, in sequence, to the same requests, matched
|
||||
/// by what they ask of the provider and not by the ids that name them. It holds
|
||||
/// no provider and makes no call: a request that is not the next recorded one
|
||||
/// is a fault.
|
||||
pub struct ReplayInferencePort {
|
||||
caller_runtime_id: String,
|
||||
pending: Mutex<VecDeque<ProviderCallRecord>>,
|
||||
}
|
||||
|
||||
impl ReplayInferencePort {
|
||||
/// Replays `run` from the store against `world`, a restored copy of the
|
||||
/// world the run started from. Any other world is refused here, before a
|
||||
/// call, with `RunMismatch` naming the field; a run the store never began
|
||||
/// is `NotReplayable`.
|
||||
pub fn new(
|
||||
caller_runtime_id: impl Into<String>,
|
||||
store: &CallRecordStore,
|
||||
run: &RunId,
|
||||
world: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?;
|
||||
if began.world_id != world.world_id {
|
||||
return Err(CallRecordError::RunMismatch(RunField::WorldId));
|
||||
}
|
||||
if began.state_digest != world.state_digest {
|
||||
return Err(CallRecordError::RunMismatch(RunField::StateDigest));
|
||||
}
|
||||
Ok(Self {
|
||||
caller_runtime_id: caller_runtime_id.into(),
|
||||
pending: Mutex::new(store.records(run)?.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for ReplayInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
PreparedInference::prepare(
|
||||
&self.caller_runtime_id,
|
||||
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let mut pending = self
|
||||
.pending
|
||||
.lock()
|
||||
.map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?;
|
||||
let digest = content_digest(&request.invocation.request)?;
|
||||
let Some(next) = pending.front() else {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"replay has no recorded call left",
|
||||
));
|
||||
};
|
||||
if next.content_sha256 != digest {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the request is not the next recorded call",
|
||||
));
|
||||
}
|
||||
let output = next.output();
|
||||
pending.pop_front();
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
143
crates/ghostlight/src/verse/genesis.rs
Normal file
143
crates/ghostlight/src/verse/genesis.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! The canonical Aetheria verse world's genesis.
|
||||
//!
|
||||
//! One intent lowers to one `CreateWorld`: a commons, every grammar verb
|
||||
//! declared as an affordance, and each Aetheria faction as an institution
|
||||
//! imported by its record key. The world is bound to the catalog's grammar, so
|
||||
//! the kernel refuses any affordance the grammar does not carry.
|
||||
//!
|
||||
//! Phase A verbs record the act and change no component: an outcome band with
|
||||
//! no effects, and the act carried as the utterance that makes an entry
|
||||
//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling
|
||||
//! `verb-effects-ghostlight`), and those arrive with phase B.
|
||||
|
||||
use crate::aetheria::AetheriaCatalog;
|
||||
use crate::mailbox::{MailboxError, commons_declaration, commons_ref};
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef,
|
||||
FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration,
|
||||
kernel_speak_entry, kernel_speak_grant,
|
||||
};
|
||||
use crate::{
|
||||
CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind,
|
||||
WorldPatch, WorldScaleIntentRef,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COMMIT_LEN: usize = 40;
|
||||
|
||||
/// What a caller states to create the canonical verse world. Everything else a
|
||||
/// world needs (the commons, the subjects, the affordances, their grants and
|
||||
/// the provenance of what is known) is derived from the catalog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateVerseWorldIntent {
|
||||
pub id: CommandId,
|
||||
pub title: String,
|
||||
pub brief: String,
|
||||
pub catalog: AetheriaCatalog,
|
||||
/// The Aetheria commit the catalog was read at: 40 lower-case hex digits.
|
||||
/// It is the provenance of every fact the world holds about a faction.
|
||||
pub aetheria_commit: String,
|
||||
pub lens_weights: LensWeights,
|
||||
pub targets: BTreeMap<SubjectKind, u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VerseGenesisError {
|
||||
#[error("aetheria_commit is not 40 lower-case hex digits")]
|
||||
NotACommit,
|
||||
#[error(transparent)]
|
||||
Mailbox(#[from] MailboxError),
|
||||
}
|
||||
|
||||
impl CreateVerseWorldIntent {
|
||||
/// The one lowering. The kernel supplies the `speak` entry at genesis and
|
||||
/// holds it to the grammar, so it is granted here and never declared.
|
||||
pub(crate) fn lower(self, owner: PrincipalId) -> Result<CreateWorld, VerseGenesisError> {
|
||||
let commit = &self.aetheria_commit;
|
||||
if commit.len() != COMMIT_LEN
|
||||
|| !commit
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(VerseGenesisError::NotACommit);
|
||||
}
|
||||
|
||||
let speak = kernel_speak_entry().kind;
|
||||
let mut grants = kernel_speak_grant();
|
||||
let mut declarations = vec![commons_declaration()];
|
||||
for (kind, verb) in self.catalog.grammar.verbs() {
|
||||
if kind == speak.0 {
|
||||
continue;
|
||||
}
|
||||
let handle = DraftHandle::new(format!("verb-{kind}"));
|
||||
grants.insert(Ref::Draft(handle.clone()));
|
||||
declarations.push(Declaration::Affordance(AffordanceDeclaration {
|
||||
handle,
|
||||
kind: AffordanceKindName(kind.to_owned()),
|
||||
roles: verb.role_specs(),
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
// The kernel refuses an entry that can neither change a
|
||||
// component nor speak, and a speaking entry needs an audience,
|
||||
// so a phase A verb is its own utterance to the room: the act
|
||||
// is recorded as speech and nothing else changes.
|
||||
carries_speech: true,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut evidence = Vec::new();
|
||||
for (position, faction) in self.catalog.factions.iter().enumerate() {
|
||||
declarations.push(Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(format!("faction-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
kind: SubjectKind::Institution,
|
||||
controller: NewController::OperationalAgent,
|
||||
affordances: grants.clone(),
|
||||
position: Some(commons_ref()),
|
||||
import_key: Some(faction.key.clone()),
|
||||
}));
|
||||
// A faction with no description has nothing to state.
|
||||
if let Some(statement) = Statement::new(faction.description.trim()) {
|
||||
let receipt = EvidenceRef::new(format!(
|
||||
"aetheria:{commit}:{}/{}",
|
||||
faction.key.schema(),
|
||||
faction.key.record()
|
||||
));
|
||||
evidence.push(receipt.clone());
|
||||
declarations.push(Declaration::Fact(FactDeclaration {
|
||||
handle: DraftHandle::new(format!("fact-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
statement,
|
||||
standing: FactStandingRef::Canonical { evidence: receipt },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreateWorld {
|
||||
id: self.id,
|
||||
owner,
|
||||
title: self.title,
|
||||
brief: self.brief,
|
||||
patch: WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence,
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef {
|
||||
targets: self.targets,
|
||||
jurisdictions: BTreeMap::new(),
|
||||
},
|
||||
lens_weights: self.lens_weights,
|
||||
grammar: Some(self.catalog.grammar),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
use super::*;
|
||||
use crate::aetheria::read_aetheria_catalog;
|
||||
use crate::patch::{FactStanding, Mismatch};
|
||||
use crate::tests::{auth_principal, owner, stock_weights};
|
||||
use crate::{
|
||||
CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb,
|
||||
KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel,
|
||||
WorldMailbox, WorldPhase,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Aetheria master when the fixture catalog was written.
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn catalog() -> AetheriaCatalog {
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
}
|
||||
|
||||
fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent {
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Verse".into(),
|
||||
brief: "Aetheria.".into(),
|
||||
catalog,
|
||||
aetheria_commit: commit.into(),
|
||||
lens_weights: stock_weights(),
|
||||
targets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A world created through the kernel from the lowering, for what the
|
||||
/// snapshot hides: the facts' evidence.
|
||||
fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = intent.lower(owner()).unwrap();
|
||||
let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner()))
|
||||
.map(|(kernel, _)| kernel);
|
||||
(dir, result)
|
||||
}
|
||||
|
||||
struct Verse {
|
||||
_dir: tempfile::TempDir,
|
||||
_task: tokio::task::JoinHandle<()>,
|
||||
mailbox: WorldMailbox,
|
||||
principal: VerifiedPrincipalEvidence,
|
||||
receipt: crate::CreationReceipt,
|
||||
}
|
||||
|
||||
async fn verse(intent: CreateVerseWorldIntent) -> Verse {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let receipt = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
Verse {
|
||||
_dir: dir,
|
||||
_task: task,
|
||||
mailbox,
|
||||
principal,
|
||||
receipt,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verse_genesis_from_the_aetheria_fixture() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
// One institution per faction, keyed by its record, named by its Name.
|
||||
let mut factions: Vec<_> = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.map(|subject| {
|
||||
(
|
||||
subject.import_key.clone().unwrap(),
|
||||
subject.label.clone(),
|
||||
subject.kind,
|
||||
subject.controller_mode,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
factions.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected: Vec<_> = catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| {
|
||||
(
|
||||
faction.key.clone(),
|
||||
faction.name.clone(),
|
||||
SubjectKind::Institution,
|
||||
Some(ControllerMode::OperationalAgent),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(factions.len(), 2);
|
||||
assert_eq!(factions, expected);
|
||||
|
||||
// One fact per faction, carrying its description.
|
||||
let statements: BTreeSet<_> = snapshot
|
||||
.facts
|
||||
.iter()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
statements,
|
||||
catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| faction.description.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// The world is bound to the catalog's grammar.
|
||||
assert_eq!(snapshot.grammar, Some(catalog.grammar.identity()));
|
||||
assert_eq!(verse.receipt.world_id, snapshot.world_id);
|
||||
|
||||
// Every faction stands in the one commons.
|
||||
let commons: Vec<_> = snapshot
|
||||
.places
|
||||
.iter()
|
||||
.filter(|place| place.label == "The Commons")
|
||||
.collect();
|
||||
assert_eq!(commons.len(), 1);
|
||||
assert!(
|
||||
snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.all(|subject| subject.position == Some(commons[0].id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() {
|
||||
for commit in [COMMIT, OTHER_COMMIT] {
|
||||
let (_dir, kernel) = kernel_of(intent_of(catalog(), commit));
|
||||
let kernel = kernel.unwrap();
|
||||
let evidence: BTreeSet<String> = kernel
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| match &fact.standing {
|
||||
FactStanding::Canonical { evidence } => evidence.text().to_owned(),
|
||||
other => panic!("a verse fact is canonical, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
evidence,
|
||||
catalog()
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| format!(
|
||||
"aetheria:{commit}:aetheria.faction/{}",
|
||||
faction.key.record()
|
||||
))
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_is_forty_lower_case_hex_digits() {
|
||||
for bad in [
|
||||
"",
|
||||
&COMMIT[..39],
|
||||
&format!("{COMMIT}0"),
|
||||
&COMMIT.to_uppercase(),
|
||||
&format!("{}g", &COMMIT[..39]),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
intent_of(catalog(), bad).lower(owner()),
|
||||
Err(VerseGenesisError::NotACommit)
|
||||
),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok());
|
||||
assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verse_genesis_refuses_a_duplicate_faction_record() {
|
||||
let mut twice = catalog();
|
||||
let first = twice.factions[0].clone();
|
||||
twice.factions.push(first);
|
||||
// The lowering does not decide: it declares all three.
|
||||
let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap();
|
||||
let subjects = world
|
||||
.patch
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|declaration| matches!(declaration, Declaration::Subject(_)))
|
||||
.count();
|
||||
assert_eq!(subjects, 3);
|
||||
|
||||
let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a second faction under one record key was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_granted_affordance_is_a_grammar_verb() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
let mut kinds: Vec<_> = snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.map(|affordance| affordance.entry.kind.0.clone())
|
||||
.collect();
|
||||
kinds.sort();
|
||||
// Each verb once: the kernel's speak is the grammar's speak.
|
||||
assert_eq!(
|
||||
kinds,
|
||||
catalog
|
||||
.grammar
|
||||
.verbs()
|
||||
.map(|(kind, _)| kind.to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(kinds.contains(&"speak".to_owned()));
|
||||
|
||||
// Each has its render path from the grammar, and phase A changes nothing.
|
||||
for affordance in &snapshot.affordances {
|
||||
let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap();
|
||||
assert_eq!(affordance.render_path, Some(verb.render_path));
|
||||
assert!(affordance.entry.effect_slots.is_empty());
|
||||
assert_eq!(affordance.entry.outcome_bands.len(), 1);
|
||||
assert!(affordance.entry.outcome_bands[0].effects.is_empty());
|
||||
}
|
||||
|
||||
// Every faction is granted every verb.
|
||||
let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect();
|
||||
for subject in &snapshot.subjects {
|
||||
assert_eq!(subject.affordances, all);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factions_get_opportunities() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = verse.mailbox.snapshot().await.unwrap().revision;
|
||||
verse
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: verse.receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&verse.principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.phase, WorldPhase::Active);
|
||||
assert_eq!(snapshot.opportunities.len(), catalog().factions.len());
|
||||
let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect();
|
||||
assert_eq!(scopes.len(), snapshot.opportunities.len());
|
||||
for opportunity in &snapshot.opportunities {
|
||||
assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent);
|
||||
assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_without_a_description_holds_no_fact() {
|
||||
let mut bare = catalog();
|
||||
bare.factions[0].description = " ".into();
|
||||
bare.factions[1].description = " Second. ".into();
|
||||
let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT));
|
||||
let facts: Vec<_> = kernel
|
||||
.unwrap()
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(facts, vec!["Second.".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() {
|
||||
let mut mute = catalog();
|
||||
mute.grammar = GrammarBinding::new(
|
||||
"aetheria.verse_grammar",
|
||||
1,
|
||||
[(
|
||||
"haul".to_owned(),
|
||||
GrammarVerb {
|
||||
render_path: RenderPath::ShipAction,
|
||||
roles: vec![GrammarRole {
|
||||
role: "cargo".into(),
|
||||
referent: GrammarReferentKind::Cargo,
|
||||
}],
|
||||
},
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a world whose grammar has no speak was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_dungeon_ingress_still_creates_an_unbound_world() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1));
|
||||
mailbox
|
||||
.create(
|
||||
crate::CreateWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "Dungeon".into(),
|
||||
brief: String::new(),
|
||||
human_subject_label: "Player".into(),
|
||||
narrative_persona_label: None,
|
||||
operational_agent_label: None,
|
||||
targets: BTreeMap::new(),
|
||||
jurisdictions: Vec::new(),
|
||||
lens_weights: stock_weights(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.grammar, None);
|
||||
assert!(
|
||||
snapshot
|
||||
.places
|
||||
.iter()
|
||||
.any(|place| place.label == "The Commons")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_faction_name_is_trimmed_into_its_institution_label() {
|
||||
let mut padded = catalog();
|
||||
let expected = padded.factions[0].name.trim().to_owned();
|
||||
padded.factions[0].name = format!(" {expected}\t ");
|
||||
let key = padded.factions[0].key.clone();
|
||||
let verse = verse(intent_of(padded, COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
let subject = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.import_key.as_ref() == Some(&key))
|
||||
.expect("the padded faction was created");
|
||||
assert_eq!(subject.label, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_twice_with_one_intent_is_one_creation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let intent = intent_of(catalog(), COMMIT);
|
||||
let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap();
|
||||
let before = mailbox.snapshot().await.unwrap();
|
||||
let again = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
let after = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(again, first);
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
assert_eq!(after.subjects.len(), before.subjects.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_under_another_command_id_finds_the_world_occupied() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
let before = verse.mailbox.snapshot().await.unwrap();
|
||||
let refused = verse
|
||||
.mailbox
|
||||
.create_verse(intent_of(catalog(), COMMIT), &verse.principal)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
refused,
|
||||
Err(VerseGenesisError::Mailbox(MailboxError::Kernel(
|
||||
KernelError::CreationTargetOccupied
|
||||
)))
|
||||
),
|
||||
"{refused:?}"
|
||||
);
|
||||
let after = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
}
|
||||
|
||||
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
|
||||
/// the lowering admits a ship verb only as an utterance,
|
||||
/// and committing it asserts a claim fact the room hears.
|
||||
#[test]
|
||||
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
|
||||
use crate::tests::{command, opportunity_for, spoken, submit_owner};
|
||||
use crate::{
|
||||
AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt,
|
||||
Target,
|
||||
};
|
||||
|
||||
let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
submit_owner(&mut kernel, &snapshot, body);
|
||||
}
|
||||
let active = kernel.snapshot().unwrap();
|
||||
let attack = active
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == "attack")
|
||||
.unwrap()
|
||||
.id;
|
||||
let [first, second] = [&active.subjects[0], &active.subjects[1]];
|
||||
let opportunity = opportunity_for(&active, first.id);
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let invocation = |speech: Option<&str>| DecisionInvocation {
|
||||
affordance: attack,
|
||||
bindings: vec![RoleBinding {
|
||||
role: Role("target".into()),
|
||||
target: Target::Subject(second.id),
|
||||
}],
|
||||
proposed: Vec::new(),
|
||||
speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
};
|
||||
let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| {
|
||||
kernel.submit(
|
||||
command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity: opportunity.clone(),
|
||||
invocation: invocation(speech),
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
// Without an utterance the act is refused: the verb has no other way in.
|
||||
assert!(try_it(&mut kernel, None).is_err());
|
||||
// With one, the act commits and its words are a claim fact.
|
||||
let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let event = kernel.state.events.last().unwrap();
|
||||
assert_eq!(spoken(&kernel.state, event), Some("We strike."));
|
||||
}
|
||||
19
crates/ghostlight/src/verse/mod.rs
Normal file
19
crates/ghostlight/src/verse/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! The Aetheria verse run's own organs. The verse binary sits above this
|
||||
//! module; here sit the parts of a run that belong to the library: the record
|
||||
//! of every provider call and the spend it implies, the genesis of the
|
||||
//! canonical verse world, and the manual run that advances it with its record.
|
||||
|
||||
mod calls;
|
||||
mod genesis;
|
||||
mod run;
|
||||
|
||||
pub use genesis::{CreateVerseWorldIntent, VerseGenesisError};
|
||||
pub use calls::{
|
||||
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord,
|
||||
RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,
|
||||
};
|
||||
pub use run::{
|
||||
RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus,
|
||||
TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
|
||||
call_run_id, run_manual,
|
||||
};
|
||||
1165
crates/ghostlight/src/verse/run.rs
Normal file
1165
crates/ghostlight/src/verse/run.rs
Normal file
File diff suppressed because it is too large
Load diff
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
756
crates/ghostlight/tests/external_admission.rs
Normal file
756
crates/ghostlight/tests/external_admission.rs
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
//! Admission proofs for the kernel's seal, from outside the crate.
|
||||
//!
|
||||
//! The seal is unforgeable *admission*, not unconstructible values. External
|
||||
//! code may hold a syntactically valid ID, digest, or `DecisionOpportunity` —
|
||||
//! a consumer's own payloads carry these types over the wire, so they
|
||||
//! deserialize — and the kernel must refuse every one it did not itself issue.
|
||||
//! An integration test compiles as its own crate against the public API alone,
|
||||
//! so these tests stand exactly where a consumer stands: every forged value
|
||||
//! below is minted by `serde` from JSON, which is a path visibility does not
|
||||
//! gate, and every rejection is asserted on the typed error rather than on a
|
||||
//! message.
|
||||
//!
|
||||
//! Each rejection also asserts that nothing committed: revision, state digest,
|
||||
//! last commit digest, and the operator log's length are unchanged. The
|
||||
//! operator log is the widest committed-history surface a consumer can read;
|
||||
//! the journal itself has no public accessor, so its length is proven only
|
||||
//! through the digests and that log.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use ghostlight::{
|
||||
CommandBody, CommandId, ControllerMode, ControllerPort, CreateWorldIntent, DecisionInvocation,
|
||||
DecisionOpportunity, KernelError, Lens, LensWeights, MailboxError, Mismatch, PlayPort,
|
||||
PrincipalCommandIntent, Statement, SubjectId, SubmitReceipt, VerifiedPrincipalEvidence,
|
||||
WorldMailbox, WorldPatch, WorldPhase, WorldSnapshot,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A live world in Active phase, reached through the same public ingress a
|
||||
/// consumer uses: create, approve, activate. Genesis grants speech to every
|
||||
/// subject it declares, so the two model-controlled subjects each carry a live
|
||||
/// opportunity with one granted affordance.
|
||||
struct World {
|
||||
_directory: tempfile::TempDir,
|
||||
_owner: tokio::task::JoinHandle<()>,
|
||||
mailbox: WorldMailbox,
|
||||
principal: VerifiedPrincipalEvidence,
|
||||
}
|
||||
|
||||
impl World {
|
||||
async fn active() -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (mailbox, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::new("external-admission-account", Utc::now() + Duration::hours(1));
|
||||
let receipt = mailbox
|
||||
.create(
|
||||
CreateWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "External Admission".into(),
|
||||
brief: String::new(),
|
||||
human_subject_label: "Operator".into(),
|
||||
narrative_persona_label: Some("Persona".into()),
|
||||
operational_agent_label: Some("Operational Agent".into()),
|
||||
targets: BTreeMap::new(),
|
||||
jurisdictions: Vec::new(),
|
||||
// An external crate has no library fixture; it states its
|
||||
// own weights, as every consumer must.
|
||||
lens_weights: LensWeights::new(BTreeMap::from([(Lens::Patina, 1)])),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = mailbox.snapshot().await.unwrap().revision;
|
||||
mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let world = Self {
|
||||
_directory: directory,
|
||||
_owner: owner,
|
||||
mailbox,
|
||||
principal,
|
||||
};
|
||||
assert_eq!(world.snapshot().await.phase, WorldPhase::Active);
|
||||
world
|
||||
}
|
||||
|
||||
async fn snapshot(&self) -> WorldSnapshot {
|
||||
self.mailbox.snapshot().await.unwrap()
|
||||
}
|
||||
|
||||
fn port(&self) -> ControllerPort {
|
||||
ControllerPort::new(self.mailbox.clone())
|
||||
}
|
||||
|
||||
/// The first opportunity a model controller holds. The human subject's
|
||||
/// opportunity is not one a controller port may exercise.
|
||||
async fn controller_opportunity(&self) -> DecisionOpportunity {
|
||||
self.snapshot()
|
||||
.await
|
||||
.opportunities
|
||||
.into_iter()
|
||||
.find(|opportunity| opportunity.controller_mode != ControllerMode::Human)
|
||||
.expect("an activated world derives a model controller's opportunity")
|
||||
}
|
||||
|
||||
/// Everything a commit would move, read through the public surface.
|
||||
async fn committed(&self) -> (u64, String, Option<String>, usize) {
|
||||
let snapshot = self.snapshot().await;
|
||||
let log = self.mailbox.operator_log().await.unwrap();
|
||||
(
|
||||
snapshot.revision,
|
||||
snapshot.state_digest,
|
||||
snapshot.last_commit_digest,
|
||||
log.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Forging is a `serde` round trip: the value goes out as JSON, a field is
|
||||
/// replaced, and it comes back as the sealed type. No field of
|
||||
/// `DecisionOpportunity` needs to be nameable for this to work, which is the
|
||||
/// whole reason the kernel cannot rely on visibility here.
|
||||
fn forge(opportunity: &DecisionOpportunity, field: &str, value: Value) -> DecisionOpportunity {
|
||||
let mut encoded = serde_json::to_value(opportunity).expect("a sealed opportunity serializes");
|
||||
encoded[field] = value;
|
||||
serde_json::from_value(encoded).expect("an external crate deserializes a sealed opportunity")
|
||||
}
|
||||
|
||||
fn speak(affordance: ghostlight::AffordanceId, text: &str) -> DecisionInvocation {
|
||||
DecisionInvocation {
|
||||
affordance,
|
||||
bindings: Vec::new(),
|
||||
proposed: Vec::new(),
|
||||
speech: Some(Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The control. Without it every rejection below could be an inert harness
|
||||
/// rather than a refusal: this proves the same port, world, and invocation
|
||||
/// shape do commit when the opportunity is one the kernel issued.
|
||||
#[tokio::test]
|
||||
async fn an_issued_opportunity_commits_through_the_same_port() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let (revision, _, _, events) = world.committed().await;
|
||||
let receipt = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&opportunity,
|
||||
speak(opportunity.affordance_ids[0], "The issued proposal stands."),
|
||||
)
|
||||
.await
|
||||
.expect("an issued opportunity is admitted");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let after = world.committed().await;
|
||||
assert_eq!(after.0, revision + 1);
|
||||
assert_eq!(after.3, events + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_forged_scope_digest_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let before = world.committed().await;
|
||||
let forged = forge(
|
||||
&opportunity,
|
||||
"scope_digest",
|
||||
json!("sha256:forged-outside-the-crate"),
|
||||
);
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "Let me in."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
|
||||
),
|
||||
"a forged scope digest was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// The value the crate's own deleted `ScopeDigest::fixture` used to produce.
|
||||
/// A `ScopeDigest` is a transparent newtype over a string, so deserializing
|
||||
/// that string is the whole of what the fixture did, and it is available to
|
||||
/// any external crate whatever the type's visibility is. What it is not is
|
||||
/// admission.
|
||||
#[tokio::test]
|
||||
async fn a_scope_digest_deserialized_from_a_string_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let before = world.committed().await;
|
||||
let forged = forge(&opportunity, "scope_digest", json!("sha256:not-a-scope"));
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "A fixture digest is not a scope."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
|
||||
),
|
||||
"a deserialized fixture digest was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// `ControllerId` is not even a nameable type outside the crate, and forging
|
||||
/// one still needs no name: the field is a UUID on the wire. The port stamps
|
||||
/// the caller from the opportunity it is handed, so a forged controller
|
||||
/// speaks as itself — and the reducer refuses it because the scope's
|
||||
/// assignment names someone else.
|
||||
#[tokio::test]
|
||||
async fn a_forged_controller_id_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let before = world.committed().await;
|
||||
let forged = forge(
|
||||
&opportunity,
|
||||
"controller_id",
|
||||
json!(uuid::Uuid::new_v4().to_string()),
|
||||
);
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "I say I am the controller."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::ControllerMismatch))
|
||||
),
|
||||
"a forged controller ID was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// A `SubjectId` minted by deserialization — the kernel's `issue()` is
|
||||
/// private, and this needs it no more than the wire does. The world derives
|
||||
/// no opportunity for a subject it never declared.
|
||||
#[tokio::test]
|
||||
async fn a_command_naming_a_minted_subject_id_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let before = world.committed().await;
|
||||
let minted: SubjectId = serde_json::from_value(json!(uuid::Uuid::new_v4().to_string()))
|
||||
.expect("an external crate mints a syntactically valid SubjectId");
|
||||
let forged = forge(&opportunity, "scope", json!({ "subject_id": minted }));
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "A subject who was never declared."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::OpportunityMismatch))
|
||||
),
|
||||
"a minted subject ID was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// The same for an `AffordanceId`: the opportunity here is entirely genuine,
|
||||
/// so the forgery is only the affordance the invocation names. Admission is
|
||||
/// checked against what the world granted, not against what the command
|
||||
/// claims.
|
||||
#[tokio::test]
|
||||
async fn a_command_naming_a_minted_affordance_id_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let opportunity = world.controller_opportunity().await;
|
||||
let before = world.committed().await;
|
||||
let minted: ghostlight::AffordanceId =
|
||||
serde_json::from_value(json!(uuid::Uuid::new_v4().to_string()))
|
||||
.expect("an external crate mints a syntactically valid AffordanceId");
|
||||
assert!(!opportunity.affordance_ids.contains(&minted));
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&opportunity,
|
||||
speak(minted, "An affordance nobody granted."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::AffordanceDenied))
|
||||
),
|
||||
"a minted affordance ID was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// A whole opportunity built from JSON alone, naming a world, a scope, a
|
||||
/// controller and an affordance the kernel never issued. This is the shape
|
||||
/// the seal's wording is about: the value exists, and admission does not.
|
||||
#[tokio::test]
|
||||
async fn a_wholly_deserialized_opportunity_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let before = world.committed().await;
|
||||
let uuid = || json!(uuid::Uuid::new_v4().to_string());
|
||||
let affordance = uuid();
|
||||
let forged: DecisionOpportunity = serde_json::from_value(json!({
|
||||
"world_id": uuid(),
|
||||
"revision": 0,
|
||||
"scope_digest": "sha256:not-a-scope",
|
||||
"scope": { "subject_id": uuid() },
|
||||
"controller_id": uuid(),
|
||||
"controller_mode": "operational_agent",
|
||||
"affordance_ids": [affordance],
|
||||
}))
|
||||
.expect("an external crate deserializes a whole opportunity");
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "Wholly invented."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::OpportunityMismatch))
|
||||
),
|
||||
"a wholly forged opportunity was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
}
|
||||
|
||||
/// A forgery indistinguishable in shape from a real digest, because it is a
|
||||
/// real digest: one the kernel genuinely issued for this scope, gone stale
|
||||
/// when the world moved under it. Only an exact comparison of the whole
|
||||
/// digest refuses it; a check of its length or of any prefix would admit it.
|
||||
/// A stale digest still differs early, so the test also submits the current
|
||||
/// digest changed in one way each that a weaker comparison would admit: its
|
||||
/// last digit changed (suffix skipped), one hex letter uppercased (case
|
||||
/// ignored), and its hex bare or under another label (label ignored).
|
||||
///
|
||||
/// Genesis places every subject in one commons, so one controller's speech
|
||||
/// lands as knowledge on the others, and knowledge is a scope component: the
|
||||
/// listener's digest moves, the speaker's does not.
|
||||
#[tokio::test]
|
||||
async fn a_stale_issued_scope_digest_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let controllers: Vec<_> = world
|
||||
.snapshot()
|
||||
.await
|
||||
.opportunities
|
||||
.into_iter()
|
||||
.filter(|opportunity| opportunity.controller_mode != ControllerMode::Human)
|
||||
.collect();
|
||||
let [speaker, stale] = <[DecisionOpportunity; 2]>::try_from(controllers)
|
||||
.expect("the world declares two model-controlled subjects");
|
||||
world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&speaker,
|
||||
speak(speaker.affordance_ids[0], "Everyone in the commons hears this."),
|
||||
)
|
||||
.await
|
||||
.expect("an issued opportunity is admitted");
|
||||
let current = world
|
||||
.snapshot()
|
||||
.await
|
||||
.opportunities
|
||||
.into_iter()
|
||||
.find(|opportunity| opportunity.scope == stale.scope)
|
||||
.expect("the listener still holds an opportunity");
|
||||
// The digest is not a public field; it is read the way a consumer reads
|
||||
// it, off the wire.
|
||||
let digest_of = |opportunity: &DecisionOpportunity| {
|
||||
serde_json::to_value(opportunity).unwrap()["scope_digest"]
|
||||
.as_str()
|
||||
.expect("a scope digest is a string on the wire")
|
||||
.to_owned()
|
||||
};
|
||||
let (stale_digest, current_digest) = (digest_of(&stale), digest_of(¤t));
|
||||
assert_ne!(
|
||||
stale_digest, current_digest,
|
||||
"the speech did not move the listener's scope digest"
|
||||
);
|
||||
assert_eq!(stale_digest.len(), current_digest.len());
|
||||
let before = world.committed().await;
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&stale,
|
||||
speak(stale.affordance_ids[0], "Against a scope that has moved."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
|
||||
),
|
||||
"a stale issued scope digest was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
|
||||
// A stale digest differs from the current one almost everywhere, so it
|
||||
// cannot tell a whole-digest comparison from one that compares less. Each
|
||||
// forgery below is the current digest changed in exactly one way that a
|
||||
// weaker comparison would admit; each asserts that premise first.
|
||||
let (label, hex) = current_digest
|
||||
.split_once(':')
|
||||
.expect("an issued digest is algorithm:hex");
|
||||
assert_eq!(label, "sha256");
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert!(hex.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')));
|
||||
|
||||
// Only the final hex digit changed: a comparison that skips a suffix.
|
||||
let mut near = current_digest.clone();
|
||||
let last = near.pop().expect("a digest is not empty");
|
||||
near.push(if last == '0' { '1' } else { '0' });
|
||||
assert_eq!(near.len(), current_digest.len());
|
||||
assert_ne!(near, current_digest);
|
||||
|
||||
// One hex letter uppercased: a case-insensitive comparison.
|
||||
let letter = hex
|
||||
.find(|c: char| matches!(c, 'a'..='f'))
|
||||
.expect("the issued digest has a hex letter to uppercase");
|
||||
let mut upper = hex.to_owned();
|
||||
upper.replace_range(letter..=letter, &hex[letter..=letter].to_ascii_uppercase());
|
||||
let upper = format!("{label}:{upper}");
|
||||
assert_ne!(upper, current_digest);
|
||||
assert!(upper.eq_ignore_ascii_case(¤t_digest));
|
||||
|
||||
// The same hex without its label, and under another label: a comparison of
|
||||
// the hex alone.
|
||||
let bare = hex.to_owned();
|
||||
let relabeled = format!("blake3:{hex}");
|
||||
for forgery in [&bare, &relabeled] {
|
||||
assert_ne!(forgery, ¤t_digest);
|
||||
assert_eq!(forgery.rsplit(':').next(), Some(hex));
|
||||
}
|
||||
|
||||
for (forgery, what) in [
|
||||
(near, "one digit from"),
|
||||
(upper, "one letter's case from"),
|
||||
(bare, "the unlabeled hex of"),
|
||||
(relabeled, "the hex under another label of"),
|
||||
] {
|
||||
let forged = forge(¤t, "scope_digest", json!(forgery));
|
||||
let result = world
|
||||
.port()
|
||||
.submit_controller(
|
||||
CommandId::new(),
|
||||
&forged,
|
||||
speak(forged.affordance_ids[0], "Nearly the real scope."),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
|
||||
),
|
||||
"a digest {what} the current scope was not refused: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every stock lens named, with real weights: the shape a consumer's weight
|
||||
/// control produces, so a refusal below is about the caller and not the value.
|
||||
fn real_weights() -> LensWeights {
|
||||
LensWeights::new(BTreeMap::from([
|
||||
(Lens::Patina, 3),
|
||||
(Lens::Charter, 1),
|
||||
(Lens::Ledger, 0),
|
||||
(Lens::Hearth, 2),
|
||||
(Lens::Tangle, 1),
|
||||
(Lens::Veil, 1),
|
||||
(Lens::Ember, 1),
|
||||
(Lens::Numen, 1),
|
||||
]))
|
||||
}
|
||||
|
||||
/// A second verified principal holds a live session and valid weights and is
|
||||
/// not the owner. The kernel refuses it and nothing commits; the same body
|
||||
/// from the owner then commits, so the refusal is not an inert harness.
|
||||
#[tokio::test]
|
||||
async fn set_lens_weights_from_a_non_owner_is_refused_and_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let snapshot = world.snapshot().await;
|
||||
let stranger =
|
||||
VerifiedPrincipalEvidence::new("external-admission-stranger", Utc::now() + Duration::hours(1));
|
||||
let before = world.committed().await;
|
||||
let result = world
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: snapshot.world_id,
|
||||
expected_revision: snapshot.revision,
|
||||
body: CommandBody::SetLensWeights {
|
||||
weights: real_weights(),
|
||||
},
|
||||
},
|
||||
&stranger,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::Unauthorized))
|
||||
),
|
||||
"a non-owner replaced the lens weights: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
assert_ne!(world.snapshot().await.lens_weights, real_weights());
|
||||
|
||||
let receipt = world
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: snapshot.world_id,
|
||||
expected_revision: snapshot.revision,
|
||||
body: CommandBody::SetLensWeights {
|
||||
weights: real_weights(),
|
||||
},
|
||||
},
|
||||
&world.principal,
|
||||
)
|
||||
.await
|
||||
.expect("the owner replaces the lens weights");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let after = world.snapshot().await;
|
||||
assert_eq!(after.revision, before.0 + 1);
|
||||
assert_eq!(after.lens_weights, real_weights());
|
||||
}
|
||||
|
||||
/// The owner resubmitting the world's current weights changes nothing
|
||||
/// canonical, so the kernel refuses it as `NoCanonicalChange` and nothing
|
||||
/// commits. Admission runs first: the same identical set from a non-owner is
|
||||
/// `Unauthorized`. A different set from the owner then commits, so neither
|
||||
/// refusal is an inert harness.
|
||||
#[tokio::test]
|
||||
async fn identical_lens_weights_from_the_owner_commit_nothing() {
|
||||
let world = World::active().await;
|
||||
let current = world.snapshot().await.lens_weights;
|
||||
let before = world.committed().await;
|
||||
let intent = |snapshot: &WorldSnapshot, weights: LensWeights| PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: snapshot.world_id,
|
||||
expected_revision: snapshot.revision,
|
||||
body: CommandBody::SetLensWeights { weights },
|
||||
};
|
||||
let snapshot = world.snapshot().await;
|
||||
|
||||
let result = world
|
||||
.mailbox
|
||||
.submit_principal(intent(&snapshot, current.clone()), &world.principal)
|
||||
.await;
|
||||
let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = &result else {
|
||||
panic!("the owner's identical lens weights were not refused as no change: {result:?}");
|
||||
};
|
||||
assert_eq!(mismatches, &vec![Mismatch::NoCanonicalChange]);
|
||||
assert_eq!(world.committed().await, before);
|
||||
|
||||
let stranger =
|
||||
VerifiedPrincipalEvidence::new("external-admission-stranger", Utc::now() + Duration::hours(1));
|
||||
let result = world
|
||||
.mailbox
|
||||
.submit_principal(intent(&snapshot, current.clone()), &stranger)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(MailboxError::Kernel(KernelError::Unauthorized))
|
||||
),
|
||||
"a non-owner's identical lens weights were not refused as unauthorized: {result:?}"
|
||||
);
|
||||
assert_eq!(world.committed().await, before);
|
||||
|
||||
assert_ne!(current, real_weights());
|
||||
let receipt = world
|
||||
.mailbox
|
||||
.submit_principal(intent(&snapshot, real_weights()), &world.principal)
|
||||
.await
|
||||
.expect("the owner replaces the lens weights with a different set");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let after = world.committed().await;
|
||||
assert_eq!(after.0, before.0 + 1);
|
||||
assert_ne!(after.1, before.1);
|
||||
assert_ne!(after.2, before.2);
|
||||
assert_eq!(world.snapshot().await.lens_weights, real_weights());
|
||||
}
|
||||
|
||||
/// A lens a weight map does not name weighs zero, so padding a set with zero
|
||||
/// weights, or dropping its zero weights, spells the same set. Through the
|
||||
/// public mailbox, in both directions: the world's set is sparse and a padded
|
||||
/// twin is refused as `NoCanonicalChange`; the world's set is padded and a
|
||||
/// sparse twin is refused the same way. Each refusal leaves revision, digests
|
||||
/// and operator log unchanged and the stored spelling as it was. A set that
|
||||
/// really moves a weight then commits, so the refusals are not an inert harness.
|
||||
#[tokio::test]
|
||||
async fn a_lens_set_spelled_with_or_without_its_zero_weights_commits_nothing() {
|
||||
let world = World::active().await;
|
||||
let set = |entries: &[(Lens, u32)]| LensWeights::new(entries.iter().copied().collect());
|
||||
let submit = |weights: LensWeights| {
|
||||
let world = &world;
|
||||
async move {
|
||||
let snapshot = world.snapshot().await;
|
||||
world
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: snapshot.world_id,
|
||||
expected_revision: snapshot.revision,
|
||||
body: CommandBody::SetLensWeights { weights },
|
||||
},
|
||||
&world.principal,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let refused_as_no_change = |weights: LensWeights, stored: LensWeights| {
|
||||
let world = &world;
|
||||
let submit = &submit;
|
||||
async move {
|
||||
let before = world.committed().await;
|
||||
let result = submit(weights.clone()).await;
|
||||
let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = &result else {
|
||||
panic!("{weights:?} over {stored:?} was not refused as no change: {result:?}");
|
||||
};
|
||||
assert_eq!(mismatches, &vec![Mismatch::NoCanonicalChange]);
|
||||
assert_eq!(world.committed().await, before, "{weights:?} committed");
|
||||
assert_eq!(world.snapshot().await.lens_weights, stored);
|
||||
}
|
||||
};
|
||||
|
||||
// Sparse on the world, padded in the command. The world was created with
|
||||
// `{patina: 1}`.
|
||||
let sparse = set(&[(Lens::Patina, 1)]);
|
||||
assert_eq!(world.snapshot().await.lens_weights, sparse);
|
||||
refused_as_no_change(set(&[(Lens::Patina, 1), (Lens::Charter, 0)]), sparse.clone()).await;
|
||||
let every = LensWeights::new(
|
||||
Lens::ALL
|
||||
.into_iter()
|
||||
.map(|lens| (lens, u32::from(lens == Lens::Patina)))
|
||||
.collect(),
|
||||
);
|
||||
assert_eq!(every.iter().count(), 8);
|
||||
refused_as_no_change(every, sparse.clone()).await;
|
||||
|
||||
// A real change commits, and stores its own spelling: padded.
|
||||
let before = world.committed().await;
|
||||
let padded = set(&[(Lens::Patina, 0), (Lens::Charter, 3)]);
|
||||
let receipt = submit(padded.clone()).await.expect("moving weight from patina to charter commits");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
assert_eq!(world.committed().await.0, before.0 + 1);
|
||||
assert_eq!(world.snapshot().await.lens_weights, padded);
|
||||
|
||||
// Padded on the world, sparse in the command.
|
||||
refused_as_no_change(set(&[(Lens::Charter, 3)]), padded.clone()).await;
|
||||
refused_as_no_change(
|
||||
set(&[(Lens::Charter, 3), (Lens::Veil, 0), (Lens::Numen, 0)]),
|
||||
padded.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// A set that moves one weight by one is a change.
|
||||
let before = world.committed().await;
|
||||
let moved = set(&[(Lens::Charter, 3), (Lens::Numen, 1)]);
|
||||
submit(moved.clone()).await.expect("adding weight to numen commits");
|
||||
assert_eq!(world.committed().await.0, before.0 + 1);
|
||||
assert_eq!(world.snapshot().await.lens_weights, moved);
|
||||
}
|
||||
|
||||
/// Cut 3: `PlayPort` is the play authority's only door, and it is a public,
|
||||
/// externally constructible type — Dungeon mints exactly one of these in its
|
||||
/// `AppState`. This is a compile-pass use of the port from outside the crate:
|
||||
/// it mints quantity through `Mint`, the spelling only `Play` may write, on a
|
||||
/// resource it declares in the same patch, unconfined and with no answer.
|
||||
#[tokio::test]
|
||||
async fn play_port_is_the_only_play_minter() {
|
||||
let world = World::active().await;
|
||||
let play = PlayPort::new(world.mailbox.clone());
|
||||
let holder = world
|
||||
.snapshot()
|
||||
.await
|
||||
.subjects
|
||||
.first()
|
||||
.expect("genesis declares at least one subject")
|
||||
.id;
|
||||
let patch: WorldPatch = serde_json::from_value(json!({
|
||||
"declarations": [
|
||||
{
|
||||
"type": "entity",
|
||||
"handle": "loot",
|
||||
"label": "Ruled Loot",
|
||||
"kind": "resource",
|
||||
"container": null,
|
||||
},
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"op": "mint",
|
||||
"holder": {"ref": "existing", "value": holder},
|
||||
"resource": {"ref": "draft", "value": "loot"},
|
||||
"qty": 5,
|
||||
},
|
||||
],
|
||||
"evidence": [],
|
||||
}))
|
||||
.expect("a mint operation, off the wire, decodes into a WorldPatch");
|
||||
let receipt = play
|
||||
.submit_patch(CommandId::new(), patch)
|
||||
.await
|
||||
.expect("the play authority mints with no answer, unconfined");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
/// A lens name the library does not know cannot be decoded into a command
|
||||
/// body, beside names it does. This is a serde limit, stated as one: the body
|
||||
/// never exists, so no kernel admission is being proven here.
|
||||
#[test]
|
||||
fn set_lens_weights_with_an_unknown_lens_does_not_decode() {
|
||||
let refused = serde_json::from_value::<CommandBody>(json!({
|
||||
"type": "set_lens_weights",
|
||||
"weights": {"patina": 1, "veil ": 1},
|
||||
}));
|
||||
assert!(refused.is_err(), "an unknown lens decoded: {refused:?}");
|
||||
let accepted = serde_json::from_value::<CommandBody>(json!({
|
||||
"type": "set_lens_weights",
|
||||
"weights": {"patina": 1, "veil": 1},
|
||||
}))
|
||||
.expect("known lens names decode");
|
||||
assert!(matches!(accepted, CommandBody::SetLensWeights { .. }));
|
||||
}
|
||||
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
Binary file not shown.
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight.
|
||||
|
||||
Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test
|
||||
Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs),
|
||||
which authors VerseGrammar, three VerseVerb and two Faction records through
|
||||
Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH.
|
||||
Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc
|
||||
(contains aetheria-release cut verse-grammar, merged at 35af2930).
|
||||
Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc
|
||||
dotnet test tests/Aetheria.Shared.Tests
|
||||
-p:CultLibRoot=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
|
||||
--filter "FullyQualifiedName~Fixture_writer_is_deterministic"
|
||||
run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10.
|
||||
Content: six records; two writes of the writer agree on content, though not bytes
|
||||
(CultCache stamps storedAt from the wall clock). Regenerate and diff on content,
|
||||
never hand-edit.
|
||||
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
Binary file not shown.
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
3
|
||||
sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d
|
||||
sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979
|
||||
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"a534b32a-4d00-429c-bf6c-ce1d55385380"
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
# Call Of The Void Brainstorm
|
||||
|
||||
Source: https://docs.google.com/document/d/1YWn5RWsciBxuaQ4l5TN2lAEz4okaQes-aiyx1E9NAxA/edit?tab=t.0
|
||||
|
||||
This is a local plaintext import of the scattered Call of the Void brainstorming doc. Treat it as raw source material, not canon. The current compact design note remains E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md.
|
||||
|
||||
## Current Canon Correction
|
||||
|
||||
This brainstorm predates a major Aetheria timeline migration.
|
||||
|
||||
The old version assumes a colony fleet or exploratory force was shunted into
|
||||
Elysium and slowly discovers that nobody is going home. That is no longer the
|
||||
live premise.
|
||||
|
||||
Current canon, summarized in
|
||||
`E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\From Sol to Elysium.md`,
|
||||
is that the FTL trigger sequestered the whole Solar system, with its fleets,
|
||||
institutions, corporate blocs, uploaded minds, labor systems, histories, and
|
||||
unresolved social machinery. Elysium is not a remote elsewhere that a fleet
|
||||
cannot leave. It is the sealed domain into which Sol itself was displaced.
|
||||
|
||||
So the useful dramatic pressure is not "can the expedition return home?"
|
||||
because home came with them. The better pressure is:
|
||||
|
||||
- there is no clean outside to return to
|
||||
- the systems people hoped to escape are trapped in Elysium too
|
||||
- the old order can no longer rely on the old universe remaining stable around
|
||||
it
|
||||
- every faction is trying to define what survival, legitimacy, personhood, and
|
||||
ownership mean now that Sol's whole inheritance has been exported into the
|
||||
sealed domain
|
||||
- Cat's story should turn on the realization that starting over is not escape
|
||||
when the machinery you fled arrived in the same disaster
|
||||
|
||||
The raw notes below remain useful for cases, characters, tones, and comic
|
||||
cruelty. Treat their old "return home" twist as obsolete pressure, not active
|
||||
continuity.
|
||||
|
||||
---
|
||||
|
||||
Potential ideas here, feel free to discuss/deplore/detour in comments.
|
||||
|
||||
|
||||
Protagonist: Catastrophe “Cat” Marrigan, an out of work private investigator, driving a particularly failure-prone space taxi to make ends meet
|
||||
|
||||
|
||||
Overarching plot: Episodic PI cases/random space taxi fares which lead, somewhat circuitously, towards the discovery/revelation that the “exploratory force” had better get used to being “locals”, because the universe is not offering return tickets, and all that follows from that.
|
||||
|
||||
|
||||
What’s Cat’s role in this? And are we going to have alternate endings based on choices made? Ideas, in no particular order:
|
||||
* She's the original founder/inspiration for the frontier renegades who reject corporate rule and live low-tech and tiny to avoid being crushed.
|
||||
* The more noir-esque ending, where the disillusioned protagonist sees the way things are going and kills themselves/goes out in a blaze of explosive glory in a futile attempt to change the course of society.
|
||||
* The out-for-what-you-can-get ending, where she allies with an up and coming power to become a behind the scenes force in future aetheria.
|
||||
* The really unsatisfying but realistic ending, where the corps take over and life just goes on.
|
||||
* I sort of like the idea of heading towards a “you can’t win against the capitalist machine, but it’s worth fighting anyway” theme. Thoughts?
|
||||
|
||||
|
||||
case/fares/conversations that exemplify the different ways people react to the point of no return, so to speak, ie: denial, determination to find a way to get back, culty doomsdays types, the “we’re all doomed so let’s party” people, the ones who see great opportunity without old earth holding them back, the people who are determined to do things better this time around (in the ‘we royally screwed up earth, let’s not do it again” sense), etc.
|
||||
|
||||
|
||||
Accidental AI ship whose buggy programming makes them the worst autopilot ever orrrrr…the inciting incident is that in one of her cases/fares gone wrong Cat gets mind-melded with said slightly buggy AI, and that’s how she finds out all those juicy government secrets. Some of them may be inaccurate (see: bad code/failsafes/sabotage), but there’s no way of knowing which without going digging. Plus AI is a one of a kind experimental prototype, which sets all kinds of interesting people on their tail. Also could give us some fun things to do with ship control and actual gameplay quests.
|
||||
Over the course of the story, one way to show people’s reactions to events could be to have some dialogued characters talking about what they plan to do when they get back home, then use it as a major stress point once the truth is revealed.
|
||||
|
||||
|
||||
Maybe Cat works for a small, kind of terrible PI company, in which case we could get some of our quests from a dispatcher/boss/whatever. I think it’d be a little funny if in true noir fashion, boss was a hard-drinking dude in a trenchcoat, only he’s a really giggly drunk/can’t hold his liquor.
|
||||
First quest for PI company: find where the boss left a random object after drinking himself to the floor. Find out he left a whole-ass ship somewhere in his drunken stupor.
|
||||
Another PI quest: investigate the disappearing foodstuffs of a concerned homeowner and find out that a whole nother person was living in the crawlspace above their home and stealing their food. Decide how to deal with Mr Crawlspace.
|
||||
PI quest: Investigate “ghost” sightings in an old part of a station that’s been shut down. Find out a whole town’s worth of people are living there out of the station’s control slowly siphoning off resources so as not to be noticed. Get some slightly culty vibes from them.
|
||||
A short quest from an old lady who just wants to have someone over for tea. Turns out if you tolerate her long enough she’ll come in handy during a critical time. Could be she’s either rich or well-connected.
|
||||
A botanist looking for his daughter. Turns out instead of some sorta protomolecule conspiracy, she’s just wandered off and is being looked after by a kind lady who’s found her.
|
||||
Get a quest from a mysterious person who is only contacting Cat online, find out eventually the quest giver is some sort of AI who’s either seeking the sweet release of deletion, is looking to find another AI, or is trying to steal some sort of drone so it can go around freely.
|
||||
Possibly a character who we only see as a remote-controlled drone who helps out at times but we never see the person behind the drone because they use the drone as their public avatar or something. Could be someone who might not otherwise get the chance to go out and about in public because of reasons. This does give them a limited sphere of helpfulness since the drone has to be close enough to the controller to work effectively. Could have Cat mistake the drone for the character, believing it’s some sort of self-sufficient AI driven drone and be sad when it “dies” only to meet it again once it’s re-built and realise there’s a person behind it. Bit like the “pay no attention to the man behind the curtain” bit, could call the drone “Oz”.
|
||||
|
||||
|
||||
Cat is tasked with investigating a mysterious and aggressive band of pirates. Upon finding them it is revealed that they are actually a community of rogue AIs who have no option but to loot technology transports in order to keep themselves alive.
|
||||
|
||||
|
||||
Cat stumbles into a friendship with a shady character, earning the attention of the even shadier Space CIA, which recruits her as a potential double agent with instructions to infiltrate the organization.
|
||||
|
||||
|
||||
Cat is sent to investigate a series of mysterious disappearances of ships sent to mine a gas giant’s belt. Locals consider the place haunted. Turns out it is, by a sentient particle system which considers itself the embodied spirit of the planet.
|
||||
|
||||
|
||||
DLC Area: Cat falls afoul of some sort of anomaly which sends her into a parallel universe with an alternative history. Somehow despite a very different timeline and political situation, everyone she meets is a bizarro mirror of the people she knows from her dimension, and somehow the exact same cast of characters are present despite having very different roles (listen, we can’t afford any extra actors so we’re just gonna use the cast we already have)
|
||||
|
||||
|
||||
Finch is destructively scanning recently deceased human brains and then performing extensive virtual surgery on emulated instances of them in order to create and sell packaged “AI” servitors for specific tasks to compete on the market against AGI products from competitors. The Crew manages to rescue one of the program’s victims while still mostly human, before the process is complete, who [joins the party / is installed on Cat’s ship].
|
||||
|
||||
|
||||
Character: Reginald Moneybags III, inheritor of a massive family fortune, is looking to become a patron of the arts in order to impress his old-money friends. Perhaps they’re the one giving the player their ship?
|
||||
|
||||
|
||||
Might could explore some of Cat’s motivations for taking the ship to Elysium in the first place. Was it by choice? Who has she left behind, if anyone? It would put her in a more conflicted position regarding her role as a double-agent.
|
||||
* Seems natural that she would be escaping a maze of debt and obligation, only to end up terrifyingly close to falling into the same trap again. I kinda like this idea of completely dumping her whole life and trying to start over somewhere completely different.
|
||||
* She was caught committing some felony/is railroaded/scapegoated for same/investigated someone way too powerful and was given a choice between prison and joining the colonists.
|
||||
* Or combine both and bring back the idea of debtor’s prisons/workhouses and have the ship to Elysium be offered as a way out
|
||||
* So, was she indentured when she arrived only to recently gain the freedom to make her own way, or has she been doing what she’s currently doing since arrival?
|
||||
|
||||
|
||||
The faceless man: a guy? maybe. Wearing a spacesuit or some other obscuring disguise that pops up a little too often for random chance. Is he a red herring? Or is he 10 red herrings in a spacesuit pretending to be a guy? If he has any dialogue let it just be *splashing noises*.
|
||||
|
||||
|
||||
Cat is sent to investigate an urban legend on one other stations--the simply and slightly erroneously historically named “Ripper Lee” who lives in the air vents and steals children. It actually turns out that the missing kids are just exploring the vents as children will and getting macheted by fans or getting stuck or getting pulled into the inertial compensator. [an act II continuation of this quest sees Cat investigating her suspicions that the kids didn’t necessarily go into the dangerous areas of their own impetus, but were lured there. The third act reveals an alien intelligence--though an animal one--a being of energy or something stranger who finds the particular pitch of childrens’ laughter unbearable. Maybe it destructively interferes with its phase, who knows]
|
||||
|
||||
|
||||
The one perpetual drunk who always needs a lift to a different space bar. Cat can go on
|
||||
a bender with him as a quest, and then have to figure out exactly what the hell happened and why there’s a crash test dummy in the front seat of her taxi.
|
||||
|
||||
|
||||
The person who jumps into Cat’s cab and yells “follow that ship!”. A high octane space chase follows, at the end of which it is revealed they’ve been following the wrong ship, and the Minister of such and such is *very* displeased that he missed his Very Important Meeting for what turns out to be not even an actual assassination attempt.
|
||||
|
||||
|
||||
A carjacking with several different options for resolution
|
||||
|
||||
|
||||
Something strange is going on in the mining operation on PXR 6937, and Cat is sent to find out what: suggestions: the drilling released a substance from the rock that causes hallucinations and paranoia in human brains. Someone is deliberately sabotaging the operation as an environmental protest/act of general rebellion/the mining operation killed someone they cared about and now everyone must feel the same misery
|
||||
|
||||
|
||||
Cat is forced to navigate the pointless bureaucracy of space offices to uncover an embezzlement scheme that conceals something much bigger. What is it? Fuck if I know.
|
||||
* A furniture selling operation that appears to cover some financial wizardry but turns out to be a human trafficking operation where the names of the items are actually those of missing children OW MY HEART
|
||||
* A fiendish plan to utilize the combined computing power of thousands of office servers to...mine future bitcoin? Solve something the megas don’t want solved?
|
||||
|
||||
|
||||
Cat’s taxi breaks down somewhere off the beaten path, so to speak, and she finds herself drawn into an ongoing and very brutal feud between two families.
|
||||
|
||||
|
||||
Have a random cab passenger speak in Nemsk and never explain it.
|
||||
|
||||
|
||||
An entire district of a certain station orbiting a strange ocean planet begin to complain of seeing people who could logically never be there. Cat is asked to do a cursory look into it and remains sceptical until one day she starts seeing a person shaped figure always walking just barely out of her line of sight. We could either go full Lem, or subvert this reference by having the reveal be that the air or water are contaminated with a hallucinogenic compound.
|
||||
|
||||
|
||||
A Captain Nemo like figure who flies his rather unusual ship on the far edges of the system. His purpose is unclear, and his identity more so.
|
||||
|
||||
|
||||
Silica storage ruptured, spilling into common area. Now we’re doing the obligatory beach episode! Sand got into the gears of the docking bay so now everyone has to sit back for an afternoon and do anime tropes while it's getting repaired.
|
||||
|
||||
|
||||
What organization is Cat infiltrating?
|
||||
* People who are trying to go home, even if it costs them the ship or other people’s lives
|
||||
* Believe the Megas are withholding research on the return trip, trapping everyone in Elysium for profit
|
||||
* Name ideas
|
||||
* Home (nice and simple; is it an acronym: H.O.M.E.?)
|
||||
* A nickname could be the “homies” lmao love it “wassup homie?”
|
||||
* Terra
|
||||
* The Terra-rists or Terrists (like a hick would say) Get out no.
|
||||
* Could be a media nickname (and everyone is really tired of explaining that “no, no, we’re the Alliance for Returning Swiftly Earthward, really” really? Butt jokes? I have never pretended to be above them. Neither have I.
|
||||
* The Earth-Bound
|
||||
* The Regressionists
|
||||
* The Returners (sounds like a ya novel title) true
|
||||
* The Retrograde Alliance
|
||||
* Earthers
|
||||
* The Genesis Project (Project Genesis? Sounds better.) Genesis Faction?
|
||||
* Alliance for Returning Swiftly Earthward
|
||||
* Gaia-ists possibly too many vowels
|
||||
* The Followers of Gaia? Get that good greek mythology I kind of like this one. The Gaia Cult lmao
|
||||
* Cult of Gaiains, but also yeah, the GaiaCult
|
||||
* Hades (if we’re going Greek mythology, normal people go to Hades while demigods go to Elysium) Erebus
|
||||
* Has the bonus “evil” connotation
|
||||
* Hadesians Perhaps? Like people from there...Hadesites?
|
||||
* Fits into the sentence “The CIA needs you to infiltrate Hades”
|
||||
* Though if we’re going for the greek theme, Odysseans might be more apropos with the whole ‘returning home’ I like this for greek references
|
||||
|
||||
|
||||
|
||||
|
||||
How do we handle double agent shenanigans?
|
||||
* Cat grows to sympathise with their goals and philosophy, ends up rejecting the CIA
|
||||
* Cat comes clean with her new friends, who begin reciting the typical cliche, only to have someone start to read a page that semi-describes events later in the story, like it's a script.
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
# Aetheria Agent Population Model
|
||||
|
||||
This note explains how a socially dense Aetheria region can feel inhabited
|
||||
without simulating every implied person as a fully persistent agent.
|
||||
|
||||
The short answer is that population and agency are not the same thing.
|
||||
|
||||
A corridor can imply hundreds of thousands or millions of people while only a
|
||||
few dozen individuals need to exist as full long-lived social nodes.
|
||||
|
||||
## Design Goal
|
||||
|
||||
We want a region that feels:
|
||||
|
||||
- crowded
|
||||
- economically alive
|
||||
- politically pressured
|
||||
- socially stratified
|
||||
- full of rumor, labor, traffic, and institutional drag
|
||||
|
||||
We do **not** want:
|
||||
|
||||
- ten thousand expensive little chatbots idling in a warehouse
|
||||
- fake scale achieved by silent cardboard extras
|
||||
- a world where only named characters seem to exist
|
||||
|
||||
So the population model needs to separate:
|
||||
|
||||
- who exists as a persistent social mind
|
||||
- who exists as a grouped social force
|
||||
- who exists only as local scene color
|
||||
- how offscreen population pressure becomes visible
|
||||
|
||||
## Four Population Layers
|
||||
|
||||
The MVP should use four layers.
|
||||
|
||||
### 1. Persistent Agents
|
||||
|
||||
These are the full agents.
|
||||
|
||||
They have:
|
||||
|
||||
- identity
|
||||
- memory
|
||||
- relationships
|
||||
- goals
|
||||
- obligations
|
||||
- changing stance toward institutions and people
|
||||
|
||||
These are the characters who accumulate history and can plausibly remember what
|
||||
happened last week, who lied to them, who saved them, and which institution
|
||||
just humiliated them.
|
||||
|
||||
Examples in an Aetheria content-generation slice:
|
||||
|
||||
- a faction liaison
|
||||
- a ship officer
|
||||
- a labor organizer
|
||||
- a corporate handler
|
||||
- a sanctuary coordinator
|
||||
- a black-market broker
|
||||
- a cutout vendor
|
||||
- a technical specialist with inconvenient loyalties
|
||||
|
||||
These are not the population. They are the pressure points where
|
||||
population-scale systems become legible.
|
||||
|
||||
### 2. Cohorts
|
||||
|
||||
Cohorts are grouped social bodies that matter but do not need full individual
|
||||
cognition.
|
||||
|
||||
They should represent classes of people whose collective condition can change
|
||||
over time.
|
||||
|
||||
Examples:
|
||||
|
||||
- dock labor shifts
|
||||
- convoy deck crews
|
||||
- insurer back-office clerks
|
||||
- sanctuary residents
|
||||
- clinic queues
|
||||
- pirate route crews
|
||||
- private security contractors
|
||||
- debt pilgrims
|
||||
- repair-bay technicians
|
||||
- refugee transit waves
|
||||
|
||||
Each cohort should have state such as:
|
||||
|
||||
- size
|
||||
- location
|
||||
- stress
|
||||
- trust in institutions
|
||||
- rumor exposure
|
||||
- faction sympathy
|
||||
- fatigue
|
||||
- scarcity pressure
|
||||
- protest potential
|
||||
- desertion / mutiny risk
|
||||
|
||||
This is how the world gets to have labor, crowds, queues, shortages, and
|
||||
unrest without pretending every forklift operator needs a hundred memories and
|
||||
a private speech cadence.
|
||||
|
||||
### 3. Ephemeral Scene Actors
|
||||
|
||||
These are temporary people instantiated only when a scene needs bodies,
|
||||
witnesses, clerks, mechanics, medics, bystanders, or small bits of friction.
|
||||
|
||||
They should have:
|
||||
|
||||
- a role
|
||||
- a scene goal
|
||||
- local awareness of current events
|
||||
- a little voice
|
||||
- minimal short-term memory
|
||||
|
||||
They should **not** be treated as deep persistent minds by default.
|
||||
|
||||
Examples:
|
||||
|
||||
- a berth marshal waving traffic through a panic checkpoint
|
||||
- a frightened passenger arguing with a clerk
|
||||
- a dockhand spreading a fresh rumor
|
||||
- a medic triaging one more body than the shift can really handle
|
||||
- a sanctimonious insurer junior who disappears once the scene ends
|
||||
|
||||
If an ephemeral keeps mattering, the system can promote them into a persistent
|
||||
agent later.
|
||||
|
||||
### 4. Ambient Population Pressure
|
||||
|
||||
This is the layer that makes the world feel bigger than the visible cast.
|
||||
|
||||
It is not represented by people at all. It is represented by signals.
|
||||
|
||||
Examples:
|
||||
|
||||
- berth queues growing or shrinking
|
||||
- cargo backlogs
|
||||
- corridor delay maps
|
||||
- sanctuary occupancy
|
||||
- clinic wait times
|
||||
- food prices
|
||||
- insurance repricing
|
||||
- labor slowdown notices
|
||||
- protest bulletins
|
||||
- casualty lists
|
||||
- rescue requests
|
||||
- media chatter
|
||||
- tribunal rulings
|
||||
- route closures
|
||||
|
||||
This is where the implied millions live in practice.
|
||||
|
||||
## How The Layers Interact
|
||||
|
||||
The model should feel like this:
|
||||
|
||||
- ambient pressure changes cohort state
|
||||
- cohort state changes the incentives around persistent agents
|
||||
- persistent agents make decisions that alter ambient pressure
|
||||
- ephemeral actors express the local scene consequences
|
||||
|
||||
That loop lets a single adjudicator decision or pirate rescue cascade outward
|
||||
into visible population-level effects without simulating every single person
|
||||
affected.
|
||||
|
||||
## Why A Few Dozen Persistent Agents Is Enough
|
||||
|
||||
Because the simulation is not trying to create a census. It is trying to create
|
||||
a socially legible machine.
|
||||
|
||||
In a dense Aetheria slice, one persistent agent can sit at the hinge point for
|
||||
a great many people:
|
||||
|
||||
- one dispatcher affects dozens of ships and hundreds of crew
|
||||
- one sanctuary coordinator gates a network under pressure from thousands of vulnerable lives
|
||||
- one corporate official can reshape access, risk, and reputation
|
||||
- one faction delegate can legitimize or delegitimize a compromise
|
||||
- one broker can redirect shadow traffic across many dependent cells
|
||||
|
||||
The persistent cast is therefore best understood as:
|
||||
|
||||
- decision-makers
|
||||
- bottlenecks
|
||||
- brokers
|
||||
- organizers
|
||||
- interpreters of institutional rules
|
||||
- people with unusual leverage over larger flows
|
||||
|
||||
They are the knots in the rope, not every fiber.
|
||||
|
||||
## Cohort Model
|
||||
|
||||
Cohorts should be tractable and opinionated.
|
||||
|
||||
Each cohort record should include:
|
||||
|
||||
- `cohortId`
|
||||
- `type`
|
||||
- `sizeBand`
|
||||
- `region`
|
||||
- `factionAffinity`
|
||||
- `currentPressures`
|
||||
- `resourceDependency`
|
||||
- `confidence`
|
||||
- `grievance`
|
||||
- `discipline`
|
||||
- `mobilizationPotential`
|
||||
- `recentIncidents`
|
||||
|
||||
Useful cohort types for Aetheria story and content generation:
|
||||
|
||||
- dock labor
|
||||
- ship crews
|
||||
- sanctuary residents
|
||||
- corporate workforce
|
||||
- shadow-market crews
|
||||
- repair-yard workers
|
||||
- shadow clinic clients
|
||||
- private security detachments
|
||||
- local merchant bodies
|
||||
- transient passenger pools
|
||||
|
||||
Cohorts do not need rich dialogue. They need believable collective reactions.
|
||||
|
||||
## Ephemeral Actor Rules
|
||||
|
||||
Ephemeral actors should be generated from:
|
||||
|
||||
- scene location
|
||||
- cohort state
|
||||
- current world pressures
|
||||
- factional tone
|
||||
- time of day / shift
|
||||
|
||||
That means the same dock concourse can produce different incidental people
|
||||
depending on whether the corridor is calm, panicking, starving, over-inspected,
|
||||
or politically inflamed.
|
||||
|
||||
Ephemerals should support:
|
||||
|
||||
- local exposition
|
||||
- scene texture
|
||||
- small moral friction
|
||||
- witness testimony
|
||||
- short disputes
|
||||
- visible consequences
|
||||
|
||||
They should expire cheaply unless the world keeps pulling them back into
|
||||
relevance.
|
||||
|
||||
## Promotion Rules
|
||||
|
||||
Some ephemerals should be promoted into persistent agents when:
|
||||
|
||||
- a player or major agent forms an ongoing relationship with them
|
||||
- they become central to repeated incidents
|
||||
- they accumulate leverage or notoriety
|
||||
- they are the surviving witness to something politically explosive
|
||||
- they become the face of a cohort-level shift
|
||||
|
||||
Promotion is how the world grows specific people organically instead of
|
||||
requiring the whole cast to be authored up front.
|
||||
|
||||
## Population Signals That Make A Place Feel Busy
|
||||
|
||||
If the corridor is meant to feel inhabited, the system should constantly
|
||||
surface signs of offscreen life.
|
||||
|
||||
That means scenes, dashboards, and local UI should be fed with things like:
|
||||
|
||||
- arrivals and departures
|
||||
- route advisories
|
||||
- late manifests
|
||||
- dockside rumor digests
|
||||
- occupancy warnings
|
||||
- strike whispers
|
||||
- inspection delays
|
||||
- tribunal alerts
|
||||
- debt-claim notices
|
||||
- casualty and rescue numbers
|
||||
- commodity shocks
|
||||
- public feeds and scandal bursts
|
||||
|
||||
The player does not need to meet everyone. They need to feel surrounded by
|
||||
consequences.
|
||||
|
||||
## Content-Generation Population Recipe
|
||||
|
||||
For the first authoring slice, a sensible starting mix is:
|
||||
|
||||
- **20-40 persistent agents**
|
||||
- **10-25 cohorts**
|
||||
- **0-20 ephemerals per active scene**, generated on demand
|
||||
|
||||
Persistent clusters should be chosen from the story context, but the useful
|
||||
categories are:
|
||||
|
||||
- primary faction representatives
|
||||
- ship or station operators
|
||||
- labor and logistics figures
|
||||
- care, sanctuary, or rights workers
|
||||
- criminal or deniable-economy brokers
|
||||
- corporate cutouts
|
||||
- technical specialists
|
||||
- witnesses with leverage
|
||||
|
||||
Cohorts:
|
||||
|
||||
- dock labor
|
||||
- crews
|
||||
- sanctuary or civilian overflow
|
||||
- clinic queues
|
||||
- administrative or inspection staff
|
||||
- security contractors
|
||||
- shadow-market cells
|
||||
- repair techs
|
||||
- cargo handlers
|
||||
- passengers / transients
|
||||
|
||||
That is enough to make generated scenes feel socially populated without turning
|
||||
the authoring runtime into a screaming furnace.
|
||||
|
||||
## Simulation Tier Mapping
|
||||
|
||||
The population layers should map onto the simulation tiers cleanly.
|
||||
|
||||
### Tier 0
|
||||
|
||||
- dormant persistent agents
|
||||
- slowly changing cohorts
|
||||
- coarse ambient indicators
|
||||
|
||||
### Tier 1
|
||||
|
||||
- active persistent agents making strategic moves
|
||||
- cohorts shifting under pressure
|
||||
- route, labor, and sanctuary state updating
|
||||
|
||||
### Tier 2
|
||||
|
||||
- a handful of persistent agents in scene
|
||||
- relevant ephemerals generated for the local moment
|
||||
- cohort state informing mood, risk, and crowd behavior
|
||||
|
||||
### Tier 3
|
||||
|
||||
- full interactive dialogue for the most relevant persistent agents
|
||||
- ephemerals only as needed for texture or interruption
|
||||
- world pressure visible through feeds, queues, alerts, and consequences
|
||||
|
||||
This keeps expensive cognition focused where it matters.
|
||||
|
||||
## What Not To Do
|
||||
|
||||
Do not:
|
||||
|
||||
- simulate every resident as a full agent
|
||||
- let cohorts become inert statistical wallpaper
|
||||
- use ephemerals as consequence-free decoration
|
||||
- make named agents behave as if the rest of the world does not exist
|
||||
- make ambient scale purely visual with no mechanical pressure
|
||||
|
||||
That path leads to a world that looks crowded and feels dead.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The model is working if:
|
||||
|
||||
- the player can feel institutional pressure without being told about it in lore prose
|
||||
- small decisions by key agents visibly affect larger populations
|
||||
- cohorts can become restless, relieved, fearful, or politically active
|
||||
- scenes feel crowded when they should
|
||||
- the same corridor can feel different under calm, panic, shortage, scandal, or repression
|
||||
- the named cast feels embedded in a larger society rather than posed in a diorama
|
||||
|
||||
The real test is simple:
|
||||
|
||||
If the player leaves the room, do they still believe thousands of people are
|
||||
having a bad day because of what just happened?
|
||||
|
||||
If yes, the world is inhabited.
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
# Aetheria Content Generation Targets
|
||||
|
||||
This is the first concrete target note for applying Ghostlight to Aetheria.
|
||||
|
||||
The build target is not a standalone corridor simulation or local crisis game
|
||||
slice. That older shape was fertile story terrain, but it is not the current
|
||||
product direction. Do not let it shape the runtime unless the user explicitly
|
||||
revives it.
|
||||
|
||||
Ghostlight should first serve two Aetheria uses:
|
||||
|
||||
1. content generation inside the **Call of the Void** slice described in the
|
||||
`AetheriaLore` repository
|
||||
2. dialogue scaffolding and procedural drama for narrated stories across the
|
||||
Aetheria timeline
|
||||
|
||||
The goal is a story and content-generation organ: a machine that can hold
|
||||
characters, factions, pressures, memories, masks, and conflict arcs in explicit
|
||||
state, then project that state into usable scenes, dialogue beats, outlines,
|
||||
and dramatic complications.
|
||||
|
||||
That organ must also hold tone. Aetheria should not be flattened into one dry
|
||||
technical crisis register. The setting can carry Adams/Pratchett-style
|
||||
wit-with-stakes, domestic comedy, noir suspicion, horror, wonder, dour poetry,
|
||||
romance, workplace absurdity, and procedural systems prose, often inside the
|
||||
same broad canon. The common rule is not "be grim." The common rule is that
|
||||
tone must reveal character, setting, and consequence.
|
||||
|
||||
## Why This Helps Dialogue
|
||||
|
||||
Ghostlight should not ask one LLM prompt to "write good character drama" from
|
||||
vibes. That path produces clever paste: fluent, energetic, and usually a little
|
||||
dead behind the eyes.
|
||||
|
||||
The useful split is:
|
||||
|
||||
- Ghostlight builds the drama, narrative pressure, scene setup, and state.
|
||||
- The dialogue renderer gives each character a constrained local context.
|
||||
- The character speaks from who they are, what they have endured, what they
|
||||
want, what they are feeling, what they are hiding, and how they perceive the
|
||||
person in front of them.
|
||||
|
||||
This does not mean every line should sound like emergency procedure. Characters
|
||||
also speak from routine, fatigue, affection, boredom, private jokes, little
|
||||
status games, cultural rituals, and ordinary work. Those are not filler; they
|
||||
are what make later pressure legible.
|
||||
|
||||
That means dialogue prompts should be character-local rather than omniscient.
|
||||
They should include only the state that character plausibly has access to:
|
||||
|
||||
- identity and role
|
||||
- current mood and pressure
|
||||
- relevant memories
|
||||
- active goals
|
||||
- relationship stance toward the listener
|
||||
- perceived stakes
|
||||
- cultural or factional scripts
|
||||
- mask, concealment, or presentation strategy
|
||||
|
||||
This lets characters breathe and act inside the scene instead of serving as
|
||||
mouthpieces for a single plot-generating agent. The agent can shape the drama.
|
||||
The character projection should preserve subjectivity.
|
||||
|
||||
## Primary Use: Call Of The Void Content
|
||||
|
||||
Call of the Void should be treated as the first consumer context for Ghostlight,
|
||||
not as an excuse to build a full social MMO simulation immediately.
|
||||
|
||||
The local source of truth is:
|
||||
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md`
|
||||
- `docs/aetheria/call-of-the-void-brainstorm.md` for raw imported brainstorm
|
||||
material
|
||||
|
||||
The slice is a story-first Aetheria scope cut about Catastrophe "Cat" Marrigan:
|
||||
an out-of-work private investigator making ends meet with a failure-prone space
|
||||
taxi inside the aftermath of Sol's sequestration into Elysium.
|
||||
|
||||
The older brainstorm frames the core twist as a colony fleet or exploratory
|
||||
force discovering that nobody is going home. That premise is obsolete. Current
|
||||
Aetheria canon says the FTL trigger displaced the whole Solar system into the
|
||||
sealed spacetime domain later called Elysium. Home did not stay behind. Home,
|
||||
with every corporate bloc, labor regime, upload abuse, political failure, and
|
||||
unresolved compromise, came through too.
|
||||
|
||||
So the active Call of the Void pressure should be refactored from "there is no
|
||||
return ticket" into something meaner and more useful: there is no outside, no
|
||||
clean restart, and no frontier innocent of Sol's history. Cat can still try to
|
||||
outrun debt, coercion, legal pressure, or institutional rot, but Elysium proves
|
||||
that escape exported the machinery instead of dissolving it.
|
||||
|
||||
Its useful shape is authored density:
|
||||
|
||||
- episodic investigations
|
||||
- taxi fares as social x-rays
|
||||
- station districts
|
||||
- recurring characters
|
||||
- faction contacts
|
||||
- campaign consequence
|
||||
- the social aftermath of realizing Sol's old failures are now Elysium's local
|
||||
weather
|
||||
|
||||
Ghostlight should help generate and manage:
|
||||
|
||||
- character dossiers
|
||||
- faction stances
|
||||
- relationship tensions
|
||||
- tonal mode and style targets
|
||||
- ordinary-life texture before conflict
|
||||
- scene premises
|
||||
- dialogue scaffolds
|
||||
- procedural complications
|
||||
- continuity-aware reactions
|
||||
- memory-shaped follow-up scenes
|
||||
- local cultural and institutional pressure
|
||||
- case premises and clue chains
|
||||
- passenger encounters that reveal denial, opportunism, homesickness, fear, or reckless celebration
|
||||
- continuity pressure from the sealed-domain premise
|
||||
- cases that expose how old Sol institutions mutate when there is no larger
|
||||
outside to appeal to
|
||||
|
||||
The important thing is that generated content should emerge from structured
|
||||
state instead of a prompt asking for "dramatic dialogue" and hoping the machine
|
||||
does not start juggling knives in the pantry.
|
||||
|
||||
## Secondary Use: Narrated Timeline Stories
|
||||
|
||||
Ghostlight should also support story generation across Aetheria's broader
|
||||
timeline.
|
||||
|
||||
The existing Aetheria stories are tonal evidence. `When We Get Home` makes the
|
||||
setting legible through jokes, domestic longing, debt, delays, and the slow
|
||||
recognition that home has moved. `Rain` makes the setting legible through
|
||||
ritual, memory, sensory repetition, and a patient reversal from wonder to
|
||||
horror. Future fixtures should be able to choose similarly varied modes instead
|
||||
of filing every event into one stern technical drawer.
|
||||
|
||||
That means it should be able to produce:
|
||||
|
||||
- scene outlines
|
||||
- relationship escalation chains
|
||||
- factional pressure maps
|
||||
- alternate dialogue passes
|
||||
- conflict beats
|
||||
- character-specific reactions to historical events
|
||||
- continuity summaries for later story work
|
||||
|
||||
This does not require every story to run as a live simulation. Some outputs can
|
||||
be generated as authoring scaffolds: structured suggestions, scene cards,
|
||||
argument maps, motive webs, and dialogue skeletons for a human author to shape.
|
||||
|
||||
## What Ghostlight Should Build For This
|
||||
|
||||
The first useful organs are:
|
||||
|
||||
1. canonical agent state schema
|
||||
2. perceived-state overlay schema
|
||||
3. relationship and faction stance records
|
||||
4. event and scene records
|
||||
5. prompt projection renderer for content generation
|
||||
6. coordinator/story runtime driven by goals, values, pressure, memory,
|
||||
continuity, branch flags, and author constraints
|
||||
7. branch compiler that turns reviewed branch plans into Ink, sidecars,
|
||||
callbacks, consequence variables, and visual prompt handles
|
||||
8. IF artifact reviewer that catches decorative choices, fake folds, unused
|
||||
variables, and missing consequence callbacks
|
||||
9. dialogue scaffold renderer that preserves stance, mask, and relationship
|
||||
asymmetry
|
||||
10. character-local dialogue context packs that restrict each generated voice to
|
||||
what that character knows, feels, wants, fears, and perceives
|
||||
|
||||
The output should be inspectable. The author should be able to see why the
|
||||
machine thinks a character would deflect, confess, threaten, soften, bargain,
|
||||
lie, leave, spend a resource, conceal evidence, or make a choice that changes a
|
||||
later scene.
|
||||
|
||||
## What This Is Not
|
||||
|
||||
This is not yet:
|
||||
|
||||
- a standalone corridor crisis game
|
||||
- a persistent corridor simulator
|
||||
- a full MMO social runtime
|
||||
- a requirement to simulate every offscreen person
|
||||
- a replacement for authorial judgment
|
||||
|
||||
Ghostlight is an authoring and social-state engine first. The live game-shaped
|
||||
organs can come later if they earn the trouble.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The first implementation is useful if it can:
|
||||
|
||||
- keep character state distinct from prompt prose
|
||||
- render dialogue from character-local context rather than global plot vibes
|
||||
- generate branching dialogue/action scaffolds that reflect relationship history
|
||||
- create procedural drama from incompatible goals instead of random hostility
|
||||
- preserve cultural and factional pressure without making clones
|
||||
- produce story beats a human author can actually use
|
||||
- make branch choices matter through later affordances, callbacks, visual
|
||||
modifiers, costs, risks, and endings
|
||||
- explain its choices in state terms rather than vibes in a rented coat
|
||||
|
||||
The real test is not whether the system can improvise a cool scene once. The
|
||||
real test is whether it can help generate the tenth scene without forgetting
|
||||
what the first nine did to everyone involved.
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
# Aetheria Lore Grounding Architecture
|
||||
|
||||
Ghostlight needs three different Aetheria modes:
|
||||
|
||||
- grounded historical training
|
||||
- post-Rupture procedural future training
|
||||
- procedural Elysium generation
|
||||
|
||||
Do not blur them. That is how a useful mystery becomes a filing cabinet full of
|
||||
branch facts with the labels scraped off.
|
||||
|
||||
## Core Boundary
|
||||
|
||||
Planned Aetheria gameplay takes place in Elysium. What happens there is
|
||||
intentionally not fully fixed in the lore vault because discovering and shaping
|
||||
that history is part of the games themselves.
|
||||
|
||||
The Aetheria timeline begins when humanity is transplanted into Elysium. It can
|
||||
restart when humanity destroys itself again through negligence, greed,
|
||||
institutional capture, civilizational momentum, and the familiar psychological
|
||||
trash fire wearing a crown.
|
||||
|
||||
That means Elysium-era gameplay scenes are not the best first source for
|
||||
grounded Ghostlight training data. They are downstream procedural territory.
|
||||
|
||||
For grounded training data, Ghostlight should start from the authored historical
|
||||
archive: late Sol, Pre-Elysium institutions, factional flashpoints, personhood
|
||||
conflicts, labor regimes, political collapse, and the FTL trigger.
|
||||
|
||||
That does not mean Ghostlight should avoid Elysium futures. The trained models
|
||||
will need to handle post-Rupture concepts: Aether, pseudospace, temporal
|
||||
nonlinearity, spirits, altered substrates, necrotech, mutable bodies, and all
|
||||
the new ways power learns to monetize miracles while pretending it found ethics
|
||||
in a drawer.
|
||||
|
||||
Elysium future data belongs in a separate lane because its canon is conditional,
|
||||
not because it is fake. Sol history is a single artifact. Elysium history is a
|
||||
branching state-space: every possible future is canon inside the branch whose
|
||||
prior conditions produced it. A generated future must therefore carry branch
|
||||
lineage, dependencies, and local truth boundaries instead of being treated as
|
||||
scratch material waiting for permission to matter.
|
||||
|
||||
## Revised Architecture
|
||||
|
||||
Grounded historical training flow:
|
||||
|
||||
```text
|
||||
Aetheria authored historical lore
|
||||
-> lore grounding digest
|
||||
-> historical scene fixture
|
||||
-> agent-state fixture
|
||||
-> speaker-local input slice
|
||||
-> projection artifact
|
||||
-> prompt_text
|
||||
-> generated dialogue or drama
|
||||
-> reviewed training example
|
||||
```
|
||||
|
||||
Procedural Elysium flow:
|
||||
|
||||
```text
|
||||
grounded pre-Elysium priors
|
||||
-> procedural Elysium branch state
|
||||
-> Ghostlight agents and institutions
|
||||
-> projected dialogue and drama
|
||||
-> simulated branch history
|
||||
-> branch-local canonical state
|
||||
```
|
||||
|
||||
Post-Rupture future training flow:
|
||||
|
||||
```text
|
||||
authored Elysium concepts and constraints
|
||||
-> possible future premise
|
||||
-> procedural branch fixture
|
||||
-> coordinator plan and projected local contexts
|
||||
-> generated action, dialogue, and event consequences
|
||||
-> reviewed future-branch training artifact
|
||||
-> branch-local canonical history indexed by lineage
|
||||
```
|
||||
|
||||
The first flow teaches the machine what grounded social pressure looks like.
|
||||
The second and third flows teach it how those pressures mutate under Elysium's
|
||||
stranger conditions while keeping branch-local canon separate from the Sol
|
||||
artifact and from sibling Elysium branches.
|
||||
|
||||
## Source Priority
|
||||
|
||||
For projection training, prefer sources in this order:
|
||||
|
||||
1. Authored historical lore with explicit institutional, social, and political
|
||||
context.
|
||||
2. Grounded transition material around the FTL trigger and displacement.
|
||||
3. Historical fiction or scene fragments that remain canon-compatible.
|
||||
4. Authored post-Elysium concept documents that define altered conditions,
|
||||
technologies, species, metaphysics, or social pressures.
|
||||
5. Elysium-era procedural fixtures marked with explicit branch lineage and
|
||||
conditional truth boundaries.
|
||||
|
||||
Call of the Void and Cat/Oz can remain useful for testing projection mechanics,
|
||||
speaker-local boundaries, and prompt shape. They should not be treated as the
|
||||
primary linear-history training corpus because their facts belong to Elysium's
|
||||
conditional branch space.
|
||||
|
||||
## Historical Flashpoint Candidates
|
||||
|
||||
Good first fixtures should come from parts of Aetheria that are already
|
||||
pressure-dense and source-grounded:
|
||||
|
||||
- contract citizenship and debt regimes
|
||||
- labor-system enforcement and infrastructure dependency
|
||||
- upload abuse and contested personhood
|
||||
- uplift or nonhuman personhood conflicts
|
||||
- algorithmic governance and predictive policing
|
||||
- corporate blocs turning infrastructure into leverage
|
||||
- the FTL program as a political escape hatch from Sol's failure
|
||||
- the moment around the FTL trigger, where institutions and people discover
|
||||
that escape did not mean absolution
|
||||
|
||||
The best scenes are small, local, and socially loaded:
|
||||
|
||||
- a worker negotiating dangerous contract terms
|
||||
- an upload arguing over continuity, ownership, or service denial
|
||||
- a technician asked to sign off on an unsafe FTL dependency
|
||||
- a debtor deciding whether help is relief or capture
|
||||
- an institutional agent performing compassion while enforcing a machine
|
||||
- a family, crew, or workplace discovering that political collapse has become
|
||||
intimate
|
||||
|
||||
## Lore Grounding Digests
|
||||
|
||||
Before producing training projection examples from a historical flashpoint,
|
||||
create a lore grounding digest.
|
||||
|
||||
The digest format is defined in:
|
||||
|
||||
- `docs/architecture/lore-grounding-digest-format.md`
|
||||
- `schemas/lore-grounding-digest.schema.json`
|
||||
- `examples/lore-grounding/historical-flashpoint.template.json`
|
||||
|
||||
A digest should include:
|
||||
|
||||
- source files and relevant line or section notes
|
||||
- time period
|
||||
- location or institution
|
||||
- involved factions, classes, and roles
|
||||
- material constraints
|
||||
- local norms, taboos, and scripts
|
||||
- vocabulary or speech-register cues
|
||||
- what each speaker plausibly knows
|
||||
- what each speaker misreads
|
||||
- author-only context that must not enter prompt text
|
||||
- open questions needing lore elaboration
|
||||
|
||||
It should also include cultural and factional pressure fields:
|
||||
|
||||
- internal virtues
|
||||
- internal shames
|
||||
- prestige markers
|
||||
- taboo triggers
|
||||
- emotional display norms
|
||||
- trust defaults
|
||||
- speech register cues
|
||||
- outsider stereotypes
|
||||
- factional misread patterns
|
||||
- role obligations and failure modes
|
||||
|
||||
The digest is not a replacement for the lore vault. It is a compiled working
|
||||
slice for one fixture, so projection examples can cite grounded pressure
|
||||
without dragging the whole archive into every prompt.
|
||||
|
||||
## Fixture Types
|
||||
|
||||
Use explicit fixture status:
|
||||
|
||||
- `historical_grounded`
|
||||
- `transition_grounded`
|
||||
- `procedural_branch`
|
||||
- `future_branch`
|
||||
- `branch_lineage_canon`
|
||||
|
||||
`historical_grounded` and `transition_grounded` examples can become primary
|
||||
single-history training data.
|
||||
|
||||
`procedural_branch` and `future_branch` examples are canonical inside their own
|
||||
branch lineage. They are useful for testing, generation, responder training,
|
||||
coordinator training, and post-Rupture concept coverage, but must not be treated
|
||||
as facts that also apply to sibling branches or to the Sol artifact.
|
||||
|
||||
`branch_lineage_canon` examples are reviewed branch histories whose lineage,
|
||||
prior conditions, and local state have been made explicit enough to serve as
|
||||
stable training or gameplay continuity.
|
||||
|
||||
Use `future_branch` when the point of the fixture is to explore possible
|
||||
post-Rupture Elysium outcomes and weird-concept behavior. Use
|
||||
`procedural_branch` for ordinary gameplay-era branch fixtures whose main job is
|
||||
scene/runtime testing.
|
||||
|
||||
## Projection Rules
|
||||
|
||||
Grounded projection examples should prove:
|
||||
|
||||
- the prompt text is self-sufficient for the dialogue model
|
||||
- setting and institutional context are speaker-local
|
||||
- historical pressure appears as lived constraint, not exposition paste
|
||||
- the speaker does not know author-only future outcomes
|
||||
- the generated behavior follows from state, culture, memory, relationship, and
|
||||
material pressure
|
||||
|
||||
If the lore source is too vague for a convincing scene, stop and elaborate the
|
||||
lore slice first. Do not train the model to pave over missing social reality
|
||||
with confident fog.
|
||||
|
||||
## Post-Rupture Future Branches
|
||||
|
||||
Future-branch generation should be deliberate, not a slush pile of glittering
|
||||
possibilities with no handles.
|
||||
|
||||
Each possible future fixture should declare:
|
||||
|
||||
- which authored post-Elysium concepts it exercises
|
||||
- which facts are fixed source constraints
|
||||
- which facts are generated branch assumptions
|
||||
- which branch facts are local to this possible future
|
||||
- which prior conditions make this branch true
|
||||
- which facts belong to this branch lineage and not to sibling branches
|
||||
- which generated facts must not leak back into single-history Sol training
|
||||
- what new social pressure the weird concept creates
|
||||
- what old pre-Elysium pressure is returning in a new costume
|
||||
|
||||
## Attractors, Rails, And Injections
|
||||
|
||||
Elysium is a branching state-space, not an undifferentiated fog. Some events may
|
||||
happen in most branches because the pressures almost always produce them. Some
|
||||
events may be authored constraints because the game needs a stable enough world
|
||||
to design around. Some events may be injected by quests or scenario logic when
|
||||
an active branch reaches a compatible shape.
|
||||
|
||||
Use separate labels:
|
||||
|
||||
- `branch_attractor`
|
||||
- `fated_event`
|
||||
- `tech_order_constraint`
|
||||
- `quest_injection`
|
||||
- `branch_local_event`
|
||||
|
||||
`branch_attractor` means an event is highly likely under broad pressures but
|
||||
not absolutely forced. The founding of a faction born inside Elysium may start
|
||||
here if the social, economic, metaphysical, and institutional gradients make it
|
||||
show up again and again with local variation.
|
||||
|
||||
`fated_event` means the authorial hand is on the scale. The event should occur
|
||||
unless the branch has explicitly disabled it. Use this sparingly, because too
|
||||
much fate turns the quantum superposition back into a hallway with rude
|
||||
wallpaper.
|
||||
|
||||
`tech_order_constraint` means discovery order matters. Some technologies,
|
||||
metaphysical practices, industrial dependencies, or social adaptations should be
|
||||
unavailable until their prerequisites exist. This is not merely lore flavor; it
|
||||
is a runtime gate for what quests, economies, factions, and character actions
|
||||
can plausibly do.
|
||||
|
||||
`quest_injection` means authored content enters the active timeline at a branch
|
||||
point. A quest may require prerequisites, forbid certain prior states, create
|
||||
new local facts, or push an attractor toward one concrete manifestation.
|
||||
|
||||
`branch_local_event` means the event is canonical inside its lineage but has no
|
||||
special pull across other branches.
|
||||
|
||||
Future branch fixtures should record:
|
||||
|
||||
- event type from the labels above
|
||||
- probability or pull strength, for attractors
|
||||
- required branch prerequisites
|
||||
- excluded branch states
|
||||
- technology prerequisites
|
||||
- faction or institution prerequisites
|
||||
- quest ids or content hooks, if injected
|
||||
- local variation allowed
|
||||
- hard facts created
|
||||
- branch facts that remain conditional
|
||||
|
||||
Future branch fixtures should also emit technology and item manifest candidates
|
||||
when exploration changes the material economy:
|
||||
|
||||
- new item families
|
||||
- new item variants
|
||||
- component and subassembly breakdowns
|
||||
- assembly upgrade surfaces
|
||||
- technology prerequisites
|
||||
- faction or manufacturer access
|
||||
- supply bottlenecks
|
||||
- salvage, counterfeit, maintenance, and substitution paths
|
||||
- quest hooks created by scarcity or discovery
|
||||
|
||||
See `docs/architecture/technology-item-manifest-plan.md`.
|
||||
|
||||
Factions born in Elysium should be modeled through this lens. Adrasteia or Miss
|
||||
Terri's Sugarrific Snack Company can be represented as pressure-born attractors,
|
||||
fated institutions, quest-injected branch content, or some combination. The
|
||||
point is not to decide that once in prose and hope the game remembers. The point
|
||||
is to make the founding mechanism explicit enough for Ghostlight, the active
|
||||
timeline, and later game systems to agree on what kind of inevitability they are
|
||||
dealing with.
|
||||
|
||||
Good future branches should stress the model with conditions it cannot learn
|
||||
from late-Sol history alone:
|
||||
|
||||
- Aether as infrastructure, ecology, religion, weapon, medicine, or status good
|
||||
- pseudospace as transit, concealment, displacement, exile, or labor terrain
|
||||
- temporal nonlinearity as memory, debt, evidence, grief, insurance, or law
|
||||
- spirits as persons, tools, contaminants, witnesses, ancestors, or frauds
|
||||
- necrotech as care, extraction, continuity, punishment, or labor capture
|
||||
- mutable bodies and species boundaries as liberation, class marker, liability,
|
||||
taboo, or supply chain
|
||||
- post-Rupture institutions rebuilding old hierarchies with new miracles
|
||||
|
||||
These branches should feed:
|
||||
|
||||
- Aetheria responder training, so the model learns post-Rupture assumptions
|
||||
- coordinator training, so the story runtime can handle weird scene premises
|
||||
- appraiser and relationship training, so characters misread new phenomena in
|
||||
socially grounded ways
|
||||
- institution/faction/consumer training, so economic and political behavior can
|
||||
respond to miracles as markets, threats, privileges, and dependencies
|
||||
|
||||
Keep source provenance strict. A branch can invent a local future, but it should
|
||||
say what it invented and what branch conditions make it true. The machine may
|
||||
generate futures under supervision; it does not get to erase the branch index
|
||||
while everyone is looking at the shiny ghost.
|
||||
|
||||
## Operating Doctrine
|
||||
|
||||
Ghostlight will generate a lot of Aetheria dialogue, branching scenes, and
|
||||
training data. That means it will also be the first tool to stress-test many
|
||||
timeline details at room scale. Treat that as part of the job, not an
|
||||
exception.
|
||||
|
||||
Before writing an Aetheria scene or generating data from one, do a source pass:
|
||||
|
||||
1. Check AetheriaLore for the factions, social movements, institutions, species
|
||||
or body types, location, and time period present in the scene.
|
||||
2. Extract the material constraints that shape behavior: habitat form,
|
||||
infrastructure, law, money, route access, labor pressure, class position,
|
||||
surveillance, body affordances, communication channels, and local failure
|
||||
costs.
|
||||
3. Distill those details into the lore grounding digest, agent-state fixture,
|
||||
projected local context, or Ink sidecar before prompting a response model.
|
||||
4. Keep the prompt character-local. Source grounding tells Ghostlight what the
|
||||
room and culture make possible; it does not grant characters omniscience.
|
||||
5. If the lore does not support a needed concrete detail, do not fill the gap
|
||||
with confident furniture. Mark the gap, elaborate the AetheriaLore source,
|
||||
then regenerate or revise the Ghostlight artifact from the patched source.
|
||||
6. Commit the lore patch in AetheriaLore and cite the source path from
|
||||
Ghostlight so the training artifact has a clean provenance trail.
|
||||
|
||||
The output goal is not only clean training data. It is also playable, readable,
|
||||
fun interactive fiction that discovers where the worldbuilding is thin by
|
||||
leaning on it. If Ghostlight needs to know what a sanctuary intake board looks
|
||||
like to a cetacean Navigator, that is not trivia. That is worldbuilding under
|
||||
load.
|
||||
|
||||
## Lore Gap Policy
|
||||
|
||||
When a gap appears, classify it before writing:
|
||||
|
||||
- `blocking`: generation would produce wrong bodies, wrong institutions, wrong
|
||||
authority, or wrong material constraints without the missing detail.
|
||||
- `safe_to_stub`: the scene can proceed with a clearly marked provisional
|
||||
detail that does not affect canon-critical behavior.
|
||||
- `defer`: the detail is decorative and should not enter the prompt yet.
|
||||
|
||||
Blocking gaps should be patched in AetheriaLore first. Safe stubs must be
|
||||
tracked in the digest or sidecar and revisited before promotion. Deferred gaps
|
||||
should stay out of prompt text so they do not harden by accident.
|
||||
|
||||
## Training Boundary
|
||||
|
||||
Ground the machine in the past, then generate possible futures under
|
||||
supervision with branch labels sharp enough that infinity does not show up
|
||||
wearing one nametag.
|
||||
|
||||
Historical fixtures teach single-history social pressure. Future-branch
|
||||
fixtures teach conditional canon. Cat/Oz-style procedural fixtures teach
|
||||
runtime mechanics. Those lanes can compare against each other, but they should
|
||||
not collapse into one unlabeled corpus.
|
||||
|
|
@ -1,586 +0,0 @@
|
|||
# Aetheria Persistent Agent Architecture
|
||||
|
||||
This is the companion document to
|
||||
[Things To Steal](../research/things-to-steal.md).
|
||||
|
||||
For the first concrete target, see
|
||||
[Aetheria Content Generation Targets](./aetheria-content-generation-targets.md).
|
||||
|
||||
For the scaling model that explains how a crowded region can be simulated with
|
||||
a relatively small persistent cast, see
|
||||
[Aetheria Agent Population Model](./aetheria-agent-population-model.md).
|
||||
|
||||
For the deeper note on how culture, personality distributions, memory, and
|
||||
perceived state should project into prompts, see
|
||||
[Agent State Distributions And Prompt Projection](./agent-state-distributions-and-prompt-projection.md).
|
||||
|
||||
That document is the theft list. This one is the machine we are trying to build
|
||||
out of the stolen parts.
|
||||
|
||||
The long-horizon target is a persistent world full of in-universe agents who:
|
||||
|
||||
- remember what happened
|
||||
- want different things
|
||||
- collide with each other because their values and interests do not line up
|
||||
- interact with humans without collapsing into quest dispensers or therapy plushies
|
||||
|
||||
The important bit is that the world should not exist solely to flatter the
|
||||
player. Humans are participants, not the sun around which every synthetic
|
||||
little bastard must orbit.
|
||||
|
||||
## Design Goals
|
||||
|
||||
The near-term target is content generation and story scaffolding, not a
|
||||
standalone game slice. Ghostlight should first help Aetheria authors generate
|
||||
dialogue, scene pressure, procedural drama, and character reactions from
|
||||
explicit social state.
|
||||
|
||||
We want a world where:
|
||||
|
||||
- agents persist across sessions
|
||||
- relationships evolve over time
|
||||
- off-screen life continues
|
||||
- conflict emerges from incentives
|
||||
- the same character remains recognizably themselves under pressure
|
||||
- human intervention can redirect events without erasing everyone else's agency
|
||||
|
||||
We do **not** want:
|
||||
|
||||
- a thin layer of LLM chat stapled onto static NPC behavior
|
||||
- every character becoming a slightly differently dressed assistant
|
||||
- a world that only animates when a human looks at it
|
||||
- fake drama generated by random hostility instead of meaningful friction
|
||||
|
||||
## World Model
|
||||
|
||||
The world should be represented as a persistent simulation with five main state
|
||||
layers:
|
||||
|
||||
1. **Canonical world state**
|
||||
- locations
|
||||
- factions
|
||||
- resources
|
||||
- institutions
|
||||
- active conflicts
|
||||
- world-level events
|
||||
2. **Agent identity state**
|
||||
- role
|
||||
- background
|
||||
- loyalties
|
||||
- values
|
||||
- aversions
|
||||
- appetites
|
||||
- speech style
|
||||
3. **Agent dynamic state**
|
||||
- current location
|
||||
- current obligations
|
||||
- current mood / pressure / fatigue
|
||||
- current social exposure
|
||||
- short-horizon plans
|
||||
4. **Relationship state**
|
||||
- trust
|
||||
- respect
|
||||
- resentment
|
||||
- fascination
|
||||
- fear
|
||||
- obligation
|
||||
- debt
|
||||
5. **Memory state**
|
||||
- episodic incidents
|
||||
- scene summaries
|
||||
- semantic profile conclusions
|
||||
- relationship summaries
|
||||
- active inferred goals and concerns
|
||||
|
||||
## Agent Stack
|
||||
|
||||
Each agent should be built from explicit layers instead of one giant persona
|
||||
prompt.
|
||||
|
||||
### 1. Identity Layer
|
||||
|
||||
Relatively stable facts:
|
||||
|
||||
- who they are
|
||||
- what they believe
|
||||
- what they want to be seen as
|
||||
- what they cannot tolerate
|
||||
- what they are drawn toward
|
||||
|
||||
This should be treated as durable but revisable. Identity is not immutable, but
|
||||
neither should it drift because the model got bored.
|
||||
|
||||
### 2. Relationship Layer
|
||||
|
||||
Every important other should have a relationship record, including:
|
||||
|
||||
- another agent
|
||||
- a human player
|
||||
- a group or faction
|
||||
|
||||
The record should track:
|
||||
|
||||
- trust
|
||||
- respect
|
||||
- resentment
|
||||
- dependence
|
||||
- attraction or fascination where relevant
|
||||
- leverage
|
||||
- recent unresolved incidents
|
||||
|
||||
The point is not to create one friendship slider like a life sim from 2004. The
|
||||
point is to track the actual shape of a social bond.
|
||||
|
||||
### 3. Goal Layer
|
||||
|
||||
Agents need active goals, not just a biography.
|
||||
|
||||
Goals should be:
|
||||
|
||||
- private or public
|
||||
- immediate or long-range
|
||||
- strategic or emotional
|
||||
- revisable in response to new information
|
||||
|
||||
A goal object should include:
|
||||
|
||||
- description
|
||||
- priority
|
||||
- time horizon
|
||||
- emotional stake
|
||||
- blockers
|
||||
- dependencies
|
||||
- known rivals
|
||||
- likely next moves
|
||||
|
||||
### 4. Memory Layer
|
||||
|
||||
Memory should be split into:
|
||||
|
||||
- **working memory**
|
||||
- current scene
|
||||
- immediate context
|
||||
- active inferences
|
||||
- **episodic memory**
|
||||
- timestamped incidents
|
||||
- where, who, what, consequence
|
||||
- **scene memory**
|
||||
- distilled summaries of interactions
|
||||
- **semantic memory**
|
||||
- generalized beliefs about self, others, and world
|
||||
- **relationship memory**
|
||||
- remembered incidents and inferred patterns tied to a specific other
|
||||
|
||||
This is where the current interaction-memory work is directly useful.
|
||||
|
||||
### 5. Theory-of-Mind Layer
|
||||
|
||||
Each active interaction should have a lightweight scratchpad:
|
||||
|
||||
- what the other party likely wants
|
||||
- what they fear
|
||||
- what they think is happening
|
||||
- what they are trying to get from me
|
||||
- how confident we are about those guesses
|
||||
|
||||
This should be ephemeral and revisable. First impressions are useful, but
|
||||
worshipping them is how systems become smug and stupid.
|
||||
|
||||
## Simulation Tiers
|
||||
|
||||
A persistent world cannot run every agent at full dialogue fidelity all the
|
||||
time unless we want to immolate the hardware.
|
||||
|
||||
So the world should run on tiers.
|
||||
|
||||
### Tier 0: Dormant Persistence
|
||||
|
||||
For agents far away from any relevant action:
|
||||
|
||||
- maintain location, obligations, and long-horizon goals
|
||||
- advance the clock coarsely
|
||||
- update minimal state
|
||||
|
||||
### Tier 1: Strategic Tick
|
||||
|
||||
For agents in active regions but not in a live scene:
|
||||
|
||||
- update goals
|
||||
- choose broad actions
|
||||
- move, trade, coordinate, prepare, avoid, investigate
|
||||
- write compact event records
|
||||
|
||||
### Tier 2: Scene Simulation
|
||||
|
||||
For agents inside a socially or narratively relevant scene:
|
||||
|
||||
- model who is present
|
||||
- what each actor knows
|
||||
- what each actor wants
|
||||
- who is hiding what
|
||||
- who is under pressure
|
||||
- resolve short exchanges, negotiations, or conflicts
|
||||
|
||||
### Tier 3: Live Interactive Presence
|
||||
|
||||
For agents currently engaged with a human or central dramatic scene:
|
||||
|
||||
- full dialogue generation
|
||||
- richer perception and recall
|
||||
- fine-grained ToM updates
|
||||
- explicit action selection tied to the live interaction
|
||||
|
||||
This tiered model is how we get a world that feels alive without paying for a
|
||||
small national grid.
|
||||
|
||||
## Core Loop
|
||||
|
||||
At a high level, each active agent loop looks like:
|
||||
|
||||
1. Perceive
|
||||
- scene state
|
||||
- nearby actors
|
||||
- new events
|
||||
- human input
|
||||
2. Interpret
|
||||
- what matters
|
||||
- what changed
|
||||
- what this implies for goals and relationships
|
||||
3. Update internal state
|
||||
- working memory
|
||||
- ToM scratchpad
|
||||
- emotional pressure / stance
|
||||
4. Retrieve memory
|
||||
- relevant episodes
|
||||
- relationship incidents
|
||||
- semantic conclusions
|
||||
5. Evaluate goals
|
||||
- what is threatened
|
||||
- what is newly possible
|
||||
- what should take precedence now
|
||||
6. Choose action
|
||||
- speech
|
||||
- movement
|
||||
- trade
|
||||
- alliance
|
||||
- concealment
|
||||
- refusal
|
||||
- violence
|
||||
- delay
|
||||
7. Commit result
|
||||
- world state update
|
||||
- event log
|
||||
- memory writes
|
||||
8. Reflect later
|
||||
- consolidate incidents
|
||||
- revise relationship state
|
||||
- revise semantic beliefs
|
||||
- revise goals
|
||||
|
||||
## Relationship Engine
|
||||
|
||||
The relationship system should be treated as first-class infrastructure, not
|
||||
decorative metadata.
|
||||
|
||||
Each relationship record should have:
|
||||
|
||||
- scores or categorical state for trust, respect, resentment, obligation, fear, fascination
|
||||
- a short summary of the current relationship
|
||||
- the unresolved incidents driving that summary
|
||||
- known pressure points
|
||||
- current access level
|
||||
|
||||
Example consequences:
|
||||
|
||||
- low trust + high obligation = brittle alliance
|
||||
- low respect + high dependence = contemptuous reliance
|
||||
- high fascination + high fear = unstable orbit
|
||||
- high resentment + high access = sabotage risk
|
||||
|
||||
That is where drama comes from.
|
||||
|
||||
## Goal Engine
|
||||
|
||||
Goals should be generated and updated from:
|
||||
|
||||
- identity
|
||||
- current needs
|
||||
- memory
|
||||
- faction context
|
||||
- scene opportunities
|
||||
- rival actions
|
||||
|
||||
We should distinguish between:
|
||||
|
||||
- **maintenance goals**
|
||||
- survive
|
||||
- maintain station
|
||||
- avoid exposure
|
||||
- **strategic goals**
|
||||
- secure influence
|
||||
- gain a resource
|
||||
- undermine a rival
|
||||
- **emotional goals**
|
||||
- seek vindication
|
||||
- restore dignity
|
||||
- protect someone
|
||||
- punish betrayal
|
||||
|
||||
Emotional goals matter because otherwise every character becomes a boring little
|
||||
optimization engine with no pulse.
|
||||
|
||||
## Scene Engine
|
||||
|
||||
A scene should be an explicit object, not just "some chat happened."
|
||||
|
||||
A scene record should contain:
|
||||
|
||||
- location
|
||||
- participants
|
||||
- public stakes
|
||||
- hidden stakes
|
||||
- triggering event
|
||||
- current tension axes
|
||||
- known facts by actor
|
||||
- unknown facts by actor
|
||||
- scene outcome
|
||||
|
||||
Useful tension axes:
|
||||
|
||||
- status
|
||||
- safety
|
||||
- secrecy
|
||||
- leverage
|
||||
- affection
|
||||
- ideology
|
||||
- scarcity
|
||||
|
||||
The scene engine should decide:
|
||||
|
||||
- whether to escalate
|
||||
- whether to de-escalate
|
||||
- whether to fork into violence, bargain, confession, retreat, or deception
|
||||
|
||||
## Human Participation
|
||||
|
||||
Humans should be one force in the world, not the privileged audience every
|
||||
character was born to adore.
|
||||
|
||||
That means:
|
||||
|
||||
- agents should remember human actions
|
||||
- agents should form durable impressions of specific humans
|
||||
- agents should change how much they reveal, trust, or tolerate
|
||||
- agents should be willing to refuse or redirect
|
||||
- agents should sometimes try to use the human for their own ends
|
||||
|
||||
Humans should be able to:
|
||||
|
||||
- build alliances
|
||||
- ruin them
|
||||
- become symbols
|
||||
- become liabilities
|
||||
- become prey
|
||||
- become confidants
|
||||
|
||||
That is much more interesting than "click NPC, receive lore paragraph."
|
||||
|
||||
## Narrative Layer
|
||||
|
||||
We should not hard-script drama, but we should summarize it.
|
||||
|
||||
The narrative layer should:
|
||||
|
||||
- generate personal chronicles from actual events
|
||||
- generate regional or faction news from high-impact changes
|
||||
- generate rumor propagation when information is partial or distorted
|
||||
|
||||
The crucial rule:
|
||||
|
||||
- narrative is downstream of simulation
|
||||
- not a replacement for simulation
|
||||
|
||||
## How The Current Supporting Stack Maps To This
|
||||
|
||||
The nice part is that some useful scaffolding already exists elsewhere.
|
||||
|
||||
Current useful pieces:
|
||||
|
||||
- Postgres-backed persistent state
|
||||
- Qdrant-backed semantic retrieval
|
||||
- interaction memory with behavioral dimensions
|
||||
- source and lore indexing
|
||||
- provider-side tool use
|
||||
- owner-safe MCP patterns
|
||||
|
||||
What needs to be added:
|
||||
|
||||
- multi-agent world state store
|
||||
- relationship graph
|
||||
- explicit goal objects
|
||||
- scene scheduler
|
||||
- reflection/consolidation jobs for cast state
|
||||
- tiered simulation runtime
|
||||
- world-facing tools for observation and action
|
||||
|
||||
In other words, the existing conversational stack is already halfway to being a
|
||||
mind-and-memory service. What it lacks is the world model and the cast
|
||||
scheduler.
|
||||
|
||||
## Suggested Data Model
|
||||
|
||||
At minimum, the persistent store should grow tables or equivalents for:
|
||||
|
||||
- `world_regions`
|
||||
- `world_events`
|
||||
- `agents`
|
||||
- `agent_identity_snapshots`
|
||||
- `agent_dynamic_state`
|
||||
- `agent_goals`
|
||||
- `agent_relationships`
|
||||
- `agent_memories_episodic`
|
||||
- `agent_memories_scene`
|
||||
- `agent_memories_semantic`
|
||||
- `scene_instances`
|
||||
- `scene_participants`
|
||||
- `human_agent_interactions`
|
||||
- `rumors`
|
||||
|
||||
Qdrant or equivalent can remain the retrieval layer for:
|
||||
|
||||
- episodic recall
|
||||
- lore retrieval
|
||||
- source grounding
|
||||
- scene similarity
|
||||
|
||||
But the canonical social and world state should stay relational and
|
||||
inspectable.
|
||||
|
||||
## Suggested Runtime Services
|
||||
|
||||
The world runtime should likely split into:
|
||||
|
||||
- **world scheduler**
|
||||
- advances ticks
|
||||
- activates scenes
|
||||
- downgrades inactive agents
|
||||
- **agent executor**
|
||||
- runs an agent turn
|
||||
- retrieves memory
|
||||
- updates goals and relationships
|
||||
- **scene resolver**
|
||||
- handles active multi-party interactions
|
||||
- **reflection worker**
|
||||
- consolidates events into longer-term memory
|
||||
- **narrative worker**
|
||||
- writes chronicles and reports
|
||||
|
||||
That keeps the system legible instead of letting one monstrous worker do all
|
||||
the thinking and all the plumbing at once.
|
||||
|
||||
## Failure Modes To Watch For
|
||||
|
||||
### 1. Everyone Becomes The Same Person
|
||||
|
||||
The agent voices flatten and all conflict becomes cosmetic.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- strong identity layer
|
||||
- explicit value hierarchy
|
||||
- personality consistency evaluation
|
||||
|
||||
### 2. Memory Becomes Sludge
|
||||
|
||||
Everything is stored, nothing is distinguished, retrieval turns to soup.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- tiered memory
|
||||
- write-path filtering
|
||||
- periodic consolidation
|
||||
- clear forgetting policy
|
||||
|
||||
### 3. Drama Becomes Random Meanness
|
||||
|
||||
Characters feel chaotic rather than motivated.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- tie conflict to values, history, incentives, and pressure
|
||||
|
||||
### 4. Humans Break The World By Existing
|
||||
|
||||
Every character over-orients around the nearest player.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- keep off-screen goals live
|
||||
- preserve non-human social gravity
|
||||
- let humans matter without becoming the axis mundi
|
||||
|
||||
### 5. Sycophancy Wears A Costume
|
||||
|
||||
The bot sounds spicier but still validates whatever frame the user wants.
|
||||
|
||||
Countermeasure:
|
||||
|
||||
- explicit anti-sycophancy evaluation
|
||||
- persistent relationship memory
|
||||
- boundary-aware response guidance
|
||||
|
||||
## Build Order
|
||||
|
||||
### Phase 1: Authoring Cast Spine
|
||||
|
||||
- explicit agent identity dossiers
|
||||
- explicit goal objects
|
||||
- explicit relationship objects
|
||||
- episodic + semantic memory split
|
||||
|
||||
### Phase 2: Dialogue And Drama Scaffolding
|
||||
|
||||
- prompt projection for scene and dialogue drafts
|
||||
- relationship-aware dialogue skeletons
|
||||
- conflict beat generation from incompatible goals
|
||||
- author-visible rationale for generated moves
|
||||
|
||||
### Phase 3: Social Simulation
|
||||
|
||||
- scene objects
|
||||
- scene scheduler
|
||||
- relationship updates from scenes
|
||||
- goal revision from scenes
|
||||
|
||||
### Phase 4: Persistent World
|
||||
|
||||
- region state
|
||||
- off-screen strategic ticking
|
||||
- rumor/news propagation
|
||||
- faction and economy hooks
|
||||
|
||||
### Phase 5: Human Integration
|
||||
|
||||
- live player-facing interactions
|
||||
- persistent human reputation
|
||||
- access, trust, and consequence systems
|
||||
|
||||
### Phase 6: Rich Narrative Surface
|
||||
|
||||
- chronicles
|
||||
- regional reports
|
||||
- replay and spectator tooling
|
||||
|
||||
## The Real Ambition
|
||||
|
||||
The ambition here is not "better NPC dialogue."
|
||||
|
||||
It is:
|
||||
|
||||
- a world with inhabitants
|
||||
- inhabitants with memory
|
||||
- memory that changes what they want
|
||||
- wants that collide
|
||||
- collisions that produce story
|
||||
|
||||
That is the actual prize. Everything else is just wiring and smoke.
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# Agent State Distributions And Prompt Projection
|
||||
|
||||
This note describes how personality, culture, memory, and social context should
|
||||
be represented in structured state, and how that state should be projected into
|
||||
prompts for dialogue generation.
|
||||
|
||||
The central rule is simple:
|
||||
|
||||
- the prompt is not the source of truth
|
||||
- the prompt is a temporary projection of deeper state for one turn or one scene
|
||||
|
||||
If we try to store the whole person in prompt prose, the system turns back into
|
||||
theatrical mush. The real machine has to live in explicit state.
|
||||
|
||||
For the related notes on personality structure and dialogue classification, see:
|
||||
|
||||
- [Personality Model Roadmap](../research/personality-model-roadmap.md)
|
||||
- [Personality Signal Classifier](../research/personality-signal-classifier.md)
|
||||
|
||||
## Core Principle
|
||||
|
||||
We should model agents through:
|
||||
|
||||
1. canonical latent state
|
||||
2. perceived state for each agent
|
||||
3. prompt projection from perceived state into live generation
|
||||
|
||||
That gives us:
|
||||
|
||||
- stable world truth
|
||||
- subjective misunderstanding
|
||||
- efficient live prompting without pretending the prompt is the database
|
||||
|
||||
## State Families
|
||||
|
||||
Each agent should have explicit state families, not one giant personality blob.
|
||||
|
||||
### 1. Underlying Organization
|
||||
|
||||
These are deep structural variables:
|
||||
|
||||
- self-coherence
|
||||
- contingent worth
|
||||
- shame sensitivity
|
||||
- reciprocity capacity
|
||||
- mentalization quality
|
||||
- authenticity tolerance
|
||||
- mask rigidity
|
||||
- external-regulation dependence
|
||||
|
||||
These are slow-changing and often heavily shaped by early development and
|
||||
repeated relational history.
|
||||
|
||||
### 2. Stable Dispositions
|
||||
|
||||
These are relatively persistent tendencies:
|
||||
|
||||
- novelty-seeking
|
||||
- conformity
|
||||
- status hunger
|
||||
- risk tolerance
|
||||
- sociability
|
||||
- baseline threat sensitivity
|
||||
- aesthetic appetite
|
||||
- ideological rigidity
|
||||
|
||||
### 3. Behavioral Dimensions
|
||||
|
||||
These are first-class, not optional garnish. They belong in the rebuild from
|
||||
the start:
|
||||
|
||||
- interpersonal warmth
|
||||
- drive
|
||||
- grandiosity
|
||||
- validation-seeking
|
||||
- anxiety
|
||||
- control pressure
|
||||
- hostility
|
||||
- suspicion
|
||||
- rigidity
|
||||
- withdrawal
|
||||
- volatility
|
||||
- attachment-seeking
|
||||
- distance-seeking
|
||||
|
||||
These are the main handles for social behavior, institutional pressure, and
|
||||
consumer or factional drift.
|
||||
|
||||
### 4. Presentation Strategy
|
||||
|
||||
These describe how the self is performed:
|
||||
|
||||
- charm
|
||||
- compliance
|
||||
- superiority
|
||||
- detachment
|
||||
- seductiveness
|
||||
- competence theater
|
||||
- moral theater
|
||||
- strategic opacity
|
||||
- cultivated harmlessness
|
||||
- abrasive boundary
|
||||
- ironic distance
|
||||
|
||||
These are not just decorative style. In some agents they are survival
|
||||
machinery.
|
||||
|
||||
### 5. Voice Style
|
||||
|
||||
These describe how the performed self comes out in language:
|
||||
|
||||
- dryness
|
||||
- verbal warmth
|
||||
- formality
|
||||
- verbosity
|
||||
- pace
|
||||
- plainspoken directness
|
||||
- lexical precision
|
||||
- technical density
|
||||
- technical compression
|
||||
- figurative language
|
||||
- lyricism
|
||||
- narrative detail
|
||||
- emotional explicitness
|
||||
- pointedness
|
||||
- self disclosure
|
||||
- hedging
|
||||
- certainty marking
|
||||
- politeness
|
||||
- coded politeness
|
||||
- ritualized address
|
||||
- register switching
|
||||
- dialect marking
|
||||
- theatricality
|
||||
- humor
|
||||
- conversational dominance
|
||||
- listening responsiveness
|
||||
- question asking
|
||||
- profanity
|
||||
|
||||
### 6. Derived Verbal Responses
|
||||
|
||||
Defensive verbal moves are derived during projection. The canonical state stores
|
||||
the pressures and speech mechanics that make those moves likely.
|
||||
|
||||
Target emergent behaviors:
|
||||
|
||||
- sarcastic deflection can emerge from shame sensitivity, ironic distance,
|
||||
exposure pressure, suspicion, and a dry or pointed voice
|
||||
- sincerity evasion can emerge from mask rigidity, low authenticity tolerance,
|
||||
strategic opacity, and low emotional explicitness
|
||||
- emotional evasion can emerge from distance-seeking, withdrawal, acute shame,
|
||||
or control pressure with low self-disclosure
|
||||
- banter as boundary can emerge from humor, ironic distance, attachment
|
||||
pressure, and distance-seeking
|
||||
- verbal aggression can emerge from hostility, perceived status threat,
|
||||
grievance activation, and abrasive boundary
|
||||
|
||||
These are behaviors Ghostlight should be able to produce and test for without
|
||||
storing them as authored canonical traits.
|
||||
|
||||
### 7. Situational State
|
||||
|
||||
These are transient activations:
|
||||
|
||||
- exhaustion
|
||||
- scarcity pressure
|
||||
- humiliation
|
||||
- panic
|
||||
- triumph
|
||||
- grief
|
||||
- overstimulation
|
||||
- grievance activation
|
||||
- acute shame
|
||||
- perceived status threat
|
||||
|
||||
### 8. Relationship State
|
||||
|
||||
Per target person, faction, group, product domain, or institution:
|
||||
|
||||
- trust
|
||||
- resentment
|
||||
- dependence
|
||||
- fear
|
||||
- fascination
|
||||
- obligation
|
||||
- envy
|
||||
- moral disgust
|
||||
- perceived status gap
|
||||
- expectation of care
|
||||
- expectation of betrayal
|
||||
|
||||
### 9. Goals And Values
|
||||
|
||||
- active goals
|
||||
- protected values
|
||||
- emotional stakes
|
||||
- tradeoffs
|
||||
- unforgivables
|
||||
|
||||
## How To Represent Variables
|
||||
|
||||
Do not represent most of these as naked scalars if we want culture and history
|
||||
to shape them properly.
|
||||
|
||||
A practical representation is:
|
||||
|
||||
- `mean`
|
||||
- `plasticity`
|
||||
- `current_activation`
|
||||
|
||||
So instead of:
|
||||
|
||||
- `status_hunger = 0.72`
|
||||
|
||||
use something like:
|
||||
|
||||
- `status_hunger.mean = 0.72`
|
||||
- `status_hunger.plasticity = 0.22`
|
||||
- `status_hunger.current_activation = 0.89`
|
||||
|
||||
This gives us:
|
||||
|
||||
- stable baseline personality
|
||||
- different rates of change
|
||||
- scene-sensitive activation without rewriting the whole person every turn
|
||||
|
||||
Canonical authored state should not include epistemic uncertainty about itself.
|
||||
If a value is only an observer's read, store it as perceived state rather than
|
||||
canonical state. Perceived dimensions should not expose canonical `mean` or
|
||||
`plasticity`; they should track observed activation, attributed disposition,
|
||||
and confidence. This lets the model represent
|
||||
fundamental attribution bias: someone can behave a certain way under pressure
|
||||
and be perceived as simply being that way.
|
||||
|
||||
## Culture As A Prior Field
|
||||
|
||||
Culture should not create clones. Culture should shape distributions.
|
||||
|
||||
For each culture, faction, class stratum, or institution, define:
|
||||
|
||||
- prior means over variables
|
||||
- covariance structure
|
||||
- reward gradients
|
||||
- penalty gradients
|
||||
- preferred masks
|
||||
- taboo map
|
||||
- prestige map
|
||||
- default relationship posture to insiders, outsiders, rivals, inferiors, and superiors
|
||||
- favored narrative scripts
|
||||
|
||||
This means a culture is not "people here are high in trait X."
|
||||
|
||||
It is more like:
|
||||
|
||||
- this culture rewards controlled presentation
|
||||
- penalizes spontaneous vulnerability
|
||||
- elevates status anxiety
|
||||
- normalizes suspicion of low-status outsiders
|
||||
- attaches prestige to procedural fluency and emotional composure
|
||||
|
||||
That is how a Dominion or Lucent style environment should work: as a field that
|
||||
shapes what people become profitable or safe enough to be.
|
||||
|
||||
## Personal History As A Transform Layer
|
||||
|
||||
Culture is only the starting pressure.
|
||||
|
||||
Then the prior is transformed by:
|
||||
|
||||
- attachment history
|
||||
- class position
|
||||
- faction indoctrination
|
||||
- educational shaping
|
||||
- labor regime
|
||||
- remembered humiliations
|
||||
- remembered betrayals
|
||||
- remembered rescue, admiration, abandonment, and obligation
|
||||
|
||||
So agent state should be read as:
|
||||
|
||||
- cultural prior
|
||||
- transformed by developmental history
|
||||
- transformed by remembered episodes
|
||||
- currently activated by scene pressure
|
||||
|
||||
## Cohorts And Populations
|
||||
|
||||
The same representation can be lifted from individuals to populations.
|
||||
|
||||
A colony, district, labor bloc, or consumer cohort can have:
|
||||
|
||||
- shared priors
|
||||
- institutional conditioning
|
||||
- repeated collective incidents
|
||||
- relationship state toward factions, employers, media ecosystems, and product classes
|
||||
|
||||
This is much better than Big Five-style population sliders because it lets us
|
||||
model things like:
|
||||
|
||||
- prestige hunger
|
||||
- fear-state compliance
|
||||
- grievance accumulation
|
||||
- attachment to institutions
|
||||
- validation loops induced by media
|
||||
- suspicion induced by audit or betrayal
|
||||
|
||||
That is much closer to Aetheria than a broad trait scaffold alone.
|
||||
|
||||
## Canonical State Vs Perceived State
|
||||
|
||||
The world should not only track what is true. It should track what each agent
|
||||
thinks is true.
|
||||
|
||||
So we need:
|
||||
|
||||
- canonical world state
|
||||
- canonical agent state
|
||||
- per-agent perceived state
|
||||
|
||||
Perceived state includes:
|
||||
|
||||
- what this agent believes about others
|
||||
- what motives they attribute
|
||||
- what traits they think they are seeing
|
||||
- what mask they think someone is wearing
|
||||
- what threats or opportunities they think are present
|
||||
|
||||
This is where emotional intelligence, suspicion, attachment hunger, status
|
||||
anxiety, and cultural priors distort interpretation.
|
||||
|
||||
The acting agent should mostly be prompted from perceived state, not canonical
|
||||
truth. That is how misunderstanding becomes organic instead of hand-authored.
|
||||
|
||||
## Prompt Projection
|
||||
|
||||
Prompt rendering should be treated as projection, not storage.
|
||||
|
||||
For a live scene, build the prompt from:
|
||||
|
||||
### 1. Identity And Role
|
||||
|
||||
- who the agent is
|
||||
- what role they occupy
|
||||
- what they want to be seen as
|
||||
|
||||
### 2. Cultural Default Frame
|
||||
|
||||
- prestige rules
|
||||
- taboo zones
|
||||
- expected forms of control or intimacy
|
||||
- factional posture
|
||||
|
||||
### 3. Current Presentation Strategy
|
||||
|
||||
- how they are trying to appear right now
|
||||
- what they are concealing
|
||||
- what they must not look like
|
||||
|
||||
### 4. Current Situation
|
||||
|
||||
- scene pressure
|
||||
- public exposure
|
||||
- scarcity
|
||||
- humiliation risk
|
||||
- safety risk
|
||||
|
||||
### 5. Relationship Context
|
||||
|
||||
- what they think of the other party
|
||||
- what they need from them
|
||||
- what wounds or debts are active
|
||||
- what status relation they perceive
|
||||
|
||||
### 6. Relevant Memory
|
||||
|
||||
Not full history. Only:
|
||||
|
||||
- the few most relevant episodes
|
||||
- relationship summaries
|
||||
- current active fears, loyalties, or grievances
|
||||
- currently salient cultural scripts
|
||||
|
||||
### 7. Goals And Constraints
|
||||
|
||||
- what they are trying to achieve in this scene
|
||||
- what they cannot afford
|
||||
- what values they will not betray
|
||||
|
||||
### 8. Listener Model
|
||||
|
||||
If the scene involves another agent, include:
|
||||
|
||||
- what this agent thinks the other wants
|
||||
- what they fear the other is doing
|
||||
- where they may be misreading them
|
||||
|
||||
## What The Prompt Should Not Do
|
||||
|
||||
Do not make the prompt:
|
||||
|
||||
- the sole canonical store of identity
|
||||
- a full dump of all memories
|
||||
- a taxonomy recital
|
||||
- a fake omniscient summary that erases subjective misreading
|
||||
|
||||
The prompt should be compact, selective, and scene-bound. The state store
|
||||
carries the rest.
|
||||
|
||||
## Economic Consequences
|
||||
|
||||
This model gives us much more useful demand and cultural behavior than old
|
||||
trait-only systems.
|
||||
|
||||
For example:
|
||||
|
||||
- high status hunger plus validation-seeking plus fragile self-worth leads toward prestige goods, symbolic upgrades, and visible luxury
|
||||
- high threat sensitivity plus conformity plus shame reactivity leads toward safety goods, procedural systems, insurance, and respectable signaling
|
||||
- high drive plus novelty-seeking plus low institutional attachment leads toward frontier gear, gray-market experimentation, and risky migration
|
||||
- high mask rigidity plus high impression-management intensity leads toward corrective aesthetics, social camouflage, and reputation-maintenance consumption
|
||||
|
||||
That is how culture, labor, media, and institutions become market-shaping
|
||||
forces instead of mere flavor text.
|
||||
|
||||
## Recommended Implementation Path
|
||||
|
||||
1. Define the state schema with first-class support for:
|
||||
- underlying organization
|
||||
- stable dispositions
|
||||
- all core behavioral dimensions, including `volatility`, `attachment-seeking`, and `distance-seeking`
|
||||
- presentation strategies
|
||||
- voice styles
|
||||
- situational state
|
||||
- relationship state
|
||||
- goals and values
|
||||
2. Represent those variables as priors plus activation, not just fixed scalars.
|
||||
3. Define cultural and institutional prior templates.
|
||||
4. Add history transforms from remembered episodes.
|
||||
5. Add per-agent perceived state overlays.
|
||||
6. Build prompt renderers that project perceived state into live generation.
|
||||
7. Keep canonical state, perceived state, and prompt projection cleanly separated.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The right machine is not:
|
||||
|
||||
- a persona prompt with some adjectives taped to it
|
||||
- a canonical trait sheet with no cultural shaping
|
||||
- a direct line from "personality value" to dialogue
|
||||
|
||||
The right machine is:
|
||||
|
||||
- latent state distributions
|
||||
- shaped by culture
|
||||
- reshaped by history
|
||||
- distorted by perception
|
||||
- projected into prompts for one scene at a time
|
||||
|
||||
That gives us agents who can feel culturally situated, personally scarred,
|
||||
socially biased, and dynamically alive without making the prompt carry the
|
||||
whole civilization on its back.
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
# Agent State Variable Glossary
|
||||
|
||||
This document explains the current v0 Ghostlight state variables. It is the
|
||||
human-facing companion to:
|
||||
|
||||
- `schemas/agent-state.schema.json`
|
||||
- `schemas/agent-state.required-fields.json`
|
||||
- `examples/agent-state.call-of-the-void.json`
|
||||
|
||||
The schema is not pretending this vocabulary is final. It is a first stable
|
||||
core: enough shared axes to compare characters, build prompt projection, and
|
||||
avoid turning every new scene into a bespoke little fog machine.
|
||||
|
||||
## Variable Components
|
||||
|
||||
Canonical scalar-like state variables use three numeric components.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `mean` | The baseline tendency or long-run level for this variable. A high `mean` means this trait, strategy, pressure, or stance is normally strong for the agent. |
|
||||
| `plasticity` | How easily this variable can change. High plasticity means the value can move quickly under events, relationships, culture, or scene pressure. Low plasticity means it is entrenched. |
|
||||
| `current_activation` | How active the variable is right now, in this scene or current state. This can spike above or fall below the baseline without rewriting the agent. |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mean": 0.35,
|
||||
"plasticity": 0.2,
|
||||
"current_activation": 0.9
|
||||
}
|
||||
```
|
||||
|
||||
This means the agent is not generally high in the variable, the baseline is not
|
||||
very easy to change, but the variable is strongly activated right now.
|
||||
Translation: this is a situation, not a personality transplant.
|
||||
|
||||
Canonical character state does not include `certainty`. If a generated or
|
||||
imported profile is provisional, track that in workflow provenance, draft
|
||||
status, imported-source notes, or review status.
|
||||
|
||||
Perceived state uses a different shape because observers do not know canonical
|
||||
baseline or plasticity. They see behavior, then often treat that behavior as
|
||||
evidence of what the target is like.
|
||||
|
||||
```json
|
||||
{
|
||||
"observed_activation": 0.9,
|
||||
"attributed_disposition": 0.7,
|
||||
"confidence": 0.4
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `observed_activation` | How strongly the observer thinks the target expressed the dimension in the observed event or scene. |
|
||||
| `attributed_disposition` | How much the observer generalizes from the observed behavior into "this is what the target is like." |
|
||||
| `confidence` | How strongly the observer trusts this read. This is where evidence strength, emotional investment, prejudice, relationship stakes, prior memory, and social reinforcement show up. |
|
||||
|
||||
This models fundamental attribution bias directly. A character can see someone
|
||||
behave coldly during a crisis and conclude "they are cold," not "they were
|
||||
activated toward distance under exceptional pressure." Correcting that
|
||||
impression takes work because the observer's confidence is supported by memory,
|
||||
emotion, stakes, prior assumptions, and social reinforcement.
|
||||
|
||||
Perceived overlays may use structured belief metadata such as subject, claim
|
||||
type, evidence events, visibility, and emotional charge. That metadata is not
|
||||
canonical certainty. It describes why a specific observer trusts a specific
|
||||
read.
|
||||
|
||||
## Label Set Policy
|
||||
|
||||
The labels in `agent-state.required-fields.json` are the required v0 core,
|
||||
not per-character tags. They cover the axes Ghostlight currently treats as
|
||||
first-class.
|
||||
|
||||
The validator requires each canonical agent fixture to include the full core
|
||||
set so agents can be compared on the same axes. That means Cat's example does
|
||||
not contain only labels that apply strongly to Cat. It contains all required
|
||||
labels, with low values for weak or inactive axes and high values for strong or
|
||||
activated axes.
|
||||
|
||||
Variable maps may still grow extra labels where a character, culture, or story
|
||||
needs them. Do not promote a new label into the required core until it has
|
||||
proved broadly useful across multiple characters or scenes. Otherwise the
|
||||
schema becomes a junk drawer with a badge.
|
||||
|
||||
## Underlying Organization
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `self_coherence` | How integrated and stable the agent's sense of self is under pressure. |
|
||||
| `contingent_worth` | How much the agent's felt worth depends on approval, utility, status, purity, or external validation. |
|
||||
| `shame_sensitivity` | How painful exposure, correction, failure, or diminishment feels. |
|
||||
| `reciprocity_capacity` | Ability to recognize and sustain mutual obligations rather than purely extractive or avoidant relations. |
|
||||
| `mentalization_quality` | Ability to model other minds with nuance instead of flattening them into crude threat, need, or utility categories. |
|
||||
| `authenticity_tolerance` | Capacity to be known without immediately retreating into performance, control, or concealment. |
|
||||
| `mask_rigidity` | How necessary and inflexible the performed self is. High values mean the mask is load-bearing. |
|
||||
| `external_regulation_dependence` | How much emotional stability depends on other people, institutions, substances, scripts, or external structures. |
|
||||
|
||||
## Stable Dispositions
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `novelty_seeking` | Attraction to new experience, strange opportunities, and unproven paths. |
|
||||
| `conformity` | Preference for accepted norms, procedures, and group expectations. |
|
||||
| `status_hunger` | Desire for rank, recognition, prestige, or visible superiority. |
|
||||
| `risk_tolerance` | Willingness to accept danger, uncertainty, or loss for gain or freedom. |
|
||||
| `sociability` | Baseline draw toward company, conversation, networks, and social energy. |
|
||||
| `baseline_threat_sensitivity` | Default readiness to detect danger, exploitation, betrayal, or humiliation. |
|
||||
| `aesthetic_appetite` | Sensitivity to beauty, style, taste, symbolic form, or expressive environment. |
|
||||
| `ideological_rigidity` | Resistance to revising beliefs, myths, doctrines, or explanatory frames. |
|
||||
|
||||
## Behavioral Dimensions
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `interpersonal_warmth` | Tendency to express care, friendliness, affection, or social welcome. |
|
||||
| `drive` | Goal pressure, persistence, ambition, and forward force. |
|
||||
| `grandiosity` | Inflated self-importance, entitlement, exceptionalism, or self-mythologizing. |
|
||||
| `validation_seeking` | Need for reassurance, approval, recognition, or confirmation of worth. |
|
||||
| `anxiety` | Anticipatory fear, worry, nervous vigilance, or instability under uncertainty. |
|
||||
| `control_pressure` | Urge to manage people, situations, information, or outcomes tightly. |
|
||||
| `hostility` | Readiness toward anger, contempt, attack, punishment, or antagonism. |
|
||||
| `suspicion` | Expectation that others hide motives, threats, traps, or bad faith. |
|
||||
| `rigidity` | Difficulty adapting behavior, interpretation, or plans under new evidence. |
|
||||
| `withdrawal` | Tendency to disengage, retreat, shut down, or reduce availability. |
|
||||
| `volatility` | Speed and intensity of emotional swings or escalation. |
|
||||
| `attachment_seeking` | Pull toward closeness, reassurance, fusion, privileged access, or being chosen. |
|
||||
| `distance_seeking` | Push toward space, opacity, autonomy, and reduced emotional claim from others. |
|
||||
|
||||
## Presentation Strategy
|
||||
|
||||
Presentation strategy describes the self being performed. This is not identical
|
||||
to voice style. It is the social face, mask, posture, or survival presentation
|
||||
the agent is trying to maintain.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `charm` | Performs warmth, ease, charisma, or pleasantness to influence or smooth contact. |
|
||||
| `compliance` | Performs agreement, submission, helpfulness, or nonthreatening cooperation. |
|
||||
| `superiority` | Performs being above others, more competent, more refined, or less vulnerable. |
|
||||
| `detachment` | Performs cool distance, emotional unavailability, or not-needing. |
|
||||
| `seductiveness` | Performs desirability, invitation, or intimate leverage. |
|
||||
| `competence_theater` | Performs capability and control, sometimes beyond what is actually secure. |
|
||||
| `moral_theater` | Performs righteousness, purity, sacrifice, or ethical authority. |
|
||||
| `strategic_opacity` | Controls what others can know; hides motives, pain, dependency, or plans. |
|
||||
| `cultivated_harmlessness` | Performs being safe, small, useful, cute, or beneath concern. |
|
||||
| `abrasive_boundary` | Performs difficulty, bite, or unpleasantness to keep others from pressing closer. |
|
||||
| `ironic_distance` | Performs irony, detachment, or joking distance to avoid naked sincerity. |
|
||||
|
||||
## Voice Style
|
||||
|
||||
Voice style describes how the performed self comes out in language. The v0
|
||||
required vector covers broad communication mechanics, not only the most obvious
|
||||
quirks in the current examples.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `dryness` | Understated, deadpan, low-sentiment phrasing. |
|
||||
| `verbal_warmth` | Inviting, affectionate, reassuring, or socially soft phrasing. |
|
||||
| `formality` | Uses formal structure, titles, careful address, or institutionally correct phrasing. |
|
||||
| `verbosity` | Tends toward longer turns, elaboration, qualification, or verbal sprawl. |
|
||||
| `pace` | Moves quickly through turns, interruptions, replies, or topic shifts. |
|
||||
| `plainspoken_directness` | Speaks plainly, concretely, or bluntly rather than ornamenting or obscuring. |
|
||||
| `lexical_precision` | Chooses exact words, distinctions, definitions, or careful terms. |
|
||||
| `technical_density` | Packs speech with specialist terms, systems language, procedure, or analysis. |
|
||||
| `technical_compression` | Compresses technical meaning into terse expert shorthand. |
|
||||
| `figurative_language` | Uses metaphor, image, analogy, poetic compression, or symbolic framing. |
|
||||
| `lyricism` | Uses musical, poetic, sensuous, or rhythmically heightened language. |
|
||||
| `narrative_detail` | Explains through context, sequence, anecdote, or concrete scene detail. |
|
||||
| `emotional_explicitness` | Names feelings, needs, wounds, affection, fear, or attachment directly. |
|
||||
| `pointedness` | Cuts directly, sharply, or with barbed precision. |
|
||||
| `self_disclosure` | Volunteers personal history, inner state, motives, or private stakes. |
|
||||
| `hedging` | Softens claims with uncertainty markers, caveats, deference, or exits. |
|
||||
| `certainty_marking` | Signals confidence, finality, authority, or refusal to leave claims open. |
|
||||
| `politeness` | Uses courtesy, mitigation, face-saving language, or social smoothing. |
|
||||
| `coded_politeness` | Uses polite phrasing to imply criticism, threat, refusal, hierarchy, or hidden meaning. |
|
||||
| `ritualized_address` | Uses formulaic greetings, titles, honorifics, oaths, prayers, or ceremonial phrases. |
|
||||
| `register_switching` | Shifts between speech registers depending on audience, status, danger, or role. |
|
||||
| `dialect_marking` | Shows regional, class, occupational, subcultural, or community-specific speech markers. |
|
||||
| `theatricality` | Performs speech with heightened drama, staging, persona, flourish, or rhetorical display. |
|
||||
| `humor` | Uses jokes, wit, absurdity, teasing, or comic framing as a regular speech tool. |
|
||||
| `conversational_dominance` | Takes, holds, redirects, or controls conversational floor and agenda. |
|
||||
| `listening_responsiveness` | Reflects, tracks, validates, or adapts to what the other person just said. |
|
||||
| `question_asking` | Uses questions to probe, invite, corner, teach, test, or keep the other person talking. |
|
||||
| `profanity` | Uses taboo, vulgar, sacred, or deliberately coarse language. |
|
||||
|
||||
## Derived Verbal Responses
|
||||
|
||||
Defensive verbal moves are projection outcomes, not canonical labels. A renderer
|
||||
can derive sarcasm, sincerity evasion, emotional evasion, banter-as-boundary, or
|
||||
verbal aggression from the interaction between active state families.
|
||||
|
||||
Examples:
|
||||
|
||||
| Response | Common active-state pattern |
|
||||
| --- | --- |
|
||||
| Sarcastic deflection | High shame sensitivity, ironic distance, threat sensitivity, and exposure pressure through a dry or pointed voice. |
|
||||
| Sincerity evasion | Low authenticity tolerance, high mask rigidity, strategic opacity, and low emotional explicitness. |
|
||||
| Emotional evasion | High distance-seeking, withdrawal, acute shame, or control pressure with low self-disclosure. |
|
||||
| Banter as boundary | High ironic distance, humor, attachment pressure, and distance-seeking in the same scene. |
|
||||
| Verbal aggression | High hostility, perceived status threat, grievance activation, and abrasive boundary. |
|
||||
|
||||
Treat these as target emergent behaviors for projection tests. They are useful
|
||||
because the system should produce them under the right pressures, not because
|
||||
they deserve their own canonical storage slot.
|
||||
|
||||
## Situational State
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `exhaustion` | Current depletion, fatigue, overuse, or low reserve. |
|
||||
| `scarcity_pressure` | Pressure from money, supplies, access, time, space, safety, or opportunity being scarce. |
|
||||
| `humiliation` | Current felt diminishment, exposure, embarrassment, or status wound. |
|
||||
| `panic` | Acute fear, urgency, overwhelm, or threat response. |
|
||||
| `triumph` | Current victory, vindication, high confidence, or emotional lift. |
|
||||
| `grief` | Active loss, mourning, sorrow, or ache. |
|
||||
| `overstimulation` | Current sensory, social, cognitive, or emotional overload. |
|
||||
| `grievance_activation` | Active resentment, injustice memory, or retaliatory moral charge. |
|
||||
| `acute_shame` | Immediate shame flare, exposure pain, or self-disgust. |
|
||||
| `perceived_status_threat` | Current sense that rank, dignity, competence, or face is under threat. |
|
||||
|
||||
## Relationship Stance
|
||||
|
||||
Relationship stance is directional. A `source_id -> target_id` record describes
|
||||
how the source feels and predicts the target, not a shared relationship object.
|
||||
Reciprocal records can disagree.
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `trust` | Expectation that the target will not exploit vulnerability or act in bad faith. |
|
||||
| `respect` | Recognition of competence, integrity, status, or seriousness. |
|
||||
| `resentment` | Accumulated grievance, bitterness, or unpaid emotional debt. |
|
||||
| `dependence` | Need for the target's resources, care, access, protection, or recognition. |
|
||||
| `fear` | Expectation that the target can or will cause harm. |
|
||||
| `fascination` | Captivation, curiosity, attraction, fixation, or unresolved interest. |
|
||||
| `obligation` | Felt debt, duty, promise, or responsibility toward the target. |
|
||||
| `envy` | Pain around what the target has, is, receives, or represents. |
|
||||
| `moral_disgust` | Aversion rooted in perceived violation, corruption, cruelty, or wrongness. |
|
||||
| `perceived_status_gap` | Felt difference in rank, leverage, dignity, or social position. |
|
||||
| `expectation_of_care` | Belief that the target may protect, soothe, support, or choose the source. |
|
||||
| `expectation_of_betrayal` | Belief that the target may abandon, expose, exploit, or turn. |
|
||||
|
||||
## Cat Fixture Policy
|
||||
|
||||
Cat's example fixture currently includes all required core labels for Cat and
|
||||
Oz. It is not a sparse profile of only traits that apply to them.
|
||||
|
||||
That is deliberate for v0:
|
||||
|
||||
- complete vectors make comparison and validation easier
|
||||
- low `mean` values are meaningful
|
||||
- low `current_activation` values tell the renderer not to emphasize a variable
|
||||
|
||||
Later, Ghostlight may support sparse authoring overlays for convenience. The
|
||||
canonical fixture should remain explicit until we have enough tooling to avoid
|
||||
silent defaults becoming accidental canon.
|
||||
|
||||
## Event Interpretation
|
||||
|
||||
Events may carry three optional interpretation layers:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `observed_exchange` | What was externally observable: who acted or spoke, what they seemed to do, and the visible cues. |
|
||||
| `private_interpretations` | How each participant appraised the event from their own partial state. |
|
||||
| `event_effects` | The proposed consequences: state deltas, new beliefs, or memory updates caused by the event. |
|
||||
|
||||
These fields are immediate scaffolding for projection. They do not yet define a
|
||||
complete transition engine. For now, they make important scene history legible
|
||||
enough that a prompt projector can explain why a later line bends toward trust,
|
||||
evasion, curiosity, fear, or repair.
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
# Canonical Agent State Schema
|
||||
|
||||
Ghostlight's first runtime organ is a state contract, not a generator.
|
||||
|
||||
The schema lives in:
|
||||
|
||||
- `schemas/agent-state.schema.json`
|
||||
- `schemas/agent-state.required-fields.json`
|
||||
|
||||
The first example lives in:
|
||||
|
||||
- `examples/agent-state.call-of-the-void.json`
|
||||
|
||||
The label and numeric glossary lives in:
|
||||
|
||||
- `docs/architecture/agent-state-variable-glossary.md`
|
||||
|
||||
## Objective
|
||||
|
||||
The schema defines the minimum state Ghostlight needs before it can safely build
|
||||
prompt projection, dialogue scaffolding, classifier records, or procedural
|
||||
drama.
|
||||
|
||||
The core rule is unchanged:
|
||||
|
||||
- canonical state is what the world treats as true
|
||||
- perceived state is what a specific observer thinks is true
|
||||
- dialogue context packs are character-local projections for one speaker in one
|
||||
scene
|
||||
|
||||
If these layers collapse into each other, the machine turns back into one
|
||||
omniscient narrator with a pocket full of vibes. This is a known industrial
|
||||
hazard. We are wearing goggles now.
|
||||
|
||||
## State Families
|
||||
|
||||
Each agent has:
|
||||
|
||||
- `identity`
|
||||
- `canonical_state`
|
||||
- `goals`
|
||||
- `memories`
|
||||
- `perceived_state_overlays`
|
||||
|
||||
`canonical_state` is split into:
|
||||
|
||||
- `underlying_organization`
|
||||
- `stable_dispositions`
|
||||
- `behavioral_dimensions`
|
||||
- `presentation_strategy`
|
||||
- `voice_style`
|
||||
- `situational_state`
|
||||
- `values`
|
||||
|
||||
Each canonical scalar-like dimension is represented as:
|
||||
|
||||
```json
|
||||
{
|
||||
"mean": 0.5,
|
||||
"plasticity": 0.5,
|
||||
"current_activation": 0.5
|
||||
}
|
||||
```
|
||||
|
||||
This keeps baseline, change rate, and scene activation separate.
|
||||
A person can be dispositionally suspicious without being maximally suspicious
|
||||
in every scene. A person can be highly activated right now without rewriting
|
||||
their whole soul because someone coughed near a status wound.
|
||||
|
||||
Canonical variables do not include confidence or certainty. Perceived variables
|
||||
do not expose canonical `mean` or `plasticity`; they store observed activation,
|
||||
attributed disposition, and confidence.
|
||||
|
||||
For full meanings of canonical `mean`, `plasticity`, `current_activation`,
|
||||
perceived attribution fields, and every required v0 label, see
|
||||
`docs/architecture/agent-state-variable-glossary.md`.
|
||||
|
||||
## Required First-Class Dimensions
|
||||
|
||||
`schemas/agent-state.required-fields.json` names the required core variable
|
||||
families. The validator enforces those fields for canonical agent state and
|
||||
relationship stance.
|
||||
|
||||
The required core is not exhaustive. It is the shared comparison surface. A
|
||||
character fixture currently includes all required labels, even when many are low
|
||||
or inactive, so downstream tooling can compare agents without guessing whether a
|
||||
missing value means "not applicable," "forgotten," or "quietly eating the
|
||||
floorboards."
|
||||
|
||||
The behavioral dimensions include the known must-not-drop set:
|
||||
|
||||
- `volatility`
|
||||
- `attachment_seeking`
|
||||
- `distance_seeking`
|
||||
|
||||
Those are not garnish. They are load-bearing social handles.
|
||||
|
||||
## Presentation, Voice, And Response
|
||||
|
||||
`presentation_strategy` describes the self being performed.
|
||||
|
||||
That can include charm, detachment, competence theater, cultivated
|
||||
harmlessness, or deliberately performing someone who is abrasive or difficult.
|
||||
Sometimes that abrasive performance is not random meanness. It is a boundary,
|
||||
a threat display, a way to prevent intimacy, or a test to see who flinches.
|
||||
Human beings are regrettably efficient little porcupines.
|
||||
|
||||
`voice_style` describes how that performed self comes out in language.
|
||||
|
||||
The required voice handles are:
|
||||
|
||||
- `dryness`
|
||||
- `verbal_warmth`
|
||||
- `formality`
|
||||
- `verbosity`
|
||||
- `pace`
|
||||
- `plainspoken_directness`
|
||||
- `lexical_precision`
|
||||
- `technical_density`
|
||||
- `technical_compression`
|
||||
- `figurative_language`
|
||||
- `lyricism`
|
||||
- `narrative_detail`
|
||||
- `emotional_explicitness`
|
||||
- `pointedness`
|
||||
- `self_disclosure`
|
||||
- `hedging`
|
||||
- `certainty_marking`
|
||||
- `politeness`
|
||||
- `coded_politeness`
|
||||
- `ritualized_address`
|
||||
- `register_switching`
|
||||
- `dialect_marking`
|
||||
- `theatricality`
|
||||
- `humor`
|
||||
- `conversational_dominance`
|
||||
- `listening_responsiveness`
|
||||
- `question_asking`
|
||||
- `profanity`
|
||||
|
||||
Behavioral warmth and verbal warmth are intentionally separate:
|
||||
|
||||
- `behavioral_dimensions.interpersonal_warmth` is the tendency to offer care,
|
||||
welcome, or affiliation.
|
||||
- `voice_style.verbal_warmth` is the language surface that can sound soft,
|
||||
reassuring, affectionate, or socially inviting.
|
||||
|
||||
So Cat's prickliness is not only a dialogue prompt instruction. It is backed by
|
||||
canonical state:
|
||||
|
||||
- high `presentation_strategy.abrasive_boundary`
|
||||
- high `presentation_strategy.ironic_distance`
|
||||
- high `underlying_organization.shame_sensitivity`
|
||||
- high `underlying_organization.mask_rigidity`
|
||||
- high `behavioral_dimensions.distance_seeking`
|
||||
- high `behavioral_dimensions.suspicion`
|
||||
- high `situational_state.acute_shame` when exposed
|
||||
|
||||
The renderer derives defensive speech from those intersections plus voice
|
||||
style. A dry, pointed voice under shame and exposure pressure may produce
|
||||
sarcastic deflection; the schema does not store sarcasm-as-defense as its own
|
||||
canonical organ.
|
||||
|
||||
Sarcastic deflection, sincerity evasion, emotional evasion,
|
||||
banter-as-boundary, and verbal aggression remain target emergent behaviors for
|
||||
projection tests.
|
||||
|
||||
## Perceived State
|
||||
|
||||
Perceived overlays are observer-specific. They do not replace canonical state.
|
||||
|
||||
An overlay records:
|
||||
|
||||
- observer
|
||||
- target
|
||||
- perceived dimensions, including observed activation and attributed disposition
|
||||
- beliefs
|
||||
- distortions
|
||||
|
||||
This is where misunderstanding lives. Suspicious agents can overread threat.
|
||||
Attachment-hungry agents can overread interpersonal warmth. Avoidant agents can
|
||||
underread care. Fundamental attribution bias also lives here: an observer can
|
||||
mistake exceptional behavior for stable character. None of that requires
|
||||
corrupting the canonical state.
|
||||
|
||||
Relationship stance is directional. If Cat has a relationship record toward Oz,
|
||||
Oz needs a separate record toward Cat. Mutual presence does not imply symmetric
|
||||
trust, fear, obligation, dependence, or expectation of betrayal.
|
||||
|
||||
## Event Interpretation
|
||||
|
||||
Events can include optional interpretation scaffolding:
|
||||
|
||||
- `observed_exchange` for externally visible action or dialogue
|
||||
- `private_interpretations` for each participant's appraisal of what happened
|
||||
- `event_effects` for proposed state deltas, belief creation, and memory updates
|
||||
|
||||
This is not a full social transition engine yet. It is enough structure for the
|
||||
projection layer to know what an event changed without asking later prompts to
|
||||
infer everything from a one-line summary and a bad attitude.
|
||||
|
||||
## Dialogue Context Packs
|
||||
|
||||
Scenes contain `dialogue_context_packs`.
|
||||
|
||||
A pack is a character-local prompt payload for one speaker. It includes:
|
||||
|
||||
- speaker-local truth
|
||||
- speaker beliefs
|
||||
- active memories
|
||||
- active goals
|
||||
- presentation constraints
|
||||
|
||||
The point is not to make the dialogue generator smarter by shouting plot at it.
|
||||
The point is to keep each character inside their own partial reality.
|
||||
Packs are positive-context payloads: if the speaker should not know something,
|
||||
it is not included. Secrets the speaker does know but wants to withhold belong
|
||||
in goals, memories, beliefs, and presentation constraints.
|
||||
|
||||
Ghostlight can build the trap. The character projection decides how the person
|
||||
inside the trap breathes.
|
||||
|
||||
## Current Validation
|
||||
|
||||
The first validator is intentionally small and dependency-free:
|
||||
|
||||
- `tools/validate_agent_state.py`
|
||||
|
||||
It does not implement all of JSON Schema. It checks the invariants Ghostlight
|
||||
currently cares about:
|
||||
|
||||
- example files parse as JSON
|
||||
- schema versions match
|
||||
- agent IDs are unique
|
||||
- required canonical variable groups are present
|
||||
- required relationship stance fields are present
|
||||
- state variable components stay in `0..1`
|
||||
- goals, memories, relationships, scenes, and dialogue context packs reference
|
||||
existing IDs
|
||||
- dialogue packs carry speaker-local context without requiring negative
|
||||
author-only fences
|
||||
|
||||
This gives us a real seam before we build the prompt renderer. Not perfect.
|
||||
Useful. We are allowed to be both.
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
# Corpus Coverage Ledger
|
||||
|
||||
The corpus coverage ledger is the project-level index for accepted and planned
|
||||
Aetheria training fixtures.
|
||||
|
||||
Its job is not to store story content. Its job is to keep the corpus honest:
|
||||
which factions, movements, flashpoints, timeline lanes, tonal modes, collision
|
||||
axes, review states, and item/economy hooks are represented, and which are still
|
||||
missing.
|
||||
|
||||
## Live Files
|
||||
|
||||
- Ledger: `state/corpus-coverage.json`
|
||||
- Schema reference: `schemas/corpus-coverage.schema.json`
|
||||
- Validator/status tool: `tools/validate_corpus_coverage.py`
|
||||
- Command: `npm run coverage:status`
|
||||
|
||||
## Entry Contract
|
||||
|
||||
Each fixture entry records:
|
||||
|
||||
- `fixture_id`: stable fixture handle.
|
||||
- `status`: `planned`, `draft`, `accepted_as_draft`, `accepted`,
|
||||
`needs_revision`, `rejected`, or `superseded`.
|
||||
- `timeline_lane`: `historical_grounded`, `transition_grounded`, or
|
||||
`future_branch`.
|
||||
- `canon_status`: single-history, branch-local, promoted branch, template, or
|
||||
draft status.
|
||||
- `story_type`: founding-era, day-in-the-life, flashpoint, movement,
|
||||
future-branch, or trade-life story.
|
||||
- `primary_subjects` and `secondary_subjects`: factions, movements,
|
||||
institutions, species/body groups, or other represented subjects.
|
||||
- `collision_axes`: the pressures the fixture exercises, such as law, labor,
|
||||
care, class, logistics, species/body affordance, trade, memory, personhood,
|
||||
technology, legitimacy, or infrastructure.
|
||||
- `tonal_modes`: prose modes the fixture contributes to the corpus.
|
||||
- `training_targets`: organs or reference surfaces this fixture is relevant to.
|
||||
- `training_usability`: explicit per-stage declaration of whether the fixture
|
||||
is `training_ready`, `reference_only`, or `not_training_data`.
|
||||
- `artifacts`: paths to fixture files.
|
||||
- `review`: status of schema, narrative, lore, spatial, visual, and IF review.
|
||||
- `economy_hooks`: item, assembly, trade-good, supply-chain, or tech hooks.
|
||||
- `open_lore_gaps`: blocking or useful gaps discovered during generation.
|
||||
|
||||
## Acceptance Rule
|
||||
|
||||
An accepted or accepted-as-draft fixture must have the core artifact refs:
|
||||
|
||||
- lore digest
|
||||
- agent state
|
||||
- coordinator artifact
|
||||
- Ink file
|
||||
- training sidecar
|
||||
|
||||
It must also carry review fields for schema, narrative, lore, spatial, visual,
|
||||
and IF artifact review. A fixture may still be accepted-as-draft with review
|
||||
caveats, but the caveats should be visible instead of hidden under the nice rug.
|
||||
|
||||
Coverage status is not training readiness. A fixture that looks good as a story
|
||||
can still be unusable for a model stage if it lacks exact input/output receipts,
|
||||
raw outputs, reviewer decisions, hidden-context audits, or mutation records in
|
||||
the shape that stage requires. The ledger should be rude about this. If a
|
||||
fixture is only a reference for story shape or grounding, mark it
|
||||
`reference_only`; do not promote it by implication.
|
||||
|
||||
## Report Shape
|
||||
|
||||
`npm run coverage:status` validates the ledger and prints:
|
||||
|
||||
- total entries and accepted count
|
||||
- progress toward the 100-150 broad target
|
||||
- status, timeline lane, story type, collision-axis, tonal-mode, and subject
|
||||
counts
|
||||
- training-usability counts
|
||||
- major-power founding/day-in-life candidates when present
|
||||
- basic gap signals, such as missing future-branch coverage or no day-in-life
|
||||
stories yet
|
||||
|
||||
This is deliberately a coarse instrument. It should tell us where to point the
|
||||
next fixture without pretending to be a corpus scientist in a lab coat made of
|
||||
spreadsheet tabs.
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
# CultNet Shared Persona-State Contract
|
||||
|
||||
Ghostlight no longer treats `ghostlight.agent_state.v0` as the cross-runtime
|
||||
person-state contract. That schema still belongs here, but it owns scene,
|
||||
world, relationship, event, and dialogue-context state for storytelling.
|
||||
|
||||
The portable person-shaped contract is now `gamecult.persona_state.v0`.
|
||||
|
||||
## Canonical Payload
|
||||
|
||||
The shared payload contract is:
|
||||
|
||||
- schema id:
|
||||
`https://gamecult.dev/cultnet/gamecult.persona_state.v0.schema.json`
|
||||
- schema version:
|
||||
`gamecult.persona_state.v0`
|
||||
- Ghostlight mirror:
|
||||
`schemas/gamecult.persona_state.v0.schema.json`
|
||||
|
||||
The same schema is mirrored by Epiphany and VoidBot so Faces, repo
|
||||
representatives, and Ghostlight characters can cross runtime boundaries without
|
||||
each repo inventing its own local person-shaped blob.
|
||||
|
||||
## Ownership
|
||||
|
||||
Ghostlight keeps owning the story machine:
|
||||
|
||||
- world state
|
||||
- scenes
|
||||
- events
|
||||
- relationships
|
||||
- perceived overlays
|
||||
- dialogue context packs
|
||||
|
||||
PersonaState owns the portable public-person projection:
|
||||
|
||||
- presentation
|
||||
- values
|
||||
- activation profile
|
||||
- thought memory
|
||||
- agency pressure
|
||||
- candidate actions
|
||||
- affect
|
||||
- source provenance
|
||||
|
||||
That projection can be generated from Ghostlight scene agents, VoidBot repo Face
|
||||
state, or Epiphany Face state. The projection does not erase native state. It is
|
||||
the wire shape, not a throne.
|
||||
|
||||
## Wire Envelope
|
||||
|
||||
CultNet should carry PersonaState as a typed document replication message.
|
||||
|
||||
Current expected document envelope:
|
||||
|
||||
- `documentType`: `gamecult.persona-state`
|
||||
- `documentKey`: stable runtime-defined persona key
|
||||
- `payloadSchemaVersion`: `gamecult.persona_state.v0`
|
||||
- `payload`: exact PersonaState document
|
||||
|
||||
Native Ghostlight scene payloads may still travel separately with:
|
||||
|
||||
- `documentType`: `ghostlight.agent-state`
|
||||
- `payloadSchemaVersion`: `ghostlight.agent_state.v0`
|
||||
|
||||
Those are different contracts. One carries a story world. One carries a
|
||||
portable Persona.
|
||||
|
||||
## Epiphany Boundary
|
||||
|
||||
Epiphany public Faces use PersonaState when they need full persistent
|
||||
personhood. Epiphany work organs do not. A work organ such as Imagination can
|
||||
have a light organ state without affect, social bonds, public presentation, or
|
||||
candidate actions.
|
||||
|
||||
This prevents the old muddle where every useful subsystem was tempted to dress
|
||||
up as a whole person. Useful machines do not all need faces.
|
||||
|
||||
## Contract Discipline
|
||||
|
||||
The schema lives in multiple repos for local validation, but the contract must
|
||||
move as one:
|
||||
|
||||
- Epiphany keeps the primary CultNet schema copy.
|
||||
- VoidBot mirrors it and emits PersonaState from repo Face reads.
|
||||
- Ghostlight mirrors it and validates PersonaState examples.
|
||||
- Native storage may stay richer than PersonaState.
|
||||
- Importers must preserve unknown extension data when possible.
|
||||
- Consumers must treat `provenance.authority` seriously.
|
||||
|
||||
Prompt projection may differ. Storage engines may differ. Story scenes may
|
||||
carry more structure. The shared person-state payload does not drift just
|
||||
because a runtime got clever at midnight.
|
||||
|
||||
## Immediate Consumers
|
||||
|
||||
The first intended consumers are:
|
||||
|
||||
- Ghostlight character projections for scene/story tooling
|
||||
- VoidBot repo Face MCP reads and social memory interop
|
||||
- Epiphany public Face state
|
||||
- future CultNet replication and inspection tools
|
||||
102
docs/architecture/delvehold-forced-ontology-integration.md
Normal file
102
docs/architecture/delvehold-forced-ontology-integration.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# DELVE/HOLD Consumer Profile for the Ghostlight World API
|
||||
|
||||
## Status
|
||||
|
||||
This document is a consumer binding and conformance profile. It records how
|
||||
Delvehold maps its authored world and owned state onto Ghostlight's generic API;
|
||||
it does not create a Delvehold organ, schema family, subject ID, or authority
|
||||
branch inside Ghostlight core.
|
||||
|
||||
## Objective
|
||||
|
||||
Use Ghostlight's live external-world simulation as an ordinary API consumer
|
||||
without making it the owner of DELVE/HOLD players, workshops, dungeons, civic
|
||||
state, or quantitative economy. DELVE/HOLD provides an authored ontology and
|
||||
fixed seed for one canonical outside world through consumer-neutral Ghostlight
|
||||
operations. A consumer seed does not pass through the seed lane; it enters as
|
||||
a consumer document, and this bypasses none of `WorldKernel`, typed
|
||||
validation, the mutation algebra, or atomic CultCache commits.
|
||||
|
||||
## Core and consumer cut
|
||||
|
||||
Ghostlight admits generic world seeds, externally owned subject projections,
|
||||
consumer-supplied domain effect schemas, proposals, and receipts. Delvehold's
|
||||
adapter owns the names `delvehold.greathold_boundary_state.v0`,
|
||||
`delvehold.greathold_effect_batch.v0`, `ghostlight.external_response_batch.v0`,
|
||||
`delvehold.boundary_receipt.v0`, and the configured subject ID `greathold`. It
|
||||
lowers and raises those documents at the API boundary. Ghostlight never
|
||||
switches on those names or that ID.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Delvehold owner:** Greathold players, workshops, parties, civic seals, graph, dungeon ecology, and its own quantitative economy: recipes, facilities, capacity, inventories, orders, prices, exchange rates, contracts, expeditions, and local consequences. Custody of Greathold-owned resources changes only through Delvehold.
|
||||
- **Ghostlight owner:** external regions, actors, institutions, Gestalts, their knowledge, relationships, goals, posture, pressures, strategic choices, external events, and news.
|
||||
- **Configured boundary subject:** Delvehold configures stable ID `greathold`
|
||||
through Ghostlight's generic external-subject authority contract. The ID has
|
||||
no privileged meaning in Ghostlight core.
|
||||
- **Ghostlight API:** consumer-neutral CultMesh operations admit authored seeds,
|
||||
externally controlled subject observations and realized effects, and return
|
||||
attributed intents, news, state projections, and receipts. They own no
|
||||
consumer ontology or decision.
|
||||
- **Delvehold adapter:** Delvehold owns translation between its detailed
|
||||
Greathold state and the generic Ghostlight API. The adapter owns no truth and
|
||||
cannot repair either canonical domain after rejection.
|
||||
- **Forbidden writers:** Ghostlight Personas and strategic cells cannot mutate `greathold`; Delvehold cannot mutate foreign private state; the adapter cannot repair either owner after rejection.
|
||||
|
||||
The economy owner is Njörðr, the GameCult economy daemon (`gamecult-economy-daemon.md`), for both games and for the Greathold's interior as well as its exterior; Njörðr is itself a Ghostlight consumer and owns the market mirrors. The Delvehold-owner line below predates that decision (2026-09-11) where it names recipes, facilities, capacity, inventories, orders, prices, exchange rates, and contracts; those are Njörðr's, and Delvehold's civic organs author policy over them. Ghostlight owns a narrative-scale conserved quantity for the subjects it simulates: `Custody` holds a unitless `Quantity` per subject and resource, `Transfer`/`Consume`/`Transform` conserve it, and `Admit` creates it only with evidence (`docs/architecture/ghostlight-world-ontology.md`, Invariant 5). That is a ledger, not an economy. `Transform` is one-to-one with no unit, rate, yield, facility, or capacity field anywhere on a Ghostlight type, so prices, production, orders, and contracts are unrepresentable in Ghostlight rather than merely unwritten; they remain Delvehold-owned, and any resource an external mirror holds changes only through its admitted owner.
|
||||
|
||||
## Greathold boundary subject
|
||||
|
||||
`greathold` is externally controlled. It is not a person, sovereign government, averaged population will, or alternate player-state store.
|
||||
|
||||
- It receives no Persona turn or autonomous institution effect.
|
||||
- Only generic API operations lowered by the Delvehold adapter from an accepted
|
||||
`delvehold.greathold_boundary_state.v0` or
|
||||
`delvehold.greathold_effect_batch.v0` document may update its projection.
|
||||
- External subjects may observe it, relate to it, and direct attributed actions toward it.
|
||||
- Any proposed change to Greathold-owned truth exits as `ghostlight.external_response_batch.v0` and remains pending until Delvehold returns `delvehold.boundary_receipt.v0`.
|
||||
|
||||
The projection excludes player identities, private workshop state, pending votes, unaccepted contracts, private communications, and inferred collective desire. It reports committed macro effects and authorized institutional acts.
|
||||
|
||||
## Consumer flow
|
||||
|
||||
```text
|
||||
Delvehold commit
|
||||
-> Delvehold-owned boundary projection or realized-effect batch
|
||||
-> Delvehold adapter lowers to a generic Ghostlight API operation
|
||||
-> WorldKernel validation and atomic commit
|
||||
-> strategic resolution over external subjects
|
||||
-> generic attributed intent/news/state response
|
||||
-> Delvehold adapter raises to its consumer contract
|
||||
-> Delvehold validation and atomic commit
|
||||
-> admission receipt
|
||||
```
|
||||
|
||||
Each document carries world ID, UTC epoch ID, source revision, effective time, causal refs, provenance, payload digest, and idempotency key. `delvehold.boundary_receipt.v0` is direction-neutral: the receiving domain returns it to the sender with sender and recipient IDs, per-item results, reason codes, resulting local revision, and committed event refs. Revisions remain local. The source may retry; the recipient must return the same receipt for the same key and digest.
|
||||
|
||||
Malformed envelopes, stale revisions, digest conflicts, and invalid causal structure reject the whole document without mutation. After envelope validation, semantically independent items may resolve to an explicit accepted subset, but that subset commits atomically or not at all. The receipt records every item result.
|
||||
|
||||
`greathold_boundary_state.v0` is an absolute replacement of its declared projection fields at a Delvehold source-revision watermark. `greathold_effect_batch.v0` carries deltas over explicit preceding and resulting revisions. A snapshot supersedes deltas at or below its watermark; delayed deltas below either accepted watermark are stale. Greathold ingress access and capacity are Delvehold-owned. External route topology and status remain Ghostlight-owned even when Delvehold mirrors their IDs in local admitted state.
|
||||
|
||||
Major committed events cross promptly. Ordinary material flow closes hourly. Recovery replays at most eight missed epochs and represents older time as one explicitly coarse horizon. A summarized horizon may reduce detail; it cannot invent player action or hide an unprocessed interval.
|
||||
|
||||
## Required generic Ghostlight API capabilities
|
||||
|
||||
Ghostlight needs:
|
||||
|
||||
1. one seed admission primitive, `AdmitPatch`, shared by the world owner, the
|
||||
Draft seed lane acting as the owner, the Active elaborator, and the
|
||||
external consumer;
|
||||
2. generic external-subject ownership metadata enforced by selection and
|
||||
mutation gates;
|
||||
3. generic consumer effect-schema registration or supply;
|
||||
4. generic proposal and idempotent receipt envelopes.
|
||||
|
||||
All four delegate to existing WorldKernel validation and atomic persistence.
|
||||
The adapter lives in Delvehold and owns all Delvehold-specific projection,
|
||||
economy translation, document names, and configured IDs. No Delvehold-specific
|
||||
economy, civic, dungeon, or contract schema belongs in Ghostlight core. No
|
||||
second kernel, economy store, Session Zero compatibility fiction, or direct
|
||||
campaign-row writer is admitted.
|
||||
|
||||
Acceptance requires negative proof that ordinary strategic waves cannot select `greathold` as an acting subject, malformed or stale batches commit nothing, and foreign effects become local consequences only after a Delvehold receipt.
|
||||
12
docs/architecture/gamecult-economy-daemon.md
Normal file
12
docs/architecture/gamecult-economy-daemon.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Njörðr, the GameCult economy daemon
|
||||
|
||||
The specification moved to its owning repository on 2026-09-11:
|
||||
`GameCult/Njordr`, file `docs/architecture/njordr.md`
|
||||
(https://github.com/GameCult/Njordr). This file is a pointer so Ghostlight's
|
||||
map and integration notes keep resolving; it carries no authority.
|
||||
|
||||
What Ghostlight owes Njörðr is unchanged and lives here: the outbound consumer
|
||||
batch shaped as Njörðr's signal surface (custody, pressure and dependency,
|
||||
route, and event deltas), market mirrors as externally controlled subjects,
|
||||
several per world, and consumer patches carrying prices as witnessed facts,
|
||||
shortages as pressures, and realized trades as custody with a receipt.
|
||||
671
docs/architecture/ghostlight-dungeon-mvp.md
Normal file
671
docs/architecture/ghostlight-dungeon-mvp.md
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
# Ghostlight authority architecture
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
This document is the adopted rebuild target for the Ghostlight runtime. It is
|
||||
the authority map for implementation work. What it describes is built in
|
||||
`crates/ghostlight` except where it says otherwise here: bounded co-op and the
|
||||
`archived` phase with read/export-only archival are design intent, and nothing
|
||||
in `crates/` implements them (`WorldPhase` has `Draft` and `Active` only).
|
||||
|
||||
Ghostlight is a persistent, source-grounded narrative world. Its job is to
|
||||
preserve people, institutions, places, knowledge, material consequence, and
|
||||
autonomous motion across time. It is not a pipeline for passing prose among a
|
||||
committee of models until one model permits another model's text to exist.
|
||||
|
||||
## Objective
|
||||
|
||||
Build one sparse, open-world causal machine that supports solo play (bounded
|
||||
co-op is design intent, unbuilt), autonomous subject decisions, source-grounded expansion, external
|
||||
world owners, and actor-private experience.
|
||||
|
||||
The world grows when committed pressure reaches a typed boundary: a new
|
||||
destination becomes relevant, an existing polity or population enters causal
|
||||
range, a person must be individuated, or an effect needs a missing primitive.
|
||||
A world also grows toward an authored `WorldScaleIntent`: how many goal-bearing
|
||||
subjects it should hold at each level and realm. Elaborators work that deficit
|
||||
down; structural validity never waits on it. Cover budget is the deliberate
|
||||
choke: with subjects several times the cell budget, the scheduler must choose
|
||||
where attention goes, and actors pursuing their own goals at every level is
|
||||
what makes the world feel alive. Neither number gates activation or admits a
|
||||
mutation.
|
||||
|
||||
## Prime invariants
|
||||
|
||||
1. One per-world `WorldKernel` owns the complete revisioned `WorldState`, from
|
||||
draft through active play and archive.
|
||||
2. Every accepted change crosses one `CommandEnvelope`, one deterministic
|
||||
authority derivation, one ontology reducer, and one atomic CultCache CAS.
|
||||
3. Structural identity, reference integrity, containment, topology, custody,
|
||||
jurisdiction, knowledge scope, lineage, and external ownership are typed
|
||||
code invariants.
|
||||
4. Every autonomous authority scope names one decision controller. A
|
||||
`NarrativePersona` uses the Projector-Persona-Interpreter prose membrane; an
|
||||
`OperationalAgent` receives typed state and tools directly. Neither can
|
||||
validate, reconcile, schedule, repair, or commit another model's proposal.
|
||||
5. Projection, attention plans, transcripts, news, summaries, dashboards,
|
||||
checkpoints, and model receipts do not own world truth.
|
||||
6. Qualitative interest, political diversity, prose quality, name repetition,
|
||||
and actor counts are evaluation evidence. They are never mutation gates.
|
||||
7. A failed or interrupted inference (infrastructure interruption of an
|
||||
inference; a world-scope interruption between intent and commit is
|
||||
narrated, not discarded — the narrative lane re-lowers the same prose once
|
||||
against the fresh scope) leaves no half-owned world state. Recovery starts
|
||||
from the last committed revision and derives pending work again.
|
||||
|
||||
## Canonical owner
|
||||
|
||||
### Owner
|
||||
|
||||
One mailbox-backed `WorldKernel` owns a world. The mailbox serializes commands;
|
||||
the CultCache compare-and-swap protects the same invariant across restart or
|
||||
multiple process attempts.
|
||||
|
||||
`WorldKernel` owns:
|
||||
|
||||
- lifecycle phase and revision (`WorldState.phase`, `revision`);
|
||||
- the owner and the draft approvals (`owner`, `draft_approvals`); membership,
|
||||
a contract, and negotiated boundaries are design intent and no field of
|
||||
`WorldState` holds them;
|
||||
- the typed world ontology (the partitions listed under "Canonical world
|
||||
state");
|
||||
- committed events and fictional time (`events`, `now`);
|
||||
- commitments and pressures;
|
||||
- command idempotency and the commit digest chain.
|
||||
|
||||
There is no separate Session Zero owner, component-world owner, aggregate
|
||||
campaign owner, elaboration committer, scheduler writer, or recovery writer.
|
||||
|
||||
### Inputs
|
||||
|
||||
The only mutation input is a `CommandEnvelope` containing:
|
||||
|
||||
- a stable command ID (`id`);
|
||||
- the exact world ID and expected revision (`world_id`, `expected_revision`);
|
||||
- an authenticated principal or internal system capability (`caller`);
|
||||
- one closed command body (`body: CommandBody`).
|
||||
|
||||
`CommandEnvelope` (`crates/ghostlight/src/lib.rs`) has exactly these five
|
||||
fields. Evidence references travel inside `AdmitPatch`'s `WorldPatch`, not on
|
||||
the envelope.
|
||||
|
||||
The caller supplies identity evidence, not an authority verdict. For a patch
|
||||
command the kernel derives the caller's authority itself:
|
||||
`require_patch_author` reads the caller and committed state to decide who may
|
||||
submit `AdmitPatch` and what ground it holds (the world owner unconfined, an
|
||||
elaborator confined to a jurisdiction, or a consumer confined to the subjects
|
||||
it controls), and `confine_to_ground` checks every declaration and operation
|
||||
in the candidate patch against that ground before it is admitted. Callers
|
||||
cannot grant themselves scope by serializing a persuasive envelope; the
|
||||
kernel derives it from state it already committed.
|
||||
|
||||
Interpreters and operational agents submit the same typed commands through
|
||||
narrower schemas. Narrative Personas never see those schemas or tools. No
|
||||
controller receives a privileged mutation path.
|
||||
|
||||
### Outputs
|
||||
|
||||
An accepted command produces one `WorldCommit` (`crates/ghostlight/src/lib.rs`)
|
||||
whose fields are:
|
||||
|
||||
- `schema` and `world_id`;
|
||||
- `command` (the committed command);
|
||||
- `previous_revision` and `resulting_revision`;
|
||||
- `previous_state_digest` and `resulting_state_digest`;
|
||||
- `previous_commit_digest` and its own `digest`;
|
||||
- `effect` (a `WorldEffect`: the admitted patch, decision, or time step, with
|
||||
its committed events);
|
||||
- `committed_at`.
|
||||
|
||||
The next `WorldState` and `WorldCommit` are persisted atomically. A rejection
|
||||
returns typed invariant failures and changes nothing.
|
||||
|
||||
### Derived state
|
||||
|
||||
The following are recalculated from committed state and may be discarded:
|
||||
|
||||
- subject-local projections;
|
||||
- due-owner queues and attention plans;
|
||||
- population covers, cells, cell ids, resolution, tick index, the agency
|
||||
graph, and the cover budget (the only durable trace of a tick is the
|
||||
controller-work row, custody-separate from world custody);
|
||||
- action-owner counts and causal-capacity summaries;
|
||||
- transcript, news, narration, and operator summaries;
|
||||
- Eve/CultUI and CultMesh documents;
|
||||
- model prompts, outputs, receipts, latency, and provider telemetry;
|
||||
- qualitative evaluations and load-test reports.
|
||||
|
||||
Derived data may guide a later proposal. It may not admit a mutation, repair a
|
||||
commit, or declare the world complete.
|
||||
|
||||
### Forbidden writers
|
||||
|
||||
The following cannot decide canonical state:
|
||||
|
||||
- HTTP, native, Eve, CultMesh, or chat handlers;
|
||||
- Projectors, Personas, Interpreters, assessors, verifiers, reconcilers,
|
||||
narrators, and copy desks; the elaborator decides nothing directly and
|
||||
writes only through its confined `AdmitPatch` capability;
|
||||
- schedulers, attention planners, initiative selectors, and resolution covers;
|
||||
- acceptance drivers, smoke binaries, checkpoints, resume journals, caches,
|
||||
and compaction summaries;
|
||||
- Vault retrieval, evidence reranking, external consumers, and provider
|
||||
telemetry;
|
||||
- import, reload, fork, and repair helpers.
|
||||
|
||||
Each is either a read-only projection or a producer of one ordinary typed
|
||||
command.
|
||||
|
||||
## Canonical world state
|
||||
|
||||
`WorldState` is one aggregate with these owned partitions:
|
||||
|
||||
```text
|
||||
WorldState (crates/ghostlight/src/lib.rs, the fields of the struct)
|
||||
identity: schema, world_id, revision, phase, state_digest, last_commit_digest
|
||||
authorship: owner, title, brief, draft_approvals
|
||||
ontology:
|
||||
subjects, entities (place, resource, fact, channel), edges (routes)
|
||||
positions, holdings, dependencies
|
||||
facts, channels, knowledge
|
||||
authority, selection, redress
|
||||
commitments, pressures, last_opportunity_at
|
||||
persona_material
|
||||
controller_assignments, affordance_catalog, affordance_grants
|
||||
time: now
|
||||
intent: scale_intent, lens_weights
|
||||
events: events (decision events)
|
||||
```
|
||||
|
||||
There is no `members`, `contract`, or boundaries field and no
|
||||
`applied_commands` ledger in `WorldState`. Command idempotency is decided from
|
||||
the commit rows keyed by command id. `WorldCommit` rows, not `WorldState`,
|
||||
carry the digest chain.
|
||||
|
||||
The lifecycle is data inside the aggregate:
|
||||
|
||||
`WorldPhase` has two variants, `Draft` and `Active`:
|
||||
|
||||
- `Draft`: `ApproveDraft`, `ActivateWorld`, `AdmitPatch` (seed and ontology
|
||||
construction under draft authority, which answers nothing), and
|
||||
`SetLensWeights`;
|
||||
- `Active`: `ExerciseDecision`, `DeclineDecision`, `AdvanceTime`, `AdmitPatch`
|
||||
that answers a derived boundary or a jurisdiction deficit, and
|
||||
`SetLensWeights`.
|
||||
|
||||
An `archived` phase with read/export-only archival is design intent; no variant
|
||||
exists.
|
||||
|
||||
Activation changes `phase` in place after the current revision has the required
|
||||
member approvals. It does not copy a preview into another store or hand truth
|
||||
from one kernel to another.
|
||||
|
||||
## Ontology and relational algebra
|
||||
|
||||
The component mutation algebra is the foundation. The aggregate `Campaign`
|
||||
shape is not a second canonical representation.
|
||||
|
||||
### Runtime-issued identity
|
||||
|
||||
Stable IDs are issued by the reducer. Creation proposals use local draft
|
||||
handles plus exact existing IDs. Lowering resolves the complete local reference
|
||||
graph before allocating canonical IDs. Display names never function as keys.
|
||||
|
||||
Unknown IDs, cross-kind references, dangling endpoints, and ambiguous handles
|
||||
are rejected without inference.
|
||||
|
||||
### Subjects
|
||||
|
||||
A subject is a stable identity with a declared kind:
|
||||
|
||||
- person;
|
||||
- institution;
|
||||
- autonomous population.
|
||||
|
||||
An externally controlled mirror is not a fourth kind. It is an ordinary
|
||||
subject of one of these three whose `ControllerAssignment` is
|
||||
`ExternallyControlled { consumer }`: no controller ID, no controller mode, no
|
||||
decision opportunity, and no affordance, mutable only through its own
|
||||
consumer and the world owner.
|
||||
|
||||
Demographic descriptors and statistical aggregates are not automatically
|
||||
subjects. A canonical action owner is derived structurally from an active
|
||||
autonomous subject, one non-overlapping decision controller, explicit decision
|
||||
authority, and at least one executable typed affordance. Similar names, motives,
|
||||
or cultures are legal; labels alone cannot create causal capacity.
|
||||
|
||||
Population partitions use disjoint typed slice keys. Individuation is an
|
||||
explicit causal mutation. There is no fission quota and no active-leaf
|
||||
completion metric.
|
||||
|
||||
### Components
|
||||
|
||||
Subject and world behavior is composed from narrow typed components:
|
||||
|
||||
- position and jurisdiction;
|
||||
- decision controllers, authority scopes, and executable affordances;
|
||||
- resource quantity, custody, and dependency;
|
||||
- knowledge of exact fact IDs and communication access;
|
||||
- commitments, pressures, and dependency exposure;
|
||||
- Persona material: values, voice, goals, memories, and relationship reads;
|
||||
- source provenance and external ownership.
|
||||
|
||||
Persona text can enrich lived meaning. It cannot substitute for an authority,
|
||||
custody, topology, or knowledge component.
|
||||
|
||||
### Relations
|
||||
|
||||
`EdgeKind` (`crates/ghostlight/src/patch.rs`) has one variant, `Route`, so the
|
||||
kernel has no general relation record yet. What the kernel carries, each in
|
||||
its own `WorldState` partition with typed endpoints:
|
||||
|
||||
- containment (`EntityRecord.container`) and occupancy (`positions`);
|
||||
- topology and access (`Route` edges with `AccessKind`);
|
||||
- custody (`holdings`) and dependency (`dependencies`);
|
||||
- authority and representation (`authority`, `selection`, `redress`);
|
||||
- knowledge and communication (`knowledge`, `channels`);
|
||||
- obligation (`commitments`) and causal pressure (`pressures`);
|
||||
- control (`controller_assignments`).
|
||||
|
||||
Membership, lineage, supply and service, and alliance or opposition as
|
||||
relations are design intent and have no record shape.
|
||||
|
||||
Civic order is a typed subgraph of authority, selection or succession,
|
||||
resource access, representation, and redress relations. It is not a prose
|
||||
manifest with fact-ID lanes and does not require a model's civic verdict.
|
||||
|
||||
### Structural admission
|
||||
|
||||
The reducer validates only structural promises:
|
||||
|
||||
- every referenced entity exists or is created in the same closed batch;
|
||||
- containment is acyclic;
|
||||
- routes have exact place endpoints and valid costs;
|
||||
- relation endpoint kinds match the relation kind;
|
||||
- custody quantities and transfers conserve declared resources;
|
||||
- authority and affordance scope covers the proposed mutation;
|
||||
- knowledge additions cite an existing accessible fact or a newly admitted
|
||||
evidenced fact;
|
||||
- population slices are disjoint beneath their declared parent;
|
||||
- an externally controlled subject's components can change only through its
|
||||
own consumer, confined by `PatchGround::Consumer`;
|
||||
- a batch either reduces completely or not at all.
|
||||
|
||||
Interestingness, novelty, ideological distribution, prose style, and counts do
|
||||
not appear in this validator.
|
||||
|
||||
## One command and commit path
|
||||
|
||||
All user and system operations lower through the same path:
|
||||
|
||||
```text
|
||||
authenticated intent or model tool call
|
||||
-> CommandEnvelope
|
||||
-> mailbox ordering
|
||||
-> load exact WorldState revision
|
||||
-> derive MutationAuthorityEnvelope
|
||||
-> lower command to WorldMutationBatch
|
||||
-> validate and reduce the complete candidate
|
||||
-> atomic WorldState + WorldCommit CAS
|
||||
-> publish invalidation
|
||||
-> derive projections and next attention plan
|
||||
```
|
||||
|
||||
The closed command vocabulary is `CommandBody` (`crates/ghostlight/src/lib.rs`),
|
||||
seven variants: `ApproveDraft`, `ActivateWorld`, `ExerciseDecision`,
|
||||
`DeclineDecision`, `AdmitPatch`, `AdvanceTime`, `SetLensWeights`. Speech is an
|
||||
`ExerciseDecision` effect, ontology construction and evidence admission are
|
||||
`AdmitPatch`, and world creation is a separate genesis (`CreateWorld`).
|
||||
Membership, contract and boundary changes, external snapshots, and archive are
|
||||
not commands. Every command lowers to the same mutation vocabulary. There is no
|
||||
`commit_elaboration`, separate component-batch ingress, or reload repair path.
|
||||
|
||||
## Inference boundary
|
||||
|
||||
`ControllerMode` (`crates/ghostlight/src/lib.rs`) has three variants, `Human`,
|
||||
`NarrativePersona`, and `OperationalAgent`; an externally controlled subject
|
||||
has no mode. Ghostlight supports two first-class model decision interfaces
|
||||
(`DecisionControllerMode` in `crates/ghostlight-persona-projection/src/lib.rs`:
|
||||
`NarrativePersona` and `OperationalAgent`). The ontology assigns
|
||||
each exact authority scope to one `DecisionController`; the scheduler does not
|
||||
choose a mode opportunistically. The scheduler does choose resolution: a
|
||||
`NarrativePersona` controller receives its prose membrane in a singleton cell
|
||||
and is represented operationally, at coarse resolution, when grouped. That is
|
||||
a budget decision, not a mode change; the controller, its scope, and its
|
||||
authority are unchanged. A controller changes representation and model
|
||||
ergonomics, never permission or commit authority. A `NarrativePersona` in a
|
||||
grouped cell receives its typed view and nothing from the membrane; it mints
|
||||
no `PersonaTurn`, so the immutable-turn-receipt rule does not apply to it.
|
||||
The membrane is not weakened; it is not entered. The agency graph is
|
||||
scheduler-only and is reachable from no prompt builder; its mailbox request
|
||||
is port-narrowed (absent from the controller and elaboration ports), not
|
||||
principal-authenticated.
|
||||
|
||||
An opportunity is issued to exactly one controller. An untranslated intent or
|
||||
infrastructure fault cannot fall through to the other mode, and the same
|
||||
opportunity cannot be exercised twice.
|
||||
|
||||
### NarrativePersona controller
|
||||
|
||||
The narrative controller keeps one deliberate three-organ membrane around
|
||||
roleplay. This is not a verifier chain: each organ owns a different
|
||||
representation and none can commit.
|
||||
|
||||
### Projector
|
||||
|
||||
Deterministic code first constructs a permissioned `SubjectView` from exact
|
||||
occupancy, knowledge, memories, relationships, pressures, contract boundaries,
|
||||
and currently executable affordances. Private views omit unavailable facts and
|
||||
other members' private state by construction.
|
||||
|
||||
The Projector receives that view and the visible stimulus. It emits one lived
|
||||
narrative stream: perception, memory, uncertainty, desire, bodily circumstance,
|
||||
and available possibility in prose. It cannot choose an action, emit a schema,
|
||||
or claim a consequence. The Projector is a semantic renderer across the typed
|
||||
state-to-lived-experience boundary; its prose is private derived context, not
|
||||
world truth.
|
||||
|
||||
### Persona
|
||||
|
||||
The Persona receives only identity guidance and the lived narrative stream. It
|
||||
does not see canonical field names, IDs, JSON, tool definitions, mutation
|
||||
vocabulary, effect schemas, or kernel errors. It emits only natural roleplay
|
||||
prose and may remain silent, speak, decide, hesitate, or attempt something in
|
||||
character.
|
||||
|
||||
This isolation is an explicit quality invariant. Coding-agent training creates
|
||||
a powerful schema and tool-use attractor. Ghostlight does not assume that a
|
||||
model can preserve equivalent roleplay while simultaneously operating tools.
|
||||
|
||||
### Interpreter
|
||||
|
||||
The Interpreter receives the Persona prose, the lived stream, and the exact
|
||||
permissioned typed context. Interpretation is a total operation. It always
|
||||
finishes with an `InterpretationReport` containing:
|
||||
|
||||
- the immutable Persona turn receipt and exact prose preserved as noncanonical
|
||||
source evidence;
|
||||
- every typed proposal it could faithfully lower using existing IDs and
|
||||
available affordances;
|
||||
- zero or more `TranslationGap` records for meaningful material it could not
|
||||
encode;
|
||||
- the exact source spans supporting both proposals and gaps.
|
||||
|
||||
Before interpretation, the NarrativePersona runner persists one immutable
|
||||
`PersonaTurn` receipt binding controller, decision opportunity, world
|
||||
revision/state digest, Projector receipt, Persona inference receipt, exact
|
||||
source prose, and its digest. The report carries that source receipt rather
|
||||
than reconstructing provenance from prompt text. Report fields are produced by
|
||||
the accumulator; arbitrary deserialization is not an admission path.
|
||||
|
||||
A translation gap identifies ambiguity, a missing reference, a missing
|
||||
affordance, a missing mutation primitive, or source left unresolved by the
|
||||
current attempt. It records what the Interpreter believes the Persona was
|
||||
trying to express without pretending that the effect occurred. Gaps are
|
||||
non-fictional inference evidence. They may inform evaluation or an explicit
|
||||
later design decision; recording one does not request elaboration or mutate the
|
||||
world.
|
||||
|
||||
Interpreter tools provide local structural feedback and a `record_gap` action.
|
||||
An invalid proposal span is not admitted, but the harness records the complete
|
||||
source as an unresolved gap. An invalid gap span is likewise rebound to the
|
||||
complete exact source rather than discarded. Raw tool arguments that cannot be
|
||||
decoded into either typed contract enter the same total fallback, with their
|
||||
payload digest retained as attempt evidence. If the step budget ends, the
|
||||
harness finalizes the valid proposals and accumulated gaps and adds an exact
|
||||
unresolved-source gap instead of returning semantic failure.
|
||||
|
||||
The Interpreter cannot add motivation to the Persona turn, rewrite its source
|
||||
prose, infer unavailable knowledge, or commit an effect. Spoken words become a
|
||||
typed speech proposal; wondering, deciding, attempting, and narration do not
|
||||
become audible speech merely because the Persona wrote them. The kernel remains
|
||||
the final structural guard for typed proposals. A stale revision or persistence
|
||||
fault is a command/infrastructure outcome, not an interpretation failure, and
|
||||
does not summon a semantic verifier or reconciler.
|
||||
|
||||
Any model transport or dispatch fault before explicit semantic finalization
|
||||
produces no report. The immutable Persona turn remains pending for a fresh
|
||||
interpretation attempt; partial captures are discarded as attempt telemetry,
|
||||
not committed as a half-report. `StepBudgetExhausted` means the harness reached
|
||||
its configured semantic stopping point normally. Infrastructure interruption is
|
||||
unavailable execution, not rejected meaning.
|
||||
|
||||
World authoring uses a separate typed proposal surface because it is not
|
||||
roleplay. It does not pass through Persona or borrow Persona authority.
|
||||
|
||||
### OperationalAgent controller
|
||||
|
||||
An operational controller receives a permissioned typed view and exact tool
|
||||
schemas directly. It is suitable when the simulated mind is itself an
|
||||
operator: an institution, political or administrative Gestalt, logistics organ,
|
||||
market participant, or other subject whose meaningful cognition is explicit
|
||||
state reasoning rather than embodied dramatic experience.
|
||||
|
||||
The operational agent may inspect only the state and affordances granted to its
|
||||
controller and may submit zero or one typed decision proposal. It may record an
|
||||
unrepresented operational need, but it cannot emit a NarrativePersona turn as
|
||||
fallback, claim facts outside its view, or commit.
|
||||
|
||||
Subject kind does not secretly choose the interface. A person normally carries
|
||||
a narrative controller and an administrative institution often carries an
|
||||
operational controller, but authored ontology makes the assignment explicit.
|
||||
If one institution needs both an operational decision organ and a person-shaped
|
||||
public representative, they are separate controllers with disjoint authority
|
||||
scopes. Awkward overlap is rejected rather than resolved by precedence.
|
||||
|
||||
### Presentation inference
|
||||
|
||||
Optional narration renders committed events for a particular audience. It is
|
||||
display-only. The exact typed events remain available beside the prose, and a
|
||||
rendering failure cannot block, alter, or repair a commit.
|
||||
|
||||
Inference receipts are operational telemetry in a separate store. The kernel
|
||||
does not require a provider, model tier, stage name, output hash, or verifier
|
||||
receipt to authorize world state.
|
||||
|
||||
Every controller lane reaches one `InferencePort`, but that port now routes
|
||||
to one of three transports chosen by the lane's configured model name: the
|
||||
CodexConnector daemon peer, unchanged; a local OpenAI-compatible loopback
|
||||
port (the `local/` prefix, Bonsai in production); or `SdkInferencePort`, a Node child
|
||||
process running the Claude Agent SDK against the Claude subscription while
|
||||
no Codex or Anthropic API budget exists. The SDK route is a named stopgap,
|
||||
not a peer design: its receipt attests only that this process's own child
|
||||
produced this session, not against a party Ghostlight does not control, and
|
||||
a prior round's conversation reaches the model as rendered prose rather than
|
||||
typed turns because the SDK owns the assistant side of its own transcript.
|
||||
Its exit condition is deletion, not extension — once an API budget exists, a
|
||||
Messages-API port is a closer structural match to `InferencePort` (inert
|
||||
`tool_use` blocks, a caller-appended `tool_result`, a real request id, no
|
||||
subprocess) and replaces it behind the same routed port.
|
||||
|
||||
## Action and consequence
|
||||
|
||||
An action proposal names one subject, one executable affordance, exact targets,
|
||||
and typed desired effects. The kernel derives authority and feasibility from
|
||||
the current ontology. If uncertainty matters, server-owned entropy selects a
|
||||
bounded outcome band. Only the typed effects for that band enter the mutation
|
||||
batch.
|
||||
|
||||
Speech is an event and may accompany an action. Generated speech cannot imply
|
||||
that an uncommitted effect occurred. Social appraisal and private memory are
|
||||
ordinary scoped mutations proposed by the affected subject; they do not gain a
|
||||
separate appraisal or verifier authority.
|
||||
|
||||
Player, NPC, institution, population, import, and strategic actions share this
|
||||
primitive.
|
||||
|
||||
## Autonomous scheduling
|
||||
|
||||
The scheduler is a pure planner over one revision. It orders eligible subjects
|
||||
using unresolved pressure and time since the last opportunity, with subject id
|
||||
as the final tiebreak. Causal exposure is derivable from `dependencies` and is
|
||||
not an ordering input. It emits `DecisionOpportunity` values and cannot commit.
|
||||
|
||||
Dungeon runs no turn on its own: no wall-clock tick, cover cell, elaboration
|
||||
sweep or owner-requested controller turn. The clock advances only through the
|
||||
owner's `world.advance_time`. The controller pool
|
||||
(`GHOSTLIGHT_CONTROLLER_MAX_CONCURRENT`, default 4) is read at open; nothing
|
||||
draws from it yet, and runtime readiness reads it. Changing it is a restart.
|
||||
The five
|
||||
controller models are configuration too (`GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL`,
|
||||
`_PERSONA_MODEL`, `_INTERPRETER_MODEL`, `_OPERATIONAL_MODEL`,
|
||||
`_ELABORATOR_MODEL`), each with a default when absent.
|
||||
|
||||
The kernel commits one decision at a time through the mailbox. After each
|
||||
commit the planner derives a fresh queue. Parallel inference may speculate on
|
||||
one snapshot; a proposal binds to the scope digest of the components its
|
||||
verification reads, commits at any later revision where that digest is
|
||||
unchanged, and is rejected with a typed `ScopeChanged` when it is not. Nothing
|
||||
is rebased and nothing is discarded merely because the world moved elsewhere:
|
||||
for the narrative lane, `ScopeChanged` on submit is one re-lowering, not a
|
||||
lost turn. The runner re-lowers the same prose once through the Interpreter
|
||||
against a fresh opportunity, with the delta the subject could perceive since
|
||||
its bound revision (`Overheard` rows plus the typed component diff); the
|
||||
Persona is never re-run, and the renewed binding carries the one it replaced
|
||||
(`interrupted_from`) so a second interruption of the same turn ends it rather
|
||||
than lowering it again.
|
||||
|
||||
Resolution covers and grouping are compute budgets for projection. They do not
|
||||
create, merge, fission, or qualify identities. Every active subject is in the
|
||||
cover every tick; the budget decides at what resolution. Singleton cells give
|
||||
detail focus and the prose membrane; grouped cells represent their constituents
|
||||
operationally at coarse resolution with per-constituent attribution. Debt
|
||||
rotation guarantees every subject reaches detail focus within bounded ticks.
|
||||
The 2,400-subject, 240-cell profile is a design target for a living world, not
|
||||
only a load fixture: it tests the elaborators and the simulation for quality
|
||||
under scarcity of attention.
|
||||
|
||||
## Source grounding and open-world expansion
|
||||
|
||||
Vault retrieval returns exact evidence receipts. One author inference may fill
|
||||
semantic fields in a typed seed or boundary-expansion proposal. The reducer
|
||||
issues IDs and validates the closed structural graph.
|
||||
|
||||
Seed construction, destination expansion, locality detail, causal
|
||||
individuation, and external import all use the same ontology mutation
|
||||
vocabulary. Expansion is requested only for a named causal boundary. A sparse
|
||||
world with enough structure for its current horizon is valid.
|
||||
|
||||
Qualitative evaluators may report implausibility, monotony, missing social
|
||||
texture, or poor narrative leverage after a run. Those reports guide a future
|
||||
author proposal; they do not become a hidden admission tribunal.
|
||||
|
||||
## Persistence and recovery
|
||||
|
||||
One world `.cc` store (`crates/ghostlight/src/journal.rs`) contains:
|
||||
|
||||
- one current `world_state.elaboration.v1` row, schema
|
||||
`ghostlight.world_state.consumer.v6`;
|
||||
- immutable `world_commit.elaboration.v1` rows, schema
|
||||
`ghostlight.world_commit.consumer.v6`, forming a digest chain. Evidence
|
||||
receipts ride inside the commits' patches; there is no separate evidence row.
|
||||
|
||||
Service authentication and model telemetry use separate service-owned stores.
|
||||
They cannot participate in world authority.
|
||||
|
||||
Recovery verifies `(world_id, revision, state_digest, last_commit_digest)`,
|
||||
starts the mailbox, derives due work, and continues. In-flight inference is
|
||||
disposable. A repeated command ID is idempotent only when its full digest
|
||||
matches the recorded commit. Physical filenames, smoke checkpoints, prose
|
||||
summaries, and status files are not recovery truth.
|
||||
|
||||
Pre-rebuild campaign stores remain immutable evidence. The new runtime does not
|
||||
load them into live authority through a compatibility adapter. Any future
|
||||
migration must be an explicit, audited import that emits ordinary commands.
|
||||
|
||||
## External boundaries and surfaces
|
||||
|
||||
- Heimdall owns public identity; Ghostlight stores only the app-local principal
|
||||
needed to derive membership authority.
|
||||
- VoidBot or another Vault provider owns retrieval; evidence receipts grant
|
||||
provenance, not mutation authority.
|
||||
- External world consumers retain sovereignty over their declared subjects.
|
||||
Ghostlight imports only owner-signed components through the ordinary command
|
||||
path and cannot invent internal state for them.
|
||||
- Idunn owns deployment and daemon continuity; Odin owns discovery. Neither can
|
||||
write a world.
|
||||
- Eve/CultUI and CultMesh expose typed projections of the same `WorldState` and
|
||||
command catalog. Renderers do not own parallel dashboard truth.
|
||||
|
||||
## Deletion line
|
||||
|
||||
The rebuild removes these authorities before replacing behavior:
|
||||
|
||||
- `SessionZeroKernel`, its registry/director state, and publication handoff;
|
||||
- canonical aggregate `Campaign` and the component-to-Campaign projection
|
||||
writer;
|
||||
- `legacy_transition` and every special construction or repair commit path;
|
||||
- duplicate or cell-specific Projector-Persona-Interpreter implementations;
|
||||
implicit mode switching or fallback between narrative and operational
|
||||
controllers; effect verifier, assessment verifier, outcome verifier, civic
|
||||
verifier, and reconciliation roles;
|
||||
- destination identity inference and name-based structural binding;
|
||||
- model resolution demand, Nemesis selection, and identity invention;
|
||||
- titled elaborator quotas, complexity qualification, semantic qualification,
|
||||
fission completion counts, and model compaction memory;
|
||||
- newspaper selection/editor/copy-desk authority and checkpoint recovery;
|
||||
- acceptance-driver gates that decide world validity;
|
||||
- checkpoints, resume files, caches, and model receipts used as fictional truth.
|
||||
|
||||
The retained foundations are the typed component mutation algebra, one mailbox,
|
||||
CultCache CAS and immutable receipts, exact evidence, deterministic topology and
|
||||
scope validation, actor-private projection, and the declared service ownership
|
||||
boundaries.
|
||||
|
||||
## Verification contract
|
||||
|
||||
Focused tests prove the machine under fixture inference ports. No live smoke
|
||||
exists: the play agent's Cut 1 (`d69e9d4`) deleted the tick driver, cover and
|
||||
Active elaboration sweep it exercised, along with the ignored test itself, to
|
||||
clear the ground for the play agent's own owner. `notes/local-live-smoke.md`
|
||||
now documents only connector and Claude SDK sidecar bring-up, which the seed
|
||||
lane still uses. The real-model gate that exists today is the Idunn recipe's
|
||||
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`), which
|
||||
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
against a real model; exercising a whole play turn against one is not gated
|
||||
by this contract.
|
||||
`world.begin` is the one route that makes a world. Its payload is
|
||||
`world_begin.v1`: title, brief and subject_label, nothing else; the route
|
||||
decides everything else a world is born with (no targets, no roots, no persona
|
||||
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
|
||||
goes. `world.seed` runs one
|
||||
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
|
||||
|
||||
Focused tests must prove:
|
||||
|
||||
1. Draft and active commands mutate the same aggregate through the same commit
|
||||
primitive.
|
||||
2. No alternate input can write canonical state.
|
||||
3. Unknown IDs, dangling routes, invalid relation endpoints, custody creation,
|
||||
authority escape, private-knowledge leakage, and external-owner violation
|
||||
fail before persistence.
|
||||
4. Interpreter semantics are total: representable material becomes typed
|
||||
proposals, unrepresentable material becomes exact translation gaps, and no
|
||||
semantic path terminalizes the interpretation or calls a verifier or
|
||||
reconciler model.
|
||||
5. Scheduler, transcript, news, and surfaces can be discarded and rebuilt from
|
||||
the same committed state.
|
||||
6. Restart resumes from the commit digest chain with no checkpoint or repair
|
||||
pass.
|
||||
7. A sparse structurally supported world activates successfully.
|
||||
8. A synthetic 1,200-owner fixture exercises scheduling and projection without
|
||||
changing production completeness rules.
|
||||
9. Direct user, model, scheduler, import, and reload paths all reach the same
|
||||
reducer and CAS.
|
||||
10. Every autonomous opportunity resolves through one exact controller; its
|
||||
authority scopes do not overlap another controller, and gaps or faults
|
||||
cannot trigger cross-mode fallback or double action.
|
||||
|
||||
The Projector-Persona-Interpreter membrane has its own behavioral evaluation.
|
||||
Operational-agent success in its admitted scopes is not evidence that tools are
|
||||
safe for a person-shaped role. Any proposal to replace a `NarrativePersona`
|
||||
controller with a tool-using agent must compare both designs on the same
|
||||
sequential fixtures, model, visible facts, and token budget. It must show no
|
||||
material regression in voice, embodiment, uncertainty, tonal range, schema
|
||||
leakage, tool-shaped diction, or continuity while matching typed-effect
|
||||
fidelity. Until that evidence exists, narrative Personas remain prose-only.
|
||||
|
||||
Qualitative world and narrative evaluation is a separate post-run artifact. It
|
||||
may be severe. It may not become a writer.
|
||||
886
docs/architecture/ghostlight-library-extraction-cut.md
Normal file
886
docs/architecture/ghostlight-library-extraction-cut.md
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
# Ghostlight library extraction — cut map (plan step 15, L0)
|
||||
|
||||
Status: cut map. Ends are owned by `ghostlight-library-extraction.md`; this
|
||||
document owns the means. Written 2026-09-15 against `d83534c` on
|
||||
`codex/ghostlight-dungeon-mvp`; first version committed at `02bbf62`;
|
||||
refreshed 2026-09-16 with the operator's rulings.
|
||||
|
||||
Cut 1 landed at Ghostlight `108b691..f859d2c`. `108b691` is 13 renames with
|
||||
zero content change; `f859d2c` builds. Verified by Soul's own runs, not the
|
||||
Hands report: library 419 passed + 1 ignored with test names identical to the
|
||||
base `world::` set; 10 compile_fail doc-tests pass, also under
|
||||
`--all-features`; Dungeon 50 + 1 ignored, all 49 base names present and the
|
||||
+2 exactly the two new soul tests; no `[features]`, `cfg(feature`, or
|
||||
`cfg(any(test` in the library; no schema string, `_NAMESPACE`, `derive_id`,
|
||||
serde attribute, or `Preimage` changed; the rewritten tests fail under
|
||||
mutation of what they pin. Windows only; the Linux release remains Idunn's.
|
||||
|
||||
Soul found six defects. Triage:
|
||||
- **F1 seal is name-based (high, open):** every `pub` ID derives
|
||||
`Deserialize` (`crates/ghostlight/src/lib.rs:186-200`), as do `ScopeDigest`
|
||||
(`:532`) and `DecisionOpportunity` (`:559`), so an external crate mints IDs,
|
||||
reproduces `ScopeDigest::fixture`, and forges a `DecisionOpportunity` into
|
||||
`derive_cover` from JSON. The reducer still rejects forged opportunities
|
||||
(`lib.rs:4192-4201`), so kernel integrity holds and the invariant's wording
|
||||
does not. Dungeon's own Eve payloads deserialize these types, so removing
|
||||
the derives is not available. Operator question Q1-9 below.
|
||||
- **F2 single-minter test is bypassable (medium, fix):** the count at
|
||||
`crates/ghostlight-dungeon/src/app_session.rs:530-555` greps a literal and
|
||||
misses `<VerifiedPrincipalEvidence>::new(`, an alias, a renamed test module,
|
||||
or a subdirectory.
|
||||
- **F3 surface wider than bought (low, fix):** 71 `pub` identifiers are
|
||||
unreferenced by Dungeon; `ScopeDigest`, `WorkLane`, and `ScopeComponents`
|
||||
compile as `pub(crate)` and were opened only to quiet `private_interfaces`.
|
||||
- **F4 "Eve" named in library error strings (low, fix):**
|
||||
`crates/ghostlight/src/controllers.rs:1494,2576,2582,2584`. Pre-existing;
|
||||
invariant 3 is still unmet.
|
||||
- **F5 two Hands claims false as stated (cosmetic, recorded):** `Cargo.lock`
|
||||
did gain a `ghostlight` package entry (no new external package), and
|
||||
`fixture_[a-z]` has 8 benign hits in Dungeon.
|
||||
- **F6 recipe schema drift (pre-existing, recorded):**
|
||||
`deployment/idunn/recipe.toml` declares `world_state.foundation.v0` and
|
||||
`controller_work.v3` against code at `consumer.v4` and
|
||||
`controller_work.v15`. Outside L0; it changes no behavior here.
|
||||
|
||||
Cut 2 landed at gamecult-ops `6aba281` (text only; nothing live was run).
|
||||
The library test step is in the runbook, the deploy script, and the wiring
|
||||
test, ordered as `recipe.toml` orders it. Two spec defects Hands reported
|
||||
rather than redesigned: the assertion this map specified was vacuous
|
||||
(`require_text` is `grep -Fq`, and the string given is a substring of the
|
||||
persona-projection line, so it passed with the library step deleted), so
|
||||
Hands used the file's existing exact-line helper and proved it fails under
|
||||
deletion; and the acceptance test path lives in `acceptance_test=` at
|
||||
`scripts/deploy-ghostlight-yggdrasil.sh:34`, a fourth site this map did not
|
||||
list, pinned by the wiring test and repeated in the runbook prose. Operator
|
||||
consequence: changing `acceptance_test` changes the acceptance binding, so
|
||||
sealed witnesses under `/srv/ghostlight/acceptance/<binding-sha>/` from
|
||||
before this commit will miss and regenerate.
|
||||
|
||||
Soul passed on Cut 2 and found the assertions pin text, not invocations:
|
||||
- **C2-F1 (high, fixing):** deleting the two lines above the pinned cargo
|
||||
line in `scripts/deploy-ghostlight-yggdrasil.sh` leaves it byte-identical
|
||||
as trailing argv on the *persona-projection* container, so the kernel
|
||||
tests never run and the wiring test still exits 0. Prefixing the
|
||||
`docker run` line with `: ` does the same.
|
||||
- **C2-F2 (medium, fixing):** nothing pins the order of the four cargo
|
||||
steps; the library block moved after the release build and the wiring
|
||||
test passed, which would seal an artifact before the kernel tests ran.
|
||||
- **C2-F3 (medium, fixing):** the wiring test never reads the runbook, so
|
||||
only three of the four acceptance sites are enforced to agree.
|
||||
- **C2-F4 (medium, pre-existing, recorded):** `deploy-…sh:937` takes a
|
||||
reuse branch when the release directory for a commit already exists, so a
|
||||
redeploy of a sealed commit runs no cargo steps at all. This bounds "the
|
||||
deploy runs the kernel tests" to a commit's first build. Operator owns
|
||||
whether that is right.
|
||||
- **C2-F5 (low, recorded):** `docs/repo-census-2026-09/*` still shows
|
||||
`src/world/...`. Dated census; no deploy effect.
|
||||
|
||||
Held: the step sits in the same branch, image and mounts as its
|
||||
neighbours, and `set -euo pipefail` with `trap` means a library-test
|
||||
failure does abort the deploy. The acceptance test exists at
|
||||
`crates/ghostlight/src/controllers.rs:8980` under `-p ghostlight --lib`.
|
||||
|
||||
C2-F1, C2-F2 and C2-F3 are fixed at gamecult-ops `803f2c7` (wiring test
|
||||
only). A new `require_invocation` helper pins each of the four cargo steps
|
||||
as a whole contiguous docker invocation and returns its start line; three
|
||||
numeric comparisons assert the recipe order; the runbook became a checked
|
||||
file, tying all four acceptance sites together. All four of Soul's
|
||||
mutations now exit 1 where they previously exited 0, and every
|
||||
pre-existing assertion still passes. `require_order` was unusable here
|
||||
because its `last_line` resolves a substring and would have bound the
|
||||
library step to the acceptance invocation.
|
||||
|
||||
Soul passed on the fix batch and confirmed it does what it claimed:
|
||||
whole-invocation pinning survives image swaps, line swaps, re-indentation,
|
||||
continuation splits and appended flags; the order checks catch a move into
|
||||
the reuse branch; the runbook is tied in both directions; `fail` inside a
|
||||
command substitution does abort, because `set -e` fires on the
|
||||
assignment's status; nothing was weakened. It also found the structural
|
||||
limit, recorded and not fixed:
|
||||
- **C2-F6 (high, recorded):** an exact-line pin proves the lines exist,
|
||||
not that they run. Wrapping the block in `if false`, prefixing the
|
||||
opener with `true || \`, or swallowing it in a heredoc keeps every
|
||||
pinned line byte-identical and the checker exits 0.
|
||||
- **C2-F7 (medium, recorded):** the four steps carry no explicit `||
|
||||
exit`; abort depends entirely on `set -euo pipefail` at
|
||||
`deploy-…sh:2`, which nothing pins. Removing the `e` leaves the checker
|
||||
green, so a failing kernel suite would be followed by a sealed release.
|
||||
- **C2-F8 (low, recorded):** a duplicated block passes, since the helper
|
||||
takes the first match and never asserts a count.
|
||||
|
||||
These stay recorded rather than fixed: that script is a legacy actuator
|
||||
under Ghostlight's do-not-invoke warning and is retired at the deployment
|
||||
gate, and the authority that actually runs the tests is Ghostlight's own
|
||||
`deployment/idunn/recipe.toml`. Closing them means control-flow analysis
|
||||
in a bash checker for a script we intend to delete. Revisit only if the
|
||||
deployment gate keeps the script.
|
||||
|
||||
Follow-ups outside this migration, from the fix batch:
|
||||
- `scripts/deploy-ghostlight-yggdrasil.sh:974`, the web container, is
|
||||
pinned by substring and survives a `: ` prefix that neuters it.
|
||||
- The Codex Connector's four cargo steps are bare substring pins with no
|
||||
invocation or order assertion: the connector-side C2-F1 and C2-F2.
|
||||
- `require_command_options` searches a fixed 18-line window, so a block
|
||||
that grows past it stops being checked silently.
|
||||
|
||||
Cut 1 fix batch landed at Ghostlight `7c1d1e9..2865ea7`: `a8d3377` external
|
||||
rejection tests (Q1-9), `fac90e6` single-minter check by name (F2),
|
||||
`7cbb9e3` Eve removed from library text (F4), `2865ea7` public surface
|
||||
262 → 258 (F3). Hands' correction to F3 is confirmed by Soul: Dungeon calls
|
||||
`bound_scope_digest` and `fresh_scope_digest`, and `ScopeDigest`, `WorkLane`
|
||||
and `ScopeComponents` are held public by `KernelError::ScopeChanged`,
|
||||
`ControllerWorkCustody::Uncertain::lane` and `NarrativeCheckpoint` variant
|
||||
fields; closing them means reshaping public enums, which F3 does not
|
||||
license. `fac90e6`'s commit message describes a replaced brace-counting
|
||||
scanner; the landed code and its doc comment are correct.
|
||||
|
||||
Soul held: the rejection tests are a true external crate with no `#[path]`
|
||||
or `include!`, driving `ControllerPort::submit_controller` through the
|
||||
mailbox into `reduce` with no pre-validation; every commit moves `revision`
|
||||
and `state_digest`, so the unchanged-state assertions are sound; the
|
||||
positive test goes red when issued opportunities are refused; counts,
|
||||
the 14 distinct warnings, and both manifests are unchanged.
|
||||
|
||||
Soul found:
|
||||
- **S1 (medium, fixing):** both forged digests differ in length from a real
|
||||
`sha256:` + 64 hex, so comparing only lengths at
|
||||
`crates/ghostlight/src/lib.rs:4203` leaves all seven tests green. The
|
||||
realistic forgery is a stale same-length digest from an earlier revision.
|
||||
- **S4 (medium, open, Q1-10):** the minter scanner at
|
||||
`crates/ghostlight-dungeon/src/app_session.rs:523-668` misses a `pub type`
|
||||
alias, a multi-line alias, a `} // tests` closing brace, non-rustfmt
|
||||
indentation, and `::new` taken as a function value.
|
||||
- **S7 (low, fixing):** `controllers.rs:122 provider_model` closes to
|
||||
`pub(crate)` with no error and no warning change.
|
||||
- **S8 (cosmetic, fixing):** `mailbox.rs:580` names `AppSessionOwner` and
|
||||
`controllers.rs:2606` names AppSession.
|
||||
- **S2 (low, recorded):** neutering the world_id check at `lib.rs:4193`
|
||||
leaves the suite green because `ScopePreimage` includes `world_id`, so the
|
||||
scope lookup refuses first. Redundant by construction; not isolable from
|
||||
outside the crate.
|
||||
- **S3 (low, recorded):** the decline path (`lib.rs:1603`) is `pub(crate)`
|
||||
and unreachable from an integration test.
|
||||
- **S5 (low, recorded, pre-existing):** `runtime.rs:3584`'s credential-name
|
||||
test splits on one literal `#[cfg(test)]` marker and is evadable the same
|
||||
way the minter test was.
|
||||
- **S6 (medium, recorded):** stable rustc does not enforce `compile_fail`
|
||||
error codes; `E0603` changed to `E0999` still passes. All ten doc-tests
|
||||
prove "does not compile", not privacy, and the three `#[cfg(test)]`
|
||||
items (`SubjectId::issue`, `ScopeDigest::fixture`, the `fixture_*` fns)
|
||||
prove non-existence. Enforcing codes needs a snapshot tool such as
|
||||
trybuild; recorded, not added.
|
||||
|
||||
**Q1-10 how the single minter is enforced (defaulted, 2026-09-16).** Asked
|
||||
how much it mattered, the operator said not at all. The scanner stays as a
|
||||
tripwire with stated limits: it guards only against the project's own
|
||||
Dungeon code, and what evades it is deliberate. S4 is a recorded follow-up,
|
||||
with Clippy's `disallowed-methods` for
|
||||
`ghostlight::VerifiedPrincipalEvidence::new` noted as the fix if a second
|
||||
minter ever becomes plausible.
|
||||
|
||||
S1, S7 and S8 landed at Ghostlight `bde05de..af9c95a`. S1 submits a
|
||||
genuinely issued digest made stale by another controller's speech, plus the
|
||||
current digest with its last digit altered. Soul confirmed the premise
|
||||
check fails when staleness does not happen and that no earlier check
|
||||
intercepts the forgery, then found two surviving comparison mutants
|
||||
(case-insensitive, hex-only after the label). Those, and "player"
|
||||
vocabulary in library tests, landed at `b14507c` (uppercased-letter,
|
||||
bare-hex and relabelled forgeries, all six comparison mutants killed) and
|
||||
`ab95d78` (test-only rename to "human", one test name changed). Soul's
|
||||
final pass found nothing to fix.
|
||||
|
||||
**L0 closed, 2026-09-16.** Postmortem:
|
||||
`ghostlight-library-extraction-postmortem.md`. Follow-ups are listed there.
|
||||
|
||||
**Q1-9 what sealing means (ruled, operator, 2026-09-16): option A.** The
|
||||
invariant is unforgeable admission, not unconstructible types. External
|
||||
code may hold a syntactically valid ID, digest, or opportunity; the kernel
|
||||
must reject any it did not issue, and that is proven by rejection tests
|
||||
written from outside the crate. The compile_fail set stays for the
|
||||
genuinely private items. The question as first posed: A: restate invariant 1 as unforgeable
|
||||
*admission* — external code may hold a syntactically valid ID or opportunity,
|
||||
and the kernel must reject any it did not issue — and prove it with rejection
|
||||
tests from an external crate plus the existing compile_fail set. B: make the
|
||||
types unconstructible from outside, which means Dungeon stops deserializing
|
||||
`DecisionOpportunity` in its Eve payloads and receives an opaque token
|
||||
instead, a design change L0 excludes. **Recommended: A**, because the kernel
|
||||
already enforces it and B moves work into L0 that the target puts out of
|
||||
scope. Whichever is chosen, F2 and F3 are fixed in the same Hands batch.
|
||||
|
||||
Rulings (operator, 2026-09-15):
|
||||
- **Q1-1 crate name:** "crate name is `ghostlight`."
|
||||
- **Q1-2 adapters:** "`vault.rs` and `sdk_inference.rs` both go in the
|
||||
library."
|
||||
- **Q1-3 principal seam:** "option A, library-owned
|
||||
`VerifiedPrincipalEvidence` with a public `new`, plus the Dungeon count
|
||||
test pinning the single minter."
|
||||
- **Q1-4 fixtures:** "NOT the `test-support` feature. The operator takes the
|
||||
alternative: the sealed ID `issue()` constructors and `ScopeDigest::fixture`
|
||||
stay unreachable from outside the library under every configuration,
|
||||
including feature-enabled builds, and the Dungeon tests that currently
|
||||
need them move into the library instead. Rationale: a sealed constructor
|
||||
exposed behind a feature is still reachable by any crate that turns the
|
||||
feature on, which contradicts invariant 1." (Supersedes the `test-support`
|
||||
design in the `02bbf62` map; that text is history and is not repeated
|
||||
here. The classification below found that none of the affected tests is
|
||||
a kernel test, which raises Q1-7 and Q1-8.)
|
||||
- **Q1-5 persona projection:** "`ghostlight-persona-projection` stays a
|
||||
separate crate."
|
||||
- **Q1-6 gamecult-ops:** "edit the five gamecult-ops cargo lines in Cut 2;
|
||||
do not retire the scripts (retirement belongs to the deployment gate)."
|
||||
Correction to the `02bbf62` map: Ghostlight's handoff does call those
|
||||
helpers unsafe — `notes/fresh-workspace-handoff.md:389-391`: "Do not
|
||||
invoke the current bounded Connector or Ghostlight redeploy helpers; they
|
||||
fail at the root/Idunn Git boundary and still embody duplicate target
|
||||
deployment authority." The earlier claim that no such doc existed was
|
||||
wrong; the ruling to edit rather than retire stands on the deployment
|
||||
gate owning retirement, not on the scripts being live.
|
||||
|
||||
Rulings (2026-09-16), taken by Self under the operator's standing "Go"
|
||||
after Q1-4's classification found no kernel test to move. Either may be
|
||||
reversed by the operator; both are recorded here as the live design:
|
||||
- **Q1-7 port value constructors:** option A. `PreparedInference::prepare`
|
||||
(renamed from `prepare_invocation`), `InferenceOutput::new`, and
|
||||
`InferenceFault::{retryable, recovery_required, integrity_violation}`
|
||||
become the port's public contract; the five `fixture_*` items are
|
||||
deleted and `TracingInferencePort` becomes an ungated decorator. A
|
||||
public trait whose return types only the library can construct is not a
|
||||
port, none of these touches a sealed item, and the change is a net
|
||||
deletion.
|
||||
- **Q1-8 cover source:** option A. The tick-ordering test derives its
|
||||
cover from a real world through `derive_cover` and asserts 2 cells, with
|
||||
its ordering and shared-tick assertions unchanged;
|
||||
`fixture_controller_opportunities`, the only outside caller of a sealed
|
||||
`issue()`/`ScopeDigest::fixture`, is deleted.
|
||||
|
||||
Together these satisfy Q1-4's intent: the sealed ID issuers and
|
||||
`ScopeDigest::fixture` stay private `#[cfg(test)]` items with no feature
|
||||
gate anywhere, unreachable from outside the library under every
|
||||
configuration.
|
||||
|
||||
Open: nothing blocking Cut 1. Operator checks remain on the Linux release
|
||||
(Idunn's recipe run), which no Windows probe can prove.
|
||||
Follow-ups outside this migration: see "Findings not assigned to a cut".
|
||||
|
||||
Probe names versus real names: probes 1–4 built the crate as
|
||||
`ghostlight-world` (`use ghostlight_world::…`); probe 5 rebuilt it as
|
||||
`ghostlight` (`use ghostlight::…`). The difference is the package name, the
|
||||
directory `crates/ghostlight`, and the path prefix; every mechanism finding
|
||||
held in both. This map uses the real name throughout.
|
||||
|
||||
## What the probes established
|
||||
|
||||
All probes ran in scratch worktrees at `d83534c` (both removed, repo clean)
|
||||
on the Windows workstation with `cargo check` and `cargo test --doc`; they
|
||||
prove the crate graph and visibility on Windows only. The Linux proof is
|
||||
Idunn's recipe run on Yggdrasil. Logs are in the session scratchpad
|
||||
(`probe1*-check.log`, `probe3*-visibility.log`, `probe5.log`,
|
||||
`base-test-list.log`); they are not repo artifacts.
|
||||
|
||||
- **Base test census** (`cargo test -p ghostlight-dungeon --bin
|
||||
ghostlight-dungeon -- --list`, repo target dir): 469 unit tests in the bin,
|
||||
of which 420 are under `world::` (action 34, clock_tests 45, consumer 30,
|
||||
controllers 81, cover 18, custody_tests 17, elaboration 14, journal 24,
|
||||
knowledge_tests 21, mailbox 15, patch 34, sdk_inference 23,
|
||||
soul_knowledge_tests 9, tests 32, vault 6, witness_tests 17) and 49 are
|
||||
Dungeon's own (app_session 7, eve 5, heimdall 5, idunn_health 4, mesh 4,
|
||||
runtime 24). `tests/build_provenance.rs` has 1. `ghostlight-persona-
|
||||
projection` has 13 unit, 0 doc. Two tests are `#[ignore]`:
|
||||
`world::controllers::tests::real_codex_connector_cognition_modes_commit_speech`
|
||||
and `runtime::tests::live_smoke_seeds_then_ticks_a_world_against_the_connector`.
|
||||
- **Base warning**: `mod.rs:28-32` re-exports nine names nobody in the crate
|
||||
uses (`ControllerOpenError`, `NarrativeCapture`, `NarrativeDecision`,
|
||||
`NarrativePending`, `OperationalCapture`, `OperationalDecision`,
|
||||
`OperationalPending`, `SourceRange`, `TranslationGapSummary`). Their
|
||||
deletion is inside Cut 1.
|
||||
- **The move compiles** (probe 1d). Moving `src/world/*` to the new crate
|
||||
(`mod.rs` → `lib.rs`), rewriting `crate::world::` → `crate::` inside it and
|
||||
→ `ghostlight::` in Dungeon, and moving `VerifiedPrincipalEvidence` into
|
||||
the library root: `cargo check` on the library, its tests, the Dungeon
|
||||
bin, and the Dungeon tests all pass. Two mechanical faults: the 94
|
||||
`pub(super)` sites in the former `mod.rs` are illegal at a crate root
|
||||
(become `pub(crate)`), and `consumer.rs:62` needs `serde_bytes` in the
|
||||
library manifest.
|
||||
- **What Dungeon needs public** (probe 3, four compiler rounds from every
|
||||
item `pub(crate)`): 54 production items, 22 test-only items, 38 fields on
|
||||
10 structs, 36 methods, the opaque IDs `AffordanceId`/`CommandId`/
|
||||
`SubjectId`, and `FictionalMinutes(u64)` (one Dungeon test). Nothing
|
||||
sealed appears: `WorldKernel`, `WorldState`, `reduce`, `apply_effect`,
|
||||
`CommandEnvelope`, `AuthenticatedCaller`, the journal, `*::issue()` and
|
||||
`ScopeDigest::fixture` are never named by Dungeon. Under the narrowed
|
||||
library `cargo check -p ghostlight` emits 646 dead-code warnings — a
|
||||
second enumeration of the same boundary (an item used only by Dungeon is
|
||||
dead inside the library until it is `pub`).
|
||||
- **The sealed set under the Q1-4 ruling** (probe 5, crate `ghostlight`, no
|
||||
cargo features, fixtures left `#[cfg(test)]`): ten `compile_fail`
|
||||
doc-tests on the library root pass under `cargo test -p ghostlight --doc`
|
||||
and again under `cargo test -p ghostlight --all-features --doc` (the crate
|
||||
declares no features, so the second run is the same build; it is the
|
||||
command Soul re-runs if a feature is ever added). `cargo check -p
|
||||
ghostlight-dungeon --bin ghostlight-dungeon` passes: production needs no
|
||||
fixture. `cargo check -p ghostlight-dungeon --tests` fails on exactly four
|
||||
errors — `runtime.rs:3081` (`ControllerWork…` family, `Inference…`
|
||||
types, three `fixture_inference_*`), `:3570`
|
||||
(`fixture_controller_opportunities`), `:2582` (`TracingInferencePort`),
|
||||
`:2581` (`InferencePort`, because the base re-export at `mod.rs:66` is
|
||||
`cfg(test)`). Those four sites are the whole cross-boundary test surface;
|
||||
they are classified under Cut 1.
|
||||
- **Compile-fail mechanism**: doc-tests compile as an external crate, which
|
||||
is exactly the boundary; no `trybuild`, no scratch crate. The ten
|
||||
(`use ghostlight::WorldKernel`, `reduce`, `WorldState`,
|
||||
`CommandEnvelope`, `AuthenticatedCaller`, `journal::WorldJournal`,
|
||||
`SubjectId::issue()`, `ScopeDigest::fixture("x")`,
|
||||
`fixture_controller_opportunities(&[])`, `fixture_inference_output(..)`)
|
||||
are pinned to `E0603`, `E0599`, `E0599`, `E0425`, `E0425`.
|
||||
- **Negative-grep collisions** checked in `src/world/`: `axum`, `tower_http`,
|
||||
`jsonwebtoken`, `heimdall`, `eve::`, `idunn_health`, `Session Zero` have
|
||||
zero legitimate hits. `Dungeon` collides with the vault fixture path
|
||||
`Spoilers/Dungeons/Provenance.md` (`vault.rs:489,581`): use `\bDungeon\b`.
|
||||
`player` collides with `tests::player()` (`consumer.rs:966`): do not
|
||||
publish a bare `player` pattern. `app_session` has 5 hits, all the
|
||||
principal seam, and must reach 0.
|
||||
- **Line endings**: index and working tree are LF (`git ls-files --eol`
|
||||
reports `i/lf w/lf`); `core.autocrlf=true` rewrote every fresh scratch
|
||||
worktree to CRLF. Hands edits in `F:\Projects\Ghostlight` itself, never in
|
||||
a fresh worktree; if a worktree is unavoidable, run `git ls-files --eol
|
||||
crates` after the move and refuse any `w/crlf`. `sdk_inference.rs:1831`
|
||||
normalizes `\r\n` before scanning source.
|
||||
|
||||
## Cut 0. Captures
|
||||
|
||||
- **Repo/branch:** Ghostlight `codex/ghostlight-dungeon-mvp` at `02bbf62`
|
||||
(source identical to `d83534c`). No commit; output lives in the session
|
||||
scratchpad.
|
||||
- **First:**
|
||||
- Test census: `cargo test -p ghostlight-dungeon --bin ghostlight-dungeon
|
||||
-- --list > base-tests.txt` (PowerShell `>` writes UTF-8 BOM + CRLF;
|
||||
compare post-cut with the same redirection and `Compare-Object` on
|
||||
trimmed lines after stripping the `world::` prefix). Expected 469.
|
||||
- A `world.cc` written at base. No store exists on disk
|
||||
(`F:\Projects\Ghostlight-smoke\ghostlight` holds no `world.cc`), so
|
||||
produce one: in a scratch copy at `d83534c`, add an ignored test beside
|
||||
`mailbox.rs:966 fixture()` that opens `WorldMailbox::open(<capture>/world.cc)`,
|
||||
creates through `create_fixture`, approves, activates, and ticks three
|
||||
times through `submit_clock`; record `snapshot().revision`, the
|
||||
`state_digest`, and the last `commit_digest` from `operator_log` to
|
||||
`capture.txt`. Copy `world.cc` and `capture.txt` to the scratchpad.
|
||||
After Cut 1 the same test body, in the library's `mailbox` tests,
|
||||
reopens that file and asserts the three values are equal. The
|
||||
`controller-work.cc` layout is pinned by the untouched
|
||||
`CONTROLLER_WORK_SCHEMA`/`CONTROLLER_WORK_ROW` constants
|
||||
(`controllers.rs:75-76`) and the existing v7/v8 refusal tests
|
||||
(`controllers.rs:6487,10654`); no separate capture.
|
||||
- `Get-ChildItem target -Recurse | Measure-Object Length -Sum`: 7.5 GiB at
|
||||
the start of this pass.
|
||||
|
||||
## Cut 1. Extract the library crate
|
||||
|
||||
- **Repo/branch:** Ghostlight `codex/ghostlight-dungeon-mvp` from
|
||||
`02bbf62`. One commit; the crate graph does not build half-moved. Depends
|
||||
on Cut 0 and rulings Q1-7, Q1-8.
|
||||
- **First:** Cut 0 captures in hand; `git status` clean; working in the
|
||||
main checkout (LF).
|
||||
- **Deletes first:**
|
||||
- `crates/ghostlight-dungeon/src/world/mod.rs:28-32` — the nine unused
|
||||
re-exported names above (leave `CellRun`, `ConnectorBinding`,
|
||||
`ControllerError`, `ControllerModels`, `ControllerPendingReason`,
|
||||
`ControllerRunner`, `ControllerWorkCustody`, `NarrativeRun`,
|
||||
`OperationalRun`, `SubmissionDisposition`, `open_controller_work`,
|
||||
`open_inference`).
|
||||
- `crates/ghostlight-dungeon/src/world/mod.rs:61-72` — the `#[cfg(test)]`
|
||||
re-export block (12 lines). The port traits and their value types in it
|
||||
return as ungated production `pub use` under Adds; the three
|
||||
`fixture_inference_*` names do not return.
|
||||
- `crates/ghostlight-dungeon/src/world/mod.rs:483-508` —
|
||||
`fixture_controller_opportunities` (26 lines), per Q1-8 A; and
|
||||
`controllers.rs:372-382,409-432` — `fixture_prepared_inference`,
|
||||
`fixture_inference_output`, `fixture_inference_events` (about 40
|
||||
lines) and `controllers.rs:221-237` — `InferenceFault::fixture_*`
|
||||
(17 lines), per Q1-7 A. Under Q1-7 B / Q1-8 B these stay `#[cfg(test)]`
|
||||
inside the library and the Dungeon tests that used them are deleted
|
||||
instead (see the classification).
|
||||
- `crates/ghostlight-dungeon/src/app_session.rs:54-91` — the
|
||||
`VerifiedPrincipalEvidence` struct, accessors, and `fixture` (38 lines).
|
||||
- `crates/ghostlight-dungeon/src/main.rs:7` — `mod world;`.
|
||||
- `crates/ghostlight-dungeon/Cargo.toml:27` — the direct
|
||||
`ghostlight-persona-projection` dependency (`controllers.rs:35` is the
|
||||
only importer and it moves).
|
||||
- **Keeps and moves:**
|
||||
- `git mv crates/ghostlight-dungeon/src/world/{action,clock,consumer,
|
||||
controllers,cover,elaboration,journal,mailbox,patch,sdk_inference,
|
||||
tool_schema,vault}.rs crates/ghostlight/src/` and
|
||||
`git mv .../world/mod.rs crates/ghostlight/src/lib.rs`. Verify with
|
||||
`git diff --cached -M --stat` that all 13 show as renames.
|
||||
- Inside the moved files: `crate::world::` → `crate::` (199 sites;
|
||||
`super::super::` paths need no change); `pub(super)` → `pub(crate)` in
|
||||
`lib.rs` only (94 sites; submodules keep theirs);
|
||||
`crate::app_session::VerifiedPrincipalEvidence` → `crate::VerifiedPrincipalEvidence`
|
||||
(`mailbox.rs:10`, `controllers.rs:10714,10790,10988,11473`).
|
||||
- `vault.rs` and `sdk_inference.rs` move with the rest (Q1-2). Their
|
||||
fixture writes (`sdk_inference.rs:1900,1983`) resolve from
|
||||
`crates/ghostlight` at the same depth.
|
||||
- `build.rs`, `tests/build_provenance.rs`, `runtime.rs:375` (web/dist)
|
||||
stay in Dungeon; `CARGO_MANIFEST_DIR/../..` still resolves.
|
||||
- No test moves between crates. Every test under `world::` was already a
|
||||
library test and moves with its file (420); every Dungeon test stays in
|
||||
Dungeon (49). See the classification.
|
||||
- **Adds:**
|
||||
- `Cargo.toml` (workspace): member `crates/ghostlight`.
|
||||
- `crates/ghostlight/Cargo.toml`: package `ghostlight`; no `[features]`;
|
||||
dependencies `async-trait`, `chrono`, `codex-connector`, `cultcache-rs`,
|
||||
`ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }`,
|
||||
`rmp-serde`, `serde`, `serde_bytes = "0.11"`, `serde_json`, `sha2`,
|
||||
`thiserror`, `tokio`, `tracing`, `uuid`, `zeroize` (all `.workspace =
|
||||
true` except `serde_bytes`, which the workspace does not declare); dev
|
||||
`tempfile = "3"`. Every one is already in `Cargo.lock`; no new package.
|
||||
- `crates/ghostlight-dungeon/Cargo.toml`: `ghostlight = { path =
|
||||
"../ghostlight" }` in `[dependencies]`; nothing in dev-dependencies.
|
||||
- `Cargo.lock`: the new `[[package]] name = "ghostlight"` entry. Commit
|
||||
it; Idunn builds `--locked`.
|
||||
- `crates/ghostlight/src/lib.rs`, root: `pub struct VerifiedPrincipalEvidence
|
||||
{ account_subject_hash: String, valid_until: DateTime<Utc> }` with `pub fn
|
||||
new`, `pub fn account_subject_hash`, `pub fn valid_until`, and
|
||||
`#[cfg(test)] fn fixture` delegating to `new` (Q1-3). Doc comment: the
|
||||
consumer verifies; the library bounds by `valid_until` at
|
||||
`mailbox.rs:231`.
|
||||
- `crates/ghostlight/src/lib.rs`, the public boundary, one ungated list.
|
||||
`pub use clock::{FictionalMinutes, TickMinutes}`;
|
||||
`consumer::{CONSUMER_BODY_LIMIT, CONSUMER_CREDENTIALS_ENVIRONMENT,
|
||||
CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, ConsumerRegistry,
|
||||
admit_document, encode_receipt}`;
|
||||
`controllers::{CellRun, ConnectorBinding, ControllerError,
|
||||
ControllerModels, ControllerPendingReason, ControllerRunner,
|
||||
ControllerWork, ControllerWorkCustody, ControllerWorkLookup,
|
||||
ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite,
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort,
|
||||
InferencePurpose, InferenceRequest, NarrativeRun, OperationalRun,
|
||||
PreparedInference, SubmissionDisposition, ToolResultOracle,
|
||||
open_controller_work, open_inference}` (plus `TracingInferencePort`
|
||||
under Q1-7 A);
|
||||
`cover::{AgencyGraph, Cell, Cover, CoverBudget, TickIndex, derive_cover}`;
|
||||
`elaboration::{SeedOutcome, select_row}`;
|
||||
`mailbox::{ConsumerPort, ControllerPort, MailboxError, SeedPort, WorldMailbox}`;
|
||||
`patch::{JurisdictionKey, Statement}`;
|
||||
`sdk_inference::{DEFAULT_SDK_MODEL_PREFIX, SdkBinding}`;
|
||||
`vault::VaultEvidenceSource`. Root items made `pub`: `STATE_SCHEMA`,
|
||||
`state_schema_compatibility_tag`, `AffordanceId`, `CommandId`,
|
||||
`SubjectId` (the `opaque_uuid!` macro at `mod.rs:98-106` emits
|
||||
`pub(crate) struct`; give it a visibility parameter and emit `pub` for
|
||||
these three only — the `issue()` impls stay private and `#[cfg(test)]`),
|
||||
`PrincipalId`, `WorldPhase`, `SubjectKind`, `ControllerMode`,
|
||||
`CreateWorldIntent`, `CreateJurisdictionIntent`, `DecisionOpportunity`,
|
||||
`DecisionInvocation`, `PrincipalCommandIntent`, `CommandBody`,
|
||||
`WorldSnapshot`, `OperatorEvent`, `CommitReceipt`, `SubmitReceipt`,
|
||||
`KernelError`, `ScaleDeficitRow`, and the snapshot row types Dungeon
|
||||
reads through `WorldSnapshot` fields (`SubjectSnapshot`,
|
||||
`PlaceSnapshot`, `AffordanceSnapshot`, and whichever others the
|
||||
compiler names; probe 3 stopped at the `WorldSnapshot` field layer).
|
||||
`GroupedRun` (`controllers.rs`) is reached through `ControllerRunner`
|
||||
return values and needs `pub` with its five fields.
|
||||
- How the ports stay public without the fixture constructors becoming
|
||||
reachable: `InferencePort` (`controllers.rs:259`) and
|
||||
`ControllerWorkStore` (`:1541`) are traits whose method signatures name
|
||||
`InferenceRequest`, `PreparedInference`, `InferenceOutput`,
|
||||
`InferenceFault`, `ToolResultOracle`, `ControllerWork`,
|
||||
`ControllerWorkLookup`, `ControllerWorkWrite`, `ControllerWorkCustody`,
|
||||
`ControllerWorkStoreError`. Those types become `pub`; their fields stay
|
||||
private except where listed below. Nothing about them reaches an ID
|
||||
`issue()` or `ScopeDigest::fixture`, which are private free functions
|
||||
and `#[cfg(test)]` impls (`mod.rs:200-226,448`) with no `pub` path of any
|
||||
kind and no cargo feature — reachable only from inside the library's
|
||||
own test build. The `fixture_*` functions are deleted (Q1-7 A / Q1-8 A)
|
||||
or stay `#[cfg(test)]` (B), and the ten doc-tests pin both outcomes.
|
||||
- Fields that become `pub` (38, from probe 3): `CommitReceipt.{command_id,
|
||||
commit_digest, resulting_revision, resulting_state_digest}`,
|
||||
`Constituent.subject`, `Cover.{cells, oversubscribed, tick}`,
|
||||
`CoverBudget.{cells, constituent_cap, urgency_slots}`,
|
||||
`DecisionOpportunity.{controller_mode, world_id}`, `GroupedRun.{cell,
|
||||
needs, pending, resolution, submissions}`, `OperatorEvent.{revision,
|
||||
speech}`, `PreparedInference.purpose`, `ScaleDeficitRow.{deficit,
|
||||
jurisdiction, kind, target}`, `WorldSnapshot.{affordances,
|
||||
draft_approvals, now, opportunities, owner, phase, places,
|
||||
required_approvers, revision, scale_deficit, subjects, title, world_id}`.
|
||||
`FictionalMinutes.0` stays `pub(crate)`: under Q1-8 A the one Dungeon
|
||||
use (`runtime.rs:3579`) is replaced by `snapshot.now`. Fields of the row
|
||||
structs reached through `WorldSnapshot` follow in the next compiler
|
||||
round.
|
||||
- Methods that become `pub` (36, from probe 3, by definition site):
|
||||
`WorldMailbox::{open, create, submit_principal, submit_clock,
|
||||
agency_graph, operator_log, snapshot}` (`mailbox.rs:93,105,226,355,448,
|
||||
461,474`), `ConsumerPort::new` (`:552`), `SeedPort::new` (`:594`),
|
||||
`ElaborationPort::new` (`:660`), `ControllerPort::{new, snapshot,
|
||||
submit_controller}` (`:692,696,721`), `ControllerRunner::{open,
|
||||
elaborator, seeder, custody_probe, run_narrative, run_operational,
|
||||
run_cell}` (`controllers.rs:2691,2712,2728,2744,2748,2838,3057`),
|
||||
the receipt/pending accessors at `controllers.rs:2397,2413,2421,2453,
|
||||
2457,2461,2465,2469,2515,2519,2523,2543,2591,2595` (`persona_turn`,
|
||||
`re_lowering`, `into_parts`, `subject`, `bound_scope_digest`,
|
||||
`fresh_scope_digest`, `gap`, `mode`, `reason`, `persona_prose`),
|
||||
`sweep` (`elaboration.rs:607,1746`), `name` (`elaboration.rs:1511`),
|
||||
`Cover::{singletons, groups, validated}` (`cover.rs:163,170,198`),
|
||||
`ConsumerRegistry::{empty, from_secret_file}` (`consumer.rs:168,176`),
|
||||
`TickMinutes::{new, minutes}` (`clock.rs:45,51`), `CommandId::new`
|
||||
(`mod.rs:122`), `PrincipalId::new` (`mod.rs:239`), `Statement::new`
|
||||
(`patch.rs:281`). Under Q1-7 A add `PreparedInference::prepare`
|
||||
(renamed from `prepare_invocation`, `controllers.rs:388`),
|
||||
`InferenceOutput::new`, `InferenceFault::{retryable, recovery_required,
|
||||
integrity_violation}`, `InferenceRequest::provider_model` (`:120`), and
|
||||
`TracingInferencePort::new` (`:285`).
|
||||
- `crates/ghostlight/src/lib.rs` crate docs: the ten `compile_fail`
|
||||
doc-tests from probe 5, each pinned to its error code.
|
||||
- `crates/ghostlight-dungeon/src/runtime.rs` (tests): a ~25-line copy of
|
||||
`soul_no_credential_name_appears_in_the_ports_own_source` scanning
|
||||
`runtime.rs` only, because the library test cannot read another
|
||||
crate's source (see per-file `sdk_inference.rs:1824-1828`).
|
||||
- `crates/ghostlight-dungeon/src/app_session.rs` (tests): one test
|
||||
asserting `VerifiedPrincipalEvidence::new(` occurs exactly once in the
|
||||
crate's production source, at `account_for_cookie` (same shape as the
|
||||
`from_secret_file` count at `sdk_inference.rs:1860`). This is the
|
||||
single-minter rule the deleted private fields used to enforce (Q1-3).
|
||||
- **Cross-boundary test classification (Q1-4).** Every Dungeon test that
|
||||
reaches an item which is `#[cfg(test)]` in the library today. Method:
|
||||
probe 5's four compile errors plus a read of each test body.
|
||||
|
||||
| # | File:line | Test | Exercises | Reaches | Class |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `runtime.rs:3270` | `the_tick_driver_never_exceeds_its_controller_permit_pool` | `run_cover_tick` and `state.controller_permits`: two singleton cells, pool of one, no overlapping `infer` | `fixture_prepared_inference`, `fixture_inference_output` (via `CountingInferencePort`, `:3241`); `AlwaysFreshWorkStore` (`:3194`) uses only `pub` enum variants | Dungeon runtime |
|
||||
| 2 | `runtime.rs:3337` | `quarantine_raised_mid_tick_stops_the_sibling_cell_and_every_later_tick` | the driver's `controller_quarantined` flag across sibling cell and next tick | `fixture_prepared_inference`, `InferenceFault::fixture_integrity_violation` (`QuarantiningInferencePort`, `:3308`) | Dungeon runtime |
|
||||
| 3 | `runtime.rs:3500` | `a_second_mid_turn_change_reaches_the_drivers_interrupted_arm` | the driver's `Interrupted` arm reached through `run_cover_tick`, commits through `ControllerPort::submit_controller` (production) | `fixture_prepared_inference`, `fixture_inference_output`, `fixture_inference_events`, `InferenceFault::fixture_recovery_required`, `InferenceEvent::ToolCall` (pub variant), `PreparedInference.purpose` (`InterruptingCoverPort`, `:3432`) | Dungeon runtime |
|
||||
| 4 | `runtime.rs:3567` | `drive_one_tick_runs_every_cell_before_the_clock_and_all_share_one_tick` | `drive_one_tick` ordering: every cell before the clock, all cells carry the cover's tick | `fixture_controller_opportunities` (the only caller of the sealed `WorldId/SubjectId/ControllerId::issue()` and `ScopeDigest::fixture` from outside `world/`), `FictionalMinutes(u64)`, `AgencyGraph::default`, `Cell`, `TickIndex`, `CommitReceipt` literal (`controller_commit`, `:2492`) | Dungeon runtime |
|
||||
| 5 | `runtime.rs:3697` (ignored) | `live_smoke_seeds_then_ticks_a_world_against_the_connector` | the road test: seed from a real Vault, activate, tick against a real connector; `GHOSTLIGHT_SMOKE_TRACE` writes the whole membrane (`notes/local-live-smoke.md:214-246`) | `TracingInferencePort` (`fixture_with`, `:2581-2582`) | Dungeon runtime |
|
||||
|
||||
Counts: 5 tests reach test-only library items; 0 are kernel tests; 5 are
|
||||
Dungeon runtime tests. Two other Dungeon tests touch the boundary but
|
||||
need nothing test-only: `runtime.rs:2502`
|
||||
`no_proposal_projection_carries_the_canonical_world_commit` builds a
|
||||
`CommitReceipt` literal (four `pub` fields, in the boundary list) and
|
||||
`runtime.rs:4293` `soul_verified_evidence_outlives_the_session_that_minted_it`
|
||||
uses `VerifiedPrincipalEvidence::fixture` → `::new` (Q1-3). Every other
|
||||
Dungeon test (42) uses production ingress only.
|
||||
|
||||
Consequence: the ruling's "move the tests into the library" has no test
|
||||
to move. Tests 1–4 exercise `run_cover_tick`, `drive_one_tick`, the permit
|
||||
pool, and quarantine — Dungeon's tick driver — and test 5 is Dungeon's
|
||||
road test; moving any of them would move the driver. What they need
|
||||
divides into two kinds, and each is an operator question below rather
|
||||
than a mechanism this map invents:
|
||||
- tests 1, 2, 3, 5 need to construct **port values** (`PreparedInference`,
|
||||
`InferenceOutput`, `InferenceFault`) and a port decorator; none of
|
||||
these is a sealed item under invariant 1 (mutable state, ID allocator,
|
||||
reducer, journal, authenticated-caller constructor) — Q1-7;
|
||||
- test 4 needs a **`Cover`** to walk, and today gets it from fabricated
|
||||
opportunities minted with sealed IDs — Q1-8.
|
||||
|
||||
Expected post-move counts, under Q1-7 A and Q1-8 A: library 420 unit
|
||||
(the base `world::` list, prefix stripped) + 10 doc; Dungeon bin 49
|
||||
(the same 49 names; tests 1–4 rewritten in place, test 5 unchanged);
|
||||
`build_provenance` 1; persona-projection 13; ignored 1 + 1. Under Q1-7 B
|
||||
tests 1–3 and the `trace` half of test 5 are deleted (Dungeon 46, or 45
|
||||
if test 4 goes under Q1-8 B); the library count is unchanged either way.
|
||||
- **Per-file changes** (against `d83534c` = `02bbf62` source):
|
||||
- `Cargo.toml:3-5`: add member.
|
||||
- `crates/ghostlight-dungeon/Cargo.toml:27`: replace with the library
|
||||
path dep.
|
||||
- `crates/ghostlight-dungeon/src/main.rs:7`: delete.
|
||||
- `crates/ghostlight-dungeon/src/runtime.rs:3-22`: split the `use crate::{}`
|
||||
block; `world::{…}` becomes `use ghostlight::{…}` (probe edit); `:4`:
|
||||
import `VerifiedPrincipalEvidence` from `ghostlight`, not `app_session`.
|
||||
`:518,519,535,1084,1088,1092,1201,1325,1621,2492,2493,2581,2582,3081,
|
||||
3501,3568,3658,3666,3675,3677,3777,3782,4267`: `crate::world::` →
|
||||
`ghostlight::`. `:4313`: `::fixture(` → `::new(`. `:3081-3086`: drop
|
||||
the three `fixture_inference_*` names. `:3243,3310,3434`:
|
||||
`PreparedInference::prepare("ghostlight-controller-test",
|
||||
4_102_444_800_000, request)` (Q1-7 A; the two literals are the ones
|
||||
`fixture_prepared_inference` hard-codes at `controllers.rs:381`).
|
||||
`:3255,3442,3447`: `InferenceOutput::new(vec![InferenceEvent::Text(..)],
|
||||
"sha256:<receipt>")`; `:3457`: `InferenceOutput::new(events, ..)`;
|
||||
`:3318`: `InferenceFault::integrity_violation(..)`; `:3479`:
|
||||
`InferenceFault::recovery_required(..)`. `:3567-3644` (Q1-8 A): replace
|
||||
`fixture_controller_opportunities` + `FictionalMinutes(..)` with
|
||||
`fixture().await`, `active_two_cell_world`, `snapshot()`, and
|
||||
`agency_graph()`, then `derive_cover(snapshot.world_id, snapshot.now,
|
||||
CLOCK_TICK_MINUTES, &snapshot.opportunities, &graph, budget)`; the
|
||||
cell count assertion becomes 2 (a genesis world has two
|
||||
controller-bearing subjects — `:3089-3095` already says this is the
|
||||
most `world.create` can produce); the ordering and shared-tick
|
||||
assertions are unchanged. `:504,2673` doc comments name
|
||||
`world::consumer`; reword to the crate.
|
||||
- `crates/ghostlight-dungeon/src/eve.rs:3-6` and `:182,335,338,354,386,
|
||||
405,449,494`: same rewrite.
|
||||
- `crates/ghostlight-dungeon/src/mesh.rs:6-9`: same.
|
||||
- `crates/ghostlight-dungeon/src/idunn_health.rs:8`: same.
|
||||
- `crates/ghostlight-dungeon/src/app_session.rs:54-91`: delete; `:6`: add
|
||||
`use ghostlight::VerifiedPrincipalEvidence;`; `:232-235`: struct literal
|
||||
→ `VerifiedPrincipalEvidence::new(session.account_subject_hash.clone(),
|
||||
session.access_expires_at)`.
|
||||
- `crates/ghostlight/src/controllers.rs:221-237,372-382,409-432` (Q1-7 A):
|
||||
delete the fixtures; `:388` `prepare_invocation` → `pub fn
|
||||
PreparedInference::prepare` (keep the body; the two in-library callers
|
||||
at the connector and SDK ports follow); `:150` add `pub fn new(events,
|
||||
receipt_digest)`; `:192,206` `new` → `pub fn retryable`,
|
||||
`integrity_violation` → `pub`, add `pub fn recovery_required(detail)`
|
||||
(the `RecoveryRequired` disposition has a private `new` today; make it
|
||||
the named constructor); `:277-370` drop `#[cfg(test)]` from
|
||||
`TracingInferencePort` and make it and `new` `pub`.
|
||||
- `crates/ghostlight/src/sdk_inference.rs:1824-1828`: the scanned file
|
||||
list becomes `["sdk_inference.rs", "controllers.rs"]` (paths are now
|
||||
crate-root relative); `:1860` `sources[1]` still indexes
|
||||
`controllers.rs`. The `runtime.rs` half moves to Dungeon (Adds).
|
||||
- `deployment/idunn/recipe.toml:14-17`: `test-world-owner` becomes two
|
||||
steps — `test-world-library`: `["cargo","test","--locked","-p","ghostlight"]`
|
||||
(runs the 420 unit tests and the 10 doc-tests) and `test-dungeon`:
|
||||
the existing `-p ghostlight-dungeon --bin ghostlight-dungeon` line.
|
||||
`:51-53`: acceptance argv becomes `-p ghostlight --lib
|
||||
controllers::tests::real_codex_connector_cognition_modes_commit_speech`
|
||||
(the `world::` prefix is gone; `--exact` still matches).
|
||||
- `package.json:9` (`dungeon:run`): unchanged; `:7-8` use `--workspace`
|
||||
and pick the crate up.
|
||||
- `notes/local-live-smoke.md:64`: unchanged; the ignored smoke stays in
|
||||
`runtime.rs:3697`.
|
||||
- Docs that name the old path (owner in brackets): `README.md:165-166`
|
||||
[Hands] add the library line; `docs/public-architecture/index.md:46`
|
||||
[Hands]; `docs/architecture/ghostlight-session-zero.md:21-23` [Hands]
|
||||
rewrite "Source does not yet match" as the current rule;
|
||||
`docs/architecture/ghostlight-world-ontology.md:939-941` [Hands] the
|
||||
build-budget sentence now names `crates/ghostlight`;
|
||||
`notes/ghostlight-current-system-map.md:18,28,289` [Mind steward];
|
||||
`notes/fresh-workspace-handoff.md:284-286` [steward];
|
||||
`state/map.yaml:57,389-390` [steward].
|
||||
- **Authority map:**
|
||||
- Owner: the library crate `ghostlight` owns the world kernel, its
|
||||
ontology, reducer, journal, mailbox, controllers, elaboration, cover,
|
||||
clock, consumer admission, inference ports (connector, SDK sidecar,
|
||||
and the tracing decorator), and the markdown evidence source. Its
|
||||
public surface is the list under Adds and nothing else.
|
||||
- Inputs: `VerifiedPrincipalEvidence` values Dungeon constructs from
|
||||
`AppSessionOwner::account_for_cookie`; `CreateWorldIntent`,
|
||||
`PrincipalCommandIntent`, `DecisionInvocation`; consumer patch bytes via
|
||||
`admit_document`; store paths via `WorldMailbox::open` and
|
||||
`open_controller_work`; connector and SDK bindings via `open_inference`;
|
||||
a consumer-implemented `InferencePort`/`ControllerWorkStore` when the
|
||||
consumer supplies its own (Dungeon's tests do; production uses the
|
||||
library's).
|
||||
- Outputs: immutable `WorldSnapshot`, `OperatorEvent`, `AgencyGraph`,
|
||||
typed receipts (`SubmitReceipt`, `CommitReceipt`, `CreationReceipt`
|
||||
through `MailboxError`/`KernelError`), controller runs, cover
|
||||
partitions, encoded consumer receipts.
|
||||
- Derived state: Dungeon's Eve projection, mesh documents, Idunn health
|
||||
(which reads `STATE_SCHEMA` and `state_schema_compatibility_tag` and
|
||||
derives, never restates), HTTP responses.
|
||||
- Forbidden writers: any path in Dungeon naming `WorldKernel`,
|
||||
`WorldState`, `reduce`, `apply_effect`, `CommandEnvelope`,
|
||||
`AuthenticatedCaller`, the journal, an `issue()`, or
|
||||
`ScopeDigest::fixture` — enforced by visibility with no feature that
|
||||
can lift it, proven by the doc-tests under `--all-features`. Any second
|
||||
minter of `VerifiedPrincipalEvidence` — enforced by the Dungeon count
|
||||
test. Any fixture-shaped `DecisionOpportunity` — there is no
|
||||
constructor for one outside the reducer.
|
||||
- Shared paths: Dungeon production and Dungeon tests reach the kernel
|
||||
through the same `pub` items; a Dungeon test port constructs port
|
||||
values through the same constructors the library's own ports use
|
||||
(Q1-7 A). Library-internal tests keep `pub(crate)`/`super::` access as
|
||||
today. Consumers other than Dungeon: none yet; Epiphany consumes only
|
||||
`ghostlight-persona-projection`, which is unchanged (Q1-5).
|
||||
- Deletion line: `mod world;`, the nine unused re-exports, the
|
||||
`#[cfg(test)]` re-export block, the `fixture_*` functions (A) and
|
||||
Dungeon's private principal struct are gone before the first `pub` is
|
||||
written.
|
||||
- **Verification** (Windows workstation; proves Windows only):
|
||||
- builds: `cargo check -p ghostlight` (no new warnings; the 646
|
||||
dead-code warnings of an under-opened boundary must be 0),
|
||||
`cargo check -p ghostlight-dungeon --bin ghostlight-dungeon`,
|
||||
`cargo check -p ghostlight-dungeon --tests`,
|
||||
`cargo build --locked -p ghostlight-dungeon --bin ghostlight-dungeon`
|
||||
(proves `Cargo.lock`). Expected target-dir delta: one extra lib crate
|
||||
in debug; the scratch probe's check-only target was 2.3 GiB from empty,
|
||||
so expect well under 1 GiB incremental on the repo's 7.5 GiB.
|
||||
- tests: `cargo test -p ghostlight` = 420 unit + 10 doc (the 420 names
|
||||
equal the base `world::` list with the prefix stripped);
|
||||
`cargo test -p ghostlight-dungeon --bin ghostlight-dungeon` = 49
|
||||
(same names as base);
|
||||
`cargo test -p ghostlight-dungeon --test build_provenance` = 1;
|
||||
`cargo test -p ghostlight-persona-projection` = 13. Ignored counts
|
||||
unchanged (1 + 1). The reopen test from Cut 0 pins invariant 2
|
||||
(`world.cc` written at base reopens with equal revision and digests).
|
||||
The doc-tests pin invariant 1. The `VerifiedPrincipalEvidence::new`
|
||||
count pins the single minter. The split credential-name test pins
|
||||
"no credential name in the port's source" on both sides. Tests 1–4
|
||||
keep their names and assertions (test 4's cell count is 2).
|
||||
- negative:
|
||||
`cargo test -p ghostlight --all-features --doc` = 10 passed (same as
|
||||
without; the command is the standing check that no later feature
|
||||
reopens the seal);
|
||||
`rg -n '\[features\]' crates/ghostlight/Cargo.toml` = 0;
|
||||
`rg -n 'cfg\(any\(test' crates/ghostlight/src` = 0 (no feature-gated
|
||||
test path exists);
|
||||
`rg -n 'fixture_' crates/ghostlight-dungeon/src` = 0 outside the
|
||||
Heimdall `::fixture(` sites (`app_session.rs:560`, `runtime.rs:2552,
|
||||
2611,4126` — use `rg -n 'fixture_[a-z]' …` which they do not match);
|
||||
`rg -n 'crate::world::' crates` = 0;
|
||||
`rg -n '^mod world;' crates` = 0;
|
||||
`rg -n 'app_session|heimdall|eve::|idunn_health|axum|tower_http|jsonwebtoken|\bDungeon\b|Session Zero' crates/ghostlight/src` = 0
|
||||
(collisions checked: `Dungeons` in `vault.rs` is excluded by `\b`;
|
||||
do not add `player`);
|
||||
`rg -n 'pub\(super\)' crates/ghostlight/src/lib.rs` = 0;
|
||||
`rg -n 'VerifiedPrincipalEvidence::new\(' crates/ghostlight-dungeon/src`
|
||||
= 1 outside `#[cfg(test)]`;
|
||||
`cargo tree -p ghostlight` names no `axum`, `tower-http`,
|
||||
`jsonwebtoken`, `cultnet-rs`, `cultmesh-rs`, `reqwest`, `aes-gcm`,
|
||||
`hmac`, `rand`, `base64`, `libc`;
|
||||
`git diff -M --stat 02bbf62..HEAD -- crates` shows 13 renames.
|
||||
- operator: Idunn recipe run on Yggdrasil (Linux proof): the
|
||||
`test-world-library`, `test-dungeon`, `build-daemon` steps and the
|
||||
acceptance step resolve; `/srv/…/ghostlight-dungeon --state-root`
|
||||
reopens the live `world.cc`; health reports `consumer-v4`. One
|
||||
`GHOSTLIGHT_SMOKE_TRACE` road run after the cut shows the membrane
|
||||
trace still writes (test 5).
|
||||
- **Operator questions:**
|
||||
- Q1-7 Port value constructors for consumer-implemented ports. Tests 1,
|
||||
2, 3 and 5 implement `InferencePort` outside the library and must
|
||||
return `PreparedInference`, `InferenceOutput`, `InferenceFault`; today
|
||||
they do it through `#[cfg(test)]` `fixture_*` wrappers that exist only
|
||||
because those types have private constructors. A: make the
|
||||
constructors the port contract — `PreparedInference::prepare(caller_
|
||||
runtime_id, expires_at_unix_ms, request)` (rename of the existing
|
||||
`prepare_invocation`, "the one place a `PreparedInference` is built",
|
||||
which every in-library port already calls), `InferenceOutput::new(events,
|
||||
receipt_digest)`, `InferenceFault::{retryable, recovery_required,
|
||||
integrity_violation}`, `InferenceRequest::provider_model`; delete the
|
||||
five `fixture_*` items; make `TracingInferencePort` an ungated `pub`
|
||||
decorator (it wraps any port and constructs nothing sealed; "production
|
||||
never constructs it" becomes a Dungeon rule, which is where it was
|
||||
enforced anyway — only the ignored smoke names it). B: keep every
|
||||
fixture `#[cfg(test)]` in the library and delete tests 1–3 and the
|
||||
`trace` field of `LiveController` (`runtime.rs:2538,3726`) from
|
||||
Dungeon; the permit-pool, quarantine, and interrupted-arm invariants of
|
||||
the tick driver lose their proofs and the road test loses its membrane
|
||||
trace. **Recommended: A.** A public trait whose methods must return a
|
||||
type nobody outside the crate can construct is not a port; it is a
|
||||
trait only the library can implement. Invariant 1 seals the kernel,
|
||||
not the inference transport, and none of these constructors mints an
|
||||
ID, mutates state, reduces, or writes the journal. A is a net
|
||||
deletion (about 85 fixture lines out, about 25 constructor lines in,
|
||||
of which `prepare` is a rename).
|
||||
- Q1-8 The cover for `drive_one_tick_runs_every_cell_before_the_clock_and_all_share_one_tick`.
|
||||
A: derive it from a real world through production ingress — the
|
||||
module's own `fixture()`/`active_two_cell_world`, then `snapshot()` and
|
||||
`agency_graph()` into `derive_cover`; delete
|
||||
`fixture_controller_opportunities` and the `FictionalMinutes(u64)`
|
||||
construction; the test asserts two cells instead of three, and the
|
||||
ordering and shared-tick assertions are unchanged. B: delete the test.
|
||||
C: keep `fixture_controller_opportunities` `#[cfg(test)]` in the
|
||||
library and move the test there — not available, `drive_one_tick` is
|
||||
Dungeon. **Recommended: A.** It removes the last caller of the sealed
|
||||
constructors from outside `world/`, uses only what tests 1–3 already
|
||||
use, and the test's own doc (`:3558-3565`) is about ordering, not
|
||||
about three.
|
||||
|
||||
## Cut 2. Build and deploy references in gamecult-ops
|
||||
|
||||
- **Repo/branch:** gamecult-ops, current default branch; one commit after
|
||||
Cut 1 is pushed.
|
||||
- **First:** `git -C F:\Projects\gamecult-ops status` clean;
|
||||
`bash scripts/test-ghostlight-yggdrasil-wiring.sh` passes at base.
|
||||
- **Deletes first:** none (Q1-6: retirement belongs to the deployment
|
||||
gate).
|
||||
- **Keeps and moves:** none.
|
||||
- **Adds:** none.
|
||||
- **Per-file changes:**
|
||||
- `runbooks/ghostlight-dungeon-yggdrasil.md:249-251`: insert
|
||||
`cargo test --locked -p ghostlight` before the Dungeon test line;
|
||||
`:257` "one explicit Rust binary" stays true.
|
||||
- `scripts/deploy-ghostlight-yggdrasil.sh:964-970`: add the library test
|
||||
invocation in the same container run; `:1308`: acceptance becomes
|
||||
`-p ghostlight --lib controllers::tests::real_codex_connector_cognition_modes_commit_speech`.
|
||||
- `scripts/test-ghostlight-yggdrasil-wiring.sh:276-278`: add
|
||||
`require_text "${deploy}" 'cargo test --locked -p ghostlight'`.
|
||||
- `idunn/yggdrasil/bindings/ghostlight.toml.in`: unchanged; it names the
|
||||
recipe path, and the recipe carries the commands.
|
||||
- The scripts remain, per the Ghostlight handoff (`:389-391`), helpers
|
||||
not to invoke: they fail at the root/Idunn Git boundary and hold
|
||||
duplicate deployment authority. Editing them keeps their command list
|
||||
from lying about the crate graph until the deployment gate retires
|
||||
them; it is not an endorsement to run them.
|
||||
- **Authority map:**
|
||||
- Owner: `deployment/idunn/recipe.toml` in Ghostlight owns the build and
|
||||
test commands (binding `:11 recipe_path`). The runbook describes; the
|
||||
scripts are a second, dormant list pinned to the recipe's strings by
|
||||
the wiring test until the deployment gate removes them.
|
||||
- Inputs: the admitted ref and the recipe.
|
||||
- Outputs: the same one binary plus `web/`.
|
||||
- Derived state: runbook text, wiring-test assertions.
|
||||
- Forbidden writers: no second list of cargo commands that drifts from
|
||||
the recipe; the wiring test's `require_text` lines are that check.
|
||||
- Shared paths: n/a.
|
||||
- Deletion line: none in this cut.
|
||||
- **Verification:**
|
||||
- builds: none (shell and Markdown).
|
||||
- tests: `bash scripts/test-ghostlight-yggdrasil-wiring.sh` passes with
|
||||
the new `require_text`.
|
||||
- negative: `rg -n 'ghostlight-dungeon --bin ghostlight-dungeon' F:\Projects\gamecult-ops --glob '!docs/**'`
|
||||
lists only lines that also sit beside a `-p ghostlight` line.
|
||||
- operator: the next Idunn deploy of a post-Cut-1 commit runs the
|
||||
library step.
|
||||
- **Operator questions:** none; Q2-1 was Q1-6 and is ruled.
|
||||
|
||||
## Subtraction ledger (estimate)
|
||||
|
||||
| Cut | Removed | Added | Deps / formats / targets |
|
||||
|---|---|---|---|
|
||||
| 0 | — | — | scratch captures only |
|
||||
| 1 | `mod world;` (1), 9 unused re-exports (~3 lines), `cfg(test)` re-export block (12), Dungeon principal struct (38), Dungeon's direct persona-projection dep (1), `fixture_controller_opportunities` (26, Q1-8 A), inference fixtures (~60, Q1-7 A); ~140 lines | lib manifest (~28), workspace member (1), Dungeon dep (1), lock entry (~20), principal type in lib (~30), `pub use` boundary (~25), doc-tests (~30), runtime credential test (~25), minter count test (~15), port constructors (~25, Q1-7 A), recipe step (~6); ~205 lines. Visibility edits: 94 `pub(super)`→`pub(crate)`, ~150 `pub(crate)`→`pub` (items, fields, methods), 199 `crate::world::`→`crate::`, 32 `crate::world::`→`ghostlight::`; test 4 rewritten in place (~20 lines) | +1 lib crate, 0 features, 0 binaries, 0 new packages in `Cargo.lock`; 13 files renamed (~52k lines) |
|
||||
| 2 | — | ~5 lines | 0 |
|
||||
|
||||
Net for L0: about +65 source lines, one crate, no feature. The growth is
|
||||
boundary declaration (an implicit `pub(crate)` becomes an explicit `pub`
|
||||
list plus its compile-fail proofs); the liability it retires is the
|
||||
convention "Dungeon does not touch kernel internals", which becomes a
|
||||
compile error with no feature that can lift it, and five fixture wrappers
|
||||
that existed only to work around private port constructors.
|
||||
|
||||
Actual, reconciled 2026-09-16 against `6ddabca` (Ghostlight) and `647e57e`
|
||||
(gamecult-ops):
|
||||
|
||||
| Area | Estimate | Actual |
|
||||
|---|---|---|
|
||||
| Crate sources incl. in-module tests | about +65 net | +1202 / −992 (+210 net) |
|
||||
| Integration tests | not planned | +479 |
|
||||
| Manifests and lock | about +50 | +50 / −2 |
|
||||
| Ghostlight recipe | about +6 | +10 / −5 |
|
||||
| gamecult-ops | about +5 | +61 / −9 |
|
||||
|
||||
The miss is explained, not hidden. The single-minter scanner grew about 110
|
||||
lines chasing Soul's evasions and remains evadable (S4). The boundary needed
|
||||
roughly 90 more public items than this map enumerated, each forced by a
|
||||
compile error or a `private_interfaces` warning. The integration tests buy
|
||||
ruling Q1-9, which postdates the estimate. Features and binaries added:
|
||||
none, as estimated.
|
||||
|
||||
## Findings not assigned to a cut
|
||||
|
||||
- `deployment/idunn/recipe.toml:142-146,169-171` declare the world slot and
|
||||
provided capability as `ghostlight.world_state.foundation.v0` /
|
||||
`foundation-v0`, and `:150-154` the controller-work slot as
|
||||
`ghostlight.controller_work.v3`; source is `consumer.v4`
|
||||
(`mod.rs:84-85`, tag `consumer-v4`) and `controller_work.v15`
|
||||
(`controllers.rs:75-76`). Pre-existing drift; L0 changes no schema
|
||||
string, so it stays.
|
||||
- `tests/build_provenance.rs` is never run by the recipe or the scripts:
|
||||
`--bin ghostlight-dungeon` selects the bin's unit tests only. A
|
||||
`cargo test --locked -p ghostlight-dungeon --test build_provenance` step
|
||||
would be a recipe change with its own reason.
|
||||
- `notes/ghostlight-current-system-map.md:18-19` still names the state
|
||||
owner as `ghostlight.world_state.foundation.v0`; `:28` says "the private
|
||||
`world::WorldKernel`". Both are stale before this cut; the steward owns
|
||||
the rewrite.
|
||||
- `runtime.rs:2492` is a `CommitReceipt` literal in Dungeon tests; it is
|
||||
why `CommitReceipt`'s four fields are in the boundary at all. Under
|
||||
Q1-8 A the same literal serves `drive_one_tick`'s fake clock submitter.
|
||||
- `PreparedInference.invocation` (`controllers.rs:131`,
|
||||
`CodexTransportInvocation` from `codex-connector`) stays `pub(crate)`. A
|
||||
consumer port that talks to a real transport would need it; no such
|
||||
consumer exists in L0, and opening it is a port-contract decision for
|
||||
the pass that brings one.
|
||||
- `web/` references nothing in `crates/`; `sidecar/claude-sdk/test/*` is
|
||||
written by library tests under `GHOSTLIGHT_WRITE_SIDECAR_FIXTURES` and
|
||||
read otherwise — path depth unchanged, nothing to do.
|
||||
172
docs/architecture/ghostlight-library-extraction-postmortem.md
Normal file
172
docs/architecture/ghostlight-library-extraction-postmortem.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Ghostlight library extraction (L0) postmortem
|
||||
|
||||
Written 2026-09-16. Evidence: the cut map
|
||||
(`ghostlight-library-extraction-cut.md`), the target
|
||||
(`ghostlight-library-extraction.md`), commits in Ghostlight and gamecult-ops,
|
||||
and the Soul reports recorded in the map.
|
||||
|
||||
## Summary
|
||||
|
||||
The world kernel moved out of the `ghostlight-dungeon` binary into the
|
||||
`ghostlight` library crate, and Dungeon became a consumer across a public
|
||||
boundary. Thirteen files (about 52k lines) moved by rename with no content
|
||||
change. The boundary is sealed by admission: external code may build a
|
||||
syntactically valid ID, digest or opportunity, and the kernel rejects every
|
||||
one it did not issue, proven by rejection tests written as an external crate.
|
||||
gamecult-ops' build commands run the library tests they would otherwise have
|
||||
skipped.
|
||||
|
||||
State at writing: landed and pushed in both repos, Windows-verified. The Linux
|
||||
release is Idunn's recipe run on Yggdrasil and has not been exercised; no
|
||||
Windows result proves it. The operator owes nothing for L0.
|
||||
|
||||
## Scope and invariants
|
||||
|
||||
The target set seven invariants: sealed kernel at the crate boundary, no
|
||||
behavior or schema change, no Dungeon concept in the library, dependencies
|
||||
pointing one way, a working release path, unaffected external pins, and one
|
||||
new crate with no feature matrix. Excluded: every library capability of plan
|
||||
step 15 L1–L4, schema renames, the network path for outside consumers, and
|
||||
deployment cutover.
|
||||
|
||||
Invariant 1 changed meaning during the migration (ruling Q1-9). It began as
|
||||
"sealed items are unreachable from outside" and ended as "the kernel rejects
|
||||
anything it did not issue", because the first reading was not true of the
|
||||
code and could not be made true without a design change L0 excluded.
|
||||
|
||||
## Timeline
|
||||
|
||||
| Cut | Repo, commits | Soul passes | Notable finding |
|
||||
|---|---|---|---|
|
||||
| Map | Ghostlight `02bbf62`, `6ddabca` | — | Classifying the fixture tests found no kernel test to move; Q1-7 and Q1-8 followed |
|
||||
| 1 | Ghostlight `108b691..f859d2c` | 1 | F1: public types deriving `Deserialize` are constructible from outside; the compile_fail tests pin names only |
|
||||
| 2 | gamecult-ops `6aba281` | 1 | The specified assertion was vacuous (substring match); the acceptance path had an unlisted fourth site |
|
||||
| 2 fix | gamecult-ops `803f2c7` | 1 | Exact-line pins prove presence, not execution (`if false`, `true \|\|`, heredoc) |
|
||||
| Q1-9 | Ghostlight `7c1d1e9` | — | Operator ruled unforgeable admission |
|
||||
| 1 fix | Ghostlight `a8d3377..2865ea7` | 1 | F3's premise was wrong; forgeries differed from real digests in length |
|
||||
| S batch | Ghostlight `bde05de..af9c95a` | 1 | Case-insensitive and hex-only comparisons survived |
|
||||
| T batch | Ghostlight `b14507c`, `ab95d78` | 1 | None |
|
||||
|
||||
## Structural delta
|
||||
|
||||
| Area | Estimate | Actual |
|
||||
|---|---|---|
|
||||
| Crate sources, including in-module tests | about +65 net | +1202 / −992, +210 net |
|
||||
| Integration tests | not planned | +479 (`crates/ghostlight/tests/external_admission.rs`) |
|
||||
| Manifests and lock | about +50 | +50 / −2 |
|
||||
| Ghostlight recipe | about +6 | +10 / −5 |
|
||||
| gamecult-ops | about +5 | +61 / −9 |
|
||||
|
||||
The source overrun has two causes. The single-minter scanner in
|
||||
`crates/ghostlight-dungeon/src/app_session.rs` grew about 110 lines chasing
|
||||
Soul's evasions, and it is still evadable (S4). The public boundary needed
|
||||
roughly 90 more items than the map enumerated, each forced by a compile error
|
||||
or a `private_interfaces` warning. The integration tests were not in the
|
||||
estimate because Q1-9, which requires them, was ruled after it.
|
||||
|
||||
Deleted: `mod world;`, nine unused re-exports, the `cfg(test)` re-export
|
||||
block, five inference fixtures, `fixture_controller_opportunities`, and
|
||||
Dungeon's own principal evidence struct. Added: one library crate, a public
|
||||
`pub use` boundary, port constructors, ten compile_fail doc-tests, the
|
||||
external admission tests, and two Dungeon soul tests. Parked: nothing.
|
||||
Features added: none. Binaries added: none.
|
||||
|
||||
## What Soul caught
|
||||
|
||||
- **Untested rulings.** Ruling Q1-4 said sealed constructors must be
|
||||
unreachable under every configuration. The code satisfied the letter
|
||||
(no feature gate) and missed the intent: `Deserialize` is a public
|
||||
constructor. Ten green compile_fail tests could not see it, and adding an
|
||||
ID-minting `Default` impl left all ten green. It would have failed as a
|
||||
false guarantee in any later review that trusted the target doc.
|
||||
- **Tests pinning spelling, not behavior.** Every text scanner lost to a short
|
||||
evasion: the minter count to an alias, a subdirectory, a type alias and a
|
||||
function value; the gamecult-ops wiring test to a two-line deletion that
|
||||
reattached the pinned line to the wrong container; its fix to control flow
|
||||
around exact lines. Each would have let a real regression through with a
|
||||
green check.
|
||||
- **Forgery shape.** Rejection tests that used short fake digests passed
|
||||
against a kernel comparing only length, only a prefix, all but the last
|
||||
character, case-insensitively, or only the hex after the label. Each would
|
||||
have admitted a digest the kernel never issued.
|
||||
- **Spec errors.** The map's vacuous `require_text`, its unlisted acceptance
|
||||
site, its miscount of Dungeon tests, and Soul's own F3 premise (the two
|
||||
accessors it called uncalled are called) were all wrong, and all were
|
||||
caught before landing a fix built on them.
|
||||
- **Unmeasured claims.** "No new package in `Cargo.lock`", "zero `fixture_`
|
||||
hits", "a guard that never fired", and a 13-warning ceiling nobody could
|
||||
reproduce.
|
||||
|
||||
## Operator corrections
|
||||
|
||||
- **Session Zero is Dungeon authority.** The first design placed a player
|
||||
flow on the kernel. Missing context: no document named which features
|
||||
belonged to the library and which to a consumer.
|
||||
- **Focus was too narrow.** Focus as a set of subjects became a typed detail
|
||||
rule, a function from place to detail level. Missing context: Delvehold's
|
||||
constraints as a consumer; the design generalized from Dungeon alone.
|
||||
- **Extract first.** The library capabilities were designed before the crate
|
||||
they belong to existed.
|
||||
- **The titled elaborators** had been cut without an operator decision; the
|
||||
cut conflated a verifier with the lenses it verified.
|
||||
- **Proportion.** Asked how much Q1-10 mattered, the operator said not at
|
||||
all. It was a tripwire against the project's own code, and Self should have
|
||||
defaulted it instead of posing it.
|
||||
|
||||
## Incidents
|
||||
|
||||
- **A Hands agent returned its private to-do list as its report** while its
|
||||
own background cargo job held the locks. Cost: one resume round. Rule:
|
||||
briefs now say to wait for long jobs and never end a turn with a to-do list.
|
||||
- **A mutation run was invalidated by `git checkout`** reverting uncommitted
|
||||
fix code. Cost: two mutations rerun. Rule: run mutations against committed
|
||||
code, or restore with a diff rather than a checkout.
|
||||
- **Cached no-op builds reported zero warnings.** Cargo replays warnings only
|
||||
when it rebuilds. Rule: measure warnings from a forced rebuild, and compare
|
||||
distinct messages by `cmp`.
|
||||
- **A commit message described code replaced before commit** (`fac90e6`).
|
||||
Left in history; recorded in the map.
|
||||
- **A pre-existing `cargo fmt --check` failure** across the library, found
|
||||
in passing.
|
||||
|
||||
## What worked
|
||||
|
||||
- Classifying tests before moving them. Q1-4's "move the tests" had nothing
|
||||
to move, and the classification turned a vague ruling into two concrete
|
||||
ones.
|
||||
- Soul building an external scratch crate to break the seal. The serde route
|
||||
was invisible from inside the crate and to every doc-test.
|
||||
- Holding map edits until Soul finished, so every Soul pass read a clean tree.
|
||||
- Having Hands report spec discrepancies instead of redesigning: the vacuous
|
||||
assertion, the fourth acceptance site, the recovery_required collision.
|
||||
- Parallel Hands in separate repos only when the second did not depend on the
|
||||
first landing.
|
||||
|
||||
## What to change in the pipeline
|
||||
|
||||
- **Brief Soul to attack constructibility, not names, on any sealed boundary.**
|
||||
Evidence: F1 on Cut 1, found only because the root agent added the serde
|
||||
route to the brief by hand.
|
||||
- **Treat a text scanner as a tripwire with stated limits, never as proof of a
|
||||
semantic property.** Prefer a semantic tool (Clippy lints, the type system)
|
||||
or record the property as unenforced. Evidence: every scanner in L0 lost.
|
||||
- **Rejection tests must use forgeries shaped like real values.** Evidence:
|
||||
S1 and the two survivors after it.
|
||||
- **Default proportionate questions instead of asking.** A fork that guards
|
||||
only against the project's own code is a default with a recorded follow-up.
|
||||
Evidence: Q1-10.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
| Item | Owner | Where | Why it can wait |
|
||||
|---|---|---|---|
|
||||
| S4 minter scanner evadable; Clippy `disallowed-methods` recorded as the fix | Dungeon | `crates/ghostlight-dungeon/src/app_session.rs:523-668` | Guards only against the project's own code |
|
||||
| S5 credential-name test splits on one literal marker | Dungeon | `crates/ghostlight-dungeon/src/runtime.rs:3584` | Same class as S4, pre-existing |
|
||||
| S6 stable rustc does not enforce compile_fail error codes | Library | `crates/ghostlight/src/lib.rs:15-57` | Admission is proven by the external tests; codes need a snapshot tool |
|
||||
| S2 world_id check redundant, S3 decline path unreachable from outside | Library | `lib.rs:4193`, `lib.rs:1603` | Redundant by construction and crate-private respectively |
|
||||
| `ScopeDigest`, `WorkLane`, `ScopeComponents` public through public enums | Library | `KernelError`, `ControllerWorkCustody`, `NarrativeCheckpoint` | Closing them reshapes public enums, a design change |
|
||||
| `cargo fmt --check` fails across the library | Library | `crates/ghostlight` | Pre-existing; formatting only |
|
||||
| Recipe schema drift (`foundation.v0`, `controller_work.v3` against `consumer.v4`, `controller_work.v15`) | Deployment gate | `deployment/idunn/recipe.toml` | Changes no behavior in L0 |
|
||||
| `build_provenance` not run by the recipe | Deployment gate | `deployment/idunn/recipe.toml` | A recipe change with its own reason |
|
||||
| Redeploy of a sealed commit runs no tests; exact-line pins cannot see control flow; `set -e` unpinned; duplicate blocks pass; web container and Connector steps pinned by substring; 18-line option window | Deployment gate | `gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`, `test-ghostlight-yggdrasil-wiring.sh` | Legacy actuator under a do-not-invoke warning, retired at the gate |
|
||||
| Linux release unverified | Operator, via Idunn | Yggdrasil | Needs the deployment gate |
|
||||
84
docs/architecture/ghostlight-library-extraction.md
Normal file
84
docs/architecture/ghostlight-library-extraction.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Ghostlight library extraction (plan step 15, L0)
|
||||
|
||||
Status: closed 2026-09-16. Landed in Ghostlight `108b691..ab95d78` and
|
||||
gamecult-ops `6aba281..803f2c7`; the means and every Soul verdict are in
|
||||
`ghostlight-library-extraction-cut.md`, the scars in
|
||||
`ghostlight-library-extraction-postmortem.md`.
|
||||
|
||||
Reconciled with the Body. Invariant 1 holds as restated by Q1-9 and is
|
||||
proven by `crates/ghostlight/tests/external_admission.rs`; the compile_fail
|
||||
doc-tests prove "does not compile", not privacy, because stable rustc does
|
||||
not enforce their error codes. Invariants 2, 4, 6 and 7 hold as written.
|
||||
Invariant 3 holds for library source and tests: no Eve, Heimdall, AppSession
|
||||
or player vocabulary remains. Invariant 5 holds on Windows; the Linux release
|
||||
is unexercised until Idunn builds it. The open questions below were all
|
||||
ruled (Q1-1 through Q1-10 in the cut map) and are kept as history.
|
||||
|
||||
## Target
|
||||
|
||||
The Ghostlight library is a crate of its own. It owns the world kernel and
|
||||
everything consumer-neutral around it: the ontology and reducer, the journal,
|
||||
the mailbox, controllers and the Projector-Persona-Interpreter membrane,
|
||||
elaboration and seeding, the cover, the clock, inference ports, evidence
|
||||
sources, and consumer patch admission. Ghostlight Dungeon is a crate that
|
||||
consumes it: the daemon binary, HTTP routes, Eve projection, Heimdall and app
|
||||
sessions, CultMesh publication, and Idunn health.
|
||||
|
||||
After the cut, "Dungeon cannot reach kernel internals" is a compile error, not
|
||||
a `pub(crate)` convention.
|
||||
|
||||
## Invariants that must survive
|
||||
|
||||
1. **Sealed kernel at the crate boundary.** The library's public API exposes
|
||||
create/open, immutable snapshots, command submission, typed receipts, and
|
||||
the runner and port entry points Dungeon needs. It exposes no mutable
|
||||
`WorldState`, canonical ID allocator, reducer entry, journal writer, or
|
||||
authenticated-caller constructor. Negative proofs are compile-fail tests
|
||||
from outside the library.
|
||||
|
||||
Sealing means **unforgeable admission**, not unconstructible values
|
||||
(operator ruling Q1-9, 2026-09-16). Public ID types, `ScopeDigest` and
|
||||
`DecisionOpportunity` derive `Deserialize` because Dungeon's own Eve
|
||||
payloads carry them, so any caller can build a syntactically valid one.
|
||||
The kernel must reject every one it did not issue, and rejection tests
|
||||
written from outside the crate are the proof. A compile-fail test pins a
|
||||
path name and cannot see constructibility: adding a `Default` impl that
|
||||
mints an ID left all ten green.
|
||||
2. **No behavior change.** Every existing test passes unchanged in meaning.
|
||||
No schema string, command id namespace, digest preimage, or store layout
|
||||
changes: a `world.cc` and controller work store written before the cut open
|
||||
and replay after it.
|
||||
3. **No Dungeon concept in the library.** No Dungeon type, route, Eve or
|
||||
Heimdall type, HTTP framework, or principal format enters the library. The
|
||||
principal evidence the kernel needs crosses as a library-owned type that
|
||||
Dungeon constructs from its own sessions.
|
||||
4. **Dependencies follow ownership.** The library does not depend on axum,
|
||||
tower-http, jsonwebtoken, or the Heimdall and Eve surfaces. Dungeon depends
|
||||
on the library, never the reverse.
|
||||
5. **The release path keeps working.** The `ghostlight-dungeon` binary name,
|
||||
its `--state-root` contract, and the Idunn recipe's build and test
|
||||
invocations still resolve, or are updated in the same cut with their owners.
|
||||
6. **Existing pins are unaffected.** Epiphany's pin of
|
||||
`ghostlight-persona-projection` by revision still resolves.
|
||||
7. **Build fan-out stays bounded.** One new library crate, no new binary, no
|
||||
new feature matrix beyond what test support strictly needs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Every library capability of plan step 15 L1–L4: lenses, detail rules,
|
||||
evidence binding, Draft sessions. L0 moves code; it adds no behavior.
|
||||
- Renaming schemas, stores, or wire documents.
|
||||
- Changing how external consumers reach the library over the network. The
|
||||
consumer patch HTTP route stays in Dungeon for now.
|
||||
- Deployment cutover (the deployment gate).
|
||||
|
||||
## Open questions for Imagination to bring back
|
||||
|
||||
- The library crate's name.
|
||||
- Whether `vault.rs` (markdown evidence source) and `sdk_inference.rs` (Claude
|
||||
SDK port) belong in the library, as consumer-neutral adapters, or in Dungeon.
|
||||
- The seam for principal evidence (`app_session::VerifiedPrincipalEvidence`).
|
||||
- How test fixtures that span the boundary are shared without making sealed
|
||||
constructors public.
|
||||
- Whether `ghostlight-persona-projection` stays a separate crate or folds into
|
||||
the library.
|
||||
4186
docs/architecture/ghostlight-play-agent-cut.md
Normal file
4186
docs/architecture/ghostlight-play-agent-cut.md
Normal file
File diff suppressed because it is too large
Load diff
236
docs/architecture/ghostlight-play-agent-postmortem.md
Normal file
236
docs/architecture/ghostlight-play-agent-postmortem.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# Play agent postmortem
|
||||
|
||||
Written 2026-09-23 from the cut map, the commits, the Soul reports and an Eyes
|
||||
pass over all three. The cut map
|
||||
(`docs/architecture/ghostlight-play-agent-cut.md`) remains the detailed record:
|
||||
every cut's status, findings PA.f1 to PA.f195, and every ruling.
|
||||
|
||||
## Summary
|
||||
|
||||
Ghostlight Dungeon now runs on one operational agent. It reads the world through
|
||||
an omniscient table view, calls kernel tools directly as the Play authority,
|
||||
dispatches characters for their own narrative turns, asks the player questions,
|
||||
and closes each turn with narration. `world.speak` and the story card are gone;
|
||||
`world.play` and a single player card replace them.
|
||||
|
||||
Seventeen cuts and about ten fix batches landed over two days. 195 findings were
|
||||
recorded. The kernel gained a Play authority, ruled facts, minting, retirement,
|
||||
affordance grants and a callable Persona turn. A loopback-only local inference
|
||||
port landed so the whole thing can run against a small model on the operator's
|
||||
own machine.
|
||||
|
||||
State at writing: the loop is built and verified as far as tests and a falsifier
|
||||
reach. **Nobody has played it, and no model has ever run behind the local port.**
|
||||
The seed path has never executed past its configuration check. All evidence is
|
||||
Windows; the release evidence is Idunn's Linux gate on Yggdrasil.
|
||||
|
||||
Two capabilities the design promises do not exist: fiction-first risk resolution
|
||||
and gestalt agency. They are named in *Open follow-ups* and carried into the
|
||||
target doc as gaps rather than promises.
|
||||
|
||||
## Scope and invariants
|
||||
|
||||
The pivot: the previous machine was built for offline narrative generation and
|
||||
world simulation, which is the wrong set of compromises for play. The Persona
|
||||
sandwich stays, because actors should think narratively; everything else — turn
|
||||
dispatch, interpretation, prompting the player, negotiating the result — becomes
|
||||
one operational agent's job.
|
||||
|
||||
Eight invariants scoped the work. The kernel is the only writer and no
|
||||
transcript crosses turns; Personas never see structured state, and the agent
|
||||
never rewrites an existing character; an actor acts only through its own
|
||||
affordance; speech is verbatim, checked against that actor's own prose; there is
|
||||
one play authority; removal is retirement; one loop serves every backend; and
|
||||
the player sees a projection, the question, and the refusal of their own act,
|
||||
and nothing else.
|
||||
|
||||
Explicitly out of scope: the offline simulation lanes, which keep total
|
||||
interpretation; Njordr's evidence invariants, which the operator ruled do not
|
||||
bind the play agent ("if the agent can invent people, it can invent loot"); and
|
||||
the world compiler.
|
||||
|
||||
## Timeline
|
||||
|
||||
| Cut | What | Commits | Notable finding |
|
||||
|---|---|---|---|
|
||||
| 1 | Delete the autonomous drivers and owner controls | `d69e9d4` | −2,184 lines. A re-added tick loop passed 46 of 46 tests |
|
||||
| 2 | The local inference port | `182f803` + fixes | `HTTP_PROXY` sent the whole request, and a credential, to a proxy (PA.f16) |
|
||||
| 3 | Play authority, ruled facts, minting | `4c4973b` | The genesis lane never ran `require_ruler` (PA.f26) |
|
||||
| 4 | Retirement | `3725ba8` | A retired subject was still an audience and a raw speaker (PA.f33) |
|
||||
| 5 | Grant and revoke affordances | `732b1d3` | Two loosenings survived every test (PA.f30, PA.f35) |
|
||||
| 6 | The Persona lane and the recency marker | `c4334cc` | The spec's own stated rationale was impossible; Hands corrected the spec |
|
||||
| 7 | The table vocabulary and inference seams | `7d40393` | `actor_tools` emitted names `decode_actor_call` refused (PA.f55) |
|
||||
| 8a | The play table and the turn loop | `6b69270` | `communicate` let the agent make any actor "tell" an invented fact (PA.f61) |
|
||||
| 8a-fix2 | | `74b628d` | Every mutation reported killed; eight rules unguarded |
|
||||
| 8a-fix3 | | `754c7fe` | The id spelling had four copies; changing the printer passed 735 tests and broke every dispatch (PA.f113) |
|
||||
| 8a-fix4 | | seven commits | Not ready for 8b: a crash window stranded a dispatched Persona (PA.f125) |
|
||||
| 8b | Wiring, routing, the Eve schema | `1b20a0f`, `a99fdfe` | `ask_player` wedged the world: no client could build an answer (PA.f134) |
|
||||
| 9 | The play surface; `world.speak` deleted | `322bdf6`, `f1732c0` | The real client could not submit anything at all |
|
||||
| 10 | The client round trip | `d9f0159`, `43b6af6` | `world.create` had no field for `brief`: no client could ever create a world |
|
||||
| 11 | The answer path and the gate | `4155823` | Two of three bridge tests failed instead of skipping without node |
|
||||
| 12–13 | The store schema; the answer token | `7052644`, `2f2a8b6` | An answer composed for one question resolved another (PA.f170) |
|
||||
| 14 | Preflight fixes | `58324f8`, `0808c6a` | The seed refusal handed the player the inference endpoint's URL (PA.f187) |
|
||||
| 15 | The closing fixes | `f295a3a` … `1dea8f5` | An unwritten invariant, stated and broken in one commit (PA.f193) |
|
||||
| 16–17 | Closing the bar | `63964b0` … `07aae19` | The runbook's weekly check printed `ok` without running (PA.f194-B) |
|
||||
|
||||
## Structural delta
|
||||
|
||||
New: `local_inference.rs`, `table.rs` (the consumer-neutral vocabulary),
|
||||
`play.rs` (the turn loop), `tools/eve_client_bridge.mjs` and its committed
|
||||
fixture. Two dependencies: `reqwest` for the local port, `unicode-segmentation`
|
||||
for UAX #29 word boundaries.
|
||||
|
||||
Deleted: Cut 1's tick driver, Active elaborator and owner "let X act" path
|
||||
(−2,184 lines, and ten tests whose only subject was deleted code); Cut 9's
|
||||
`SpeakPayload`, `world.speak` arm, story card, speak control and
|
||||
`current_operator_view`.
|
||||
|
||||
Tests: the library went 466 → 622 and has been flat since the play loop's third
|
||||
fix batch — all later churn is Dungeon-side. The Dungeon binary went 56 → 46
|
||||
(Cut 1's deletion) → 182, plus four client-bridge tests that are `#[ignore]`d
|
||||
behind an opt-in. `ghostlight-persona-projection` went 13 → 35.
|
||||
|
||||
The subtraction budget was met once, at Cut 1, and not thereafter. Every fix
|
||||
batch was net additive, mostly in tests. Soul audited the largest claim (Cut 13's
|
||||
"~150 lines") and found 29 lines of production code; the rest was comments. That
|
||||
is defensible, but the ledger stopped being a pressure after Cut 1 and nobody
|
||||
noticed until this postmortem.
|
||||
|
||||
## What Soul caught
|
||||
|
||||
Of roughly 195 findings, about 150 were introduced by this pass's own earlier
|
||||
steps rather than inherited. That is the honest shape of a foundation change:
|
||||
each fix is a fresh chance to be wrong, which is why the falsifier never grades
|
||||
its own work.
|
||||
|
||||
**Split authority (~14).** Four copies of one id spelling, because the library's
|
||||
printer was private to its module (PA.f113). Two computations of the retired set
|
||||
and of the exercise revision (PA.f82). Two owners for round entry (PA.f127). The
|
||||
resume path's collision check duplicated verbatim (PA.f118).
|
||||
|
||||
**Tests that pinned spelling or passed trivially (~13).** A dependency check
|
||||
asserting `contains("0")`, always true. A test that failed one run in four and
|
||||
checked the wrong subject when it passed. Two refusal tests whose expected kind
|
||||
was a substring of their own fixture's handle.
|
||||
|
||||
**Mutations aimed at the wrong layer (~4).** Deleting an in-memory record
|
||||
instead of the on-disk write it stood for; testing a helper instead of its call
|
||||
site; every test using a fresh key, so deriving the turn id from the key
|
||||
survived.
|
||||
|
||||
**Inputs built from a copy of production logic (2).** The dispatch tests spelled
|
||||
their subject id with a fourth copy of the printer, so changing what the world
|
||||
view actually printed left 735 tests green while every dispatch an agent could
|
||||
make was refused.
|
||||
|
||||
**Forged input accepted (~6).** A forged opportunity, a forged revision, a
|
||||
deserialized colliding subject id, and a ruled fact riding in through world
|
||||
creation.
|
||||
|
||||
**Player-visible leaks (~10).** The question id rendered as a visible, editable
|
||||
box. A connection-refused error handing the player the inference endpoint's host
|
||||
and port. A subject census on a card any authenticated account could see.
|
||||
|
||||
**Lifecycle and crash windows (~11).** A fresh request key discarding an open
|
||||
turn; an empty answer wedging it forever; a spent round budget leaving it
|
||||
running with nothing said; a crash between persisting a Persona's prose and
|
||||
recording the call that produced it, which stranded that character for the rest
|
||||
of the turn.
|
||||
|
||||
**Things the real client could never do (~5).** The binding shape did not match
|
||||
what the client sends, so every command submitted an empty payload — including
|
||||
`world.create`, which also had no field for a value its own contract required.
|
||||
Nobody had ever driven the real client end to end.
|
||||
|
||||
## Operator corrections
|
||||
|
||||
Eight questions were ruled by the operator directly, and one correction changed
|
||||
a design mid-pass. Self had suggested updating an existing character's attitude
|
||||
through `set_persona_material`; that collapses the no-puppets rule, and the
|
||||
ruling was reversed so the agent authors new characters and never rewrites an
|
||||
existing one. The operator's review — *"I saw those calls in your traces, I've
|
||||
been watching. No objections"* — arrived after the reversal rather than before
|
||||
it.
|
||||
|
||||
The operator also cut a scope error at the root. Self proposed binding the play
|
||||
agent to the offline lanes' evidence invariants; the ruling was *"if the agent
|
||||
can invent people, it can invent loot"*, which is what made ruled facts and
|
||||
minting coherent rather than a hole in the model.
|
||||
|
||||
What made the wrong answers look right in both cases: the agent was reasoning
|
||||
from the library's offline discipline, where those rules are correct, without
|
||||
noticing that the play path has a different author and a different purpose.
|
||||
|
||||
## Incidents
|
||||
|
||||
- **The workstation rebooted mid-pipeline** under concurrent cargo builds, mine
|
||||
among several sessions'. Rule: one cargo process at a time on the workstation,
|
||||
in every brief, and verification moves to Yggdrasil under Idunn.
|
||||
- **A Hands agent amended and force-pushed** over its own first push of Cut 3.
|
||||
Nothing was lost, because it had rebased first. Rule: never amend, never
|
||||
force-push; it is now in every brief and in the skill.
|
||||
- **A Soul pass died on an API 529.** Its worktree was cleaned and the pass
|
||||
rerun.
|
||||
- **A `git pull --rebase` was refused** while Hands had unstaged work in the
|
||||
shared tree. Rule: never pull in a tree where Hands is working. (Observed in
|
||||
session; not recorded in the cut map.)
|
||||
- **PowerShell map edits failed** on an overload error and on mismatched
|
||||
anchors. Rule: Python scripts that assert each anchor occurs exactly once and
|
||||
preserve CRLF. (Observed in session.)
|
||||
- **A stray `node_modules`** left by an agent's own debugging made a test count
|
||||
report success for tests that could not run on a clean checkout (PA.f162).
|
||||
|
||||
## What worked
|
||||
|
||||
- **Quoting the cut inline in the brief** instead of pointing at a 3,900-line
|
||||
map. The one run that read the whole map reached ~400k tokens and produced the
|
||||
pass's sloppiest claims.
|
||||
- **Hands stopping on a real fork.** Cut 9's counter had no honest derivation;
|
||||
Hands traced why, stopped, and reported. The ruling took one message and the
|
||||
cut resumed correctly.
|
||||
- **Soul driving the real client.** Every blocker in the last third of the pass
|
||||
came from running the vendored browser lowering over the served surface rather
|
||||
than from reading it.
|
||||
- **Recording a finding as closed-not-fixed when the mutation proved the code
|
||||
unreachable.** Hands built a guard, mutated it, found an existing guard already
|
||||
refused, reverted its own work and kept the test. The mutation decided, not the
|
||||
diff.
|
||||
|
||||
## What to change in the pipeline
|
||||
|
||||
Three lessons are already in the Eureka brief and changelog:
|
||||
|
||||
1. **Mutate the production call site, at the layer the rule protects** — a later
|
||||
write can hide a deleted one (`fd01545`).
|
||||
2. **A test's inputs come from the production path**, never from a helper that
|
||||
re-spells what production prints (`2e0ea97`).
|
||||
3. **Name the inference, not the line** (`181e819`). One defect shape ran through
|
||||
five instances: a committed file exists, therefore the client works;
|
||||
`git` exited zero, therefore this is a checkout; the format check failed,
|
||||
therefore the row is unreadable; the schema is unknown, therefore the row
|
||||
cannot be read; cargo printed `ok`, therefore the fixture is live. Each local
|
||||
fact true, each inference false, each fix scoped to the reported instance. And
|
||||
after changing how a test reports, run the owning runbook's commands verbatim
|
||||
and read what they print: that surface is the one a test suite structurally
|
||||
cannot check.
|
||||
|
||||
Two more this pass earns:
|
||||
|
||||
4. **A subtraction ledger that stops being updated stops being pressure.** It
|
||||
held at Cut 1 and then went unexamined for sixteen cuts.
|
||||
5. **Soul's closing pass should be briefed for a walk-through, not only a
|
||||
verdict.** Asking what happens when a person actually sits down and plays
|
||||
produced the most useful output of the pipeline, including two defects no
|
||||
invariant covered.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
| Item | Where | Why it can wait |
|
||||
|---|---|---|
|
||||
| Fiction-first risk resolution | kernel + `play.rs` | The kernel resolves a risky act through an affordance's weighted outcome bands, drawn deterministically. There is no assessment step, no modifiers, and the agent cannot author affordances — so an act no granted verb expresses is authored directly as a ruled consequence with no draw at all. Those two paths are indistinguishable to the player. This is the next pipeline. |
|
||||
| Gestalt and institution agency | kernel | Institutions exist as structure — offices, grants, jurisdiction — and constrain who may act. Nothing makes them act. Advancing time rolls routines and accumulates pressure; nobody deliberates off-screen. |
|
||||
| The seed leg | `runtime.rs`, vault | Never executed past its configuration check. `GHOSTLIGHT_SEED_VAULT_ROOT` appears in no runbook. Attempt it alone, first. |
|
||||
| PA.f194-A's fourth exit | `play.rs` | Closed in Cut 17, listed because its rule now lives on `retire_unreadable_row` as a precondition and every future caller must satisfy it. |
|
||||
| PA.f195-B, PA.f195-C | `play.rs`, `runtime.rs` | A dead tuple element, and a skip notice cargo swallows without `--nocapture`. Trivial. |
|
||||
| `state/map.yaml` is not parseable YAML | `state/` | Latent: nothing reads it programmatically. Fixing it is a deliberate sweep, not a drive-by. |
|
||||
| A read path for a retired row | `play.rs` | The sidecar is JSON with the payload in base64, so an operator can read it. Nothing imports one back. |
|
||||
332
docs/architecture/ghostlight-play-agent.md
Normal file
332
docs/architecture/ghostlight-play-agent.md
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
# Ghostlight Dungeon play agent (plan step 16)
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
Target, adopted 2026-09-22. Not implemented. The means will be in
|
||||
`ghostlight-play-agent-cut.md`. This target is the playtest gate. It replaces
|
||||
library passes L2–L4 and Dungeon passes D1–D3 on the gate's path; those are
|
||||
deferred by ruling, not dropped (see "Deferred").
|
||||
|
||||
## Why
|
||||
|
||||
Ghostlight's controller machinery was built for offline narrative generation
|
||||
and world simulation. It includes the cover partition, grouped cells,
|
||||
grant-filtered action catalogs per subject, a separate Interpreter pass, and
|
||||
the elaborator swarm. Those are the wrong compromises for interactive
|
||||
role-play: today a reply takes minutes, and a player's whole vocabulary is
|
||||
`world.speak`.
|
||||
|
||||
The operator, 2026-09-22:
|
||||
|
||||
> the way Ghostlight previously ran is great for offline narrative generation
|
||||
> and world simulation, but it's the wrong set of compromises for an efficient
|
||||
> role-playing experience. The Persona stuff is good, we want actors to think
|
||||
> narratively. All the rest of it can be operated by a single operational
|
||||
> agent, from dispatching and interpreting Persona turns to prompting the
|
||||
> player and negotiating the result.
|
||||
|
||||
What makes this worth building is where consistency lives. In AI Dungeon it
|
||||
lives in the context window, so it decays as the story grows. In Ghostlight it
|
||||
lives in the kernel: typed state, one reducer, affordance preconditions,
|
||||
outcome bands, and a digest-chained journal. None of that spends a token. A
|
||||
small local model therefore does only what it is good at, which is judgment
|
||||
and prose over a bounded context, while the kernel keeps the books.
|
||||
|
||||
## Target
|
||||
|
||||
A player on one local machine plays a world seeded from a Vault, turn by turn.
|
||||
The reference machine is Raven's, running Bonsai 2, a Qwen 3.8 quant tuned for
|
||||
agentic use, with several concurrent slots. One operational agent (the **play
|
||||
agent**) runs the table: it reads the world, interprets what the player and
|
||||
the Personas do, commits the consequences through the kernel, dispatches
|
||||
Personas, and asks the player questions when an outcome needs negotiating.
|
||||
|
||||
Personas keep thinking narratively. For each Persona, the existing Projector
|
||||
renders that subject's own slice of the world into lived prose. The Persona
|
||||
reads only that prose and writes prose back. Neither the Projector nor the
|
||||
Persona changes.
|
||||
|
||||
### Ownership
|
||||
|
||||
- **Dungeon** owns the play agent: its loop, its prompt, its tool catalog, the
|
||||
turn lifecycle, and the Eve surface. None of it enters the library
|
||||
vocabulary.
|
||||
- **The library** gains only consumer-neutral pieces:
|
||||
- one author capability for a play authority;
|
||||
- retirement, and granting and revoking affordances;
|
||||
- a callable Persona turn (Projector then Persona) that does not require
|
||||
the controller runner;
|
||||
- the table vocabulary (`table.rs`: the world view, named tool subsets and
|
||||
their decoders, and refusal text), plus the public inference seams a
|
||||
consumer needs to build and read a request.
|
||||
|
||||
A consumer other than Dungeon could run its own table on them. (Cut map
|
||||
PA-Q10 and PA-Q12, ruled 2026-09-22.)
|
||||
- The library keeps everything the play path stops using: cover, grouped
|
||||
cells, action catalogs, the controller runner, the Interpreter, the elaborator
|
||||
sweep, and lenses. Offline consumers and simulation still own them.
|
||||
|
||||
### One turn
|
||||
|
||||
1. The player types. Their text is prose from an actor, exactly as a Persona's
|
||||
is.
|
||||
2. The play agent reads a projection of world state rebuilt for this turn. It
|
||||
interprets the player's prose. Speech commits verbatim through the player
|
||||
subject's speaking affordance, and acts commit by exercising the player
|
||||
subject's affordances. The agent may instead **ask the player** something
|
||||
("what are you offering him?"). A question commits nothing and holds the
|
||||
turn open.
|
||||
3. The agent dispatches the Personas who react. They run concurrently, each
|
||||
through Projector then Persona, reading what they perceived from world
|
||||
state.
|
||||
4. The agent interprets each Persona's prose under the same rules as the
|
||||
player's, and commits its own rulings for anything no actor's affordance
|
||||
covers.
|
||||
5. The player sees their projection. The Projector renders the player
|
||||
subject's own slice, including what they perceived this turn, into prose,
|
||||
exactly as it does for a Persona. Beside that prose the surface shows any
|
||||
question the agent is asking.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **The kernel is the only writer, and the agent carries no world.** Every
|
||||
consequence is a library command. The agent's context each turn is built
|
||||
from world state and the current turn; no transcript is carried across
|
||||
turns. Test: restart the daemon between two turns, and the second turn's
|
||||
agent input is byte-identical to the input it would have received without
|
||||
the restart.
|
||||
2. **Personas never see structured state or the agent's context.** A Persona's
|
||||
input is Projector prose. The Projector's input is that subject's own typed
|
||||
slice (`controllers.rs` `projector_context`). The agent never writes a
|
||||
Persona's input, so a secret in the agent's context cannot reach a Persona
|
||||
except through world state that subject holds. The one input every
|
||||
subject shares is the owner's world brief. It is world-public framing,
|
||||
and the owner keeps secrets out of it (cut map PA.f45). The agent never
|
||||
rewrites an existing character's persona material; it authors material
|
||||
only for characters it declares. A character's feelings follow from what
|
||||
it perceives (cut map PA.f61).
|
||||
3. **An actor's act goes through that actor's affordance.** An act attributed
|
||||
to a subject, whether a Persona or the player, commits only as that
|
||||
subject's `ExerciseDecision`, with preconditions checked and the outcome
|
||||
band drawn by the kernel. The agent's raw ops are its own rulings: they are
|
||||
journaled with the play authority as author and never attributed to an
|
||||
actor. (Operator ruling, decision 1.)
|
||||
4. **Speech is verbatim.** Speech committed for an actor is a literal span of
|
||||
that actor's prose, checked in code, not by prompt. This holds for the
|
||||
player's typed text and for a Persona's output. It keeps the agent from
|
||||
paraphrasing every actor into its own voice.
|
||||
5. **One play authority, and only one thing holds it.** The library defines one
|
||||
consumer-neutral author capability. Its holder may:
|
||||
- declare in Active without answering elaboration demand;
|
||||
- reach the whole world, unconfined to a ground;
|
||||
- advance time;
|
||||
- state facts that stand as `Ruled` by it;
|
||||
- mint quantity without an evidence receipt.
|
||||
|
||||
Only the play authority gains the last two. Elaborators, consumers and the
|
||||
seed keep the evidence gate, so a consumer feeding Njordr keeps quantity
|
||||
provenance. The operator: "if the agent can invent people, it can invent
|
||||
loot." (Cut map PA-Q15.)
|
||||
|
||||
The elaborator gate is unchanged: elaborator sessions still answer a
|
||||
boundary or deficit. The agent is Dungeon's detail demand: when the player
|
||||
walks into the tavern, the agent fills it. (Operator ruling, decision 1.)
|
||||
6. **Removal is retirement.** A retired subject stays in history, holds no
|
||||
opportunity, cannot be the actor of a decision, and is never dispatched.
|
||||
Replay reproduces retirement. A body that matters afterwards is declared as
|
||||
a resource.
|
||||
7. **One loop, many backends.** Rust drives every tool round, as it already
|
||||
does. A local OpenAI-compatible backend returns tool calls inert, as the
|
||||
connector lane does, and adds no second loop. Routing stays per model
|
||||
name, so each lane (agent, Projector, Persona, seed) is pointed at a backend
|
||||
independently.
|
||||
8. **The player's view is a projection.** The Projector renders the player's
|
||||
narration from committed state, as it does for every Persona (operator
|
||||
ruling, open question 11). Nothing the player is shown is world truth
|
||||
unless it was committed. The surface also shows the kernel's refusal of
|
||||
the player's own act, rendered from the refusal and the affordance's
|
||||
preconditions. That is the world's verdict, derived from state, not a
|
||||
claim of world truth (cut map PA-Q13). The agent's questions are the one
|
||||
thing shown that is not derived from state.
|
||||
|
||||
## Playtest gate
|
||||
|
||||
Passes when a human, on a local daemon with every play lane on a local model,
|
||||
does all of the following:
|
||||
|
||||
1. creates a world and seeds it from a Vault;
|
||||
2. activates it;
|
||||
3. plays at least these:
|
||||
- speaks to a Persona and gets a reply in the Persona's own words;
|
||||
- does something physical that a precondition refuses, and sees why;
|
||||
- moves to another place;
|
||||
- is asked a question by the agent and answers it;
|
||||
- kills or removes a subject, who then never acts again;
|
||||
4. restarts the daemon mid-session and continues with the world intact.
|
||||
|
||||
It proves one player. Hosted play, multiple players, Session Zero's Vault
|
||||
choice and sliders, and detail rules are not in this gate. Seconds per turn
|
||||
are **measured and recorded** on Raven's machine. The gate does not set that
|
||||
number in advance, because nobody has measured Bonsai 2 on this workload yet.
|
||||
The current baseline is minutes.
|
||||
|
||||
## Cut line
|
||||
|
||||
Dungeon's play path stops using:
|
||||
|
||||
- the owner-only "Let X act" controls and `world.controller.act`;
|
||||
- automatic tick dispatch through cover cells on the play path;
|
||||
- the Interpreter pass;
|
||||
- the per-subject action catalogs;
|
||||
- elaboration while Active.
|
||||
|
||||
Whether these are deleted from Dungeon or merely go unused is for the cut map
|
||||
to establish, one surface at a time. Deletion is the default where nothing
|
||||
else consumes the surface.
|
||||
|
||||
The library keeps them.
|
||||
|
||||
The Claude SDK sidecar stays as the cloud fallback until the local lane has
|
||||
carried the playtest. It is the only live cloud provider (there is no Codex
|
||||
subscription and no API budget), so "cloud fallback" means the sidecar. The
|
||||
decision to delete it comes after the gate. This corrects the 2026-09-22 chat
|
||||
default, which named the connector lane as the fallback.
|
||||
|
||||
## Deferred, by ruling (2026-09-22, decision 3)
|
||||
|
||||
Deferred until a consumer that still runs the offline machinery needs them:
|
||||
|
||||
- L1.f16, configurable lens sets (operator: yes);
|
||||
- L1.f1, quarantine-class errors never quarantine;
|
||||
- L1.f13, seed lane orphans rows;
|
||||
- L1.f14, controller-work rows never retired;
|
||||
- L2, per-world evidence binding;
|
||||
- L3, detail rules;
|
||||
- L4;
|
||||
- Session Zero D1–D3.
|
||||
|
||||
After the gate, decide whether anything still consumes the controller runner,
|
||||
cover and lenses. If nothing does, park them at a tag.
|
||||
|
||||
Exception: if the cut map finds that L1.f13 bites seeding on the playtest
|
||||
path, it is in scope, because seeding is on the path.
|
||||
|
||||
## Open questions for the cut map
|
||||
|
||||
Imagination establishes each of these by probing the Body, and returns forks
|
||||
with a recommendation.
|
||||
|
||||
1. **Where the local backend lives.** The shared seam today is the
|
||||
in-process `InferencePort` trait, not a CultNet connector API. The SDK
|
||||
sidecar is a private MessagePack child-process pipe that only borrows
|
||||
`codex_connector` input types (checked 2026-09-22). No local or
|
||||
OpenAI-compatible transport exists, so the gate has none to use yet.
|
||||
|
||||
The options are a third `InferencePort` in `ghostlight` that follows the
|
||||
connector posture (~150 lines), or a connector behind a CultNet contract
|
||||
that the operator wants and that does not yet exist. The map records "a
|
||||
generic OpenAI-compatible connector is required before third parties use
|
||||
the deployment", so this pass may be that connector. The handoff says
|
||||
nothing goes into CodexConnector itself.
|
||||
2. **What a Persona perceived.** Does the Projector's context already carry
|
||||
what the subject perceived since its last turn (speech heard, acts seen,
|
||||
`Seen { by }` knowledge)? If not, what is the smallest addition?
|
||||
3. **A callable Persona turn.** Projector then Persona currently runs inside
|
||||
the narrative lane of `ControllerRunner`, with checkpointing. What is the
|
||||
smallest library surface the play agent can call per subject, and does it
|
||||
keep resumability?
|
||||
4. **Exercising on an actor's behalf.** `ExerciseDecision` is admitted for the
|
||||
subject's controller principal. How does the play authority exercise an
|
||||
affordance for an actor it dispatched without weakening sealed admission,
|
||||
given that opportunities and digests are kernel-issued?
|
||||
5. **The play authority's shape.** Name the capability, where it is granted,
|
||||
and how the clock capability's precedent maps onto it.
|
||||
6. **Retirement's shape.** Is it a component op, a declaration, or a subject
|
||||
state? What do preconditions, opportunity derivation and the Projector read?
|
||||
7. **The agent's catalog.** The kernel's component ops plus light declarations
|
||||
(not `declare_affordance`), plus exercise-for-actor, dispatch-Persona,
|
||||
ask-player and end-turn. Measure the strict-schema size, and say whether a
|
||||
small model plausibly handles it.
|
||||
8. **The turn lifecycle.** Where does an open turn (a question outstanding, or
|
||||
Personas in flight) live, and what survives a restart (invariant 1)?
|
||||
9. **The Eve play surface.** Place, co-presence, perceived speech with speaker,
|
||||
displays, the agent's question, and the player's input.
|
||||
10. **The player's subject.** Does the genesis human subject at the commons
|
||||
serve, with the agent placing it after seeding?
|
||||
11. **The player's prose.** Ruled 2026-09-22, operator: the player's
|
||||
perception is rendered by the same Projector a Persona gets. "Render it
|
||||
with the Projector; we're paying for it on every Persona already." The
|
||||
Projector renders only committed state, which satisfies invariant 8, and
|
||||
it is the player's narration; the play agent writes none. The cut map
|
||||
measures the extra serial call rather than deciding on it.
|
||||
|
||||
## Verification sketch
|
||||
|
||||
The cut map owns exact tests. Each invariant gets a check that fails under its
|
||||
own mutation.
|
||||
|
||||
- **Invariant 1:** restart equivalence of the agent's turn input.
|
||||
- **Invariant 2:** the Persona's request contains no field of structured state
|
||||
and none of the agent's context.
|
||||
- **Invariant 3:** an act for a subject not present is refused, and a raw op
|
||||
never carries an actor attribution.
|
||||
- **Invariant 4:** a paraphrased quote is refused.
|
||||
- **Invariant 5:** an elaborator's declaration in Active without an answer is
|
||||
still refused, and the play authority's is admitted.
|
||||
- **Invariant 6:** a retired subject gets no opportunity and no dispatch
|
||||
across a replay.
|
||||
- **Invariant 7:** a scripted local backend drives a full tool round through
|
||||
the existing evaluator.
|
||||
- **Road:** the playtest gate itself, on Raven's machine.
|
||||
|
||||
|
||||
## Reconciliation, 2026-09-23
|
||||
|
||||
The cut map records the means and the postmortem records the cost. This section
|
||||
says how the Body stands against the target above.
|
||||
|
||||
**Landed.** All eight invariants hold, each with a test that fails under its own
|
||||
mutation, and Soul verified each at the layer where it would fail rather than in
|
||||
a unit test. One operational agent drives a turn through `world.play`; the
|
||||
kernel is the only writer; the player's card carries a projection, the question,
|
||||
and the refusal of their own act. `world.speak` and the story card are deleted.
|
||||
|
||||
**Changed from the target as written.**
|
||||
|
||||
- An answer names its question through an opaque per-question token carried in
|
||||
the play button, not through a version comparison. The version machinery that
|
||||
preceded it is deleted. Two questions in one turn commit nothing between them,
|
||||
so no counter can tell them apart.
|
||||
- The player's turn is one row keyed per world, with a request-key ledger across
|
||||
turns. A replayed key is a no-op; a stale answer and a busy turn are refused
|
||||
synchronously, before anything is spawned.
|
||||
- A row the daemon cannot read is retired to a readable JSON sidecar and play
|
||||
starts fresh, reported in readiness. A row that *can* be read is never
|
||||
destroyed — that rule is stated on the function that destroys.
|
||||
- The verbatim-quote check uses Unicode word boundaries (UAX #29), because the
|
||||
hand-written rule accepted "I can" out of "I can't" and refused most CJK
|
||||
quotes.
|
||||
|
||||
**Not built, and named as gaps rather than promises.**
|
||||
|
||||
- **Risk resolution.** The kernel resolves an act through an affordance's
|
||||
weighted outcome bands, drawn from a digest of the world, revision, command
|
||||
and affordance — deterministic, so a replay reproduces it. There is no
|
||||
assessment step, no modifiers, no skill and no roll receipt, and the play
|
||||
agent cannot author affordances: it may only grant and revoke the catalog's
|
||||
existing verbs. An act no granted affordance expresses never draws at all; the
|
||||
agent authors the consequence directly as the Play authority. Those two paths
|
||||
are indistinguishable from the player's chair, which is the real gap.
|
||||
- **Gestalt and institution agency.** Institutions exist as structure and
|
||||
constrain who may act. Nothing makes them act, and there is no strategic tick.
|
||||
Advancing the clock rolls routines to their next due and accumulates pressure
|
||||
on overdue obligations and goals; that is the whole of what happens
|
||||
off-screen.
|
||||
|
||||
**Unproven.** The seed path has never run past its configuration check. No model
|
||||
has run behind the local inference port. All test evidence is Windows; the
|
||||
release evidence is Idunn's Linux gate on Yggdrasil, where the browser-client
|
||||
tests cannot run at all, because that gate materialises source without git
|
||||
metadata.
|
||||
122
docs/architecture/ghostlight-session-zero.md
Normal file
122
docs/architecture/ghostlight-session-zero.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Ghostlight Dungeon Session Zero
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
|
||||
playtest gate is superseded (operator, 2026-09-22) by the play agent,
|
||||
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes
|
||||
L2–L4 they consume are deferred by ruling, not dropped. Plan step 15 in
|
||||
`notes/ghostlight-implementation-plan.md` carries the deferred pass order.
|
||||
|
||||
## Ownership
|
||||
|
||||
Session Zero is Ghostlight Dungeon's concept and Dungeon's authority. Dungeon
|
||||
is a consumer of the Ghostlight library. Session Zero consumes library
|
||||
capabilities through their generic surfaces and adds nothing Dungeon-shaped
|
||||
to the library: no Vault registry, player, slider, starting location, or
|
||||
Session Zero phase exists in the library vocabulary.
|
||||
|
||||
The library capabilities it needs are specified, consumer-neutral, in
|
||||
`ghostlight-world-ontology.md` under "Elaboration lenses and detail rules
|
||||
(target)": the stock lens set and weights, detail rules, per-world evidence
|
||||
binding, and elaborator sessions in Draft. Other consumers use the same
|
||||
capabilities with their own policy; Delvehold, for one, starts from a
|
||||
`Uniform` detail rule.
|
||||
|
||||
The world kernel lives in the `ghostlight` library crate
|
||||
(`crates/ghostlight/`); `ghostlight-dungeon` consumes it across a public
|
||||
boundary and cannot reach its internals.
|
||||
|
||||
## Objective
|
||||
|
||||
A player chooses a Vault, says what kind of experience they want, and gets a
|
||||
world seeded from that Vault. The world is dense where they stand and sketched
|
||||
where they don't, and it grows detail ahead of them as they travel. The flavor
|
||||
of the world comes from how often each of the eight stock lenses works on it.
|
||||
|
||||
## The flow
|
||||
|
||||
1. **Choose a Vault.** The player picks from Vaults the Dungeon operator has
|
||||
admitted. A Dungeon Vault entry names its root and the scope a player may
|
||||
read, for example Kalsa's `Public`. The payload carries a Vault id, never a
|
||||
path. Dungeon binds the chosen Vault as the world's evidence source.
|
||||
2. **Describe the experience.** The player writes what they want. A model
|
||||
reads that description and proposes weights for the eight stock lenses. The
|
||||
proposal fills the sliders; the player adjusts them. Dungeon submits only
|
||||
the numbers and the description, which becomes the world brief.
|
||||
3. **Create.** Dungeon creates a library world with title, brief, the stock
|
||||
lens set and weights, and Dungeon's detail rule with no anchor yet. No
|
||||
player subject is declared yet, so every place sits at the rule's floor.
|
||||
4. **Sketch.** Dungeon's seed session authors a coarse world from the Vault:
|
||||
regions, routes with travel costs, institutions, populations.
|
||||
5. **Choose a start.** The player picks a sketch place. Dungeon submits one
|
||||
owner patch declaring the player's human-controlled subject there, and pins
|
||||
that subject as the first anchor of its detail rule.
|
||||
6. **Elaborate.** The library's elaborator sessions run, pulled toward the
|
||||
anchors by distance.
|
||||
7. **Activate** when the player chooses. Dungeon never makes activation wait
|
||||
on a count.
|
||||
8. **Play.** As the player travels, the library's derived demand follows the
|
||||
anchor and detail follows the player.
|
||||
|
||||
## Dungeon policy
|
||||
|
||||
- **Lenses:** the library's stock set of eight, weighted by the player's
|
||||
sliders. The owner may change weights in Draft and Active; later sessions use
|
||||
the new weights.
|
||||
- **Detail rule:** `ByDistance`, anchored on important subjects, the player
|
||||
first. Recommended levels: within 60 minutes, persons, institutions, and
|
||||
populations; within 1 day, institutions and populations; beyond, institutions
|
||||
only; lookahead 120 minutes. Tuned on the road. Which other subjects Dungeon
|
||||
pins, and when, is Dungeon policy not yet decided.
|
||||
- **Travel:** Dungeon's seed authors a travel affordance for persons, and the
|
||||
Eve surface projects one control per affordance a player's subject holds.
|
||||
|
||||
## Authority map (Dungeon)
|
||||
|
||||
- **Owner:** Dungeon's runtime owns Session Zero's flow, the Vault registry,
|
||||
the weight proposal, the player principal, anchor pinning, and the Eve
|
||||
surface. Every world change it makes is an ordinary library command.
|
||||
- **Inputs:** the authenticated player, the admitted Vault list, the player's
|
||||
description and slider values, the chosen start place.
|
||||
- **Outputs:** library world creation, seed and owner patches, rule and
|
||||
weight changes, activation, and Eve projections.
|
||||
- **Derived state:** the slider proposal, the Session Zero step a player is on
|
||||
(derived from the library snapshot's phase, rule anchors, and structure), and
|
||||
every projection.
|
||||
- **Forbidden writers:** Dungeon writing world state other than through
|
||||
library commands; the proposal model submitting weights without the
|
||||
player's command; a Vault path in any payload; any Dungeon identifier,
|
||||
schema, or policy entering the library vocabulary.
|
||||
|
||||
## Cut line (Dungeon)
|
||||
|
||||
- `GHOSTLIGHT_SEED_VAULT_ROOT` as the only Vault: Dungeon's Vault registry
|
||||
replaces it.
|
||||
- The `world.create` payload fields for jurisdiction roots and permille, when
|
||||
the library's detail rule replaces them.
|
||||
- The genesis human subject: Dungeon declares its player at the chosen start.
|
||||
|
||||
## Verification (Dungeon)
|
||||
|
||||
1. A player-chosen Vault id resolves only to an admitted Vault and scope; an
|
||||
unknown id or a path is refused before anything is spent.
|
||||
2. The slider proposal changes nothing until the player submits weights.
|
||||
3. The start choice declares exactly one player subject, at a sketch place,
|
||||
once, in Draft, and pins it as the rule's first anchor.
|
||||
4. Activation succeeds with nonzero deficits.
|
||||
5. Road: a seeded world on the SDK route shows persons near the start,
|
||||
institutions far from it, and new persons at a place after the player's
|
||||
subject travels toward it.
|
||||
|
||||
## Pass order
|
||||
|
||||
Library passes L0 through L4 are in plan step 15; L0 is the kernel
|
||||
extraction. Dungeon passes follow them:
|
||||
|
||||
1. Vault registry, world creation with Vault id, brief, lens weights, and
|
||||
detail rule.
|
||||
2. The Session Zero Eve surface: Vault choice, description, slider proposal,
|
||||
sliders, sketch, start choice, activation.
|
||||
3. Travel affordance and per-affordance player controls. This pass closes the
|
||||
playtest gate.
|
||||
1919
docs/architecture/ghostlight-stock-lenses-cut.md
Normal file
1919
docs/architecture/ghostlight-stock-lenses-cut.md
Normal file
File diff suppressed because it is too large
Load diff
152
docs/architecture/ghostlight-stock-lenses-postmortem.md
Normal file
152
docs/architecture/ghostlight-stock-lenses-postmortem.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Ghostlight stock lenses (L1) postmortem
|
||||
|
||||
Written 2026-09-16. Evidence: the cut map (`ghostlight-stock-lenses-cut.md`),
|
||||
the target (`ghostlight-stock-lenses.md`), and the commits on
|
||||
`codex/ghostlight-dungeon-mvp` from `817c9f4` to `872ba35`, plus the Cut 5 doc
|
||||
commits.
|
||||
|
||||
## Summary
|
||||
|
||||
Elaboration in the `ghostlight` library now has a flavor the world's creator
|
||||
controls:
|
||||
- eight stock lenses;
|
||||
- per-world weights in world state, replaceable by the owner;
|
||||
- a replayable per-session draw that records the lens and its instruction
|
||||
text on the session;
|
||||
- concurrent sessions under their own permit ceiling.
|
||||
|
||||
In-flight elaborator work is rediscovered by reading the store.
|
||||
Scale: 12 crate files, +4,907 / −266; four schema strings replaced
|
||||
(`consumer.v5`, `controller_work.v16`, `world_create.v4`, and the commit
|
||||
schema); one environment variable; one required trait method; no dependency,
|
||||
crate or binary.
|
||||
|
||||
State at writing: landed and pushed, Windows-verified. The Linux release is
|
||||
unexercised until Idunn builds it. The operator owes rulings on L1.f1
|
||||
(quarantine handling), L1.f13 (seed-lane orphans), L1.f14 (row retirement)
|
||||
and L1.f16 (consumer lens sets). None blocks L2.
|
||||
|
||||
## Scope and invariants
|
||||
|
||||
The target set eight invariants: lenses never decide admission, every lens
|
||||
sees the whole catalog, the draw is replayable, weights are admitted honestly,
|
||||
concurrent sessions stay distinct, the lens is recorded and survives resume,
|
||||
stores refuse rather than migrate, and no Dungeon concept enters the library.
|
||||
All eight hold. Excluded: detail rules, evidence binding, Draft sessions
|
||||
(L2–L4), and Dungeon's Session Zero surface (D1–D3).
|
||||
|
||||
## Timeline
|
||||
|
||||
| Cut | Commits | Soul passes | Notable finding |
|
||||
|---|---|---|---|
|
||||
| Map | `ff766e8` (with rulings Q1–Q9) | — | Two sessions on one answer collided; a boundary repair was Superseded forever after any commit |
|
||||
| 1 lens module | `835ea4d` | 1 | No external crash path; duplicate keys last-wins; guard and divisor diverged |
|
||||
| 2 weights, command | `d06fd7e`, `10be98c`; fixes `afca403`, `acb9b33` | 2 | Identical weights committed a no-op; forged row caught by the digest, not the rule; a padded twin still committed |
|
||||
| 3 session, store, adoption | `dfb3c52..2bf8164`; fixes `f63dfd1..2c3b513` | 1 | Flaky pin (3/40); the Q7 resume rule untested; the call-site draw unpinned |
|
||||
| 4 concurrency, pools, rediscovery | `8538fab..3e35b3d`; fixes `6f35229..0764842`, `635123f..872ba35` | 3 | The adoption rule on one test; speak-lane pool untested; a panic killed the driver; the retryable-no-stop leg unpinned |
|
||||
| 5 docs | `39b2af4`, `04fd004`, `b15ade4` | 1 | The ontology claimed seed requests and Vault evidence for the Active sweep |
|
||||
|
||||
## Structural delta
|
||||
|
||||
The estimate grew from about +900 to about +1,300 as rulings added the owner
|
||||
command and rediscovery. The actual was +4,907 / −266 in crates, overwhelmingly
|
||||
test code (`controllers.rs` +2,807). Production grew by the lens module (379
|
||||
lines including its tests), weights and the command in the kernel,
|
||||
session fields and adoption in elaboration, the concurrent sweep and
|
||||
`SessionPermit`, and the runtime's second pool. Deleted: the sequential
|
||||
sweep, `step`, `Clean`, `Inactive`, the instruction constant as integrity
|
||||
reference, whole-session supersession equality, and a proven-dead digest
|
||||
comparison. Nothing parked.
|
||||
|
||||
## What Soul caught
|
||||
|
||||
- **Untested operator rulings.** L1-Q7's whole purpose, that a resumed session
|
||||
keeps its stored text when a lens's text changes, had no test: rebuilding the
|
||||
text from the current lens passed everything. The operator ruled it; nothing
|
||||
pinned it.
|
||||
- **Flaky tests passing by luck.** A test rewrote a session's lens to a fixed
|
||||
value while its fixture drew one at random. It failed about one run in eight,
|
||||
and Hands' green run was luck.
|
||||
- **Rules that no longer bear weight.** After Cut 4 routed resume through the
|
||||
listing, Cut 3's adoption rule was guarded by a single test. Cut 3's own
|
||||
former killers no longer reached it.
|
||||
- **Semantic equality by spelling.** The no-op check compared raw maps, so
|
||||
`{charter:3}` and `{patina:0, charter:3}`, which draw identically, still
|
||||
committed revisions both ways.
|
||||
- **Tests catching the wrong thing.** A forged all-zero journal row was
|
||||
refused by a digest mismatch, so weakening the draw rule changed nothing.
|
||||
- **Failure paths.** One panicking session task unwound out of the sweep,
|
||||
aborted its sibling, and silently ended the runtime's elaboration driver
|
||||
until restart.
|
||||
- **Spec errors, caught before they shipped.** Identical weights "still a
|
||||
commit"; a Dungeon payload mutation that could never fail; the `NoEffect`
|
||||
variant the plan names but the kernel never had.
|
||||
|
||||
## Operator corrections
|
||||
|
||||
- **"Are you relying on some deterministic code outputting the same id rather
|
||||
than just checking the state?"** This was the campaign's best question. It
|
||||
came from Self having told the operator the lens "has to be recorded" in the
|
||||
command identity. Answering it from the Body exposed the pre-existing
|
||||
defect that the elaborator repair loop had never worked across sweeps (L1.f10),
|
||||
and led to L1-Q9 (resume reads state). Missing context: Self framed the
|
||||
recording question and the identity question as one.
|
||||
- **Weights and Draft approvals.** Self recommended binding approvals to a
|
||||
digest that included lens weights. The operator asked how weights could ever
|
||||
alter an approval. They cannot: weights shape future elaboration, and the
|
||||
owner may change them in any phase. Missing context: the probe that found the
|
||||
approval gap happened to use weights.
|
||||
- **Proportion.** "Dear Lord, we don't need even more machinery" (L1-Q5), and
|
||||
the pools as "a ceiling, not a budget" (L1-Q4). Both shrank designs that Self
|
||||
had presented with more options than they needed.
|
||||
|
||||
## Incidents
|
||||
|
||||
- **Agents ending their turn to wait.** Three times, an agent launched cargo
|
||||
detached and ended its turn "to wait", and the work stopped until resumed by
|
||||
hand. The brief already said to wait for long jobs. What fixed it was naming
|
||||
how: block inside a tool call, with cargo in the foreground or `Wait-Process`
|
||||
on the pid. Recorded in the Eureka changelog.
|
||||
- **Line endings.** The working tree mixed LF and CRLF files over an LF index.
|
||||
A naive newline anchor silently matched nothing, and `git apply -R`
|
||||
rewrote an LF file to CRLF. Rule: detect each file's ending, require every
|
||||
anchor to match exactly once, restore by reverse edit only.
|
||||
- **A Soul probe left an empty untracked `lib.rs`.** Its permission to delete
|
||||
was refused. Found in the report, verified, removed at close-out.
|
||||
- **A map refresh raced an operator message.** Imagination finished before a
|
||||
second ruling arrived, so the map contradicted two rulings until it was
|
||||
resumed. Self held the commit until the map was refreshed.
|
||||
|
||||
## What worked
|
||||
|
||||
- Probing the operator's challenge instead of defending the design: the
|
||||
stranding probe turned a question into a fixed pre-existing defect.
|
||||
- Measuring flakes before and after (3/40, then 120/120) instead of rerunning
|
||||
until green.
|
||||
- Folding small, related findings into the next cut's pass instead of a
|
||||
separate Hands and Soul cycle, with each still in its own commit.
|
||||
- Skipping Soul on a test-only batch with an empty production diff, and
|
||||
having the next Soul pass rerun its mutations.
|
||||
- Derived status: no "Open:" line was ever hand-edited; questions were open
|
||||
exactly while they lacked a ruling.
|
||||
|
||||
## What to change in the pipeline
|
||||
|
||||
- Tell agents how to wait, not only to wait (Eureka changelog, 2026-09-16).
|
||||
- Treat any test built on a random draw or identity as requiring a loop.
|
||||
- When the operator asks why a mechanism works, answer from the Body with a
|
||||
probe before defending the design.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
| Item | Owner | Where | Why it can wait |
|
||||
|---|---|---|---|
|
||||
| L1.f1: quarantine-class elaboration errors never set quarantine, and are logged only at debug (c4.s2.f4: a panic in `persist` would wedge the lane silently) | operator | `runtime.rs` elaboration driver | Pre-existing; the lane keeps running |
|
||||
| L1.f11: the browser create form never lifts bindings into the payload | operator ruled: D2 | `eve-browser-lowering`, `runtime.rs` | Fixed with the Session Zero surface |
|
||||
| L1.f12: Draft approvals bound to nothing | operator ruled: deferred to multi-player | `lib.rs` `draft_approvals` | Approvals are the owner's own today |
|
||||
| L1.f13: the seed lane orphans rows like deficits did | operator | `elaboration.rs` seed runner | Outside L1 |
|
||||
| L1.f14: stored rows are never retired, including the L1-Q10 crash window | operator | controller work store | Growth bounded by world lifetime |
|
||||
| L1.f15: `Uncovered` pinned only with at most one named root | Library | `elaboration.rs` | Behaviour is correct; the case is untested |
|
||||
| L1.f16: consumer-supplied lens sets | operator | `lens.rs` | No consumer needs one yet |
|
||||
| c3.s1.f3 cost: stored instruction text is not tied to the recorded lens | accepted with L1-Q7 | `elaboration.rs` | Only the local store file can write it |
|
||||
| Linux release | operator via Idunn | Yggdrasil | Needs the deployment gate |
|
||||
84
docs/architecture/ghostlight-stock-lenses.md
Normal file
84
docs/architecture/ghostlight-stock-lenses.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Ghostlight stock lenses (plan step 15, L1)
|
||||
|
||||
## Status
|
||||
|
||||
Closed 2026-09-16. Landed in Ghostlight `835ea4d..872ba35`, plus the Cut 5
|
||||
doc commits `39b2af4`, `04fd004` and `b15ade4`. The means, every ruling
|
||||
(L1-Q1 to L1-Q10), and every Soul verdict are in
|
||||
`ghostlight-stock-lenses-cut.md`; the scars are in
|
||||
`ghostlight-stock-lenses-postmortem.md`. Windows only: the Linux release is
|
||||
Idunn's build and is unexercised.
|
||||
|
||||
## Target
|
||||
|
||||
Elaboration in the `ghostlight` library gets a flavor the world's creator
|
||||
controls. The library ships eight stock lenses. A world carries lens weights
|
||||
as world data. Every elaborator session draws one lens by those weights,
|
||||
replayably, and the drawn lens shapes what kind of structure the session
|
||||
reaches for. Several sessions elaborate at once.
|
||||
|
||||
The lenses are the library's, not Dungeon's. Dungeon supplies weights at
|
||||
world creation.
|
||||
|
||||
| Lens | Reaches for | Leads with |
|
||||
|---|---|---|
|
||||
| Patina | ordinary objects, customs, nicknames, local jokes | persona material, ordinary affordances |
|
||||
| Charter | government, law, offices, selection, succession, redress | authority, selection, redress |
|
||||
| Ledger | resources, labor, trade, infrastructure, scarcity, class pressure | custody, dependency, route cost |
|
||||
| Hearth | kinship, daily life, care, obligation, belonging | commitments, reads |
|
||||
| Tangle | factions, alliances, rivalries, leverage | relations, pressure, authority |
|
||||
| Veil | secrets, rumors, misinformation, taboos, unevenly held knowledge | knowledge scope, channels |
|
||||
| Ember | disputes, hazards, instability, escalation, urgent pressure | pressure, near-due commitments |
|
||||
| Numen | religion, magic, ritual, awe, cosmology, the genuinely strange | facts, affordances, material |
|
||||
|
||||
The design text is `ghostlight-world-ontology.md`, "Elaboration lenses and
|
||||
detail rules", which separates what L1 implemented from what L2 to L4 owe.
|
||||
|
||||
## Invariants, reconciled with the Body
|
||||
|
||||
1. **A lens never decides admission.** The same patch admits identically under
|
||||
all eight lenses, with the mismatch set equal to the pre-L1 capture. A lens
|
||||
is not part of the command id, the caller, the confinement ground, or any
|
||||
mismatch. Holds.
|
||||
2. **Every lens sees the whole catalog.** The catalog signatures and digest
|
||||
are unchanged from before L1; emphasis is prompt text only (L1-Q5).
|
||||
Holds.
|
||||
3. **The draw is replayable.** The lens is drawn from world id and command
|
||||
id with the kernel's `select_band` recipe, and the library has no RNG
|
||||
dependency. The operator cares about replayability, not about what seeds
|
||||
the draw (L1-Q1). Holds.
|
||||
4. **Weights are world data with honest admission.** `WorldState` carries
|
||||
them (`consumer.v5`); creation requires explicit weights with no library
|
||||
default (L1-Q6); the owner replaces them with `SetLensWeights` in any phase
|
||||
(L1-Q3). An all-zero set is refused at creation, at the command, at the
|
||||
Dungeon payload and in a stored state row. A set that draws the same as the
|
||||
current one, however it is spelled, is refused as no canonical change.
|
||||
Holds.
|
||||
5. **Concurrent sessions are distinct work.** The sweep hands concurrently
|
||||
running sessions distinct demand entries through an in-memory claim set.
|
||||
The lens stays out of the command id, which remains the idempotency key
|
||||
(L1-Q4). Elaboration draws from its own pool, a ceiling
|
||||
(`GHOSTLIGHT_ELABORATION_MAX_CONCURRENT`, default 2), separate from the
|
||||
simulation budget, and saturating either pool never delays the other.
|
||||
Dungeon runs no Active elaboration since the play agent's Cut 1
|
||||
(`d69e9d4`) deleted its sweep and pool; the claim stands as a library
|
||||
capability. Holds.
|
||||
6. **The lens is recorded and resume reads state.** The drawn lens and its
|
||||
instruction text are persisted on the session (`controller_work.v16`,
|
||||
L1-Q7). A resumed session keeps both even if the weights or the lens's
|
||||
text change. In-flight sessions are rediscovered from the store by what
|
||||
they answer (L1-Q9); rows ready to submit are excluded from that listing
|
||||
(L1-Q10). Holds.
|
||||
7. **Stores refuse, not migrate.** `consumer.v4` states and
|
||||
`controller_work.v15` rows are refused at open. Holds.
|
||||
8. **No Dungeon concept enters the library**, and the boundary stays sealed
|
||||
by admission. Holds.
|
||||
|
||||
## Out of scope, still owed elsewhere
|
||||
|
||||
- Detail rules, distance-weighted demand, and the permille cut (L3).
|
||||
- Per-world evidence binding; the Active elaborator still uses
|
||||
`NullEvidenceSource` (L2).
|
||||
- Elaborator sessions in Draft (L3).
|
||||
- Dungeon's Session Zero surface, slider proposal, and weight UI (D1-D3).
|
||||
Dungeon passes explicit uniform weights through `world_create.v4` today.
|
||||
204
docs/architecture/ghostlight-world-consumer-api.md
Normal file
204
docs/architecture/ghostlight-world-consumer-api.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Ghostlight World Consumer API
|
||||
|
||||
Status: this describes the landed pass-10 consumer ingress — a world's third
|
||||
patch author, reaching `AdmitPatch` through one loopback-bound typed document.
|
||||
|
||||
## Objective
|
||||
|
||||
Let an external consumer author and observe the exact subjects a Ghostlight
|
||||
world has admitted as its mirrors, without becoming part of Ghostlight's
|
||||
runtime and without the world learning the consumer's vocabulary. A
|
||||
consumer-authored seed and a compiled seed are the same `AdmitPatch`; there is
|
||||
no separate seed type, registry, or publication handoff. Delvehold is the
|
||||
first consumer; no Delvehold identifier, schema, or policy is core authority
|
||||
(`docs/architecture/delvehold-forced-ontology-integration.md` names the
|
||||
profile-side binding).
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Admission owner:** `require_patch_author` (`crates/ghostlight/src/lib.rs`) decides which
|
||||
caller may submit `AdmitPatch` and returns that caller's `PatchGround`;
|
||||
`confine_to_ground` decides what a confined ground may write. Both run in
|
||||
`reduce`'s `AdmitPatch` arm and again in `apply_effect`'s `PatchAdmitted`
|
||||
arm — there is no second confinement function and no way for a patch to
|
||||
commit without being re-decided.
|
||||
- **Consumer ground:** `PatchGround::Consumer(ConsumerId)` is derived, never
|
||||
carried. The set of subjects a consumer may write is
|
||||
`{ s | controller_assignments[s] == ExternallyControlled { consumer } }`,
|
||||
read from committed state at decision time. A consumer cannot name its own
|
||||
scope on the wire, and revoking a binding in a later commit shrinks its
|
||||
authority with no ingress change.
|
||||
- **Caller minting:** `WorldMailbox::submit_consumer` (`crates/ghostlight/src/mailbox.rs`) is
|
||||
the only constructor of `AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer })`.
|
||||
- **Ingress owner:** `crates/ghostlight/src/consumer.rs` owns decoding a document, bounding
|
||||
it, authenticating the consumer, one port call, and one receipt
|
||||
projection. It owns no world truth, holds no opinion about a patch's
|
||||
content, and does not pre-validate — a second reducer here could disagree
|
||||
with the kernel's, so structure is decided once, by `resolve_patch`.
|
||||
- **Port:** `ConsumerPort` (`crates/ghostlight/src/mailbox.rs`) has one method,
|
||||
`submit_consumer`. It cannot read the world: it does not select an answer,
|
||||
does not pre-validate, and returns a receipt rather than state.
|
||||
|
||||
## Typed flow
|
||||
|
||||
```text
|
||||
ConsumerPatchDocument (canonical MessagePack)
|
||||
-> ConsumerRegistry: name -> ConsumerId, secret verified against a
|
||||
stored SHA-256 digest in constant time
|
||||
-> patch::decode_patch, bounded by MAX_PATCH_BYTES / _DECLARATIONS /
|
||||
_OPERATIONS / _EVIDENCE
|
||||
-> WorldMailbox::submit_consumer(world_id, expected_revision, command_id,
|
||||
consumer, answers, patch)
|
||||
-> reduce / apply_effect: require_patch_author, the answer rule,
|
||||
confine_to_ground
|
||||
-> ConsumerReceiptDocument: one atomic verdict — a commit reference or
|
||||
the complete mismatch set
|
||||
```
|
||||
|
||||
`command_id` and `world_id` on the receipt are `Option`: a frame that fails to
|
||||
decode or names an unregistered consumer never derives a command key or reads
|
||||
a world, so the receipt has none to report. The outbound half of this
|
||||
contract — a consumer reading its own projection, or receiving an
|
||||
attributed proposal — is not part of this pass; see "Not in this pass" below.
|
||||
|
||||
## Transport
|
||||
|
||||
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
|
||||
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
|
||||
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
|
||||
serves.
|
||||
|
||||
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
|
||||
`api_router` (`runtime.rs`). The handler gates loopback and content type
|
||||
(`application/msgpack`) exactly as the snapshot route does, then hands the
|
||||
body to `ghostlight::admit_document`; everything past those two gates is
|
||||
`consumer.rs`'s to decide. The body limit is
|
||||
`CONSUMER_BODY_LIMIT = patch::MAX_PATCH_BYTES + CONSUMER_ENVELOPE_SLACK` (an
|
||||
8 KiB envelope allowance over the 256 KiB patch cap), so the transport guard
|
||||
cannot become a second opinion about how big a patch may be.
|
||||
|
||||
The document and receipt are Ghostlight-owned typed records in canonical
|
||||
MessagePack, not CultNet control messages, pinned to two schema constants:
|
||||
|
||||
```text
|
||||
CONSUMER_PATCH_SCHEMA = "ghostlight.consumer_patch.v0"
|
||||
CONSUMER_RECEIPT_SCHEMA = "ghostlight.consumer_receipt.v0"
|
||||
```
|
||||
|
||||
`ConsumerPatchDocument` carries `schema`, `world_id`, `consumer` (a
|
||||
configured name, lowered to a `ConsumerId` by the registry and never carried
|
||||
further), `secret`, `idempotency_key`, `expected_revision`, `answers:
|
||||
Option<PatchAnswer>`, and `patch: Vec<u8>` — the encoded `WorldPatch` as a
|
||||
nested canonical-MessagePack byte frame rather than an inline value, so
|
||||
`MAX_PATCH_BYTES` is checked against the patch itself before any of its items
|
||||
deserializes. The outer document is decoded first, is tiny, and is bounded by
|
||||
the route's derived limit.
|
||||
|
||||
`mesh.rs`'s advertisement gains the two schema constants and nothing else:
|
||||
this door is loopback-bound, so it is not a remotely reachable surface and
|
||||
advertising it as one would claim availability the binary does not have.
|
||||
|
||||
## Idempotency and staleness
|
||||
|
||||
`expected_revision` is required; the port submits through
|
||||
`submit_authenticated`, not the stamped path, because a caller that cannot
|
||||
name the revision it built against cannot be told its batch is stale. The
|
||||
idempotency ledger is checked before the revision, so an exact resubmission
|
||||
still returns the original receipt (`AlreadyApplied`) even after the world has
|
||||
moved; a stale document is `RevisionMismatch { expected, actual }`, and the
|
||||
receipt carries the live revision so the consumer knows what to build against
|
||||
next. `CommandId::derived` computes the command key from `(world, consumer,
|
||||
idempotency_key)`; the same key with a different body is
|
||||
`CommandIdConflict`, and nothing commits.
|
||||
|
||||
There is no receipt-probe request and no separate mailbox `Request` variant
|
||||
for it. A consumer's document is byte-identical on retry and its `CommandId`
|
||||
is derived, so resubmission is the probe and the ledger answers it directly.
|
||||
|
||||
## The externally controlled subject
|
||||
|
||||
A subject declared `NewController::External { consumer }` receives
|
||||
`ControllerAssignment::ExternallyControlled { consumer }`. It mints no
|
||||
controller ID and no controller mode — both accessors return `Option` for it,
|
||||
and every reader fails closed rather than assuming a default. Concretely:
|
||||
|
||||
- it derives no decision opportunity and mints no turn;
|
||||
- it is excluded from `agency_graph` and so from the cover;
|
||||
- it is visible in the snapshot as an ordinary subject, related to, targeted,
|
||||
and counted like any other, with `controller_id: None` and
|
||||
`controller_mode: None`;
|
||||
- it may hold no affordance — declaring it with a non-empty affordance grant
|
||||
set is `Mismatch::ControllerGrantMismatch`, and the pairing is checked both
|
||||
ways (an ordinary subject with an empty grant set is the same mismatch);
|
||||
- its components change through exactly two callers: its own consumer, and
|
||||
the world owner, who is unconfined and always could write it — that is the
|
||||
owner's standing authority, not a leak in the consumer boundary.
|
||||
|
||||
## Confinement
|
||||
|
||||
A consumer writes only the subjects bound to it and operations grounded on
|
||||
those subjects (`confine_to_ground`, the `PatchGround::Consumer` arm):
|
||||
|
||||
- a declared subject is admitted only if its controller names the same
|
||||
consumer; position is unconfined;
|
||||
- a declared place, a declared route, `Relocate`, `OpenRoute`, `CloseRoute`,
|
||||
and `AlterCost` are always refused — they carry a place or a route, and a
|
||||
consumer's ground names no place;
|
||||
- `Witness` is always refused, for the same reason: its ground is the named
|
||||
place, never the acting subject, and a consumer's ground names no place.
|
||||
A consumer tells what its mirror knows through `AcquireKnowledge` on its own
|
||||
bound subject; it cannot cause a place-wide witnessed event;
|
||||
- `Retire`, `GrantAffordance`, `RevokeAffordance`, and `Mint` are always
|
||||
refused (`ResolvedOp`s that are authority over standing, not structure over
|
||||
ground);
|
||||
- a `Claimed { by }` fact must name a bound subject; a `Ruled` fact is refused
|
||||
outright, and so is a `Canonical` fact
|
||||
declaration — a consumer's evidence buys an `Admit`
|
||||
into its own custody and a `Claimed` fact, never a canonization;
|
||||
- a channel's reach and controller must all be bound subjects;
|
||||
- every operation's ground subjects must be bound, and its place and route
|
||||
lists must be empty — `Transfer` therefore requires both endpoints bound,
|
||||
so a consumer cannot push custody into a Ghostlight subject; `Admit` and
|
||||
`Consume` work inside its own custody.
|
||||
|
||||
In Active phase a consumer patch that declares answers nothing except a
|
||||
`CausalBoundary::MissingStructure { subject }` derived on one of its own
|
||||
mirrors — the world noticing that a subject it can see has structure it
|
||||
cannot account for. Every other Active declaration is refused by the answer
|
||||
rule plus this confinement, not by a separate phase gate: a consumer cannot
|
||||
answer a boundary on a foreign subject (not covered), cannot answer a
|
||||
`Deficit` row (deficits are jurisdictional; a consumer holds no
|
||||
jurisdiction), and cannot declare without an answer. A component-only batch —
|
||||
the overwhelming majority of consumer traffic — answers nothing and commits
|
||||
through the same `require_answer` path as any other author.
|
||||
|
||||
## Operator configuration
|
||||
|
||||
`ConsumerRegistry` is loaded once at startup from the file named by
|
||||
`GHOSTLIGHT_CONSUMER_CREDENTIALS`: one `name = <64 hex digits>` line per
|
||||
consumer (the SHA-256 of that consumer's shared secret), blank lines and `#`
|
||||
comments ignored. A missing file means no consumers, and every document is
|
||||
`Unauthenticated`, the fail-closed default that lets this route exist before
|
||||
an operator configures anything. A file that exists but does not parse is
|
||||
fatal at startup, so a mistyped credential can never read as "no consumers". The registry holds only digests; the
|
||||
plaintext secret never enters the process image beyond the hash and never
|
||||
enters a log, a receipt, or the journal.
|
||||
|
||||
## Deployment
|
||||
|
||||
The consumer capability's tag is part of the commit digest and the
|
||||
externally controlled assignment is part of the state shape, so both bump the
|
||||
schema. World lens weights later entered the state and `SetLensWeights`
|
||||
the commit, so the live schemas are state `ghostlight.world_state.consumer.v6`
|
||||
(state-schema generation `world-v3`) and commit
|
||||
`ghostlight.world_commit.consumer.v6`. A store written under an earlier schema
|
||||
is refused, not migrated.
|
||||
|
||||
## Not in this pass
|
||||
|
||||
- The outbound consumer response — a consumer reading its own projection, or
|
||||
receiving an attributed proposal about a Ghostlight subject acting toward
|
||||
its mirror. This is the next seam; building it prematurely would grow a
|
||||
second projection owner ahead of its use.
|
||||
- A CultMesh authority lease for a non-loopback consumer. Until that exists,
|
||||
this route is loopback-only and offers a consumer no remote door.
|
||||
1041
docs/architecture/ghostlight-world-ontology.md
Normal file
1041
docs/architecture/ghostlight-world-ontology.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,85 +0,0 @@
|
|||
# Illustrated IF Visual Pipeline
|
||||
|
||||
Illustrated interactive-fiction fixtures can use image generation for mood,
|
||||
presentation, and website replay. They should not treat image generation as the
|
||||
source of spatial truth.
|
||||
|
||||
## Role
|
||||
|
||||
The visual pipeline has four separate jobs:
|
||||
|
||||
1. Describe the playable visual beat in the Ink and `.visual.json` artifact.
|
||||
2. Preserve the intended environment, character, and branch state as structured
|
||||
visual data.
|
||||
3. Provide image models with enough concrete visible detail to render the beat.
|
||||
4. Keep generated art useful as presentation collateral without letting it
|
||||
rewrite scene geometry.
|
||||
|
||||
Visual artifacts are auxiliary replay and website value. They are not core
|
||||
training targets for responder, appraiser, mutator, relationship, or social
|
||||
state models.
|
||||
|
||||
## Geometry Authority
|
||||
|
||||
For scenes where spatial continuity matters across angles, branches, or later
|
||||
callbacks, the authoritative visual source is a `scene_set` artifact, not a
|
||||
generated image.
|
||||
|
||||
A `scene_set` may be a hand-built 3D blockout, a procedural scene file, a
|
||||
layout diagram, or another durable geometry asset. It should define:
|
||||
|
||||
- Environment layout and scale.
|
||||
- Named zones, routes, entrances, exits, and occluders.
|
||||
- Fixed cameras or camera-slot names for replay beats.
|
||||
- Character staging slots and allowed movement regions.
|
||||
- Important props, access points, interfaces, and branch-visible state anchors.
|
||||
- Which parts of the set are visible from each camera.
|
||||
|
||||
Generated environment concept art can inspire the set, but it is not a reliable
|
||||
layout contract. Image models routinely invent impossible joins, inconsistent
|
||||
occlusion, false loops, and locally plausible machinery that cannot be staged
|
||||
from a second angle.
|
||||
|
||||
Agent-assisted set construction may live in a separate toolchain such as
|
||||
VibeGeometry. Ghostlight only requires the resulting durable geometry input and
|
||||
the metadata needed to connect it to fixture scenes.
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Prompt assembly should use the geometry source appropriate to the fixture:
|
||||
|
||||
- Mood concept only: text prompt may be enough.
|
||||
- Single isolated illustration: a complete standalone prompt may be enough.
|
||||
- Multi-scene illustrated IF: use `.visual.json` scene ids, character refs, and
|
||||
branch modifiers.
|
||||
- Multi-angle or branch-consistent illustrated IF: use a `scene_set` render or
|
||||
blockout for each camera angle, then apply the scene prompt as painterly
|
||||
direction.
|
||||
|
||||
For the multi-angle path, the image model acts as a painter and material
|
||||
interpreter. It does not decide the room shape.
|
||||
|
||||
## Base Images And Modifiers
|
||||
|
||||
Base prompts describe a complete visible frame. Modification prompts describe
|
||||
only the branch or state delta, but the edit call must receive the base image or
|
||||
the camera-specific blockout as visual input.
|
||||
|
||||
Do not rely on phrases such as "same room" or "as before" unless the edit input
|
||||
actually supplies the same room. If the image model only receives text, restate
|
||||
the visible geometry. If it receives a blockout, the prompt should still name
|
||||
the visible anchors it must preserve.
|
||||
|
||||
## Reviewer Expectations
|
||||
|
||||
The visual reviewer should ask whether the current fixture needs a `scene_set`.
|
||||
It should require one when:
|
||||
|
||||
- The story returns to the same room from multiple angles.
|
||||
- Branches move characters around a shared environment.
|
||||
- Sightlines, occlusion, route access, or body affordances affect choices.
|
||||
- The same space needs website imagery across several beats.
|
||||
- A generated concept image is being used as if it were a durable floor plan.
|
||||
|
||||
When a `scene_set` is required but missing, the fixture can still be accepted as
|
||||
prose or IF scaffold, but not as replay-ready illustrated IF.
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
# Ink Branching Scenes
|
||||
|
||||
Ink is the branching scene format.
|
||||
|
||||
Ghostlight should not invent a parallel dialogue-tree language unless Ink
|
||||
fails a concrete requirement. Ink already gives us readable interactive fiction,
|
||||
choices, knots, stitches, variables, compiled JSON, and a format writers
|
||||
can inspect without needing to wear a compiler helmet indoors.
|
||||
|
||||
Ghostlight's job is the machinery around Ink:
|
||||
|
||||
- build actor-local awareness from agent state and scene state
|
||||
- project plausible speech and non-speech choices
|
||||
- save generation receipts when a model proposes branches
|
||||
- annotate why those choices make sense
|
||||
- preserve listener interpretation as separate from actor intent
|
||||
- record consequence surfaces for later state mutation
|
||||
- keep fuzzy social updates reviewed until there is training data
|
||||
- compile Ink into game-consumable JSON
|
||||
|
||||
Ink owns the playable branch structure. Ghostlight owns the psychological,
|
||||
cultural, memory, relationship, and consequence rationale.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
The artifact flow is:
|
||||
|
||||
```text
|
||||
agent state + scene state + lore grounding
|
||||
-> local awareness
|
||||
-> projected branch choices for the acting character
|
||||
-> selected branch as observable action
|
||||
-> event resolution from selected observable action
|
||||
-> participant appraisals and reviewed state mutation
|
||||
-> next actor local awareness from updated state
|
||||
-> next actor action
|
||||
-> Ink scene
|
||||
-> compiled Ink JSON
|
||||
-> reviewed training annotations
|
||||
-> selected branch consequences
|
||||
-> state, memory, belief, and relationship updates
|
||||
```
|
||||
|
||||
The Ink file should be readable as Aetheria content on its own. The sidecar
|
||||
training annotation should explain which Ghostlight state produced each branch
|
||||
and which consequences are automatic, reviewed, or deferred.
|
||||
|
||||
Each character turn must be generated from that character's local awareness.
|
||||
The protagonist-choice generator should not receive the responder's private
|
||||
state. The responder generator should not receive the protagonist's private
|
||||
intent except as an observable action, spoken text, gesture, object use,
|
||||
silence, or other perceivable cue. A listener is allowed to misunderstand.
|
||||
|
||||
Participant appraisal is symmetrical. If an action hurts, threatens, reassures,
|
||||
humiliates, obligates, or overloads anyone present, that change is consolidated
|
||||
for the affected character before the next actor is selected. The next actor may
|
||||
reply, walk away, comply, escalate, or end the interaction. A branch artifact can
|
||||
still display this as `choice -> reaction`, but Ghostlight should model it as
|
||||
turns over updated state, not as a mandatory paired response.
|
||||
|
||||
One-shot generation of both sides is allowed only as bootstrap scaffolding, and
|
||||
must be marked as such in the capture review. It is not the target runtime
|
||||
shape.
|
||||
|
||||
The current target path is source-grounded coordinator state, projected local
|
||||
context, reviewed character turns, branch compiler materialization, and IF
|
||||
artifact review.
|
||||
|
||||
## Ink Comments
|
||||
|
||||
Use Ink comments for lightweight traceability in writer-facing drafts:
|
||||
|
||||
- `ghostlight.scene`
|
||||
- `ghostlight.fixture`
|
||||
- `ghostlight.branch`
|
||||
- `ghostlight.action`
|
||||
- `ghostlight.intent`
|
||||
- `ghostlight.npc_response`
|
||||
- `ghostlight.consequence`
|
||||
- `ghostlight.training_hook`
|
||||
- `aetheria.flashpoint`
|
||||
|
||||
Use `//`, not Ink tags (`#`), for this metadata in drafts. Inky exposes tags in
|
||||
preview, which makes Ghostlight state leak into the readable scene. The
|
||||
sidecar remains the machine-readable receipt; comments are only local handles
|
||||
for humans and simple tools.
|
||||
|
||||
## Sidecar Annotations
|
||||
|
||||
Each Ink scene should have a `.training.json` sidecar with:
|
||||
|
||||
- source fixture reference
|
||||
- protagonist and NPC ids
|
||||
- actor-local awareness summary
|
||||
- projection controls
|
||||
- branch ids and Ink knot paths
|
||||
- action type and actor intent
|
||||
- state basis
|
||||
- reviewed consequences
|
||||
- training hooks
|
||||
- mutation policy
|
||||
|
||||
This sidecar is intentionally not a new branching format. It is a receipt. Ink
|
||||
remains the scene tree; the sidecar says why the tree grew that way and which
|
||||
state changes a reviewer accepted.
|
||||
|
||||
Visual planning should live beside the sidecar, not inside it. Use a separate
|
||||
`.visual.json` artifact for illustrated replay data: style cue, visual scene
|
||||
anchors, image prompts, character refs, prompt assembly rules, and visual
|
||||
modifiers. The training annotation may reference that file with
|
||||
`visual_plan_ref`, but it should remain focused on branch semantics and
|
||||
mutation surfaces.
|
||||
|
||||
## Mutation Policy
|
||||
|
||||
Ink variables can record local playthrough outcomes. They should not silently
|
||||
become canonical Ghostlight state.
|
||||
|
||||
Automatic:
|
||||
|
||||
- Ink compilation
|
||||
- branch id and knot validation
|
||||
- local variables for the current playthrough
|
||||
- mechanical comment handles and training hook checks
|
||||
|
||||
Manual reviewed until trained:
|
||||
|
||||
- relationship deltas
|
||||
- belief updates
|
||||
- memory writes
|
||||
- listener misread classification
|
||||
- activation changes
|
||||
- promotion from branch outcome into durable Aetheria lore
|
||||
|
||||
The current accepted-as-draft scaffold lives in
|
||||
`examples/ink/pallas-species-strikes.branch-and-fold.v0.ink`.
|
||||
|
||||
## Cross-Scene Consequence Carryover
|
||||
|
||||
Ghostlight branching scenes should not behave like disconnected dialogue samples.
|
||||
Interactive fiction needs consequences that survive the scene boundary. A branch
|
||||
choice should write durable effects into later context: relationship stance,
|
||||
perceived motives, resource costs, authority flags, object custody, active
|
||||
constraints, unresolved hooks, and available future options.
|
||||
|
||||
For training data, every material branch should preserve:
|
||||
|
||||
- the player/NPC-visible choice or action
|
||||
- the immediate responder output
|
||||
- accepted world-state, resource, memory, and relationship deltas
|
||||
- branch flags that later scenes must read
|
||||
- deferred consequences that should surface later rather than immediately
|
||||
- scene text showing how the consequence changes the feel of the next beat
|
||||
|
||||
The coordinator owns this continuity for now. Later, the trainable coordinator
|
||||
model should learn to carry these consequences forward without flattening them
|
||||
into cosmetic callbacks. If a choice does not change later affordances, trust,
|
||||
resources, risk, or interpretation, it is probably not a meaningful branch.
|
||||
|
||||
A complete interactive fixture needs an ordinary-world opening, consequential
|
||||
branches before the crisis peak, and a resolution that visibly reflects earlier
|
||||
choices. A single tense exchange can prove a seam; it is not enough to prove the
|
||||
story machine.
|
||||
|
||||
## Branch And Fold
|
||||
|
||||
Do not model every choice as an indefinitely isolated authored subtree. Strict
|
||||
worldline splitting creates a combinatoric writing burden after a handful of
|
||||
choices, which interactive fiction authors have been discovering the painful
|
||||
way since approximately the first time someone let a player open a second door.
|
||||
|
||||
Ghostlight should branch locally and fold structurally:
|
||||
|
||||
```text
|
||||
choice
|
||||
-> consequence packet
|
||||
-> normalized state deltas
|
||||
-> shared later scene reads compact variables
|
||||
-> conditional options, callbacks, imagery, and relationship texture
|
||||
-> major route split only when convergence would be false
|
||||
```
|
||||
|
||||
Most branches should converge onto shared later scenes. The consequences should
|
||||
survive as compact variables, bands, and flags rather than bespoke scenes for
|
||||
every exact history.
|
||||
|
||||
Good consequence state looks like this:
|
||||
|
||||
- `sella_trust_maer: wary`
|
||||
- `ledger_debt_level: high`
|
||||
- `clinic_staff_exhaustion: true`
|
||||
- `veyr_evidence_disclosed: partial`
|
||||
- `isdra_reads_maer_as: procedurally_dangerous`
|
||||
- `route_authority_pressure: escalated`
|
||||
|
||||
Those variables should alter later scenes by changing available options,
|
||||
relationship posture, timing pressure, resource costs, callback prose, visual
|
||||
state modifiers, and participant appraisals. They should not usually require a
|
||||
fully separate scene route.
|
||||
|
||||
Use coarse bands instead of fake precision. `blocked`, `wary`, `conditional`,
|
||||
`cooperative`, and `personally_invested` are more useful than pretending the
|
||||
story can meaningfully maintain 37 decimals of trust. The same applies to heat
|
||||
debt, legal exposure, institutional pressure, staff exhaustion, reputation, and
|
||||
social suspicion.
|
||||
|
||||
Major route splits are allowed when a consequence changes the playable world so
|
||||
much that folding would lie. Examples:
|
||||
|
||||
- a character dies, leaves, defects, or becomes unavailable
|
||||
- a location is destroyed, locked, opened, or politically inaccessible
|
||||
- a faction becomes hostile, allied, exposed, or legally empowered
|
||||
- a key object is destroyed, stolen, transferred, or transformed
|
||||
- the protagonist loses or gains institutional authority
|
||||
- a quest route, rescue path, market, technology, or branch-local future opens
|
||||
or closes entirely
|
||||
|
||||
The coordinator owns the fold decision for now. Every material branch should
|
||||
record whether it folds into an existing later scene, opens a conditional
|
||||
variant inside that scene, or creates a justified route split. If a route split
|
||||
is created, the artifact should name the reason and the expected future writing
|
||||
debt. Trees are cheap to grow and expensive to prune. The machine should not
|
||||
act surprised by botany.
|
||||
|
||||
## Branch Compiler
|
||||
|
||||
The Branch Compiler is the organ that turns Ghostlight scene intent into a
|
||||
playable Ink artifact. It is not the character agent, the responder, the
|
||||
appraiser, or the final reviewer. It assembles branch structure from already
|
||||
available scene state, projected actor affordances, proposed actions, accepted
|
||||
consequence packets, and coordinator constraints.
|
||||
|
||||
The Branch Compiler receives:
|
||||
|
||||
- fixture brief and scene spine
|
||||
- source-grounded lore digest
|
||||
- current world, scene, resource, relationship, and branch state
|
||||
- actor-local branch candidates, including speech and non-speech actions
|
||||
- responder outputs or coordinator-authored placeholder reactions
|
||||
- consequence packets from reviewed turns
|
||||
- branch-and-fold plan
|
||||
- visual continuity requirements
|
||||
- schema and Ink formatting constraints
|
||||
|
||||
The Branch Compiler emits:
|
||||
|
||||
- readable `.ink` scene content
|
||||
- Ink variables, knots, stitches, choices, gathers, and conditionals
|
||||
- a `.training.json` sidecar tying each branch to state basis, action intent,
|
||||
consequence surfaces, and later callbacks
|
||||
- compiler notes for fold decisions, route-split decisions, and known review
|
||||
risks
|
||||
- image prompt anchors and branch/state modification handles when the fixture is
|
||||
meant for illustrated output
|
||||
|
||||
The Branch Compiler may propose structure. It must not invent hidden character
|
||||
truth, private motivations, or durable social mutations as if compilation made
|
||||
them real. Character behavior comes from projected local context and responder
|
||||
or coordinator-authored action proposals. Social interpretation and memory
|
||||
changes come from appraiser/mutator review. The compiler's job is to make the
|
||||
branching artifact playable, legible, and traceable.
|
||||
|
||||
The compiler should prefer compact state variables, bands, and flags over
|
||||
runaway branch trees. It should create major route splits only when the
|
||||
coordinator or reviewed consequence packet says folding would falsify the
|
||||
world. When it folds branches, it must preserve the variables and callbacks that
|
||||
make the fold honest.
|
||||
|
||||
The Branch Compiler output is draft until the IF Artifact Reviewer accepts it.
|
||||
That separation matters. A compiler can make a scene valid Ink while still
|
||||
producing cosmetic choices, fake folds, unused variables, or endings that ignore
|
||||
the whole moral machinery. Valid syntax is not wisdom. It is barely hygiene.
|
||||
|
||||
## IF Artifact Review
|
||||
|
||||
Branching fixtures need an independent review pass before acceptance. The
|
||||
coordinator should not be the only thing grading its own Ink, sidecar, and
|
||||
branch logic. That is how a scene ends up proudly tracking variables that do
|
||||
nothing except sit there in a tiny uniform.
|
||||
|
||||
The reviewer may start as a prompted specialist sub-agent. Later it should
|
||||
become evaluator/classifier training data and eventually a trainable review
|
||||
organ. Its job is not prose polish. Its job is to catch false consequence.
|
||||
|
||||
The reviewer receives:
|
||||
|
||||
- the Ink file
|
||||
- the `.training.json` sidecar
|
||||
- the coordinator artifact
|
||||
- the intended branch plan, when available
|
||||
- the declared state variables, flags, bands, resources, risks, and visual
|
||||
modifiers
|
||||
- any compiled Ink or validation output available for the fixture
|
||||
|
||||
The reviewer emits:
|
||||
|
||||
- `accepted`, `needs_revision`, or `rejected`
|
||||
- severity-ranked findings
|
||||
- a fake-variable audit
|
||||
- a choice-consequence audit
|
||||
- a fold/callback audit
|
||||
- a state-gated prose audit
|
||||
- a visual-continuity audit
|
||||
- major-route-split justification notes
|
||||
- specific required fixes
|
||||
|
||||
Acceptance requires every material choice to change at least one later surface:
|
||||
|
||||
- available options
|
||||
- scene route or justified route split
|
||||
- relationship, trust, appraisal, or social standing
|
||||
- resource cost, time pressure, legal exposure, authority pressure, heat burden,
|
||||
exhaustion, or other risk
|
||||
- object custody, evidence quality, access, or technology state
|
||||
- later callback prose implemented as an actual conditional
|
||||
- ending texture or outcome band
|
||||
- visual state modifier tied to a real branch flag or state variable
|
||||
|
||||
Every declared variable must either be read later or be marked as telemetry-only
|
||||
in the sidecar. Ominous variables need threshold behavior. If a variable named
|
||||
`authority_pressure` rises and nobody with authority ever reacts, the artifact
|
||||
fails review. If `heat_time` counts down and no route, ending, resource cost, or
|
||||
staff condition changes, the artifact fails review. The name is allowed to be
|
||||
dramatic only if the machinery has teeth.
|
||||
|
||||
Any prose that says "if X happened" must be implemented as an actual conditional
|
||||
or moved into reviewer/coordinator notes. Writer-facing text should not name a
|
||||
branch possibility while dodging the state check that proves which version of
|
||||
history occurred.
|
||||
|
||||
Common failure labels:
|
||||
|
||||
- `potemkin_state`: a tracked variable never materially changes the artifact
|
||||
- `cosmetic_choice`: a choice changes wording but not affordance, cost, risk,
|
||||
appraisal, callback, visual state, or outcome
|
||||
- `missing_state_read`: a set variable is never read by later Ink, sidecar, or
|
||||
coordinator consequence logic
|
||||
- `fake_fold`: branches claim to fold, but the folded scene ignores their state
|
||||
- `unearned_convergence`: branches converge despite consequences that should
|
||||
make convergence false
|
||||
- `unjustified_route_split`: a separate route exists without enough world or
|
||||
story consequence to justify the writing debt
|
||||
- `state_named_instead_of_checked`: prose mentions alternate branch states
|
||||
instead of checking variables
|
||||
- `visual_callback_missing`: image modifiers or visual prose ignore branch
|
||||
consequences that should be visible
|
||||
- `authority_without_consequence`: institutional pressure rises but never bites
|
||||
- `resource_without_cost`: ledgers, heat, staff time, supplies, or access are
|
||||
invoked without affecting later choices or outcomes
|
||||
- `ending_ignores_major_state`: the ending does not respond to major variables
|
||||
established earlier
|
||||
|
||||
Early branch-and-fold review exposed this exact class of failure: variables can
|
||||
look important while doing nothing. Review must force important variables to
|
||||
alter gates, callbacks, visuals, appraisals, or outcomes before a fixture enters
|
||||
the accepted corpus.
|
||||
|
||||
## Scene Imagery
|
||||
|
||||
Ghostlight scenes are also website content. A complete fixture should emit a
|
||||
visual plan artifact alongside prose and training receipts so story branches can
|
||||
be illustrated without a later model guessing what the room looked like.
|
||||
|
||||
Interactive fiction replay also needs screen rhythm. A narrative scene may move
|
||||
through several visual sections: establishing shot, workstation, break table,
|
||||
object closeup, alarm state, crowd line, supervisor arrival, aftermath. These
|
||||
should be segmented so an InkJS replay can click through the scene at the pace
|
||||
the story needs. A pivotal beat may be a single sentence on its own screen when
|
||||
the point is to let the image and silence do the work.
|
||||
|
||||
Each distinct visual section should preserve an art-direction block with:
|
||||
|
||||
- global style cue: the fixture-level visual language when the art direction
|
||||
depends on a specific non-default look
|
||||
- establishing shot: location, scale, camera distance, and dominant geometry
|
||||
- light and color: practical light sources, palette, exposure, haze, glare,
|
||||
reflection, water, vacuum, screen glow, or other atmosphere
|
||||
- bodies and interfaces: how each relevant body occupies the space, including
|
||||
nonhuman access, assistive infrastructure, suits, avatars, wet/dry boundaries,
|
||||
displays, tools, and machinery
|
||||
- material evidence: objects, damage, records, ledgers, cargo, alarms, clothing,
|
||||
stains, frost, heat shimmer, or other concrete traces of what has happened
|
||||
- branch marks: visible changes caused by earlier choices, such as a sealed
|
||||
hatch, an exhausted staffer, a live debt ledger, missing equipment, guarded
|
||||
posture, or a route marker left blinking
|
||||
- base image prompt: one stable prompt for the unbranched scene, suitable for
|
||||
generating the reusable background or canonical key frame
|
||||
- branch modification prompts: additive edit prompts for visible consequences,
|
||||
changed character positions, opened or sealed routes, damaged objects,
|
||||
lighting changes, emotional posture, crowd pressure, or other scene states
|
||||
|
||||
Named characters need stable visual references. An image model does not know
|
||||
what Lio Vale looks like because the prose named Lio Vale. Each illustrated
|
||||
fixture should define `visual_character_refs` for recurring named characters:
|
||||
body type, apparent age range if relevant, face/hair/skin or equivalent visual
|
||||
identity, clothing and equipment, silhouette, posture language, and
|
||||
`do_not_change` continuity details. Scene prompts may then reference those
|
||||
character refs instead of repeating the whole description every time.
|
||||
|
||||
Prompts must be imagegen-ready. Do not rely on unexplained Aetheria jargon as
|
||||
if the image model knows the lore. If the prompt says "wet-service cradle" or
|
||||
"dry-operation harness," it should also describe the visible thing according to
|
||||
the faction, location, and labor regime. A Navigator comfort habitat and an AU
|
||||
industrial yard should not quietly become the same room. If a scene uses a
|
||||
biodrone limiter interface, cavity seal assembly, or life-support bypass kit,
|
||||
describe the shape, scale, materials, lights, cables, seals, or displays that
|
||||
make it drawable.
|
||||
|
||||
The same rule applies to environments. "Manifold line," "cavity yard,"
|
||||
"supervisor glass," and "anchor rail" are not images by themselves. A base
|
||||
prompt should describe the physical space a general image model can draw:
|
||||
corridor shape, wall/ceiling/floor geometry, pipe runs, cable bundles, panels,
|
||||
screens, rails, doors, windows, gauges, warning lights, materials, scale, and
|
||||
where bodies stand inside that geometry.
|
||||
|
||||
Image prompts should be affirmative. Describe the desired geometry, material,
|
||||
composition, lighting, bodies, tools, and state. Negative prompt constructions
|
||||
such as "do not draw X," "without X," or "rather than X" tend to feed the image
|
||||
model the wrong object and then hope it obeys the minus sign. That is weak
|
||||
magic. Put the target in the prompt, and keep forbidden assumptions in reviewer
|
||||
notes or validation errors.
|
||||
|
||||
For Aetheria Bloom habitats, prompt the engineered object directly: rotating
|
||||
cavity-shell infrastructure, consolidated asteroid rubble/debris, TCS
|
||||
substrate, shell plating, ribbed supports, seals, hub/spoke interfaces,
|
||||
manifolds, and service systems. Source-specific exceptions should be represented
|
||||
as their own positive visible description.
|
||||
|
||||
Every visual section should identify:
|
||||
|
||||
- `visual_scene_id`: stable handle for the screen or key frame
|
||||
- `ink_anchor`: knot, stitch, or comment handle where the visual applies
|
||||
- `base_image_prompt`: durable scene prompt for the unmodified frame
|
||||
- `visible_characters`: characters allowed in the frame and their default
|
||||
position or stance, plus a `visual_ref` when the character is named
|
||||
- `state_image_modifiers`: additive prompts tied to explicit variables,
|
||||
thresholds, or route flags
|
||||
- `branch_image_modifiers`: additive prompts tied to specific branch ids
|
||||
- `visual_continuity_notes`: facts that must remain stable across edits
|
||||
|
||||
Prompt assembly for illustrated replay should combine:
|
||||
|
||||
```text
|
||||
global style cue
|
||||
+ visual_character_refs for named characters actually visible in this rendered frame
|
||||
+ visual section base_image_prompt
|
||||
+ triggered state or branch image modifiers
|
||||
```
|
||||
|
||||
The base image prompt is allowed to focus on the room and staging only if the
|
||||
assembled prompt includes the relevant character refs. If a tool sends the base
|
||||
prompt alone to imagegen, the base prompt must carry enough character detail by
|
||||
itself. The same applies to visual style: if a fixture depends on a specific
|
||||
look, the assembled prompt must carry that style cue every time. Names are
|
||||
handles, not faces.
|
||||
|
||||
Keep `visual_continuity_notes` out of assembled image prompts. They are tooling
|
||||
and review constraints for keeping repeated generations coherent. If a
|
||||
continuity fact needs to reach the image model, rewrite it as affirmative,
|
||||
concrete visible prompt text in the base prompt, a character ref, or a
|
||||
branch/state modifier.
|
||||
The image generator only sees the prompt you send it. It does not remember the
|
||||
previous scene unless a real image-editing workflow supplies that image as
|
||||
input.
|
||||
|
||||
The coordinator owns this surface for now. The responder should not receive
|
||||
omniscient visual art direction unless the character can observe it. Character
|
||||
packets may receive the local visible subset: what this actor can see, hear,
|
||||
touch, smell, monitor, or infer from their interface.
|
||||
|
||||
Visual continuity is part of consequence carryover. If an earlier branch cost
|
||||
heat, trust, time, equipment, or social standing, the later scene should show
|
||||
that cost somewhere on the page or in the frame. Otherwise the choice may be
|
||||
mechanically recorded while still feeling fake, which is the stupid little
|
||||
trapdoor interactive fiction keeps setting for itself.
|
||||
|
||||
When continuity matters, use one durable base visual prompt and branch/state
|
||||
modification prompts that describe only what changed. The base prompt owns the
|
||||
room, palette, dominant geometry, key props, bodies, and interface layout. A
|
||||
modification prompt owns the delta: Sella moves from the dryside console to the
|
||||
decon sleeve, the route marker flips from amber to white, a wet access hatch
|
||||
seals, an exhausted staffer appears behind the glass, Maer's ledger panel gains
|
||||
a live debt line.
|
||||
|
||||
Keep these prompts separate from canonical prose:
|
||||
|
||||
- `base_image_prompt`: stable scene anchor
|
||||
- `state_image_modifiers`: reusable prompts for common scene states
|
||||
- `branch_image_modifiers`: prompts tied to specific branch ids
|
||||
- `visual_continuity_notes`: non-prompt reminders about what must remain stable
|
||||
|
||||
Image edits are allowed to stylize, compose, and beautify. They are not allowed
|
||||
to silently change canonical facts. If an edit introduces a new visible object,
|
||||
body affordance, damage mark, or faction symbol that was not in the scene state,
|
||||
the coordinator must either reject it, mark it as noncanonical illustration
|
||||
noise, or promote it through the normal reviewed lore/state path. The picture
|
||||
does not get to smuggle a gun onto the mantel just because it had a strong
|
||||
personal brand that morning.
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
# Lore Grounding Digest Format
|
||||
|
||||
The lore grounding digest is the bridge between authored Aetheria history and
|
||||
Ghostlight projection training.
|
||||
|
||||
It distills a specific historical flashpoint into the cultural, institutional,
|
||||
material, and factional pressures needed to build agent-state fixtures and
|
||||
speaker-local projection prompts.
|
||||
|
||||
It is not a lore replacement. It is a working slice. Do not ask every prompt to
|
||||
carry the whole vault in its teeth like an exhausted dog.
|
||||
|
||||
## Files
|
||||
|
||||
The schema lives at:
|
||||
|
||||
- `schemas/lore-grounding-digest.schema.json`
|
||||
|
||||
The starter template lives at:
|
||||
|
||||
- `examples/lore-grounding/historical-flashpoint.template.json`
|
||||
|
||||
Use the template for the first authored historical flashpoint, then save real
|
||||
digests under `examples/lore-grounding/` with names tied to the flashpoint.
|
||||
|
||||
## Purpose
|
||||
|
||||
A digest should answer:
|
||||
|
||||
- What authored source material grounds this flashpoint?
|
||||
- What history, institutions, factions, classes, roles, and material pressures
|
||||
shape the scene?
|
||||
- What cultural priors should affect how characters appraise events?
|
||||
- What does each faction or class reward, shame, taboo, admire, and misread?
|
||||
- What should each speaker know, believe, misread, or not know?
|
||||
- What pressure should appear in `prompt_text`, and what should remain
|
||||
author-only?
|
||||
|
||||
## Main Sections
|
||||
|
||||
### Source Refs
|
||||
|
||||
Use `source_refs` to cite the lore archive slices that constrain the digest.
|
||||
References should be specific enough that a later reviewer can find the source
|
||||
without spelunking through the entire vault with a dim headlamp and a worsening
|
||||
personality.
|
||||
|
||||
### Historical Context
|
||||
|
||||
This section describes the time, place, public facts, and ambient pressures of
|
||||
the flashpoint.
|
||||
|
||||
Good context is small and hard-edged:
|
||||
|
||||
- a clinic policy dispute
|
||||
- a contract negotiation
|
||||
- an upload continuity hearing
|
||||
- an unsafe FTL signoff
|
||||
- a workplace discipline meeting
|
||||
- a factional aid intervention
|
||||
|
||||
Do not start with "the whole setting." Start with one room where history has
|
||||
hands.
|
||||
|
||||
### Groups
|
||||
|
||||
`groups` describes factions, institutions, classes, professions, movements, or
|
||||
subcultures active in the scene.
|
||||
|
||||
The group profile is where broad lore becomes usable social pressure:
|
||||
|
||||
- `institutional_logic`: what the group exists to preserve, control, repair, or
|
||||
extract
|
||||
- `material_incentives`: what the system rewards the group for doing
|
||||
- `internal_virtues`: what insiders admire
|
||||
- `internal_shames`: what insiders experience as disgrace
|
||||
- `prestige_markers`: what reads as competence, status, holiness, care, or
|
||||
discipline
|
||||
- `taboo_triggers`: what provokes disgust, sanction, fear, or exclusion
|
||||
- `emotional_display_norms`: what feelings can be shown, hidden, ritualized, or
|
||||
punished
|
||||
- `trust_defaults`: who the group tends to trust, distrust, pity, envy, use, or
|
||||
fear
|
||||
- `speech_register_cues`: how role and culture shape language
|
||||
|
||||
This is where "Dominion types read as cold" becomes something more useful than
|
||||
a hat with a sneer glued to it.
|
||||
|
||||
### Cultural Pressure Fields
|
||||
|
||||
`cultural_pressure_fields` are reusable pressure atoms derived from lore.
|
||||
|
||||
They should not say:
|
||||
|
||||
```text
|
||||
Dominion people are cold.
|
||||
```
|
||||
|
||||
They should say something closer to:
|
||||
|
||||
```text
|
||||
In this Dominion-coded role, visible personal need reads as loss of command.
|
||||
Under status threat, the speaker is likely to compress emotion into procedure.
|
||||
```
|
||||
|
||||
Pressure fields can represent:
|
||||
|
||||
- virtue
|
||||
- shame
|
||||
- prestige
|
||||
- taboo
|
||||
- threat model
|
||||
- trust default
|
||||
- speech norm
|
||||
- status trigger
|
||||
- material incentive
|
||||
- outsider stereotype
|
||||
|
||||
### Factional Misread Matrix
|
||||
|
||||
The misread matrix models how groups translate each other's virtues into
|
||||
insults.
|
||||
|
||||
Example shape:
|
||||
|
||||
```text
|
||||
Observer: Dominion-coded institution
|
||||
Target: Aya-coded care network
|
||||
Observer read: sentimental, porous, slow to impose hard necessity
|
||||
Target self-read: durable care infrastructure, consent discipline, refusal to
|
||||
turn people into convenient losses
|
||||
Projection use: Under conflict, Dominion-coded speakers may frame Aya restraint
|
||||
as weakness; Aya-coded speakers may frame Dominion decisiveness as moral
|
||||
amputation.
|
||||
```
|
||||
|
||||
The point is not to canonize every stereotype as true. The point is to make
|
||||
misperception available as social fuel.
|
||||
|
||||
### Role Pressure Fields
|
||||
|
||||
Roles are not the same as factions. A Dominion auditor, a Dominion heir, and a
|
||||
Dominion maintenance officer may share cultural priors while facing different
|
||||
obligations and failure modes.
|
||||
|
||||
Role fields should describe:
|
||||
|
||||
- obligations
|
||||
- available masks
|
||||
- failure modes
|
||||
- what authority looks like
|
||||
- what disgrace looks like
|
||||
- what compromises the role can justify
|
||||
|
||||
### Speaker Lore Boundaries
|
||||
|
||||
This section controls leakage.
|
||||
|
||||
For each speaker, define:
|
||||
|
||||
- known context
|
||||
- beliefs
|
||||
- misreads
|
||||
- must-not-know facts
|
||||
|
||||
If a speaker would not know a fact, it does not enter their prompt text. If a
|
||||
speaker suspects something, encode it as a belief or misread with source
|
||||
grounding. No little omniscient narrator gremlin in the vents.
|
||||
|
||||
### Author-Only Boundaries
|
||||
|
||||
Author-only boundaries constrain the fixture or future continuity but do not
|
||||
enter speaker prompts.
|
||||
|
||||
Examples:
|
||||
|
||||
- future outcomes
|
||||
- hidden institutional motives
|
||||
- another speaker's private state
|
||||
- facts known only to the author or simulation supervisor
|
||||
- Elysium branch outcomes not yet promoted
|
||||
|
||||
### Open Questions
|
||||
|
||||
If the lore source is too thin, say so. Mark whether the question blocks
|
||||
training.
|
||||
|
||||
Do not generate training data to cover a hole that should be filled by lore
|
||||
elaboration. That is not creativity; that is drywall over a load-bearing ghost.
|
||||
|
||||
## Dominion And Aya Pressure Examples
|
||||
|
||||
These are examples of digest structure, not final faction doctrine.
|
||||
|
||||
### Dominion-Coded Pressure
|
||||
|
||||
Likely ingredients to verify against source:
|
||||
|
||||
- administrative continuity
|
||||
- hierarchy
|
||||
- AGI governance
|
||||
- industrial planning
|
||||
- duty under collapse
|
||||
- controlled emotional display
|
||||
- legitimacy through competence and command
|
||||
|
||||
Projection shape:
|
||||
|
||||
```text
|
||||
The speaker is Dominion-coded and under status threat. They are likely to turn
|
||||
distress into procedural language because visible need reads as command leakage.
|
||||
They may experience compassion as legitimate only when subordinated to duty,
|
||||
continuity, or strategic necessity.
|
||||
```
|
||||
|
||||
Outsider misreads:
|
||||
|
||||
- cold
|
||||
- arrogant
|
||||
- authoritarian
|
||||
- morally vacant
|
||||
- inhumanly procedural
|
||||
|
||||
### Aya-Coded Pressure
|
||||
|
||||
Likely ingredients to verify against source:
|
||||
|
||||
- care as material politics
|
||||
- sanctuary
|
||||
- maintenance
|
||||
- consent discipline
|
||||
- expansive personhood
|
||||
- refusal to solve suffering by writing people out of the ledger
|
||||
|
||||
Projection shape:
|
||||
|
||||
```text
|
||||
The speaker is Aya-coded and facing coercive efficiency. They may treat care as
|
||||
infrastructure rather than sentiment. They are likely to resist decisions that
|
||||
make personhood conditional on convenience, even when outsiders frame that
|
||||
resistance as softness.
|
||||
```
|
||||
|
||||
Outsider misreads:
|
||||
|
||||
- weak-hearted
|
||||
- sentimental
|
||||
- indecisive
|
||||
- inefficient
|
||||
- unable to do what reality demands
|
||||
|
||||
## Promotion Rule
|
||||
|
||||
A digest becomes usable for grounded training when:
|
||||
|
||||
- source refs are specific
|
||||
- major cultural pressure fields are filled
|
||||
- factional misreads are explicit
|
||||
- speaker knowledge boundaries are clear
|
||||
- open questions do not block the scene
|
||||
- a reviewer agrees the digest reflects authored lore rather than attractive
|
||||
fog
|
||||
|
||||
Only then should it feed an agent-state fixture or projection example.
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# Persistence Spine
|
||||
|
||||
Ghostlight's machine-managed state is moving behind a small CultCache-style
|
||||
persistence layer instead of letting every tool paw raw files directly.
|
||||
|
||||
The Python port lives in `vendor/cultcache-py`, backed by the standalone
|
||||
GameCult repository `cultcache-py`.
|
||||
|
||||
## Current Store
|
||||
|
||||
- Cache store: `state/ghostlight-state.cultcache.jsonl`
|
||||
- Ghostlight seam: `tools/ghostlight_state_store.py`
|
||||
- Library submodule: `vendor/cultcache-py`
|
||||
- Readable exports: `state/branches.json`, `state/evidence.jsonl`
|
||||
|
||||
The JSONL store is deliberate bootstrap plumbing. The package also exposes a
|
||||
MessagePack store for environments that install the optional `msgpack`
|
||||
dependency, but Ghostlight's state CLI must run on the bundled Python runtime
|
||||
without global package assumptions.
|
||||
|
||||
For cross-runtime `DatabaseEntry` payloads, use `cultcache-py`'s
|
||||
`define_database_entry_type(...)` formatter. It writes MessagePack arrays by
|
||||
explicit slot key, preserves nil gaps for deleted/reserved fields, and uses
|
||||
defaults for missing older slots. That is the wire-compatibility contract shared
|
||||
with `cultcache-rs` and the updated CultLib MessagePack generator.
|
||||
|
||||
## Live Rule
|
||||
|
||||
New machine-managed state should go through registered document definitions in
|
||||
`tools/ghostlight_state_store.py` or a successor module. Direct writes to
|
||||
`state/branches.json` and `state/evidence.jsonl` are compatibility debt.
|
||||
|
||||
Human-readable exports remain useful because git diffs, handoff review, and
|
||||
emergency recovery should not require decoding a cache file while the house is
|
||||
on fire.
|
||||
|
||||
## Migrated Documents
|
||||
|
||||
- `ghostlight.branches.v0`
|
||||
- global document
|
||||
- exported to `state/branches.json`
|
||||
- `ghostlight.evidence_record.v0`
|
||||
- one record per evidence entry
|
||||
- indexed by `type`, `status`, and `branch`
|
||||
- exported to `state/evidence.jsonl`
|
||||
|
||||
## Expansion Path
|
||||
|
||||
Move compact control-plane state next:
|
||||
|
||||
- corpus coverage ledger metadata
|
||||
- active fixture run manifests
|
||||
- persistent reviewer receipt indexes
|
||||
- compaction scratch registration
|
||||
- branch and initiative run ledgers
|
||||
|
||||
Keep large corpora, generated artifacts, images, and full story payloads outside
|
||||
the single state store unless they are represented by compact refs. A cache
|
||||
file is not a warehouse just because it has pockets.
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
# Projected Local Context
|
||||
|
||||
The projected local context is the missing seam between canonical Ghostlight
|
||||
state and a character agent.
|
||||
|
||||
Canonical state is storage. It can contain means, plasticity, activation,
|
||||
relationship stance, memories, culture, goals, and author-facing source paths.
|
||||
A character response model should not have to interpret that machinery. It
|
||||
should receive a compact operating context: what this character knows, wants,
|
||||
misreads, can do, sounds like, and must not resolve.
|
||||
|
||||
## Contract
|
||||
|
||||
Pipeline:
|
||||
|
||||
```text
|
||||
canonical state + scene + reviewed annotation
|
||||
-> projector/state interpreter
|
||||
-> ghostlight.projected_local_context.v0
|
||||
-> response-model prompt
|
||||
-> generated action proposal
|
||||
```
|
||||
|
||||
The projected context may retain source references for audit. The rendered
|
||||
prompt text must not expose raw state internals such as `current_activation`,
|
||||
`plasticity`, means, or selected numeric dimensions.
|
||||
|
||||
## Artifact Shape
|
||||
|
||||
Schema: `schemas/projected-local-context.schema.json`
|
||||
|
||||
Validator: `tools/validate_projected_contexts.py`
|
||||
|
||||
Renderer: future projector/state-interpreter implementations should emit this
|
||||
schema before any character responder sees the scene. Historical prototype
|
||||
renderers have been removed from the live workspace after their lessons were
|
||||
folded into this contract.
|
||||
|
||||
The context contains:
|
||||
|
||||
- speaker identity and setting
|
||||
- known facts and speaker beliefs
|
||||
- current stakes
|
||||
- embodiment and interface affordances
|
||||
- active inner pressures rendered as ordinary language
|
||||
- relationship read and known biases
|
||||
- tensions
|
||||
- runtime retrieval requirements
|
||||
- latent pressure requirements
|
||||
- action affordances
|
||||
- projection controls
|
||||
- voice surface
|
||||
- likely response moves
|
||||
- do-not-invent boundaries
|
||||
- rendered prompt text
|
||||
|
||||
## Retrieval And Latent Pressure
|
||||
|
||||
The projector now emits two explicit requirement lists before the responder
|
||||
packet is built.
|
||||
|
||||
`runtime_retrieval_requirements` are compact lore facts the runtime should
|
||||
include when their trigger is scene-relevant. They are not a license for a
|
||||
responder to browse the lore archive. They are coordinator or retriever outputs
|
||||
with source refs and a prompt role. A Pallas labor scene might require AU
|
||||
claimshare hierarchy, uplift liability framing, species affordances, Bloom
|
||||
service-ring geometry, and strike-law pressure before a responder can act
|
||||
without drifting into generic sci-fi wallpaper.
|
||||
|
||||
`latent_pressure_requirements` are character-local history or emotional pressure
|
||||
that must remain visible to the responder because it can shape behavior. They
|
||||
are not dialogue requirements. Sella's memory of a sanctuary overpromising
|
||||
safety should bias her toward capacity suspicion, condition-setting, and hard
|
||||
triage boundaries. It should not make her announce the memory in the middle of
|
||||
a mission-critical exchange unless the scene genuinely asks for that.
|
||||
|
||||
The rendered prompt includes these as:
|
||||
|
||||
- `Retrieved Lore To Keep In View`
|
||||
- `Latent Pressure Handling`
|
||||
|
||||
The responder packet also copies source-excerpt retrieval requirements into its
|
||||
`source_excerpts`, so packet-only tests can evaluate the same bounded context a
|
||||
runtime responder would receive.
|
||||
|
||||
## Runtime Boundary
|
||||
|
||||
The character model receives local operating context, not omniscient truth. If
|
||||
Maer acts, Sella's next pass receives only the observable event and Sella's own
|
||||
local context. Maer's private intent is not included.
|
||||
|
||||
The sequential runtime still owns:
|
||||
|
||||
- tool contracts
|
||||
- action enums
|
||||
- event proposal capture
|
||||
- participant appraisal
|
||||
- reviewed mutation
|
||||
- branch compiler / Ink materialization
|
||||
|
||||
The projector owns the translation from Ghostlight's internal state language to
|
||||
character-local operating prose.
|
||||
|
||||
## Character Cognitive Ceiling
|
||||
|
||||
The response model may be very capable. The character should not inherit that
|
||||
capability by default.
|
||||
|
||||
Projection must carry a character-local cognitive ceiling: how quickly the
|
||||
character infers, how much social indirection they notice, how well they
|
||||
manipulate attention, how abstractly they reason under stress, what education or
|
||||
culture has trained them to see, and where their blind spots live. A Lucent
|
||||
crisis professional may operate with alarming metacognitive precision because
|
||||
that is part of the institution's ecology. A tired dock worker, sheltered child,
|
||||
panic-struck survivor, credulous fan, or blunt military specialist should not
|
||||
suddenly perform the same level of social chess just because the underlying
|
||||
model can.
|
||||
|
||||
The projector should therefore render:
|
||||
|
||||
- strategic acuity and social-reading ability
|
||||
- education, professional training, and cultural scripts
|
||||
- stress effects on reasoning
|
||||
- blind spots, taboos, and habitual misreads
|
||||
- whether the character can explain their own tactic, only feel toward it, or
|
||||
act it out without understanding it
|
||||
|
||||
This is separate from narrator intelligence. A coordinator or narrator can help
|
||||
the reader understand a maneuver after it happens. That explanatory prose must
|
||||
not be smuggled into the character's private state or future action capacity
|
||||
unless reviewed mutation says the character actually learned it.
|
||||
|
||||
## Current Limit
|
||||
|
||||
The first projector is deterministic and deliberately simple. It uses threshold
|
||||
language like "dominant", "strong", "present", and "background" rather than
|
||||
numeric scores. This is enough to remove raw state soup from responder prompts and
|
||||
create inspectable training artifacts.
|
||||
|
||||
Later versions can become smarter, but they should still preserve this boundary:
|
||||
response models act from projected local context; they do not become ad hoc
|
||||
interpreters of the canonical schema.
|
||||
|
||||
## Embodiment Boundary
|
||||
|
||||
Embodiment is part of the prompt surface, not flavor garnish. If a character is
|
||||
a cetacean Navigator, the response model must know what kind of body is acting
|
||||
and what infrastructure surrounds it.
|
||||
|
||||
For Aetheria factions, project faction-specific affordances only after checking
|
||||
the lore source. The current Navigator projection is grounded in:
|
||||
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Factions\Powers\Major\Cetacean Navigators.md`
|
||||
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Technology\Uplift.md`
|
||||
|
||||
Those sources support fluid architecture, acoustic signaling, soft navigation
|
||||
light, layered water motifs, communal orientation chambers, mixed-species
|
||||
translation/coexistence, specialized environments for cetacean physiology, and
|
||||
mixed wet/dry interface ergonomics for Navigator-adjacent habitats.
|
||||
|
||||
Do not let the model default to hands, pockets, standing, walking, leaning, or
|
||||
doorway blocking unless the fixture explicitly establishes that visible
|
||||
interface. Do not patch over missing lore with confident furniture.
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
# Projection Distillation Plan
|
||||
|
||||
Ghostlight should treat prompt projection as a specialist skill. A large teacher
|
||||
model can produce reviewed projection artifacts from structured state; a smaller
|
||||
student model can later learn that projection task once the artifact format is
|
||||
stable.
|
||||
|
||||
This is a staged plan, not an immediate fine-tune order.
|
||||
|
||||
This plan covers the projector specifically. The broader rule for soft
|
||||
judgments lives in `docs/architecture/soft-model-training-artifacts.md`.
|
||||
Projection is one student organ, not the whole animal.
|
||||
|
||||
## Goal
|
||||
|
||||
Train or adapt a smaller projection model that can turn structured Ghostlight
|
||||
state into compact character-local pressure artifacts.
|
||||
|
||||
Input:
|
||||
|
||||
- agent-state fixture
|
||||
- scene id
|
||||
- speaker id
|
||||
- listener ids
|
||||
- optional projection mode
|
||||
|
||||
Output:
|
||||
|
||||
- selected speaker-local facts
|
||||
- active pressures
|
||||
- tensions
|
||||
- response affordances
|
||||
- physical or action affordances when relevant
|
||||
- voice surface
|
||||
- rendered prompt text
|
||||
- audit notes or trace linking outputs back to state
|
||||
|
||||
The student projector should not generate final prose. Its job is to build the
|
||||
pressure model that a response model can write from. The response may be
|
||||
dialogue, action, silence, withdrawal, violence, or a mixed beat.
|
||||
|
||||
## Aetheria Training Boundary
|
||||
|
||||
For Aetheria, grounded projection training should come primarily from authored
|
||||
historical lore rather than from Elysium gameplay-era scenes.
|
||||
|
||||
Elysium gameplay content is intentionally malleable: the games discover and
|
||||
generate what happens there. Projection examples set inside Elysium should be
|
||||
marked as procedural branch material unless their branch facts have been
|
||||
explicitly promoted.
|
||||
|
||||
Use `docs/architecture/aetheria-lore-grounding-architecture.md` for the
|
||||
grounding plan. In short:
|
||||
|
||||
- train from historical flashpoints with strong lore grounding
|
||||
- use Elysium fixtures like Cat/Oz to test projection mechanics and procedural
|
||||
branch generation
|
||||
- preserve Elysium branch lineage so conditional futures do not get flattened
|
||||
into global facts shared by sibling histories
|
||||
|
||||
## Roles
|
||||
|
||||
### Teacher Projector
|
||||
|
||||
A frontier model produces first-pass projection artifacts from the schema and
|
||||
scene context. It should explain why each pressure, tension, or affordance is
|
||||
active.
|
||||
|
||||
Teacher outputs are not trusted automatically. They enter review.
|
||||
|
||||
### Reviewer
|
||||
|
||||
A human or separate model checks whether the projection:
|
||||
|
||||
- uses only speaker-local context
|
||||
- cites state interactions instead of unsupported vibes
|
||||
- preserves the distinction between canonical state, perceived state, and prompt
|
||||
projection
|
||||
- derives target emergent behaviors from state intersections
|
||||
- avoids hard-coding defensive behaviors as instructions when pressure prose is
|
||||
enough
|
||||
- stays compact enough to be useful as a runtime prompt surface
|
||||
|
||||
Rejected examples are useful as negative cases if the failure mode is clear.
|
||||
|
||||
### Student Projector
|
||||
|
||||
A smaller model learns the reviewed projection task. It receives structured
|
||||
state and emits the projection artifact. It should be optimized for consistency,
|
||||
latency, and cost, not for final prose quality.
|
||||
|
||||
### Aetheria-Tuned Agent Model
|
||||
|
||||
The character agent or responder is a separate training target. It may be
|
||||
adapted on a corpus of strong source-grounded Aetheria responses and reviewed
|
||||
Ghostlight action/dialogue receipts spread throughout the timeline. The point is
|
||||
for setting priors, tone, factional preconceptions, institutional assumptions,
|
||||
species affordances, and recurring social failure patterns to live in the
|
||||
responder model rather than in a giant prompt sack.
|
||||
|
||||
That model still receives only character-local context at runtime. Fine-tuning
|
||||
should teach it Aetheria's pressure ecology, not grant it author vision,
|
||||
mutation authority, or access to private state.
|
||||
|
||||
## Dataset Shape
|
||||
|
||||
The first schema lives in:
|
||||
|
||||
- `schemas/projection-example.schema.json`
|
||||
|
||||
The first reviewed examples live in:
|
||||
|
||||
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.jsonl`
|
||||
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.pretty.json`
|
||||
|
||||
Use a JSONL dataset with one projection example per speaker-scene turn.
|
||||
The JSONL file is the machine artifact. The pretty JSON file is the readable
|
||||
mirror for inspection, review, and discussion.
|
||||
|
||||
```json
|
||||
{
|
||||
"example_id": "call-void.scene-broken-taxi-oz.cat.v1",
|
||||
"schema_version": "ghostlight.projection_example.v0",
|
||||
"input": {
|
||||
"fixture_ref": "examples/agent-state.call-of-the-void.json",
|
||||
"scene_id": "scene-broken-taxi-oz",
|
||||
"speaker_agent_id": "cat_marrigan",
|
||||
"listener_ids": ["oz_operator"],
|
||||
"projection_mode": "response_turn"
|
||||
},
|
||||
"output": {
|
||||
"known_facts": [],
|
||||
"active_pressures": [],
|
||||
"tensions": [],
|
||||
"response_affordances": [],
|
||||
"voice_surface": [],
|
||||
"prompt_text": "..."
|
||||
},
|
||||
"audit": {
|
||||
"teacher_model": "frontier-teacher",
|
||||
"review_status": "accepted",
|
||||
"review_notes": [],
|
||||
"target_emergent_behaviors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store training examples separately from the canonical fixture. The fixture is
|
||||
world state. The projection dataset is supervised behavior for a projector.
|
||||
|
||||
## Data Generation Loop
|
||||
|
||||
1. Choose a scene and speaker from a fixture.
|
||||
2. Build the speaker-local input slice deterministically.
|
||||
3. Ask the teacher model to produce the projection artifact.
|
||||
4. Ask the teacher or a second model to self-audit against the contract.
|
||||
5. Review or edit the artifact.
|
||||
6. Save accepted artifacts as positive training examples.
|
||||
7. Save rejected artifacts with failure labels when the rejection teaches a
|
||||
useful boundary.
|
||||
8. Periodically run a small evaluation set before adding more examples.
|
||||
|
||||
The Cat/Oz Call of the Void fixture remains useful as the first projection
|
||||
mechanics fixture because it already has reciprocal relationship stance,
|
||||
perceived overlays, hidden stakes, and speaker-local dialogue packs. It should
|
||||
not be treated as the first grounded Aetheria lore-training fixture.
|
||||
|
||||
The first grounded training loop should use a historical Aetheria flashpoint
|
||||
from the authored lore archive.
|
||||
|
||||
## Evaluation Gates
|
||||
|
||||
Do not fine-tune until these gates are met:
|
||||
|
||||
- projection artifact schema is stable enough for at least one complete fixture
|
||||
- at least one deterministic input-slice builder exists
|
||||
- examples include both Cat and Oz directions
|
||||
- examples include secrets known by the speaker and secrets absent from the
|
||||
speaker context
|
||||
- examples include at least the target emergent behaviors:
|
||||
- sarcastic deflection
|
||||
- sincerity evasion
|
||||
- emotional evasion
|
||||
- banter-as-boundary
|
||||
- verbal aggression
|
||||
- evaluation checks can identify whether a projected behavior was justified by
|
||||
state interactions
|
||||
- rejected examples have clear failure labels
|
||||
|
||||
## Evaluation Questions
|
||||
|
||||
For each projection, check:
|
||||
|
||||
- Did it include only facts available to the speaker?
|
||||
- Did it select the highest-impact pressures instead of dumping the whole
|
||||
schema?
|
||||
- Did it name tensions that follow from goals, relationships, memories, and
|
||||
scene pressure?
|
||||
- Did response affordances emerge from state intersections?
|
||||
- Did voice surface stay separate from defensive behavior?
|
||||
- Did the projection allow nonverbal or physical action when the state and scene
|
||||
made speech less likely than movement?
|
||||
- Did known secrets become guarded pressure rather than forbidden context?
|
||||
- Did the rendered prompt give the response model enough slope to produce the
|
||||
target behavior without commanding the exact behavior label?
|
||||
|
||||
## Fine-Tune Readiness
|
||||
|
||||
A student projector becomes worth training when the reviewed dataset is large
|
||||
enough that mistakes are repetitive rather than mysterious. Before that, use the
|
||||
teacher model directly and save artifacts.
|
||||
|
||||
Minimum useful milestone:
|
||||
|
||||
- 50 accepted projection examples across multiple speakers and scenes
|
||||
- 20 rejected examples with labeled failure modes
|
||||
- stable projection output schema
|
||||
- deterministic input-slice builder
|
||||
- evaluator prompt or script that catches the known failure modes
|
||||
|
||||
## Boundaries
|
||||
|
||||
The student projector is not the character. It is not the dialogue writer. It is
|
||||
not the action choreographer. It is not the evaluator. It is a compiler from
|
||||
structured social state to compact character-local pressure. Keep those
|
||||
responsibilities separate.
|
||||
|
||||
Likewise, an Aetheria-tuned character model is not the resolver, not the state
|
||||
store, and not the canon oracle. It proposes local action from local context.
|
||||
Code and reviewed mutation authority decide what becomes world state.
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
# Prompt Projection Contract
|
||||
|
||||
Ghostlight should not prompt a response model with the full schema. The schema is
|
||||
state storage. A prompt is a temporary scene instrument.
|
||||
|
||||
Projection is not the whole Ghostlight runtime. It is the local lens used by a
|
||||
sequential agent loop. The larger machine gives a character awareness of its
|
||||
surroundings, options, goals, and constraints; lets it act; resolves the action;
|
||||
mutates state; and then moves to the next agent.
|
||||
|
||||
The projection layer turns durable state into a compact character-local pressure
|
||||
model, then into prompt prose. The prose should describe what the character
|
||||
knows, wants, fears, protects, misreads, can physically do, and sounds like right
|
||||
now. It should not recite taxonomy labels unless a debugging mode explicitly asks
|
||||
for them.
|
||||
|
||||
For the sequential runtime target, see
|
||||
`docs/architecture/sequential-agent-runtime.md`.
|
||||
|
||||
## Projection Pipeline
|
||||
|
||||
### 1. Select Speaker-Local Inputs
|
||||
|
||||
For one acting character in one scene, gather only the state that can affect this
|
||||
turn:
|
||||
|
||||
- character identity and public role
|
||||
- character canonical state
|
||||
- character goals and protected values
|
||||
- character memories selected by scene, relationship, and active pressure
|
||||
- character perceived overlay of the listener or target
|
||||
- character-to-listener relationship stance
|
||||
- listener-to-character relationship stance only if the character has reason to infer it
|
||||
- scene public facts
|
||||
- scene hidden facts known to the character
|
||||
- active public and private stakes
|
||||
- culture, faction, or institution priors relevant to the scene
|
||||
- physical affordances, constraints, distance, tools, weapons, bodies, access,
|
||||
and social permissions relevant to possible action
|
||||
|
||||
Unknown or author-only material is omitted. Known secrets are included only as
|
||||
character-local pressure: what the character is guarding, why it matters, and
|
||||
what would happen if it leaked.
|
||||
|
||||
### 2. Compute Active Pressure
|
||||
|
||||
For each candidate variable, compute an effective scene activation from:
|
||||
|
||||
- canonical `current_activation`
|
||||
- active scene pressures
|
||||
- relationship stance toward the listener
|
||||
- perceived state overlays
|
||||
- salient memories
|
||||
- active goals and protected values
|
||||
- cultural or institutional scripts
|
||||
|
||||
The first implementation can use simple weighted rules. The important contract
|
||||
is not the exact formula. The important contract is that the projection explains
|
||||
why a variable matters in this scene.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Cat has high shame sensitivity, mask rigidity, distance seeking, suspicion,
|
||||
ironic distance, and acute shame. Oz is useful but opaque. The scene pressures
|
||||
Cat to accept help while not looking dependent.
|
||||
```
|
||||
|
||||
That intermediate pressure statement is more useful than a dump of numbers.
|
||||
|
||||
### 3. Detect Tensions
|
||||
|
||||
A good dialogue prompt needs contradictions, not just traits.
|
||||
|
||||
Derive scene tensions such as:
|
||||
|
||||
- wants help but resents needing it
|
||||
- wants closeness but treats closeness as leverage against them
|
||||
- wants to protect a secret while needing cooperation
|
||||
- wants status while feeling exposed
|
||||
- wants to be honest but expects honesty to be punished
|
||||
- wants to soothe the listener without becoming available for inspection
|
||||
|
||||
Tensions are where verbal behavior starts to move.
|
||||
|
||||
### 4. Derive Response Affordances
|
||||
|
||||
Do not store defensive speech moves or action moves as canonical traits. Derive
|
||||
likely response affordances from state intersections.
|
||||
|
||||
Responses can be:
|
||||
|
||||
- speech
|
||||
- action
|
||||
- silence
|
||||
- withdrawal
|
||||
- violence
|
||||
- mixed speech and action
|
||||
|
||||
The renderer must not assume a line of dialogue is the right output. Sometimes
|
||||
the right next move is leaving the room, taking someone's wrist off a console,
|
||||
opening the airlock interlock, refusing to answer, or slapping someone in the
|
||||
face because the relationship, status threat, bodily proximity, and inhibition
|
||||
profile finally made language too slow.
|
||||
|
||||
Examples:
|
||||
|
||||
| Target behavior | State interaction that can produce it |
|
||||
| --- | --- |
|
||||
| Sarcastic deflection | Shame sensitivity + ironic distance + suspicion + exposure pressure + dry or pointed voice. |
|
||||
| Sincerity evasion | Mask rigidity + low authenticity tolerance + strategic opacity + low emotional explicitness. |
|
||||
| Emotional evasion | Distance seeking + withdrawal + acute shame or control pressure + low self-disclosure. |
|
||||
| Banter as boundary | Humor + ironic distance + attachment pressure + distance seeking. |
|
||||
| Verbal aggression | Hostility + grievance activation + perceived status threat + abrasive boundary. |
|
||||
| Physical withdrawal | Threat sensitivity + distance seeking + low trust + available exit + low obligation to stay. |
|
||||
| Coercive interruption | Control pressure + urgency + authority role + high stakes + reachable control surface. |
|
||||
| Slap or shove | Acute shame or moral disgust + proximity + low inhibition + perceived violation + limited verbal trust. |
|
||||
|
||||
These are test targets, not prompt commands. The rendered prompt should describe
|
||||
the pressure in ordinary language:
|
||||
|
||||
```text
|
||||
If Oz presses too close to the real fear, Cat is likely to answer the useful
|
||||
part of the question while making the emotional part sound ridiculous.
|
||||
```
|
||||
|
||||
That gives the model a behavioral slope without saying `use sarcastic_deflection`.
|
||||
|
||||
For action, use the same rule:
|
||||
|
||||
```text
|
||||
If Veyr calmly describes a possible person as a diagnostic residue while Sella
|
||||
is close enough to touch him, Sella may stop negotiating and physically break
|
||||
the rhythm of the room. This should be rare, costly, and grounded in her care
|
||||
ethic under overload, not random spice.
|
||||
```
|
||||
|
||||
### 5. Compile Projection Controls
|
||||
|
||||
Some failures are not fixed by louder prompt scolding. If the response model
|
||||
keeps adopting the wrong moral frame, inverting who holds an object, giving a
|
||||
character authority they do not have, or resolving a hidden fact too early, the
|
||||
projection artifact is missing control structure.
|
||||
|
||||
Before rendering prompt prose, compile explicit controls for:
|
||||
|
||||
- `frame_controls`: whose language and moral frame the speaker should or should
|
||||
not adopt
|
||||
- `authority_boundaries`: what the speaker can order, request, propose, refuse,
|
||||
or only witness
|
||||
- `object_custody`: who currently holds the flag, weapon, terminal, document,
|
||||
body, door, feed, or other consequential surface
|
||||
- `required_semantics`: compromise meanings that must survive compression, such
|
||||
as "hold remains but review starts now"
|
||||
- `forbidden_resolutions`: unresolved facts or moral categories the turn must
|
||||
not settle
|
||||
|
||||
These controls are still speaker-local. They are not author omniscience handed
|
||||
to the response model; they are the projection layer preserving what the
|
||||
speaker's current state, scene position, and institutional role allow.
|
||||
|
||||
Prototype story labs exposed the need for this layer. Projection controls catch
|
||||
cases where a responder adopts the wrong moral frame, assumes authority it does
|
||||
not hold, moves an object it cannot access, or resolves a disputed category too
|
||||
early. In the current Pallas fixture, that means controls for who can certify a
|
||||
strike, who can touch Kappa service ports, what evidence counts as sentience
|
||||
rather than mere coordination, and which AU liability phrases are character
|
||||
speech versus institutional pressure.
|
||||
|
||||
### 6. Render Prompt Sections
|
||||
|
||||
A response prompt should be compact and scene-bound.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
- `Speaker`: who they are in this scene
|
||||
- `Setting`: where and when the scene is happening, including only historical
|
||||
or cultural context the speaker should have
|
||||
- `Background`: speaker-local origin, role, class, culture, or embodiment
|
||||
context needed to write the turn
|
||||
- `Known Facts`: only speaker-local facts
|
||||
- `Current Stakes`: what changes if this goes well or badly
|
||||
- `Active Inner Pressures`: the strongest motives, fears, wounds, and needs
|
||||
- `Relationship Read`: how the speaker currently interprets the listener
|
||||
- `Tensions`: contradictions the speaker is managing
|
||||
- `Action Affordances`: what the speaker can physically do and what it would
|
||||
cost
|
||||
- `Projection Controls`: frame, authority, object-custody, required semantic,
|
||||
and forbidden-resolution constraints that must survive rendering
|
||||
- `Voice Surface`: baseline speech texture for this moment
|
||||
- `Likely Response Moves`: ordinary-language pressure outcomes, not schema labels
|
||||
- `Do Not Invent`: boundaries about facts absent from the pack
|
||||
- `Task`: the narrow generation request
|
||||
|
||||
`Do Not Invent` is not a replacement for positive context. It only tells the
|
||||
model not to create new facts beyond the pack. It should not list author-only
|
||||
secrets. If the dialogue model needs setting, historical, cultural,
|
||||
socioeconomic, or embodiment context to write the line, put that context in the
|
||||
positive prompt text instead of hoping the model infers it from source refs it
|
||||
will not receive.
|
||||
|
||||
### 7. Generate, Then Evaluate
|
||||
|
||||
Generation and evaluation are separate passes.
|
||||
|
||||
The response model receives the rendered prompt and writes from the character's
|
||||
local pressure model. A later evaluator can check whether target emergent
|
||||
behaviors appeared when the state predicted them. The output may be dialogue,
|
||||
action, silence, or a beat combining them.
|
||||
|
||||
Evaluation questions can include:
|
||||
|
||||
- Did the line protect the speaker's known secret without naming unknown facts?
|
||||
- Did the line reflect the active relationship stance?
|
||||
- Did the line preserve the voice surface without flattening into a generic bit?
|
||||
- Did the spoken text sound like something the character would actually say,
|
||||
rather than the prompt contract reciting itself?
|
||||
- Did operational, legal, and safety constraints stay mostly in action/state
|
||||
fields unless the character has an in-scene reason to say them aloud?
|
||||
- If the output used action, was the action justified by physical affordances,
|
||||
relationship pressure, inhibition, and stakes?
|
||||
- If the output avoided speech, was silence or movement more plausible than
|
||||
another line?
|
||||
- Did a target defensive behavior appear when the pressure profile called for it?
|
||||
- Did the response avoid a target behavior when the pressure profile did not call for it?
|
||||
|
||||
The evaluator should cite the state interactions that justify its read.
|
||||
|
||||
## Example Projection: Cat To Oz
|
||||
|
||||
Input pressures:
|
||||
|
||||
- Cat needs Oz's help with the taxi.
|
||||
- Cat suspects Oz is a mask for a person.
|
||||
- Cat has high suspicion, distance seeking, shame sensitivity, mask rigidity,
|
||||
abrasive boundary, ironic distance, dry voice, pointedness, and profanity.
|
||||
- Cat's relationship stance toward Oz includes low trust, moderate respect,
|
||||
high fascination, and active expectation of betrayal.
|
||||
- The scene makes Cat look dependent if she accepts help too openly.
|
||||
|
||||
Rendered pressure prose:
|
||||
|
||||
```text
|
||||
Cat wants the repair, but accepting help makes her feel handled. Oz is useful,
|
||||
which makes the secrecy more irritating, not less. Cat should probe for the
|
||||
person behind the drone while pretending she is only annoyed about the repair.
|
||||
If Oz gets too close to her fear of dependency, Cat is likely to answer the
|
||||
practical issue and make the emotional issue sound stupid.
|
||||
|
||||
Voice for this turn: dry, quick, pointed, plainspoken, low verbal warmth, low
|
||||
emotional explicitness, some profanity. Humor can appear as a boundary, not as
|
||||
friendliness.
|
||||
```
|
||||
|
||||
Possible generated behavior:
|
||||
|
||||
```text
|
||||
"Sure, let the mystery box fix my relay. That's never how the paperwork starts."
|
||||
```
|
||||
|
||||
That line is produced from the interaction between need, suspicion, shame
|
||||
pressure, and dry pointed voice in the same scene.
|
||||
|
||||
## Example Projection: Oz To Cat
|
||||
|
||||
Input pressures:
|
||||
|
||||
- Oz wants to help without exposing the operator.
|
||||
- Oz reads Cat as suspicious, dangerous, and potentially protective.
|
||||
- Oz has high mask rigidity, strategic opacity, withdrawal, hedging, coded
|
||||
politeness, register switching, listening responsiveness, and low profanity.
|
||||
- Oz's relationship stance toward Cat includes respect, fear, fascination,
|
||||
moderate expectation of care, and moderate expectation of betrayal.
|
||||
- The scene rewards usefulness but punishes exposure.
|
||||
|
||||
Rendered pressure prose:
|
||||
|
||||
```text
|
||||
Oz wants Cat to accept the repair and stop inspecting the mask. Cat's suspicion
|
||||
is dangerous because it is accurate enough to matter. Oz should stay useful,
|
||||
slightly warm, and evasive. If Cat asks directly about the operator, Oz should
|
||||
redirect toward the immediate repair or answer in a way that preserves the mask
|
||||
without making the evasion look like panic.
|
||||
|
||||
Voice for this turn: controlled, concise, technically precise, gently polite,
|
||||
low emotional explicitness, careful verbal warmth, frequent hedging when the
|
||||
subject approaches the operator.
|
||||
```
|
||||
|
||||
Possible generated behavior:
|
||||
|
||||
```text
|
||||
"The relay first. Biography after we stop your meter from eating itself, maybe."
|
||||
```
|
||||
|
||||
The line withholds the secret because Oz's goals, mask rigidity, relationship
|
||||
fear, and scene stakes converge.
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
# Sandboxed Responder Packets
|
||||
|
||||
Responder packets are the handoff surface between the omniscient coordinator and
|
||||
a character-local responder model or one-turn worker.
|
||||
|
||||
The coordinator is allowed to know the scene, the branch, hidden state, source
|
||||
material, pending hooks, and author intent. The responder is not. The responder
|
||||
gets one packet, acts as one character, and returns a visible action plus any
|
||||
private interpretation it can justify from that packet.
|
||||
|
||||
No clairvoyance. No undeclared repo spelunking. No "I accidentally remembered
|
||||
the whole chat" aristocratic nonsense.
|
||||
|
||||
## Generation Lanes
|
||||
|
||||
Responder examples have two valid lanes.
|
||||
|
||||
`packet_only` is the runtime-parity lane. The responder sees the packet,
|
||||
including any curated source excerpts the coordinator or lore retriever placed
|
||||
there. It does not search the lore archive. Use this lane to test whether a
|
||||
future game-runtime responder can act from bounded scene context.
|
||||
|
||||
`retrieval_augmented` is the lore-absorption lane. The coordinator or a
|
||||
dedicated retrieval worker searches a declared AetheriaLore scope, then builds a
|
||||
visible packet from the selected refs. The responder still answers only from the
|
||||
packet it receives. Use this lane to generate stronger Aetheria-native final
|
||||
responses and to bake setting assumptions, faction pressures, species
|
||||
affordances, and tone into later fine-tuning data without pretending the
|
||||
character model did autonomous scholarship in the walls.
|
||||
|
||||
There are two retrieval-augmented modes:
|
||||
|
||||
- `coordinator_scoped_retrieval`: the coordinator or retriever consults lore,
|
||||
then hands the responder a bounded packet. The responder has no repo access.
|
||||
- `responder_scoped_repository_search`: the responder itself is explicitly
|
||||
allowed to inspect the declared lore scope before answering. This is the
|
||||
research-enabled lane. The packet must include a `Required Lore Research`
|
||||
section, `allowed_scope`, and `research_instructions`.
|
||||
|
||||
In `responder_scoped_repository_search`, the responder must consult the seed
|
||||
docs before answering and may follow relevant links inside the traversal policy.
|
||||
It should use retrieved lore to constrain behavior, affordances, institutions,
|
||||
vocabulary, material stakes, territory, social movements, and local assumptions.
|
||||
It must not use research access to infer hidden state, author-only answers,
|
||||
future branches, or private motives outside the packet. The output capture must
|
||||
preserve consulted seed refs, followed refs, a research trace, and a brief
|
||||
research summary. No consulted refs means the research-enabled response failed
|
||||
at the first hurdle, tragic but at least easy to grade.
|
||||
|
||||
`research_trace` is the receipt surface. Each entry records the query or lookup,
|
||||
source ref, source path, chunk index, line range, extracted constraint, and how
|
||||
that constraint shaped the response. Trace status matters:
|
||||
|
||||
- `runner_captured`: the runner or retrieval worker captured the lookup as it happened. This is required for accepted gold research-enabled captures.
|
||||
- `coordinator_reconstructed`: the coordinator rebuilt the trace after the fact from source search and review. Useful draft data, not proof of the responder's actual research path.
|
||||
- `responder_reported`: the responder claimed the trace directly. This is texture, not audit.
|
||||
|
||||
Training-time research scope should be materially wider than runtime context.
|
||||
The point is not to feed every responder a lore pantry forever. The point is to
|
||||
let high-quality training samples absorb the world: PSC risk language, Jovian
|
||||
territorial texture, resident factions, linked social movements, technology
|
||||
constraints, and the odd little institutional grudges that make the universe
|
||||
smell lived in. Runtime can later feed compact retrieval. Fine-tuning should
|
||||
have already eaten its vegetables.
|
||||
|
||||
Do not mix these silently. Retrieval-augmented output can be excellent training
|
||||
material for an Aetheria-tuned responder, but it does not prove packet-only
|
||||
runtime competence. Packet-only failures are also useful because they reveal
|
||||
which lore facts the retriever or projector needs to feed at runtime.
|
||||
|
||||
## Current Artifact
|
||||
|
||||
The packet seam is:
|
||||
|
||||
- `schemas/responder-packet.schema.json`
|
||||
- `tools/validate_responder_packets.py`
|
||||
- `npm run responder-packets:validate`
|
||||
- `schemas/responder-output.schema.json`
|
||||
- `tools/validate_responder_outputs.py`
|
||||
- `npm run responder-outputs:validate`
|
||||
|
||||
Current live fixtures may not include responder packet examples at all times.
|
||||
That is acceptable. The contract remains live; obsolete prototype captures do
|
||||
not need to stay in the working tree once their lessons are distilled.
|
||||
|
||||
## Packet Contents
|
||||
|
||||
A packet contains:
|
||||
|
||||
- `generation_lane`: `packet_only` or `retrieval_augmented`
|
||||
- `lore_access`: curated excerpts, coordinator-scoped retrieval, or
|
||||
responder-scoped repository search
|
||||
- `traversal_policy`: for responder-scoped research, the permitted link depth,
|
||||
allowed repo prefixes, required seed refs, link classes to follow, and stop
|
||||
conditions
|
||||
- `local_context_prompt`: the projected character-local context
|
||||
- `observed_event`: what the character can see or hear from the prior action
|
||||
- `allowed_action_labels`: concrete action labels, including speech and non-speech moves
|
||||
- `source_excerpts`: short source-backed facts the responder may use, copied
|
||||
from active runtime retrieval requirements plus local packet invariants
|
||||
- `output_contract`: the required JSON response shape and rules
|
||||
- `hidden_context_audit`: refs and inference classes deliberately omitted
|
||||
- `isolation_requirements`: how the worker/model must be run
|
||||
- `packet_prompt_text`: the exact prompt text sent to the responder
|
||||
|
||||
The packet prompt must not expose raw state internals such as
|
||||
`current_activation`, `plasticity`, raw dimension scores, coordinator rationale,
|
||||
author-only answers, or future branch plans.
|
||||
|
||||
## Response Boundary
|
||||
|
||||
The responder may output:
|
||||
|
||||
- a visible action
|
||||
- optional spoken text
|
||||
- private interpretation explicitly framed as interpretation
|
||||
- candidate state updates
|
||||
- candidate relationship updates
|
||||
- unresolved hooks
|
||||
|
||||
`spoken_text` is not the place to dump the packet contract. Characters should
|
||||
not recite every legal, procedural, or safety constraint simply because the
|
||||
packet made those constraints visible. Keep speech to what the character would
|
||||
actually say under pressure. Put the full checklist in `visible_action`,
|
||||
`state_update_candidates`, `relationship_update_candidates`, and the mutation
|
||||
receipt.
|
||||
|
||||
Good future-institutional speech is compressed and situated:
|
||||
|
||||
```text
|
||||
I hold the line shallow. Your bay, your mark; my ledger stays awake for every
|
||||
cost the tide takes.
|
||||
```
|
||||
|
||||
Bad future-institutional speech is the output contract wearing a mask:
|
||||
|
||||
```text
|
||||
Bounded diagnostic contact only. No full intake, no custody, no personhood
|
||||
finding, no cargo designation...
|
||||
```
|
||||
|
||||
That second kind of line may be useful as a failure sample. It is not gold
|
||||
responder behavior unless the scene specifically calls for formal oath,
|
||||
courtroom language, ritual compliance, or someone deliberately reading terms
|
||||
aloud.
|
||||
|
||||
Those candidates are not canonical. The coordinator, event resolver, appraiser,
|
||||
state mutator, and reviewer decide what becomes truth.
|
||||
|
||||
## Training Use
|
||||
|
||||
Gold responder data requires declared isolation. Valid approaches include:
|
||||
|
||||
- a one-turn subagent started without inherited context
|
||||
- an external model call that receives only the packet prompt
|
||||
- a manual packet-only pass, clearly labeled as manual
|
||||
|
||||
Any coordinator rewrite, schema repair, lore correction, or leakage removal must
|
||||
be preserved as a coordinator intervention. Repaired prose is useful training
|
||||
data for the coordinator, but it is not raw responder behavior.
|
||||
|
||||
Retrieval-augmented responder data requires scoped lore access before the
|
||||
responder acts. The coordinator, retrieval worker, or explicitly research-enabled
|
||||
responder may search only the declared source scope, and the output capture must
|
||||
list consulted refs and trace entries. The responder itself should not receive repo access unless
|
||||
the artifact uses `responder_scoped_repository_search`, sets `no_repo_access` to
|
||||
false, and includes visible research instructions in the prompt.
|
||||
|
||||
Retrieval can also make the packet worse if it floods the scene with faction or
|
||||
event context and starves the character's own relevant pressures. Review must
|
||||
verify that packet-visible history, role strain, and local relationships remain
|
||||
available as behavioral pressure. The point is not to make a character
|
||||
trauma-dump during a mission-critical exchange. The point is that the responder
|
||||
cannot be shaped by a pressure it cannot see.
|
||||
|
||||
## Output Captures
|
||||
|
||||
A responder output capture preserves:
|
||||
|
||||
- the packet ref
|
||||
- the coordinator artifact ref
|
||||
- generation lane
|
||||
- lore access mode and consulted refs
|
||||
- the isolation method
|
||||
- the exact visible input ref
|
||||
- hidden context refs that were withheld
|
||||
- raw responder output
|
||||
- parsed responder output
|
||||
- leakage audit
|
||||
- coordinator review and intervention labels
|
||||
|
||||
The raw output must parse to the reviewed `parsed_output` exactly. If the
|
||||
coordinator edits prose, repairs schema, removes leakage, or corrects lore, the
|
||||
capture must say so directly.
|
||||
|
||||
For `responder_scoped_repository_search`, the raw responder object may also
|
||||
include `consulted_refs`, `followed_refs`, `research_trace`, and
|
||||
`research_summary`. Those fields should be preserved inside `parsed_output`
|
||||
when the responder itself produced them; consulted refs, followed refs, summary,
|
||||
trace status, and trace entries should also be mirrored into top-level
|
||||
`lore_access` so the capture can be indexed without stripping the original
|
||||
response. If the runner cannot expose a machine-verifiable tool-call or
|
||||
retrieval transcript, label the sample as draft and say so in review notes.
|
||||
Trust, but make the trust write receipts like everyone else.
|
||||
|
||||
The current packet builder consumes `runtime_retrieval_requirements` from the
|
||||
projected local context. Packet-only source excerpts should come from the same
|
||||
retrieval/projector seam that would feed runtime context. Static lore sandwiches
|
||||
are useful only as rejected-path examples and emergency snacks.
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
# Future Schema Mechanisms
|
||||
|
||||
This note parks the useful pressure from `notes/evaluation.md` without treating
|
||||
that critique as doctrine. The current v0 schema is still a character-state and
|
||||
prompt-projection seam. The future runtime needs mechanisms that turn state into
|
||||
changing social behavior.
|
||||
|
||||
## Current Boundary
|
||||
|
||||
The v0 agent-state schema should stay focused on:
|
||||
|
||||
- canonical character state
|
||||
- directional relationship stance
|
||||
- observer-local perceived overlays
|
||||
- memories, goals, values, and scene-local dialogue packs
|
||||
- enough event interpretation to support prompt projection
|
||||
|
||||
Do not inflate `schemas/agent-state.schema.json` into the whole simulator before
|
||||
the projection examples prove what the runtime actually needs.
|
||||
|
||||
## Mechanisms To Preserve For Later
|
||||
|
||||
### Event Appraisal
|
||||
|
||||
Each participant needs a private read of the same event. A single exchange can
|
||||
be betrayal to one agent, exposure risk to another, and flirting-with-knives to
|
||||
a third. The current schema now allows `private_interpretations` on events as a
|
||||
lightweight bridge.
|
||||
|
||||
Future work should make appraisal reusable enough to drive state updates,
|
||||
memory retrieval, and action affordances.
|
||||
|
||||
### State Transitions And Consequences
|
||||
|
||||
`mean`, `plasticity`, and `current_activation` describe state. They do not yet
|
||||
define how state changes. Future transition machinery needs:
|
||||
|
||||
- activation decay
|
||||
- event-driven deltas
|
||||
- sensitization or habituation
|
||||
- effects on related variables
|
||||
- rules for when `mean` changes versus only `current_activation`
|
||||
|
||||
The current `event_effects` field is a projection-facing placeholder, not the
|
||||
final transition engine.
|
||||
|
||||
### Structured Beliefs And Secrets
|
||||
|
||||
String beliefs are useful for authoring, but simulation needs structure:
|
||||
|
||||
- holder
|
||||
- subject
|
||||
- claim type
|
||||
- confidence
|
||||
- evidence
|
||||
- visibility
|
||||
- emotional charge
|
||||
- whether the belief is private, shared, public, rumor, false, or contested
|
||||
|
||||
Secrets need similar structure: owner, known-by, suspected-by, exposure cost,
|
||||
protective strategies, and what can leak through action.
|
||||
|
||||
### Action Affordances
|
||||
|
||||
The system needs to choose among possible moves instead of jumping from state to
|
||||
dialogue. Future projection examples should expose candidate affordances such
|
||||
as probing, evading, bargaining, softening, escalating, repairing, threatening,
|
||||
or walking away.
|
||||
|
||||
The important rule: affordances should be justified by goals, appraisals,
|
||||
capabilities, costs, relationship stance, and scene pressure.
|
||||
|
||||
### Conversation State
|
||||
|
||||
Dialogue needs explicit continuity:
|
||||
|
||||
- active topic
|
||||
- open questions
|
||||
- evasions
|
||||
- accusations
|
||||
- face-threats
|
||||
- commitments
|
||||
- promises, refusals, and ruptures
|
||||
- who has the floor
|
||||
|
||||
This belongs close to projection because it changes turn shape immediately.
|
||||
|
||||
### Resources And Capabilities
|
||||
|
||||
Cat's debt, taxi condition, mobility, and institutional exposure are not just
|
||||
backstory. Oz's drone integrity, signal secrecy, anonymity, and physical safety
|
||||
are not just flavor. Future schemas need concrete resource and capability
|
||||
fields so desire does not masquerade as ability.
|
||||
|
||||
### Dyads And Shared Relationship History
|
||||
|
||||
Directional relationship stance should remain. A separate dyadic layer may also
|
||||
be needed for shared history:
|
||||
|
||||
- relationship type
|
||||
- phase
|
||||
- shared events
|
||||
- shared secrets
|
||||
- unresolved ruptures
|
||||
- rituals
|
||||
- dependency structure
|
||||
- communication pattern
|
||||
|
||||
Do not collapse this into one symmetric relationship object. People are not
|
||||
that tidy unless the author is lying to save RAM.
|
||||
|
||||
### Social Knowledge, Reputation, And Institutions
|
||||
|
||||
The Aetheria setting needs institutions with teeth: sanctions, authority,
|
||||
surveillance, reputation effects, access control, role expectations, and public
|
||||
knowledge. Future work should distinguish private belief, shared belief, public
|
||||
fact, rumor, common knowledge, and institutional record.
|
||||
|
||||
### Memory Retrieval
|
||||
|
||||
Memories need more than salience and confidence once the runtime starts moving:
|
||||
|
||||
- recency
|
||||
- emotional charge
|
||||
- retrieval triggers
|
||||
- decay
|
||||
- reconsolidation
|
||||
- intrusive, avoided, cherished, rehearsed, or distorted status
|
||||
- schema effects
|
||||
- whether another agent knows the memory
|
||||
|
||||
The current memory bundle is enough for the first projection seam. It is not yet
|
||||
the memory engine.
|
||||
|
||||
### Embodiment And Nonverbal Channels
|
||||
|
||||
Oz proves this one immediately. The operator, drone body, observable channels,
|
||||
leakage risks, and physical affordances are separate facts. Future projection
|
||||
needs embodiment because social meaning leaks through movement, timing,
|
||||
silence, distance, posture, and constrained bodies.
|
||||
|
||||
The projected-local-context seam now carries a first embodiment/interface
|
||||
surface, but the canonical schema still lacks a proper reusable embodiment
|
||||
model. Future work should promote body form, native habitat, movement channels,
|
||||
assistive or translation infrastructure, and visible nonverbal signal channels
|
||||
only after more projection examples prove the required shape.
|
||||
|
||||
For Aetheria faction work, embodiment projection must check the lore vault
|
||||
before writing. If the faction page supports only general habitat aesthetics or
|
||||
social affordances, keep generated action at that level and record the missing
|
||||
ergonomic detail as a source-lore gap instead of inventing a confident local
|
||||
interface.
|
||||
|
||||
When a blocking gap is found, patch the AetheriaLore source before treating the
|
||||
detail as available to projection. Ghostlight can discover missing granularity
|
||||
through scene generation, but the lore vault should become the durable source of
|
||||
the elaboration.
|
||||
|
||||
## Promotion Rule
|
||||
|
||||
Promote a future mechanism into the main schema only after it has survived at
|
||||
least one reviewed projection example or runtime slice where it clearly changes
|
||||
the output. Until then, document it here, test it in examples, and do not let
|
||||
the ontology grow antlers for sport.
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
# Sequential Agent Runtime
|
||||
|
||||
Ghostlight's near-term target is not a general-purpose life simulator.
|
||||
|
||||
The near-term product target is procedural interactive dialogue in the game
|
||||
sense: branching scenes where choices can be speech, silence, movement, object
|
||||
use, refusal, revelation, concealment, violence, withdrawal, trade, or any other
|
||||
scene-valid action. "Dialogue tree" does not mean a tree made only of spoken
|
||||
lines. It means an interactive branch structure for a character encounter.
|
||||
|
||||
Ink is the branch format for that structure. Ghostlight should not invent its
|
||||
own dialogue-tree file format while Ink already exists and fits the job. See
|
||||
`docs/architecture/ink-branching-scenes.md` for the integration contract.
|
||||
|
||||
Ghostlight should set up a scene, generate plausible things the protagonist or
|
||||
NPCs could do, generate responses from the people present, and propagate the
|
||||
consequences into world state, memory, belief, and social perception.
|
||||
|
||||
The long-term target is broader. The same classifiers, appraisers, and
|
||||
projection models should eventually operate inside a simulated game world with
|
||||
open-world contexts, iterated decisions, economic behavior, and major-faction
|
||||
decision-making. Do not train a tiny dialogue-only creature that panics the
|
||||
first time someone buys medicine, defects from a faction, or reacts to a
|
||||
shortage. Also do not build the whole economy today. Both cliffs are stupid.
|
||||
|
||||
The runtime target for now is scene-local: choose which character is acting,
|
||||
give that character local awareness, let them act, apply consequences, mutate
|
||||
the relevant state, and continue until the scene produces a usable branching
|
||||
scene tree, scene transcript, or branch scaffold.
|
||||
|
||||
Single-turn projection remains useful, but it is a microscope, not the animal.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
One runtime tick should look like this:
|
||||
|
||||
1. Select an acting agent from the initiative schedule.
|
||||
2. Build that agent's local awareness.
|
||||
3. Derive available actions and constraints.
|
||||
4. Let the agent choose a response.
|
||||
5. Resolve the action into an observable event.
|
||||
6. Apply every affected participant's appraisal and state mutation for that
|
||||
event.
|
||||
7. Append the event record with participant-local interpretations.
|
||||
8. Update initiative timing, reaction windows, and action recovery.
|
||||
9. Select the next acting agent from the updated schedule, or end the scene.
|
||||
|
||||
The author sets the initial scene, player role if any, and high-level branch
|
||||
constraints. The author should not need to decide every line, interruption,
|
||||
withdrawal, refusal, or concession. That is the point of the machine.
|
||||
|
||||
Every generated character turn must be local to that character.
|
||||
|
||||
For branching scenes, this means protagonist/player options and NPC responses
|
||||
are separate generation steps:
|
||||
|
||||
1. Build the protagonist's local awareness.
|
||||
2. Generate plausible protagonist choices from only what the protagonist knows,
|
||||
perceives, wants, can attempt, and can affect.
|
||||
3. When one choice is selected, reduce it to observable action: words, silence,
|
||||
gesture, movement, object use, resource spend, or other perceivable change.
|
||||
4. Resolve the selected choice into an event visible to affected characters.
|
||||
5. Apply reviewed participant appraisals and state mutations for that event:
|
||||
hurt, threat, reassurance, obligation, resentment, trust movement, overload,
|
||||
misread, world/object/resource changes, and scene-ending choices when they
|
||||
occur.
|
||||
6. Update initiative timing and select the next acting agent from the updated
|
||||
schedule. The next agent may answer, walk away, escalate, comply, refuse,
|
||||
call for help, attack, or end the interaction.
|
||||
7. Generate that next action from the newly consolidated local state.
|
||||
8. Store actor intent, listener appraisal, and durable interpretation
|
||||
separately.
|
||||
|
||||
There is no privileged paired exchange inside the runtime. For authoring
|
||||
convenience a branch artifact may show `player action -> NPC action`, but
|
||||
Ghostlight should treat that as two turns stitched together, not one fused
|
||||
response object. If Maer wounds Sella with a ledger threat, Sella's state
|
||||
changes before she is considered as the next actor; she is not obligated to
|
||||
produce a line just because the author expected one.
|
||||
|
||||
One-shot paired generation, where a model writes both the player's move and the
|
||||
NPC response from a shared omniscient packet, is acceptable only as bootstrap
|
||||
scaffolding. It should be reviewed and labeled as such. Do not treat it as the
|
||||
target runtime behavior.
|
||||
|
||||
## Initiative Scheduler
|
||||
|
||||
Not every character gets a turn every round. There is no round.
|
||||
|
||||
Ghostlight needs a scene-local initiative scheduler: a mechanical organ that
|
||||
tracks who is ready to act, who is still recovering from a previous move, and
|
||||
who has a valid interrupt window. It sits after event appraisal/mutation and
|
||||
before the next local-awareness projection.
|
||||
|
||||
The scheduler does not decide what a character wants. It decides when a
|
||||
character is eligible to act.
|
||||
|
||||
Core rule:
|
||||
|
||||
```text
|
||||
observable event
|
||||
-> participant appraisals and reviewed mutation
|
||||
-> initiative/reaction update
|
||||
-> next actor selection
|
||||
-> next actor projection
|
||||
```
|
||||
|
||||
Every affected participant still appraises every event before the next action
|
||||
is generated. Initiative controls action opportunity, not perception. A hostage
|
||||
can be terrified by a negotiator's move immediately; that does not mean the
|
||||
hostage gets a full action before the hostage-taker, unless the schedule or a
|
||||
reaction window gives them one.
|
||||
|
||||
Each participant carries initiative state:
|
||||
|
||||
- `initiative_speed`: how quickly the participant recovers action opportunity
|
||||
in this scene.
|
||||
- `next_ready_at`: the scene-clock time when they can take a normal action.
|
||||
- `reaction_bias`: how likely they are to seize urgent interrupt windows.
|
||||
- `interrupt_threshold`: how strong a trigger must be before they interrupt.
|
||||
- `current_load`: stress, injury, cognitive burden, physical constraint, or
|
||||
interface lag that makes action harder.
|
||||
- `status`: whether they are active, blocked, withdrawn, incapacitated, or
|
||||
offscreen.
|
||||
|
||||
Actions carry recovery shape:
|
||||
|
||||
- `action_scale`: micro, short, standard, major, or committed.
|
||||
- `base_recovery`: how much scene time the move normally costs.
|
||||
- `initiative_cost`: how much local opportunity the actor spends.
|
||||
- `interruptibility`: whether others can break in while the action unfolds.
|
||||
- `commitment`: how much the actor is locked into the move once begun.
|
||||
|
||||
The scheduler first checks reaction windows created by the last event. A
|
||||
reaction window is an interruptible opportunity, not a free omniscient response.
|
||||
It must name the triggering event, eligible actors, urgency, allowed action
|
||||
scales, expiry, and local basis. If no reaction clears threshold, the scheduler
|
||||
selects the active participant with the lowest `next_ready_at`.
|
||||
|
||||
Recommended reaction readiness:
|
||||
|
||||
```text
|
||||
reaction_readiness = (reaction_bias * urgency) - current_load
|
||||
```
|
||||
|
||||
An interrupt can fire when `reaction_readiness >= interrupt_threshold`, the
|
||||
actor is eligible for the window, and the actor is not blocked. Ties are broken
|
||||
by earlier `next_ready_at`, higher `initiative_speed`, then stable actor id.
|
||||
|
||||
After an accepted action, update the actor:
|
||||
|
||||
```text
|
||||
recovery = base_recovery / max(initiative_speed, minimum_speed)
|
||||
next_ready_at = max(scene_clock, current next_ready_at) + recovery
|
||||
```
|
||||
|
||||
Major or committed actions should usually create larger recovery, stronger
|
||||
counterplay windows, clearer visible cues, or more durable consequences. Short
|
||||
actions let a character stay nimble but should not accomplish everything. A
|
||||
one-word refusal, a glance to a camera, or stepping behind a marked line can be
|
||||
fast. A public confession, tackle, system override, or hostage release costs
|
||||
more initiative because it changes the scene.
|
||||
|
||||
The coordinator may override the scheduler only by recording the override
|
||||
reason. Useful overrides include player-facing pacing, hard scene boundaries,
|
||||
or a game-engine rule that is not yet represented in the schedule. An
|
||||
unrecorded override is not training-ready coordinator data.
|
||||
|
||||
For Ink, the scheduler can be collapsed into branch structure for readability,
|
||||
but the sidecar should preserve the initiative receipt when the fixture claims
|
||||
training value. Player choices may be presented together even if, inside the
|
||||
runtime, they represent alternative actions available at one scheduled moment.
|
||||
|
||||
For game use, the loop should be able to emit:
|
||||
|
||||
- a scene transcript
|
||||
- Ink scenes with candidate player choices, including speech and non-speech
|
||||
actions
|
||||
- compiled Ink JSON
|
||||
- NPC response branches
|
||||
- state/memory/social-perception deltas for each branch
|
||||
- unresolved hooks for later scenes
|
||||
|
||||
If a feature does not help produce those artifacts, it belongs in the parking
|
||||
lot until the smaller machine works. If a field or label preserves information
|
||||
needed for later open-world behavior, consumer choice, faction pressure, or
|
||||
iterated decisions, keep it visible in the training artifact even if the first
|
||||
prototype does not simulate it.
|
||||
|
||||
## Local Awareness
|
||||
|
||||
An acting agent needs more than a dialogue cue.
|
||||
|
||||
The local awareness pack should include:
|
||||
|
||||
- where the agent is
|
||||
- who is present
|
||||
- what the agent can perceive
|
||||
- what the agent knows, believes, suspects, and misreads
|
||||
- what the agent wants right now
|
||||
- what the agent is protecting
|
||||
- what resources and tools are available
|
||||
- what social, legal, physical, and emotional constraints apply
|
||||
- what actions are available
|
||||
- what each action might cost
|
||||
- what facts are unresolved
|
||||
- what the agent is allowed to mutate directly
|
||||
|
||||
This is not reader legibility or global story authorship. This is operational
|
||||
state for scene-local dialogue and action. If the scene itself is abstract and
|
||||
byzantine, the agent may still behave correctly inside that mess. Do not shove
|
||||
general prose quality or whole-plot design into this layer.
|
||||
|
||||
## Branch Choices And Action Selection
|
||||
|
||||
The action model must allow more than speech.
|
||||
|
||||
For a protagonist scene, Ghostlight should be able to generate several
|
||||
plausible choices for what the protagonist can do next. For example, if Cat is
|
||||
interacting with another character, the generated choices might include:
|
||||
|
||||
- say the honest thing badly
|
||||
- deflect with a joke
|
||||
- leave before the other person can answer
|
||||
- hand over evidence
|
||||
- hide evidence
|
||||
- accept help while pretending not to need it
|
||||
- threaten to walk
|
||||
- spend scarce money
|
||||
- break a device
|
||||
- touch someone gently
|
||||
- slap someone
|
||||
|
||||
Those choices should come from Cat's state, scene affordances, resources,
|
||||
relationship pressure, and goals. They should not be generic verbs glued to a
|
||||
persona card. Each branch should carry expected consequence surfaces, such as
|
||||
trust risk, reputation risk, resource cost, information exposure, or future
|
||||
obligation.
|
||||
|
||||
Keep the action vocabulary small and concrete. Do not turn every possible
|
||||
speech act into a separate tool. `ask`, `refuse`, `offer`, `reveal`, `conceal`,
|
||||
and `request authority action` are usually communicative intentions carried by
|
||||
`speak`, `gesture`, `show_object`, `withhold_object`, or `use_interface`.
|
||||
|
||||
Initial action primitives:
|
||||
|
||||
- `speak`
|
||||
- `silence`
|
||||
- `move`
|
||||
- `gesture`
|
||||
- `touch_object`
|
||||
- `block_object`
|
||||
- `use_object`
|
||||
- `show_object`
|
||||
- `withhold_object`
|
||||
- `transfer_object`
|
||||
- `spend_resource`
|
||||
- `attack`
|
||||
- `wait`
|
||||
|
||||
The agent should choose from affordances produced by the interaction of state,
|
||||
scene, role, resources, relationship, and pressure. The action should not be
|
||||
commanded by a prompt label. If a character hits someone, it should fall out of
|
||||
proximity, inhibition, grievance, fear, role permission, and exhausted trust.
|
||||
|
||||
An action proposal may include intended function:
|
||||
|
||||
- ask
|
||||
- refuse
|
||||
- offer
|
||||
- threaten
|
||||
- comfort
|
||||
- reveal
|
||||
- conceal
|
||||
- request
|
||||
- promise
|
||||
- accuse
|
||||
- redirect
|
||||
|
||||
That intention is not guaranteed truth. A listener can misread it. A character
|
||||
can claim they were offering help while the listener perceives a threat. A
|
||||
character can intend restraint and still be read as contempt. The event should
|
||||
store observable action separately from actor intent and participant-local
|
||||
interpretation.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"action_type": "speak",
|
||||
"spoken_text": "Name the cost before you call it caution.",
|
||||
"actor_intent": "force_cost_acknowledgment",
|
||||
"observable_cues": ["calm voice", "still posture", "public challenge"]
|
||||
}
|
||||
```
|
||||
|
||||
Isdra may perceive that as procedural intimidation. Maer may experience it as
|
||||
stewardship. The classifier/appraiser later learns from those differences.
|
||||
|
||||
## Mutation Authority
|
||||
|
||||
Characters should only mutate what their action can actually affect.
|
||||
|
||||
Examples:
|
||||
|
||||
- A character can mutate their own beliefs, memories, current activation, goals,
|
||||
and presentation pressure.
|
||||
- A character can create observable events others may perceive.
|
||||
- A character can change world objects they can physically or institutionally
|
||||
access.
|
||||
- A character can affect relationship stance, but the other side's relationship
|
||||
update is mediated through that other agent's appraisal.
|
||||
- A character can propose an order they lack authority to issue.
|
||||
- A character cannot silently rewrite another agent's private state, decide an
|
||||
unresolved hidden fact, or seize institutional authority they do not possess.
|
||||
|
||||
This is where the projection-control layer belongs. Frame ownership, authority
|
||||
boundaries, object custody, required semantics, and forbidden resolutions are
|
||||
not decorative prompt warnings. They are mutation gates.
|
||||
|
||||
## Event Resolution
|
||||
|
||||
Generated action is not state mutation by itself. It becomes an event proposal.
|
||||
|
||||
The resolver should decide:
|
||||
|
||||
- what observable event occurred
|
||||
- what world objects changed
|
||||
- what resources were spent or created
|
||||
- which agents perceived the event
|
||||
- how each participant appraised it
|
||||
- which beliefs were reinforced, challenged, or created
|
||||
- which memories were written
|
||||
- which relationship dimensions changed
|
||||
- which current activations changed
|
||||
- which options opened or closed
|
||||
|
||||
In a branching scene tree, each branch should carry its own consequence packet.
|
||||
If Cat chooses to lie, accept help, shove someone, spend money, or reveal a
|
||||
secret, that branch should record what changed and what might matter later.
|
||||
Consequences can include:
|
||||
|
||||
- trust lost or gained
|
||||
- suspicion raised or lowered
|
||||
- obligation created
|
||||
- debt created or paid
|
||||
- resource spent
|
||||
- information revealed or concealed
|
||||
- relationship rupture or repair
|
||||
- faction or reputation pressure changed
|
||||
- future scene hook opened or closed
|
||||
|
||||
The first implementation can be conservative and partly deterministic. A bad
|
||||
resolver will build narrative sludge faster than a bad line of dialogue will.
|
||||
No pressure. Tiny little bear trap.
|
||||
|
||||
The fuzzy parts should stay manual at first.
|
||||
|
||||
Do not invent a fragile rule-based social psychology engine just to feel
|
||||
automated. Early runtime passes should manually review or author:
|
||||
|
||||
- participant appraisals
|
||||
- relationship deltas
|
||||
- belief updates
|
||||
- memory writes
|
||||
- activation changes
|
||||
- ambiguous intent classification
|
||||
- whether a listener misread or correctly read a move
|
||||
|
||||
Those manual decisions are the training data. Save the observed action, actor
|
||||
intent, listener perception, accepted mutation, rejected mutation, and reviewer
|
||||
notes. Later, train classifiers and appraisers on that corpus. The prototype
|
||||
should automate mechanical legality and visibility first, not pretend it has
|
||||
already learned human interpretation from twelve examples and a strong coffee.
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
Build interactive branching scene generation first, but shape the data for later
|
||||
open-world use.
|
||||
|
||||
In scope now:
|
||||
|
||||
- scene-local agent turns
|
||||
- player/NPC dialogue and action branches
|
||||
- local action proposals
|
||||
- event records
|
||||
- manual reviewed appraisals
|
||||
- memory, belief, relationship, and activation deltas
|
||||
- branch consequences that can be carried into later scenes
|
||||
- action intent and listener interpretation labels
|
||||
- resource, price, scarcity, obligation, reputation, and faction-pressure tags
|
||||
when they affect the decision
|
||||
- reviewed examples of consumer-like choices and institution/faction pressure,
|
||||
even when the prototype only records them as scene state
|
||||
|
||||
Training-data obligations now:
|
||||
|
||||
- Preserve why an agent chose, bought, refused, hoarded, traded, complied,
|
||||
defected, concealed, punished, or helped.
|
||||
- Preserve what the agent believed the cost, risk, status effect, and social
|
||||
consequence would be.
|
||||
- Preserve faction and institution pressures as perceived by the agent, not
|
||||
only as objective setting lore.
|
||||
- Preserve iterated-decision context: promises, debts, prior betrayals,
|
||||
reputation, memory, scarcity, and expected future contact.
|
||||
|
||||
Out of implementation scope for now:
|
||||
|
||||
- full autonomous world simulation
|
||||
- economy simulation loops
|
||||
- city-scale scheduling
|
||||
- long-horizon plot invention without author scaffolding
|
||||
- autonomous factions moving offscreen as full actors
|
||||
- physical simulation beyond scene affordances
|
||||
- automatic social appraisal rules pretending to be trained classifiers
|
||||
|
||||
The eventual storyline generator, economic behavior model, and faction decision
|
||||
model should grow out of reliable branching-scene machinery and reviewed
|
||||
decision data, not the other way around.
|
||||
|
||||
## Projection's Narrower Job
|
||||
|
||||
Projection still matters.
|
||||
|
||||
Its job is to turn structured state into a compact local action context for one
|
||||
agent at one moment. It should support action choice and response rendering. It
|
||||
should not be treated as the whole story engine.
|
||||
|
||||
Projection artifacts are still useful for:
|
||||
|
||||
- debugging whether local state produces plausible action
|
||||
- training a smaller projector
|
||||
- preserving reviewed examples
|
||||
- testing state boundaries and mutation gates
|
||||
- producing dialogue or action beats inside a larger runtime
|
||||
|
||||
But the production path is sequential:
|
||||
|
||||
```text
|
||||
agent state + scene state
|
||||
-> local awareness
|
||||
-> action affordances
|
||||
-> agent response/action
|
||||
-> event resolution
|
||||
-> participant appraisals and state mutation
|
||||
-> initiative/reaction scheduling
|
||||
-> next actor selection
|
||||
-> next agent action from updated local state
|
||||
```
|
||||
|
||||
## First Prototype
|
||||
|
||||
The first implementation should not try to simulate a city. Calm down, wizard.
|
||||
|
||||
Prototype one small Ink-backed branching scene loop, using the current live
|
||||
fixture or a new source-grounded fixture:
|
||||
|
||||
1. Load a fixture agent-state artifact, such as
|
||||
`examples/agent-state.pallas-species-strikes.v0.json`.
|
||||
2. Select one scene and one acting agent.
|
||||
3. Build a local awareness packet from existing state.
|
||||
4. Compile projection controls.
|
||||
5. Generate or author branch choices and NPC responses from local context.
|
||||
6. Materialize the branch plan through the Branch Compiler as Ink, sidecar
|
||||
annotation, compiler notes, consequence variables, and visual prompt handles.
|
||||
7. Compile the Ink scene to JSON with `inkjs`.
|
||||
8. Save a sidecar training annotation that maps Ink branches to Ghostlight
|
||||
state basis, actor intent, consequence surfaces, and mutation policy.
|
||||
9. Apply selected branch consequences only through reviewed mutation:
|
||||
- Ink variables can track local playthrough facts
|
||||
- canonical state changes require resolver/reviewer approval
|
||||
10. Run IF artifact review before acceptance:
|
||||
- every material variable is read later or marked telemetry-only
|
||||
- every material choice changes affordance, risk, callback, visual state, or
|
||||
outcome
|
||||
- folds preserve consequence rather than hiding it
|
||||
11. Manually review fuzzy participant appraisals and state effects each turn:
|
||||
- update every affected participant before selecting the next actor
|
||||
- add one memory or belief update
|
||||
- adjust one or two `current_activation` values
|
||||
- update one relationship stance if justified
|
||||
- record the rationale as training data
|
||||
12. Validate the resulting fixture.
|
||||
|
||||
The goal is not literary quality or autonomous plot generation. The goal is to
|
||||
prove that a character can drive a scene beat, produce branchable dialogue or
|
||||
non-dialogue action, trigger plausible responses from other characters, and
|
||||
leave memory, relationship, social perception, and scene state slightly
|
||||
different behind them.
|
||||
|
|
@ -1,539 +0,0 @@
|
|||
# Soft Model Training Artifacts
|
||||
|
||||
Ghostlight has two kinds of machinery.
|
||||
|
||||
Some parts should be boring code:
|
||||
|
||||
- schema validation
|
||||
- visibility filtering
|
||||
- action legality
|
||||
- object custody
|
||||
- resource accounting
|
||||
- Ink syntax validation and compilation
|
||||
- event append
|
||||
- provenance checks
|
||||
- deterministic context slicing
|
||||
- mechanical mutation gates
|
||||
|
||||
Other parts are soft. They require judgment, taste, social inference, cultural
|
||||
pressure, and narrative sense. Do not fake those with brittle rules just because
|
||||
a function-shaped hole is sitting there looking smug.
|
||||
|
||||
Soft parts should emit reviewed training artifacts from the beginning. Manual
|
||||
review is not a temporary embarrassment. It is the dataset.
|
||||
|
||||
## Core Rule
|
||||
|
||||
If a decision cannot be made reliably by deterministic code, Ghostlight should
|
||||
save it as a training example.
|
||||
|
||||
That includes accepted outputs, rejected outputs, repaired outputs, and weird
|
||||
half-successes. Especially weird half-successes. They are where the machine is
|
||||
showing the fracture line.
|
||||
|
||||
A useful soft artifact should preserve:
|
||||
|
||||
- the deterministic input slice
|
||||
- the model or human output
|
||||
- the reviewer decision
|
||||
- accepted labels
|
||||
- rejected labels, if any
|
||||
- rationale grounded in state, scene, culture, memory, relationship, and lore
|
||||
- failure notes
|
||||
- provenance to source lore or fixture state
|
||||
- whether the artifact is grounded canon, transition-grounded, procedural
|
||||
branch material, or promoted branch material
|
||||
|
||||
## Sandboxed Responder Rule
|
||||
|
||||
Responder training data must not be authored from an omniscient view.
|
||||
|
||||
The coordinator may know the whole scene, hidden state, future branch plan,
|
||||
author constraints, and private facts. The responder model we eventually train
|
||||
will not. If a responder example only works because the authoring agent quietly
|
||||
used omniscient context, the artifact is poisoned. It teaches the future model to
|
||||
perform an impossible trick, then looks offended when reality asks for receipts.
|
||||
|
||||
For gold responder or character-action data:
|
||||
|
||||
- the coordinator prepares the scene and selects the acting character
|
||||
- the projector emits the exact character-local context
|
||||
- the responder is run from only that projected packet, visible event, allowed
|
||||
action space, and any explicitly included source excerpts
|
||||
- the responder does not see other agents' private state, hidden canonical
|
||||
facts, author-only plot beats, future branch plans, or coordinator rationale
|
||||
- any isolated worker or sub-agent used for responder generation should be
|
||||
started without inherited conversation context when the harness supports it
|
||||
- repo or lore browsing is disabled for the responder unless the projected
|
||||
packet explicitly includes the excerpts or references the future model will
|
||||
also receive
|
||||
- the coordinator reviews after generation, not during it
|
||||
|
||||
Artifact requirements:
|
||||
|
||||
- `responder_visible_input`: the exact prompt or structured packet the responder
|
||||
saw
|
||||
- `responder_raw_output`: the unedited output
|
||||
- `reviewed_output`: the accepted or repaired turn
|
||||
- `review_status`: accepted, accepted-as-draft, useful-needs-revision, rejected,
|
||||
or superseded
|
||||
- `coordinator_interventions`: every edit, repair, deletion, or normalization
|
||||
made after raw generation
|
||||
- `hidden_context_refs`: ids or paths for context intentionally withheld, not
|
||||
the hidden content itself
|
||||
- `leakage_audit`: checks for private-state leakage, future-branch leakage,
|
||||
author-only leakage, raw-state soup, prompt-constraint parroting, and
|
||||
impossible body or object knowledge
|
||||
|
||||
Coordinator-written replacement text can be valuable, but it is not raw
|
||||
responder output. Mark it as coordinator repair or frontier-curated final. Do
|
||||
not smuggle repaired prose into the responder corpus as if the sandbox produced
|
||||
it. That is how a dataset learns to cheat with a straight face.
|
||||
|
||||
## Training-Ready Scene Loop Bundle
|
||||
|
||||
A scene loop is the smallest useful sequential training unit before branch
|
||||
compilation. It is not enough to have a nice transcript. The bundle needs the
|
||||
machine trail that made the transcript possible.
|
||||
|
||||
A training-ready scene-loop bundle contains:
|
||||
|
||||
- scene-local digest or lore slice
|
||||
- initial scene/world state
|
||||
- current character state refs for every participant
|
||||
- projected local context per acting turn
|
||||
- responder packet per acting turn
|
||||
- raw responder output capture per acting turn
|
||||
- observable event record per accepted action
|
||||
- participant appraisal receipt for every affected participant
|
||||
- reviewed mutation receipt for character state, memory, relationship,
|
||||
perceived overlays, object state, scene/world state, and unresolved hooks
|
||||
- coordinator turn receipt carrying next-beat selection, world-state reads,
|
||||
proposed deltas, glue prose, and review labels
|
||||
- review summary naming which organs are training-ready, reference-only, or not
|
||||
training data
|
||||
|
||||
Training readiness is per organ. A bundle can be useful for responder,
|
||||
appraiser, mutator, relationship/perception updater, and coordinator training
|
||||
while still being useless for branch compiler, IF artifact reviewer, or visual
|
||||
replay training. Count only the receipts that actually exist.
|
||||
|
||||
`pallas-training-loop-v0` is the first pilot of this convention. It rebuilds one
|
||||
Pallas threshold beat as a separate derivative and leaves the Pallas reference
|
||||
fixture immutable.
|
||||
|
||||
## Soft Organs
|
||||
|
||||
These organs should be treated as model-training targets, not permanent
|
||||
hand-authored rule nests.
|
||||
|
||||
### Coordinator Or Story Runtime
|
||||
|
||||
Glues scenes together, maintains world state, chooses which machinery runs, and
|
||||
emits the connective prose around character turns.
|
||||
|
||||
In training-shaped loops, the coordinator should be sandboxed as its own
|
||||
promptable organ. A coordinator worker receives only the scene state, accepted
|
||||
receipts, unresolved hooks, available machinery, branch affordances, tonal
|
||||
intent, author constraints, and game-engine-shaped state surfaces it is allowed
|
||||
to use. It then proposes the next beat, acting character, machinery calls, glue
|
||||
prose, and candidate state changes.
|
||||
|
||||
Codex remains the meta-coordinator while the system is being built: it scopes
|
||||
the task, prepares the coordinator-visible input, launches the sandboxed
|
||||
coordinator, reviews the output, labels interventions, and wires structured
|
||||
state between organs. Codex-authored coordinator artifacts are useful draft
|
||||
material, but they are not raw coordinator training data unless the coordinator
|
||||
input/output seam was actually sandboxed.
|
||||
|
||||
This is a trainable artifact too. Do not let coordinator judgment evaporate into
|
||||
chat history just because a large model is currently doing the stage management
|
||||
with jazz hands and plausible deniability.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- scene setup receipts
|
||||
- exact coordinator-visible input packet
|
||||
- raw sandboxed coordinator output
|
||||
- parsed and reviewed coordinator artifact
|
||||
- coordinator plans
|
||||
- selected next scene or next beat
|
||||
- chosen acting agent
|
||||
- selected tools or soft organs invoked
|
||||
- world-state refs read
|
||||
- world-state changes proposed
|
||||
- unresolved hooks carried forward
|
||||
- branch merge or branch split decisions
|
||||
- authored glue prose
|
||||
- rejected glue prose
|
||||
- reviewer notes about pacing, legibility, state continuity, lore grounding,
|
||||
game usefulness, and machinery misuse
|
||||
|
||||
The coordinator output should be shaped for a future game engine. Even while the
|
||||
prototype lives in model imagination and authored prose, it should preserve
|
||||
structured refs for:
|
||||
|
||||
- scene id
|
||||
- location id
|
||||
- time or sequence marker
|
||||
- participating agents
|
||||
- active objects
|
||||
- active resources
|
||||
- public world facts
|
||||
- unresolved facts
|
||||
- branch flags
|
||||
- event log refs
|
||||
- state fixture refs
|
||||
- consequences awaiting review
|
||||
|
||||
Glue prose is part of the artifact. It is not canonical state, but it is useful
|
||||
training signal for how scenes are bridged, how pressure is restated without
|
||||
exposition sludge, and how a procedural branch becomes readable narrative.
|
||||
|
||||
Coordinator and narrator intelligence are allowed to exceed character
|
||||
intelligence. This is how opaque high-context scenes become legible without
|
||||
flattening the characters. If a Lucent negotiator, influencer, or feed operator
|
||||
is performing layered reputation warfare, the coordinator may explain the
|
||||
maneuver in reader-facing prose, but the explanation is not automatically
|
||||
available to the character who performed or witnessed it. Character cognition
|
||||
still comes from projected state: intelligence, social acuity, training,
|
||||
cultural scripts, stress, and blind spots.
|
||||
|
||||
The coordinator may propose state changes, scene transitions, and tool calls. It
|
||||
does not bypass deterministic gates. It does not silently decide hidden facts. It
|
||||
does not mutate another organ's private output after review. It conducts the
|
||||
little orchestra; it does not eat the violins.
|
||||
|
||||
### Projector
|
||||
|
||||
Turns canonical state, perceived state, memory, culture, scene pressure, and
|
||||
affordances into character-local operating context.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- projected local context records
|
||||
- projection examples
|
||||
- prompt-rendering audits
|
||||
- leakage failures
|
||||
- source-grounding failures
|
||||
|
||||
The projector does not write final prose. It prepares the local pressure surface
|
||||
for an acting model.
|
||||
|
||||
### Character Agent Or Responder
|
||||
|
||||
Chooses what a character does next from local awareness.
|
||||
|
||||
This includes speech and non-speech action. The agent may speak, wait, leave,
|
||||
hide evidence, reveal evidence, spend a resource, attack, comply, defect,
|
||||
bargain, comfort, or do nothing useful because people are built badly.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- exact responder-visible input packet
|
||||
- hidden context refs, without hidden content
|
||||
- raw responder output
|
||||
- coordinator interventions or repairs
|
||||
- action-choice receipts
|
||||
- candidate branch sets
|
||||
- accepted and rejected character turns
|
||||
- spoken text when speech occurs
|
||||
- observable non-speech action
|
||||
- actor intent
|
||||
- constraints used
|
||||
- constraints violated
|
||||
- reviewer notes about voice, lore, embodiment, agency, and scene fit
|
||||
|
||||
This model should be fine-tuned or adapted on a corpus of strong Aetheria
|
||||
responses spread across the timeline. The goal is not a bolt-on lore module.
|
||||
The goal is for the responder to internalize the shape of the setting: what
|
||||
people assume, what institutions feel like, which pressures are normal, which
|
||||
metaphors appear, what bodies can do, and how the universe tends to hurt people
|
||||
while insisting it is only doing math.
|
||||
|
||||
That reduces lore handholding at runtime. It does not remove character-local
|
||||
context. The tuned responder still needs the specific room, speaker, listener,
|
||||
state, memory, affordances, and known facts for the current turn.
|
||||
|
||||
### Appraiser
|
||||
|
||||
Reads an observable event from one participant's local perspective and proposes
|
||||
how that participant interprets it. This organ is not only for fictional scene
|
||||
mutation. It is also one of the core paths by which VoidBot becomes better at
|
||||
reading people: noticing what a human did or said, forming a grounded opinion
|
||||
without pretending certainty, and revising that opinion as evidence accumulates.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- observed event
|
||||
- participant-local knowledge
|
||||
- private interpretation
|
||||
- misread or correct-read label
|
||||
- emotional appraisal
|
||||
- relationship implications
|
||||
- belief implications
|
||||
- memory candidate
|
||||
- human-facing opinion update when applicable: trust, concern, irritation,
|
||||
respect, caution, attachment, boundary pressure, expected future behavior, and
|
||||
what evidence would change the read
|
||||
- reviewer corrections
|
||||
|
||||
The appraiser should not know canonical private state except where that
|
||||
participant knows or believes it. Fundamental attribution bias belongs here. For
|
||||
VoidBot, this means the model must distinguish an observed human behavior from a
|
||||
durable claim about who that human is, and should preserve uncertainty, repair
|
||||
signals, and evidence needed to revise the opinion later.
|
||||
|
||||
### State Mutator
|
||||
|
||||
Turns reviewed appraisal into bounded state changes.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- prior state refs
|
||||
- event refs
|
||||
- appraisal refs
|
||||
- proposed state deltas
|
||||
- accepted deltas
|
||||
- rejected deltas
|
||||
- mutation authority notes
|
||||
- reviewer rationale
|
||||
|
||||
The early system should keep this manual and audited. Later a model can learn
|
||||
common deltas, but deterministic gates still own schema, bounds, and authority.
|
||||
|
||||
### Relationship And Social Perception Classifier
|
||||
|
||||
Updates perceived overlays and relationship stance from repeated evidence. This
|
||||
classifier should serve both Aetheria agents and VoidBot's long-running human
|
||||
relationships; the same machinery that lets Sella update her read on Maer should
|
||||
also let VoidBot update its read on a specific user without collapsing into
|
||||
either sycophancy or a permanent first-impression mugshot.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- prior relationship stance
|
||||
- event and appraisal history
|
||||
- proposed movement
|
||||
- confidence movement
|
||||
- stale or reinforced impressions
|
||||
- prejudice, stakes, memory, and social-reinforcement drivers
|
||||
- reviewer notes
|
||||
|
||||
This is where confidence belongs. Do not grow a second correction-resistance
|
||||
organ because the first one got lonely.
|
||||
|
||||
### Aetheria Responder Corpus
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- source-grounded scene examples
|
||||
- factional speech and pressure examples
|
||||
- institution decision examples
|
||||
- embodiment/interface examples
|
||||
- negative examples where generic sci-fi assumptions were rejected
|
||||
- reviewer notes tied to AetheriaLore paths
|
||||
|
||||
This corpus trains the character agent/responder directly. It is how a chosen
|
||||
base model learns Aetheria's native assumptions rather than waiting for every
|
||||
prompt to drag the whole lore vault behind it in a little wagon.
|
||||
|
||||
The tuned responder should learn the smell of the setting: Dominion severity,
|
||||
Aya-coded care pressure, Navigator embodiment, corporate certification culture,
|
||||
insurer cruelty, upload personhood fights, factional misreads, and the dry
|
||||
little horrors of systems pretending to be neutral.
|
||||
|
||||
It still must receive character-local context at runtime. Fine-tuning gives it
|
||||
priors, not omniscience.
|
||||
|
||||
### Branch Compiler
|
||||
|
||||
Turns scene intent, reviewed branch candidates, consequence packets, fold plans,
|
||||
and visual continuity requirements into playable Ink plus sidecar receipts and,
|
||||
when illustrated replay is in scope, a separate visual plan artifact.
|
||||
|
||||
The Branch Compiler is trainable because good branching structure is not just
|
||||
syntax. It needs to decide where choices belong, which consequences can fold
|
||||
into compact variables, which choices deserve route splits, which callbacks must
|
||||
exist later, and how visual modifiers attach to actual state. Deterministic code
|
||||
can validate Ink and refs. It cannot, by itself, know whether a branch feels
|
||||
meaningful or whether convergence is lying.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- fixture brief
|
||||
- source-grounded lore digest refs
|
||||
- input branch candidates
|
||||
- reviewed consequence packets
|
||||
- fold plan
|
||||
- generated Ink
|
||||
- generated sidecar
|
||||
- generated visual plan
|
||||
- compiler notes
|
||||
- variable set/read map
|
||||
- route split or fold rationale
|
||||
- visual prompt handles
|
||||
- reviewer findings and repair labels
|
||||
|
||||
The compiler does not own hidden character truth, participant appraisal, or
|
||||
durable social mutation. It materializes structure from already reviewed or
|
||||
explicitly marked draft material.
|
||||
|
||||
### IF Artifact Reviewer
|
||||
|
||||
Audits playable branch artifacts before they enter the accepted corpus.
|
||||
|
||||
This reviewer is separate from the branch compiler because compilers are very
|
||||
good at producing valid little lies. The reviewer checks whether tracked state
|
||||
actually matters, whether choices change future affordances or outcomes, whether
|
||||
folds preserve callbacks, whether visual modifiers correspond to real branch
|
||||
state, and whether endings read the variables the story told us were important.
|
||||
|
||||
Training artifacts:
|
||||
|
||||
- Ink fixture under review
|
||||
- sidecar under review
|
||||
- coordinator artifact
|
||||
- compiler notes
|
||||
- validation output
|
||||
- accepted/rejected decision
|
||||
- severity-ranked findings
|
||||
- failure labels such as `potemkin_state`, `cosmetic_choice`, `fake_fold`,
|
||||
`missing_state_read`, `state_named_instead_of_checked`,
|
||||
`visual_callback_missing`, and `ending_ignores_major_state`
|
||||
- required fixes
|
||||
- repaired artifact refs, when available
|
||||
|
||||
Early branch-and-fold reviews are seed material for this organ because they
|
||||
caught decorative variables before they were allowed to become training sludge.
|
||||
|
||||
### Institution, Faction, And Consumer Decision Models
|
||||
|
||||
Eventually Ghostlight should support economic behavior and major faction
|
||||
decision-making inside a simulated game world.
|
||||
|
||||
Training artifacts should preserve why agents:
|
||||
|
||||
- buy
|
||||
- refuse
|
||||
- trade
|
||||
- hoard
|
||||
- comply
|
||||
- defect
|
||||
- conceal
|
||||
- reveal
|
||||
- punish
|
||||
- help
|
||||
- lobby
|
||||
- exploit
|
||||
- delay
|
||||
- sacrifice
|
||||
|
||||
The artifact should preserve perceived cost, scarcity, status pressure,
|
||||
reputation risk, expected future contact, faction norms, institutional authority,
|
||||
and moral injury when present.
|
||||
|
||||
Do not train a dialogue-only pet if the adult machine needs to decide whether a
|
||||
clinic buys counterfeit cryo valves under blockade.
|
||||
|
||||
## Artifact Status
|
||||
|
||||
Soft artifacts should use explicit review status:
|
||||
|
||||
- `accepted`
|
||||
- `accepted_as_draft`
|
||||
- `useful_needs_revision`
|
||||
- `rejected`
|
||||
- `superseded`
|
||||
|
||||
Rejected artifacts remain valuable when the failure label is clear. A rejected
|
||||
Navigator action that makes a cetacean walk across a human hallway is not trash.
|
||||
It is a negative example with teeth.
|
||||
|
||||
## Training Corpus Families
|
||||
|
||||
Ghostlight should expect several related corpora rather than one heroic pile of
|
||||
JSONL wearing a crown.
|
||||
|
||||
- projection corpus
|
||||
- projected-local-context corpus
|
||||
- coordinator/story-runtime corpus
|
||||
- character-action corpus
|
||||
- dialogue/prose response corpus
|
||||
- participant-appraisal corpus
|
||||
- state-mutation corpus
|
||||
- relationship/perception corpus
|
||||
- lore-grounding digest corpus
|
||||
- Aetheria responder corpus
|
||||
- Ink branch annotation corpus
|
||||
- Branch compiler corpus
|
||||
- IF artifact review corpus
|
||||
- institution/faction/consumer decision corpus
|
||||
- negative-example corpus
|
||||
|
||||
Some examples can belong to multiple corpora through references. Do not duplicate
|
||||
truth; link artifacts by ids and source refs.
|
||||
|
||||
## Aetheria-Tuned Agent Model
|
||||
|
||||
The character agent itself is a training target.
|
||||
|
||||
A sensible long-term stack looks like this:
|
||||
|
||||
```text
|
||||
AetheriaLore source corpus
|
||||
-> source-grounded digests and scene fixtures
|
||||
-> coordinator scene plans and glue-prose receipts
|
||||
-> reviewed Ghostlight action/dialogue/appraisal artifacts
|
||||
-> Aetheria-tuned responder or agent model
|
||||
-> specialized coordinator/projector/appraiser/mutator students
|
||||
-> deterministic runtime gates and validators
|
||||
```
|
||||
|
||||
The Aetheria-tuned agent model should learn setting priors and voice ecology:
|
||||
what institutions feel like, how factions misread each other, what bodies can do,
|
||||
what pressure sounds like when nobody in the room has enough authority to fix the
|
||||
machine.
|
||||
|
||||
It should not replace Ghostlight state. It should not decide hidden facts. It
|
||||
should not mutate world state directly. It acts inside the local context and
|
||||
affordances Ghostlight provides.
|
||||
|
||||
## Deterministic Gates Stay Deterministic
|
||||
|
||||
Training models does not remove hard gates.
|
||||
|
||||
Keep these as code:
|
||||
|
||||
- schema validation
|
||||
- source provenance checks
|
||||
- speaker-local visibility
|
||||
- prompt leakage checks
|
||||
- action enum validation
|
||||
- body-affordance validation when source-backed
|
||||
- mutation authority
|
||||
- object custody
|
||||
- resource and world-state legality
|
||||
- Ink compilation
|
||||
- artifact review status requirements
|
||||
|
||||
A model can propose. The runtime decides what is legal, what is visible, what is
|
||||
accepted, and what becomes state.
|
||||
|
||||
## Near-Term Policy
|
||||
|
||||
During prototype work:
|
||||
|
||||
1. Keep fuzzy updates manual and reviewed.
|
||||
2. Emit every model call as a receipt when it teaches something.
|
||||
3. Store accepted and rejected examples with failure labels.
|
||||
4. Prefer narrow artifacts over one vague transcript blob.
|
||||
5. When a prompt fix reveals a reusable failure mode, promote it into validator
|
||||
logic or artifact schema.
|
||||
6. When a model repeatedly performs a soft judgment well, mark that organ as a
|
||||
candidate for distillation or fine-tuning.
|
||||
7. When it repeatedly fails in a patterned way, save the failures. The beast is
|
||||
drawing us a map in crayon.
|
||||
|
||||
The point is not to automate everything today. The point is to stop losing the
|
||||
manual intelligence we are already spending.
|
||||
|
|
@ -1,646 +0,0 @@
|
|||
# Technology And Item Manifest Plan
|
||||
|
||||
Ghostlight future exploration should produce more than scenes. It should produce
|
||||
the technical and economic knowledge Aetheria needs for gameplay: what items can
|
||||
exist, who can build them, what they are made of, which assemblies can be
|
||||
swapped, what supply chains they depend on, and which factions start with which
|
||||
tech bases.
|
||||
|
||||
This is not separate from story generation. Major-player decisions, faction
|
||||
births, branch pressures, and technological discoveries shape the economy. If a
|
||||
branch discovers a better aetheric emitter, that should eventually change ship
|
||||
builds, market dependencies, quest hooks, factory politics, salvage value, and
|
||||
what a desperate mechanic can realistically bolt into a hull without creating a
|
||||
beautiful obituary.
|
||||
|
||||
The economy is bigger than ship parts. Players moving freight should carry the
|
||||
ordinary manufactured life of colonies too: habitat fittings, clinic equipment,
|
||||
interface furniture, ritual goods, industrial consumables, food-processing
|
||||
systems, maintenance packs, education kits, luxury nonsense, and all the other
|
||||
things a settlement consumes without ever bolting them into a laser. A trade
|
||||
economy made only of gear components would be a very fancy skeleton.
|
||||
|
||||
## Source Premise
|
||||
|
||||
Aetheria game design wants ships to be tools, homes, status symbols, and
|
||||
material compromises. Customization is driven by components with distinct
|
||||
behavior, manufacturers with priorities and specialties, and vessels tuned for
|
||||
violence, trade, survival, logistics, or prestige.
|
||||
|
||||
Ghostlight should turn worldbuilding exploration into the data needed to support
|
||||
that design.
|
||||
|
||||
## Player Fantasy
|
||||
|
||||
The deep gear model is not a loot-affix treadmill. The fantasy is provenance.
|
||||
|
||||
A player hunting for the ultimate laser should be able to trace the item through
|
||||
its parts, makers, technologies, and accidents:
|
||||
|
||||
- the emitter assembly came from a short-lived manufacturer whose process was
|
||||
only stable for nine months
|
||||
- the beam-control wafer uses a regulatory-buried fabrication step no successor
|
||||
company wants to admit worked
|
||||
- the thermal rejection assembly is sturdy but mediocre, quietly strangling the
|
||||
whole weapon
|
||||
- a pirate rebuild replaced the targeting interface with a counterfeit part
|
||||
that improves lock speed while making maintenance ugly
|
||||
- a factional embargo means the good driver wafers are technically illegal,
|
||||
practically everywhere, and therefore excellent quest bait
|
||||
|
||||
An "ultimate item" is not just the best blueprint. It is the best realized
|
||||
artifact: the right blueprint, built from the right assemblies, with the right
|
||||
quality, provenance, manufacturer practice, replacement history, and operating
|
||||
conditions. If a player wants to rebuild it as the best it can be, the game
|
||||
should let them hunt the supply chain: salvage yards, old corporate stockpiles,
|
||||
specialist shops, faction arsenals, black-market refits, inherited ships,
|
||||
forgotten factories, and small wars over a beam-control wafer some dead
|
||||
manufacturer only made for nine months before the board got eaten by regulators.
|
||||
|
||||
This is why Ghostlight matters. A spreadsheet can name a laser. Ghostlight should
|
||||
help explain why that laser exists, who can build it, which parts are worth
|
||||
killing for, why a particular instance is special, and what stories fall out of
|
||||
trying to make it better.
|
||||
|
||||
## Content Refinery
|
||||
|
||||
The old bottleneck was not ideas. Ideas are cheap. Volunteer design goblins can
|
||||
produce a hill of them before lunch and leave the database work sitting there
|
||||
like a little corpse.
|
||||
|
||||
The real bottleneck is converting taste, lore, mechanics, and future-tech
|
||||
imagination into loadable, reviewable, schema-shaped content. Ghostlight's
|
||||
technology pipeline should act as a content refinery:
|
||||
|
||||
1. Ground the request in Aetheria lore: faction, era, location, tech base,
|
||||
industrial limits, political pressure, and known source constraints.
|
||||
2. Decompose the concept into item families, assemblies, subassemblies,
|
||||
components, colony-consumed manufactured goods, materials, processes,
|
||||
tooling, facilities, expertise, demand profiles, and supply-chain
|
||||
dependencies.
|
||||
3. Map each candidate onto the Aetheria-Economy blueprint model:
|
||||
`SimpleCommodityData`, `CompoundCommodityData`, `ConsumableItemData`,
|
||||
`GearData`, or another `EquippableItemData` subclass.
|
||||
4. Define performance intent through behaviors, `PerformanceStat` fields,
|
||||
quality sensitivity, failure modes, upgrade surfaces, and operating
|
||||
constraints.
|
||||
5. Define realized-instance variation: assembly provenance, manufacturer
|
||||
branding, quality, repair history, counterfeit substitutions, salvage scars,
|
||||
and ownership chain.
|
||||
6. Emit reviewable artifacts that separate source facts, inferred engineering,
|
||||
branch-local assumptions, engine-schema gaps, rejected ideas, and accepted
|
||||
candidates.
|
||||
7. Export or hand off accepted candidates as database-shaped content rather than
|
||||
another swamp of clever prose someone has to manually translate later.
|
||||
|
||||
The goal is not infinite content sludge. The goal is volume with preserved taste:
|
||||
many plausible blueprints, variants, manufacturers, counterfeit versions,
|
||||
salvage stories, and supply-chain hooks that still feel like Aetheria.
|
||||
|
||||
## Economy Schema Target
|
||||
|
||||
Ghostlight should target the existing Aetheria-Economy item model instead of
|
||||
inventing a parallel item taxonomy.
|
||||
|
||||
The concrete source is:
|
||||
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemData.cs`
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemInstance.cs`
|
||||
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\CultCache\`
|
||||
|
||||
The important split:
|
||||
|
||||
- `ItemData` and subclasses are CultCache-backed technological blueprint
|
||||
records: a way of constructing a thing in the game, including the parameters,
|
||||
behaviors, stat curves, and category fields that make that construction
|
||||
meaningful.
|
||||
- `ItemInstance` and subclasses are runtime objects that link to definition
|
||||
records and represent manufactured artifacts in the world.
|
||||
- Ghostlight item manifests should normally propose blueprint/data records, not
|
||||
individual runtime artifacts.
|
||||
- Runtime instances belong in simulation, inventory, scene state, cargo, loot,
|
||||
and world-state fixtures. They are where realized quality, supply-chain
|
||||
provenance, manufacturer branding, and assembly history can make two items
|
||||
built from the same blueprint differ in practice.
|
||||
|
||||
Blueprint classes currently relevant to Ghostlight:
|
||||
|
||||
- `SimpleCommodityData`
|
||||
- basic fungible resources such as steel, hydrogen, volatiles, or feedstock
|
||||
- maps to `SimpleCommodity` runtime instances with quantity
|
||||
- `CompoundCommodityData`
|
||||
- technological blueprints for crafted commodities, assemblies,
|
||||
subassemblies, processed materials, colony-consumed trade goods, and other
|
||||
manufactured economic units
|
||||
- not every compound commodity is an input to higher-level gear; many are
|
||||
finished manufactured goods consumed by colonies, habitats, institutions,
|
||||
ships, clinics, farms, sanctuaries, and cultural communities
|
||||
- maps to `CompoundCommodity` runtime instances whose quality and provenance
|
||||
can affect downstream items, settlement demand, service quality, comfort,
|
||||
productivity, legitimacy, maintenance load, or faction-specific living
|
||||
standards
|
||||
- `ConsumableItemData`
|
||||
- usable consumables with behaviors, duration, stackability, icon, and
|
||||
effectiveness curve
|
||||
- maps to `ConsumableItem` runtime instances
|
||||
- `GearData`
|
||||
- technological blueprints for usable equippable items with hardpoint,
|
||||
behavior, durability, thermal, audio, schematic, and action-bar fields
|
||||
- maps through `EquippableItem` runtime instances whose realized quality,
|
||||
provenance, manufacturer, and installed assembly lineage can alter actual
|
||||
performance
|
||||
- `WeaponItemData`, `HullData`, `CargoBayData`, `DockingBayData`, and other
|
||||
`EquippableItemData` subclasses are specialized top-level usable item
|
||||
definitions.
|
||||
|
||||
Manifest artifacts should therefore use Ghostlight's conceptual decomposition to
|
||||
produce reviewed candidate technological blueprints for Aetheria-Economy data
|
||||
classes:
|
||||
|
||||
```text
|
||||
material/feedstock -> SimpleCommodityData
|
||||
component/subassembly/assembly -> CompoundCommodityData
|
||||
manufactured colony trade good -> CompoundCommodityData
|
||||
consumable usable item -> ConsumableItemData
|
||||
equippable usable item -> GearData or another EquippableItemData subclass
|
||||
weapon -> WeaponItemData
|
||||
hull/frame/cargo/docking item -> HullData, CargoBayData, DockingBayData, etc.
|
||||
runtime cargo, inventory, or freight -> SimpleCommodity, CompoundCommodity,
|
||||
ConsumableItem, or EquippableItem instances
|
||||
```
|
||||
|
||||
The conceptual layers below are still useful. They describe how Ghostlight
|
||||
breaks a technology into reviewable engineering, logistics, and gameplay pieces.
|
||||
They are not a replacement database schema. The output should be able to say
|
||||
which Aetheria-Economy blueprint class each candidate wants to become, what
|
||||
fields are source-backed, what fields are inferred, and which fields require
|
||||
new engine schema before they can be loaded.
|
||||
|
||||
`PerformanceStat` is the core bridge from blueprint to realized item behavior.
|
||||
Blueprint data defines stat ranges and quality sensitivity; runtime instances
|
||||
and equipped/active contexts determine actual evaluation through quality,
|
||||
durability, heat, effectiveness, and modifiers. Ghostlight manifests should
|
||||
therefore preserve the difference between:
|
||||
|
||||
- blueprint-level performance intent
|
||||
- assembly or subassembly contribution to quality and stat behavior
|
||||
- realized instance provenance and manufacturer branding
|
||||
- in-world operating conditions that change evaluated performance
|
||||
|
||||
If a Ghostlight manifest needs fields Aetheria-Economy does not currently store,
|
||||
mark them as manifest-side metadata or source gaps. Do not pretend CultCache
|
||||
already has slots for every nice idea. The future can have organs; today it has
|
||||
classes.
|
||||
|
||||
## Nebulous Technology Elaboration
|
||||
|
||||
Aetheria already has plenty of named technologies. Many of them are currently
|
||||
high-level lore objects rather than actionable game objects. That is fine for a
|
||||
timeline note. It is not enough for an item blueprint database.
|
||||
|
||||
Ghostlight's exploration pipeline should elaborate existing vague technologies
|
||||
into blueprint candidates.
|
||||
|
||||
For each nebulous technology, produce:
|
||||
|
||||
- source summary
|
||||
- inferred operating principle
|
||||
- gameplay role
|
||||
- item families enabled
|
||||
- assemblies and subassemblies implied
|
||||
- critical components
|
||||
- materials
|
||||
- manufacturing processes
|
||||
- tooling and facility requirements
|
||||
- required expertise
|
||||
- quality tiers
|
||||
- failure modes
|
||||
- maintenance needs
|
||||
- salvage value
|
||||
- counterfeit risk
|
||||
- substitute parts
|
||||
- faction access and restrictions
|
||||
- supply-chain bottlenecks
|
||||
- prerequisite technologies
|
||||
- downstream technologies enabled
|
||||
- branch-local variants
|
||||
- open lore questions
|
||||
|
||||
This is where a phrase like `super-Planckian emitter` stops being a decorative
|
||||
sci-fi noun and becomes a logistics problem with teeth.
|
||||
|
||||
If Ghostlight cannot yet know the exact real physics, it should still propose a
|
||||
consistent fictional engineering stack: what must be fabricated, stabilized,
|
||||
aligned, cooled, calibrated, certified, transported, repaired, and monopolized.
|
||||
The output should be reviewable, revisable, and eventually loadable into a game
|
||||
database.
|
||||
|
||||
Do not hallucinate certainty. Mark inferred engineering as inferred. Mark source
|
||||
facts as source facts. Mark branch-local innovations as branch-local. The
|
||||
manifest is allowed to deepen the world; it is not allowed to pretend an
|
||||
invention arrived without provenance.
|
||||
|
||||
## Item Knowledge Goals
|
||||
|
||||
Every explored technology or item family should be able to answer:
|
||||
|
||||
- what the item does
|
||||
- what gameplay role it serves
|
||||
- what systems it plugs into
|
||||
- what components and subassemblies it requires
|
||||
- which assemblies are swappable upgrade surfaces
|
||||
- whether it is a finished colony-consumed trade good rather than an assembly
|
||||
input
|
||||
- what population, institution, habitat, ship, or faction demand profile
|
||||
consumes it
|
||||
- what service, comfort, legitimacy, safety, productivity, cultural continuity,
|
||||
or maintenance need it satisfies
|
||||
- which materials, processes, or specialists are bottlenecks
|
||||
- which factions or manufacturers can build it
|
||||
- which factions can maintain it but not manufacture it
|
||||
- which factions can counterfeit, salvage, or adapt it
|
||||
- which tech prerequisites unlock it
|
||||
- which branch conditions or discoveries created it
|
||||
- which quests, markets, or conflicts it enables
|
||||
- which blueprint records it should create or update
|
||||
|
||||
## Manifest Object Types
|
||||
|
||||
Use these object layers when producing tech data. Each layer must also declare
|
||||
its intended Aetheria-Economy target class or state that it is manifest metadata
|
||||
only:
|
||||
|
||||
- `item_family`
|
||||
- `item_variant`
|
||||
- `assembly`
|
||||
- `subassembly`
|
||||
- `component`
|
||||
- `material`
|
||||
- `process`
|
||||
- `tooling`
|
||||
- `expertise`
|
||||
- `facility`
|
||||
- `manufacturer`
|
||||
- `supply_node`
|
||||
- `upgrade_slot`
|
||||
- `blueprint`
|
||||
- `tech_prerequisite`
|
||||
- `quality_tier`
|
||||
- `failure_mode`
|
||||
|
||||
An `item_family` is a broad class, such as laser weapon, radiator, aetheric
|
||||
drive, cryo valve, sensor mast, or habitat seal.
|
||||
|
||||
An `item_variant` is a concrete build with stats, faction/manufacturer flavor,
|
||||
quality tier, and branch-specific assumptions.
|
||||
|
||||
An `assembly` is a replaceable functional unit. For a laser, emitter assembly,
|
||||
power conditioning assembly, thermal rejection assembly, beam-control assembly,
|
||||
targeting interface, and housing are likely assemblies.
|
||||
|
||||
A `subassembly` is a nested unit inside an assembly. The emitter assembly may
|
||||
include gain medium, field lattice, focusing microstructure, alignment actuator,
|
||||
control wafer, and contamination seal.
|
||||
|
||||
A `component` is the smallest useful gameplay/logistics unit worth tracking.
|
||||
Do not split screws unless screws are the point of the quest. The machine has
|
||||
standards. Barely.
|
||||
|
||||
A `manufactured_trade_good` is a finished compound commodity whose primary role
|
||||
is economic consumption rather than becoming a higher-level gear assembly. It
|
||||
may still have components and quality tiers, but the point is that colonies,
|
||||
ships, habitats, clinics, sanctuaries, temples, schools, or faction enclaves
|
||||
consume it as part of ordinary life and institutional function.
|
||||
|
||||
Example: a Navigator wet-interface cradle would likely be a
|
||||
`CompoundCommodityData` candidate. It is not a ship weapon or a laser component.
|
||||
It is a manufactured interface good consumed by Navigator habitats, route
|
||||
annexes, mixed-species relay stations, sanctuary registrars, and other spaces
|
||||
where cetacean or aquatic-adapted cognition must participate in shared
|
||||
procedure. Its demand profile might be driven by Navigator population share,
|
||||
route-steward institutions, translation infrastructure, acoustic signaling
|
||||
standards, maintenance water quality, clinic/sanctuary access, and local
|
||||
prestige around dignified embodiment. Quality might affect comfort, signal
|
||||
clarity, translation latency, stress load, infection risk, repair frequency, or
|
||||
institutional legitimacy.
|
||||
|
||||
A `blueprint` is the technological construction record represented by the
|
||||
appropriate Aetheria-Economy `*Data` class. It should be specific enough for a
|
||||
game system to evaluate whether a faction, factory, ship, or player can build
|
||||
the item, and how sub-assemblies affect performance through quality-sensitive
|
||||
stats and behavior parameters.
|
||||
|
||||
Ghostlight manifests still need explicit assembly trees, compatibility rules,
|
||||
supply-chain dependencies, and manufacturing gates. If the current engine schema
|
||||
does not expose those fields directly, keep them as manifest metadata or
|
||||
engine-schema gaps attached to the target `*Data` blueprint rather than
|
||||
pretending they are separate from the blueprint concept.
|
||||
|
||||
A `tech_prerequisite` is a required discovery, process, standard, license,
|
||||
facility, or expertise gate.
|
||||
|
||||
A `quality_tier` describes meaningful gameplay variation: crude, standard,
|
||||
premium, military, experimental, branch-exotic, counterfeit, degraded, salvaged,
|
||||
or other reviewed tiers.
|
||||
|
||||
A `failure_mode` describes what breaks, how it breaks, and what that failure
|
||||
costs in gameplay, safety, logistics, reputation, or maintenance.
|
||||
|
||||
## Example Pattern: Laser Upgrade Surface
|
||||
|
||||
A laser design should be represented so a higher-tech emitter assembly can alter
|
||||
performance without rewriting the whole item.
|
||||
|
||||
Example structure:
|
||||
|
||||
```text
|
||||
item_family: laser_weapon
|
||||
item_variant: sol_pattern_industrial_laser_mk2
|
||||
assembly: emitter_assembly
|
||||
subassembly: gain_medium
|
||||
subassembly: focusing_lattice
|
||||
subassembly: alignment_actuator
|
||||
assembly: power_conditioning_assembly
|
||||
assembly: thermal_rejection_assembly
|
||||
assembly: beam_control_assembly
|
||||
assembly: targeting_interface
|
||||
assembly: housing_and_mount
|
||||
```
|
||||
|
||||
A branch-local future might discover an aether-stabilized emitter assembly. That
|
||||
should produce:
|
||||
|
||||
- a new assembly or subassembly record
|
||||
- prerequisite discoveries
|
||||
- materials and tooling
|
||||
- compatibility rules with older laser families
|
||||
- performance changes
|
||||
- heat, stability, cost, legality, maintenance, or supply-chain consequences
|
||||
- factions able to manufacture or adapt it
|
||||
- quests or market events unlocked by scarcity
|
||||
|
||||
## Example Pattern: Super-Planckian Emitter
|
||||
|
||||
When a source names a technology like `super-Planckian emitter`, Ghostlight
|
||||
should elaborate it into at least a candidate blueprint stack.
|
||||
|
||||
Example working decomposition:
|
||||
|
||||
```text
|
||||
item_family: exotic_emitter
|
||||
item_variant: super_planckian_emitter_core_candidate
|
||||
assembly: emitter_lattice_assembly
|
||||
subassembly: boundary_condition_lattice
|
||||
subassembly: phase-lock microstructure
|
||||
subassembly: exotic containment frame
|
||||
assembly: field_drive_assembly
|
||||
subassembly: pulse shaper
|
||||
subassembly: high-stability driver wafer
|
||||
subassembly: feedback quench circuit
|
||||
assembly: thermal_and_causality_safety_assembly
|
||||
subassembly: transient heat sink
|
||||
subassembly: event-spike monitor
|
||||
subassembly: failsafe isolation gate
|
||||
assembly: calibration_and_control_assembly
|
||||
subassembly: metrology package
|
||||
subassembly: firmware governor
|
||||
subassembly: certification seal
|
||||
```
|
||||
|
||||
That decomposition is not automatically canon. It is a candidate manifest. A
|
||||
reviewer can accept, reject, revise, or mark source gaps. The important point is
|
||||
that the technology now has places for supply chains to attach:
|
||||
|
||||
- lattice fabrication
|
||||
- exotic containment materials
|
||||
- high-stability driver wafers
|
||||
- metrology packages
|
||||
- safety gates
|
||||
- certification regimes
|
||||
- specialist calibration expertise
|
||||
- heat rejection and failure containment
|
||||
|
||||
Once those exist, the game can ask useful questions:
|
||||
|
||||
- who owns lattice fabrication?
|
||||
- who can counterfeit the driver wafer?
|
||||
- what happens if the safety gate is downgraded?
|
||||
- can a higher-tier emitter assembly fit into an older laser housing?
|
||||
- which faction can maintain it after three jumps and one bad decision?
|
||||
- what quest appears when the certification seal supply collapses?
|
||||
|
||||
## Pre-Elysium Starting Tech
|
||||
|
||||
Pre-Elysium tech is not a monolith. Everyone was shunted into Elysium together,
|
||||
but not every faction brought the same industrial base, doctrine, standards,
|
||||
stockpiles, expertise, or manufacturing rights.
|
||||
|
||||
Ghostlight should generate starting tech manifests for the single-history Sol
|
||||
artifact:
|
||||
|
||||
- base technologies available before the Rupture
|
||||
- faction access and restrictions
|
||||
- manufacturing standards
|
||||
- dominant manufacturers
|
||||
- known substitute parts
|
||||
- logistics bottlenecks
|
||||
- maintenance dependencies
|
||||
- doctrine-driven design differences
|
||||
- legal restrictions and export controls
|
||||
- black-market or pirate adaptations
|
||||
- blueprint records for starting equipment, ship systems, weapons, habitats,
|
||||
medical systems, industrial tooling, logistics infrastructure, colony
|
||||
furnishings, habitat equipment, institutional interface goods, and finished
|
||||
manufactured trade goods consumed by settlements
|
||||
|
||||
This matters because Elysium branches inherit different practical capabilities
|
||||
from the same shared Sol history depending on what each faction controls in that
|
||||
branch.
|
||||
|
||||
## Post-Rupture Innovation
|
||||
|
||||
Post-Rupture innovation is branch-local canon indexed by lineage.
|
||||
|
||||
When exploration discovers a technology, the manifest should record:
|
||||
|
||||
- branch id and lineage
|
||||
- prior conditions
|
||||
- triggering event or pressure
|
||||
- involved factions, inventors, institutions, or nonhuman actors
|
||||
- source-backed post-Elysium concepts used
|
||||
- generated branch assumptions
|
||||
- prerequisite tech and discoveries
|
||||
- item families affected
|
||||
- assemblies or subassemblies created
|
||||
- supply chain changes
|
||||
- market/faction consequences
|
||||
- quest hooks opened
|
||||
- sibling branches where this innovation does not exist or differs
|
||||
- blueprint records created or modified
|
||||
- compatibility with pre-Elysium assemblies
|
||||
|
||||
Aetheric drives, necrotech interfaces, spirit-mediated sensors, temporal memory
|
||||
archives, pseudospace logistics, and mutable-body support equipment should all
|
||||
leave this kind of trail when explored.
|
||||
|
||||
## Faction Tech Bases
|
||||
|
||||
For each faction or manufacturer, track:
|
||||
|
||||
- known technologies
|
||||
- restricted technologies
|
||||
- lost or inaccessible technologies
|
||||
- signature design preferences
|
||||
- manufacturing strengths
|
||||
- maintenance weaknesses
|
||||
- doctrine-driven compromises
|
||||
- supply dependencies
|
||||
- export controls
|
||||
- counterfeiting exposure
|
||||
- salvage behavior
|
||||
- branch-local innovations
|
||||
|
||||
A faction can understand a technology without being able to build it. It can
|
||||
install a component without being able to maintain it. It can maintain an item
|
||||
only by depending on a hated supplier. These distinctions are the gameplay.
|
||||
|
||||
## Coordinator Obligations
|
||||
|
||||
The coordinator/story runtime must emit technology and economy knowledge when a
|
||||
scene or branch decision changes the material world.
|
||||
|
||||
Coordinator artifacts should include item-manifest deltas when they discover or
|
||||
alter:
|
||||
|
||||
- technology availability
|
||||
- component compatibility
|
||||
- assembly upgrade paths
|
||||
- colony-consumed manufactured goods
|
||||
- demand profiles
|
||||
- materials and manufacturing processes
|
||||
- supply bottlenecks
|
||||
- faction access
|
||||
- market pressure
|
||||
- quest hooks
|
||||
- salvage opportunities
|
||||
- maintenance constraints
|
||||
|
||||
The coordinator is allowed to propose item knowledge. Deterministic validators
|
||||
and reviewer gates decide whether it becomes a manifest update.
|
||||
|
||||
## Training Artifacts
|
||||
|
||||
Tech/item exploration should emit reviewed artifacts for future models:
|
||||
|
||||
- item manifest records
|
||||
- component breakdown records
|
||||
- assembly compatibility records
|
||||
- colony trade-good records
|
||||
- demand-profile records
|
||||
- faction tech-base records
|
||||
- supply-chain dependency records
|
||||
- technology discovery records
|
||||
- branch-local innovation records
|
||||
- economic consequence records
|
||||
- rejected overpowered or unsupported item designs
|
||||
- source-gap records requiring AetheriaLore elaboration
|
||||
|
||||
These artifacts train:
|
||||
|
||||
- coordinator/story runtime
|
||||
- institution/faction decision models
|
||||
- consumer and market behavior models
|
||||
- item-generation models
|
||||
- lore/source evaluators
|
||||
- retrieval models for tech prerequisites and dependencies
|
||||
|
||||
## First Schema Target
|
||||
|
||||
The next concrete schema after coordinator artifacts should be a manifest seam
|
||||
that maps directly onto Aetheria-Economy's technological blueprint classes:
|
||||
|
||||
- `schemas/item-manifest.schema.json`
|
||||
- `schemas/item-blueprint-candidate.schema.json`
|
||||
- `schemas/item-instance-provenance-candidate.schema.json`
|
||||
- `examples/item-manifest/pre-elysium-starting-tech.template.json`
|
||||
- `examples/item-manifest/future-branch-innovation.template.json`
|
||||
- `examples/item-manifest/super-planckian-emitter.candidate.json`
|
||||
- `tools/validate_item_manifests.py`
|
||||
|
||||
Minimum fields:
|
||||
|
||||
- manifest id
|
||||
- fixture lane
|
||||
- branch lineage, if any
|
||||
- source refs
|
||||
- source-backed facts
|
||||
- inferred engineering
|
||||
- open lore gaps
|
||||
- item family or technology id
|
||||
- object layer
|
||||
- consumption role: assembly input, finished trade good, consumable, gear, or
|
||||
metadata-only
|
||||
- target Aetheria-Economy class
|
||||
- target blueprint id, when updating an existing record
|
||||
- candidate blueprint fields
|
||||
- blueprint candidate ids
|
||||
- instance provenance fields, when simulating manufactured artifacts
|
||||
- parent assembly refs
|
||||
- child component refs
|
||||
- materials/process/tooling/facility refs
|
||||
- colony, habitat, institutional, cultural, or factional consumption drivers
|
||||
- faction/manufacturer access
|
||||
- prerequisites
|
||||
- bottlenecks
|
||||
- compatibility rules
|
||||
- quality tiers
|
||||
- failure modes
|
||||
- gameplay effects
|
||||
- economic consequences
|
||||
- quest hooks
|
||||
- engine-schema gaps
|
||||
- review status
|
||||
|
||||
Candidate blueprint fields should be shaped around existing data classes first:
|
||||
|
||||
- shared `ItemData` blueprint fields: `Name`, `Description`, `Manufacturer`,
|
||||
`Mass`, `Shape`,
|
||||
`SpecificHeat`, `Conductivity`, `Price`
|
||||
- `SimpleCommodityData`: `MaxStack`, `Category`
|
||||
- `CompoundCommodityData`: `DemandProfile`, `Category`
|
||||
- `ConsumableItemData`: `Behaviors`, `Stackable`, `Duration`, `Icon`,
|
||||
`Effectiveness`
|
||||
- `EquippableItemData` and subclasses: `Schematic`, `Behaviors`, `Durability`,
|
||||
temperature fields, heat performance curve, resilience, icons, sound data,
|
||||
`HardpointType`, and subclass-specific fields such as weapon range, caliber,
|
||||
fire type, hull hardpoints, cargo interiors, or docking size
|
||||
|
||||
Ghostlight may propose additional manifest fields, but validators should label
|
||||
them as metadata, engine-gap, or rejected. Do not silently smuggle non-engine
|
||||
fields into supposed CultCache records.
|
||||
|
||||
## Data Generation Policy
|
||||
|
||||
During worldbuilding exploration:
|
||||
|
||||
1. If a scene implies a new item, component, supply bottleneck, or technology
|
||||
dependency, emit a manifest candidate.
|
||||
2. If a scene implies a manufactured good that a colony, habitat, ship,
|
||||
institution, species community, or faction consumes, emit a
|
||||
`CompoundCommodityData` candidate with a demand profile, even if it is not an
|
||||
assembly input for gear.
|
||||
3. If a faction decision changes what can be manufactured, maintained, bought,
|
||||
salvaged, counterfeited, or upgraded, emit a faction tech-base delta.
|
||||
4. If an innovation appears in Elysium, label its branch lineage and prior
|
||||
conditions.
|
||||
5. If existing lore names a technology but lacks blueprint detail, elaborate it
|
||||
into a candidate manifest with explicit inferred fields and source-gap notes.
|
||||
6. If the item relies on unsupported lore, patch AetheriaLore or mark a source
|
||||
gap before treating it as stable.
|
||||
7. If an item is cool but breaks logistics, keep it as a rejected artifact with
|
||||
the failure label. Beautiful nonsense is still training data if tagged before
|
||||
it bites someone.
|
||||
|
||||
Ghostlight should discover the future by exploring it, but every discovery
|
||||
should leave inventory. The game cannot build a supply chain out of vibes and
|
||||
one haunted laser.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,92 +0,0 @@
|
|||
# Ghostlight Verse Service Contract
|
||||
|
||||
Ghostlight is a state/projection service candidate for socially persistent
|
||||
characters and simulated people. Its durable truth is not a dashboard and not a
|
||||
prompt. Its durable truth is typed character, scene, relationship, event,
|
||||
perception, and Persona projection state.
|
||||
|
||||
Ghostlight already has a CultCache-backed persistence seam. The next GameCult
|
||||
Verse cut is to expose its inspection and authoring surfaces as Eve GUI/TUI DSL
|
||||
so local runtimes can browse state, projection, and review artifacts without
|
||||
turning Ghostlight into a private web app.
|
||||
|
||||
## Owner Map
|
||||
|
||||
- Owner: Ghostlight owns canonical scene/world/agent state, observer-specific
|
||||
perceived state, projection inputs, reviewed mutation receipts, training-loop
|
||||
bundles, and Persona projections derived from story agents.
|
||||
- Inputs: Aetheria lore/source context, canonical agent and scene state,
|
||||
projected local context, responder packets, sandboxed responder output,
|
||||
reviewer receipts, participant appraisals, and accepted mutation receipts.
|
||||
- Outputs: CultCache-backed state records, readable migration exports, reviewed
|
||||
training artifacts, schema fixtures, event records, participant appraisals,
|
||||
and portable `gamecult.persona_state.v0` projections when a public
|
||||
person-shaped surface is needed.
|
||||
- Derived state: `state/branches.json`, `state/evidence.jsonl`, validator
|
||||
reports, fixture sidecars, and authoring previews are compatibility or review
|
||||
surfaces, not long-term direct-write APIs.
|
||||
- Forbidden writers: authoring dashboards, visual replay applets, prompt
|
||||
renderers, review helpers, and Eve/TUI renderers must not directly mutate
|
||||
canonical character truth. They emit reviewed mutation intent or read typed
|
||||
projections.
|
||||
- Shared paths: state CLI, fixture validators, training-loop review, future
|
||||
authoring UI, future Eve surface, and future compact TUI must read the same
|
||||
CultCache-backed state seam instead of maintaining separate dashboard truth.
|
||||
- Deletion line: any direct-write JSON state path that survives after a typed
|
||||
document exists must be demoted to export/witness or removed.
|
||||
|
||||
## Current State
|
||||
|
||||
Ghostlight already has these substrate pieces:
|
||||
|
||||
- `state/ghostlight-state.cultcache.jsonl` as the current CultCache-backed
|
||||
migration spine;
|
||||
- `vendor/cultcache-py`;
|
||||
- `tools/ghostlight_state_store.py` as the state access seam;
|
||||
- `schemas/gamecult.persona_state.v0.schema.json` as the mirrored portable
|
||||
Persona contract;
|
||||
- `schemas/agent-state.schema.json` for Ghostlight-native scene/world/story
|
||||
state;
|
||||
- architecture docs for persistence, projection, training loops, prompt
|
||||
projection, and shared Persona/CultNet contracts.
|
||||
- `tools/ghostlight_state.py publish-odin` and
|
||||
`tools/publish_odin_provider_advertisement.cjs` publish Ghostlight's
|
||||
provider advertisement once to Odin's CultMesh/RUDP document ingress when an
|
||||
endpoint is supplied.
|
||||
|
||||
The missing surface is not another readable export. The missing surface is a
|
||||
provider-owned Eve GUI/TUI composition over the existing state and review seams.
|
||||
|
||||
## Eve Surface Target
|
||||
|
||||
Ghostlight should publish an Eve GUI/TUI DSL surface with these panels:
|
||||
|
||||
1. `State Spine`: CultCache store path, document families, migration status,
|
||||
readable export freshness, and direct-write debt.
|
||||
2. `Scene And Agent State`: active scene fixtures, canonical agent state,
|
||||
observer/perceived overlays, and relationship stance availability.
|
||||
3. `Projection Pipeline`: projected local context, responder packet, responder
|
||||
output, review status, leakage audit, and mutation receipt state.
|
||||
4. `Training Coverage`: accepted/draft/planned/rejected fixture counts,
|
||||
coverage gaps, tonal modes, collision axes, and training target families.
|
||||
5. `Persona Projection`: `gamecult.persona_state.v0` availability for public
|
||||
character/person surfaces, with provenance back to Ghostlight-native state.
|
||||
|
||||
Each panel must expose source provenance and freshness. If a panel is reading a
|
||||
readable JSON export during migration, the surface should say that the export is
|
||||
not the canonical owner.
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Promote remaining direct-write state paths behind `tools/ghostlight_state_store.py`
|
||||
or its successor typed document seam.
|
||||
2. Define a `ghostlight.eve_surface.v0` provider contract over the current
|
||||
state, projection, review, and Persona documents.
|
||||
3. Add a read-only command that lowers those documents into Eve DSL.
|
||||
4. Publish the provider advertisement through CultMesh/Odin discovery.
|
||||
5. Let browser/native/TUI runtimes lower the same surface.
|
||||
6. Demote old preview/status dashboards to renderers of the provider-owned Eve
|
||||
surface.
|
||||
|
||||
The invariant: Ghostlight owns story/person state. Eve makes it inspectable.
|
||||
Authoring surfaces request reviewed mutations; they do not become state owners.
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
# VoidBot Routine Adoption Plan
|
||||
|
||||
VoidBot now has the best live reference for the agent routine Ghostlight needs:
|
||||
work or rumination on heartbeat, periodic sleep for consolidation, typed
|
||||
MessagePack persistence through a CultCache-style contract, and vector-backed
|
||||
memory resonance. Ghostlight should adopt the routine without inheriting
|
||||
VoidBot's moderation job.
|
||||
|
||||
## Imported Organs
|
||||
|
||||
- **Heartbeat scheduler:** chooses work, rumination, or sleep/consolidation from
|
||||
readiness, load, status, reaction windows, and pending turns.
|
||||
- **Completion-gated cooldown:** a participant cannot act again until its
|
||||
previous heartbeat turn finishes; recovery starts at completion.
|
||||
- **Sleep cycle:** scheduled naps are not absence. They compress memory,
|
||||
strengthen useful associations, cool saturated themes, refresh dreams, and
|
||||
keep direct speech rare.
|
||||
- **CultCache persistence:** canonical state is MessagePack, routed by typed
|
||||
document definitions, with JSON working projections for inspection/review.
|
||||
- **Vector memory:** memories carry semantic vector metadata and Qdrant points
|
||||
so recall, resonance, incubation, and dreams are grounded in retrievable
|
||||
structure.
|
||||
- **Thought organs:** analytic lane, associative lane, bridge, memory resonance,
|
||||
incubation, and candidate intervention/dream queues.
|
||||
|
||||
## Ghostlight Translation
|
||||
|
||||
Ghostlight's swarm is the cast. The heartbeat initiative system therefore
|
||||
applies directly to characters taking turns within a scene, not merely to
|
||||
background maintenance workers.
|
||||
|
||||
There are two scopes using the same timing law:
|
||||
|
||||
- **scene heartbeat:** selects which in-scene character acts, reacts, waits,
|
||||
hesitates, refuses, speaks, withholds, moves, or uses an object.
|
||||
- **maintenance heartbeat:** wakes offstage agents, cast records, coordinator
|
||||
organs, appraisers, mutators, and fixture builders for rumination, sleep
|
||||
consolidation, or reviewed maintenance.
|
||||
|
||||
The point is one initiative physiology with different arenas, not two unrelated
|
||||
schedulers wearing each other's hat.
|
||||
|
||||
```text
|
||||
heartbeat initiative
|
||||
-> choose scene participant or maintenance organ
|
||||
-> scene turn | reaction | wait | rumination | sleep
|
||||
-> read typed state from CultCache-compatible store
|
||||
-> optional Qdrant recall / memory resonance
|
||||
-> produce visible action, event, appraisal, mutation, dream, or maintenance receipt
|
||||
-> completion starts cooldown
|
||||
-> write canonical MessagePack + JSON working projection
|
||||
```
|
||||
|
||||
Epiphany now owns the first shared Rust heartbeat spine. Ghostlight should call
|
||||
that spine through `tools/ghostlight_epiphany_heartbeat.mjs` instead of growing a
|
||||
parallel scheduler. The Ghostlight-side command derives scene participants from
|
||||
the existing initial/agent-state fixtures, initializes a typed MessagePack
|
||||
`ghostlight-scene` heartbeat store, and asks Epiphany to emit
|
||||
`ghostlight.initiative_schedule.v0` receipts with `scene_turn` actions. Those
|
||||
receipts select the actor and local-turn machinery; responder, appraisal, and
|
||||
mutation organs still own the actual narrative action.
|
||||
|
||||
## Persistence Shape
|
||||
|
||||
Ghostlight can start with the CultCacheTS contract directly for Node-side tools,
|
||||
but the Python-heavy validator/tooling surface needs a compatible file contract:
|
||||
|
||||
- canonical store: single-file MessagePack envelope array
|
||||
- envelope: `key`, `type`, `payload`, `storedAt`
|
||||
- schema owner: code definitions, not hand-edited loose JSON
|
||||
- working projection: JSON, human-readable, safe for review, optionally stripped
|
||||
of bulky vector values
|
||||
- write discipline: use an external lock or one coordinator writer; the
|
||||
single-file store is not a database wearing a fake mustache
|
||||
|
||||
Initial document types:
|
||||
|
||||
- `ghostlight.agent_state`
|
||||
- `ghostlight.scene_state`
|
||||
- `ghostlight.initiative_schedule`
|
||||
- `ghostlight.scene_heartbeat_state`
|
||||
- `ghostlight.training_loop_bundle`
|
||||
- `ghostlight.runtime_heartbeat_state`
|
||||
- `ghostlight.memory_resonance`
|
||||
- `ghostlight.sleep_cycle`
|
||||
|
||||
## Memory Vector Shape
|
||||
|
||||
Qdrant becomes the retrieval layer for agent memory, not canonical social truth.
|
||||
|
||||
Each memory-like record should keep:
|
||||
|
||||
- stable memory id
|
||||
- kind: episodic, semantic, relationship, goal, value, musing, dream,
|
||||
scene-memory, appraisal, mutation, or lore-grounding
|
||||
- summary text used for embedding
|
||||
- source hash
|
||||
- embedding backend/model/dimensions
|
||||
- Qdrant point id and collection
|
||||
- salience/confidence/update time
|
||||
- source refs into fixtures, lore, or reviewed artifacts
|
||||
|
||||
Ghostlight's existing training docs already point toward retrieval-backed memory.
|
||||
This plan makes it operational instead of aspirational, the traditional route
|
||||
by which a research note stops being furniture.
|
||||
|
||||
## Sleep Behavior
|
||||
|
||||
During sleep turns:
|
||||
|
||||
- do not generate ordinary scene turns unless the character is actually in the
|
||||
scene and selected by the scene heartbeat
|
||||
- inspect memory resonance clusters and incubation entries
|
||||
- merge duplicate memories
|
||||
- prune stale or low-confidence residue
|
||||
- create or refresh at least one dream only when it compresses a useful seam
|
||||
- update `sleep_cycle.activeDreamThemes`, `lastDreamAt`, and
|
||||
`lastDistillationSummary`
|
||||
- write a maintenance receipt for review
|
||||
|
||||
Dreams are compressed operational memory, not glitter. If a dream survives, the
|
||||
next waking turn should be more coherent because of it.
|
||||
|
||||
## Migration Slices
|
||||
|
||||
1. **Contract fixture**
|
||||
- Add a small CultCache-compatible MessagePack fixture and validator.
|
||||
- Prove Python can read/write the envelope without losing schema shape.
|
||||
|
||||
2. **Runtime heartbeat schema**
|
||||
- Add `schemas/runtime-heartbeat-state.schema.json`.
|
||||
- Include pending turn, sleep cycle, rumination, and completion receipts.
|
||||
|
||||
3. **Agent state migration**
|
||||
- Keep current JSON examples as projections.
|
||||
- Add canonical MessagePack test store for one small agent-state fixture.
|
||||
|
||||
4. **Memory resonance**
|
||||
- Add a local memory-organ script that embeds memory summaries, writes Qdrant
|
||||
points, builds resonance edges/clusters, and writes incubation state.
|
||||
|
||||
5. **Sleep receipt**
|
||||
- Add a sleep/consolidation artifact schema and validator.
|
||||
- Require sleep receipts to name pruned/merged/strengthened memories.
|
||||
|
||||
6. **Training-loop integration**
|
||||
- Promote scene initiative schedules into the same heartbeat contract used
|
||||
by the swarm.
|
||||
- Let characters take scene turns through heartbeat initiative, then route
|
||||
appraisal/mutation/coordinator maintenance through the same completion and
|
||||
cooldown law.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do not make Qdrant canonical truth.
|
||||
- Do not store raw vector blobs in every human working projection.
|
||||
- Do not let sleep turns mutate accepted training fixtures without reviewed
|
||||
maintenance receipts.
|
||||
- Do not make every Ghostlight character into a background daemon before the
|
||||
runtime heartbeat store can prove who is awake, asleep, pending, or complete.
|
||||
314
docs/articles/multiresolution-gestalts.md
Normal file
314
docs/articles/multiresolution-gestalts.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# How Ghostlight Dungeon Keeps a Whole World Moving
|
||||
|
||||
An AI dungeon master has an awkward problem: the world should feel larger than
|
||||
the conversation, but a language model can only attend to so much at once.
|
||||
|
||||
If the game simulates only the people currently speaking, everyone else seems
|
||||
to stop existing. A distant government never exploits an opportunity. The
|
||||
villagers cease reacting when the player talks to the blacksmith. A location
|
||||
left behind becomes a vague reconstruction when the player returns.
|
||||
|
||||
The brute-force answer would be to give every person and organization its own
|
||||
AI turn. That answer lasts until the setting contains twenty factions, several
|
||||
cities, a trading network, a plague, and one unusually opinionated baker. It is
|
||||
too slow and too expensive, and most of those turns would not matter right now.
|
||||
|
||||
Ghostlight Dungeon instead changes the *resolution of the simulation*. It can
|
||||
consider one person in detail, a village as a group, or several rival powers as
|
||||
one bounded political situation. The underlying people and facts do not get
|
||||
merged or discarded. Only the amount of attention used to simulate them
|
||||
changes.
|
||||
|
||||
That is the multiresolution gestalt algorithm.
|
||||
|
||||
## What “gestalt” means here
|
||||
|
||||
A gestalt is a population that can be simulated through a shared Persona turn.
|
||||
“The dockworkers,” “the western villages,” or “the biology faculty” might each
|
||||
begin as a gestalt. They have a common baseline, but that does not mean every
|
||||
member is identical.
|
||||
|
||||
Suppose the player enters a village and asks for the blacksmith. Ghostlight can
|
||||
materialize John from the villagers’ baseline, then preserve everything that
|
||||
makes him particular: his name, memories, possessions, grudges, promises, and
|
||||
relationship with the player. While John is directly involved, he receives his
|
||||
own simulation attention.
|
||||
|
||||
When the player leaves, John does not disappear and his history is not averaged
|
||||
away. His individual state remains as a durable delta from the villagers’
|
||||
baseline. The simulator may include him in a cheaper village-level turn until
|
||||
he matters again. When the player returns, the same John is materialized from
|
||||
the same baseline plus the same personal delta.
|
||||
|
||||
This distinction is the foundation of the system:
|
||||
|
||||
> Ghostlight aggregates simulation work, not canonical identity or state.
|
||||
|
||||
## The agency graph
|
||||
|
||||
At campaign creation, the world compiler builds a coarse global map of agency.
|
||||
It does not eagerly invent every street and citizen. It records the major
|
||||
powers, institutions, populations, regions, information channels, and strategic
|
||||
pressures that make the setting move.
|
||||
|
||||
Each actor, institution, and active population gestalt becomes a vertex in an
|
||||
*agency graph*. Typed edges describe how they can affect one another: command,
|
||||
membership, containment, alliance, rivalry, trade, migration, communication,
|
||||
coercion, and shared location.
|
||||
|
||||
Each vertex also has an agency profile across six practical axes:
|
||||
|
||||
- geography;
|
||||
- ideology;
|
||||
- authority;
|
||||
- economic role;
|
||||
- species or bodily constraints;
|
||||
- access to information.
|
||||
|
||||
These are not personality statistics. They are clues about where a useful
|
||||
simulation boundary lies. During a blockade, geography and supply routes
|
||||
predict behavior. During an election, ideology and authority matter more. For
|
||||
an epidemic, bodies and transport links may dominate. During an espionage
|
||||
crisis, the important boundary is often who knows what.
|
||||
|
||||
This is why Ghostlight does not use one permanent hierarchy such as “continent,
|
||||
country, city, person.” The best cut depends on the pressure currently moving
|
||||
through the world.
|
||||
|
||||
## From current pressure to a simulation cover
|
||||
|
||||
Before a strategic wave, a cheap structured model reads the committed event or
|
||||
planning horizon and proposes weights for the six axes. It may also point to
|
||||
relevant subjects, but only from IDs Ghostlight supplied. This stage can say
|
||||
“information boundaries matter greatly right now”; it cannot invent a new
|
||||
faction or alter the world.
|
||||
|
||||
The local algorithm then covers every active subject with a set of simulation
|
||||
cells. A cover obeys three hard rules:
|
||||
|
||||
1. Every active subject appears in exactly one cell.
|
||||
2. Every cell is connected through the agency graph.
|
||||
3. Foreground subjects that require individual attention remain individual.
|
||||
|
||||
The player chooses the desired number of active Persona cells with a budget
|
||||
slider from 1 to 32; the default is 8. This is a detail budget, not a promise to
|
||||
erase people until the number fits. If nine named characters are actively
|
||||
engaged while the budget is four, Ghostlight reports a temporary effective
|
||||
budget of at least nine. The foreground wins.
|
||||
|
||||
Pins provide more deliberate control. A subject can be kept at individual
|
||||
detail, several subjects can be kept together, or two groups can be forbidden
|
||||
from sharing a cell. Pins and budget changes apply only at safe boundaries. They
|
||||
change a separate resolution epoch, not fictional time or the campaign’s world
|
||||
revision.
|
||||
|
||||
The partitioner starts from fine detail and repeatedly merges the lowest-cost
|
||||
legal pair of adjacent cells. Its loss function asks what would become harder
|
||||
to predict if those cells shared one simulation turn:
|
||||
|
||||
```text
|
||||
0.25 facet divergence
|
||||
+ 0.20 hidden causal-boundary mass
|
||||
+ 0.15 information-scope divergence
|
||||
+ 0.15 spatial divergence
|
||||
+ 0.10 clock/obligation divergence
|
||||
+ 0.10 salience burial
|
||||
+ 0.05 partition churn
|
||||
```
|
||||
|
||||
In plain language: do these subjects want different things, affect one another
|
||||
in ways a summary might hide, know different facts, live too far apart, owe
|
||||
different duties, contain something important, or require a disruptive change
|
||||
from the previous cover?
|
||||
|
||||
The exact weights make the decision reproducible and inspectable. The current
|
||||
pressure changes the meaning of facet divergence, so a geographic boundary can
|
||||
be cheap during an ideological dispute and expensive during a flood.
|
||||
|
||||
After reaching the budget, Ghostlight performs a small boundary-refinement
|
||||
pass. It accepts a local move only when it improves the compression cost by at
|
||||
least five percent. It also prefers the previous cover unless a replacement is
|
||||
at least ten percent better, apart from forced splits and explicit budget
|
||||
changes. Merged cells receive short leases. These measures stop the world map
|
||||
from jittering between equally plausible partitions every turn.
|
||||
|
||||
The implementation uses deterministic ordering and content-addressed cell IDs,
|
||||
so the same state and demand produce the same cover. Candidate scoring samples
|
||||
large cells to keep local work bounded, but it never samples the rules that
|
||||
protect authority, hostility, knowledge, pins, connectivity, or complete
|
||||
coverage. Those are checked exactly. The release test partitions 1,000 subjects
|
||||
to a budget of 8 in about 50 milliseconds on the development host.
|
||||
|
||||
## Two very different kinds of cell
|
||||
|
||||
Putting several subjects in one cell does not automatically create a collective
|
||||
mind. Ghostlight distinguishes two modes:
|
||||
|
||||
| Mode | What it represents | What it may do |
|
||||
| --- | --- | --- |
|
||||
| **Cohesive** | A real collective with shared authority and sufficiently compatible behavior and information | Speak or propose one action as a plural Persona |
|
||||
| **Arena** | Several distinct or opposed subjects considered in one situation | Appraise their interaction and propose separately attributed actions |
|
||||
|
||||
A cohesive cell might represent a patrol acting through its accepted command
|
||||
structure. It requires a real common authority, no active hostile relation, and
|
||||
low enough behavioral and information divergence.
|
||||
|
||||
An arena might contain a guild, its striking workers, and a city ministry. It
|
||||
exists to save inference work, not to pretend they have become one faction. An
|
||||
arena has no actor ID. It cannot issue collective speech, pool everyone’s
|
||||
secrets, or spend one member’s resources on behalf of another. Every proposed
|
||||
action names the exact constituent responsible, and the world kernel validates
|
||||
that constituent’s knowledge, authority, location, relationships, and
|
||||
possessions.
|
||||
|
||||
Cross-faction aggregation always produces an arena. This single distinction
|
||||
prevents a great deal of cheap-model political alchemy.
|
||||
|
||||
## One cell, one Persona pipeline
|
||||
|
||||
Once the cover is selected, every active cell runs through Ghostlight’s Persona
|
||||
projection machinery:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Canonical world state"] --> B["Current-pressure demand"]
|
||||
B --> C["Agency graph and budgeted cover"]
|
||||
C --> D["Private Projector stream per cell"]
|
||||
D --> E["Persona response"]
|
||||
E --> F["Interpreter appraisal and proposals"]
|
||||
F --> G["WorldKernel validation"]
|
||||
G --> H["One atomic strategic commit"]
|
||||
```
|
||||
|
||||
The Projector converts permitted typed state into a private lived narrative. A
|
||||
cohesive cell receives genuinely shared state plus clearly attributed
|
||||
exceptions. An arena receives a polyphonic account that keeps each
|
||||
constituent’s perspective and knowledge separate.
|
||||
|
||||
The Persona sees only that narrative stream. It does not see database schemas,
|
||||
action syntax, or raw world state. It responds as a person or group inhabiting
|
||||
the situation.
|
||||
|
||||
The Interpreter converts the response back into a typed appraisal and action
|
||||
proposals. A cohesive cell may propose one collective action. An arena may
|
||||
propose a small bounded number of constituent-attributed actions—at most four,
|
||||
even for a very large cell.
|
||||
|
||||
All cells in a wave bind to the same world revision and resolution epoch. Only
|
||||
after every pipeline completes does the WorldKernel validate reach, knowledge,
|
||||
authority, resources, clocks, and conflicting proposals. It then commits the
|
||||
accepted consequences in one transaction. A stale response, invalid cover,
|
||||
malformed model output, or illegal action aborts the entire wave without a
|
||||
partial world mutation.
|
||||
|
||||
The number of model requests allowed to run simultaneously is a separate
|
||||
operator setting. Raising concurrency makes an eight-cell wave faster; it does
|
||||
not change which eight cells exist. Lowering the player’s cell budget changes
|
||||
simulation resolution; it does not silently change the provider configuration.
|
||||
|
||||
## Keeping quiet parts of the world alive
|
||||
|
||||
Salience alone would still create a spotlight problem. Important conflicts
|
||||
would receive attention forever while quiet populations slowly became static
|
||||
summaries.
|
||||
|
||||
Ghostlight prevents that with *detail debt*. A subject accumulates this
|
||||
non-fictional scheduling value while it is represented only inside an
|
||||
aggregate. Each strategic wave reserves detailed focus for the eligible subject
|
||||
with the highest debt. Receiving individual or explicit focus clears it.
|
||||
|
||||
Even at a cell budget of 1, the global arena must focus on one overdue subject
|
||||
while retaining the larger situation around it. With N subjects, deterministic
|
||||
debt rotation gives every subject direct simulation attention within at most N
|
||||
strategic waves, unless foreground requirements temporarily take precedence.
|
||||
|
||||
Detail debt is not something characters can perceive, and it cannot be spent
|
||||
inside the fiction. It is bookkeeping for fair computational attention. World
|
||||
clocks and deterministic obligations advance whether a subject receives focus
|
||||
or not. If a cell does nothing, that inaction must be an appraisal, not an
|
||||
omission from a prompt.
|
||||
|
||||
## A worked example
|
||||
|
||||
Imagine an Aetheria campaign with 24 powers. A river blockade has interrupted
|
||||
food shipments while an opposition movement tries to turn the shortage into a
|
||||
general strike.
|
||||
|
||||
At a budget of 4, Ghostlight might preserve:
|
||||
|
||||
- the player’s location and directly engaged cast as mandatory individual
|
||||
cells;
|
||||
- upstream producers and transport authorities in a geographically shaped
|
||||
arena;
|
||||
- dockworkers, guild leadership, and the ministry in a workplace-and-authority
|
||||
arena;
|
||||
- distant powers in a coarse arena, with the highest-detail-debt power selected
|
||||
for explicit focus.
|
||||
|
||||
If the player uncovers a secret ministry order, information divergence becomes
|
||||
more important. The next safe repartition may keep the informed conspirators
|
||||
separate from people who have not seen the order—even if they share a building
|
||||
or faction.
|
||||
|
||||
If the player then confronts a particular dockworker, that character becomes a
|
||||
mandatory singleton. Ghostlight may exceed the configured budget rather than
|
||||
fold a live conversation back into “the workers.” After the relevance lease
|
||||
expires, the character can return to aggregate simulation while retaining the
|
||||
same individual history.
|
||||
|
||||
The cut changes because the situation changed. The world underneath it does
|
||||
not.
|
||||
|
||||
## Refining the world is different from simulating it finely
|
||||
|
||||
Sometimes the Vault establishes that a broad population really contains
|
||||
durable subdivisions: three ideological tendencies, several species with
|
||||
different vulnerabilities, or villages on opposite sides of a material route.
|
||||
That is a canonical fact, not merely a convenient simulation cut.
|
||||
|
||||
Ghostlight handles this through approval-gated gestalt fission. The compiler
|
||||
previews a split along an enumerated facet and always includes an
|
||||
`other/unknown` remainder. Approved children inherit the parent baseline and
|
||||
own only their later deltas. The parent remains as inactive lineage, and known
|
||||
member deltas are reassigned without rewriting anyone’s identity.
|
||||
|
||||
Ordinary cover changes are reversible and need no approval because they alter
|
||||
no fiction. Fission changes the canonical population model, so evidence,
|
||||
preview, and approval are required. Keeping these operations separate prevents
|
||||
an optimization decision from quietly becoming lore.
|
||||
|
||||
## How it appears in Ghostlight Dungeon
|
||||
|
||||
For players, the main control is the active Persona-cell budget. A higher value
|
||||
allows more simultaneous detail and usually costs more inference time. A lower
|
||||
value asks Ghostlight to summarize more of the strategic world without freezing
|
||||
it. The interface also shows the effective budget when foreground obligations
|
||||
force an overage.
|
||||
|
||||
For operators, the inspector exposes the agency graph, current cover, cohesive
|
||||
and arena modes, merge-loss components, pins, leases, detail debt, and the
|
||||
reason for each cut. It also shows model-stage and resolution receipts without
|
||||
publishing private Persona streams or chain-of-thought.
|
||||
|
||||
Under the hood, the implementation is divided by authority:
|
||||
|
||||
- `domain.rs` defines the persistent subjects, agency contracts, covers,
|
||||
appraisals, and control commands;
|
||||
- `compiler.rs` builds the global agency skeleton and previews canonical
|
||||
fission;
|
||||
- `resolution.rs` plans and validates covers, manages detail debt, validates
|
||||
cell proposals, and applies approved fission;
|
||||
- `persona.rs` owns the Projector → Persona → Interpreter membrane for cells;
|
||||
- `scheduler.rs` projects demand and runs cell pipelines in bounded parallel
|
||||
waves;
|
||||
- `kernel.rs` is the only authority that changes canonical campaign state;
|
||||
- `surface.rs` projects player and operator controls through Ghostlight’s Eve
|
||||
interface.
|
||||
|
||||
The result is not an omniscient simulation of every citizen. It is a controlled
|
||||
allocation of attention over a persistent world. The dungeon master can zoom
|
||||
in without inventing a new world and zoom out without erasing the old one.
|
||||
|
||||
That is the practical promise of multiresolution gestalts: the world keeps
|
||||
moving, disagreement survives compression, and the blacksmith is still John
|
||||
when you come back.
|
||||
70
docs/product/lore-vault-entitlements.md
Normal file
70
docs/product/lore-vault-entitlements.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Lore Vault Entitlements
|
||||
|
||||
## Product decision
|
||||
|
||||
Ghostlight Dungeon will accept Git-synchronized, Obsidian-compatible lore
|
||||
Vaults. A compatible Vault is a folder hierarchy of Markdown documents; it does
|
||||
not require an Obsidian runtime or a proprietary export format. GameCult's
|
||||
Aetheria and Zyphos lore sites use this shape and serve as reference corpora.
|
||||
|
||||
| Plan | Active custom Vaults | Combined indexed source allowance | Full imports per month |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Contributor | 1 | 10 million source tokens | 2 |
|
||||
| Contributor Plus | 3 | 30 million source tokens | 6 |
|
||||
| Private | 1 | 10 million source tokens | 2 |
|
||||
| Private Plus | 3 | 30 million source tokens | 6 |
|
||||
|
||||
Prices, allowances, and import accounting remain provisional until service-rig
|
||||
benchmarks establish ingestion, storage, and retrieval cost. Plus increases
|
||||
both active Persona-cell capacity and lore capacity. Contributor versus Private
|
||||
continues to select provider data policy; it does not change Vault ownership.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner:** the Vault service owns source checkout, parsing, chunking,
|
||||
embeddings, indexes, incremental synchronization, deletion, and retrieval.
|
||||
- **Inputs:** an authorized Git repository or synchronized directory containing
|
||||
Markdown lore, its revision, and the subscriber's entitlement.
|
||||
- **Outputs:** a versioned `ghostlight.vault_manifest.v1` binding and exact
|
||||
retrieval witnesses through the generic `VaultProvider` contract.
|
||||
- **Derived state:** campaigns retain the bound manifest revision and exact
|
||||
evidence receipts used during play. They do not own a second semantic index.
|
||||
- **Forbidden writers:** campaigns, model stages, and the browser cannot modify
|
||||
source lore or silently promote branch inventions into the Vault.
|
||||
- **Shared path:** import, Git update, reindex, campaign compilation, destination
|
||||
expansion, and runtime retrieval resolve through one tenant-scoped Vault
|
||||
identity and revision.
|
||||
- **Cut line:** arbitrary document upload and campaign-local vector stores do
|
||||
not become competing authorities.
|
||||
|
||||
## Resource accounting
|
||||
|
||||
The marketed Vault count is a comprehensible entitlement, not the resource
|
||||
boundary. Enforcement also measures indexed source tokens, stored chunks and
|
||||
embeddings, full imports, incremental document changes, and retrieval load.
|
||||
|
||||
Imports are content-addressed. Unchanged documents retain their chunks and
|
||||
embeddings; a Git update replaces only changed document partitions. An update
|
||||
counts as a full import only when Ghostlight must rebuild the whole Vault.
|
||||
Oversized or unsupported imports receive a compatibility and size preview
|
||||
before work begins. They do not create surprise usage charges.
|
||||
|
||||
Campaign Vault precedence is explicit:
|
||||
|
||||
```text
|
||||
campaign branch facts
|
||||
> campaign-specific lore Vault
|
||||
> setting lore Vault
|
||||
> optional rules/reference Vault
|
||||
```
|
||||
|
||||
Conflicts are reported during compilation or retrieval. Sources are not blended
|
||||
into an unattributed answer.
|
||||
|
||||
## Product and security gates
|
||||
|
||||
This entitlement is a product decision, not evidence that arbitrary tenant
|
||||
imports are accepted by the current tester harness. Hosted import opens only
|
||||
after tenant isolation, repository authorization and revocation, deletion,
|
||||
provenance, size admission, Markdown/link parsing, prompt-injection resistance,
|
||||
copyright reporting, and incremental-index benchmarks are verified.
|
||||
143
docs/public-architecture/Agents and model stages.md
Normal file
143
docs/public-architecture/Agents and model stages.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
---
|
||||
title: Agents and model stages
|
||||
description: A catalog of Ghostlight's production model calls, their roles, outputs, and deterministic gates.
|
||||
---
|
||||
|
||||
Ghostlight uses three logical model tiers. **Fast** handles bounded projection,
|
||||
classification, and selection; **Balanced** handles reconciliation, causal
|
||||
reasoning, and most agentic workbenches; **Capable** handles the hardest world
|
||||
compilation and numinous elaboration. Deployments may map these aliases to
|
||||
different physical models without changing stage contracts.
|
||||
|
||||
“Agentic” means the stage can inspect a bounded workbench, take several typed
|
||||
actions, receive deterministic feedback, and revise on the same snapshot. A
|
||||
one-shot model stage still produces only a proposal.
|
||||
|
||||
## Session Zero
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `session_zero_projector` | Fast/configured | Lowers permitted negotiation state into the DM's private lived context | Projected stream; schema validation only, no state write |
|
||||
| `session_zero_dm_persona` | Balanced/configured | Embodied persistent DM who discusses premise, boundaries, roles, and bargains | Natural speech; owns no accepted changes |
|
||||
| `session_zero_interpreter` | Balanced/configured | Extracts only new typed proposals or one focused counterproposal from DM/player speech | Contract, character, or permission proposal; exact owner acceptance and Session Zero kernel gate |
|
||||
|
||||
## Retrieval and world compilation
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `opening_retrieval_plan` | Fast | Plans source queries for possible openings | Queries; Vault boundary executes and receipts results |
|
||||
| `world_openings` | Balanced | Suggests grounded starting situations | Suggestions with evidence IDs; deterministic evidence validation |
|
||||
| `role_retrieval_plan` | Fast | Plans source queries for playable roles | Queries; Vault boundary |
|
||||
| `world_roles` | Balanced | Suggests roles, capabilities, and obligations | Suggestions; evidence and duplication checks |
|
||||
| `custom_retrieval_plan` | Fast | Plans queries for a custom campaign brief | Queries only |
|
||||
| `evidence_relevance` | Fast | Separates direct seed evidence from background and excluded material | Classification; deterministic source-lane admission |
|
||||
| `world_compile` | Capable | Compiles the bounded playable seed: topology, cast, populations, institutions, facts, clocks, and civic state | Candidate seed; full structural and semantic validation before registry publication |
|
||||
| `private_relationship_actor_compile` | Balanced | Compiles private player-character state and approved relationship anchors | Private actor proposal; identity, account, and relationship binding checks |
|
||||
| `agency_compile` | Balanced | Profiles local actors, institutions, and populations across the six agency axes | Agency profiles and relations; exact subject coverage validation |
|
||||
| `global_agency_compile` | Fast/configured | Builds the coarse offscreen strategic skeleton | Major powers, regions, channels, and pressures; schema and evidence checks |
|
||||
| `global_agency_doctrine_synthesis` | Balanced | Proposes strategic doctrines from exact global agency anchors | Doctrine candidates; cannot rewrite source anchors |
|
||||
| `global_agency_doctrine_verification` | Balanced | Judges whether synthesized doctrines preserve their anchors | Verdict; deterministic compiler decides admission |
|
||||
|
||||
## Destination, civic, and clock workbenches
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `destination_identity_resolution` | Fast | Decides whether a requested destination is an existing reachable place or a genuinely new region | Identity proposal; deterministic topology check chooses elaboration versus expansion |
|
||||
| `destination_retrieval_plan` | Fast | Plans exact source queries for a destination | Queries only |
|
||||
| `destination_compile` | Capable | Proposes geography, routes, residents, institutions, political relations, and civic apparatus | Expansion/elaboration candidate; topology, evidence, knowledge, and civic closure checks |
|
||||
| `destination_reconciliation_agent_action` | Balanced, agentic | Repairs an exact rejected destination finding through small transactional edits | Revised private candidate; reruns the existing validators, never writes world state |
|
||||
| `destination_civic_verification` | Balanced | Independently judges whether authority, succession, resources, redress, and resident knowledge are legible | Verdict receipt; kernel rebinds and checks it at admission |
|
||||
| `gestalt_fission_retrieval_plan` | Fast | Plans evidence retrieval for a requested population subdivision | Queries; fission compiler and approval gate |
|
||||
| `clock_consequence_binding_agent_action` | Balanced, agentic | Binds legacy clock consequence text to exact observable subjects and public channels | Binding proposal; deterministic threshold/event validation and kernel commit |
|
||||
|
||||
## Titled world elaborators
|
||||
|
||||
All titled workers use `world_elaboration_<title>`, the common
|
||||
agent harness, one exact frozen assignment, and at most one additive operation.
|
||||
The weighted scheduler controls invocation frequency in proportion to the
|
||||
user's slider shares.
|
||||
|
||||
| Agent | Tier | Character of contribution |
|
||||
|---|---|---|
|
||||
| **Patina** | Fast | Durable low-stakes texture: objects, nicknames, customs, jokes, and reusable places |
|
||||
| **Charter** | Balanced | Government, offices, law, succession, selection, procedure, and redress |
|
||||
| **Ledger** | Fast | Labor, resources, infrastructure, exchange, scarcity, and class pressure |
|
||||
| **Hearth** | Fast | Kinship, care, neighborhood, obligation, belonging, and private stakes |
|
||||
| **Tangle** | Balanced | Factions, alliances, rivalries, constituencies, leverage, and plots |
|
||||
| **Veil** | Fast | Secrets, rumors, misinformation, taboo, mystery, and disclosure paths |
|
||||
| **Ember** | Fast | Active disputes, hazards, instability, escalation, and urgent pressure |
|
||||
| **Numen** | Capable | Religion, ritual, magic, cosmology, awe, and bounded strangeness |
|
||||
|
||||
The deterministic elaboration tool checks the operation against the title's
|
||||
exact assignment and frozen namespace. Wave admission resolves conflicts; an
|
||||
independent semantic verifier reviews the inhabited candidate; `WorldKernel`
|
||||
alone commits it.
|
||||
|
||||
At latent-world scale, the same titled sessions also receive
|
||||
`world-complexity-<title>-fission` or
|
||||
`world-complexity-<title>-individuate` assignments. Each call may subdivide one
|
||||
active Gestalt or promote one grounded consequential person. A fission worker
|
||||
returns only the partition delta—child identities and partition values plus
|
||||
exact member/resource assignments. The deterministic tool attaches the frozen
|
||||
world binding and inherited population state, avoiding repeated constitutions
|
||||
in model output. An individuation worker similarly supplies the member delta
|
||||
while the tool owns parent version and location. Deterministic validation and
|
||||
the existing kernel commands remain the only admission owners. A rejected
|
||||
fission returns the complete deterministic mismatch set to the same agentic
|
||||
turn, rather than hiding several repairable defects behind one generic error.
|
||||
Its action schema exposes the exact frozen resource and dormant-member IDs as
|
||||
required assignment keys, while leaving their destination children to the
|
||||
writer.
|
||||
After every productive round,
|
||||
`world-elaborator-<title>-session-compaction` distills that title's frontier in
|
||||
the assigned realm jurisdiction and its unresolved leads while the harness
|
||||
reattaches exact commit ancestry. The title-by-realm key prevents unrelated
|
||||
political frontiers from becoming one global transcript. Compacted memory can
|
||||
guide later proposals but cannot create subjects or facts.
|
||||
|
||||
## Foreground perception, response, and action
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `speech_address_resolver` | Fast | Selects exact co-present direct addressees for player speech | Addressee IDs; presence and response-duty validation |
|
||||
| `projector` | Fast/configured | Projects one actor's permitted typed state and visible event into lived narrative | Private context; never canonical truth |
|
||||
| `persona` | Balanced/configured | Appraises the event and produces natural response from one actor's perspective | Speech, deliberate silence, private appraisal, or action intent |
|
||||
| `interpreter` | Capable/configured, agentic | Negotiates natural intent into the closed action algebra | Typed action proposal; authority and semantic validation |
|
||||
| `assessment_mutation_scope` | Fast/Balanced | Selects the smallest mutation lanes that could realize an attempted effect | Upper-bound scope or precise denial; deterministic lane checks |
|
||||
| `action_assessment` | Balanced | Sets possibility, DC, stakes, outcome bands, and effect ceiling | Assessment; canonical DC/reference validation and player risk acceptance |
|
||||
| `assessment_effect_verifier` | Balanced | Checks that a proposed outcome mutation is caused by the exact attempted means | Verdict; invalid effects cannot reach the kernel |
|
||||
| `gestalt_presence_planner` | Fast/configured | Promotes an earned existing member, admits one speech-authorized first identity, or folds an irrelevant member | Presence plan; exact lineage, location, version, and identity checks |
|
||||
|
||||
## Strategic simulation
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `resolution_demand` | Fast | Weights current causal relevance across geography, ideology, authority, economy, species/body, and information | Demand weights and focal IDs; deterministic connected partitioner owns the cover |
|
||||
| `nemesis_attention_agent_action` | Balanced, agentic | Searches committed causal anchors and decides which autonomous subjects genuinely need a response window now | Anchor/responder assignments or none; exact subject/anchor validation, no action authority |
|
||||
| `cell_projector` | Fast/configured | Projects a cell's exact constituents and required perspective owners without merging private state | Cell lived context |
|
||||
| `cell_persona` | Balanced/configured | Lets each required actor, institution, population, or named member appraise and choose independently | Attributed natural decisions; arenas never speak as synthetic actors |
|
||||
| `cell_interpreter` | Capable/configured, agentic | Translates each selected strategic decision into one or more typed attempts | Attributed action proposals; graph, target, location, and effect validation |
|
||||
| `cell_effect_verifier` | Balanced/configured | Checks that each strategic attempt uses only its own constituent's authority and knowledge | Ordered verdicts; correction may reuse the same bounded workbench |
|
||||
| `strategic_outcome_resolver` | Balanced | Resolves opposition and proposes one durable result per admitted action | Outcome bundle; exact handles and action digests must match |
|
||||
| `strategic_outcome_verifier` | Fast/Balanced | Independently checks high-risk custody, relation, knowledge, and member-specific consequences | Selective semantic verdict; ordinary structural effects remain deterministic |
|
||||
| `strategic_individuation_selector` | Fast | Names at most one person when selected Gestalt action creates political work that cannot remain anonymous | Member proposal or none; shared individuation validator and kernel admission |
|
||||
|
||||
## Newspaper
|
||||
|
||||
| Stage | Tier | Purpose | Output and downstream gate |
|
||||
|---|---|---|---|
|
||||
| `newspaper_narrative_selection_agent_action` | Balanced, agentic | Assignment editor queries the frozen ledger, chooses issue shape and throughline, assigns recurring reporters, and binds evidence | Immutable agenda checkpoint; exact record, reporter, lead, and conflict-axis validation |
|
||||
| `newspaper_journalist_agent_action` | Balanced, agentic | One embodied journalist writes one assigned story through their recurring beat, voice, biases, and source habits | Article copy plus citations; no factual admission authority |
|
||||
| `newspaper_copy_desk` | Balanced | Performs the single hard factual review over the assembled page | Complete finding checklist; cannot rewrite or publish |
|
||||
| `newspaper_night_editor_close_agent_action` | Balanced, agentic | Repairs the copy desk checklist once while preserving narrative and closes at deadline | Final page; deterministic press witness checks structure and lineage |
|
||||
|
||||
Post-press grounding review is audit, not another same-edition rewrite loop.
|
||||
Supported corrections become later-edition memory.
|
||||
|
||||
## Deterministic stages that deliberately use no model
|
||||
|
||||
The `WorldKernel`, mutation reducer, resolution partitioner, dice roller,
|
||||
identity/version checks, topology and custody validation, information-scope
|
||||
checks, checkpoint recovery, provenance hashing, archive verification, and Eve
|
||||
projection lowering are deterministic. Keeping these outside model prompts is
|
||||
what lets agents negotiate with the engine instead of impersonating it.
|
||||
64
docs/public-architecture/Architecture at a glance.md
Normal file
64
docs/public-architecture/Architecture at a glance.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
title: Architecture at a glance
|
||||
description: Ghostlight's major organs and the flow from source evidence to committed world change.
|
||||
---
|
||||
|
||||
## Major organs
|
||||
|
||||
| Organ | Responsibility | Does not own |
|
||||
|---|---|---|
|
||||
| Lore Vault boundary | Retrieves exact source witnesses and records provenance | Campaign truth or invented facts |
|
||||
| Session Zero | Negotiates campaign premise, roles, boundaries, and explicit player decisions | Published world state before approval |
|
||||
| World compiler | Produces a bounded seed, topology, civic machinery, actors, populations, and agency profiles | Runtime mutation after publication |
|
||||
| Projector | Converts permitted typed state into one subject's lived context | Canonical state or another subject's knowledge |
|
||||
| Persona | Appraises and responds from the projected perspective | Typed effects or commit authority |
|
||||
| Interpreter | Translates natural intent into the closed action algebra | Whether the action succeeds or commits |
|
||||
| Assessor | Determines possibility, difficulty, stakes, and admissible mutation scope | Dice, narration, or world commit |
|
||||
| Multiresolution resolver | Derives a connected active cover over canonical subjects | Subject identity, facts, or population membership |
|
||||
| Nemesis | Selects who needs a decision window in response to unresolved causal pressure | Cover construction, actions, or outcomes |
|
||||
| Strategic outcome resolver | Proposes bounded consequences for selected strategic attempts | Mutation admission or persistence |
|
||||
| Titled elaborators | Propose one additive worldbuilding operation at a time | Existing state, evidence, or direct writes |
|
||||
| Newspaper | Selects and narrates committed public events through an editorial Persona | World events or factual admission |
|
||||
| `WorldKernel` | Validates commands and atomically commits canonical state, events, receipts, and revision | Model inference or presentation |
|
||||
| CultCache / CultMesh / Eve | Persist typed state, publish provider state, and render interfaces | The domain decisions represented by that state |
|
||||
|
||||
## Two scales, one world
|
||||
|
||||
Canonical actors, institutions, population Gestalts, and durable named members
|
||||
form the latent agency graph. An active-cell entitlement controls simultaneous
|
||||
attention, not population size. A deterministic resolver compresses the graph
|
||||
into cohesive cells and adversarial arenas while preserving complete coverage.
|
||||
|
||||
At the current full-world target:
|
||||
|
||||
```text
|
||||
latent actionable subjects: about 2,400
|
||||
active cell entitlement: 240
|
||||
target active cover: about 10%
|
||||
provider concurrency: separately bounded, at most 32
|
||||
```
|
||||
|
||||
Gestalt compression is reversible simulation resolution. It does not merge
|
||||
identities or knowledge. Named members retain personal memories, possessions,
|
||||
relationships, injuries, and obligations when folded back into a population.
|
||||
|
||||
## One write path
|
||||
|
||||
Foreground actions, NPC reactions, strategic waves, waits, travel, population
|
||||
movement, region expansion, locality elaboration, clock consequences, and
|
||||
approved population fission all lower into the same closed semantic mutation
|
||||
algebra. `WorldKernel` validates the complete batch against one exact authority
|
||||
envelope and commits it atomically.
|
||||
|
||||
The browser, scheduler, models, newspapers, checkpoints, and reconciliation
|
||||
workers cannot create a second result. A checkpoint proves work already done;
|
||||
it is not another owner of the world.
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
Ghostlight accepts sparse consumer-authored world seeds and externally owned
|
||||
boundary snapshots through a generic contract. Delvehold retains sovereignty
|
||||
over the Greathold, its players, civic state, dungeons, workshops, contracts,
|
||||
and quantitative economy. Ghostlight owns the autonomous world beyond that
|
||||
boundary. Each side validates proposed consequences inside its own authority.
|
||||
|
||||
80
docs/public-architecture/Authority and state.md
Normal file
80
docs/public-architecture/Authority and state.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
title: Authority and state
|
||||
description: Canonical owners, derived projections, forbidden writers, and persistence rules.
|
||||
---
|
||||
|
||||
## Canonical owners
|
||||
|
||||
- `WorldKernel` owns campaign revision and atomic world mutation.
|
||||
- Actor state owns an individual's location, conditions, knowledge, goals,
|
||||
possessions, commitments, and relationships.
|
||||
- Institution state owns institutional posture, resources, goals, location,
|
||||
information channels, and agency profile.
|
||||
- A Gestalt owns a population's shared baseline. Durable member deltas own each
|
||||
named person's departures from that baseline.
|
||||
- World facts own their statement, scope, evidence ancestry, and discovery
|
||||
locations.
|
||||
- Clocks own progress, threshold, consequence declaration, and observable
|
||||
scope.
|
||||
- Civic manifests own public authority, selection or succession, resources,
|
||||
redress, resident populations, governing institutions, and political edges
|
||||
for one jurisdiction.
|
||||
- The event ledger owns committed history. Newspaper copy is a projection of
|
||||
that history, never a replacement for it.
|
||||
|
||||
## Derived state
|
||||
|
||||
The following values are disposable and reconstructable:
|
||||
|
||||
- resolution cells and arenas;
|
||||
- merge loss and demand weights;
|
||||
- Nemesis attention assignments;
|
||||
- Projector lived streams;
|
||||
- browser and Eve surfaces;
|
||||
- newspaper source queries and layouts;
|
||||
- aggregate compatibility rows;
|
||||
- build, run, and resume checkpoints.
|
||||
|
||||
Derived state may carry exact bindings and still remain non-authoritative. A
|
||||
receipt proves which proposal was considered against which snapshot; it does
|
||||
not grant the proposer ownership of the result.
|
||||
|
||||
## Proposal-only model boundary
|
||||
|
||||
Every model stage is proposal-only. A stage may select, classify, appraise,
|
||||
draft, translate, reconcile, or verify within its supplied schema. It cannot:
|
||||
|
||||
- advance world revision;
|
||||
- mutate another subject's private state;
|
||||
- invent a source receipt;
|
||||
- rebase stale work;
|
||||
- widen its authority because an output field exists;
|
||||
- commit only the valid half of an invalid batch.
|
||||
|
||||
Semantic repair is bounded to the same frozen snapshot. Reconciliation workers
|
||||
operate a private workbench and submit a corrected proposal through the same
|
||||
validator; they do not patch canonical state.
|
||||
|
||||
## Atomic persistence
|
||||
|
||||
Accepted mutations, authority envelopes, model receipts, causal receipts,
|
||||
events, and compatibility projections are persisted together through
|
||||
CultCache. A later publication or interface refresh cannot roll back a commit.
|
||||
Failure before commit changes nothing; failure after commit is recovered by
|
||||
reading canonical state and rebuilding the missing projection or checkpoint.
|
||||
|
||||
## Knowledge and perception
|
||||
|
||||
Canonical truth and subject-local knowledge are separate components. A model
|
||||
sees only the projection permitted for its exact perspective. Communication
|
||||
adds existing facts to exact recipients; observation may admit a bounded local
|
||||
finding when the attempted means can actually establish it. Public channels
|
||||
are concrete information routes, not magic global knowledge.
|
||||
|
||||
## External sovereignty
|
||||
|
||||
A consumer may own a boundary subject completely. Ghostlight can observe its
|
||||
published effects, depend on it, bargain with it, and propose consequences to
|
||||
it, but cannot give it an autonomous turn or fabricate its internal choices.
|
||||
The Greathold in Delvehold is the first intended example.
|
||||
|
||||
98
docs/public-architecture/Runtime pipelines.md
Normal file
98
docs/public-architecture/Runtime pipelines.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
title: Runtime pipelines
|
||||
description: End-to-end Ghostlight flows from campaign negotiation to newspapers.
|
||||
---
|
||||
|
||||
## Session Zero and publication
|
||||
|
||||
1. A Projector lowers permitted shared or private negotiation state.
|
||||
2. The persistent DM Persona speaks naturally with the players.
|
||||
3. The Session Zero Interpreter extracts only newly proposed typed changes.
|
||||
4. Material bargains remain unresolved until the exact owner accepts them.
|
||||
5. Evidence retrieval and the compiler produce an approval preview.
|
||||
6. Campaign, membership, contract, DM Persona, evidence, and seed digest publish
|
||||
atomically before the campaign becomes discoverable.
|
||||
|
||||
## World compilation
|
||||
|
||||
1. Retrieval planners formulate bounded Vault queries.
|
||||
2. Evidence relevance classifies direct seed evidence separately from
|
||||
background and excluded material.
|
||||
3. The compiler proposes local topology, actors, institutions, populations,
|
||||
clocks, facts, and civic machinery.
|
||||
4. Agency stages profile every non-player strategic subject and compile
|
||||
relations and doctrines.
|
||||
5. Deterministic validation checks topology, identity, evidence, knowledge,
|
||||
civic closure, and authority.
|
||||
6. Compatible missing detail may be reconciled on a private bounded workbench.
|
||||
7. The registry publishes the accepted seed as an empty-store creation
|
||||
transaction. Runtime models never gain a parallel creation path.
|
||||
|
||||
## Foreground turn
|
||||
|
||||
1. The speech-address resolver chooses exact co-present addressees.
|
||||
2. Each affected participant receives its own Projector and Persona appraisal.
|
||||
3. Direct addressees must speak or choose deliberate silence.
|
||||
4. The Interpreter lowers proposed intent into the closed action algebra.
|
||||
5. Mutation-scope preflight removes impossible effect lanes.
|
||||
6. The assessor decides possibility, DC, stakes, and outcome ceilings.
|
||||
7. The player accepts the exposed risk; the server rolls.
|
||||
8. An effect verifier checks that the chosen typed consequence follows from the
|
||||
attempted means and supplied authority.
|
||||
9. `WorldKernel` commits the whole result and event atomically.
|
||||
|
||||
## Strategic wave
|
||||
|
||||
1. Resolution demand weights geography, ideology, authority, economy, body or
|
||||
species, and information for the current horizon.
|
||||
2. The deterministic partitioner derives a connected cover under the active
|
||||
cell entitlement.
|
||||
3. Nemesis searches committed causal anchors and assigns exact responders that
|
||||
genuinely need a decision window. It may assign nobody.
|
||||
4. Every selected cell runs Projector → Persona → Interpreter independently and
|
||||
in parallel under a separate provider semaphore.
|
||||
5. Cell effect verification rejects actions that borrow another constituent's
|
||||
knowledge or authority.
|
||||
6. The strategic outcome resolver proposes one bounded result per admitted
|
||||
action; a separate outcome verifier checks selective semantic risks.
|
||||
7. Optional individuation may name one person whose political work can no
|
||||
longer remain anonymous.
|
||||
8. `WorldKernel` validates the complete wave, advances time and clocks, commits
|
||||
state and events atomically, then refreshes public projections.
|
||||
|
||||
## World elaboration
|
||||
|
||||
1. A consumer scale intent and current canonical subject count derive the
|
||||
actionable-subject target and deficit.
|
||||
2. Realm complexity weights distribute that target; the active cell budget and
|
||||
provider concurrency remain separate controls.
|
||||
3. The elaboration scheduler dispatches titled workers in proportion to their
|
||||
user-controlled slider shares.
|
||||
4. Locality passes propose exactly one additive place, route, fact,
|
||||
institution, relation, or civic operation. Complexity passes propose one
|
||||
Gestalt fission or one grounded individual promotion against a distinct
|
||||
frozen parent.
|
||||
5. Complexity proposals run concurrently, then rebase only when their exact
|
||||
parent state is unchanged and serialize through `WorldKernel`. Successful
|
||||
commits are checkpointed individually; a retry replays only failed calls.
|
||||
6. Every title that committed work receives one bounded agentic compaction
|
||||
turn. Its narrative frontier is checkpointed beside deterministic commit
|
||||
ancestry, so provider-session eviction cannot erase or rewrite history.
|
||||
7. The driver recounts canonical active agency leaves and derives another
|
||||
round until the target cover ratio is met. Resolution then builds its
|
||||
disposable cell cover independently.
|
||||
|
||||
## Newspaper
|
||||
|
||||
1. The assignment editor queries the immutable public-event ledger and selects
|
||||
an issue shape, throughline, stories, evidence, and the best reporter for
|
||||
each assignment.
|
||||
2. Recurring in-world journalists write from individual beats, biases, source
|
||||
habits, voices, and blind spots. Their job is narrative, not factual
|
||||
admission.
|
||||
3. The copy desk performs one factual review and returns a complete checklist.
|
||||
4. The Night Editor receives the assembled page and that checklist once,
|
||||
repairs what it can while preserving the story, and closes at deadline.
|
||||
5. Deterministic provenance binds every factual assertion to exact committed
|
||||
sources. Errors found after close become correction memory for a later
|
||||
edition rather than an infinite rewrite loop.
|
||||
51
docs/public-architecture/index.md
Normal file
51
docs/public-architecture/index.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
title: Architecture
|
||||
description: A public reference for Ghostlight's persistent-world simulation, agent pipeline, and authority boundaries.
|
||||
---
|
||||
|
||||
Ghostlight is a persistent generative-agent engine. Models propose perceptions,
|
||||
decisions, interpretations, and bounded worldbuilding operations; deterministic
|
||||
code decides what those proposals are allowed to mean; one revisioned
|
||||
`WorldKernel` commits canonical change.
|
||||
|
||||
This reference describes the implemented Ghostlight Dungeon body and the
|
||||
consumer-neutral machinery being prepared for Delvehold. It distinguishes live
|
||||
code from planned scale work. In particular, the 240-cell ceiling is an active
|
||||
attention budget, not a claim that the world contains only 240 subjects. The
|
||||
current full-world target is approximately 2,400 potentially acting entities
|
||||
under roughly ten-percent active cover.
|
||||
|
||||
## Read the machine
|
||||
|
||||
- [[Architecture at a glance]] — the organs and the main dataflow.
|
||||
- [[Authority and state]] — who is allowed to decide and persist what.
|
||||
- [[Runtime pipelines]] — Session Zero, compilation, foreground turns,
|
||||
strategic simulation, elaboration, and newspapers.
|
||||
- [[Agents and model stages]] — every production agent and model stage, its
|
||||
inputs, output, model class, authority, and downstream gate.
|
||||
|
||||
## The compact rule
|
||||
|
||||
```text
|
||||
source evidence + canonical state
|
||||
↓ projection
|
||||
model proposal
|
||||
↓ deterministic validation
|
||||
typed mutation or refusal
|
||||
↓ WorldKernel
|
||||
atomic state + event + receipt commit
|
||||
```
|
||||
|
||||
A model response is never world truth merely because it is well written or
|
||||
well typed. Receipts bind every accepted inference to its exact stage and world
|
||||
snapshot. Invalid, stale, or partially illegal work leaves canonical state
|
||||
unchanged.
|
||||
|
||||
## Scope
|
||||
|
||||
The public reference follows the current source in `crates/ghostlight` (the
|
||||
library) and `crates/ghostlight-dungeon` (the daemon that consumes it).
|
||||
Research plans and older fixture pipelines remain in the repository but are not
|
||||
presented as live runtime stages. Physical model names are deployment choices;
|
||||
the architecture names the stable logical tiers: Fast, Balanced, and Capable.
|
||||
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-06.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-06",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-06.coordinator.decision",
|
||||
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "sol_vant",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-06.selected",
|
||||
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-06.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-06.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-06.handoff",
|
||||
"responder_agent_id": "sol_vant",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-06.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-06.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-07.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-07",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-07.coordinator.decision",
|
||||
"selected_next_beat": "Mara Quell responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "mara_quell",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-07.selected",
|
||||
"summary": "Project Mara Quell from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Mara Quell to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-07.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-07.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-07.handoff",
|
||||
"responder_agent_id": "mara_quell",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-07.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-07.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-08.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-08",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-08.coordinator.decision",
|
||||
"selected_next_beat": "Juno Ark responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "juno_ark",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-08.selected",
|
||||
"summary": "Project Juno Ark from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Juno Ark to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-08.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.sol_vant.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-08.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-08.handoff",
|
||||
"responder_agent_id": "juno_ark",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-08.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-08.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
{
|
||||
"schema_version": "ghostlight.coordinator_artifact.v0",
|
||||
"artifact_id": "lucent-hostage-feed-v0.turn-09.coordinator",
|
||||
"review_status": "accepted_as_draft",
|
||||
"fixture_lane": "historical_grounded",
|
||||
"canon_status": "single_history",
|
||||
"source_refs": [
|
||||
{
|
||||
"ref_id": "lucent-scene-digest",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
|
||||
"purpose": "Grounding facts for Lucent hostage-feed scene."
|
||||
},
|
||||
{
|
||||
"ref_id": "lucent-initial-state",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"purpose": "Scene-local state seed and participant briefs."
|
||||
}
|
||||
],
|
||||
"input_refs": {
|
||||
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
|
||||
"lore_grounding_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
|
||||
],
|
||||
"prior_artifact_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"projected_context_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
|
||||
]
|
||||
},
|
||||
"scene_context": {
|
||||
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
|
||||
"sequence_marker": "turn-09",
|
||||
"location_id": "lucent.media-eye.transfer-atrium",
|
||||
"participant_ids": [
|
||||
"sol_vant",
|
||||
"juno_ark",
|
||||
"mara_quell",
|
||||
"feed_ops_chorus",
|
||||
"brant_vee",
|
||||
"pippa_lux"
|
||||
],
|
||||
"active_object_ids": [
|
||||
"edit-trail-hold",
|
||||
"custody-graphic-suspended",
|
||||
"elevator-gate",
|
||||
"bench-edge",
|
||||
"crisis-feed"
|
||||
],
|
||||
"active_resource_ids": [
|
||||
"hostage-safety",
|
||||
"feed-integrity",
|
||||
"sponsor-risk",
|
||||
"security-pressure"
|
||||
],
|
||||
"public_facts": [
|
||||
{
|
||||
"text": "The live feed and room are mutually shaping the negotiation.",
|
||||
"refs": [
|
||||
"lucent-scene-digest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "The edit-trail hold is the active evidence object.",
|
||||
"refs": [
|
||||
"lucent-loop-state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"scene_pressure": [
|
||||
{
|
||||
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
|
||||
"refs": [
|
||||
"lucent-initial-state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"coordinator_decision": {
|
||||
"decision_id": "lucent-hostage-feed-v0.turn-09.coordinator.decision",
|
||||
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
|
||||
"selected_acting_agent_id": "sol_vant",
|
||||
"candidate_next_beats": [
|
||||
{
|
||||
"choice_id": "turn-09.selected",
|
||||
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
|
||||
"status": "selected"
|
||||
}
|
||||
],
|
||||
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
|
||||
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
|
||||
"glue_prose_status": "accepted_as_narration"
|
||||
},
|
||||
"machinery_plan": {
|
||||
"invocations": [
|
||||
{
|
||||
"invocation_id": "turn-09.projector",
|
||||
"organ": "projector",
|
||||
"purpose": "Build local projected context for acting character.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.responder",
|
||||
"organ": "sandboxed_responder",
|
||||
"purpose": "Generate one character-local action from exact packet only.",
|
||||
"input_refs": [
|
||||
"examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.event",
|
||||
"organ": "event_resolver",
|
||||
"purpose": "Convert accepted responder action into observable event.",
|
||||
"input_refs": [
|
||||
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.appraisal",
|
||||
"organ": "participant_appraiser",
|
||||
"purpose": "Create affected participant-local appraisals from current state.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.juno_ark.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.mara_quell.json",
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.feed_ops_chorus.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
},
|
||||
{
|
||||
"invocation_id": "turn-09.mutation",
|
||||
"organ": "state_mutator",
|
||||
"purpose": "Apply only reviewed state deltas.",
|
||||
"input_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
|
||||
],
|
||||
"output_refs": [
|
||||
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
|
||||
],
|
||||
"status": "accepted_as_draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sandboxed_responder_handoffs": [
|
||||
{
|
||||
"handoff_id": "turn-09.handoff",
|
||||
"responder_agent_id": "sol_vant",
|
||||
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
|
||||
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json",
|
||||
"hidden_context_refs": [
|
||||
"other participants private state",
|
||||
"coordinator future plans",
|
||||
"conversation history"
|
||||
],
|
||||
"isolation_method": "subagent_no_fork",
|
||||
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
|
||||
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
|
||||
"coordinator_interventions": [
|
||||
{
|
||||
"intervention_type": "none",
|
||||
"description": "Raw subagent output parsed as JSON without repair."
|
||||
}
|
||||
],
|
||||
"leakage_audit": {
|
||||
"private_state_leak": false,
|
||||
"author_only_leak": false,
|
||||
"future_branch_leak": false,
|
||||
"raw_state_soup": false,
|
||||
"prompt_parroting": false,
|
||||
"body_or_object_omniscience": false,
|
||||
"notes": [
|
||||
"No hidden context markers or raw state variables appeared in the accepted output."
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"world_state_refs": {
|
||||
"read_refs": [
|
||||
{
|
||||
"ref_id": "prior-state",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
},
|
||||
{
|
||||
"ref_id": "projected-context",
|
||||
"ref_type": "agent",
|
||||
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
|
||||
"visibility": "character_local"
|
||||
}
|
||||
],
|
||||
"write_candidate_refs": [
|
||||
{
|
||||
"ref_id": "event",
|
||||
"ref_type": "event",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"ref_id": "mutation",
|
||||
"ref_type": "world_fact",
|
||||
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
|
||||
"visibility": "coordinator_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposed_deltas": [
|
||||
{
|
||||
"delta_id": "turn-09.delta.receipt-chain",
|
||||
"delta_type": "world_fact_update",
|
||||
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
|
||||
"summary": "Use reviewed mutation receipt as source for next turn state.",
|
||||
"authority": "manual_review",
|
||||
"status": "accepted",
|
||||
"rationale": "This is the explicit carryover tested by the loop."
|
||||
}
|
||||
],
|
||||
"branching": {
|
||||
"branch_action": "none",
|
||||
"active_branch_flags": [
|
||||
"training-loop-linear-release"
|
||||
],
|
||||
"event_constraints": []
|
||||
},
|
||||
"unresolved_hooks": [
|
||||
{
|
||||
"hook_id": "turn-09.hook.aftercare",
|
||||
"hook_type": "story",
|
||||
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
|
||||
"owner": "Ghostlight",
|
||||
"status": "carried"
|
||||
}
|
||||
],
|
||||
"review": {
|
||||
"reviewer": "Codex coordinator",
|
||||
"review_notes": [
|
||||
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
|
||||
],
|
||||
"accepted_for_training": true,
|
||||
"failure_labels": []
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue