@polycode-projects/the-mechanical-code-talker 5.0.1 → 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 -2
- 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/inflect.mjs +67 -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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// memory/retraction.mjs — making a retraction survive the next sync.
|
|
2
|
+
//
|
|
3
|
+
// removeFacts is a real delete, and deleting from a replicated grow-only set is
|
|
4
|
+
// not a G-Set operation: a peer that still holds the fact re-sends it and the
|
|
5
|
+
// retraction is undone. What closes that hole is the move compaction's chain
|
|
6
|
+
// summary already makes. The retraction leaves a RECORD behind, the record
|
|
7
|
+
// carries the record ids it suppressed, and two records at one id merge by
|
|
8
|
+
// UNION of those ids. Union is a join, so two peers that retracted at different
|
|
9
|
+
// moments converge, and merging twice changes nothing.
|
|
10
|
+
//
|
|
11
|
+
// A retraction suppresses ONE SOURCE'S assertion, not the whole triple group.
|
|
12
|
+
// Two peers who independently taught the same fact hold two records; one of
|
|
13
|
+
// them retracting leaves the fact standing and cited to the other, and the
|
|
14
|
+
// retraction stays on record rather than erasing what was asserted. That falls
|
|
15
|
+
// out of absorbing concrete record ids: a record another peer holds and this
|
|
16
|
+
// one never did is not in the set.
|
|
17
|
+
//
|
|
18
|
+
// The instant matters as much as the ids. One source asserting the same triple
|
|
19
|
+
// again lands on the SAME content address, so an id on its own cannot tell a
|
|
20
|
+
// suppressed assertion from a later, deliberate one. The record therefore
|
|
21
|
+
// carries the moment of the retraction, and both enforcement points compare an
|
|
22
|
+
// assertion's own time against it: at or before, suppressed; after, it stands.
|
|
23
|
+
// Max is a join too, so that field merges in either order and agrees.
|
|
24
|
+
//
|
|
25
|
+
// Pure: this module plans, merges and encodes retraction RECORDS. core.mjs owns
|
|
26
|
+
// the store, p2p-room.mjs owns the wire. Its own class, its own id suffix and
|
|
27
|
+
// its own predicate keep it clear of compaction — a compacted record and a
|
|
28
|
+
// retracted one mean opposite things, and one namespace would let a summary
|
|
29
|
+
// read as a tombstone. The CRDT vocabulary above is pinned in
|
|
30
|
+
// docs/references/papers/crdt.md.
|
|
31
|
+
|
|
32
|
+
export const RETRACTION_CLASS = "Retraction";
|
|
33
|
+
|
|
34
|
+
/** The predicate a retraction travels under. The sync filters admit it on its
|
|
35
|
+
* own, because a retraction has to cross a wire that a chat room gates on
|
|
36
|
+
* provenance kind and a mud room gates on world predicates. */
|
|
37
|
+
export const RETRACTION_PREDICATE = "mgx:retracted";
|
|
38
|
+
|
|
39
|
+
export const RETRACTED_RECORD_IDS_PROP = "mgx:retractedRecordIds";
|
|
40
|
+
export const RETRACTED_AT_PROP = "mgx:retractedAt";
|
|
41
|
+
export const RETRACTED_COUNT_PROP = "mgx:retractedCount";
|
|
42
|
+
|
|
43
|
+
const RETRACTION_SUFFIX = "#retracted";
|
|
44
|
+
const PROVENANCE_PROP = "mgx:factProvenance";
|
|
45
|
+
const NO_INSTANT = "-";
|
|
46
|
+
|
|
47
|
+
/** One retraction record per (triple, source), the same per-source shape
|
|
48
|
+
* compaction's chain summary keys on. */
|
|
49
|
+
export const retractionIdFor = (groupId, sourceId) => `${groupId}@${sourceId}${RETRACTION_SUFFIX}`;
|
|
50
|
+
export const isRetractionId = (id) => String(id || "").endsWith(RETRACTION_SUFFIX);
|
|
51
|
+
|
|
52
|
+
/** The record id a retraction suppresses, read back off its own id. */
|
|
53
|
+
export function retractionScopeOf(retractionId) {
|
|
54
|
+
const id = String(retractionId || "");
|
|
55
|
+
return isRetractionId(id) ? id.slice(0, -RETRACTION_SUFFIX.length) : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const attrValue = (ind, prop) => (ind?.attributes || []).find((a) => a?.prop === prop)?.value || "";
|
|
59
|
+
const idList = (value) => String(value || "").split(" ").filter(Boolean);
|
|
60
|
+
const tagList = (value) => String(value || "").split(" | ").filter(Boolean);
|
|
61
|
+
|
|
62
|
+
export const retractedRecordIds = (ind) => idList(attrValue(ind, RETRACTED_RECORD_IDS_PROP));
|
|
63
|
+
export const retractedAtOf = (ind) => attrValue(ind, RETRACTED_AT_PROP);
|
|
64
|
+
|
|
65
|
+
/** The later of two instants, tolerating "" and unparseable input on either
|
|
66
|
+
* side. Max is a join, which is what lets two records merge in either order. */
|
|
67
|
+
function laterOf(a, b) {
|
|
68
|
+
const at = Date.parse(a);
|
|
69
|
+
const bt = Date.parse(b);
|
|
70
|
+
if (!Number.isFinite(at)) return Number.isFinite(bt) ? String(b) : "";
|
|
71
|
+
if (!Number.isFinite(bt)) return String(a);
|
|
72
|
+
return bt > at ? String(b) : String(a);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const templateOf = (record) => ({
|
|
76
|
+
label: record?.label || "",
|
|
77
|
+
subject: attrValue(record, "rdf:subject"),
|
|
78
|
+
predicate: attrValue(record, "rdf:predicate"),
|
|
79
|
+
object: attrValue(record, "rdf:object"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a retraction record from the only things that define one: which record
|
|
84
|
+
* ids it suppressed, when, and who said so. Planning and merging both come
|
|
85
|
+
* through here, so a record built by retracting and the same record reached by
|
|
86
|
+
* merging two halves come out identical — the property the whole design rests
|
|
87
|
+
* on. The triple it names is carried for a reader; nothing enforces on it.
|
|
88
|
+
*/
|
|
89
|
+
function buildRetraction({ id, sourceId, template, ids, retractedAt, tags }) {
|
|
90
|
+
const sorted = [...new Set(ids)].filter(Boolean).sort();
|
|
91
|
+
const provenance = [...new Set(tags)].filter(Boolean).sort();
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
label: template?.label || "",
|
|
95
|
+
class: RETRACTION_CLASS,
|
|
96
|
+
derived_from: [],
|
|
97
|
+
mentions: [],
|
|
98
|
+
attributes: [
|
|
99
|
+
{ prop: "rdf:subject", key: "subject", value: template?.subject || "" },
|
|
100
|
+
{ prop: "rdf:predicate", key: "predicate", value: template?.predicate || "" },
|
|
101
|
+
{ prop: "rdf:object", key: "object", value: template?.object || "" },
|
|
102
|
+
{ prop: "mgx:sourceId", key: "sourceId", value: sourceId || "" },
|
|
103
|
+
{ prop: RETRACTED_RECORD_IDS_PROP, key: "retractedRecordIds", value: sorted.join(" ") },
|
|
104
|
+
{ prop: RETRACTED_COUNT_PROP, key: "retractedCount", value: String(sorted.length) },
|
|
105
|
+
{ prop: RETRACTED_AT_PROP, key: "retractedAt", value: retractedAt || "" },
|
|
106
|
+
...(provenance.length ? [{ prop: PROVENANCE_PROP, key: "provenance", value: provenance.join(" | ") }] : []),
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Plan the record one source's retraction leaves behind. `recordIds` are the
|
|
113
|
+
* ids actually removed for that source — its head, its demoted leaves, and any
|
|
114
|
+
* summary standing for them. `existing` is the record this source already has
|
|
115
|
+
* here, if the triple has been retracted before; its ids come along, so
|
|
116
|
+
* retracting twice keeps one record rather than growing a chain of them.
|
|
117
|
+
*
|
|
118
|
+
* Null when there is nothing to suppress, which keeps a no-op removal from
|
|
119
|
+
* writing a tombstone for a fact nobody ever asserted.
|
|
120
|
+
*/
|
|
121
|
+
export function planRetraction({ groupId, sourceId, recordIds = [], retractedAt = "", existing = null, template = null, provenance = "" }) {
|
|
122
|
+
if (!groupId || !sourceId) return null;
|
|
123
|
+
const ids = [...retractedRecordIds(existing), ...recordIds].filter(Boolean);
|
|
124
|
+
if (!ids.length) return null;
|
|
125
|
+
return buildRetraction({
|
|
126
|
+
id: retractionIdFor(groupId, sourceId),
|
|
127
|
+
sourceId,
|
|
128
|
+
template: template || templateOf(existing),
|
|
129
|
+
ids,
|
|
130
|
+
retractedAt: laterOf(retractedAtOf(existing), retractedAt),
|
|
131
|
+
tags: [...tagList(attrValue(existing, PROVENANCE_PROP)), ...tagList(provenance)],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Join two retraction records that share an id: union the suppressed ids and
|
|
137
|
+
* the tags, take the later instant, then re-derive everything else from that.
|
|
138
|
+
* Commutative, associative and idempotent, because union and max both are and
|
|
139
|
+
* because the count is a function of the union rather than a separate running
|
|
140
|
+
* total.
|
|
141
|
+
*/
|
|
142
|
+
export function mergeRetractions(existing, incoming) {
|
|
143
|
+
const template = templateOf(attrValue(existing, "rdf:subject") ? existing : incoming);
|
|
144
|
+
return buildRetraction({
|
|
145
|
+
id: existing?.id || incoming?.id,
|
|
146
|
+
sourceId: attrValue(existing, "mgx:sourceId") || attrValue(incoming, "mgx:sourceId"),
|
|
147
|
+
template,
|
|
148
|
+
ids: [...retractedRecordIds(existing), ...retractedRecordIds(incoming)],
|
|
149
|
+
retractedAt: laterOf(retractedAtOf(existing), retractedAtOf(incoming)),
|
|
150
|
+
tags: [...tagList(attrValue(existing, PROVENANCE_PROP)), ...tagList(attrValue(incoming, PROVENANCE_PROP))],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether an assertion made at `assertedAt` is old enough for the retraction
|
|
155
|
+
* to bite. An assertion with no readable time cannot show it is newer, so it
|
|
156
|
+
* is suppressed; the same goes for a record whose own instant will not parse.
|
|
157
|
+
* Erring this way keeps a resurrected copy out, and a source that means to say
|
|
158
|
+
* the thing again says it with a fresh tag. */
|
|
159
|
+
function notLaterThan(assertedAt, retractedAt) {
|
|
160
|
+
const asserted = Date.parse(assertedAt);
|
|
161
|
+
const retracted = Date.parse(retractedAt);
|
|
162
|
+
if (!Number.isFinite(retracted) || !Number.isFinite(asserted)) return true;
|
|
163
|
+
return asserted <= retracted;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Does this record suppress `recordId` as asserted at `assertedAt`? */
|
|
167
|
+
export function suppressesRecord(retraction, recordId, assertedAt = "") {
|
|
168
|
+
if (!retraction || !recordId) return false;
|
|
169
|
+
if (!retractedRecordIds(retraction).includes(recordId)) return false;
|
|
170
|
+
return notLaterThan(assertedAt, retractedAtOf(retraction));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The same check across a group's records. Both enforcement halves — the strip
|
|
174
|
+
* on read and the refusal on ingest — ask exactly this question. */
|
|
175
|
+
export function isRetractedRecord(retractions, recordId, assertedAt = "") {
|
|
176
|
+
for (const retraction of retractions || []) {
|
|
177
|
+
if (suppressesRecord(retraction, recordId, assertedAt)) return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The instant and the ids packed into one triple slot. The instant leads, ids
|
|
183
|
+
* follow, space separated — neither an ISO instant nor a record id carries a
|
|
184
|
+
* space, so the split is unambiguous. */
|
|
185
|
+
export function encodeRetractionValue(retractedAt, ids) {
|
|
186
|
+
const sorted = [...new Set(ids || [])].filter(Boolean).sort();
|
|
187
|
+
return [retractedAt || NO_INSTANT, ...sorted].join(" ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function decodeRetractionValue(value) {
|
|
191
|
+
const parts = String(value || "").split(" ").filter(Boolean);
|
|
192
|
+
const head = parts[0] || "";
|
|
193
|
+
const retractedAt = head === NO_INSTANT || !Number.isFinite(Date.parse(head)) ? "" : head;
|
|
194
|
+
const ids = retractedAt || head === NO_INSTANT ? parts.slice(1) : parts;
|
|
195
|
+
return { retractedAt, ids };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A stored record as the one wire fact that carries it: the record id it
|
|
199
|
+
* suppresses as the subject, the retraction predicate, and the instant plus
|
|
200
|
+
* the suppressed ids as the object. Everything else on the record is derived
|
|
201
|
+
* from those, so the round trip loses nothing a peer enforces on. */
|
|
202
|
+
export function retractionWireFact(record) {
|
|
203
|
+
const scope = retractionScopeOf(record?.id);
|
|
204
|
+
if (!scope) return null;
|
|
205
|
+
return {
|
|
206
|
+
id: record.id,
|
|
207
|
+
subject: scope,
|
|
208
|
+
predicate: RETRACTION_PREDICATE,
|
|
209
|
+
object: encodeRetractionValue(retractedAtOf(record), retractedRecordIds(record)),
|
|
210
|
+
provenance: attrValue(record, PROVENANCE_PROP),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The other direction: a received wire fact as the record it stands for.
|
|
215
|
+
* Null for anything that is not a well-formed retraction, so a malformed
|
|
216
|
+
* message is dropped rather than merged. */
|
|
217
|
+
export function retractionFromWire(fact) {
|
|
218
|
+
if (!fact || fact.predicate !== RETRACTION_PREDICATE) return null;
|
|
219
|
+
const scope = String(fact.subject || "");
|
|
220
|
+
const at = scope.indexOf("@");
|
|
221
|
+
if (at <= 0 || at === scope.length - 1) return null;
|
|
222
|
+
const { retractedAt, ids } = decodeRetractionValue(fact.object);
|
|
223
|
+
if (!ids.length) return null;
|
|
224
|
+
return buildRetraction({
|
|
225
|
+
id: `${scope}${RETRACTION_SUFFIX}`,
|
|
226
|
+
sourceId: scope.slice(at + 1),
|
|
227
|
+
template: null,
|
|
228
|
+
ids,
|
|
229
|
+
retractedAt,
|
|
230
|
+
tags: tagList(fact.provenance),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
// What actually needs syncing is the delta: whatever a person or a peer
|
|
6
6
|
// actually added since boot.
|
|
7
7
|
import { provenanceTagToSource } from "../memory/trust.mjs";
|
|
8
|
+
import { RETRACTION_PREDICATE } from "../memory/retraction.mjs";
|
|
9
|
+
|
|
10
|
+
// A retraction crosses on both surfaces, whatever else they disagree about.
|
|
11
|
+
// It carries no teach tag of its own to key on and no world predicate, so
|
|
12
|
+
// leaving it to either filter's own rule would strand it and the deleted fact
|
|
13
|
+
// would come straight back from the next peer that still holds it.
|
|
14
|
+
const alwaysSyncable = (row) => row?.predicate === RETRACTION_PREDICATE;
|
|
8
15
|
|
|
9
16
|
// "teachNode" is the same human teaching, seen from one hop further out: a
|
|
10
17
|
// peer's own relabeled tag, keyed on the node id it carries. It syncs for
|
|
@@ -16,6 +23,7 @@ const CHAT_SYNCABLE_KINDS = new Set(["teach", "operator", "teachNode"]);
|
|
|
16
23
|
* asserted — never a row from the shipped corpus. */
|
|
17
24
|
export function chatSyncableFacts(rows) {
|
|
18
25
|
return rows.filter((row) => {
|
|
26
|
+
if (alwaysSyncable(row)) return true;
|
|
19
27
|
const source = provenanceTagToSource(row.provenance);
|
|
20
28
|
return source ? CHAT_SYNCABLE_KINDS.has(source.kind) : false;
|
|
21
29
|
});
|
|
@@ -31,5 +39,5 @@ export function chatSyncableFacts(rows) {
|
|
|
31
39
|
* this module's own P2P predicates via `extraPredicates`. */
|
|
32
40
|
export function mudSyncableFacts(rows, isMudStatePredicate, extraPredicates = []) {
|
|
33
41
|
const extra = new Set(extraPredicates);
|
|
34
|
-
return rows.filter((row) => extra.has(row.predicate) || isMudStatePredicate(row.predicate));
|
|
42
|
+
return rows.filter((row) => alwaysSyncable(row) || extra.has(row.predicate) || isMudStatePredicate(row.predicate));
|
|
35
43
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// structural self-description facts below quote the same shipped defaults
|
|
6
6
|
// the engine actually runs. Both
|
|
7
7
|
// scripts/gen-spider-fly-world.mjs (writes corpus/worlds/src/spider-fly.jsonl)
|
|
8
|
-
// and src/services/spider-fly.mjs (the runtime) read the SAME grid/web
|
|
8
|
+
// and src/services/spider-fly-turn.mjs (the runtime) read the SAME grid/web
|
|
9
9
|
// constants from here, so the shipped world and the engine that plays it can
|
|
10
10
|
// never drift apart.
|
|
11
11
|
|
|
@@ -14,28 +14,34 @@ import { DEFAULT_GAME_CONFIG } from "./game-config.mjs";
|
|
|
14
14
|
export const WORLD_NAME = "spider-fly";
|
|
15
15
|
export const GRID_SIZE = 10;
|
|
16
16
|
|
|
17
|
-
// The spider's home cell, and the web's Chebyshev radius around it (radius 1
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// The spider's home cell, and the web's Chebyshev radius around it (radius 1 =
|
|
18
|
+
// a 3x3 block). Corner-ish on purpose: a spider that starts here sees close to
|
|
19
|
+
// half the board just from edge-clipping.
|
|
20
20
|
export const WEB_HOME = Object.freeze({ x: 2, y: 2 });
|
|
21
21
|
export const WEB_RADIUS = 1;
|
|
22
22
|
|
|
23
|
-
// A spider-built
|
|
24
|
-
//
|
|
25
|
-
// static home zone's own always-on web without needing separate code paths.
|
|
23
|
+
// A spider-built web stays active for this many turns past the turn it was
|
|
24
|
+
// spun, mirroring the home zone's own always-on web without a second code path.
|
|
26
25
|
export const WEB_DURATION_TURNS = 10;
|
|
27
26
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// (
|
|
34
|
-
// tunable here — overridable per session via tmct.toml's [games.spider-fly]
|
|
35
|
-
// (src/domain/game-config.mjs).
|
|
27
|
+
// A spider starves like a fly does, and gains exactly a fly's remaining mass on
|
|
28
|
+
// an eat. Heavier starting mass than a single fly's worth on purpose — a spider
|
|
29
|
+
// that eats nothing for a while has some runway before starving. The decrement
|
|
30
|
+
// is half a fly's own (spiders live longer between meals than flies do), and —
|
|
31
|
+
// like every other tunable here — overridable per session via tmct.toml's
|
|
32
|
+
// [games.spider-fly] (src/domain/game-config.mjs).
|
|
36
33
|
export const SPIDER_INITIAL_MASS = 15;
|
|
37
34
|
export const SPIDER_MASS_DECREMENT_PER_TURN = 0.5;
|
|
38
35
|
|
|
36
|
+
/** The cast the shared predator/prey engine runs this board with. Same shape as
|
|
37
|
+
* the town square's own roles object, minus the food entry: nothing inert
|
|
38
|
+
* lies on a spider-and-fly board, and a null food role is what says so. */
|
|
39
|
+
export const SPIDER_FLY_ROLES = Object.freeze({
|
|
40
|
+
predator: Object.freeze({ role: "predator", kind: "spider", idPrefix: "spider" }),
|
|
41
|
+
prey: Object.freeze({ role: "prey", kind: "fly", idPrefix: "fly" }),
|
|
42
|
+
food: null,
|
|
43
|
+
});
|
|
44
|
+
|
|
39
45
|
export const cellId = (x, y) => `cell-${x}-${y}`;
|
|
40
46
|
|
|
41
47
|
const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
|
|
@@ -94,11 +100,9 @@ export const DIRECTION_DELTA = Object.freeze({
|
|
|
94
100
|
* sits EXACTLY one cardinal step away (DIRECTION_DELTA) — null for the same
|
|
95
101
|
* cell, a diagonal, or any multi-step gap, so a caller never overstates
|
|
96
102
|
* "adjacent". The one shared primitive both the engine's own plan-driven
|
|
97
|
-
* facing
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* the chat-turn layer to get it (spider-fly-turn.mjs already imports
|
|
101
|
-
* spider-fly.mjs; the reverse would cycle). */
|
|
103
|
+
* facing and the chat dock's deception pills (spider-fly-turn.mjs's
|
|
104
|
+
* pillsForSpiderFly) need — defined once here so neither has to re-derive
|
|
105
|
+
* it. */
|
|
102
106
|
export function oneStepDirectionBetween(fromCell, toCell) {
|
|
103
107
|
for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
|
|
104
108
|
if (fromCell.x + dx === toCell.x && fromCell.y + dy === toCell.y) return direction;
|
|
@@ -124,13 +128,56 @@ export const SEED_TAXONOMY = Object.freeze([
|
|
|
124
128
|
export const WORLD_OPENING =
|
|
125
129
|
"a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
|
|
126
130
|
|
|
131
|
+
/** This board as the shared predator/prey engine's own layout: a bare 10x10
|
|
132
|
+
* grid, the always-on web block, and the cast a fresh session mints. `props`
|
|
133
|
+
* is empty and stays empty — nothing here blocks movement, so every cell is
|
|
134
|
+
* open and the whole perimeter takes an arrival. */
|
|
135
|
+
export const SPIDER_FLY_LAYOUT = Object.freeze({
|
|
136
|
+
name: WORLD_NAME,
|
|
137
|
+
gridSize: GRID_SIZE,
|
|
138
|
+
opening: WORLD_OPENING,
|
|
139
|
+
boardNoun: "board",
|
|
140
|
+
boardSubject: "board",
|
|
141
|
+
cast: Object.freeze({ predators: 1, prey: 1 }),
|
|
142
|
+
props: Object.freeze([]),
|
|
143
|
+
staticWebAt: isInWebBlock,
|
|
144
|
+
webHomeCell: cellId(WEB_HOME.x, WEB_HOME.y),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** The spider-and-fly knobs restated in the engine's role-keyed shape, with the
|
|
148
|
+
* three mechanics this cast wants switched on. The public [games.spider-fly]
|
|
149
|
+
* table stays species-keyed on purpose: a tmct.toml key and a page slider keep
|
|
150
|
+
* the names a player of THIS game would use, and the translation lives here.
|
|
151
|
+
* Unset keys fall back to the shipped defaults, so a partial slider payload is
|
|
152
|
+
* always a complete engine config. Pure. */
|
|
153
|
+
export function spiderFlyEngineConfig(knobs) {
|
|
154
|
+
const k = { ...DEFAULT_GAME_CONFIG.spiderFly, ...(knobs || {}) };
|
|
155
|
+
return {
|
|
156
|
+
predatorInitialMass: k.spiderInitialMass,
|
|
157
|
+
predatorMassDecrementPerTurn: k.spiderMassDecrementPerTurn,
|
|
158
|
+
predatorVisionRadius: k.spiderVisionRadius,
|
|
159
|
+
preyInitialMass: k.flyInitialMass,
|
|
160
|
+
preyMassDecrementPerTurn: k.flyMassDecrementPerTurn,
|
|
161
|
+
preyVisionRadius: k.flyVisionRadius,
|
|
162
|
+
preySpawnIntervalTurns: k.flySpawnIntervalTurns,
|
|
163
|
+
webDurationTurns: k.webDurationTurns,
|
|
164
|
+
eggLayMassThreshold: k.eggLayMassThreshold,
|
|
165
|
+
eggHatchDelayTurns: k.eggHatchDelayTurns,
|
|
166
|
+
eggHatchCount: k.eggHatchCount,
|
|
167
|
+
minHatchlingMass: k.minHatchlingMass,
|
|
168
|
+
carryPreyToWeb: true,
|
|
169
|
+
buildWebs: true,
|
|
170
|
+
layEggs: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
127
174
|
/** Every fact row the shipped world source carries: cell typing, grid
|
|
128
175
|
* adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
|
|
129
176
|
* seed taxonomy — plain { world, kind:"fact", subject, predicate, object }
|
|
130
177
|
* objects, the exact shape src/domain/worlds-pack.mjs's isWorldFactRow
|
|
131
178
|
* reads. Deliberately NOT spider-1/fly-1: the board is reusable static
|
|
132
179
|
* content, minted game entities are a fresh session's own state
|
|
133
|
-
* (src/services/spider-fly.mjs's startSpiderFlyGame). */
|
|
180
|
+
* (src/services/spider-fly-turn.mjs's startSpiderFlyGame). */
|
|
134
181
|
export function* worldFactRows() {
|
|
135
182
|
for (let y = 1; y <= GRID_SIZE; y += 1) {
|
|
136
183
|
for (let x = 1; x <= GRID_SIZE; x += 1) {
|
|
@@ -245,7 +292,7 @@ export function isLiveRenderableAgent(id, state) {
|
|
|
245
292
|
|
|
246
293
|
/** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's
|
|
247
294
|
* shared validator ("every world needs at least one rule row") passes.
|
|
248
|
-
* src/services/spider-fly.mjs never reads these back: grid movement is
|
|
295
|
+
* src/services/spider-fly-turn.mjs never reads these back: grid movement is
|
|
249
296
|
* hand-written pathfinding over findActionPath/findReachableSet
|
|
250
297
|
* (PLAN_SPIDER_FLY.md §5), not the taught action-Rule DSL, so this rides in
|
|
251
298
|
* the shard unused, same as an unrelated fact would. */
|
package/src/domain/syllogise.mjs
CHANGED
|
@@ -1527,14 +1527,35 @@ function bestEnvironmentTrustOpts(provenance, environments, trustOfId) {
|
|
|
1527
1527
|
* without it removal is still correct, the survivor's environments just stay
|
|
1528
1528
|
* stale until the next syllogise pass.
|
|
1529
1529
|
*
|
|
1530
|
+
* `sourceTags` narrows the target removal to the INVOKING PARTY's own
|
|
1531
|
+
* record(s) — a chat `/retract` names every provenance tag its own session
|
|
1532
|
+
* could have asserted the triple under here (the free-form teach lane and
|
|
1533
|
+
* the ACE-parsed assert lane both belong to one session, under two
|
|
1534
|
+
* different tags), so two sources who independently asserted the same
|
|
1535
|
+
* triple never let one's retraction erase the other's. Omitted (or empty),
|
|
1536
|
+
* retraction stays group-wide (every source's record for the triple), which
|
|
1537
|
+
* is what mud EDIT mode wants and what every pre-existing caller of this
|
|
1538
|
+
* function already gets.
|
|
1539
|
+
*
|
|
1530
1540
|
* Returns { retracted, count, budget, depth, truncated, found } — `found` is
|
|
1531
|
-
* false when `subject ⊑ object` was never a stored fact.
|
|
1541
|
+
* false when `subject ⊑ object` was never a stored fact. With `sourceTags`
|
|
1542
|
+
* set, two more fields describe what the SCOPED removal actually did:
|
|
1543
|
+
* `ownRecord` (false when the invoking party never asserted the triple
|
|
1544
|
+
* itself, so nothing of theirs existed to retract) and `stillStands` (true
|
|
1545
|
+
* when another source's record keeps the triple asserted after this one's
|
|
1546
|
+
* record is gone — in which case nothing entailed from it is cascaded,
|
|
1547
|
+
* because its premise never actually stopped holding).
|
|
1532
1548
|
*/
|
|
1533
1549
|
export async function retractSubClassOf(repoDir, subject, object, {
|
|
1534
|
-
budget = 50, depth = 32, maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, store,
|
|
1550
|
+
budget = 50, depth = 32, maxEnvironments = DEFAULT_MAX_ENVIRONMENTS, store, sourceTags = [],
|
|
1535
1551
|
} = {}) {
|
|
1536
1552
|
const { loadMemory, readFactRows, removeFacts } = requireStore(store, ["loadMemory", "readFactRows", "removeFacts"], "retractSubClassOf");
|
|
1537
1553
|
const appendFactsFn = typeof store?.appendFacts === "function" ? store.appendFacts : null;
|
|
1554
|
+
const factRecordIdForTag = typeof store?.factRecordIdForTag === "function" ? store.factRecordIdForTag : null;
|
|
1555
|
+
const scoped = (sourceTags || []).filter(Boolean);
|
|
1556
|
+
if (scoped.length && !factRecordIdForTag) {
|
|
1557
|
+
throw new TypeError("retractSubClassOf needs a store option carrying factRecordIdForTag (memory/core.mjs's) to scope a retraction to sourceTags");
|
|
1558
|
+
}
|
|
1538
1559
|
const s = normFactTerm(subject);
|
|
1539
1560
|
const o = normFactTerm(object);
|
|
1540
1561
|
const targetId = factIdForTriple(s, SUBCLASS_PREDICATE, o);
|
|
@@ -1543,92 +1564,121 @@ export async function retractSubClassOf(repoDir, subject, object, {
|
|
|
1543
1564
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
1544
1565
|
if (!byId.has(targetId)) return { retracted: [], count: 0, budget, depth, truncated: false, found: false };
|
|
1545
1566
|
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
|
|
1549
|
-
//
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
for (const premiseId of env) {
|
|
1557
|
-
if (!citedBy.has(premiseId)) citedBy.set(premiseId, new Set());
|
|
1558
|
-
citedBy.get(premiseId).add(r.id);
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1567
|
+
// The invoking party's own record(s) for the target — one candidate source
|
|
1568
|
+
// id per tag it could have asserted under — filtered down to whichever
|
|
1569
|
+
// ones the triple is ACTUALLY recorded under. Zero of them means it was
|
|
1570
|
+
// never this party's own assertion, whatever else stored it.
|
|
1571
|
+
const targetSourceIds = byId.get(targetId).sourceIds || [];
|
|
1572
|
+
const candidateSourceIds = scoped.map((tag) => factRecordIdForTag(targetId, tag).slice(targetId.length + 1));
|
|
1573
|
+
const ownSourceIds = scoped.length ? targetSourceIds.filter((sid) => candidateSourceIds.includes(sid)) : [];
|
|
1574
|
+
const ownsTarget = !scoped.length || ownSourceIds.length > 0;
|
|
1575
|
+
if (scoped.length && !ownsTarget) {
|
|
1576
|
+
return { retracted: [], count: 0, budget, depth, truncated: false, found: true, ownRecord: false, stillStands: true };
|
|
1561
1577
|
}
|
|
1578
|
+
// Another source's record keeps the triple standing even after this
|
|
1579
|
+
// party's own record(s) go — the premise never actually broke, so no
|
|
1580
|
+
// cascade runs.
|
|
1581
|
+
const stillStands = scoped.length > 0 && ownSourceIds.length < targetSourceIds.length;
|
|
1562
1582
|
|
|
1563
1583
|
const removed = new Set([targetId]);
|
|
1564
1584
|
const order = [targetId]; // deterministic report order: target first, then removal order
|
|
1565
1585
|
const reground = new Map(); // survivor fact id -> the environments to persist for it
|
|
1566
1586
|
let truncated = false;
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1587
|
+
|
|
1588
|
+
// A scoped retraction whose triple STILL STANDS through another source's
|
|
1589
|
+
// record never runs the cascade at all — its premise never actually broke,
|
|
1590
|
+
// so nothing entailed from it loses support.
|
|
1591
|
+
if (!stillStands) {
|
|
1592
|
+
// Only a purely-entailed fact ever carries a walkable justification —
|
|
1593
|
+
// a fact later independently taught is never a cascade candidate at all.
|
|
1594
|
+
const entailedRows = rows.filter((r) => environmentsOf(r).length && isPurelyEntailed(r.provenance));
|
|
1595
|
+
// premise id -> the entailed fact ids whose environments cite it. Built
|
|
1596
|
+
// ONCE; each round's candidate set reads it for the facts the newest
|
|
1597
|
+
// removals could actually touch — backward relevance from the same
|
|
1598
|
+
// structure a forward pass reads forward.
|
|
1599
|
+
const citedBy = new Map();
|
|
1600
|
+
for (const r of entailedRows) {
|
|
1601
|
+
for (const env of environmentsOf(r)) {
|
|
1602
|
+
for (const premiseId of env) {
|
|
1603
|
+
if (!citedBy.has(premiseId)) citedBy.set(premiseId, new Set());
|
|
1604
|
+
citedBy.get(premiseId).add(r.id);
|
|
1605
|
+
}
|
|
1574
1606
|
}
|
|
1575
1607
|
}
|
|
1576
|
-
const candidates = [...candidateIds].map((id) => byId.get(id))
|
|
1577
|
-
.sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
|
|
1578
|
-
if (!candidates.length) break; // fixpoint — nothing cites what just fell
|
|
1579
|
-
|
|
1580
|
-
// The surviving fact set for THIS round excludes every candidate's own
|
|
1581
|
-
// row too, not just `removed` — otherwise a candidate could trivially
|
|
1582
|
-
// "reach itself" through its own not-yet-deleted edge, or lean on a
|
|
1583
|
-
// sibling candidate standing on the same broken premise.
|
|
1584
|
-
const survivors = rows.filter((r) => !removed.has(r.id) && !candidateIds.has(r.id));
|
|
1585
|
-
const survivorIds = new Set(survivors.map((r) => r.id));
|
|
1586
|
-
const enumerateSupport = buildSupportEnumerator(survivors);
|
|
1587
|
-
const stillDerivable = buildSurvivorDerivabilityCheck(survivors);
|
|
1588
1608
|
|
|
1589
|
-
let
|
|
1590
|
-
let
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
// survivor (the stale-justification fix).
|
|
1598
|
-
const environments = environmentsOf(c);
|
|
1599
|
-
const intact = environments.filter((env) => env.every((id) => survivorIds.has(id)));
|
|
1600
|
-
if (intact.length) {
|
|
1601
|
-
if (intact.length !== environments.length) reground.set(c.id, intact);
|
|
1602
|
-
continue;
|
|
1609
|
+
let round = 0;
|
|
1610
|
+
let newlyRemoved = [targetId];
|
|
1611
|
+
for (; round < depth; round += 1) {
|
|
1612
|
+
const candidateIds = new Set();
|
|
1613
|
+
for (const id of newlyRemoved) {
|
|
1614
|
+
for (const cited of citedBy.get(id) || []) {
|
|
1615
|
+
if (!removed.has(cited)) candidateIds.add(cited);
|
|
1616
|
+
}
|
|
1603
1617
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1618
|
+
const candidates = [...candidateIds].map((id) => byId.get(id))
|
|
1619
|
+
.sort((a, b) => a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object));
|
|
1620
|
+
if (!candidates.length) break; // fixpoint — nothing cites what just fell
|
|
1621
|
+
|
|
1622
|
+
// The surviving fact set for THIS round excludes every candidate's own
|
|
1623
|
+
// row too, not just `removed` — otherwise a candidate could trivially
|
|
1624
|
+
// "reach itself" through its own not-yet-deleted edge, or lean on a
|
|
1625
|
+
// sibling candidate standing on the same broken premise.
|
|
1626
|
+
const survivors = rows.filter((r) => !removed.has(r.id) && !candidateIds.has(r.id));
|
|
1627
|
+
const survivorIds = new Set(survivors.map((r) => r.id));
|
|
1628
|
+
const enumerateSupport = buildSupportEnumerator(survivors);
|
|
1629
|
+
const stillDerivable = buildSurvivorDerivabilityCheck(survivors);
|
|
1630
|
+
|
|
1631
|
+
let progressed = false;
|
|
1632
|
+
let hitBudget = false;
|
|
1633
|
+
newlyRemoved = [];
|
|
1634
|
+
for (const c of candidates) {
|
|
1635
|
+
if (removed.size >= budget) { hitBudget = true; break; }
|
|
1636
|
+
// FAST PATH: an environment whose every premise still stands keeps the
|
|
1637
|
+
// fact — pure set membership, no re-derivation. When some environments
|
|
1638
|
+
// broke, queue the pruned set so the next retraction still sees the
|
|
1639
|
+
// survivor (the stale-justification fix).
|
|
1640
|
+
const environments = environmentsOf(c);
|
|
1641
|
+
const intact = environments.filter((env) => env.every((id) => survivorIds.has(id)));
|
|
1642
|
+
if (intact.length) {
|
|
1643
|
+
if (intact.length !== environments.length) reground.set(c.id, intact);
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
// ENUMERATE: a fresh premise environment among the survivors re-grounds
|
|
1647
|
+
// the fact under new citations.
|
|
1648
|
+
const fresh = enumerateSupport(c, { maxEnvironments });
|
|
1649
|
+
if (fresh.length) {
|
|
1650
|
+
reground.set(c.id, fresh);
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
// BOOLEAN BACKSTOP: the closure walk is the final authority — it sees
|
|
1654
|
+
// multi-hop support with no materialised direct edge to cite, so a
|
|
1655
|
+
// still-derivable fact is never removed on a stale citation alone (its
|
|
1656
|
+
// environments stay as they were).
|
|
1657
|
+
if (stillDerivable(c)) continue;
|
|
1658
|
+
removed.add(c.id);
|
|
1659
|
+
order.push(c.id);
|
|
1660
|
+
newlyRemoved.push(c.id);
|
|
1661
|
+
progressed = true;
|
|
1610
1662
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
if
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
progressed = true;
|
|
1663
|
+
if (hitBudget) { truncated = true; break; }
|
|
1664
|
+
if (!progressed) break; // every candidate this round survived — fixpoint
|
|
1665
|
+
}
|
|
1666
|
+
if (!truncated && round >= depth) {
|
|
1667
|
+
// depth exhausted, not a natural fixpoint — honestly flag it if a pending
|
|
1668
|
+
// candidate (any surviving fact whose environment union still cites a
|
|
1669
|
+
// removed id) would have been checked next round.
|
|
1670
|
+
truncated = entailedRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
|
|
1620
1671
|
}
|
|
1621
|
-
if (hitBudget) { truncated = true; break; }
|
|
1622
|
-
if (!progressed) break; // every candidate this round survived — fixpoint
|
|
1623
|
-
}
|
|
1624
|
-
if (!truncated && round >= depth) {
|
|
1625
|
-
// depth exhausted, not a natural fixpoint — honestly flag it if a pending
|
|
1626
|
-
// candidate (any surviving fact whose environment union still cites a
|
|
1627
|
-
// removed id) would have been checked next round.
|
|
1628
|
-
truncated = entailedRows.some((r) => !removed.has(r.id) && r.justification.some((j) => removed.has(j)));
|
|
1629
1672
|
}
|
|
1630
1673
|
|
|
1631
|
-
|
|
1674
|
+
// The target's own position in `order` narrows to the invoking party's
|
|
1675
|
+
// record(s) when scoped; every cascade id stays group-wide — an entailed
|
|
1676
|
+
// fact is never itself "the invoking party's own", so there is no
|
|
1677
|
+
// per-source record to narrow it to.
|
|
1678
|
+
const removalIds = scoped.length
|
|
1679
|
+
? [...ownSourceIds.map((sid) => `${targetId}@${sid}`), ...order.slice(1)]
|
|
1680
|
+
: order;
|
|
1681
|
+
const { removed: actuallyRemoved } = await removeFacts(repoDir, removalIds);
|
|
1632
1682
|
if (appendFactsFn) {
|
|
1633
1683
|
const regroundWrites = [...reground.entries()]
|
|
1634
1684
|
.filter(([id]) => !removed.has(id))
|
|
@@ -1644,7 +1694,10 @@ export async function retractSubClassOf(repoDir, subject, object, {
|
|
|
1644
1694
|
});
|
|
1645
1695
|
if (regroundWrites.length) await appendFactsFn(repoDir, regroundWrites);
|
|
1646
1696
|
}
|
|
1647
|
-
return {
|
|
1697
|
+
return {
|
|
1698
|
+
retracted: actuallyRemoved, count: actuallyRemoved.length, budget, depth, truncated, found: true,
|
|
1699
|
+
...(scoped.length ? { ownRecord: true, stillStands } : {}),
|
|
1700
|
+
};
|
|
1648
1701
|
}
|
|
1649
1702
|
|
|
1650
1703
|
/**
|