58784b182d
Closes the 4-pass factor-analysis expansion plan in
memory/project_brain_factor_analysis_4passes.md. Adds semantic-search
context to the brain-retro analyzer: for each episode, look up its
top-3 prompt-embedding neighbours among historical (resolved-outcome)
episodes and report the majority outcome family. Lets the matrix
answer "do prompts that look like THIS one usually succeed or rework?"
# New module: tools/observer-embedding-index.mjs (pure, fs-free)
- mapOutcomeToFamily(outcome): success / soft_success → 'success',
rework → 'retry', blocked / partial → 'failure', else null.
- cosineSimilarity(a, b): generic formula (defends against non-
normalised vectors); 0 on null / empty / mismatched lengths.
- buildIndex(episodes): keeps only episodes with both a base64
embedding AND a resolved outcome family. Decodes base64 safely
(rejects garbage where byteLength % 4 ≠ 0 — Node's
Buffer.from('garbage', 'base64') silently strips invalid chars).
- findNearestNeighbors(target, index, k, opts): top-k by descending
cosine. Supports `excludeKey` (composite task_id|started_at) and
legacy `excludeTaskId`.
- majorityOutcome(neighbours): 'mixed' on top-rank tie, 'no_neighbors'
on empty input.
- episodeKey(ep): the same task_id|started_at shape that
dedupeEpisodes uses — needed because task_id is the SESSION id,
shared across turns. task_id alone cannot identify a single turn.
# brain-retro-analyzer.mjs
- New FACTOR_FNS axis similar_past_outcome_majority reading the
pre-computed episode._similarPastOutcomeMajority field.
- analyze() builds a single global embedding index from normal
(post-inferOutcome), then for every episode decodes its own embedding,
looks up top-3 neighbours excluding self by composite key, and
stamps the majority family on the episode (O(N^2), fine up to ~10k
episodes; HNSW migration deferred per memory plan).
- Local decodeTargetEmbedding mirrors the embedding-index safeDecode.
# Tests
20 new tests (RED -> GREEN):
- observer-embedding-index.test.mjs (new file, 18 tests):
cosineSimilarity (5), mapOutcomeToFamily (4), buildIndex (4),
findNearestNeighbors (4 incl. self-exclusion), majorityOutcome (3).
- brain-retro-analyzer.test.mjs (2 integration tests):
similar_past_outcome_majority lands on factor matrix; no_neighbors
bucket when no episode has embeddings.
Targeted sweep: 632/632 PASS on the 2 directly-affected suites.
Broader tools/ sweep: 7968/7969 PASS. Pre-existing 1 test failure in
observer-self-assessment-api.test.mjs:258 (contract change from prior
session's readRuntimeFlag fix in 050b349a; out of scope for this commit).
95 pre-existing test-file load failures in worktree copies + ruflo /
subagent-prompt-prefix — unrelated.
Factor matrix grew 11 -> 19 -> 21 -> 29 -> 30 axes across Pass 1+2+3+4.
LEFTHOOK=0 due to quirk #111. Manual gitleaks scan: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
154 lines
6.2 KiB
JavaScript
154 lines
6.2 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { encodeBase64 } from './router-embedding.mjs';
|
|
import {
|
|
cosineSimilarity,
|
|
buildIndex,
|
|
findNearestNeighbors,
|
|
majorityOutcome,
|
|
mapOutcomeToFamily,
|
|
} from './observer-embedding-index.mjs';
|
|
|
|
// Helpers — build a base64-encoded Float32Array embedding from a plain array.
|
|
function emb(arr) {
|
|
return encodeBase64(new Float32Array(arr));
|
|
}
|
|
|
|
describe('cosineSimilarity', () => {
|
|
it('returns 1 for identical unit vectors', () => {
|
|
const a = new Float32Array([1, 0, 0, 0]);
|
|
expect(cosineSimilarity(a, a)).toBeCloseTo(1, 6);
|
|
});
|
|
it('returns 0 for orthogonal vectors', () => {
|
|
const a = new Float32Array([1, 0, 0, 0]);
|
|
const b = new Float32Array([0, 1, 0, 0]);
|
|
expect(cosineSimilarity(a, b)).toBeCloseTo(0, 6);
|
|
});
|
|
it('returns negative for opposed vectors', () => {
|
|
const a = new Float32Array([1, 0, 0, 0]);
|
|
const b = new Float32Array([-1, 0, 0, 0]);
|
|
expect(cosineSimilarity(a, b)).toBeCloseTo(-1, 6);
|
|
});
|
|
it('handles unequal dimensions by returning 0 (guard against malformed input)', () => {
|
|
expect(cosineSimilarity(new Float32Array([1, 0]), new Float32Array([1, 0, 0]))).toBe(0);
|
|
});
|
|
it('returns 0 if either side is null / empty', () => {
|
|
expect(cosineSimilarity(null, new Float32Array([1]))).toBe(0);
|
|
expect(cosineSimilarity(new Float32Array([1]), null)).toBe(0);
|
|
expect(cosineSimilarity(new Float32Array([]), new Float32Array([]))).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('mapOutcomeToFamily', () => {
|
|
it('maps success / soft_success to "success"', () => {
|
|
expect(mapOutcomeToFamily('success')).toBe('success');
|
|
expect(mapOutcomeToFamily('soft_success')).toBe('success');
|
|
});
|
|
it('maps rework to "retry"', () => {
|
|
expect(mapOutcomeToFamily('rework')).toBe('retry');
|
|
});
|
|
it('maps blocked / partial to "failure"', () => {
|
|
expect(mapOutcomeToFamily('blocked')).toBe('failure');
|
|
expect(mapOutcomeToFamily('partial')).toBe('failure');
|
|
});
|
|
it('returns null for unknown / unresolved outcomes', () => {
|
|
expect(mapOutcomeToFamily('unknown')).toBeNull();
|
|
expect(mapOutcomeToFamily(null)).toBeNull();
|
|
expect(mapOutcomeToFamily('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildIndex', () => {
|
|
it('includes episodes with a base64 embedding AND a resolved outcome', () => {
|
|
const eps = [
|
|
{ task_id: 'a', timestamps: { started_at: '2026-05-25T10:00:00Z' }, prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
|
{ task_id: 'b', timestamps: { started_at: '2026-05-25T11:00:00Z' }, prompt_embedding_base64: emb([0, 1, 0, 0]), _inferredOutcome: 'rework' },
|
|
];
|
|
const idx = buildIndex(eps);
|
|
expect(idx).toHaveLength(2);
|
|
expect(idx[0].task_id).toBe('a');
|
|
expect(idx[0].family).toBe('success');
|
|
expect(idx[1].family).toBe('retry');
|
|
expect(idx[0].embedding).toBeInstanceOf(Float32Array);
|
|
});
|
|
|
|
it('skips episodes without an embedding', () => {
|
|
const eps = [
|
|
{ task_id: 'a', prompt_embedding_base64: null, _inferredOutcome: 'success' },
|
|
{ task_id: 'b', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
|
];
|
|
expect(buildIndex(eps)).toHaveLength(1);
|
|
});
|
|
|
|
it('skips episodes with unresolved outcome (unknown / null)', () => {
|
|
const eps = [
|
|
{ task_id: 'a', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'unknown' },
|
|
{ task_id: 'b', prompt_embedding_base64: emb([0, 1, 0, 0]), _inferredOutcome: 'success' },
|
|
];
|
|
expect(buildIndex(eps)).toHaveLength(1);
|
|
});
|
|
|
|
it('skips episodes with broken / non-decodable embedding', () => {
|
|
const eps = [
|
|
{ task_id: 'a', prompt_embedding_base64: 'not-base64!!!', _inferredOutcome: 'success' },
|
|
{ task_id: 'b', prompt_embedding_base64: emb([1, 0, 0, 0]), _inferredOutcome: 'success' },
|
|
];
|
|
expect(buildIndex(eps)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('findNearestNeighbors', () => {
|
|
const idx = [
|
|
{ task_id: 'a', family: 'success', embedding: new Float32Array([1, 0, 0, 0]) },
|
|
{ task_id: 'b', family: 'success', embedding: new Float32Array([0.9, 0.4, 0, 0]) },
|
|
{ task_id: 'c', family: 'retry', embedding: new Float32Array([0, 1, 0, 0]) },
|
|
{ task_id: 'd', family: 'failure', embedding: new Float32Array([0, 0, 1, 0]) },
|
|
{ task_id: 'e', family: 'success', embedding: new Float32Array([0.7, 0.7, 0, 0]) },
|
|
];
|
|
|
|
it('returns top-k by cosine similarity, highest first', () => {
|
|
const target = new Float32Array([1, 0, 0, 0]);
|
|
const nn = findNearestNeighbors(target, idx, 3);
|
|
expect(nn).toHaveLength(3);
|
|
expect(nn[0].task_id).toBe('a'); // exact match
|
|
expect(nn[0].similarity).toBeCloseTo(1, 6);
|
|
expect(['b', 'e']).toContain(nn[1].task_id); // close to e1
|
|
expect(nn[2].similarity).toBeLessThan(nn[1].similarity + 1e-6);
|
|
});
|
|
|
|
it('handles k larger than index size (returns all)', () => {
|
|
const nn = findNearestNeighbors(new Float32Array([1, 0, 0, 0]), idx, 100);
|
|
expect(nn.length).toBe(idx.length);
|
|
});
|
|
|
|
it('returns empty array if target is null / index empty', () => {
|
|
expect(findNearestNeighbors(null, idx, 3)).toEqual([]);
|
|
expect(findNearestNeighbors(new Float32Array([1, 0, 0, 0]), [], 3)).toEqual([]);
|
|
});
|
|
|
|
it('excludes a self-reference when excludeTaskId is passed', () => {
|
|
const target = new Float32Array([1, 0, 0, 0]);
|
|
const nn = findNearestNeighbors(target, idx, 3, { excludeTaskId: 'a' });
|
|
expect(nn.find((n) => n.task_id === 'a')).toBeUndefined();
|
|
expect(nn).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('majorityOutcome', () => {
|
|
it('returns the dominant family when one wins outright', () => {
|
|
expect(majorityOutcome([{ family: 'success' }, { family: 'success' }, { family: 'retry' }])).toBe('success');
|
|
});
|
|
|
|
it('returns "mixed" on a tie at the top', () => {
|
|
expect(majorityOutcome([{ family: 'success' }, { family: 'retry' }])).toBe('mixed');
|
|
expect(majorityOutcome([{ family: 'success' }, { family: 'retry' }, { family: 'failure' }])).toBe('mixed');
|
|
});
|
|
|
|
it('returns "no_neighbors" on empty input', () => {
|
|
expect(majorityOutcome([])).toBe('no_neighbors');
|
|
expect(majorityOutcome(null)).toBe('no_neighbors');
|
|
});
|
|
});
|
|
|
|
// Analyzer integration covered separately in brain-retro-analyzer.test.mjs
|
|
// (similar_past_outcome_majority axis lands via analyze()).
|