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 };
|