@polycode-projects/the-mechanical-code-talker 5.0.2 → 5.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/corpus/worlds/manifest.json +9 -9
  2. package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
  3. package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
  4. package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
  5. package/corpus/worlds/src/town-square-chapel.jsonl +1 -1
  6. package/corpus/worlds/src/town-square-market.jsonl +1 -1
  7. package/corpus/worlds/src/town-square.jsonl +1 -1
  8. package/data/mudiii-assets.json +35 -5
  9. package/package.json +1 -1
  10. package/src/adapters/memory/core.mjs +236 -18
  11. package/src/domain/ask-vocab.mjs +1 -1
  12. package/src/domain/ask.mjs +20 -10
  13. package/src/domain/game-config.mjs +10 -1
  14. package/src/domain/interpret/normalize.mjs +4 -0
  15. package/src/domain/memory/retraction.mjs +232 -0
  16. package/src/domain/p2p/sync-filter.mjs +9 -1
  17. package/src/domain/spider-fly-world.mjs +80 -35
  18. package/src/domain/syllogise.mjs +128 -75
  19. package/src/services/adventure-autoplay.mjs +2 -2
  20. package/src/services/adventure-viz.mjs +12 -7
  21. package/src/services/chat.mjs +42 -14
  22. package/src/services/mud-turn.mjs +1 -1
  23. package/src/services/mud-viz.mjs +11 -2
  24. package/src/services/mudiii-scene.mjs +199 -55
  25. package/src/services/mudiii-turn.mjs +82 -1
  26. package/src/services/mudiii-viz.mjs +17 -7
  27. package/src/services/p2p-room.mjs +130 -9
  28. package/src/services/predator-prey.mjs +524 -69
  29. package/src/services/spider-fly-turn.mjs +128 -56
  30. package/src/services/spider-fly-viz.mjs +65 -71
  31. package/src/services/world-teach.mjs +3 -3
  32. package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
  33. package/src/surfaces/web/memory-ask-browser.bundle.js +118 -118
  34. package/src/surfaces/web/mud-browser-entry.mjs +10 -3
  35. package/src/surfaces/web/mudiii-browser-entry.mjs +3 -3
  36. package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
  37. package/src/services/spider-fly.mjs +0 -943
@@ -50,17 +50,17 @@
50
50
  {
51
51
  "file": "src/town-square-chapel.jsonl",
52
52
  "bytes": 108408,
53
- "sha256": "2c378e51c2f7d53d700e557970b72035aadcbea9496cc959adee09dd77181334"
53
+ "sha256": "02036d284ec27244494e1f575b750efa71d9a0fc232e57dbd8b5dab7e6a6b7b3"
54
54
  },
55
55
  {
56
56
  "file": "src/town-square-market.jsonl",
57
57
  "bytes": 52311,
58
- "sha256": "91682ef54cdddd3d3fe0c5877d107f9577bff9d85621bfb4979947f89b029a45"
58
+ "sha256": "dd374f9cb1c6a13ccfab62badeb4150b8cfefdc349f834430c2c9e105c1d7a4f"
59
59
  },
60
60
  {
61
61
  "file": "src/town-square.jsonl",
62
62
  "bytes": 75251,
63
- "sha256": "3238dcd53827e4139c0752bebc204257cac4a4663a8263183d387cc98e739779"
63
+ "sha256": "2fb87416759d8cee1464b55d4e811b91e772d52f65e0f06a88eac1d3a8fc5e7f"
64
64
  }
65
65
  ],
66
66
  "files": [
@@ -106,18 +106,18 @@
106
106
  },
107
107
  {
108
108
  "file": "shards/town-square-chapel.jsonl.gz",
109
- "bytes": 5540,
110
- "sha256": "f7515fc12de8bf40f5a5c0635032be2d4fe2f5a18d153a1b64fdc2adbcf5745c"
109
+ "bytes": 5538,
110
+ "sha256": "826e8732b419b506098f4fbce1adcc07d11bd7d91ea672aa46f13ed3ccebb0bb"
111
111
  },
112
112
  {
113
113
  "file": "shards/town-square-market.jsonl.gz",
114
- "bytes": 3116,
115
- "sha256": "5791be29b86ad2329978a8bca76dafb9a0ae2419beb0d8d00b83b958adb989d6"
114
+ "bytes": 3113,
115
+ "sha256": "c6834f0754498ff800e7be620e3e2268c375686bd753fa9da9b7dd89a63aa009"
116
116
  },
117
117
  {
118
118
  "file": "shards/town-square.jsonl.gz",
119
- "bytes": 4277,
120
- "sha256": "657e3a8825e579e38b24b64f4df95c17ee95caa10862afc0067acdf1f2db842f"
119
+ "bytes": 4275,
120
+ "sha256": "460864ccee3a6eed9dae93f158abaf9780f2b7fb4051f45c3ebc63c7400b01d2"
121
121
  }
122
122
  ]
123
123
  }
@@ -920,7 +920,7 @@
920
920
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
921
921
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
922
922
  {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
923
- {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
923
+ {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
924
924
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
925
925
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
926
926
  {"world":"town-square-chapel","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -446,7 +446,7 @@
446
446
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
447
447
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
448
448
  {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
449
- {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
449
+ {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
450
450
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
451
451
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
452
452
  {"world":"town-square-market","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -678,7 +678,7 @@
678
678
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
679
679
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
680
680
  {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
681
- {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
681
+ {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
682
682
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
683
683
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
684
684
  {"world":"town-square","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -5,7 +5,8 @@
5
5
  "Every GLB listed here uses EXT_meshopt_compression and KHR_mesh_quantization (verified by parsing each file's JSON chunk directly, not by trusting docs). Three additionally use EXT_texture_webp: creatures/goblin.glb, foliage/oak_1.glb, foliage/bush.glb. three.js's GLTFLoader decodes KHR_mesh_quantization and EXT_texture_webp natively, so nobody needs to hunt for a decoder for either of those. EXT_meshopt_compression is the one exception and needs exactly one line: loader.setMeshoptDecoder(MeshoptDecoder). See commands.vendorMeshoptDecoderLine below, and commands.fallbackTranscodeToPlainGlb if vendoring the decoder turns out to be undesirable.",
6
6
  "Food markers (cheese, apples, apple crate) are primitive geometry in v1, not models. The KayKit food props (food_cheese, food_apple_*, food_crate_large_apples) are almost certainly CC0 at source, but no CREDITS.md row names a KayKit resource pack, so they're unrecorded rather than free to take and are excluded here. Fetching the genuine KayKit Resource Bits pack from source (https://kaykit.itch.io) and adding a CREDITS.md row for it is a clean later upgrade, not a wall.",
7
7
  "The predator is creatures/fox.glb, not creatures/wolf_basic.glb. wolf_basic.glb looks like the obvious predator model and is close in name to the CREDITS 'Animated creatures' row, but wolf_basic.glb itself is an unrecorded custom Tripo-generated project asset, not the CC0 Quaternius wolf. fox.glb is covered by the CC0 Quaternius creatures row by name, shares the same quadruped rig (Idle/Walk/Gallop/Attack/hit-react/Death via manifest.ts's animal() clip map), and additionally ships an 'Eating' clip verified in the GLB itself -- useful for an eaten-flourish. goblin.glb is the prey/scavenger, matching world-of-claudecraft's own mob_kobold casting (Idle/Walk/Run/Attack/HitRecieve/Death) -- note HitRecieve is misspelled in the asset itself and is reproduced verbatim below, not corrected.",
8
- "targetHeight is the model's own measured bounding-box height in metres (computed from each GLB's POSITION accessor bounds through its node transform chain, dequantizing normalized SHORT/BYTE components first), not an arbitrary guess -- these packs already ship in a consistent real-world scale, so no further per-item normalization judgment call was needed for the static props. The two character rigs (fox, goblin) use world-of-claudecraft's own manifest.ts height values instead, since their POSITION accessors are in skinned bind-pose space and a plain node-transform bbox walk (as used for the static props) does not account for joint transforms."
8
+ "targetHeight is the model's own measured bounding-box height in metres (computed from each GLB's POSITION accessor bounds through its node transform chain, dequantizing normalized SHORT/BYTE components first), not an arbitrary guess -- these packs already ship in a consistent real-world scale, so no further per-item normalization judgment call was needed for the static props. The two character rigs (fox, goblin) use world-of-claudecraft's own manifest.ts height values instead, since their POSITION accessors are in skinned bind-pose space and a plain node-transform bbox walk (as used for the static props) does not account for joint transforms.",
9
+ "food-crumb and food-morsel both reuse haybale.glb (measured bbox height 1.09m, a full-size bale) rather than the raw bbox height, because the render layer always rescales a loaded GLB to targetHeight (mudiii-scene.mjs's normalizeToHeight) regardless of the model's authored size. Their targetHeight values instead match the primitive spheres they replace (CRUMB_RADIUS 0.08, MORSEL_RADIUS 0.18 in mudiii-scene.mjs, doubled to a diameter) so the swap keeps the same on-ground footprint a fox already reads correctly against."
9
10
  ],
10
11
  "commands": {
11
12
  "verifyBytes": "stat -f '%z' <file> # BSD/macOS; use `stat -c '%s' <file>` on GNU/Linux",
@@ -20,7 +21,7 @@
20
21
  "destPath": "public/models/creatures/fox.glb",
21
22
  "licence": "CC0-1.0",
22
23
  "author": "Quaternius",
23
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
24
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
24
25
  "bytes": 332748,
25
26
  "sha256": "2694028b47e84bad3b9d985aa7743dae02a3bcfddd6becd73b46c747a1e067b3",
26
27
  "targetHeight": 1.0,
@@ -29,7 +30,10 @@
29
30
  "walk": "Walk",
30
31
  "run": "Gallop",
31
32
  "attack": "Attack",
32
- "hit": ["Idle_HitReact_Left", "Idle_HitReact_Right"],
33
+ "hit": [
34
+ "Idle_HitReact_Left",
35
+ "Idle_HitReact_Right"
36
+ ],
33
37
  "death": "Death",
34
38
  "eat": "Eating"
35
39
  }
@@ -40,7 +44,7 @@
40
44
  "destPath": "public/models/creatures/goblin.glb",
41
45
  "licence": "CC0-1.0",
42
46
  "author": "Quaternius",
43
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
47
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
44
48
  "bytes": 47924,
45
49
  "sha256": "2f02ce927a8871554f3931655d75f1abb8237d36be8a19a35b09920d0812bc3c",
46
50
  "targetHeight": 2.1,
@@ -49,7 +53,9 @@
49
53
  "walk": "Walk",
50
54
  "run": "Run",
51
55
  "attack": "Attack",
52
- "hit": ["HitRecieve"],
56
+ "hit": [
57
+ "HitRecieve"
58
+ ],
53
59
  "death": "Death"
54
60
  }
55
61
  },
@@ -196,6 +202,30 @@
196
202
  "sha256": "614172ada35fc6048487a6af6d77059a5c031e0a93eff9d58036649ea0d16c12",
197
203
  "targetHeight": 1.57,
198
204
  "clips": null
205
+ },
206
+ {
207
+ "key": "food-crumb",
208
+ "sourcePath": "public/models/dungeon/haybale.glb",
209
+ "destPath": "public/models/props/haybale.glb",
210
+ "licence": "CC0-1.0",
211
+ "author": "Kay Lousberg (KayKit)",
212
+ "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
213
+ "bytes": 14004,
214
+ "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
215
+ "targetHeight": 0.6,
216
+ "clips": null
217
+ },
218
+ {
219
+ "key": "food-morsel",
220
+ "sourcePath": "public/models/dungeon/haybale.glb",
221
+ "destPath": "public/models/props/haybale.glb",
222
+ "licence": "CC0-1.0",
223
+ "author": "Kay Lousberg (KayKit)",
224
+ "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
225
+ "bytes": 14004,
226
+ "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
227
+ "targetHeight": 0.6,
228
+ "clips": null
199
229
  }
200
230
  ]
201
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -46,6 +46,10 @@ import {
46
46
  ROLLUP_PRIOR_PROP, ROLLUP_EARLIEST_PROP, ROLLUP_LATEST_PROP, ROLLUP_COUNT_PROP,
47
47
  CHAIN_ROLLUP_THRESHOLD,
48
48
  } from "../../domain/memory/compaction.mjs";
49
+ import {
50
+ planRetraction, mergeRetractions, retractionFromWire, retractionWireFact,
51
+ isRetractedRecord, RETRACTION_CLASS,
52
+ } from "../../domain/memory/retraction.mjs";
49
53
  import { assertIndividualValid } from "./shacl.mjs";
50
54
 
51
55
  // The rollup vocabulary and its tuning constants live with the compaction
@@ -57,6 +61,15 @@ export {
57
61
  headRollupIdFor, chainRollupIdFor, isHeadRollupId, isChainRollupId, isRollupId,
58
62
  } from "../../domain/memory/compaction.mjs";
59
63
 
64
+ // Same reasoning for the retraction vocabulary: the tombstone shape is the
65
+ // domain layer's, and store consumers reach it through this one import site.
66
+ export {
67
+ RETRACTION_CLASS, RETRACTION_PREDICATE,
68
+ RETRACTED_RECORD_IDS_PROP, RETRACTED_AT_PROP, RETRACTED_COUNT_PROP,
69
+ retractionIdFor, isRetractionId, retractionScopeOf,
70
+ retractedRecordIds, retractedAtOf, retractionWireFact, retractionFromWire,
71
+ } from "../../domain/memory/retraction.mjs";
72
+
60
73
  export const MEMORY_DIR_REL = join(".tmct", "memory");
61
74
  export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
62
75
 
@@ -316,6 +329,16 @@ export function factGroupId(recordId) {
316
329
  return at < 0 ? id : id.slice(0, at);
317
330
  }
318
331
 
332
+ /** The record id one provenance tag keys for a triple. A store files an
333
+ * assertion under the Source its OWN tag derives, and a broadcast relabel
334
+ * changes that Source — locally a chat session, at the peer the node that
335
+ * sent it. So two stores hold one assertion under two ids, and anything that
336
+ * has to name a record ACROSS the wire (a retraction does) resolves the id
337
+ * through the tag rather than assuming both ends agree. */
338
+ export function factRecordIdForTag(groupId, tag) {
339
+ return `${groupId}@${assertionSourceFor(tag).id}`;
340
+ }
341
+
319
342
  /** The assertion key one provenance tag derives — the SAME closed derivation
320
343
  * Source individuals already use, with the `src:none` singleton standing in
321
344
  * for a tag that parses to no Source at all. Under this model every record
@@ -1354,10 +1377,15 @@ function buildMemoryIndex(payload) {
1354
1377
  // anyone asserting this yet" and an edge can resolve a group id to the real
1355
1378
  // nodes behind it, both without a scan.
1356
1379
  const factRecordsByGroup = new Map();
1380
+ // groupId -> the retraction records standing over that triple, so the write
1381
+ // path can ask "was this source's assertion retracted" without a scan. Almost
1382
+ // always empty, which is why it is read before anything more expensive.
1383
+ const retractionsByGroup = new Map();
1357
1384
  for (const ind of payload.individuals || []) {
1358
1385
  if (!ind?.id) continue;
1359
1386
  individualsById.set(ind.id, ind);
1360
1387
  if (ind.class === SOURCE_CLASS) sourcesById.set(ind.id, ind);
1388
+ if (ind.class === RETRACTION_CLASS) indexRetraction(retractionsByGroup, ind);
1361
1389
  if (ind.class === FACT_CLASS) {
1362
1390
  const groupId = factGroupId(ind.id);
1363
1391
  const held = factRecordsByGroup.get(groupId);
@@ -1372,10 +1400,29 @@ function buildMemoryIndex(payload) {
1372
1400
  if (list) list.push(e.object);
1373
1401
  else statedByBySubject.set(e.subject, [e.object]);
1374
1402
  }
1375
- payload[MEMORY_INDEX] = { individualsById, sourcesById, statedByBySubject, factRecordsByGroup };
1403
+ payload[MEMORY_INDEX] = { individualsById, sourcesById, statedByBySubject, factRecordsByGroup, retractionsByGroup };
1376
1404
  return payload[MEMORY_INDEX];
1377
1405
  }
1378
1406
 
1407
+ /** File one retraction record under the triple it stands over, replacing any
1408
+ * earlier reference to the same id — upsertIndividual merges in place, so the
1409
+ * index must hold the record the payload holds, not a stale copy of it. */
1410
+ function indexRetraction(retractionsByGroup, record) {
1411
+ const groupId = factGroupId(record.id);
1412
+ const held = (retractionsByGroup.get(groupId) || []).filter((r) => r.id !== record.id);
1413
+ held.push(record);
1414
+ retractionsByGroup.set(groupId, held);
1415
+ }
1416
+
1417
+ /** Every retraction record standing over one triple. Reads the index when there
1418
+ * is one and falls back to a scan for a hand-built fixture, exactly as the
1419
+ * rollup lookup beside it does. */
1420
+ function retractionsFor(payload, groupId) {
1421
+ const idx = memoryIndexOf(payload);
1422
+ if (idx) return idx.retractionsByGroup.get(groupId) || [];
1423
+ return (payload?.individuals || []).filter((i) => i?.class === RETRACTION_CLASS && factGroupId(i.id) === groupId);
1424
+ }
1425
+
1379
1426
  /** The active lookup index for this payload, or null when this payload wasn't
1380
1427
  * built by mutateMemory (a bare test fixture) — callers fall back to a
1381
1428
  * linear scan in that case. */
@@ -1673,16 +1720,26 @@ function joinIfRollup(payload, prior, incoming) {
1673
1720
  return mergeRollups(prior, incoming, { priorFor: (sid) => assertionPrior(sourceType, sources[sid]) });
1674
1721
  }
1675
1722
 
1723
+ /** A retraction record joins the same way a summary does, by union of the ids
1724
+ * it carries — the difference is only which ids they are and what the reader
1725
+ * does with them. Everything that is neither keeps plain last-write-wins. */
1726
+ function joinIfReplicatedRecord(payload, prior, incoming) {
1727
+ if (incoming?.class === RETRACTION_CLASS) return mergeRetractions(prior, incoming);
1728
+ return joinIfRollup(payload, prior, incoming);
1729
+ }
1730
+
1676
1731
  function upsertIndividual(payload, ind) {
1677
1732
  const idx = memoryIndexOf(payload);
1678
1733
  if (idx) {
1679
1734
  const prior = idx.individualsById.get(ind.id);
1680
1735
  if (prior) {
1681
- Object.assign(prior, joinIfRollup(payload, prior, ind));
1736
+ Object.assign(prior, joinIfReplicatedRecord(payload, prior, ind));
1737
+ if (prior.class === RETRACTION_CLASS) indexRetraction(idx.retractionsByGroup, prior);
1682
1738
  return prior;
1683
1739
  }
1684
1740
  payload.individuals.push(ind);
1685
1741
  idx.individualsById.set(ind.id, ind);
1742
+ if (ind.class === RETRACTION_CLASS) indexRetraction(idx.retractionsByGroup, ind);
1686
1743
  if (ind.class === FACT_CLASS) {
1687
1744
  const groupId = factGroupId(ind.id);
1688
1745
  const held = idx.factRecordsByGroup.get(groupId);
@@ -1693,7 +1750,7 @@ function upsertIndividual(payload, ind) {
1693
1750
  }
1694
1751
  const i = payload.individuals.findIndex((x) => x?.id === ind.id);
1695
1752
  if (i >= 0) {
1696
- payload.individuals[i] = joinIfRollup(payload, payload.individuals[i], ind);
1753
+ payload.individuals[i] = joinIfReplicatedRecord(payload, payload.individuals[i], ind);
1697
1754
  return payload.individuals[i];
1698
1755
  }
1699
1756
  payload.individuals.push(ind);
@@ -1884,16 +1941,28 @@ export function factRecordIdsFor(payload, groupId) {
1884
1941
  * provenance-less write onto an already-asserted triple names no new source,
1885
1942
  * so it files no second, unattributable sibling beside the real ones — its
1886
1943
  * triple-level payload lands through restateFactGroup instead. */
1887
- function assertionGroupsFor(payload, groupId, provenance) {
1888
- const groups = groupTagsBySource(provenance);
1944
+ function assertionGroupsFor(payload, groupId, provenance, createdAt = "") {
1945
+ let groups = groupTagsBySource(provenance);
1889
1946
  if (groups.length === 1 && groups[0].sourceId === NO_SOURCE_ID) {
1890
1947
  if (factRecordIdsFor(payload, groupId).length) return [];
1891
1948
  }
1892
- // A source this group has already compacted away stays compacted. Every
1893
- // delivery path for a fact lands here, so this is where a late or re-synced
1894
- // copy of an absorbed assertion is recognized and dropped rather than
1895
- // inserted without it the next sync resurrects everything compaction just
1896
- // folded, which is what makes deleting from a replicated set hard at all.
1949
+ // A source whose assertion was retracted here does not come back on the next
1950
+ // sync. This is the ingest half of the enforcement: every delivery path for a
1951
+ // fact lands in this function, so a re-sent copy of a retracted assertion is
1952
+ // recognized and dropped rather than re-materialized. The comparison is
1953
+ // against the assertion's OWN embedded instant, so the same source saying the
1954
+ // thing again — a fresh tag, a later moment — still lands.
1955
+ const retractions = retractionsFor(payload, groupId);
1956
+ if (retractions.length) {
1957
+ groups = groups.filter((group) => !isRetractedRecord(
1958
+ retractions,
1959
+ `${groupId}@${group.sourceId}`,
1960
+ assertionTimestampFor(group.tags, createdAt),
1961
+ ));
1962
+ if (!groups.length) return groups;
1963
+ }
1964
+ // A source this group has already compacted away stays compacted, for the
1965
+ // same reason and at the same point.
1897
1966
  const rollups = headRollupsFor(payload, groupId);
1898
1967
  if (!rollups.length) return groups;
1899
1968
  return groups.filter((group) => !isAbsorbedSource(rollups, group.sourceId));
@@ -2226,7 +2295,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
2226
2295
  const tokens = proseTokensFor({ doc: text });
2227
2296
  const q = normText(quantifier);
2228
2297
  await mutateMemory(dir, async (payload) => {
2229
- const groups = assertionGroupsFor(payload, groupId, normText(provenance));
2298
+ const groups = assertionGroupsFor(payload, groupId, normText(provenance), createdAt);
2230
2299
  for (const id of groups.length ? [] : restateFactGroup(payload, groupId, { quantifier: q })) {
2231
2300
  syncFactSources(payload, storedIndividual(payload, id), undefined, { premiseTrusts, ruleConfidence });
2232
2301
  }
@@ -2311,7 +2380,7 @@ export async function appendFacts(dir, facts) {
2311
2380
  const seen = new Set();
2312
2381
  const trustOptsById = new Map();
2313
2382
  for (const f of prepared) {
2314
- const groups = assertionGroupsFor(payload, f.id, f.provenance);
2383
+ const groups = assertionGroupsFor(payload, f.id, f.provenance, f.createdAt);
2315
2384
  // Naming no source, this write asserts nothing new — but its premise
2316
2385
  // environments and quantifier still belong on the records already there.
2317
2386
  for (const id of groups.length ? [] : restateFactGroup(payload, f.id, { quantifier: f.quantifier, environments: f.environments })) {
@@ -2799,7 +2868,15 @@ function factFoldContext(memory) {
2799
2868
  }
2800
2869
 
2801
2870
  const groups = new Map();
2871
+ const retractionsByGroup = new Map();
2802
2872
  for (const ind of individuals) {
2873
+ if (ind?.class === RETRACTION_CLASS) {
2874
+ const groupId = factGroupId(ind.id);
2875
+ const held = retractionsByGroup.get(groupId);
2876
+ if (held) held.push(ind);
2877
+ else retractionsByGroup.set(groupId, [ind]);
2878
+ continue;
2879
+ }
2803
2880
  if (ind?.class !== FACT_CLASS) continue;
2804
2881
  if ((ind.attributes || []).some((a) => a?.prop === SUPERSEDED_BY_PROP)) continue; // a demoted leaf, not a head
2805
2882
  if (isChainRollupId(ind.id)) continue; // a summary of one source's demoted history, which was never a vote
@@ -2809,6 +2886,24 @@ function factFoldContext(memory) {
2809
2886
  else groups.set(groupId, [ind]);
2810
2887
  }
2811
2888
 
2889
+ // The read half of retraction enforcement. A record a peer re-delivered before
2890
+ // its retraction arrived is still sitting in the payload, and this is what
2891
+ // keeps it out of the answer: the fold is a pure function of the fact set, so
2892
+ // both peers read the same row whichever order the two arrived in. Only a
2893
+ // group that actually carries a retraction pays anything for the check.
2894
+ for (const [groupId, retractions] of retractionsByGroup) {
2895
+ const members = groups.get(groupId);
2896
+ if (!members) continue;
2897
+ const standing = members.filter((ind) => !isRetractedRecord(
2898
+ retractions,
2899
+ ind.id,
2900
+ assertionTimestampFor(individualAttr(ind, "mgx:factProvenance").split(" | ").filter(Boolean), individualAttr(ind, CREATED_AT_PROP)),
2901
+ ));
2902
+ if (standing.length === members.length) continue;
2903
+ if (standing.length) groups.set(groupId, standing);
2904
+ else groups.delete(groupId);
2905
+ }
2906
+
2812
2907
  const groupsByPair = new Map();
2813
2908
  for (const [groupId, members] of groups) {
2814
2909
  // Codepoint order on the record id, which sorts by source key — the same
@@ -2935,21 +3030,48 @@ function foldFactGroup(id, heads, ctx) {
2935
3030
  };
2936
3031
  }
2937
3032
 
3033
+ /** The source key one record id belongs to: what sits between the `@` and any
3034
+ * chain or summary suffix. A record's own stored key wins when it has one; the
3035
+ * parse covers a demoted leaf and a summary, which carry the same key in their
3036
+ * id but do not all store it. */
3037
+ function recordSourceIdOf(record) {
3038
+ const stored = individualAttr(record, SOURCE_ID_PROP);
3039
+ if (stored) return stored;
3040
+ const id = String(record?.id || "");
3041
+ const at = id.indexOf("@");
3042
+ if (at < 0) return "";
3043
+ const rest = id.slice(at + 1);
3044
+ const hash = rest.indexOf("#");
3045
+ return hash < 0 ? rest : rest.slice(0, hash);
3046
+ }
3047
+
2938
3048
  /** Retract facts by id — a real DELETE (syllogise.mjs's retractability
2939
3049
  * mechanism). A GROUP id retracts the triple: every source's record for it,
2940
3050
  * demoted leaves included, since retracting "dogs bark" cannot leave half its
2941
3051
  * assertions standing. A single record id retracts just that record. Scrubs
2942
3052
  * any edge referencing a removed id as subject or object; an orphaned Source
2943
- * is left in place (not a GC pass). Unknown ids are silently skipped. Returns
2944
- * { removed } — the ids asked for that matched, so it may be smaller than the
2945
- * input and is never longer than it. */
2946
- export async function removeFacts(dir, ids) {
3053
+ * is left in place (not a GC pass). Unknown ids are silently skipped.
3054
+ *
3055
+ * The delete leaves a RETRACTION RECORD behind, one per (triple, source),
3056
+ * carrying the record ids it suppressed and the moment it did. That record is
3057
+ * what makes the retraction survive a sync: a plain delete against a grow-only
3058
+ * set comes straight back from any peer that still holds the fact. It also
3059
+ * keeps the retraction on record rather than erasing the fact that something
3060
+ * was asserted at all. A retraction record is never itself removed here.
3061
+ *
3062
+ * Returns { removed, records } — `removed` the ids asked for that matched, so
3063
+ * it may be smaller than the input and is never longer than it; `records` the
3064
+ * concrete record ids that went, which is what the retraction absorbed. */
3065
+ export async function removeFacts(dir, ids, { provenance = "", retractedAt = "" } = {}) {
2947
3066
  const idSet = new Set((ids || []).filter(Boolean));
2948
3067
  const removed = [];
2949
- if (!idSet.size) return { removed };
3068
+ const records = [];
3069
+ if (!idSet.size) return { removed, records };
3070
+ const retractedAtVal = retractedAt || nowIso();
2950
3071
  await mutateMemory(dir, (payload) => {
2951
3072
  const removedSet = new Set();
2952
3073
  const matched = new Set();
3074
+ const retiredByGroupAndSource = new Map(); // `${groupId}${sourceId}` -> { groupId, sourceId, ids, template }
2953
3075
  payload.individuals = (payload.individuals || []).filter((ind) => {
2954
3076
  if (ind?.class !== FACT_CLASS) return true;
2955
3077
  const groupId = factGroupId(ind.id);
@@ -2957,18 +3079,114 @@ export async function removeFacts(dir, ids) {
2957
3079
  if (!asked) return true;
2958
3080
  matched.add(asked);
2959
3081
  removedSet.add(ind.id);
3082
+ const sourceId = recordSourceIdOf(ind);
3083
+ const key = `${groupId}${sourceId}`;
3084
+ // The record's own tags come along. They are the account of what was
3085
+ // asserted and by whom, which a retraction keeps rather than erases, and
3086
+ // they are also what lets a broadcast re-key this id onto the Source the
3087
+ // receiving store files the same assertion under.
3088
+ const tags = individualAttr(ind, "mgx:factProvenance");
3089
+ const retired = retiredByGroupAndSource.get(key);
3090
+ if (retired) {
3091
+ retired.ids.push(ind.id);
3092
+ if (tags) retired.tags.push(tags);
3093
+ } else {
3094
+ retiredByGroupAndSource.set(key, {
3095
+ groupId,
3096
+ sourceId,
3097
+ ids: [ind.id],
3098
+ tags: tags ? [tags] : [],
3099
+ template: {
3100
+ label: ind.label || "",
3101
+ subject: individualAttr(ind, "rdf:subject"),
3102
+ predicate: individualAttr(ind, "rdf:predicate"),
3103
+ object: individualAttr(ind, "rdf:object"),
3104
+ },
3105
+ });
3106
+ }
2960
3107
  return false;
2961
3108
  });
2962
3109
  for (const id of matched) removed.push(id);
2963
3110
  if (!removed.length) return; // honest no-op — nothing matched, no write needed beyond this
3111
+ for (const id of removedSet) records.push(id);
2964
3112
  for (const group of payload.objectProperties || []) {
2965
3113
  const before = group.examples || [];
2966
3114
  group.examples = before.filter((e) => !removedSet.has(e?.subject) && !removedSet.has(e?.object));
2967
3115
  group.count = group.examples.length;
2968
3116
  }
3117
+ for (const retired of retiredByGroupAndSource.values()) {
3118
+ const record = planRetraction({
3119
+ groupId: retired.groupId,
3120
+ sourceId: retired.sourceId,
3121
+ recordIds: retired.ids,
3122
+ retractedAt: retractedAtVal,
3123
+ template: retired.template,
3124
+ provenance: [...retired.tags, provenance].filter(Boolean).join(" | "),
3125
+ });
3126
+ if (record) upsertIndividual(payload, record);
3127
+ }
3128
+ recountClasses(payload);
3129
+ });
3130
+ return { removed, records };
3131
+ }
3132
+
3133
+ /** Every retraction record the store holds, as the wire facts that carry them.
3134
+ * The P2P layer's own diff and sync response read this: a retraction is not a
3135
+ * fact row, so nothing that walks readFactRows would ever find one. */
3136
+ export function readRetractions(memory) {
3137
+ const out = [];
3138
+ for (const ind of memory?.individuals || []) {
3139
+ if (ind?.class !== RETRACTION_CLASS) continue;
3140
+ const fact = retractionWireFact(ind);
3141
+ if (fact) out.push(fact);
3142
+ }
3143
+ return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3144
+ }
3145
+
3146
+ /** Merge received retractions and enforce them. Two steps, and both are
3147
+ * needed: the record joins by union with whatever this store already held for
3148
+ * that (triple, source), then any assertion it now suppresses is dropped. The
3149
+ * second step is what makes a retraction that arrives AFTER the fact converge
3150
+ * with one that arrives before it.
3151
+ *
3152
+ * Returns { merged, removed } — how many records landed, and the record ids the
3153
+ * enforcement took out. */
3154
+ export async function appendRetractions(dir, wireFacts) {
3155
+ const incoming = [];
3156
+ for (const fact of wireFacts || []) {
3157
+ const record = retractionFromWire(fact);
3158
+ if (record) incoming.push(record);
3159
+ }
3160
+ const removed = [];
3161
+ if (!incoming.length) return { merged: 0, removed };
3162
+ await mutateMemory(dir, (payload) => {
3163
+ const suppress = new Map(); // groupId -> the merged retraction records over it
3164
+ for (const record of incoming) {
3165
+ const stored = upsertIndividual(payload, record);
3166
+ const groupId = factGroupId(stored.id);
3167
+ const held = suppress.get(groupId);
3168
+ if (held) held.push(stored);
3169
+ else suppress.set(groupId, [stored]);
3170
+ }
3171
+ // A retraction that names a triple this store never held is still stored —
3172
+ // it has to be, or the fact arriving later would land unopposed.
3173
+ const drop = new Set();
3174
+ for (const [groupId, retractions] of suppress) {
3175
+ for (const recordId of factRecordIdsFor(payload, groupId)) {
3176
+ const stored = storedIndividual(payload, recordId);
3177
+ if (!stored) continue;
3178
+ const tags = individualAttr(stored, "mgx:factProvenance").split(" | ").filter(Boolean);
3179
+ if (!isRetractedRecord(retractions, recordId, assertionTimestampFor(tags, individualAttr(stored, CREATED_AT_PROP)))) continue;
3180
+ drop.add(recordId);
3181
+ }
3182
+ }
3183
+ if (drop.size) {
3184
+ dropAbsorbedRecords(payload, drop);
3185
+ for (const id of drop) removed.push(id);
3186
+ }
2969
3187
  recountClasses(payload);
2970
3188
  });
2971
- return { removed };
3189
+ return { merged: incoming.length, removed };
2972
3190
  }
2973
3191
 
2974
3192
  /** The trust floor a fact must clear before a differing object counts as a real
@@ -222,7 +222,7 @@ export const WORLD_RELATIONS = Object.freeze({
222
222
  reads: "feels",
223
223
  }),
224
224
  mass: Object.freeze({
225
- predicate: "mgx:mass",
225
+ predicate: "mgx:hasMass",
226
226
  comment: "individual -> number: the subject's current mass.",
227
227
  nouns: Object.freeze(["mass", "masses", "weight", "weights"]),
228
228
  reads: "has mass",
@@ -4575,12 +4575,16 @@ function evalWorldRelation(graph, ast) {
4575
4575
  const wantedClasses = ast.classes ? new Set(ast.classes) : null;
4576
4576
  const wantedSubjects = ast.subjects ? new Set(ast.subjects) : null;
4577
4577
  const entry = WORLD_RELATIONS[ast.relation];
4578
+ // Both sides go through normalizePredicate: a curated predicate is written in
4579
+ // whatever case its own vocabulary uses (mgx:hasMass), and comparing a
4580
+ // lowercased row against a mixed-case constant matches nothing.
4581
+ const wantedPredicate = normalizePredicate(entry.predicate);
4578
4582
  const stated = new Map();
4579
4583
  const taught = new Map();
4580
4584
  for (const group of graph?.relations || []) {
4581
4585
  const prop = normalizePredicate(group.prop);
4582
4586
  const predicate = normalizePredicate(group.predicate);
4583
- const statesRelation = prop === entry.predicate || predicate === entry.predicate;
4587
+ const statesRelation = prop === wantedPredicate || predicate === wantedPredicate;
4584
4588
  const prep = statesRelation ? null : taughtLocativePreposition(ast.relation, group);
4585
4589
  if (!statesRelation && !prep) continue;
4586
4590
  for (const edge of group.edges || []) {
@@ -4617,16 +4621,23 @@ function evalWorldRelation(graph, ast) {
4617
4621
  }
4618
4622
 
4619
4623
  // A world writes each turn's facts against a stamped subject ("spider-1@turn3",
4620
- // "player@step7") and folds them back onto the base id, newest stamp winning —
4621
- // spider-fly.mjs's foldSpiderFlyState and domain.mjs's foldWorldState both do
4622
- // exactly this. An unstamped row is the world's own starting value, stamp 0.
4623
- const WORLD_SNAPSHOT_RE = /^(.+)@(?:turn|step)(\d+)$/;
4624
+ // "player@step7", "fox-1@epoch2@turn3" once a recast has moved it on) and folds
4625
+ // them back onto the base id, the newest (epoch, turn) winning — the same fold
4626
+ // predator-prey.mjs and domain.mjs both do. An unstamped row is the world's own
4627
+ // starting value, epoch 0, stamp 0. The base capture is lazy for the epoch
4628
+ // form's sake: a greedy one reads "fox-1@epoch2" as the base, an id no roster
4629
+ // matches, which drops every post-recast row out of the projection silently.
4630
+ const WORLD_SNAPSHOT_RE = /^(.+?)@(?:epoch(\d+)@)?(?:turn|step)(\d+)$/;
4624
4631
 
4625
4632
  function splitWorldSnapshot(term) {
4626
4633
  const m = WORLD_SNAPSHOT_RE.exec(String(term ?? ""));
4627
- return m ? { base: m[1], stamp: Number(m[2]) } : { base: String(term ?? ""), stamp: 0 };
4634
+ if (!m) return { base: String(term ?? ""), epoch: 0, stamp: 0 };
4635
+ return { base: m[1], epoch: m[2] ? Number(m[2]) : 0, stamp: Number(m[3]) };
4628
4636
  }
4629
4637
 
4638
+ const outranksSnapshot = (prior, epoch, stamp) =>
4639
+ !prior || prior.epoch < epoch || (prior.epoch === epoch && prior.stamp <= stamp);
4640
+
4630
4641
  /** Project plain world fact rows (`{subject, predicate, object}`, the shape
4631
4642
  * memory/core.mjs's readFactRows returns) into the `{individuals,
4632
4643
  * objectProperties}` payload parseEntities consumes, so a page holding a live
@@ -4654,14 +4665,13 @@ export function worldRelationGraphPayload(rows, { classOf = () => null } = {}) {
4654
4665
  const newest = new Map();
4655
4666
  for (const row of rows || []) {
4656
4667
  if (!row?.subject || !wanted.has(row.predicate)) continue;
4657
- const { base, stamp } = splitWorldSnapshot(row.subject);
4668
+ const { base, epoch, stamp } = splitWorldSnapshot(row.subject);
4658
4669
  const cls = declaredClass.get(base) || classOf(base);
4659
4670
  if (!cls) continue;
4660
4671
  const key = `${row.predicate}\u0000${base}`;
4661
- const prior = newest.get(key);
4662
- if (prior && prior.stamp > stamp) continue;
4672
+ if (!outranksSnapshot(newest.get(key), epoch, stamp)) continue;
4663
4673
  individuals.set(base, { id: base, label: base, class: cls });
4664
- newest.set(key, { stamp, predicate: row.predicate, subject: base, object: splitWorldSnapshot(row.object).base });
4674
+ newest.set(key, { epoch, stamp, predicate: row.predicate, subject: base, object: splitWorldSnapshot(row.object).base });
4665
4675
  }
4666
4676
  const groups = new Map();
4667
4677
  for (const edge of newest.values()) {