@polycode-projects/the-mechanical-code-talker 1.3.1 → 1.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 +142 -8
- package/ROADMAP.md +35 -0
- package/bin/tmct.mjs +128 -18
- package/package.json +1 -1
- package/src/chat.mjs +119 -72
- package/src/codegraph.mjs +73 -4
- package/src/config.mjs +7 -2
- package/src/conformance.mjs +59 -15
- package/src/corpus/templates.mjs +38 -0
- package/src/extensions.mjs +348 -0
- package/src/init.mjs +92 -7
- package/src/memory/bias.mjs +77 -0
- package/src/memory/blocks.mjs +57 -18
- package/src/memory/core.mjs +237 -20
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +94 -6
- package/src/providers/bootstrap.mjs +5 -3
- package/src/providers/fixture.mjs +7 -3
- package/src/providers/graph-service.mjs +205 -28
- package/src/repository-interface.mjs +21 -7
- package/src/server.mjs +39 -29
- package/src/source-slice.mjs +68 -0
- package/src/telemetry.mjs +5 -2
- package/src/toml-config.mjs +17 -0
package/src/memory/core.mjs
CHANGED
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
// (utt:<session>#<ts>#<role>) and fact ids hash the triple, so the per-turn
|
|
29
29
|
// re-append sessions.mjs performs replaces rather than duplicates.
|
|
30
30
|
|
|
31
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
31
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
32
32
|
import { dirname, join } from "node:path";
|
|
33
33
|
import { proseTokensFor, buildProseIndex } from "../prose.mjs";
|
|
34
34
|
import { fnv1aHex } from "../hash.mjs";
|
|
35
|
-
import { computeTrust, TRUST_SCORE_PROP, TRUST_INPUTS_PROP } from "./trust.mjs";
|
|
35
|
+
import { computeTrust, sessionReliabilityFrom, TRUST_SCORE_PROP, TRUST_INPUTS_PROP } from "./trust.mjs";
|
|
36
36
|
|
|
37
37
|
export const MEMORY_DIR_REL = join(".tmct", "memory");
|
|
38
38
|
export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
|
|
@@ -55,9 +55,19 @@ export const DERIVED_FROM_PROP = "mgx:derivedFrom"; // umbrella: Fact →
|
|
|
55
55
|
export const STATED_BY_PROP = "mgx:statedBy"; // a Source directly asserts a Fact
|
|
56
56
|
export const CANONICALISED_FROM_PROP = "mgx:canonicalisedFrom"; // a canonical Fact ← its raw form
|
|
57
57
|
export const CREATED_AT_PROP = "mgx:createdAt"; // first-write-wins ISO-8601 on every individual
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
export const SOURCE_RELIABILITY_PROP = "mgx:sourceReliability"; // actor-level (session-scoped) trust nudge on a Source, [0.5,1.5]
|
|
59
|
+
|
|
60
|
+
// The bare (session-less) singleton Source ids — the fallback for an
|
|
61
|
+
// operator/teach provenance tag that carries no session-id segment (e.g. a
|
|
62
|
+
// hand-authored "chat:"/"session:"/"operator" tag, or a direct API caller
|
|
63
|
+
// that never threaded a session id through). Once a provenance tag DOES carry
|
|
64
|
+
// a session-id segment (every real chat/teach write does — see
|
|
65
|
+
// grammar/assert.mjs's provenanceTag / chat.mjs's teachProvenanceTag), each
|
|
66
|
+
// session mints its OWN Source individual instead: `${ID}:<sessionId>`
|
|
67
|
+
// (sourceIdFor below) — actor-level (session-scoped) trust, unconditional,
|
|
68
|
+
// no config flag (PLAN_PROVENANCE_TRUST Part B).
|
|
60
69
|
export const OPERATOR_SOURCE_ID = "src:operator-chat";
|
|
70
|
+
export const TEACH_SOURCE_ID = "src:teach-chat";
|
|
61
71
|
|
|
62
72
|
const ROLES = new Set(["visitor", "tmct"]);
|
|
63
73
|
const LABEL_CAP = 48; // utterance/fact labels stay skimmable in renders
|
|
@@ -92,6 +102,7 @@ const MEMORY_VOCABULARY = [
|
|
|
92
102
|
{ prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | web | entailed (the trust-prior key)" },
|
|
93
103
|
{ prop: "mgx:sourceUrl", note: "a web Source's URL" },
|
|
94
104
|
{ prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
|
|
105
|
+
{ prop: "mgx:sourceReliability", note: "actor-level (session-scoped) trust nudge in [0.5,1.5], neutral 1.0 when absent — materialised by recomputeSourceReliability from a session's asserted-vs-contradicted track record (memory/trust.mjs's sessionReliabilityFrom); folds into computeTrust's per-source prior" },
|
|
95
106
|
{ 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" },
|
|
96
107
|
{ 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" },
|
|
97
108
|
{ prop: "mgx:hasProseTokens", note: "prose tokens (prose.mjs tokenizer) backing the payload's proseIndex" },
|
|
@@ -119,14 +130,123 @@ export function emptyMemory() {
|
|
|
119
130
|
};
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
|
|
133
|
+
/** Resolve the on-disk path of a memory graph file for `dir`. `version === null`
|
|
134
|
+
* (the default) is the LIVE graph (`graph.json`) — the one path every mutator
|
|
135
|
+
* funnels through (mutateMemory here, writeMemoryGraph in fold.mjs). A numeric
|
|
136
|
+
* `version` resolves a SNAPSHOT copy (`graph.v{version}.json`, see
|
|
137
|
+
* snapshotMemory below) — never the live file. The single source of truth for
|
|
138
|
+
* "where does the memory graph live on disk", closing the desync risk of two
|
|
139
|
+
* independent path-resolution copies (core.mjs's mutateMemory and fold.mjs's
|
|
140
|
+
* writeMemoryGraph used to compute this path separately). */
|
|
141
|
+
export function resolveMemoryGraphFile(dir, version = null) {
|
|
142
|
+
if (version === null) return join(dir, MEMORY_GRAPH_REL);
|
|
143
|
+
return join(dir, MEMORY_DIR_REL, `graph.v${version}.json`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const memoryGraphFile = (dir) => resolveMemoryGraphFile(dir);
|
|
147
|
+
|
|
148
|
+
/** Atomic write of raw text (temp in the same dir + rename) — the discipline
|
|
149
|
+
* every writer in this module (and fold.mjs/sessions.mjs's own copies) uses:
|
|
150
|
+
* a crash never destroys the previous file, a concurrent reader never sees a
|
|
151
|
+
* torn one. */
|
|
152
|
+
async function atomicWriteText(file, text) {
|
|
153
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
154
|
+
await writeFile(tmp, text);
|
|
155
|
+
await rename(tmp, file);
|
|
156
|
+
}
|
|
123
157
|
|
|
124
158
|
/** Atomic JSON write (temp in the same dir + rename) — same discipline as
|
|
125
159
|
* sessions.mjs's graph append: a crash never destroys the previous store. */
|
|
126
160
|
async function atomicWriteJson(file, obj) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
161
|
+
await atomicWriteText(file, JSON.stringify(obj));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- Manifest-versioned snapshots (manual trigger only — NOT wired to any ----
|
|
165
|
+
// ---- automatic call site; a primitive for a future CLI command/maintenance --
|
|
166
|
+
// ---- hook, PLAN item "memory-tree versioning") -------------------------------
|
|
167
|
+
|
|
168
|
+
export const MEMORY_MANIFEST_REL = join(MEMORY_DIR_REL, "manifest.json");
|
|
169
|
+
export const DEFAULT_RETENTION = 5;
|
|
170
|
+
|
|
171
|
+
const resolveManifestFile = (dir) => join(dir, MEMORY_MANIFEST_REL);
|
|
172
|
+
|
|
173
|
+
/** Snapshot the CURRENT live graph.json into a numbered `graph.v{N}.json`
|
|
174
|
+
* (N = the manifest's version BEFORE this call increments it), then advance
|
|
175
|
+
* the manifest and best-effort prune the oldest snapshot that falls outside
|
|
176
|
+
* the retention window.
|
|
177
|
+
*
|
|
178
|
+
* `graph.json` itself is NEVER touched or renamed here — it stays the one
|
|
179
|
+
* live file every mutator (mutateMemory / fold.mjs's writeMemoryGraph) reads
|
|
180
|
+
* and writes; only a COPY of its pre-snapshot content becomes the new
|
|
181
|
+
* numbered version. NOT called from mutateMemory, writeMemoryGraph, or
|
|
182
|
+
* anywhere else in this codebase — it has zero callers today by design; a
|
|
183
|
+
* future CLI command or maintenance hook calls it explicitly.
|
|
184
|
+
*
|
|
185
|
+
* Manifest bootstrap (no manifest.json yet): `{ version: 0, retentionVersions:
|
|
186
|
+
* opts.retentionVersions ?? DEFAULT_RETENTION }` — the optional
|
|
187
|
+
* `retentionVersions` lets a caller that already loaded tmct.toml's
|
|
188
|
+
* `[memory] retention_versions` seed the bootstrap default without this
|
|
189
|
+
* module doing its own config I/O (core.mjs has no toml-loading precedent;
|
|
190
|
+
* toml-config.mjs stays the one place that reads tmct.toml). Once a
|
|
191
|
+
* manifest.json exists on disk, ITS retentionVersions is authoritative and a
|
|
192
|
+
* later opts.retentionVersions is ignored (the persisted setting wins over a
|
|
193
|
+
* possibly-stale caller default).
|
|
194
|
+
*
|
|
195
|
+
* "No graph.json exists yet" is handled as a clean no-op: `{ skipped: true,
|
|
196
|
+
* version: null }` — nothing to snapshot is not an error, it is the honest
|
|
197
|
+
* bootstrap state (a brand-new repo that has never written a memory graph).
|
|
198
|
+
*
|
|
199
|
+
* Retention: after writing `graph.v{N}.json` and bumping the manifest to
|
|
200
|
+
* N+1, the snapshot at `graph.v{N - retentionVersions}.json` (if it exists)
|
|
201
|
+
* is deleted (best-effort — ENOENT is swallowed). Using N (the version just
|
|
202
|
+
* written), not N+1, for the prune target keeps a clean sliding window of
|
|
203
|
+
* exactly `retentionVersions` files on disk at all times, with no orphaned
|
|
204
|
+
* v0 ever left behind once the window starts sliding.
|
|
205
|
+
*
|
|
206
|
+
* Returns `{ skipped, version, prunedVersion }` — `version` is the number of
|
|
207
|
+
* the snapshot just written (or null if skipped); `prunedVersion` is the
|
|
208
|
+
* number pruned, or null if nothing was in range to prune yet. */
|
|
209
|
+
export async function snapshotMemory(dir, { retentionVersions } = {}) {
|
|
210
|
+
const graphFile = resolveMemoryGraphFile(dir);
|
|
211
|
+
let graphText;
|
|
212
|
+
try {
|
|
213
|
+
graphText = await readFile(graphFile, "utf8");
|
|
214
|
+
} catch (e) {
|
|
215
|
+
if (e?.code === "ENOENT") return { skipped: true, version: null, prunedVersion: null };
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const manifestFile = resolveManifestFile(dir);
|
|
220
|
+
let manifest;
|
|
221
|
+
try {
|
|
222
|
+
manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e?.code !== "ENOENT") throw e;
|
|
225
|
+
manifest = { version: 0, retentionVersions: retentionVersions ?? DEFAULT_RETENTION };
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isInteger(manifest.version)) manifest.version = 0;
|
|
228
|
+
if (!Number.isInteger(manifest.retentionVersions)) manifest.retentionVersions = retentionVersions ?? DEFAULT_RETENTION;
|
|
229
|
+
|
|
230
|
+
const v = manifest.version; // the version being written THIS call
|
|
231
|
+
const versionedFile = resolveMemoryGraphFile(dir, v);
|
|
232
|
+
await mkdir(dirname(versionedFile), { recursive: true });
|
|
233
|
+
await atomicWriteText(versionedFile, graphText);
|
|
234
|
+
|
|
235
|
+
manifest.version = v + 1;
|
|
236
|
+
|
|
237
|
+
let prunedVersion = null;
|
|
238
|
+
const pruneTarget = v - manifest.retentionVersions;
|
|
239
|
+
if (pruneTarget >= 0) {
|
|
240
|
+
try {
|
|
241
|
+
await unlink(resolveMemoryGraphFile(dir, pruneTarget));
|
|
242
|
+
prunedVersion = pruneTarget;
|
|
243
|
+
} catch (e) {
|
|
244
|
+
if (e?.code !== "ENOENT") throw e; // best-effort: a vanished snapshot is fine, anything else is not
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await atomicWriteJson(manifestFile, manifest);
|
|
249
|
+
return { skipped: false, version: v, prunedVersion };
|
|
130
250
|
}
|
|
131
251
|
|
|
132
252
|
/** Load the memory graph for a repo dir. A missing store is the bootstrap:
|
|
@@ -147,11 +267,14 @@ export async function loadMemory(dir) {
|
|
|
147
267
|
* goes through here so a concurrent reader never sees a torn store. The lazy,
|
|
148
268
|
* idempotent legacy-provenance migration rides this same cycle (step (b)): any
|
|
149
269
|
* Fact still carrying only the old mgx:factProvenance string gets its Sources +
|
|
150
|
-
* statedBy edges + trust materialised on the next write of any kind.
|
|
270
|
+
* statedBy edges + trust materialised on the next write of any kind. Part B3's
|
|
271
|
+
* actor-level (session-scoped) Source reliability rides the SAME cycle, after
|
|
272
|
+
* migration (so it sees every Fact's Sources, migrated or not). */
|
|
151
273
|
async function mutateMemory(dir, fn) {
|
|
152
274
|
const payload = await loadMemory(dir);
|
|
153
275
|
const out = fn(payload) ?? payload;
|
|
154
276
|
migrateLegacyProvenance(out);
|
|
277
|
+
recomputeSourceReliability(out);
|
|
155
278
|
out.proseIndex = buildProseIndex(out.individuals);
|
|
156
279
|
await mkdir(dirname(memoryGraphFile(dir)), { recursive: true });
|
|
157
280
|
await atomicWriteJson(memoryGraphFile(dir), out);
|
|
@@ -178,11 +301,20 @@ function setAttr(ind, prop, key, value) {
|
|
|
178
301
|
// ---- Sources (step (b)): first-class provenance individuals -----------------
|
|
179
302
|
|
|
180
303
|
/** Deterministic Source id + type over the closed kind set. Returns null for an
|
|
181
|
-
* unknown kind (an unmappable provenance tag → no Source, honestly).
|
|
304
|
+
* unknown kind (an unmappable provenance tag → no Source, honestly).
|
|
305
|
+
*
|
|
306
|
+
* operator/teach kinds are SESSION-SCOPED when `desc.sessionId` is present
|
|
307
|
+
* (unconditional — every real chat/teach provenance tag carries one; see
|
|
308
|
+
* provenanceTagToSource): `${OPERATOR_SOURCE_ID}:<sessionId>` /
|
|
309
|
+
* `${TEACH_SOURCE_ID}:<sessionId>` instead of the bare singleton, so each
|
|
310
|
+
* session's operator/teach facts attach to their OWN Source individual
|
|
311
|
+
* rather than every session ever collapsing onto one. Session ids are
|
|
312
|
+
* uuidv7s (hex + hyphens only — see uuid.mjs), so `:`/`@` never collide with
|
|
313
|
+
* this id scheme's own delimiters. */
|
|
182
314
|
function sourceIdFor(desc) {
|
|
183
315
|
switch (desc?.kind) {
|
|
184
|
-
case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
|
|
185
|
-
case "teach": return { id:
|
|
316
|
+
case "operator": return { id: desc.sessionId ? `${OPERATOR_SOURCE_ID}:${desc.sessionId}` : OPERATOR_SOURCE_ID, type: "operator" };
|
|
317
|
+
case "teach": return { id: desc.sessionId ? `${TEACH_SOURCE_ID}:${desc.sessionId}` : TEACH_SOURCE_ID, type: "teach" };
|
|
186
318
|
case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
|
|
187
319
|
case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
|
|
188
320
|
case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
|
|
@@ -215,30 +347,44 @@ function upsertSource(payload, desc, createdAtCandidate) {
|
|
|
215
347
|
return info.id;
|
|
216
348
|
}
|
|
217
349
|
|
|
350
|
+
/** Parse the "chat" shape both provenanceTag (grammar/assert.mjs) and
|
|
351
|
+
* teachProvenanceTag (chat.mjs) emit — `<source>[:<sessionId>][@<ts>]` after
|
|
352
|
+
* their kind prefix has already been stripped — into { createdAt, sessionId? }.
|
|
353
|
+
* `sessionId` is present only when the tag actually carried one (every real
|
|
354
|
+
* chat/teach write does; a hand-authored/legacy tag without one degrades to
|
|
355
|
+
* the bare singleton Source, honestly — see sourceIdFor). */
|
|
356
|
+
function parseChatTagRest(rest) {
|
|
357
|
+
const at = rest.indexOf("@");
|
|
358
|
+
const beforeAt = at >= 0 ? rest.slice(0, at) : rest;
|
|
359
|
+
const createdAt = at >= 0 ? rest.slice(at + 1) : "";
|
|
360
|
+
const colon = beforeAt.indexOf(":");
|
|
361
|
+
const sessionId = colon >= 0 ? beforeAt.slice(colon + 1) : "";
|
|
362
|
+
return { createdAt, ...(sessionId ? { sessionId } : {}) };
|
|
363
|
+
}
|
|
364
|
+
|
|
218
365
|
/**
|
|
219
366
|
* Parse one legacy provenance TAG into a Source descriptor over the closed kind
|
|
220
367
|
* set — the inverse the migration and the live write path both name Sources
|
|
221
368
|
* through. The tag formats are exactly what the writers produce:
|
|
222
369
|
* corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
|
|
223
|
-
* ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
|
|
224
|
-
* teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts> }
|
|
370
|
+
* ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts>, sessionId:<session> }
|
|
371
|
+
* teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts>, sessionId:<session> }
|
|
225
372
|
* web:<url> | url:<url> → { kind:"web", url:<url> }
|
|
226
373
|
* entailed:<rule> → { kind:"entailed", rule:<rule> }
|
|
227
374
|
* chat:/session: refs map to the operator; an unknown tag → null (no Source).
|
|
375
|
+
* The session-id segment (Part B: session-scoped actor-level trust) feeds
|
|
376
|
+
* sourceIdFor, which mints a PER-SESSION Source id when present, instead of
|
|
377
|
+
* collapsing every session onto one singleton operator/teach Source.
|
|
228
378
|
*/
|
|
229
379
|
export function provenanceTagToSource(tag) {
|
|
230
380
|
const t = String(tag || "").trim();
|
|
231
381
|
if (!t) return null;
|
|
232
382
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
233
383
|
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
234
|
-
if (head.startsWith("ace:")) {
|
|
235
|
-
const at = head.indexOf("@");
|
|
236
|
-
return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
|
|
237
|
-
}
|
|
384
|
+
if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
|
|
238
385
|
if (head.startsWith("teach:")) {
|
|
239
386
|
// the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
|
|
240
|
-
|
|
241
|
-
return { kind: "teach", createdAt: at >= 0 ? head.slice(at + 1) : "" };
|
|
387
|
+
return { kind: "teach", ...parseChatTagRest(head.slice("teach:".length)) };
|
|
242
388
|
}
|
|
243
389
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
244
390
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
@@ -314,6 +460,77 @@ function migrateLegacyProvenance(payload) {
|
|
|
314
460
|
if (changed) recountClasses(payload);
|
|
315
461
|
}
|
|
316
462
|
|
|
463
|
+
/** A session-scoped operator/teach Source id (Part B2's `${SINGLETON}:<sessionId>`
|
|
464
|
+
* shape) — the only Source kind actor-level reliability applies to; a corpus/
|
|
465
|
+
* web/provider/entailed Source has no "session" to hold a track record for. */
|
|
466
|
+
const isSessionScopedSourceId = (id) =>
|
|
467
|
+
typeof id === "string" && (id.startsWith(`${OPERATOR_SOURCE_ID}:`) || id.startsWith(`${TEACH_SOURCE_ID}:`));
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Recompute + materialise mgx:sourceReliability on every session-scoped
|
|
471
|
+
* operator/teach Source (Part B3): for each such Source, count the facts it
|
|
472
|
+
* stated (`factsAsserted`) and how many of those are part of a live
|
|
473
|
+
* contradiction (`factsContradicted`, via findContradictions — its own
|
|
474
|
+
* detection logic is untouched, this only READS its result), run
|
|
475
|
+
* sessionReliabilityFrom, and write the bounded result onto the Source.
|
|
476
|
+
*
|
|
477
|
+
* Contradiction membership is evaluated against the CURRENT trust scores at
|
|
478
|
+
* the point this runs (already materialised by this same mutation's
|
|
479
|
+
* syncFactSources/migrateLegacyProvenance calls) — it does NOT recursively
|
|
480
|
+
* re-evaluate contradictions after reliability changes shift trust scores
|
|
481
|
+
* (no fixed-point iteration; one pass is enough for a monotonic, self-
|
|
482
|
+
* correcting signal that only ever gets more accurate on the NEXT write).
|
|
483
|
+
*
|
|
484
|
+
* Every individual (Fact OR RULE) touched by a recomputed Source then has its
|
|
485
|
+
* OWN trust re-materialised (recomputeFactTrust — class-agnostic, same as
|
|
486
|
+
* syncFactSources: neither ever checks `.class`) so mgx:trustScore reflects
|
|
487
|
+
* the fresh reliability within THIS SAME mutation cycle — a session's
|
|
488
|
+
* reliability shift is visible immediately, not just on some future
|
|
489
|
+
* unrelated re-write. This refresh is scanned off the statedBy edge group
|
|
490
|
+
* DIRECTLY (every individual it names, any class), not off readFactRows
|
|
491
|
+
* (Fact-only) — a Rule can never be "contradicted" (findContradictions is a
|
|
492
|
+
* Fact-shape concept, so contradiction ACCOUNTING stays Fact-scoped above),
|
|
493
|
+
* but it rides the identical Source-derivation + trust pipeline a Fact does
|
|
494
|
+
* (appendRule's own doc comment), so it must not go stale here either.
|
|
495
|
+
*
|
|
496
|
+
* Called from mutateMemory itself (below), riding every mutation's existing
|
|
497
|
+
* bookkeeping cycle — not a separate write path.
|
|
498
|
+
*/
|
|
499
|
+
function recomputeSourceReliability(payload) {
|
|
500
|
+
if (!Array.isArray(payload?.individuals) || !Array.isArray(payload?.objectProperties)) return;
|
|
501
|
+
const rows = readFactRows(payload); // Fact-only — contradiction accounting is inherently Fact-shaped
|
|
502
|
+
const contradictedFactIds = new Set();
|
|
503
|
+
for (const group of findContradictions(payload)) for (const r of group) contradictedFactIds.add(r.id);
|
|
504
|
+
|
|
505
|
+
const bySource = new Map(); // sessionSourceId -> { factsAsserted, factsContradicted }
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
for (const sid of row.sourceIds) {
|
|
508
|
+
if (!isSessionScopedSourceId(sid)) continue;
|
|
509
|
+
const bucket = bySource.get(sid) || { factsAsserted: 0, factsContradicted: 0 };
|
|
510
|
+
bucket.factsAsserted += 1;
|
|
511
|
+
if (contradictedFactIds.has(row.id)) bucket.factsContradicted += 1;
|
|
512
|
+
bySource.set(sid, bucket);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (!bySource.size) return;
|
|
516
|
+
|
|
517
|
+
for (const [sid, counts] of bySource) {
|
|
518
|
+
const source = payload.individuals.find((i) => i?.id === sid);
|
|
519
|
+
if (!source) continue;
|
|
520
|
+
setAttr(source, SOURCE_RELIABILITY_PROP, "sourceReliability", String(sessionReliabilityFrom(counts)));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Re-materialise trust for EVERY individual statedBy a recomputed session
|
|
524
|
+
// Source — Fact or Rule alike — via the statedBy edge group directly.
|
|
525
|
+
const statedGroup = payload.objectProperties.find((g) => g?.prop === STATED_BY_PROP);
|
|
526
|
+
const affected = new Set();
|
|
527
|
+
for (const e of statedGroup?.examples || []) if (bySource.has(e?.object)) affected.add(e.subject);
|
|
528
|
+
for (const id of affected) {
|
|
529
|
+
const ind = payload.individuals.find((i) => i?.id === id);
|
|
530
|
+
if (ind) recomputeFactTrust(payload, ind);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
317
534
|
/** Upsert an individual by id (replace-in-place keeps ordering stable). */
|
|
318
535
|
function upsertIndividual(payload, ind) {
|
|
319
536
|
const i = payload.individuals.findIndex((x) => x?.id === ind.id);
|
package/src/memory/fold.mjs
CHANGED
|
Binary file
|
package/src/memory/trust.mjs
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// the codegraph "capped nudge" philosophy, so recency breaks ties and
|
|
13
13
|
// freshens but never flips a source-type ordering by itself.
|
|
14
14
|
//
|
|
15
|
+
// A fourth, per-Source bounded nudge folds into the type-prior term above (not a
|
|
16
|
+
// separate multiplicative stage): each Source may carry mgx:sourceReliability in
|
|
17
|
+
// [0.5, 1.5] (neutral 1.0 when absent, true of every Source until a session's
|
|
18
|
+
// actor-level trust — sessionReliabilityFrom, core.mjs's recomputeSourceReliability —
|
|
19
|
+
// starts writing it), so a session with a track record of corroborated facts
|
|
20
|
+
// nudges its own Source's contribution up, one contradicted repeatedly nudges it
|
|
21
|
+
// down — additive and safe: absent, every existing score is byte-identical.
|
|
22
|
+
//
|
|
15
23
|
// For ENTAILED facts (tier-5): trust = min(premise trusts) × rule-confidence — a
|
|
16
24
|
// conclusion is only as trustworthy as its weakest premise. Premises may be
|
|
17
25
|
// absent for now, so this is a documented HOOK: pass opts.premiseTrusts (and
|
|
@@ -44,9 +52,33 @@ export const SOURCE_PRIOR = Object.freeze({
|
|
|
44
52
|
export const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
45
53
|
export const RECENCY_FLOOR = 0.9; // recency multiplier stays within [0.9, 1.0]
|
|
46
54
|
|
|
55
|
+
// Actor-level (session-scoped) trust — a bounded NUDGE on top of a Source's
|
|
56
|
+
// type prior, read off the same already-passed Source individual a fact's
|
|
57
|
+
// corroboration already resolves via sourceTypeOf. `mgx:sourceReliability`
|
|
58
|
+
// lives in [SOURCE_RELIABILITY_MIN, SOURCE_RELIABILITY_MAX], NEUTRAL (1.0,
|
|
59
|
+
// exactly — no rounding drift) when absent, which is true of every Source
|
|
60
|
+
// until core.mjs's recomputeSourceReliability starts writing it. Neutral-when-
|
|
61
|
+
// absent makes this safely additive: every existing score is byte-identical
|
|
62
|
+
// until something actually writes the attribute.
|
|
63
|
+
export const SOURCE_RELIABILITY_MIN = 0.5;
|
|
64
|
+
export const SOURCE_RELIABILITY_MAX = 1.5;
|
|
65
|
+
export const SOURCE_RELIABILITY_NEUTRAL = 1.0;
|
|
66
|
+
|
|
47
67
|
const round = (n, p = 6) => Number(n.toFixed(p));
|
|
48
68
|
const sourceTypeOf = (s) => (s?.attributes || []).find((a) => a.prop === "mgx:sourceType")?.value || "";
|
|
49
69
|
|
|
70
|
+
/** A Source's reliability multiplier: the raw mgx:sourceReliability attribute,
|
|
71
|
+
* clamped into [SOURCE_RELIABILITY_MIN, SOURCE_RELIABILITY_MAX] (a corrupt or
|
|
72
|
+
* out-of-range stored value is defended against, never trusted blindly), or
|
|
73
|
+
* the neutral 1.0 when the attribute is absent/unparseable. */
|
|
74
|
+
function sourceReliabilityOf(s) {
|
|
75
|
+
const raw = (s?.attributes || []).find((a) => a.prop === "mgx:sourceReliability")?.value;
|
|
76
|
+
if (raw === undefined) return SOURCE_RELIABILITY_NEUTRAL;
|
|
77
|
+
const n = Number(raw);
|
|
78
|
+
if (!Number.isFinite(n)) return SOURCE_RELIABILITY_NEUTRAL;
|
|
79
|
+
return Math.max(SOURCE_RELIABILITY_MIN, Math.min(SOURCE_RELIABILITY_MAX, n));
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
/**
|
|
51
83
|
* Bounded recency multiplier in [RECENCY_FLOOR, 1] from an ISO-8601 createdAt.
|
|
52
84
|
* A half-life decay: freshly written ≈ 1.0, ancient → RECENCY_FLOOR. An unknown
|
|
@@ -81,21 +113,31 @@ export function computeTrust(fact, sourcesById = {}, opts = {}) {
|
|
|
81
113
|
const now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
82
114
|
const ids = Array.isArray(fact?.sourceIds) ? fact.sourceIds : [];
|
|
83
115
|
|
|
84
|
-
// distinct sources → their type priors
|
|
116
|
+
// distinct sources → their type priors, nudged by each Source's own
|
|
117
|
+
// mgx:sourceReliability (neutral 1.0 when absent — see sourceReliabilityOf).
|
|
118
|
+
// `types` stays the plain type multiset (the audit-trail shape callers/tests
|
|
119
|
+
// already read off `inputs.sourceTypes` is unchanged); `priors` is the
|
|
120
|
+
// per-source EFFECTIVE prior (type prior × reliability, clamped to [0,1])
|
|
121
|
+
// the noisy-OR below actually corroborates over.
|
|
85
122
|
const seen = new Set();
|
|
86
123
|
const types = [];
|
|
124
|
+
const priors = [];
|
|
87
125
|
for (const id of ids) {
|
|
88
126
|
if (seen.has(id)) continue;
|
|
89
127
|
seen.add(id);
|
|
90
|
-
const
|
|
91
|
-
|
|
128
|
+
const source = sourcesById[id];
|
|
129
|
+
const t = sourceTypeOf(source);
|
|
130
|
+
if (!t) continue;
|
|
131
|
+
types.push(t);
|
|
132
|
+
const p = (SOURCE_PRIOR[t] ?? 0) * sourceReliabilityOf(source);
|
|
133
|
+
priors.push(Math.max(0, Math.min(1, p)));
|
|
92
134
|
}
|
|
93
135
|
|
|
94
|
-
// corroboration via noisy-OR over distinct-source priors, capped at 1
|
|
136
|
+
// corroboration via noisy-OR over distinct-source EFFECTIVE priors, capped at 1
|
|
95
137
|
let base = 0;
|
|
96
138
|
let complement = 1;
|
|
97
|
-
for (const
|
|
98
|
-
if (
|
|
139
|
+
for (const p of priors) complement *= 1 - p;
|
|
140
|
+
if (priors.length) base = Math.min(1, 1 - complement);
|
|
99
141
|
|
|
100
142
|
// entailed hook (tier-5): a conclusion is only as trustworthy as its weakest
|
|
101
143
|
// premise × the rule confidence. Engages only when premises are supplied;
|
|
@@ -115,3 +157,49 @@ export function computeTrust(fact, sourcesById = {}, opts = {}) {
|
|
|
115
157
|
};
|
|
116
158
|
return { score, inputs };
|
|
117
159
|
}
|
|
160
|
+
|
|
161
|
+
// How many facts a session needs to have amassed before its track record can
|
|
162
|
+
// swing mgx:sourceReliability with full confidence toward an extreme. Without
|
|
163
|
+
// this, a SINGLE data point saturates the score immediately (asserted=1,
|
|
164
|
+
// contradicted=0 → the bare max 1.5) — measured in practice, this broke the
|
|
165
|
+
// standing invariant that a lone, uncontradicted teach-sourced fact must
|
|
166
|
+
// still score below the operator prior (0.95 × 1.5 clamps past 1.0). This
|
|
167
|
+
// pseudo-count keeps a thin track record close to NEUTRAL and only lets a
|
|
168
|
+
// session earn a confident nudge once it has a real history — the classic
|
|
169
|
+
// Bayesian-smoothing shape (Laplace/"add-k" pseudo-count) for small-sample
|
|
170
|
+
// rates.
|
|
171
|
+
export const RELIABILITY_CONFIDENCE_PSEUDOCOUNT = 19;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pure actor-level (session-scoped) reliability from one session's track
|
|
175
|
+
* record: how many facts it asserted vs. how many of those later turned out
|
|
176
|
+
* to be CONTRADICTED (findContradictions, core.mjs). Bounded to
|
|
177
|
+
* [SOURCE_RELIABILITY_MIN, SOURCE_RELIABILITY_MAX] — the same range
|
|
178
|
+
* mgx:sourceReliability lives in (B1 above), so the result of this function is
|
|
179
|
+
* exactly what a caller materialises onto a session's Source individual.
|
|
180
|
+
*
|
|
181
|
+
* Monotonic in the right direction: more uncontradicted assertions → closer to
|
|
182
|
+
* the max (1.5); more contradicted ones → closer to the min (0.5) — but
|
|
183
|
+
* CONFIDENCE-SCALED by sample size, so a session with only one or two
|
|
184
|
+
* assertions stays close to neutral (1.0) either way, and only a real track
|
|
185
|
+
* record (many assertions) earns a confident swing toward an extreme. Zero
|
|
186
|
+
* assertions is exactly neutral (1.0) — no track record, no opinion, matching
|
|
187
|
+
* the neutral default a Source without this attribute at all already gets
|
|
188
|
+
* (sourceReliabilityOf above). The shape:
|
|
189
|
+
* net = clamp[-1,1]((asserted − 2×contradicted) / max(1, asserted))
|
|
190
|
+
* confidence = asserted / (asserted + RELIABILITY_CONFIDENCE_PSEUDOCOUNT)
|
|
191
|
+
* ratio = (net × confidence + 1) / 2 → [0, 1], 0.5 at confidence 0
|
|
192
|
+
* reliability = MIN + (MAX − MIN) × ratio
|
|
193
|
+
* Each contradicted fact costs DOUBLE an asserted fact's worth of `net` (the
|
|
194
|
+
* `2×` term), so a session that is right twice and wrong once nets a positive
|
|
195
|
+
* but reduced score rather than a wash — corroboration should count for less
|
|
196
|
+
* than the reputational cost of a contradiction. Deterministic, no I/O.
|
|
197
|
+
*/
|
|
198
|
+
export function sessionReliabilityFrom({ factsAsserted = 0, factsContradicted = 0 } = {}) {
|
|
199
|
+
const asserted = Math.max(0, Number(factsAsserted) || 0);
|
|
200
|
+
const contradicted = Math.max(0, Number(factsContradicted) || 0);
|
|
201
|
+
const net = Math.max(-1, Math.min(1, (asserted - 2 * contradicted) / Math.max(1, asserted)));
|
|
202
|
+
const confidence = asserted / (asserted + RELIABILITY_CONFIDENCE_PSEUDOCOUNT);
|
|
203
|
+
const ratio = (net * confidence + 1) / 2;
|
|
204
|
+
return round(SOURCE_RELIABILITY_MIN + (SOURCE_RELIABILITY_MAX - SOURCE_RELIABILITY_MIN) * ratio);
|
|
205
|
+
}
|
|
@@ -18,7 +18,9 @@ export function bootstrapGraph() {
|
|
|
18
18
|
return parseEntities(emptyEntities());
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** The bootstrap provider: every service over the empty graph — honest empties.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/** The bootstrap provider: every service over the empty graph — honest empties.
|
|
22
|
+
* `opts` passes straight through to createGraphService (e.g. `{ sourceAccess: true,
|
|
23
|
+
* repoRoot, readFile }`), same pass-through as fixtureProvider. */
|
|
24
|
+
export function bootstrapProvider(opts = {}) {
|
|
25
|
+
return createGraphService(bootstrapGraph(), opts);
|
|
24
26
|
}
|
|
@@ -112,7 +112,11 @@ export function fixtureGraph() {
|
|
|
112
112
|
return parseEntities(FIXTURE_ENTITIES);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/** The fixture provider: a Repository-Interface service over the small real graph.
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
/** The fixture provider: a Repository-Interface service over the small real graph.
|
|
116
|
+
* `opts` passes straight through to createGraphService — e.g. `{ sourceAccess: true,
|
|
117
|
+
* repoRoot, readFile }` to construct a source-capable fixture provider for testing
|
|
118
|
+
* (see test/repository-interface.test.mjs's third runConformance call, which is the
|
|
119
|
+
* only thing that exercises the conformance kit's source-capable branch). */
|
|
120
|
+
export function fixtureProvider(opts = {}) {
|
|
121
|
+
return createGraphService(fixtureGraph(), opts);
|
|
118
122
|
}
|