@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.
@@ -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
- // = a 3x3 block, PLAN_SPIDER_FLY.md §3). Corner-ish on purpose (§4): a spider
19
- // that starts here sees close to half the board just from edge-clipping.
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 dynamic web (src/services/spider-fly.mjs's hasActiveWebAt)
24
- // stays active for this many turns past the turn it was built, mirroring the
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
- // Spider mass mirrors a fly's own (src/services/spider-fly.mjs's
29
- // FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
30
- // does, and gains exactly a fly's remaining mass on an eat. Heavier starting
31
- // mass than a single fly's worth on purpose a spider that eats nothing for
32
- // a while has some runway before starving. The decrement is half a fly's own
33
- // (spiders live longer between meals than flies do), and — like every other
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 (spider-fly.mjs) and the chat dock's deception pills
98
- * (spider-fly-turn.mjs's pillsForSpiderFly) need — defined once here so
99
- * neither has to re-derive it, and so the engine layer never has to import
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. */
@@ -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
- // Only a purely-entailed fact ever carries a walkable justification
1547
- // a fact later independently taught is never a cascade candidate at all.
1548
- const entailedRows = rows.filter((r) => environmentsOf(r).length && isPurelyEntailed(r.provenance));
1549
- // premise id -> the entailed fact ids whose environments cite it. Built
1550
- // ONCE; each round's candidate set reads it for the facts the newest
1551
- // removals could actually touch backward relevance from the same
1552
- // structure a forward pass reads forward.
1553
- const citedBy = new Map();
1554
- for (const r of entailedRows) {
1555
- for (const env of environmentsOf(r)) {
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
- let round = 0;
1568
- let newlyRemoved = [targetId];
1569
- for (; round < depth; round += 1) {
1570
- const candidateIds = new Set();
1571
- for (const id of newlyRemoved) {
1572
- for (const cited of citedBy.get(id) || []) {
1573
- if (!removed.has(cited)) candidateIds.add(cited);
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 progressed = false;
1590
- let hitBudget = false;
1591
- newlyRemoved = [];
1592
- for (const c of candidates) {
1593
- if (removed.size >= budget) { hitBudget = true; break; }
1594
- // FAST PATH: an environment whose every premise still stands keeps the
1595
- // fact — pure set membership, no re-derivation. When some environments
1596
- // broke, queue the pruned set so the next retraction still sees the
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
- // ENUMERATE: a fresh premise environment among the survivors re-grounds
1605
- // the fact under new citations.
1606
- const fresh = enumerateSupport(c, { maxEnvironments });
1607
- if (fresh.length) {
1608
- reground.set(c.id, fresh);
1609
- continue;
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
- // BOOLEAN BACKSTOP: the closure walk is the final authority — it sees
1612
- // multi-hop support with no materialised direct edge to cite, so a
1613
- // still-derivable fact is never removed on a stale citation alone (its
1614
- // environments stay as they were).
1615
- if (stillDerivable(c)) continue;
1616
- removed.add(c.id);
1617
- order.push(c.id);
1618
- newlyRemoved.push(c.id);
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
- const { removed: actuallyRemoved } = await removeFacts(repoDir, order);
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 { retracted: actuallyRemoved, count: actuallyRemoved.length, budget, depth, truncated, found: true };
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
  /**