@polycode-projects/the-mechanical-code-talker 1.3.2 → 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.
@@ -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
- // The one deterministic operator Source id — the operator chatting to tmct.
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
- const memoryGraphFile = (dir) => join(dir, MEMORY_GRAPH_REL);
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
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
128
- await writeFile(tmp, JSON.stringify(obj));
129
- await rename(tmp, file);
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: "src:teach-chat", type: "teach" };
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
- const at = head.indexOf("@");
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);
Binary file
@@ -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 t = sourceTypeOf(sourcesById[id]);
91
- if (t) types.push(t);
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 t of types) complement *= 1 - (SOURCE_PRIOR[t] ?? 0);
98
- if (types.length) base = Math.min(1, 1 - complement);
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
- export function bootstrapProvider() {
23
- return createGraphService(bootstrapGraph());
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
- export function fixtureProvider() {
117
- return createGraphService(fixtureGraph());
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
  }