@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.
Files changed (55) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/services/adventure-viz.mjs +5 -1
  27. package/src/services/adventure.mjs +70 -44
  28. package/src/services/chat-page-viz.mjs +381 -310
  29. package/src/services/chat.mjs +273 -155
  30. package/src/services/code-explorer-viz.mjs +141 -54
  31. package/src/services/index.mjs +1 -1
  32. package/src/services/ingest-viz.mjs +134 -9
  33. package/src/services/ledger-viz.mjs +7 -4
  34. package/src/services/memory-panel-viz.mjs +8 -3
  35. package/src/services/mud-turn.mjs +11 -8
  36. package/src/services/mud-viz.mjs +441 -206
  37. package/src/services/p2p-room.mjs +110 -23
  38. package/src/services/plan-viz.mjs +63 -4
  39. package/src/services/research-viz.mjs +18 -7
  40. package/src/services/share-overlay-viz.mjs +623 -0
  41. package/src/services/spider-fly-viz.mjs +2 -2
  42. package/src/services/sprite-catalog-viz.mjs +303 -78
  43. package/src/surfaces/web/adventure-browser-entry.mjs +27 -5
  44. package/src/surfaces/web/chat-browser-entry.mjs +37 -10
  45. package/src/surfaces/web/code-explorer-browser-entry.mjs +4 -3
  46. package/src/surfaces/web/ingest-browser-entry.mjs +73 -12
  47. package/src/surfaces/web/ledger-browser-entry.mjs +32 -7
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  49. package/src/surfaces/web/mud-browser-entry.mjs +38 -7
  50. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  51. package/src/surfaces/web/plan-browser-entry.mjs +33 -2
  52. package/src/surfaces/web/research-browser-entry.mjs +11 -19
  53. package/src/surfaces/web/sprites-browser-entry.mjs +39 -8
  54. package/src/surfaces/web/tmct-surface.mjs +12 -0
  55. 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
+ }