File size: 10,702 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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
// semantic.js — node side of the vector space: builds/loads the fragment
// embedding cache (python MiniLM bridge), embeds queries, ranks by cosine.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawnSync, spawn } = require('child_process');

const EMBED_PY = path.join(__dirname, 'embed.py');

// PERSISTENT EMBED SERVER: spawn python ONCE (model stays loaded), feed queries
// over stdin, read vectors over stdout. Turns the 7.3s-per-query Python
// cold-start into ~30ms. Async + FIFO (requests serialized in order). Separate
// process + async pipes — NOT the in-process-spawnSync shape that deadlocked.
let _proc = null, _buf = '', _queue = [], _ready = null;
function ensureServer() {
  if (_proc) return _ready;
  _proc = spawn('python', [EMBED_PY, 'serve'], { stdio: ['pipe', 'pipe', 'ignore'] });
  _proc.stdout.setEncoding('utf8');
  _ready = new Promise(res => {
    _proc.stdout.on('data', d => {
      _buf += d;
      let nl;
      while ((nl = _buf.indexOf('\n')) >= 0) {
        const line = _buf.slice(0, nl); _buf = _buf.slice(nl + 1);
        if (line.trim() === 'READY') { res(true); continue; }
        const r = _queue.shift();
        if (r) { try { r(line.trim() === 'null' ? null : Float32Array.from(JSON.parse(line))); } catch (_) { r(null); } }
      }
    });
  });
  _proc.on('exit', () => { _proc = null; const q = _queue; _queue = []; for (const r of q) r(null); });
  return _ready;
}
async function embedQueryAsync(text) {
  await ensureServer();
  return new Promise(resolve => { _queue.push(resolve); _proc.stdin.write(String(text).replace(/[\r\n]+/g, ' ') + '\n'); });
}
function shutdownEmbed() { if (_proc) { try { _proc.kill(); } catch (_) {} _proc = null; } }

function textsHash(texts) {
  const h = crypto.createHash('sha1');
  for (const t of texts) h.update(t + '\n');
  return h.digest('hex').slice(0, 12);
}

// generic embedding cache for any text list (fragments, prompts, ...)
function ensureEmbeddings(texts, cacheDir, tag) {
  fs.mkdirSync(cacheDir, { recursive: true });
  const prefix = path.join(cacheDir, tag + '-' + textsHash(texts));
  const metaPath = prefix + '.meta.json';
  if (!fs.existsSync(metaPath)) {
    const textsPath = prefix + '.texts.json';
    fs.writeFileSync(textsPath, JSON.stringify(texts));
    process.stderr.write(`[semantic] embedding ${texts.length} ${tag} (one-time, CPU)...\n`);
    const r = spawnSync('python', [EMBED_PY, 'cache', textsPath, prefix], { encoding: 'utf8', timeout: 900000 });
    if (r.status !== 0) { process.stderr.write('[semantic] cache build failed: ' + (r.stderr || '').slice(-300) + '\n'); return null; }
  }
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
  const buf = fs.readFileSync(prefix + '.f32');
  return { vectors: new Float32Array(buf.buffer, buf.byteOffset, meta.n * meta.d), n: meta.n, d: meta.d };
}

function ensureFragmentEmbeddings(store, cacheDir) {
  // retrieval key ≠ surface form: embed with full original context
  // (diary headers etc.) while the composer emits only clean text
  return ensureEmbeddings(store.fragments.map(f => f.embedText || f.text), cacheDir, 'frags');
}
function ensurePromptEmbeddings(store, cacheDir) {
  if (!store.prompts || !store.prompts.length) return null;
  return ensureEmbeddings(store.prompts, cacheDir, 'prompts');
}

// DISK-BACKED QUERY-EMBEDDING CACHE (R94): torch CPU mean-pooling sums in a
// thread-scheduling-dependent order, so the SAME text yields subtly different
// vectors across runs as box load changes the effective parallelism — non-null,
// so the nullEmb guard misses it. That drift fed compose different anchors and
// silently moved eval results run-to-run with ZERO code change (R89/R93: garden
// 1.0→0.802, identical semTopic, different text). Caching the first-computed
// vector per text makes the bridge bit-reproducible: query+fragment vectors stay
// in ONE numerical regime (fragments are already cached on disk), so the eval is
// finally deterministic across runs. Delete cache/qembed-cache.json to re-baseline.
const QCACHE_PATH = path.join(__dirname, '..', 'cache', 'qembed-cache.json');
let _qcache = null;
function _loadQCache() {
  if (_qcache) return _qcache;
  try { _qcache = JSON.parse(fs.readFileSync(QCACHE_PATH, 'utf8')); } catch (_) { _qcache = {}; }
  return _qcache;
}
function _qkey(text) { return crypto.createHash('sha1').update(String(text)).digest('hex').slice(0, 16); }

function embedQuery(text) {
  const cache = _loadQCache();
  const key = _qkey(text);
  if (cache[key]) return Float32Array.from(cache[key]);
  // Under heavy box load a fresh python spawn can time out or fail; a silent null
  // drops compose to keyword-only anchoring. Retry transient failures (R88), then
  // cache the result so every later run reuses the EXACT vector (R94).
  for (let attempt = 0; attempt < 3; attempt++) {
    const r = spawnSync('python', [EMBED_PY, 'query', text], { encoding: 'utf8', timeout: 120000 });
    if (r.status === 0) {
      try {
        const v = Float32Array.from(JSON.parse(r.stdout.trim().split('\n').pop()));
        cache[key] = Array.from(v);
        try { fs.mkdirSync(path.dirname(QCACHE_PATH), { recursive: true }); fs.writeFileSync(QCACHE_PATH, JSON.stringify(cache)); } catch (_) {}
        return v;
      } catch (_) { /* retry */ }
    }
  }
  return null;
}

// cosine of query vs fragment i (vectors are L2-normalized -> dot product)
function sim(emb, i, q) {
  const d = emb.d;
  let s = 0;
  const off = i * d;
  for (let k = 0; k < d; k++) s += emb.vectors[off + k] * q[k];
  return s;
}

// top-k semantic matches -> Map(index -> normalized 0..1 score)
function semanticRank(emb, q, k) {
  const scores = [];
  for (let i = 0; i < emb.n; i++) scores.push([i, sim(emb, i, q)]);
  scores.sort((a, b) => b[1] - a[1]);
  const top = scores.slice(0, k || 80);
  const max = top[0][1], min = top[top.length - 1][1];
  const m = new Map();
  for (const [i, s] of top) m.set(i, max > min ? (s - min) / (max - min) : 1);
  // ABSOLUTE confidence (raw best cosine), preserved alongside the normalized
  // map — min-max normalization erases it, but the floor-miss detector needs to
  // know whether ANYTHING in the corpus actually addresses the query (R3's law:
  // a channel's authority scales with absolute confidence, never relative rank).
  m.confidence = max;
  return m;
}

// ---------------- TRAINED ANSWER PROJECTION (R167) ----------------
// A low-rank projection P (d x r), trained contrastively on the corpus's own
// (prompt -> answer-fragment) pairs, so a QUERY lands near the fragments that
// ANSWERED similar prompts instead of the ones that merely ECHO its shape.
// Fully bounded: P only reshapes the RANKING of verbatim fragments; it never
// invents text and never calls an LLM. Gated by availability — corpora with no
// <tag>.proj.json get null and retrieval is byte-identical to before.
const _projCache = new Map();
function loadProjection(tag, baseDir) {
  if (!tag) return null;
  if (_projCache.has(tag)) return _projCache.get(tag);
  const p = path.join(baseDir || path.join(__dirname, '..', 'retrieval-train'), tag + '.proj.json');
  let proj = null;
  try {
    if (fs.existsSync(p)) {
      const j = JSON.parse(fs.readFileSync(p, 'utf8'));
      // flatten W (d x r) to a typed array for fast projection
      const W = new Float32Array(j.d * j.r);
      for (let a = 0; a < j.d; a++) for (let b = 0; b < j.r; b++) W[a * j.r + b] = j.W[a][b];
      proj = { d: j.d, r: j.r, W };
    }
  } catch (_) { proj = null; }
  _projCache.set(tag, proj);
  return proj;
}
// project + L2-normalize one vector (vec offset `off` into a flat array)
function _projectVec(vec, off, proj) {
  const { d, r, W } = proj;
  const out = new Float32Array(r);
  for (let k = 0; k < r; k++) { let s = 0; for (let j = 0; j < d; j++) s += vec[off + j] * W[j * r + k]; out[k] = s; }
  let n = 0; for (let k = 0; k < r; k++) n += out[k] * out[k]; n = Math.sqrt(n) || 1;
  for (let k = 0; k < r; k++) out[k] /= n;
  return out;
}
// answer channel: rank fragments by projected-query x projected-fragment cosine.
// Returns Map(fragmentIndex -> normalized 0..1), with .confidence = best raw sim.
function answerRank(emb, q, proj, k) {
  if (!proj || !emb) return null;
  const qp = _projectVec(q, 0, proj);
  const r = proj.r;
  const scores = [];
  for (let i = 0; i < emb.n; i++) {
    const fp = _projectVec(emb.vectors, i * emb.d, proj);
    let v = 0; for (let t = 0; t < r; t++) v += fp[t] * qp[t];
    scores.push([i, v]);
  }
  scores.sort((a, b) => b[1] - a[1]);
  const top = scores.slice(0, k || 80);
  const max = top[0][1], min = top[top.length - 1][1];
  const m = new Map();
  for (const [i, s] of top) m.set(i, max > min ? (s - min) / (max - min) : 1);
  m.confidence = max;
  return m;
}

// stimulus channel: score each FRAGMENT by how similar its parent prompt was
// to the current query — the conversational reflex, made geometric.
// Returns { map, confidence } — confidence is the ABSOLUTE best cosine, so a
// corpus that has never seen a stimulus like this admits it instead of
// shouting noise. (Rank-normalization alone erased this and broke R3 v1.)
function stimulusRank(promptEmb, q, fragments, k) {
  if (!promptEmb) return null;
  const pScore = [];
  for (let i = 0; i < promptEmb.n; i++) pScore.push([i, sim(promptEmb, i, q)]);
  pScore.sort((a, b) => b[1] - a[1]);
  const top = pScore.slice(0, Math.min(k || 12, pScore.length));
  const confidence = top[0][1];
  const max = top[0][1], min = top[top.length - 1][1];
  const pNorm = new Map();
  for (const [i, s] of top) pNorm.set(i, max > min ? (s - min) / (max - min) : 1);
  const m = new Map();
  fragments.forEach((f, fi) => {
    if (f.promptIdx >= 0 && pNorm.has(f.promptIdx)) m.set(fi, pNorm.get(f.promptIdx));
  });
  return { map: m, confidence };
}

// is the query a life-event SHARE (statement about their world) vs a question?
function eventness(query) {
  const q = query.trim();
  if (/\?\s*$/.test(q)) return 0.2;
  const pastShare = /\b(i|we|my|me)\b[^?]*\b(finished|talked|did|went|got|made|had|found|fixed|broke|lost|won|started|quit|saw|met|built|wrote)\b/i.test(q);
  return pastShare ? 0.85 : 0.45;
}

// cosine between two cached fragment vectors (both L2-normalized)
function pairSim(emb, i, j) {
  const d = emb.d;
  let s = 0;
  const oi = i * d, oj = j * d;
  for (let k = 0; k < d; k++) s += emb.vectors[oi + k] * emb.vectors[oj + k];
  return s;
}

module.exports = { ensureFragmentEmbeddings, ensurePromptEmbeddings, embedQuery, embedQueryAsync, shutdownEmbed, semanticRank, stimulusRank, eventness, pairSim, loadProjection, answerRank };