File size: 5,276 Bytes
8494d00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// hebbian.js — FAST-WEIGHTS overlay on the frozen transition graph. The
// trigram oracle is the prior (what CAN be said); this is the lived, drifting
// state (what this entity TENDS to say, with this person, lately). Cells that
// fire together wire together: a fragment used in a reply warms; a pair used
// adjacently warms their bond. Warmth decays each turn so it stays a FAST
// weight, not a permanent groove. Persisted per-entity → accumulates across
// sessions. This is TTT as a legible tensor: weights updating at inference.
//
// THE BOUND IS UNTOUCHED: Hebbian only biases WHICH licensed fragments surface.
// It can never change what's legal. Learn the music, never invent the notes.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const DECAY = 0.97;          // per-turn multiplicative decay (half-life ~23 turns)
const REINFORCE = 1.0;       // warmth added to a used fragment
const PAIR_REINFORCE = 0.6;  // warmth added to an adjacent pair
const MAX_BONUS = 0.08;      // hard cap on the relevance bonus — primes, never dominates

function h(text) { return crypto.createHash('sha1').update(text).digest('hex').slice(0, 12); }

// CONTEXT BUCKET (LSH): warmth is conditioned on the KIND of query, so grooves
// form per-topic ("fire questions warm fire-memories") instead of one global
// favorite dominating. K fixed random hyperplanes → a K-bit sign signature;
// queries pointing similar directions share a bucket. Planes are deterministic
// (seeded PRNG) so buckets are stable across runs and sessions.
const K_PLANES = 7;
let PLANES = null;
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function ensurePlanes(d) {
  if (PLANES && PLANES[0].length === d) return;
  const rng = mulberry32(0x5EED1234);
  PLANES = [];
  for (let k = 0; k < K_PLANES; k++) { const p = new Float32Array(d); for (let j = 0; j < d; j++) p[j] = rng() - 0.5; PLANES.push(p); }
}
function bucketOf(vec) {
  if (!vec || !vec.length) return 'g';            // no vector → global bucket
  ensurePlanes(vec.length);
  let bits = '';
  for (const p of PLANES) { let s = 0; for (let j = 0; j < vec.length; j++) s += vec[j] * p[j]; bits += s >= 0 ? '1' : '0'; }
  return bits;
}

// load/save take a FULL FILE PATH (kept out of read-only corpus folders —
// the product persists to its own cache dir, keyed per entity)
function load(filePath) {
  try {
    const raw = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^/, ''));
    return { warmth: new Map(Object.entries(raw.warmth || {})), pairs: new Map(Object.entries(raw.pairs || {})), turns: raw.turns || 0 };
  } catch (_) { return { warmth: new Map(), pairs: new Map(), turns: 0 }; }
}

function save(filePath, heb) {
  try {
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    const obj = { warmth: Object.fromEntries(heb.warmth), pairs: Object.fromEntries(heb.pairs), turns: heb.turns };
    fs.writeFileSync(filePath, JSON.stringify(obj));
  } catch (_) {}
}

// after a reply: warm its fragments (GLOBAL + this query's BUCKET) + adjacent
// pairs, decay everything, prune. The bucket key conditions warmth on context.
function reinforce(heb, fragmentTexts, bucket) {
  bucket = bucket || 'g';
  for (const [k, v] of heb.warmth) { const nv = v * DECAY; if (nv < 0.02) heb.warmth.delete(k); else heb.warmth.set(k, nv); }
  for (const [k, v] of heb.pairs) { const nv = v * DECAY; if (nv < 0.02) heb.pairs.delete(k); else heb.pairs.set(k, nv); }
  for (let i = 0; i < fragmentTexts.length; i++) {
    const a = h(fragmentTexts[i]);
    heb.warmth.set(a, (heb.warmth.get(a) || 0) + REINFORCE);                     // global
    const bk = bucket + '\t' + a;
    heb.warmth.set(bk, (heb.warmth.get(bk) || 0) + REINFORCE);                   // per-context
    if (i + 1 < fragmentTexts.length) {
      const pk = a + ':' + h(fragmentTexts[i + 1]);
      heb.pairs.set(pk, (heb.pairs.get(pk) || 0) + PAIR_REINFORCE);
    }
  }
  heb.turns++;
}

// build a fast bonus lookup for compose: fragmentHash -> 0..MAX_BONUS.
// Blends GLOBAL warmth (general favorites) with the current BUCKET's warmth
// (topic-specific grooves), so context-relevant memories prime hardest.
function bonusMap(heb, bucket) {
  const m = new Map();
  if (!heb || !heb.warmth.size) return m;
  bucket = bucket || 'g';
  const prefix = bucket + '\t';
  let gMax = 0, bMax = 0;
  for (const [k, v] of heb.warmth) {
    if (k.includes('\t')) { if (k.startsWith(prefix) && v > bMax) bMax = v; }
    else if (v > gMax) gMax = v;
  }
  const acc = new Map();   // fragHash -> blended raw score
  for (const [k, v] of heb.warmth) {
    if (k.includes('\t')) {
      if (!k.startsWith(prefix)) continue;
      const a = k.slice(prefix.length);
      acc.set(a, (acc.get(a) || 0) + 0.6 * (bMax ? v / bMax : 0));     // bucket groove
    } else {
      acc.set(k, (acc.get(k) || 0) + 0.4 * (gMax ? v / gMax : 0));      // global favorite
    }
  }
  for (const [a, raw] of acc) m.set(a, MAX_BONUS * Math.min(1, raw));
  return m;
}

module.exports = { load, save, reinforce, bonusMap, bucketOf, hashText: h };