@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.4.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.
- package/README.md +77 -3
- package/ROADMAP.md +411 -1
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +89 -0
- package/data/templates/responses.jsonl +13 -0
- package/package.json +30 -2
- package/src/ask-nlp.mjs +8 -10
- package/src/ask-vocab.mjs +22 -0
- package/src/ask.mjs +80 -2
- package/src/chat.mjs +576 -50
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/hash.mjs +32 -0
- package/src/init.mjs +264 -0
- package/src/interpret/normalize.mjs +34 -0
- package/src/interpret/strategies/keywords.mjs +57 -1
- package/src/memory/blocks.mjs +23 -3
- package/src/memory/core.mjs +257 -16
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +13 -2
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/wink-model.mjs +74 -0
package/src/memory/core.mjs
CHANGED
|
@@ -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: "
|
|
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:${
|
|
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
|
+
}
|
package/src/memory/fold.mjs
CHANGED
|
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
|
+
}
|