dna-sdk 0.1.0 → 0.3.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.
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/semantic.d.ts +51 -0
- package/dist/memory/semantic.js +136 -0
- package/dist/testing/index.d.ts +4 -1
- package/dist/testing/index.js +4 -1
- package/dist/testing/memoryScoringConformance.d.ts +27 -0
- package/dist/testing/memoryScoringConformance.js +212 -0
- package/package.json +1 -1
package/dist/memory/index.d.ts
CHANGED
package/dist/memory/index.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { EcphoryScore, EngramRef } from "./ecphory.js";
|
|
2
|
+
import { RecallPolicy } from "./policy.js";
|
|
3
|
+
/**
|
|
4
|
+
* The spec fields that carry an engram's semantic payload — the SAME planes
|
|
5
|
+
* the ecphory content paths score (`area` for Path 1; `summary`/`title`/`body`
|
|
6
|
+
* for Path 2). The cue-side cosine embeds exactly this text, NOT the index's
|
|
7
|
+
* full `documentText` blob: names, dates, affect labels and other metadata
|
|
8
|
+
* strings would dilute the similarity without carrying meaning.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ENGRAM_TEXT_FIELDS: readonly ["area", "title", "summary", "body"];
|
|
11
|
+
/** The text a memory means, for cue-side embedding (see `ENGRAM_TEXT_FIELDS`). */
|
|
12
|
+
export declare function engramText(spec: Record<string, unknown>): string;
|
|
13
|
+
/**
|
|
14
|
+
* Cosine similarity between two vectors. 0.0 when either has no signal
|
|
15
|
+
* (all-zero — the fake embedder's honest "no tokens" vector). Accumulation
|
|
16
|
+
* order is index order, so the result is bit-identical to the Py twin.
|
|
17
|
+
*/
|
|
18
|
+
export declare function cosineSimilarity(a: readonly number[], b: readonly number[]): number;
|
|
19
|
+
/**
|
|
20
|
+
* Per-name cosine against the cue vector, in the shape `scoreEngram`'s Path 3
|
|
21
|
+
* consumes. Non-positive cosines are dropped (no signal, never a penalty).
|
|
22
|
+
* On a duplicate name the FIRST vector wins (deterministic).
|
|
23
|
+
*/
|
|
24
|
+
export declare function semanticScoresFromVectors(names: readonly string[], vectors: readonly (readonly number[])[], queryVector: readonly number[]): Record<string, number>;
|
|
25
|
+
/**
|
|
26
|
+
* The existing ecphory ranking over candidate engrams, with the semantic hook
|
|
27
|
+
* fed. `scoreEngram` (cue = the query) + Semon adjustments, gated by
|
|
28
|
+
* `RecallPolicy.directThreshold`, sorted (score desc, name asc — fully
|
|
29
|
+
* deterministic, Py↔TS identical). Pure — the shared parity core.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ecphoryRank(engrams: readonly EngramRef[], query: string, semanticScores?: Record<string, number>, policy?: RecallPolicy, now?: number): EcphoryScore[];
|
|
32
|
+
/** A recall hit as the fusion sees it — `name` + `score` plus passthrough keys. */
|
|
33
|
+
export interface RecallHit {
|
|
34
|
+
name?: string;
|
|
35
|
+
score?: number;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fuse the existing recall ranking with the semantic ecphory ranking.
|
|
40
|
+
*
|
|
41
|
+
* `hits` is the recall ranking (best-first); `engrams` are the same candidates
|
|
42
|
+
* as `EngramRef` views. The two rank lists are fused with
|
|
43
|
+
* `reciprocalRankFusion` — a candidate below the ecphory threshold keeps its
|
|
44
|
+
* recall rank (one-list RRF), never disappears. Returns NEW hit objects in
|
|
45
|
+
* fused order, annotated: `score` = fused RRF score, `score_recall` = the
|
|
46
|
+
* pre-fusion score, `rank_recall` / `rank_ecphory` (1-based), `score_ecphory`
|
|
47
|
+
* and `semantic` (cue↔memory cosine) when present. Rankings are keyed by hit
|
|
48
|
+
* `name`; on a duplicate the first (best) hit wins. Pure; the caller owns
|
|
49
|
+
* truncation to top-k.
|
|
50
|
+
*/
|
|
51
|
+
export declare function fuseSemanticRecall(hits: readonly RecallHit[], engrams: readonly EngramRef[], query: string, semanticScores: Record<string, number>, policy?: RecallPolicy, now?: number, rrfK?: number): RecallHit[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic recall — embedding similarity fed into the EXISTING ecphory ranking
|
|
3
|
+
* (TS twin of `dna/memory/semantic.py`, s-memory-semantic-recall).
|
|
4
|
+
*
|
|
5
|
+
* Activates the inert semantic hook: `scoreEngram`'s Path 3 blends an embedding
|
|
6
|
+
* cosine into the primary content score (`RecallPolicy.cosineWeight`), and the
|
|
7
|
+
* two rankings — the recall verb's and ecphory's — are fused with the same
|
|
8
|
+
* `reciprocalRankFusion` the search provider uses for its dense + lexical
|
|
9
|
+
* planes. Pure: no kernel, no IO; parity is pinned by the shared
|
|
10
|
+
* `memory-scoring-parity.json` fixture.
|
|
11
|
+
*/
|
|
12
|
+
import { DEFAULT_RRF_K, reciprocalRankFusion } from "../adapters/search/rrf.js";
|
|
13
|
+
import { applySemonAdjustments, scoreEngram } from "./ecphory.js";
|
|
14
|
+
import { DEFAULT_RECALL_POLICY } from "./policy.js";
|
|
15
|
+
/**
|
|
16
|
+
* The spec fields that carry an engram's semantic payload — the SAME planes
|
|
17
|
+
* the ecphory content paths score (`area` for Path 1; `summary`/`title`/`body`
|
|
18
|
+
* for Path 2). The cue-side cosine embeds exactly this text, NOT the index's
|
|
19
|
+
* full `documentText` blob: names, dates, affect labels and other metadata
|
|
20
|
+
* strings would dilute the similarity without carrying meaning.
|
|
21
|
+
*/
|
|
22
|
+
export const ENGRAM_TEXT_FIELDS = ["area", "title", "summary", "body"];
|
|
23
|
+
/** The text a memory means, for cue-side embedding (see `ENGRAM_TEXT_FIELDS`). */
|
|
24
|
+
export function engramText(spec) {
|
|
25
|
+
return ENGRAM_TEXT_FIELDS
|
|
26
|
+
.filter((f) => spec[f])
|
|
27
|
+
.map((f) => String(spec[f]))
|
|
28
|
+
.join(" ")
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Cosine similarity between two vectors. 0.0 when either has no signal
|
|
33
|
+
* (all-zero — the fake embedder's honest "no tokens" vector). Accumulation
|
|
34
|
+
* order is index order, so the result is bit-identical to the Py twin.
|
|
35
|
+
*/
|
|
36
|
+
export function cosineSimilarity(a, b) {
|
|
37
|
+
let dot = 0.0;
|
|
38
|
+
let normA = 0.0;
|
|
39
|
+
let normB = 0.0;
|
|
40
|
+
const n = Math.min(a.length, b.length);
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
dot += a[i] * b[i];
|
|
43
|
+
normA += a[i] * a[i];
|
|
44
|
+
normB += b[i] * b[i];
|
|
45
|
+
}
|
|
46
|
+
if (normA === 0.0 || normB === 0.0)
|
|
47
|
+
return 0.0;
|
|
48
|
+
return dot / Math.sqrt(normA) / Math.sqrt(normB);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Per-name cosine against the cue vector, in the shape `scoreEngram`'s Path 3
|
|
52
|
+
* consumes. Non-positive cosines are dropped (no signal, never a penalty).
|
|
53
|
+
* On a duplicate name the FIRST vector wins (deterministic).
|
|
54
|
+
*/
|
|
55
|
+
export function semanticScoresFromVectors(names, vectors, queryVector) {
|
|
56
|
+
const scores = {};
|
|
57
|
+
const n = Math.min(names.length, vectors.length);
|
|
58
|
+
for (let i = 0; i < n; i++) {
|
|
59
|
+
const name = names[i];
|
|
60
|
+
if (name in scores)
|
|
61
|
+
continue;
|
|
62
|
+
const cos = cosineSimilarity(queryVector, vectors[i]);
|
|
63
|
+
if (cos > 0.0)
|
|
64
|
+
scores[name] = cos;
|
|
65
|
+
}
|
|
66
|
+
return scores;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The existing ecphory ranking over candidate engrams, with the semantic hook
|
|
70
|
+
* fed. `scoreEngram` (cue = the query) + Semon adjustments, gated by
|
|
71
|
+
* `RecallPolicy.directThreshold`, sorted (score desc, name asc — fully
|
|
72
|
+
* deterministic, Py↔TS identical). Pure — the shared parity core.
|
|
73
|
+
*/
|
|
74
|
+
export function ecphoryRank(engrams, query, semanticScores, policy = DEFAULT_RECALL_POLICY, now = Date.now()) {
|
|
75
|
+
const cueCtx = { query };
|
|
76
|
+
const ranked = [];
|
|
77
|
+
for (const engram of engrams) {
|
|
78
|
+
let s = scoreEngram(engram, cueCtx, semanticScores, policy);
|
|
79
|
+
s = applySemonAdjustments(s, now, policy);
|
|
80
|
+
if (s.score >= policy.directThreshold)
|
|
81
|
+
ranked.push(s);
|
|
82
|
+
}
|
|
83
|
+
ranked.sort((x, y) => y.score - x.score || (x.engram.name < y.engram.name ? -1 : x.engram.name > y.engram.name ? 1 : 0));
|
|
84
|
+
return ranked;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Fuse the existing recall ranking with the semantic ecphory ranking.
|
|
88
|
+
*
|
|
89
|
+
* `hits` is the recall ranking (best-first); `engrams` are the same candidates
|
|
90
|
+
* as `EngramRef` views. The two rank lists are fused with
|
|
91
|
+
* `reciprocalRankFusion` — a candidate below the ecphory threshold keeps its
|
|
92
|
+
* recall rank (one-list RRF), never disappears. Returns NEW hit objects in
|
|
93
|
+
* fused order, annotated: `score` = fused RRF score, `score_recall` = the
|
|
94
|
+
* pre-fusion score, `rank_recall` / `rank_ecphory` (1-based), `score_ecphory`
|
|
95
|
+
* and `semantic` (cue↔memory cosine) when present. Rankings are keyed by hit
|
|
96
|
+
* `name`; on a duplicate the first (best) hit wins. Pure; the caller owns
|
|
97
|
+
* truncation to top-k.
|
|
98
|
+
*/
|
|
99
|
+
export function fuseSemanticRecall(hits, engrams, query, semanticScores, policy = DEFAULT_RECALL_POLICY, now = Date.now(), rrfK = DEFAULT_RRF_K) {
|
|
100
|
+
if (!hits.length)
|
|
101
|
+
return [];
|
|
102
|
+
const recallOrder = [];
|
|
103
|
+
const firstByName = new Map();
|
|
104
|
+
for (const hit of hits) {
|
|
105
|
+
const name = String(hit.name ?? "");
|
|
106
|
+
if (!name || firstByName.has(name))
|
|
107
|
+
continue;
|
|
108
|
+
firstByName.set(name, hit);
|
|
109
|
+
recallOrder.push(name);
|
|
110
|
+
}
|
|
111
|
+
const directs = ecphoryRank(engrams, query, semanticScores, policy, now);
|
|
112
|
+
const ecphoryOrder = directs.map((s) => s.engram.name);
|
|
113
|
+
const fused = reciprocalRankFusion([recallOrder, ecphoryOrder], rrfK);
|
|
114
|
+
const recallPos = new Map(recallOrder.map((name, i) => [name, i + 1]));
|
|
115
|
+
const ecphoryPos = new Map(ecphoryOrder.map((name, i) => [name, i + 1]));
|
|
116
|
+
const ecphoryScore = new Map(directs.map((s) => [s.engram.name, s.score]));
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const [name, fusedScore] of fused) {
|
|
119
|
+
const src = firstByName.get(name);
|
|
120
|
+
if (!src)
|
|
121
|
+
continue; // ecphory-only name — impossible when engrams ⊆ hits
|
|
122
|
+
const hit = { ...src };
|
|
123
|
+
hit.score_recall = Number(src.score ?? 0.0);
|
|
124
|
+
hit.score = fusedScore;
|
|
125
|
+
hit.rank_recall = recallPos.get(name);
|
|
126
|
+
if (ecphoryPos.has(name)) {
|
|
127
|
+
hit.rank_ecphory = ecphoryPos.get(name);
|
|
128
|
+
hit.score_ecphory = ecphoryScore.get(name);
|
|
129
|
+
}
|
|
130
|
+
const cos = semanticScores[name] ?? 0.0;
|
|
131
|
+
if (cos > 0.0)
|
|
132
|
+
hit.semantic = cos;
|
|
133
|
+
out.push(hit);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ship-with-the-SDK conformance suites: an adapter author hands us a factory
|
|
5
5
|
* for their adapter and gets back the battery of cases every conforming
|
|
6
|
-
* implementation must pass. Currently the RecordSearchProvider search-plane
|
|
6
|
+
* implementation must pass. Currently the RecordSearchProvider search-plane
|
|
7
|
+
* kit and the memory-scoring kit (the pure twin of Python's memory kit — the
|
|
8
|
+
* kernel-bound memory VERBS are Py-only by the SDK's declared boundary).
|
|
7
9
|
*/
|
|
8
10
|
export { FIXTURE_SCOPE, fixtureRecords, recordSearchConformanceSuite, SearchCaseNotApplicable, type ConformanceProvider, type ProviderFactory, type RecordSearchCase, } from "./recordSearchConformance.js";
|
|
11
|
+
export { KIT_NOW, MemoryCaseNotApplicable, memoryScoringConformanceSuite, type ConformanceEmbedder, type EmbedderFactory, type MemoryScoringCase, } from "./memoryScoringConformance.js";
|
package/dist/testing/index.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ship-with-the-SDK conformance suites: an adapter author hands us a factory
|
|
5
5
|
* for their adapter and gets back the battery of cases every conforming
|
|
6
|
-
* implementation must pass. Currently the RecordSearchProvider search-plane
|
|
6
|
+
* implementation must pass. Currently the RecordSearchProvider search-plane
|
|
7
|
+
* kit and the memory-scoring kit (the pure twin of Python's memory kit — the
|
|
8
|
+
* kernel-bound memory VERBS are Py-only by the SDK's declared boundary).
|
|
7
9
|
*/
|
|
8
10
|
export { FIXTURE_SCOPE, fixtureRecords, recordSearchConformanceSuite, SearchCaseNotApplicable, } from "./recordSearchConformance.js";
|
|
11
|
+
export { KIT_NOW, MemoryCaseNotApplicable, memoryScoringConformanceSuite, } from "./memoryScoringConformance.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** An embedder, structurally: async texts → vectors. */
|
|
2
|
+
export interface ConformanceEmbedder {
|
|
3
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
4
|
+
}
|
|
5
|
+
export type EmbedderFactory = () => Promise<{
|
|
6
|
+
embedder: ConformanceEmbedder;
|
|
7
|
+
cleanup?: () => void | Promise<void>;
|
|
8
|
+
}>;
|
|
9
|
+
/** Simulated clock anchor — the kit never reads the wall clock. */
|
|
10
|
+
export declare const KIT_NOW: number;
|
|
11
|
+
declare class SkipCase extends Error {
|
|
12
|
+
}
|
|
13
|
+
export interface MemoryScoringCase {
|
|
14
|
+
name: string;
|
|
15
|
+
requires: string;
|
|
16
|
+
run(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* THE public conformance suite for the pure memory-scoring core.
|
|
20
|
+
*
|
|
21
|
+
* @param factory optional async factory returning `{ embedder, cleanup }`;
|
|
22
|
+
* omit to run against the deterministic fake floor (fully offline).
|
|
23
|
+
* @returns one runnable case per invariant — feed them to your test runner
|
|
24
|
+
* and `await case.run()`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function memoryScoringConformanceSuite(factory?: EmbedderFactory): MemoryScoringCase[];
|
|
27
|
+
export { SkipCase as MemoryCaseNotApplicable };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-scoring conformance kit (TS twin of the pure half of
|
|
3
|
+
* `dna/testing/memory_conformance.py`, s-memory-conformance-kit).
|
|
4
|
+
*
|
|
5
|
+
* The behavioral contract of the deterministic memory-scoring core — ecphory
|
|
6
|
+
* weights + threshold, deterministic ordering, the semantic hook, RRF fusion,
|
|
7
|
+
* Ebbinghaus decay, bi-temporal fail-open. Twinned 1:1 with the Python
|
|
8
|
+
* `memory_scoring_conformance_suite`: SAME case names, SAME assertions — the
|
|
9
|
+
* kit itself is a parity artifact. For anyone evolving the scoring (a public
|
|
10
|
+
* regression pin) or shipping a custom embedder (the two embedder-driven
|
|
11
|
+
* cases assert RELATIVE similarity, search-kit style, so a real model passes
|
|
12
|
+
* too).
|
|
13
|
+
*
|
|
14
|
+
* The kernel-bound memory VERB suite (`memory_conformance_suite`) has no TS
|
|
15
|
+
* twin by design: the verbs (remember/recall/forget/consolidate) are Py-only
|
|
16
|
+
* by the SDK's declared boundary — TypeScript ships the pure scoring core
|
|
17
|
+
* only.
|
|
18
|
+
*
|
|
19
|
+
* A `factory` is an async zero-arg callable returning `{ embedder, cleanup }`
|
|
20
|
+
* where `embedder` exposes async `embed(texts) -> vectors` (an
|
|
21
|
+
* `EmbeddingPort` works as-is). Omit it to run against the deterministic
|
|
22
|
+
* fake floor — fully offline. Note that `semantic_hook_lifts_paraphrase`
|
|
23
|
+
* honestly requires the embedder to score an easy paraphrase above the
|
|
24
|
+
* ecphory gate (`cos >= (directThreshold - noveltyBoost) / cosineWeight`
|
|
25
|
+
* ~ 0.41) — an embedder that can't clear it cannot power semantic recall.
|
|
26
|
+
*/
|
|
27
|
+
import { fakeEmbedOne } from "../kernel/embedding.js";
|
|
28
|
+
import { currentlyValid, ebbinghausRetention, recallBump, } from "../memory/decay.js";
|
|
29
|
+
import { DEFAULT_DECAY_POLICY, DEFAULT_RECALL_POLICY, } from "../memory/policy.js";
|
|
30
|
+
import { cosineSimilarity, ecphoryRank, engramText, fuseSemanticRecall, semanticScoresFromVectors, } from "../memory/semantic.js";
|
|
31
|
+
/** Simulated clock anchor — the kit never reads the wall clock. */
|
|
32
|
+
export const KIT_NOW = Date.UTC(2026, 6, 10, 12, 0, 0);
|
|
33
|
+
const CREATED_AT = "2026-07-01T00:00:00+00:00";
|
|
34
|
+
class SkipCase extends Error {
|
|
35
|
+
}
|
|
36
|
+
function assert(cond, msg) {
|
|
37
|
+
if (!cond)
|
|
38
|
+
throw new Error(msg);
|
|
39
|
+
}
|
|
40
|
+
async function defaultEmbed(texts) {
|
|
41
|
+
return texts.map((t) => fakeEmbedOne(t));
|
|
42
|
+
}
|
|
43
|
+
const PARAPHRASE_QUERY = "mutating documents safely";
|
|
44
|
+
/**
|
|
45
|
+
* Target/decoy pair: the query is a PARAPHRASE of the target (no shared
|
|
46
|
+
* phrase, no token-subset — the cue-match paths score it 0 + novelty) and
|
|
47
|
+
* unrelated to the decoy. Never-recalled engrams, so Semon's novelty boost
|
|
48
|
+
* applies to both equally (the decoy still stays under the gate).
|
|
49
|
+
*/
|
|
50
|
+
function paraphraseEngrams() {
|
|
51
|
+
return [
|
|
52
|
+
{ name: "rem-target", spec: {
|
|
53
|
+
area: "Feature/kernel",
|
|
54
|
+
summary: "deep-copy before mutating documents",
|
|
55
|
+
created_at: CREATED_AT,
|
|
56
|
+
} },
|
|
57
|
+
{ name: "rem-decoy", spec: {
|
|
58
|
+
area: "Feature/ops",
|
|
59
|
+
summary: "safely archive old reports nightly",
|
|
60
|
+
created_at: CREATED_AT,
|
|
61
|
+
} },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// cases — names + assertions twinned with the Python scoring suite
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
async function cosineTracksSimilarity(embed) {
|
|
68
|
+
const texts = [
|
|
69
|
+
"deep copy before mutating documents",
|
|
70
|
+
"mutating documents safely",
|
|
71
|
+
"banana tropical smoothie breakfast",
|
|
72
|
+
];
|
|
73
|
+
const vecs = await embed(texts);
|
|
74
|
+
const again = await embed([texts[0]]);
|
|
75
|
+
const selfCos = cosineSimilarity(vecs[0], again[0]);
|
|
76
|
+
assert(selfCos > 1.0 - 1e-6, `identical text must be maximally similar, got ${selfCos}`);
|
|
77
|
+
const para = cosineSimilarity(vecs[0], vecs[1]);
|
|
78
|
+
const unrelated = cosineSimilarity(vecs[0], vecs[2]);
|
|
79
|
+
assert(para > unrelated, `paraphrase must be closer than unrelated text: ${para} !> ${unrelated}`);
|
|
80
|
+
}
|
|
81
|
+
async function ecphoryWeightsAndThreshold(_embed) {
|
|
82
|
+
const pol = DEFAULT_RECALL_POLICY;
|
|
83
|
+
const oldCue = [{ at: "2020-01-01T00:00:00+00:00", cue: "old", actor: "kit" }];
|
|
84
|
+
const eArea = { name: "rem-area", spec: {
|
|
85
|
+
area: "kernel cache mutation", summary: "",
|
|
86
|
+
created_at: CREATED_AT, cues_history: oldCue,
|
|
87
|
+
} };
|
|
88
|
+
const eSem = { name: "rem-sem", spec: {
|
|
89
|
+
area: "Feature/elsewhere", summary: "totally unrelated words entirely",
|
|
90
|
+
created_at: CREATED_AT, cues_history: oldCue,
|
|
91
|
+
} };
|
|
92
|
+
const eBelow = { name: "rem-below", spec: {
|
|
93
|
+
area: "Feature/nowhere", summary: "different disjoint vocabulary again",
|
|
94
|
+
created_at: CREATED_AT, cues_history: oldCue,
|
|
95
|
+
} };
|
|
96
|
+
const sem = { "rem-sem": 0.8, "rem-below": 0.4 };
|
|
97
|
+
const ranked = ecphoryRank([eArea, eSem, eBelow], "kernel cache mutation", sem, pol, KIT_NOW);
|
|
98
|
+
const byName = new Map(ranked.map((s) => [s.engram.name, s]));
|
|
99
|
+
assert(byName.size === 2 && byName.has("rem-area") && byName.has("rem-sem"), `0.61×0.4 < directThreshold must gate rem-below out, got ${[...byName.keys()].sort().join(",")}`);
|
|
100
|
+
const area = byName.get("rem-area");
|
|
101
|
+
assert(Math.abs(area.score - pol.contentWeight) < 1e-9, `exact area match must score contentWeight, got ${area.score}`);
|
|
102
|
+
assert(area.matchedDims[0] === "area", `expected 'area', got ${area.matchedDims[0]}`);
|
|
103
|
+
const semHit = byName.get("rem-sem");
|
|
104
|
+
assert(Math.abs(semHit.score - pol.cosineWeight * 0.8) < 1e-9, `Path 3 must blend cosineWeight × cos, got ${semHit.score}`);
|
|
105
|
+
assert(semHit.matchedDims[0].startsWith("semantic~"), `expected semantic~ label, got ${semHit.matchedDims[0]}`);
|
|
106
|
+
}
|
|
107
|
+
async function ecphoryDeterministicOrdering(_embed) {
|
|
108
|
+
const oldCue = [{ at: "2020-01-01T00:00:00+00:00", cue: "old", actor: "kit" }];
|
|
109
|
+
const spec = {
|
|
110
|
+
area: "kernel cache mutation", summary: "",
|
|
111
|
+
created_at: CREATED_AT, cues_history: oldCue,
|
|
112
|
+
};
|
|
113
|
+
const engrams = [
|
|
114
|
+
{ name: "rem-b", spec: { ...spec } },
|
|
115
|
+
{ name: "rem-a", spec: { ...spec } },
|
|
116
|
+
];
|
|
117
|
+
const first = ecphoryRank(engrams, "kernel cache mutation", undefined, DEFAULT_RECALL_POLICY, KIT_NOW);
|
|
118
|
+
assert(first.map((s) => s.engram.name).join(",") === "rem-a,rem-b", "equal scores must order by name ascending");
|
|
119
|
+
const second = ecphoryRank(engrams, "kernel cache mutation", undefined, DEFAULT_RECALL_POLICY, KIT_NOW);
|
|
120
|
+
assert(JSON.stringify(first.map((s) => [s.engram.name, s.score])) ===
|
|
121
|
+
JSON.stringify(second.map((s) => [s.engram.name, s.score])), "rerun must be identical");
|
|
122
|
+
}
|
|
123
|
+
async function semanticHookLiftsParaphrase(embed) {
|
|
124
|
+
const engrams = paraphraseEngrams();
|
|
125
|
+
const without = ecphoryRank(engrams, PARAPHRASE_QUERY, undefined, DEFAULT_RECALL_POLICY, KIT_NOW);
|
|
126
|
+
assert(without.length === 0, "the cue-match paths must NOT find a paraphrase on their own");
|
|
127
|
+
const vecs = await embed([PARAPHRASE_QUERY, ...engrams.map((e) => engramText(e.spec))]);
|
|
128
|
+
const sem = semanticScoresFromVectors(engrams.map((e) => e.name), vecs.slice(1), vecs[0]);
|
|
129
|
+
const ranked = ecphoryRank(engrams, PARAPHRASE_QUERY, sem, DEFAULT_RECALL_POLICY, KIT_NOW);
|
|
130
|
+
const names = ranked.map((s) => s.engram.name);
|
|
131
|
+
assert(names.includes("rem-target"), `the embedder's paraphrase similarity must lift the target over the ` +
|
|
132
|
+
`ecphory threshold (cos×${DEFAULT_RECALL_POLICY.cosineWeight} + novelty ≥ ` +
|
|
133
|
+
`${DEFAULT_RECALL_POLICY.directThreshold}); got [${names.join(",")}] ` +
|
|
134
|
+
`from cosines ${JSON.stringify(sem)}`);
|
|
135
|
+
if (names.includes("rem-decoy")) {
|
|
136
|
+
assert(names.indexOf("rem-target") < names.indexOf("rem-decoy"), `the paraphrase target must outrank the decoy: ${names.join(",")}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function fusionPreservesAndAnnotates(_embed) {
|
|
140
|
+
const engrams = paraphraseEngrams();
|
|
141
|
+
const hits = [
|
|
142
|
+
{ kind: "LessonLearned", name: "rem-decoy", score: 0.048 },
|
|
143
|
+
{ kind: "LessonLearned", name: "rem-target", score: 0.033 },
|
|
144
|
+
];
|
|
145
|
+
const sem = { "rem-target": 0.9 };
|
|
146
|
+
const fused = fuseSemanticRecall(hits, engrams, PARAPHRASE_QUERY, sem, DEFAULT_RECALL_POLICY, KIT_NOW);
|
|
147
|
+
assert(fused.map((h) => h.name).join(",") === "rem-target,rem-decoy", `fusion must promote the semantic match, got ${fused.map((h) => h.name).join(",")}`);
|
|
148
|
+
const [target, decoy] = fused;
|
|
149
|
+
// RRF (k=60): target = 1/(60+2) [recall #2] + 1/(60+1) [ecphory #1].
|
|
150
|
+
assert(Math.abs(target.score - (1 / 62 + 1 / 61)) < 1e-12, `fused score must be the reciprocal-rank sum, got ${target.score}`);
|
|
151
|
+
assert(target.rank_recall === 2 && target.rank_ecphory === 1, `expected ranks (2, 1), got (${target.rank_recall}, ${target.rank_ecphory})`);
|
|
152
|
+
assert(Math.abs(target.score_recall - 0.033) < 1e-12, "score_recall must preserve the pre-fusion score");
|
|
153
|
+
assert(Math.abs(target.semantic - 0.9) < 1e-12, "the cue↔memory cosine must be annotated");
|
|
154
|
+
// The decoy is under the ecphory gate: recall rank only, never dropped.
|
|
155
|
+
assert(Math.abs(decoy.score - 1 / 61) < 1e-12, `one-list RRF must keep the below-threshold hit, got ${decoy.score}`);
|
|
156
|
+
assert(decoy.rank_recall === 1 && !("rank_ecphory" in decoy), "the below-threshold hit rides its recall rank only");
|
|
157
|
+
assert(fuseSemanticRecall([], engrams, PARAPHRASE_QUERY, sem, DEFAULT_RECALL_POLICY, KIT_NOW).length === 0, "empty hits fuse to empty");
|
|
158
|
+
}
|
|
159
|
+
async function decayRetentionMonotonic(_embed) {
|
|
160
|
+
assert(ebbinghausRetention(10.0, null) === 1.0, "never recalled → R = 1");
|
|
161
|
+
assert(ebbinghausRetention(10.0, 0.0) === 1.0, "just recalled → R = 1");
|
|
162
|
+
const [r1, r5, r50] = [1.0, 5.0, 50.0].map((d) => ebbinghausRetention(10.0, d));
|
|
163
|
+
assert(1.0 > r1 && r1 > r5 && r5 > r50 && r50 > 0.0, `retention must decay monotonically: ${[r1, r5, r50].join(", ")}`);
|
|
164
|
+
const cap = DEFAULT_DECAY_POLICY.maxStabilityDays;
|
|
165
|
+
assert(recallBump(cap - 1.0, 0.001) <= cap, "the spacing bump must respect the cap");
|
|
166
|
+
assert(recallBump(10.0, 1.0) > 10.0, "a recall must strengthen the engram");
|
|
167
|
+
}
|
|
168
|
+
async function bitemporalFailOpen(_embed) {
|
|
169
|
+
assert(currentlyValid(null, KIT_NOW) === true, "unset valid_to → valid");
|
|
170
|
+
assert(currentlyValid("", KIT_NOW) === true, "empty valid_to → valid");
|
|
171
|
+
assert(currentlyValid("2026-07-09T00:00:00+00:00", KIT_NOW) === false, "past valid_to → invalid");
|
|
172
|
+
assert(currentlyValid("2026-07-11T00:00:00+00:00", KIT_NOW) === true, "future valid_to → valid");
|
|
173
|
+
assert(currentlyValid("not-a-timestamp", KIT_NOW) === true, "unparseable valid_to must NEVER hide a memory (fail-open)");
|
|
174
|
+
}
|
|
175
|
+
const CASES = [
|
|
176
|
+
{ name: "cosine_tracks_similarity", requires: "embedder", fn: cosineTracksSimilarity },
|
|
177
|
+
{ name: "ecphory_weights_and_threshold", requires: "pure", fn: ecphoryWeightsAndThreshold },
|
|
178
|
+
{ name: "ecphory_deterministic_ordering", requires: "pure", fn: ecphoryDeterministicOrdering },
|
|
179
|
+
{ name: "semantic_hook_lifts_paraphrase", requires: "embedder", fn: semanticHookLiftsParaphrase },
|
|
180
|
+
{ name: "fusion_preserves_and_annotates", requires: "pure", fn: fusionPreservesAndAnnotates },
|
|
181
|
+
{ name: "decay_retention_monotonic", requires: "pure", fn: decayRetentionMonotonic },
|
|
182
|
+
{ name: "bitemporal_fail_open", requires: "pure", fn: bitemporalFailOpen },
|
|
183
|
+
];
|
|
184
|
+
/**
|
|
185
|
+
* THE public conformance suite for the pure memory-scoring core.
|
|
186
|
+
*
|
|
187
|
+
* @param factory optional async factory returning `{ embedder, cleanup }`;
|
|
188
|
+
* omit to run against the deterministic fake floor (fully offline).
|
|
189
|
+
* @returns one runnable case per invariant — feed them to your test runner
|
|
190
|
+
* and `await case.run()`.
|
|
191
|
+
*/
|
|
192
|
+
export function memoryScoringConformanceSuite(factory) {
|
|
193
|
+
return CASES.map(({ name, requires, fn }) => ({
|
|
194
|
+
name,
|
|
195
|
+
requires,
|
|
196
|
+
async run() {
|
|
197
|
+
if (!factory) {
|
|
198
|
+
await fn(defaultEmbed);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const { embedder, cleanup } = await factory();
|
|
202
|
+
try {
|
|
203
|
+
await fn((texts) => embedder.embed(texts));
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (cleanup)
|
|
207
|
+
await cleanup();
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
export { SkipCase as MemoryCaseNotApplicable };
|