@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.0

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.
@@ -31,6 +31,8 @@
31
31
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
32
32
  import { dirname, join } from "node:path";
33
33
  import { proseTokensFor, buildProseIndex } from "../prose.mjs";
34
+ import { fnv1aHex } from "../hash.mjs";
35
+ import { computeTrust, TRUST_SCORE_PROP, TRUST_INPUTS_PROP } from "./trust.mjs";
34
36
 
35
37
  export const MEMORY_DIR_REL = join(".tmct", "memory");
36
38
  export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
@@ -38,10 +40,22 @@ export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
38
40
  export const UTTERANCE_CLASS = "Utterance";
39
41
  export const FACT_CLASS = "Fact";
40
42
  export const MEMORY_SESSION_CLASS = "Session";
43
+ export const SOURCE_CLASS = "Source";
41
44
 
42
45
  export const SAID_IN_SESSION_PROP = "mgx:saidInSession";
43
46
  export const IN_REPLY_TO_PROP = "mgx:inReplyTo";
44
47
 
48
+ // The provenance-link predicate family (PLAN_PROVENANCE_TRUST step (b)): one
49
+ // umbrella object property with two workhorse subproperties, minted in the owned
50
+ // mgx: namespace to match tmct-core.ttl's object-property style.
51
+ export const DERIVED_FROM_PROP = "mgx:derivedFrom"; // umbrella: Fact → Source|Fact
52
+ export const STATED_BY_PROP = "mgx:statedBy"; // a Source directly asserts a Fact
53
+ export const CANONICALISED_FROM_PROP = "mgx:canonicalisedFrom"; // a canonical Fact ← its raw form
54
+ export const CREATED_AT_PROP = "mgx:createdAt"; // first-write-wins ISO-8601 on every individual
55
+
56
+ // The one deterministic operator Source id — the operator chatting to tmct.
57
+ export const OPERATOR_SOURCE_ID = "src:operator-chat";
58
+
45
59
  const ROLES = new Set(["visitor", "tmct"]);
46
60
  const LABEL_CAP = 48; // utterance/fact labels stay skimmable in renders
47
61
  const TEXT_CAP = 2000; // an utterance's stored text (a whole answer fits; a pasted book doesn't)
@@ -59,7 +73,16 @@ const MEMORY_VOCABULARY = [
59
73
  { prop: "rdf:subject", note: "reified fact: the triple's subject term" },
60
74
  { prop: "rdf:predicate", note: "reified fact: the triple's predicate term" },
61
75
  { prop: "rdf:object", note: "reified fact: the triple's object term" },
62
- { prop: "mgx:factProvenance", note: "where a fact came from (a session/turn ref, a corpus block id, an ACE parse)" },
76
+ { prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
77
+ { prop: CREATED_AT_PROP, note: "when an individual was FIRST written, ISO-8601 (first-write-wins on upsert); the audit 'when', the recency input to trust, the novelty signal" },
78
+ { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
79
+ { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
80
+ { prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
81
+ { prop: "mgx:sourceType", note: "a Source's kind: operator | provider | corpus | web | entailed (the trust-prior key)" },
82
+ { prop: "mgx:sourceUrl", note: "a web Source's URL" },
83
+ { prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
84
+ { prop: TRUST_SCORE_PROP, note: "materialised trust cache in [0,1] — pure function of a fact's Sources + createdAt (memory/trust.mjs); invalidated when a statedBy edge is added" },
85
+ { prop: TRUST_INPUTS_PROP, note: "JSON of the inputs the trust score was computed from (source-type multiset, corroboration count, createdAt, recency) — makes the score auditable" },
63
86
  { prop: "mgx:hasProseTokens", note: "prose tokens (prose.mjs tokenizer) backing the payload's proseIndex" },
64
87
  { prop: "mgx:sessionStarted", note: "session anchor: when the session started, ISO-8601" },
65
88
  ];
@@ -110,10 +133,14 @@ export async function loadMemory(dir) {
110
133
  }
111
134
 
112
135
  /** Fresh read → mutate → atomic write. Serialized per call; every public append
113
- * goes through here so a concurrent reader never sees a torn store. */
136
+ * goes through here so a concurrent reader never sees a torn store. The lazy,
137
+ * idempotent legacy-provenance migration rides this same cycle (step (b)): any
138
+ * Fact still carrying only the old mgx:factProvenance string gets its Sources +
139
+ * statedBy edges + trust materialised on the next write of any kind. */
114
140
  async function mutateMemory(dir, fn) {
115
141
  const payload = await loadMemory(dir);
116
142
  const out = fn(payload) ?? payload;
143
+ migrateLegacyProvenance(out);
117
144
  out.proseIndex = buildProseIndex(out.individuals);
118
145
  await mkdir(dirname(memoryGraphFile(dir)), { recursive: true });
119
146
  await atomicWriteJson(memoryGraphFile(dir), out);
@@ -122,6 +149,152 @@ async function mutateMemory(dir, fn) {
122
149
 
123
150
  const normText = (t) => String(t ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_CAP);
124
151
  const labelOf = (text) => (text.length > LABEL_CAP ? text.slice(0, LABEL_CAP - 1) + "…" : text);
152
+ const nowIso = () => new Date().toISOString();
153
+
154
+ /** First-write-wins createdAt: keep the prior individual's timestamp if it has
155
+ * one (records when a thing was FIRST learned, not when last touched), else the
156
+ * candidate. */
157
+ function firstWriteCreatedAt(prior, candidate) {
158
+ return prior?.attributes?.find((a) => a?.prop === CREATED_AT_PROP)?.value || candidate || nowIso();
159
+ }
160
+
161
+ /** Set (replace-or-append) one attribute on an individual by prop. */
162
+ function setAttr(ind, prop, key, value) {
163
+ ind.attributes = (ind.attributes || []).filter((a) => a?.prop !== prop);
164
+ ind.attributes.push({ prop, key, value });
165
+ }
166
+
167
+ // ---- Sources (step (b)): first-class provenance individuals -----------------
168
+
169
+ /** Deterministic Source id + type over the closed kind set. Returns null for an
170
+ * unknown kind (an unmappable provenance tag → no Source, honestly). */
171
+ function sourceIdFor(desc) {
172
+ switch (desc?.kind) {
173
+ case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
174
+ case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
175
+ case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
176
+ case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
177
+ case "entailed": return { id: `src:entailed:${desc.rule}`, type: "entailed", rule: String(desc.rule || "") };
178
+ default: return null;
179
+ }
180
+ }
181
+
182
+ const sourceLabel = (id) => String(id).replace(/^src:/, "");
183
+
184
+ /** Upsert a Source individual (deterministic id → idempotent, edges never
185
+ * dangle). createdAt is first-write-wins; a recovered @<ts> (desc.createdAt)
186
+ * seeds it when present. Returns the Source id, or null for an unknown kind. */
187
+ function upsertSource(payload, desc, createdAtCandidate) {
188
+ const info = sourceIdFor(desc);
189
+ if (!info) return null;
190
+ const prior = payload.individuals.find((i) => i?.id === info.id);
191
+ const created = firstWriteCreatedAt(prior, desc?.createdAt || createdAtCandidate);
192
+ upsertIndividual(payload, {
193
+ id: info.id, label: sourceLabel(info.id), class: SOURCE_CLASS,
194
+ derived_from: [], mentions: [],
195
+ attributes: [
196
+ { prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
197
+ { prop: "mgx:sourceType", key: "sourceType", value: info.type },
198
+ { prop: CREATED_AT_PROP, key: "createdAt", value: created },
199
+ ...(info.url ? [{ prop: "mgx:sourceUrl", key: "sourceUrl", value: info.url }] : []),
200
+ ...(info.rule ? [{ prop: "mgx:sourceRule", key: "sourceRule", value: info.rule }] : []),
201
+ ],
202
+ });
203
+ return info.id;
204
+ }
205
+
206
+ /**
207
+ * Parse one legacy provenance TAG into a Source descriptor over the closed kind
208
+ * set — the inverse the migration and the live write path both name Sources
209
+ * through. The tag formats are exactly what the writers produce:
210
+ * corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
211
+ * ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
212
+ * web:<url> | url:<url> → { kind:"web", url:<url> }
213
+ * entailed:<rule> → { kind:"entailed", rule:<rule> }
214
+ * chat:/session: refs map to the operator; an unknown tag → null (no Source).
215
+ */
216
+ export function provenanceTagToSource(tag) {
217
+ const t = String(tag || "").trim();
218
+ if (!t) return null;
219
+ const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
220
+ if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
221
+ if (head.startsWith("ace:")) {
222
+ const at = head.indexOf("@");
223
+ return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
224
+ }
225
+ if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
226
+ if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
227
+ if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
228
+ if (head.startsWith("chat:") || head.startsWith("session:") || head.startsWith("operator")) return { kind: "operator" };
229
+ return null;
230
+ }
231
+
232
+ /** Map a payload's Source individuals into the { id: Source } shape computeTrust
233
+ * resolves against. */
234
+ function sourcesByIdMap(payload) {
235
+ const m = {};
236
+ for (const i of payload.individuals) if (i?.class === SOURCE_CLASS) m[i.id] = i;
237
+ return m;
238
+ }
239
+
240
+ /** The Source ids a Fact is statedBy, read off the edge group. */
241
+ function statedByObjectsFor(payload, factId) {
242
+ const g = payload.objectProperties.find((x) => x?.prop === STATED_BY_PROP);
243
+ return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
244
+ }
245
+
246
+ /** Recompute + materialise a Fact's trust cache (mgx:trustScore + the auditable
247
+ * mgx:trustInputs). Called exactly where a statedBy edge could have changed. */
248
+ function recomputeFactTrust(payload, fact, nowMs = Date.now()) {
249
+ const sourceIds = statedByObjectsFor(payload, fact.id);
250
+ const createdAt = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || "";
251
+ const { score, inputs } = computeTrust({ sourceIds, createdAt }, sourcesByIdMap(payload), { now: nowMs });
252
+ setAttr(fact, TRUST_SCORE_PROP, "trustScore", String(score));
253
+ setAttr(fact, TRUST_INPUTS_PROP, "trustInputs", JSON.stringify(inputs));
254
+ }
255
+
256
+ /** Reconcile a Fact's Sources + statedBy edges with its (unchanged, compat)
257
+ * mgx:factProvenance string, then recompute its trust. ADD-only over
258
+ * deterministic Source ids and upsertEdge's subject>object dedupe, so it is
259
+ * idempotent and NEVER re-keys the fact (its id still hashes only (s,p,o)). */
260
+ function syncFactSources(payload, fact, nowMs = Date.now()) {
261
+ const prov = (fact.attributes || []).find((a) => a?.prop === "mgx:factProvenance")?.value || "";
262
+ // a Source's createdAt candidate is the FIRST stating fact's createdAt (its
263
+ // "first seen"), falling back to now — first-write-wins keeps the earliest.
264
+ const factCreated = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || new Date(nowMs).toISOString();
265
+ for (const tag of prov.split(" | ").filter(Boolean)) {
266
+ const desc = provenanceTagToSource(tag);
267
+ if (!desc) continue;
268
+ const sid = upsertSource(payload, desc, factCreated);
269
+ if (!sid) continue;
270
+ upsertEdge(payload, { predicate: "statedBy", prop: STATED_BY_PROP }, {
271
+ subject: fact.id, object: sid, subjectLabel: fact.label, objectLabel: sourceLabel(sid),
272
+ });
273
+ }
274
+ recomputeFactTrust(payload, fact, nowMs);
275
+ }
276
+
277
+ /** Lazy, idempotent migration of the legacy provenance union (step (b)): any
278
+ * Fact that carries the string but has NO statedBy edge yet gets its Sources +
279
+ * edges + trust materialised. The string is KEPT as a compat shim (readers on
280
+ * chat.mjs still key on it). New writes stay reconciled via syncFactSources, so
281
+ * in steady state this scan finds nothing and converges. */
282
+ function migrateLegacyProvenance(payload) {
283
+ if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
284
+ const statedGroup = payload.objectProperties.find((g) => g?.prop === STATED_BY_PROP);
285
+ const haveEdge = new Set((statedGroup?.examples || []).map((e) => e.subject));
286
+ let changed = false;
287
+ const now = Date.now();
288
+ for (const ind of payload.individuals) {
289
+ if (ind?.class !== FACT_CLASS) continue;
290
+ if (haveEdge.has(ind.id)) continue; // already reconciled (live path or prior run)
291
+ const prov = (ind.attributes || []).find((a) => a?.prop === "mgx:factProvenance")?.value || "";
292
+ if (!prov) continue;
293
+ syncFactSources(payload, ind, now);
294
+ changed = true;
295
+ }
296
+ if (changed) recountClasses(payload);
297
+ }
125
298
 
126
299
  /** Upsert an individual by id (replace-in-place keeps ordering stable). */
127
300
  function upsertIndividual(payload, ind) {
@@ -147,7 +320,7 @@ function upsertEdge(payload, { predicate, prop }, edge) {
147
320
  /** Recount `classes[]` from the individuals — every memory class stays counted
148
321
  * and sampled the way graph-build.mjs counts the code classes. */
149
322
  function recountClasses(payload) {
150
- const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS];
323
+ const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS, SOURCE_CLASS];
151
324
  payload.classes = payload.classes.filter((c) => !names.includes(c?.name));
152
325
  for (const name of names) {
153
326
  const of = payload.individuals.filter((i) => i?.class === name);
@@ -164,6 +337,7 @@ function ensureSession(payload, sessionId, started = "") {
164
337
  derived_from: [], mentions: [],
165
338
  attributes: [
166
339
  { prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
340
+ { prop: CREATED_AT_PROP, key: "createdAt", value: started || nowIso() },
167
341
  ...(started ? [{ prop: "mgx:sessionStarted", key: "started", value: started }] : []),
168
342
  ],
169
343
  });
@@ -172,7 +346,7 @@ function ensureSession(payload, sessionId, started = "") {
172
346
 
173
347
  /** Build (don't write) one Utterance individual + its edges; shared by the
174
348
  * single and batch append paths. Returns the utterance id. */
175
- function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "", parsed = null, replyTo = null }) {
349
+ function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "", parsed = null, replyTo = null, createdAt = "" }) {
176
350
  if (!ROLES.has(role)) throw new Error(`utterance role must be "visitor" or "tmct", got ${JSON.stringify(role)}`);
177
351
  if (!sessionId) throw new Error("utterance needs a sessionId");
178
352
  const cleanTs = String(ts || "");
@@ -180,6 +354,8 @@ function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "",
180
354
  const id = `utt:${sessionId}#${cleanTs}#${role}`;
181
355
  const label = labelOf(cleanText) || (role === "visitor" ? "a-visitor-said" : "a-tmct-said");
182
356
  const tokens = proseTokensFor({ doc: cleanText });
357
+ const prior = payload.individuals.find((x) => x?.id === id);
358
+ const createdAtVal = firstWriteCreatedAt(prior, createdAt || cleanTs); // first-write-wins
183
359
  const ind = {
184
360
  id, label, class: UTTERANCE_CLASS,
185
361
  derived_from: [], mentions: [],
@@ -188,6 +364,7 @@ function putUtterance(payload, { role, text, ts, sessionId, sessionStarted = "",
188
364
  { prop: "mgx:utteranceRole", key: "role", value: role },
189
365
  { prop: "mgx:utteranceText", key: "text", value: cleanText },
190
366
  { prop: "mgx:utteranceTs", key: "ts", value: cleanTs },
367
+ { prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
191
368
  ...(parsed != null ? [{ prop: "mgx:utteranceParsed", key: "parsed", value: JSON.stringify(parsed) }] : []),
192
369
  ...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
193
370
  ],
@@ -235,16 +412,6 @@ export async function appendUtterances(dir, utterances) {
235
412
  return { ids };
236
413
  }
237
414
 
238
- /** FNV-1a 32-bit — a stable little content hash for fact ids (dedupe by triple). */
239
- function fnv1a(s) {
240
- let h = 0x811c9dc5;
241
- for (let i = 0; i < s.length; i += 1) {
242
- h ^= s.charCodeAt(i);
243
- h = Math.imul(h, 0x01000193);
244
- }
245
- return (h >>> 0).toString(16).padStart(8, "0");
246
- }
247
-
248
415
  /** Normalize a fact TERM (subject/object) so every writer converges on one
249
416
  * spelling and the graph stays queryable: ConceptNet's /c/en/foo_bar, a
250
417
  * grammar's tmct:Foo_bar and a bare "Foo bar" all become "foo bar". The
@@ -262,18 +429,21 @@ export function normFactTerm(t) {
262
429
  * carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
263
430
  * Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
264
431
  * duplicate. Returns { id }. */
265
- export async function appendFact(dir, { subject, predicate, object, provenance = "" } = {}) {
432
+ export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "" } = {}) {
266
433
  const s = normFactTerm(subject);
267
434
  const p = normText(predicate);
268
435
  const o = normFactTerm(object);
269
436
  if (!s || !p || !o) throw new Error("a fact needs subject, predicate and object");
270
- const id = `fact:${fnv1a(`${s}${p}${o}`)}`;
437
+ const id = `fact:${fnv1aHex(`${s}${p}${o}`)}`;
271
438
  const text = `${s} ${p} ${o}`;
272
439
  const tokens = proseTokensFor({ doc: text });
273
440
  await mutateMemory(dir, (payload) => {
274
441
  const prior = payload.individuals.find((x) => x?.id === id);
275
442
  const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
443
+ // The mgx:factProvenance union stays BYTE-IDENTICAL (a compat shim readers
444
+ // still key on); the Source edges below are DERIVED from it, purely additive.
276
445
  const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
446
+ const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
277
447
  upsertIndividual(payload, {
278
448
  id, label: labelOf(text), class: FACT_CLASS,
279
449
  derived_from: [], mentions: [],
@@ -282,11 +452,82 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
282
452
  { prop: "rdf:subject", key: "subject", value: s },
283
453
  { prop: "rdf:predicate", key: "predicate", value: p },
284
454
  { prop: "rdf:object", key: "object", value: o },
455
+ { prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
285
456
  ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
286
457
  ...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
287
458
  ],
288
459
  });
460
+ // Derive Source individuals + statedBy edges from the provenance union and
461
+ // (re)materialise this fact's trust — the live half of steps (b)/(c).
462
+ syncFactSources(payload, payload.individuals.find((x) => x?.id === id));
289
463
  recountClasses(payload);
290
464
  });
291
465
  return { id };
292
466
  }
467
+
468
+ // ---- Chat-facing seams (W4 fact lookup + contradiction) ---------------------
469
+ // The W4 fact-lookup THREADING lives in chat.mjs (NOT here); these pure readers
470
+ // are the seam it calls so the answer layer ranks candidates by relevance ×
471
+ // trust and cites provenance WITHOUT re-walking the graph shape.
472
+
473
+ /**
474
+ * Resolve every reified Fact in a loaded memory payload into a row carrying its
475
+ * Source ids + source-type multiset, the legacy provenance string (compat), and
476
+ * the cached trust score. Pure. The exported seam the chat/answer layer consumes
477
+ * for trust-weighted fact ranking.
478
+ */
479
+ export function readFactRows(memory) {
480
+ const individuals = memory?.individuals || [];
481
+ const sourcesById = new Map(individuals.filter((i) => i?.class === SOURCE_CLASS).map((i) => [i.id, i]));
482
+ const statedGroup = (memory?.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
483
+ const byFact = new Map();
484
+ for (const e of statedGroup?.examples || []) {
485
+ if (!byFact.has(e.subject)) byFact.set(e.subject, []);
486
+ byFact.get(e.subject).push(e.object);
487
+ }
488
+ const rows = [];
489
+ for (const ind of individuals) {
490
+ if (ind?.class !== FACT_CLASS) continue;
491
+ const get = (k) => (ind.attributes || []).find((a) => a?.key === k)?.value || "";
492
+ const sourceIds = byFact.get(ind.id) || [];
493
+ const sourceTypes = sourceIds
494
+ .map((id) => (sourcesById.get(id)?.attributes || []).find((a) => a?.prop === "mgx:sourceType")?.value)
495
+ .filter(Boolean);
496
+ rows.push({
497
+ id: ind.id,
498
+ subject: get("subject"), predicate: get("predicate"), object: get("object"),
499
+ provenance: get("provenance"), // legacy compat string, verbatim
500
+ sourceIds, sourceTypes,
501
+ trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
502
+ });
503
+ }
504
+ return rows;
505
+ }
506
+
507
+ /** The trust floor a fact must clear before a differing object counts as a real
508
+ * contradiction (below it the fact is too weak to contradict anything). */
509
+ export const CONTRADICTION_TRUST_FLOOR = 0.5;
510
+
511
+ /**
512
+ * Facts that CONTRADICT: same (subject, predicate), DIFFERENT object, each above
513
+ * the trust floor. Returns groups (each a [rows] sorted by trust desc) so the
514
+ * answer/inspection layer surfaces BOTH with their provenance and NEVER silently
515
+ * picks the higher-trust one. Same (s,p,o) from two writers is corroboration,
516
+ * not contradiction — one Fact id, N statedBy edges — so it never appears here.
517
+ */
518
+ export function findContradictions(memory, { floor = CONTRADICTION_TRUST_FLOOR } = {}) {
519
+ const rows = readFactRows(memory).filter((r) => r.trust >= floor);
520
+ const byKey = new Map();
521
+ for (const r of rows) {
522
+ const key = `${r.subject} ${r.predicate}`;
523
+ if (!byKey.has(key)) byKey.set(key, []);
524
+ byKey.get(key).push(r);
525
+ }
526
+ const out = [];
527
+ for (const group of byKey.values()) {
528
+ if (new Set(group.map((r) => r.object)).size > 1) {
529
+ out.push(group.slice().sort((a, b) => b.trust - a.trust || a.object.localeCompare(b.object)));
530
+ }
531
+ }
532
+ return out.sort((a, b) => `${a[0].subject} ${a[0].predicate}`.localeCompare(`${b[0].subject} ${b[0].predicate}`));
533
+ }
Binary file
@@ -0,0 +1,141 @@
1
+ // memory/inspect.mjs — seeing into the memory as TEXT (ROADMAP Phase 4,
2
+ // "Memory inspection"). One renderer serves both surfaces — the `/memory` chat
3
+ // command and the `tmct memory` CLI — in a terse (default) and a verbose form:
4
+ //
5
+ // - the memory graph grouped by OWL superclass (Fact / Utterance / Session,
6
+ // plus any other class present), counts with BALANCED samples scaled
7
+ // log-wise to class size (a 10,000-fact class shows ~8 exemplars, a
8
+ // 3-session class shows all 3);
9
+ // - top facts ranked by PROVENANCE BREADTH (a fact the corpus AND the chat
10
+ // both asserted outranks a single-writer fact), provenance verbatim;
11
+ // - recent Q→A utterance pairs (read off the mgx:inReplyTo edges);
12
+ // - the block-index summary (blocks, indexed tokens, top PageRank blocks).
13
+ //
14
+ // Pure renderers over loaded payloads + one thin I/O wrapper (inspectMemory).
15
+ // Everything degrades honestly: an empty memory renders as the empty story,
16
+ // never an error.
17
+
18
+ import { loadMemory, UTTERANCE_CLASS, IN_REPLY_TO_PROP, readFactRows, findContradictions } from "./core.mjs";
19
+ import { loadBlockIndex } from "./blocks.mjs";
20
+
21
+ /** Log-scaled sample count for a class of `n` individuals: 2·log10(n), floored
22
+ * at 3, never more than n (10,000 → 8; 500 → 5; 3 → 3; 1 → 1). Verbose doubles. */
23
+ export function sampleSize(n, { verbose = false } = {}) {
24
+ const base = Math.max(3, Math.round(2 * Math.log10(Math.max(1, n))));
25
+ return Math.min(n, verbose ? base * 2 : base);
26
+ }
27
+
28
+ /** Evenly-spaced (balanced) deterministic sample of k items — spans the class
29
+ * start to end rather than showing the first k. */
30
+ export function balancedSample(items, k) {
31
+ const n = items.length;
32
+ if (n <= k) return items.slice();
33
+ if (k <= 1) return [items[0]];
34
+ const out = [];
35
+ for (let i = 0; i < k; i += 1) out.push(items[Math.round((i * (n - 1)) / (k - 1))]);
36
+ return out;
37
+ }
38
+
39
+ const attrOf = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)?.value || "";
40
+ const truncate = (s, cap) => {
41
+ const t = String(s ?? "").replace(/\s+/g, " ").trim();
42
+ return t.length > cap ? `${t.slice(0, cap - 1)}…` : t;
43
+ };
44
+
45
+ /** Render a loaded memory payload + block index into the inspection text.
46
+ * Pure. `verbose` widens every cap and stops truncating provenance/text. */
47
+ export function renderMemory({ memory, blocks }, { verbose = false } = {}) {
48
+ const individuals = memory?.individuals || [];
49
+ const lines = [];
50
+ const textCap = verbose ? 400 : 100;
51
+
52
+ if (!individuals.length) {
53
+ lines.push("memory is empty — nothing remembered yet (facts, utterances and sessions land in .tmct/memory/ as you chat).");
54
+ } else {
55
+ // ---- classes: counts + balanced log-scaled samples ----
56
+ const byClass = new Map();
57
+ for (const ind of individuals) {
58
+ const cls = ind?.class || "(unclassified)";
59
+ if (!byClass.has(cls)) byClass.set(cls, []);
60
+ byClass.get(cls).push(ind);
61
+ }
62
+ const classes = [...byClass.entries()].sort((a, b) => b[1].length - a[1].length);
63
+ lines.push(`memory — ${individuals.length} individuals: ${classes.map(([c, of]) => `${of.length} ${c}`).join(", ")}.`);
64
+ for (const [cls, of] of classes) {
65
+ const k = sampleSize(of.length, { verbose });
66
+ lines.push("", `${cls} — ${of.length} (showing ${k})`);
67
+ for (const ind of balancedSample(of, k)) lines.push(` ${truncate(ind.label, textCap)}`);
68
+ }
69
+
70
+ // ---- top facts by COMPUTED TRUST (upgraded from raw provenance breadth) ----
71
+ // Trust folds source-type prior + corroboration + recency, so a corroborated
72
+ // operator-stated fact outranks a lone web scrape by construction; provenance
73
+ // rides along (verbatim in verbose) for the audit trail.
74
+ const ranked = readFactRows(memory)
75
+ .filter((r) => r.sourceIds.length || r.provenance)
76
+ .sort((a, b) => b.trust - a.trust
77
+ || b.sourceIds.length - a.sourceIds.length
78
+ || `${a.subject} ${a.predicate} ${a.object}`.localeCompare(`${b.subject} ${b.predicate} ${b.object}`));
79
+ if (ranked.length) {
80
+ lines.push("", "top facts by trust:");
81
+ for (const r of ranked.slice(0, verbose ? 8 : 3)) {
82
+ const n = r.sourceIds.length || (r.provenance ? r.provenance.split(" | ").filter(Boolean).length : 0);
83
+ const label = `${r.subject} ${r.predicate} ${r.object}`;
84
+ lines.push(` ${truncate(label, textCap)} — trust ${r.trust.toFixed(2)}, ${n} source${n === 1 ? "" : "s"}: ${verbose ? r.provenance : truncate(r.provenance, 80)}`);
85
+ }
86
+ }
87
+
88
+ // ---- contradictions: same (subject,predicate), differing object, both above
89
+ // the trust floor → surface BOTH with provenance, never silently pick ----
90
+ const contradictions = findContradictions(memory);
91
+ if (contradictions.length) {
92
+ lines.push("", `contradictions (${contradictions.length} — both kept, never silently resolved):`);
93
+ for (const group of contradictions.slice(0, verbose ? 8 : 3)) {
94
+ lines.push(` ${group[0].subject} ${group[0].predicate}?`);
95
+ for (const r of group) {
96
+ lines.push(` ${truncate(r.object, textCap)} (trust ${r.trust.toFixed(2)}; ${verbose ? r.provenance : truncate(r.provenance, 60)})`);
97
+ }
98
+ }
99
+ }
100
+
101
+ // ---- recent Q→A pairs (off the inReplyTo edges) ----
102
+ const byId = new Map(individuals.map((i) => [i.id, i]));
103
+ const replyGroup = (memory.objectProperties || []).find((g) => g?.prop === IN_REPLY_TO_PROP);
104
+ const pairs = (replyGroup?.examples || [])
105
+ .map((e) => ({ a: byId.get(e.subject), q: byId.get(e.object) }))
106
+ .filter((p) => p.a && p.q && p.a.class === UTTERANCE_CLASS)
107
+ .sort((x, y) => attrOf(y.a, "ts").localeCompare(attrOf(x.a, "ts")));
108
+ if (pairs.length) {
109
+ lines.push("", `recent Q→A pairs (${pairs.length} recorded):`);
110
+ for (const p of pairs.slice(0, verbose ? 8 : 3)) {
111
+ lines.push(` Q: ${truncate(attrOf(p.q, "text"), textCap)}`);
112
+ lines.push(` A: ${truncate(attrOf(p.a, "text"), textCap)}`);
113
+ }
114
+ }
115
+ }
116
+
117
+ // ---- block-index summary ----
118
+ const entries = Object.entries(blocks?.blocks || {});
119
+ if (entries.length) {
120
+ const tokens = entries.reduce((n, [, b]) => n + (b.tokens?.length || 0), 0);
121
+ const top = entries
122
+ .slice()
123
+ .sort((a, b) => (b[1].rank ?? 0) - (a[1].rank ?? 0) || a[0].localeCompare(b[0]))
124
+ .slice(0, verbose ? 8 : 3);
125
+ lines.push("", `blocks — ${entries.length} folded session block${entries.length === 1 ? "" : "s"}, ${tokens} indexed tokens.`);
126
+ lines.push(` top by rank: ${top.map(([id, b]) => `${String(id).slice(0, 8)} (${(b.rank ?? 0).toFixed(3)})`).join(", ")}`);
127
+ } else {
128
+ lines.push("", "blocks — none folded yet (a session folds when it ends).");
129
+ }
130
+
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /** Load + render a repo's memory (the one thin I/O wrapper both the `/memory`
135
+ * chat command and the `tmct memory` CLI call). Never throws on a missing
136
+ * store — that is the honest empty story. */
137
+ export async function inspectMemory(dir, { verbose = false } = {}) {
138
+ const memory = await loadMemory(dir);
139
+ const blocks = await loadBlockIndex(dir);
140
+ return renderMemory({ memory, blocks }, { verbose });
141
+ }
@@ -0,0 +1,113 @@
1
+ // memory/trust.mjs — deterministic, explainable, auditable trust over a Fact's
2
+ // Sources (PLAN_PROVENANCE_TRUST step (c)).
3
+ //
4
+ // Trust is a COMPUTED attribute of a Fact — never hand-set — a pure function of
5
+ // its Source edges, those Sources' types, and its mgx:createdAt. Three inputs
6
+ // combine:
7
+ // - a Source-TYPE PRIOR (operator > provider > corpus > web > entailed);
8
+ // - CORROBORATION over the fact's distinct Sources by noisy-OR
9
+ // (1 − Π(1 − wᵢ), capped at 1) — two independent web sources (0.4) reach
10
+ // 0.64, a lone operator fact is already 1.0;
11
+ // - a bounded RECENCY nudge in [0.9, 1.0] from createdAt, half-life decayed —
12
+ // the codegraph "capped nudge" philosophy, so recency breaks ties and
13
+ // freshens but never flips a source-type ordering by itself.
14
+ //
15
+ // For ENTAILED facts (tier-5): trust = min(premise trusts) × rule-confidence — a
16
+ // conclusion is only as trustworthy as its weakest premise. Premises may be
17
+ // absent for now, so this is a documented HOOK: pass opts.premiseTrusts (and
18
+ // opts.ruleConfidence) and it engages; otherwise an entailed fact scores off its
19
+ // bare 0.3 prior like any other Source.
20
+ //
21
+ // This module is PURE and import-free of core.mjs (no cycle): it reads Source
22
+ // individuals by their attribute props and returns { score, inputs }. core.mjs
23
+ // materialises the score onto the Fact (mgx:trustScore) plus the inputs it was
24
+ // computed from (mgx:trustInputs), so every score is reproducible and auditable.
25
+
26
+ export const TRUST_SCORE_PROP = "mgx:trustScore";
27
+ export const TRUST_INPUTS_PROP = "mgx:trustInputs";
28
+
29
+ /** Source-type priors — the ordering operator > provider-graph > curated-corpus
30
+ * > web > unverified-entailment. The entailed value is a FLOOR before premise
31
+ * adjustment (see the entailed hook below). */
32
+ export const SOURCE_PRIOR = Object.freeze({
33
+ operator: 1.0,
34
+ provider: 0.9,
35
+ corpus: 0.7,
36
+ web: 0.4,
37
+ entailed: 0.3,
38
+ });
39
+
40
+ export const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
41
+ export const RECENCY_FLOOR = 0.9; // recency multiplier stays within [0.9, 1.0]
42
+
43
+ const round = (n, p = 6) => Number(n.toFixed(p));
44
+ const sourceTypeOf = (s) => (s?.attributes || []).find((a) => a.prop === "mgx:sourceType")?.value || "";
45
+
46
+ /**
47
+ * Bounded recency multiplier in [RECENCY_FLOOR, 1] from an ISO-8601 createdAt.
48
+ * A half-life decay: freshly written ≈ 1.0, ancient → RECENCY_FLOOR. An unknown
49
+ * or unparseable timestamp yields 1.0 (no penalty) — recency only ever nudges
50
+ * down from a full score, it never invents one.
51
+ */
52
+ export function recencyNudge(createdAt, now = Date.now(), halfLifeMs = RECENCY_HALF_LIFE_MS) {
53
+ const t = Date.parse(createdAt);
54
+ if (!Number.isFinite(t)) return 1;
55
+ const ageMs = Math.max(0, now - t);
56
+ return RECENCY_FLOOR + (1 - RECENCY_FLOOR) * Math.pow(0.5, ageMs / halfLifeMs);
57
+ }
58
+
59
+ /**
60
+ * Pure trust for one Fact. `fact` supplies `{ sourceIds: [...], createdAt }`;
61
+ * the Source individuals are resolved from `sourcesById` (a plain { id: Source }
62
+ * map — exactly what a memory payload's Source individuals key into). Distinct
63
+ * sources are corroborated by noisy-OR over their type priors and nudged by
64
+ * recency. Deterministic given the same inputs and `opts.now`.
65
+ *
66
+ * opts:
67
+ * - now (ms) reference time for recency; default Date.now()
68
+ * - halfLifeMs recency half-life override
69
+ * - premiseTrusts entailed hook: [trusts] of the conclusion's premise Facts
70
+ * - ruleConfidence entailed hook: the rule's confidence in [0,1] (default 1)
71
+ *
72
+ * Returns { score, inputs } — `inputs` (the source-type multiset, corroboration
73
+ * count, createdAt and the recency multiplier) is stored alongside the score so
74
+ * "why does this rank high?" is answerable from the record.
75
+ */
76
+ export function computeTrust(fact, sourcesById = {}, opts = {}) {
77
+ const now = typeof opts.now === "number" ? opts.now : Date.now();
78
+ const ids = Array.isArray(fact?.sourceIds) ? fact.sourceIds : [];
79
+
80
+ // distinct sources → their type priors
81
+ const seen = new Set();
82
+ const types = [];
83
+ for (const id of ids) {
84
+ if (seen.has(id)) continue;
85
+ seen.add(id);
86
+ const t = sourceTypeOf(sourcesById[id]);
87
+ if (t) types.push(t);
88
+ }
89
+
90
+ // corroboration via noisy-OR over distinct-source priors, capped at 1
91
+ let base = 0;
92
+ let complement = 1;
93
+ for (const t of types) complement *= 1 - (SOURCE_PRIOR[t] ?? 0);
94
+ if (types.length) base = Math.min(1, 1 - complement);
95
+
96
+ // entailed hook (tier-5): a conclusion is only as trustworthy as its weakest
97
+ // premise × the rule confidence. Engages only when premises are supplied;
98
+ // otherwise an entailed fact rides its bare prior through the noisy-OR above.
99
+ if (types.includes("entailed") && Array.isArray(opts.premiseTrusts) && opts.premiseTrusts.length) {
100
+ const rc = typeof opts.ruleConfidence === "number" ? opts.ruleConfidence : 1;
101
+ base = Math.max(0, Math.min(1, Math.min(...opts.premiseTrusts) * rc));
102
+ }
103
+
104
+ const recency = recencyNudge(fact?.createdAt, now, opts.halfLifeMs);
105
+ const score = round(Math.min(1, base * recency));
106
+ const inputs = {
107
+ sourceTypes: types.slice().sort(),
108
+ corroboration: types.length,
109
+ createdAt: fact?.createdAt || "",
110
+ recency: round(recency),
111
+ };
112
+ return { score, inputs };
113
+ }