@polycode-projects/the-mechanical-code-talker 5.0.2 → 5.0.3
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.
- package/data/mudiii-assets.json +26 -1
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +236 -18
- package/src/domain/ask-vocab.mjs +1 -1
- package/src/domain/ask.mjs +20 -10
- package/src/domain/game-config.mjs +7 -0
- package/src/domain/interpret/normalize.mjs +4 -0
- package/src/domain/memory/retraction.mjs +232 -0
- package/src/domain/p2p/sync-filter.mjs +9 -1
- package/src/domain/spider-fly-world.mjs +69 -22
- package/src/domain/syllogise.mjs +128 -75
- package/src/services/adventure-autoplay.mjs +2 -2
- package/src/services/adventure-viz.mjs +12 -7
- package/src/services/chat.mjs +42 -14
- package/src/services/mud-turn.mjs +1 -1
- package/src/services/mud-viz.mjs +11 -2
- package/src/services/mudiii-scene.mjs +69 -17
- package/src/services/mudiii-viz.mjs +2 -2
- package/src/services/p2p-room.mjs +130 -9
- package/src/services/predator-prey.mjs +524 -69
- package/src/services/spider-fly-turn.mjs +128 -56
- package/src/services/spider-fly-viz.mjs +9 -12
- package/src/services/world-teach.mjs +3 -3
- package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +126 -126
- package/src/surfaces/web/mud-browser-entry.mjs +10 -3
- package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
- package/src/services/spider-fly.mjs +0 -943
package/data/mudiii-assets.json
CHANGED
|
@@ -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",
|
|
@@ -196,6 +197,30 @@
|
|
|
196
197
|
"sha256": "614172ada35fc6048487a6af6d77059a5c031e0a93eff9d58036649ea0d16c12",
|
|
197
198
|
"targetHeight": 1.57,
|
|
198
199
|
"clips": null
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"key": "food-crumb",
|
|
203
|
+
"sourcePath": "public/models/dungeon/haybale.glb",
|
|
204
|
+
"destPath": "public/models/props/haybale.glb",
|
|
205
|
+
"licence": "CC0-1.0",
|
|
206
|
+
"author": "Kay Lousberg (KayKit)",
|
|
207
|
+
"source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
|
|
208
|
+
"bytes": 14004,
|
|
209
|
+
"sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
|
|
210
|
+
"targetHeight": 0.16,
|
|
211
|
+
"clips": null
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"key": "food-morsel",
|
|
215
|
+
"sourcePath": "public/models/dungeon/haybale.glb",
|
|
216
|
+
"destPath": "public/models/props/haybale.glb",
|
|
217
|
+
"licence": "CC0-1.0",
|
|
218
|
+
"author": "Kay Lousberg (KayKit)",
|
|
219
|
+
"source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
|
|
220
|
+
"bytes": 14004,
|
|
221
|
+
"sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
|
|
222
|
+
"targetHeight": 0.36,
|
|
223
|
+
"clips": null
|
|
199
224
|
}
|
|
200
225
|
]
|
|
201
226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
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,
|
|
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] =
|
|
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
|
-
|
|
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
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
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.
|
|
2944
|
-
*
|
|
2945
|
-
*
|
|
2946
|
-
|
|
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
|
-
|
|
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
|
package/src/domain/ask-vocab.mjs
CHANGED
|
@@ -222,7 +222,7 @@ export const WORLD_RELATIONS = Object.freeze({
|
|
|
222
222
|
reads: "feels",
|
|
223
223
|
}),
|
|
224
224
|
mass: Object.freeze({
|
|
225
|
-
predicate: "mgx:
|
|
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",
|
package/src/domain/ask.mjs
CHANGED
|
@@ -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 ===
|
|
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"
|
|
4621
|
-
//
|
|
4622
|
-
//
|
|
4623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()) {
|
|
@@ -82,6 +82,12 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
|
|
|
82
82
|
placedFoodMass: 2,
|
|
83
83
|
maxPreyPopulation: 6,
|
|
84
84
|
maxFoodItems: 8,
|
|
85
|
+
// Whether food only enters an agent's belief once it is within vision
|
|
86
|
+
// radius, the same as every other candidate. True by default — a crumb
|
|
87
|
+
// known to everyone the instant it spawns changes the foraging feel, and
|
|
88
|
+
// this is the one switch that turns that off (visionRadius: Infinity for
|
|
89
|
+
// the food-only belief call) without any new belief machinery.
|
|
90
|
+
foodVisionGated: true,
|
|
85
91
|
}),
|
|
86
92
|
guessNumber: Object.freeze({
|
|
87
93
|
defaultLo: 1,
|
|
@@ -152,6 +158,7 @@ const MUDIII_KEY_MAP = Object.freeze({
|
|
|
152
158
|
placed_food_mass: "placedFoodMass",
|
|
153
159
|
max_prey_population: "maxPreyPopulation",
|
|
154
160
|
max_food_items: "maxFoodItems",
|
|
161
|
+
food_vision_gated: "foodVisionGated",
|
|
155
162
|
});
|
|
156
163
|
|
|
157
164
|
const ADVENTURE_KEY_MAP = Object.freeze({
|
|
@@ -123,6 +123,10 @@ export function correctMisspellings(text) {
|
|
|
123
123
|
return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
|
|
127
|
+
* ("what is a cache", "is a module a component"), never a teach declarative. */
|
|
128
|
+
export const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
|
|
129
|
+
|
|
126
130
|
const FILLER_RE = FILLER_WORDS.length
|
|
127
131
|
? new RegExp(
|
|
128
132
|
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b\\s*,?",
|