@polycode-projects/the-mechanical-code-talker 4.1.1 → 4.1.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/README.md +31 -18
- package/bin/tmct.mjs +3 -0
- package/data/templates/responses.jsonl +3 -0
- package/package.json +2 -1
- package/src/adapters/memory/core.mjs +1358 -196
- package/src/adapters/memory/inspect.mjs +11 -0
- package/src/adapters/memory/shacl.mjs +38 -0
- package/src/adapters/p2p/webrtc-transport.mjs +28 -5
- package/src/domain/ask-vocab.mjs +39 -0
- package/src/domain/ask.mjs +183 -34
- package/src/domain/grammar/assert.mjs +8 -2
- package/src/domain/hanoi-board.mjs +232 -0
- package/src/domain/ingest-facts.mjs +120 -0
- package/src/domain/interpret/normalize.mjs +49 -0
- package/src/domain/memory/compaction.mjs +284 -0
- package/src/domain/memory/resolution.mjs +171 -0
- package/src/domain/memory/trust.mjs +175 -5
- package/src/domain/memory-facts.mjs +139 -0
- package/src/domain/p2p/facts.mjs +21 -0
- package/src/domain/p2p/peer-id.mjs +15 -0
- package/src/domain/p2p/provenance-relabel.mjs +13 -2
- package/src/domain/p2p/sync-filter.mjs +5 -1
- package/src/domain/p2p/wire.mjs +7 -4
- package/src/domain/scene-compose.mjs +2 -2
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/services/adventure-viz.mjs +5 -1
- package/src/services/adventure.mjs +70 -44
- package/src/services/chat-page-viz.mjs +381 -310
- package/src/services/chat.mjs +273 -155
- package/src/services/code-explorer-viz.mjs +141 -54
- package/src/services/index.mjs +1 -1
- package/src/services/ingest-viz.mjs +134 -9
- package/src/services/ledger-viz.mjs +7 -4
- package/src/services/memory-panel-viz.mjs +8 -3
- package/src/services/mud-turn.mjs +11 -8
- package/src/services/mud-viz.mjs +441 -206
- package/src/services/p2p-room.mjs +110 -23
- package/src/services/plan-viz.mjs +63 -4
- package/src/services/research-viz.mjs +18 -7
- package/src/services/share-overlay-viz.mjs +623 -0
- package/src/services/spider-fly-viz.mjs +2 -2
- package/src/services/sprite-catalog-viz.mjs +303 -78
- package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
- package/src/surfaces/web/chat-browser-entry.mjs +37 -10
- package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
- package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
- package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
- package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
- package/src/surfaces/web/mud-browser-entry.mjs +38 -7
- package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
- package/src/surfaces/web/plan-browser-entry.mjs +33 -2
- package/src/surfaces/web/research-browser-entry.mjs +11 -19
- package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
- package/src/surfaces/web/tmct-surface.mjs +12 -0
- package/src/surfaces/web/turn-session.mjs +10 -3
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// memory/compaction.mjs — bounding fact-record growth along its two separate
|
|
2
|
+
// axes, with a separate rollup for each.
|
|
3
|
+
//
|
|
4
|
+
// A triple's records grow two ways, and the ways mean different things:
|
|
5
|
+
//
|
|
6
|
+
// - MANY SOURCES each asserting it once. Every one is a live head and a live
|
|
7
|
+
// vote in the group fold, so a summary that absorbed them has to carry
|
|
8
|
+
// their trust contribution forward or the compacted answer silently
|
|
9
|
+
// under-trusts. That is `mgx:rollupPrior`.
|
|
10
|
+
// - ONE SOURCE re-asserting it many times. Every record it replaced is a
|
|
11
|
+
// demoted leaf that already contributes nothing (the fold reads heads
|
|
12
|
+
// only), so absorbing them moves no number at all — it only shortens how
|
|
13
|
+
// far back a walk can reach. A chain summary therefore carries NO prior,
|
|
14
|
+
// and a compacted chain segment must not start contributing on the way out.
|
|
15
|
+
//
|
|
16
|
+
// Rolling both into one "keep newest K" pool would rank a chatty source's
|
|
17
|
+
// recent demoted leaves above a quiet source's still-live head, and compacting
|
|
18
|
+
// that head away is exactly the mistake the split exists to avoid. So: two
|
|
19
|
+
// pools per (triple, source type), each with its own trigger and its own id.
|
|
20
|
+
//
|
|
21
|
+
// Both summaries replicate, and deleting from a replicated grow-only set is not
|
|
22
|
+
// a G-Set operation — an uncoordinated delete comes back on the next sync. What
|
|
23
|
+
// closes that hole is making the summary carry the ids it absorbed, so
|
|
24
|
+
// absorption itself replicates: two summaries at one id merge by UNION of those
|
|
25
|
+
// ids, which is a join, so peers that compacted at different moments converge.
|
|
26
|
+
// Everything else on a summary (its count, its bounds, its prior) is derived
|
|
27
|
+
// from the union, so the whole record is a pure function of it — which is what
|
|
28
|
+
// makes the merge idempotent and order-independent rather than merely usually
|
|
29
|
+
// right.
|
|
30
|
+
//
|
|
31
|
+
// Pure: this module plans and merges summary RECORDS. core.mjs owns the payload.
|
|
32
|
+
|
|
33
|
+
const FACT_CLASS = "Fact";
|
|
34
|
+
|
|
35
|
+
// Starting guesses, not measurements — revisit against real store data once
|
|
36
|
+
// `tmct inspect`'s widest-group metric has something to report.
|
|
37
|
+
export const GROUP_ROLLUP_THRESHOLD = 64; // live heads of one type before pool 1 fires
|
|
38
|
+
export const ROLLUP_KEEP_PER_TYPE = 8; // heads of that type kept intact
|
|
39
|
+
export const CHAIN_ROLLUP_THRESHOLD = 8; // demoted leaves of one chain before pool 2 fires
|
|
40
|
+
export const CHAIN_KEEP_DEPTH = 2; // leaves of that chain kept intact
|
|
41
|
+
|
|
42
|
+
export const ROLLUP_SOURCE_IDS_PROP = "mgx:rollupSourceIds"; // pool 1: the SOURCE ids absorbed
|
|
43
|
+
export const ROLLUP_RECORD_IDS_PROP = "mgx:rollupRecordIds"; // pool 2: the RECORD ids absorbed
|
|
44
|
+
export const ROLLUP_COUNT_PROP = "mgx:rollupCount";
|
|
45
|
+
export const ROLLUP_EARLIEST_PROP = "mgx:rollupEarliest";
|
|
46
|
+
export const ROLLUP_LATEST_PROP = "mgx:rollupLatest";
|
|
47
|
+
export const ROLLUP_PRIOR_PROP = "mgx:rollupPrior";
|
|
48
|
+
|
|
49
|
+
const HEAD_ROLLUP_MARKER = "@rollup:";
|
|
50
|
+
const CHAIN_ROLLUP_SUFFIX = "#rollup";
|
|
51
|
+
|
|
52
|
+
export const headRollupIdFor = (groupId, sourceType) => `${groupId}${HEAD_ROLLUP_MARKER}${sourceType}`;
|
|
53
|
+
export const chainRollupIdFor = (groupId, sourceId) => `${groupId}@${sourceId}${CHAIN_ROLLUP_SUFFIX}`;
|
|
54
|
+
|
|
55
|
+
export const isHeadRollupId = (id) => String(id || "").includes(HEAD_ROLLUP_MARKER);
|
|
56
|
+
export const isChainRollupId = (id) => String(id || "").endsWith(CHAIN_ROLLUP_SUFFIX);
|
|
57
|
+
export const isRollupId = (id) => isHeadRollupId(id) || isChainRollupId(id);
|
|
58
|
+
|
|
59
|
+
/** The source type a pool-1 summary covers, read back off the id that carries
|
|
60
|
+
* it. A summary is one type by construction, which is what keeps type priors
|
|
61
|
+
* and per-type ceilings computable over a compacted group. */
|
|
62
|
+
export function headRollupTypeOf(recordId) {
|
|
63
|
+
const id = String(recordId || "");
|
|
64
|
+
const at = id.indexOf(HEAD_ROLLUP_MARKER);
|
|
65
|
+
return at < 0 ? "" : id.slice(at + HEAD_ROLLUP_MARKER.length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const attrValue = (ind, prop) => (ind?.attributes || []).find((a) => a?.prop === prop)?.value || "";
|
|
69
|
+
const idList = (value) => String(value || "").split(" ").filter(Boolean);
|
|
70
|
+
|
|
71
|
+
export const absorbedSourceIds = (ind) => idList(attrValue(ind, ROLLUP_SOURCE_IDS_PROP));
|
|
72
|
+
export const absorbedRecordIds = (ind) => idList(attrValue(ind, ROLLUP_RECORD_IDS_PROP));
|
|
73
|
+
|
|
74
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
75
|
+
const round6 = (n) => Number(n.toFixed(6));
|
|
76
|
+
|
|
77
|
+
/** The noisy-OR base over a set of effective priors — the same corroboration
|
|
78
|
+
* math the group fold runs, with recency deliberately absent. Recency is a
|
|
79
|
+
* function of the reading moment, so a summary bakes in only what it knows at
|
|
80
|
+
* write time and the fold applies decay off `rollupLatest` on read. */
|
|
81
|
+
export function noisyOr(priors) {
|
|
82
|
+
let complement = 1;
|
|
83
|
+
let counted = 0;
|
|
84
|
+
for (const p of priors) { complement *= 1 - clamp01(p); counted += 1; }
|
|
85
|
+
return counted ? round6(Math.min(1, 1 - complement)) : 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pick the earlier/later of two instants, tolerating "" and unparseable input
|
|
89
|
+
* on either side. Min-of-earliest and max-of-latest are both joins, which is
|
|
90
|
+
* why two summaries can merge in either order and agree. */
|
|
91
|
+
function pickInstant(a, b, pick) {
|
|
92
|
+
const at = Date.parse(a);
|
|
93
|
+
const bt = Date.parse(b);
|
|
94
|
+
if (!Number.isFinite(at)) return Number.isFinite(bt) ? String(b) : "";
|
|
95
|
+
if (!Number.isFinite(bt)) return String(a);
|
|
96
|
+
return pick(at, bt) === at ? String(a) : String(b);
|
|
97
|
+
}
|
|
98
|
+
const earlierOf = (a, b) => pickInstant(a, b, Math.min);
|
|
99
|
+
const laterOf = (a, b) => pickInstant(a, b, Math.max);
|
|
100
|
+
|
|
101
|
+
/** Newest first, ties broken on the record id in codepoint order — the same
|
|
102
|
+
* locale-free determinism the rest of the fact layer sorts by, so two peers
|
|
103
|
+
* holding the same records choose the same keep-window. */
|
|
104
|
+
function byNewestFirst(a, b) {
|
|
105
|
+
const at = Date.parse(a.assertedAt);
|
|
106
|
+
const bt = Date.parse(b.assertedAt);
|
|
107
|
+
const av = Number.isFinite(at) ? at : -Infinity;
|
|
108
|
+
const bv = Number.isFinite(bt) ? bt : -Infinity;
|
|
109
|
+
if (av !== bv) return bv - av;
|
|
110
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function boundsOver(records, seedEarliest = "", seedLatest = "") {
|
|
114
|
+
let earliest = seedEarliest;
|
|
115
|
+
let latest = seedLatest;
|
|
116
|
+
for (const r of records) {
|
|
117
|
+
earliest = earlierOf(earliest, r.assertedAt);
|
|
118
|
+
latest = laterOf(latest, r.assertedAt);
|
|
119
|
+
}
|
|
120
|
+
return { earliest, latest };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a summary record from the ONLY things that define one: which ids it
|
|
125
|
+
* absorbed, the span they covered, and (pool 1 only) how to price a source.
|
|
126
|
+
* Both the compaction path and the merge path go through here, so a summary
|
|
127
|
+
* built by absorbing and the same summary reached by merging two halves come
|
|
128
|
+
* out byte-identical — which is the property the whole design rests on.
|
|
129
|
+
*/
|
|
130
|
+
function buildRollup({ id, sourceKey, template, idsProp, ids, earliest, latest, priorFor }) {
|
|
131
|
+
const sorted = [...new Set(ids)].filter(Boolean).sort();
|
|
132
|
+
const prior = priorFor ? noisyOr(sorted.map((sid) => priorFor(sid))) : undefined;
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
label: template?.label || "",
|
|
136
|
+
class: FACT_CLASS,
|
|
137
|
+
derived_from: [],
|
|
138
|
+
mentions: [],
|
|
139
|
+
attributes: [
|
|
140
|
+
{ prop: "rdf:type", key: "type", value: "rdf:Statement" },
|
|
141
|
+
{ prop: "rdf:subject", key: "subject", value: template?.subject || "" },
|
|
142
|
+
{ prop: "rdf:predicate", key: "predicate", value: template?.predicate || "" },
|
|
143
|
+
{ prop: "rdf:object", key: "object", value: template?.object || "" },
|
|
144
|
+
{ prop: "mgx:createdAt", key: "createdAt", value: earliest },
|
|
145
|
+
{ prop: "mgx:sourceId", key: "sourceId", value: sourceKey },
|
|
146
|
+
{ prop: idsProp, key: idsProp === ROLLUP_SOURCE_IDS_PROP ? "rollupSourceIds" : "rollupRecordIds", value: sorted.join(" ") },
|
|
147
|
+
{ prop: ROLLUP_COUNT_PROP, key: "rollupCount", value: String(sorted.length) },
|
|
148
|
+
{ prop: ROLLUP_EARLIEST_PROP, key: "rollupEarliest", value: earliest },
|
|
149
|
+
{ prop: ROLLUP_LATEST_PROP, key: "rollupLatest", value: latest },
|
|
150
|
+
...(prior === undefined ? [] : [{ prop: ROLLUP_PRIOR_PROP, key: "rollupPrior", value: String(prior) }]),
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const templateOf = (record) => ({
|
|
156
|
+
label: record?.label || "",
|
|
157
|
+
subject: attrValue(record, "rdf:subject"),
|
|
158
|
+
predicate: attrValue(record, "rdf:predicate"),
|
|
159
|
+
object: attrValue(record, "rdf:object"),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pool 1. Given every LIVE HEAD of one type on one triple, decide whether that
|
|
164
|
+
* type has grown past its threshold and, if so, which heads to absorb.
|
|
165
|
+
*
|
|
166
|
+
* `heads` are `{ id, sourceId, assertedAt, record }`. `existing` is the type's
|
|
167
|
+
* current summary, if it already has one — its absorbed ids come along, so
|
|
168
|
+
* compacting twice keeps one summary rather than growing a chain of them.
|
|
169
|
+
* `priorFor(sourceId)` prices one absorbed source.
|
|
170
|
+
*
|
|
171
|
+
* Null when nothing should happen, which is the answer for very nearly every
|
|
172
|
+
* fact in a store: under the threshold, no type at all, or nothing older than
|
|
173
|
+
* the keep window.
|
|
174
|
+
*/
|
|
175
|
+
export function planHeadRollup({ groupId, sourceType, heads = [], existing = null, priorFor }) {
|
|
176
|
+
if (!groupId || !sourceType) return null; // never roll up across types, or an untyped record
|
|
177
|
+
if (heads.length < GROUP_ROLLUP_THRESHOLD) return null;
|
|
178
|
+
if (heads.length <= ROLLUP_KEEP_PER_TYPE) return null; // never the sole records of a type
|
|
179
|
+
const ordered = heads.slice().sort(byNewestFirst);
|
|
180
|
+
const absorb = ordered.slice(ROLLUP_KEEP_PER_TYPE);
|
|
181
|
+
if (!absorb.length) return null;
|
|
182
|
+
const { earliest, latest } = boundsOver(
|
|
183
|
+
absorb,
|
|
184
|
+
attrValue(existing, ROLLUP_EARLIEST_PROP),
|
|
185
|
+
attrValue(existing, ROLLUP_LATEST_PROP),
|
|
186
|
+
);
|
|
187
|
+
const rollup = buildRollup({
|
|
188
|
+
id: headRollupIdFor(groupId, sourceType),
|
|
189
|
+
sourceKey: `rollup:${sourceType}`,
|
|
190
|
+
template: templateOf(absorb[0].record || existing),
|
|
191
|
+
idsProp: ROLLUP_SOURCE_IDS_PROP,
|
|
192
|
+
ids: [...absorbedSourceIds(existing), ...absorb.map((h) => h.sourceId)],
|
|
193
|
+
earliest,
|
|
194
|
+
latest,
|
|
195
|
+
priorFor,
|
|
196
|
+
});
|
|
197
|
+
return { rollup, absorbed: absorb.map((h) => h.id), absorbedSourceIds: absorb.map((h) => h.sourceId) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Pool 2. Given every DEMOTED LEAF of one source's own chain on one triple,
|
|
202
|
+
* decide whether that chain has grown past its threshold and which leaves to
|
|
203
|
+
* absorb. Triggers far more eagerly than pool 1 because nothing here is
|
|
204
|
+
* trust-sensitive: no prior, no type-ceiling interaction, no read-time trust to
|
|
205
|
+
* recompute.
|
|
206
|
+
*
|
|
207
|
+
* `leaves` are `{ id, assertedAt, record }`. The summary keeps the absorbed
|
|
208
|
+
* span's BOUNDS rather than any exact instant, so a walk that lands inside it
|
|
209
|
+
* gets "sometime in this window" and never a fabricated moment.
|
|
210
|
+
*/
|
|
211
|
+
export function planChainRollup({ groupId, sourceId, leaves = [], existing = null }) {
|
|
212
|
+
if (!groupId || !sourceId) return null;
|
|
213
|
+
if (leaves.length < CHAIN_ROLLUP_THRESHOLD) return null;
|
|
214
|
+
if (leaves.length <= CHAIN_KEEP_DEPTH) return null; // never the sole leaf of a chain
|
|
215
|
+
const ordered = leaves.slice().sort(byNewestFirst);
|
|
216
|
+
const keep = ordered.slice(0, CHAIN_KEEP_DEPTH);
|
|
217
|
+
const absorb = ordered.slice(CHAIN_KEEP_DEPTH);
|
|
218
|
+
if (!absorb.length) return null;
|
|
219
|
+
const { earliest, latest } = boundsOver(
|
|
220
|
+
absorb,
|
|
221
|
+
attrValue(existing, ROLLUP_EARLIEST_PROP),
|
|
222
|
+
attrValue(existing, ROLLUP_LATEST_PROP),
|
|
223
|
+
);
|
|
224
|
+
const rollup = buildRollup({
|
|
225
|
+
id: chainRollupIdFor(groupId, sourceId),
|
|
226
|
+
sourceKey: sourceId,
|
|
227
|
+
template: templateOf(absorb[0].record || existing),
|
|
228
|
+
idsProp: ROLLUP_RECORD_IDS_PROP,
|
|
229
|
+
ids: [...absorbedRecordIds(existing), ...absorb.map((l) => l.id)],
|
|
230
|
+
earliest,
|
|
231
|
+
latest,
|
|
232
|
+
});
|
|
233
|
+
return { rollup, absorbed: absorb.map((l) => l.id), rewire: keep[keep.length - 1]?.id || "" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Join two summaries that share an id: union the absorbed ids, then re-derive
|
|
238
|
+
* everything else from that union. Commutative, associative and idempotent
|
|
239
|
+
* because union, min and max all are, and because count and prior are functions
|
|
240
|
+
* of the union rather than independently accumulated state.
|
|
241
|
+
*
|
|
242
|
+
* The same call handles both pools; `priorFor` is supplied for pool 1 and
|
|
243
|
+
* omitted for pool 2, which is what keeps a compacted chain segment from
|
|
244
|
+
* starting to contribute trust on the way out.
|
|
245
|
+
*/
|
|
246
|
+
export function mergeRollups(existing, incoming, { priorFor } = {}) {
|
|
247
|
+
const id = existing?.id || incoming?.id;
|
|
248
|
+
const headPool = isHeadRollupId(id);
|
|
249
|
+
const idsProp = headPool ? ROLLUP_SOURCE_IDS_PROP : ROLLUP_RECORD_IDS_PROP;
|
|
250
|
+
const read = (ind) => idList(attrValue(ind, idsProp));
|
|
251
|
+
return buildRollup({
|
|
252
|
+
id,
|
|
253
|
+
sourceKey: attrValue(existing, "mgx:sourceId") || attrValue(incoming, "mgx:sourceId"),
|
|
254
|
+
template: templateOf(attrValue(existing, "rdf:subject") ? existing : incoming),
|
|
255
|
+
idsProp,
|
|
256
|
+
ids: [...read(existing), ...read(incoming)],
|
|
257
|
+
earliest: earlierOf(attrValue(existing, ROLLUP_EARLIEST_PROP), attrValue(incoming, ROLLUP_EARLIEST_PROP)),
|
|
258
|
+
latest: laterOf(attrValue(existing, ROLLUP_LATEST_PROP), attrValue(incoming, ROLLUP_LATEST_PROP)),
|
|
259
|
+
priorFor: headPool ? priorFor : undefined,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Is this source's assertion already absorbed into one of the group's pool-1
|
|
264
|
+
* summaries? A late or re-synced copy of an absorbed assertion must stay
|
|
265
|
+
* absorbed rather than reappearing as a live head — without this the next sync
|
|
266
|
+
* resurrects everything compaction just folded away, which is the failure mode
|
|
267
|
+
* that makes deleting from a replicated set hard in the first place. */
|
|
268
|
+
export function isAbsorbedSource(rollups, sourceId) {
|
|
269
|
+
if (!sourceId) return false;
|
|
270
|
+
for (const rollup of rollups || []) {
|
|
271
|
+
if (absorbedSourceIds(rollup).includes(sourceId)) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The same check for one source's own chain: a leaf id already inside the
|
|
277
|
+
* chain summary merges as a no-op re-absorption, never a re-insertion. */
|
|
278
|
+
export function isAbsorbedRecord(rollups, recordId) {
|
|
279
|
+
if (!recordId) return false;
|
|
280
|
+
for (const rollup of rollups || []) {
|
|
281
|
+
if (absorbedRecordIds(rollup).includes(recordId)) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// memory/resolution.mjs — how sibling facts sharing one (subject, predicate)
|
|
2
|
+
// but disagreeing on the OBJECT resolve. A fact is stored one record per
|
|
3
|
+
// asserting source, so two kinds of plurality exist: records inside one triple
|
|
4
|
+
// group (corroboration — same claim, different mouths, handled by the group
|
|
5
|
+
// fold in core.mjs) and different objects across groups. Only the second one
|
|
6
|
+
// needs a decision, and which decision it needs is a property of the PREDICATE.
|
|
7
|
+
//
|
|
8
|
+
// The table below is a closed enum keyed by predicate, matching this codebase's
|
|
9
|
+
// standing preference for closed vocabularies over general rules — SOURCE_PRIOR
|
|
10
|
+
// and the ISA set are the same shape. Four strategies:
|
|
11
|
+
//
|
|
12
|
+
// merge many objects at once are all true; a second object
|
|
13
|
+
// is a second fact, never a disagreement
|
|
14
|
+
// latest-observation-wins the object is a current state; successive objects
|
|
15
|
+
// are successive states, newest observation renders
|
|
16
|
+
// first-claim-wins objects race for a registration; the oldest claim
|
|
17
|
+
// takes it
|
|
18
|
+
// contradiction the default: a real disagreement, both kept, both
|
|
19
|
+
// reported, never silently resolved
|
|
20
|
+
//
|
|
21
|
+
// Pure and import-free of core.mjs, exactly like trust.mjs and capability.mjs
|
|
22
|
+
// beside it.
|
|
23
|
+
|
|
24
|
+
import { negatedPredicate } from "./capability.mjs";
|
|
25
|
+
import { AGENT_SOURCE_TYPES } from "./trust.mjs";
|
|
26
|
+
|
|
27
|
+
export const RESOLUTION_MERGE = "merge";
|
|
28
|
+
export const RESOLUTION_LATEST_OBSERVATION_WINS = "latest-observation-wins";
|
|
29
|
+
export const RESOLUTION_FIRST_CLAIM_WINS = "first-claim-wins";
|
|
30
|
+
export const RESOLUTION_CONTRADICTION = "contradiction";
|
|
31
|
+
|
|
32
|
+
/** Predicates whose real-world semantics allow many objects at once — a wheel
|
|
33
|
+
* is part of a car AND of a bike, a dog is a mammal AND a pet, you are LIKELY
|
|
34
|
+
* to find a dog in a kennel AND in a park. The ConceptNet surface templates
|
|
35
|
+
* say "typically", not "uniquely", so the associative and lexical relations
|
|
36
|
+
* all sit here. Each entry's negative twin joins it: "cannot fly" and "cannot
|
|
37
|
+
* sing" are two claims, not a self-contradiction. */
|
|
38
|
+
const MERGE_PREDICATE_STEMS = [
|
|
39
|
+
"mgx:hasA", "mgx:capableOf",
|
|
40
|
+
"rdfs:subClassOf", "rdf:type", "owl:disjointWith", "mgx:partOf",
|
|
41
|
+
"mgx:usedFor", "mgx:receivesAction", "mgx:causes", "mgx:causesDesire",
|
|
42
|
+
"mgx:hasSubevent", "mgx:hasPrerequisite", "mgx:motivatedByGoal", "mgx:obstructedBy",
|
|
43
|
+
"mgx:desires", "mgx:hasProperty", "mgx:madeOf", "mgx:atLocation", "mgx:locatedNear",
|
|
44
|
+
"mgx:createdBy",
|
|
45
|
+
"mgx:mannerOf", "mgx:relatedTo", "mgx:synonym", "mgx:antonym", "mgx:similarTo", "mgx:symbolOf",
|
|
46
|
+
"mgx:knows-about",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const MERGE_PREDICATES = new Set(
|
|
50
|
+
MERGE_PREDICATE_STEMS.flatMap((p) => [p, negatedPredicate(p)]),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Predicates whose object is the CURRENT state of something: a placement, a
|
|
54
|
+
* position, a mutable property, a name. Two different objects are two moments,
|
|
55
|
+
* not two opinions, so the newest observation renders and no contradiction is
|
|
56
|
+
* reported — unless observation time cannot order them at all (see
|
|
57
|
+
* resolveSiblingGroups). */
|
|
58
|
+
const LATEST_OBSERVATION_PREDICATES = new Set([
|
|
59
|
+
"mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
|
|
60
|
+
"mgx:on-top-of", "mgx:on-plane", "mgx:under",
|
|
61
|
+
"mgx:is-open", "mgx:hasMass", "mgx:feels", "mgx:faces", "mgx:pose",
|
|
62
|
+
"mgx:display-name", "mgx:nodeName", "mgx:worldName",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** The `mgx:has-exit-<direction>` family, which digging rewires: one more
|
|
66
|
+
* state predicate, generated per direction rather than enumerated. */
|
|
67
|
+
const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
|
|
68
|
+
|
|
69
|
+
/** Registrations: different objects race for one slot and the OLDEST claim
|
|
70
|
+
* takes it. `mgx:playedBy` already ships this semantic ("first claim wins by
|
|
71
|
+
* timestamp"); the table is where the rule lives now, instead of per-caller
|
|
72
|
+
* lore. */
|
|
73
|
+
const FIRST_CLAIM_PREDICATES = new Set(["mgx:playedBy"]);
|
|
74
|
+
|
|
75
|
+
/** How sibling objects on `predicate` resolve. `contradiction` is the default,
|
|
76
|
+
* so a predicate nobody has classified keeps the full keep-both contract. */
|
|
77
|
+
export function resolutionStrategyFor(predicate) {
|
|
78
|
+
const p = String(predicate || "");
|
|
79
|
+
if (MERGE_PREDICATES.has(p)) return RESOLUTION_MERGE;
|
|
80
|
+
if (LATEST_OBSERVATION_PREDICATES.has(p) || EXIT_PREDICATE_RE.test(p)) return RESOLUTION_LATEST_OBSERVATION_WINS;
|
|
81
|
+
if (FIRST_CLAIM_PREDICATES.has(p)) return RESOLUTION_FIRST_CLAIM_WINS;
|
|
82
|
+
return RESOLUTION_CONTRADICTION;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* When the asserting party WITNESSED this record's claim — valid time, as
|
|
87
|
+
* against mgx:createdAt's transaction time (when this store recorded it). The
|
|
88
|
+
* split is what makes a stale newspaper read today lose to an eyewitness report
|
|
89
|
+
* from yesterday; ingestion order gets that exactly backwards.
|
|
90
|
+
*
|
|
91
|
+
* The chain, in order:
|
|
92
|
+
* 1. the stored mgx:observedAt, when a caller supplied a parseable one;
|
|
93
|
+
* 2. for AGENT-kind sources only, the record's assertion time (its tag's own
|
|
94
|
+
* embedded timestamp, else its createdAt) — a live agent asserting now is
|
|
95
|
+
* witnessing now;
|
|
96
|
+
* 3. otherwise undefined.
|
|
97
|
+
*
|
|
98
|
+
* Document-kind sources (corpus, reference, web, extracted) and activity-kind
|
|
99
|
+
* (entailed) never fall back to createdAt: their createdAt is ingestion time
|
|
100
|
+
* and says nothing about observation. That is what resolves the newspaper case
|
|
101
|
+
* — an undated corpus row scores undefined and loses to any dated witness.
|
|
102
|
+
*
|
|
103
|
+
* Returns the instant as its ISO string, or undefined. Every returned value
|
|
104
|
+
* parses, so a caller may Date.parse it without re-checking.
|
|
105
|
+
*/
|
|
106
|
+
export function effectiveObservedAt(record) {
|
|
107
|
+
const stored = record?.observedAt;
|
|
108
|
+
if (stored && Number.isFinite(Date.parse(stored))) return String(stored);
|
|
109
|
+
if (!AGENT_SOURCE_TYPES.has(record?.sourceType)) return undefined;
|
|
110
|
+
for (const candidate of [record?.assertedAt, record?.createdAt]) {
|
|
111
|
+
if (candidate && Number.isFinite(Date.parse(candidate))) return String(candidate);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The instant that scores a whole object-group: the newest observation any of
|
|
117
|
+
* its records carries for latest-observation-wins, the oldest for
|
|
118
|
+
* first-claim-wins. null when no record in the group is dated at all. */
|
|
119
|
+
function groupObservationInstant(row, oldest) {
|
|
120
|
+
let best = null;
|
|
121
|
+
for (const assertion of row?.assertions || []) {
|
|
122
|
+
const at = Date.parse(effectiveObservedAt(assertion) ?? "");
|
|
123
|
+
if (!Number.isFinite(at)) continue;
|
|
124
|
+
if (best === null || (oldest ? at < best : at > best)) best = at;
|
|
125
|
+
}
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the object-groups of ONE (subject, predicate) under a time-ordered
|
|
131
|
+
* strategy. `rows` are stage-1 rows (one per triple group, so one per distinct
|
|
132
|
+
* object); `strategy` is latest-observation-wins or first-claim-wins. Returns
|
|
133
|
+
* null for any other strategy, since merge presents everything and
|
|
134
|
+
* contradiction resolves nothing.
|
|
135
|
+
*
|
|
136
|
+
* The ladder, in order:
|
|
137
|
+
* 1. score each group by its own observation instant (above);
|
|
138
|
+
* 2. a dated group beats an undated one, then the wanted extreme wins —
|
|
139
|
+
* newest for latest-observation-wins, oldest for first-claim-wins;
|
|
140
|
+
* 3. tie (equal instants, or every group undated): higher aggregate trust;
|
|
141
|
+
* 4. still tied: the codepoint-smallest object string — plain `<`, never
|
|
142
|
+
* localeCompare, so two peers holding the same records read the same
|
|
143
|
+
* winner whatever locale they run under.
|
|
144
|
+
*
|
|
145
|
+
* `contested` reports whether the WINNER came down to step 3 or 4. Those are
|
|
146
|
+
* the cases time could not order, so they still belong in the contradiction
|
|
147
|
+
* report: the ranking exists so a page always has one deterministic answer to
|
|
148
|
+
* render, never so a disagreement disappears.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveSiblingGroups(rows, strategy) {
|
|
151
|
+
const oldest = strategy === RESOLUTION_FIRST_CLAIM_WINS;
|
|
152
|
+
if (!oldest && strategy !== RESOLUTION_LATEST_OBSERVATION_WINS) return null;
|
|
153
|
+
const scored = (rows || []).map((row) => ({ row, at: groupObservationInstant(row, oldest) }));
|
|
154
|
+
scored.sort((a, b) => {
|
|
155
|
+
if (a.at !== b.at) {
|
|
156
|
+
if (a.at === null) return 1;
|
|
157
|
+
if (b.at === null) return -1;
|
|
158
|
+
return oldest ? a.at - b.at : b.at - a.at;
|
|
159
|
+
}
|
|
160
|
+
const trustGap = (b.row.trust || 0) - (a.row.trust || 0);
|
|
161
|
+
if (trustGap) return trustGap;
|
|
162
|
+
const [x, y] = [String(a.row.object), String(b.row.object)];
|
|
163
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
164
|
+
});
|
|
165
|
+
const ranked = scored.map((s) => s.row);
|
|
166
|
+
return {
|
|
167
|
+
winner: ranked[0] || null,
|
|
168
|
+
ranked,
|
|
169
|
+
contested: scored.length > 1 && scored[0].at === scored[1].at,
|
|
170
|
+
};
|
|
171
|
+
}
|