@tangleai/memory 0.20.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/package.json +80 -0
- package/src/contradiction.d.ts +87 -0
- package/src/contradiction.js +138 -0
- package/src/crystallize.d.ts +67 -0
- package/src/crystallize.js +106 -0
- package/src/index.d.ts +15 -0
- package/src/index.js +8 -0
- package/src/ingest.d.ts +28 -0
- package/src/ingest.js +31 -0
- package/src/novelty.d.ts +32 -0
- package/src/novelty.js +52 -0
- package/src/outcome.d.ts +34 -0
- package/src/outcome.js +48 -0
- package/src/retrieval.d.ts +49 -0
- package/src/retrieval.js +57 -0
- package/src/store.d.ts +33 -0
- package/src/store.js +52 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @tangleai/memory
|
|
2
|
+
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Establish the coordinated 0.20.0 release with JavaScript and TypeScript declaration distributions, preserved public subpaths and JSON schemas, and verified Node and Bun consumers. Use published JarenJS 0.83.3 fixes without a consumer installation patch. Prepare versions before release commits, verify locally, push directly to main and publish CI-verified tarballs. Deploy the website independently from local Tangle workspace source with JarenJS packages from npm, verifying dependency sources and the live commit.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @tangleai/core@0.20.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joham (jklarenbeek@gmail.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @tangleai/memory
|
|
2
|
+
|
|
3
|
+
Tangle AI memory policies — novelty gating, crystallization, contradiction resolution, outcome learning — over an injected store
|
|
4
|
+
|
|
5
|
+
Install with `npm install @tangleai/memory`. The npm distribution provides ESM JavaScript, TypeScript declarations, and the documented package subpaths for Node 24 and Bun 1.4 or newer.
|
|
6
|
+
|
|
7
|
+
See the [Tangle documentation](https://github.com/jklarenbeek/tangleai#readme) for architecture, examples, and runtime requirements. All public Tangle packages use one coordinated version.
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tangleai/memory",
|
|
3
|
+
"version": "0.20.0",
|
|
4
|
+
"description": "Tangle AI memory policies — novelty gating, crystallization, contradiction resolution, outcome learning — over an injected store",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./store": {
|
|
15
|
+
"types": "./src/store.d.ts",
|
|
16
|
+
"import": "./src/store.js",
|
|
17
|
+
"default": "./src/store.js"
|
|
18
|
+
},
|
|
19
|
+
"./ingest": {
|
|
20
|
+
"types": "./src/ingest.d.ts",
|
|
21
|
+
"import": "./src/ingest.js",
|
|
22
|
+
"default": "./src/ingest.js"
|
|
23
|
+
},
|
|
24
|
+
"./novelty": {
|
|
25
|
+
"types": "./src/novelty.d.ts",
|
|
26
|
+
"import": "./src/novelty.js",
|
|
27
|
+
"default": "./src/novelty.js"
|
|
28
|
+
},
|
|
29
|
+
"./crystallize": {
|
|
30
|
+
"types": "./src/crystallize.d.ts",
|
|
31
|
+
"import": "./src/crystallize.js",
|
|
32
|
+
"default": "./src/crystallize.js"
|
|
33
|
+
},
|
|
34
|
+
"./contradiction": {
|
|
35
|
+
"types": "./src/contradiction.d.ts",
|
|
36
|
+
"import": "./src/contradiction.js",
|
|
37
|
+
"default": "./src/contradiction.js"
|
|
38
|
+
},
|
|
39
|
+
"./outcome": {
|
|
40
|
+
"types": "./src/outcome.d.ts",
|
|
41
|
+
"import": "./src/outcome.js",
|
|
42
|
+
"default": "./src/outcome.js"
|
|
43
|
+
},
|
|
44
|
+
"./retrieval": {
|
|
45
|
+
"types": "./src/retrieval.d.ts",
|
|
46
|
+
"import": "./src/retrieval.js",
|
|
47
|
+
"default": "./src/retrieval.js"
|
|
48
|
+
},
|
|
49
|
+
"./package.json": "./package.json"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=24"
|
|
53
|
+
},
|
|
54
|
+
"sideEffects": false,
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@jarenjs/ai": "0.83.3",
|
|
57
|
+
"@tangleai/core": "^0.20.0",
|
|
58
|
+
"@jarenjs/core": "0.83.3",
|
|
59
|
+
"@jarenjs/validate": "0.83.3"
|
|
60
|
+
},
|
|
61
|
+
"private": false,
|
|
62
|
+
"types": "./src/index.d.ts",
|
|
63
|
+
"files": [
|
|
64
|
+
"src/**/*.js",
|
|
65
|
+
"src/**/*.d.ts",
|
|
66
|
+
"schemas/**/*.json",
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
"CHANGELOG.md"
|
|
70
|
+
],
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public",
|
|
73
|
+
"registry": "https://registry.npmjs.org/"
|
|
74
|
+
},
|
|
75
|
+
"repository": {
|
|
76
|
+
"type": "git",
|
|
77
|
+
"url": "git+https://github.com/jklarenbeek/tangleai.git",
|
|
78
|
+
"directory": "packages/memory"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contradiction resolution — ported from memflow `ContradictionModule`,
|
|
3
|
+
* with the LLM moved behind a seam.
|
|
4
|
+
*
|
|
5
|
+
* memflow called `llm.invoke()` inline and regex-scraped JSON out of the
|
|
6
|
+
* reply. Here the judge is INJECTED: any async function answering the
|
|
7
|
+
* verdict shape. The intended production judge is two lines of
|
|
8
|
+
* @jarenjs/ai — `createStructuredOutput({ client, schema:
|
|
9
|
+
* CONTRADICTION_VERDICT_SCHEMA })` over `contradictionMessages(a, b)` —
|
|
10
|
+
* which buys schema-constrained decoding and the bounded repair loop
|
|
11
|
+
* instead of a regex. The tests inject a table.
|
|
12
|
+
*
|
|
13
|
+
* Policy (unchanged): only pairs similar enough to be ABOUT the same
|
|
14
|
+
* thing (>= threshold, default 0.75) are worth judging; the most similar
|
|
15
|
+
* pairs are judged first; at most `maxPairs` LLM calls per pass. On a
|
|
16
|
+
* confirmed contradiction the OLDER record is marked superseded (never
|
|
17
|
+
* deleted — "what did we believe before" stays answerable). A judge
|
|
18
|
+
* failure skips the pair; it never kills the pass.
|
|
19
|
+
*
|
|
20
|
+
* Tolerance is not silence. A pass that skipped every pair because the
|
|
21
|
+
* judge was down and a pass that judged every pair and found nothing
|
|
22
|
+
* are different outcomes, and a measurement that cannot tell them apart
|
|
23
|
+
* cannot say what a threshold did — so the outcome counts both halves
|
|
24
|
+
* of both forks: every attempt is judged or failed, and every confirmed
|
|
25
|
+
* verdict is applied or skipped because its older record was already
|
|
26
|
+
* gone.
|
|
27
|
+
*
|
|
28
|
+
* Only a SYNTHESIZED resolution becomes a new record. When the judge
|
|
29
|
+
* answers with the newer record's own text (or nothing), the newer
|
|
30
|
+
* record IS the resolution — writing it again would content-address to
|
|
31
|
+
* the same id and overwrite the embedded fact with an un-embedded
|
|
32
|
+
* summary, which is how the winner disappears from ranked recall.
|
|
33
|
+
*/
|
|
34
|
+
import type { JsonSchema, MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
35
|
+
import type { MemoryStore } from './store.ts';
|
|
36
|
+
export declare const DEFAULT_CONTRADICTION_THRESHOLD = 0.75;
|
|
37
|
+
export declare const DEFAULT_MAX_PAIRS = 20;
|
|
38
|
+
/** What a judge must answer. Wire it to @jarenjs/ai `createStructuredOutput`
|
|
39
|
+
* as the `schema` and the repair loop enforces it for free. */
|
|
40
|
+
export declare const CONTRADICTION_VERDICT_SCHEMA: JsonSchema;
|
|
41
|
+
export interface ContradictionVerdict {
|
|
42
|
+
contradiction: boolean;
|
|
43
|
+
reason?: string;
|
|
44
|
+
resolution?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ChatMessage {
|
|
47
|
+
role: string;
|
|
48
|
+
content: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The judge's chat messages for one pair — kept beside the schema so the
|
|
52
|
+
* prompt and the contract cannot drift apart.
|
|
53
|
+
*/
|
|
54
|
+
export declare function contradictionMessages(a: MemoryUnit, b: MemoryUnit): ChatMessage[];
|
|
55
|
+
export interface ContradictionPair {
|
|
56
|
+
a: MemoryUnit;
|
|
57
|
+
b: MemoryUnit;
|
|
58
|
+
similarity: number;
|
|
59
|
+
}
|
|
60
|
+
export interface ContradictionPlanOptions {
|
|
61
|
+
threshold?: number;
|
|
62
|
+
maxPairs?: number;
|
|
63
|
+
}
|
|
64
|
+
/** Select the pairs worth judging. Pure. */
|
|
65
|
+
export declare function planContradictionPairs(units: MemoryUnit[], options?: ContradictionPlanOptions): ContradictionPair[];
|
|
66
|
+
export interface ResolveOptions {
|
|
67
|
+
judge: (a: MemoryUnit, b: MemoryUnit) => Promise<ContradictionVerdict | null>;
|
|
68
|
+
/** RFC 3339. */
|
|
69
|
+
now: () => string;
|
|
70
|
+
}
|
|
71
|
+
export interface ResolveOutcome {
|
|
72
|
+
/** Planned pairs handed to the judge. */
|
|
73
|
+
attempted: number;
|
|
74
|
+
/** Pairs the judge answered. */
|
|
75
|
+
judged: number;
|
|
76
|
+
/** Judge calls that threw: the pair is skipped, the pass continues. */
|
|
77
|
+
judgeFailures: number;
|
|
78
|
+
/** Verdicts that confirmed a contradiction — applied or skipped. */
|
|
79
|
+
confirmed: number;
|
|
80
|
+
/** Confirmed contradictions written: the older record marked superseded. */
|
|
81
|
+
contradictions: number;
|
|
82
|
+
/** Confirmed contradictions whose older record was gone at write time. */
|
|
83
|
+
applicationSkips: number;
|
|
84
|
+
resolutions: MemoryUnit[];
|
|
85
|
+
}
|
|
86
|
+
/** Judge the planned pairs and resolve confirmed contradictions in the store. */
|
|
87
|
+
export declare function resolveContradictions(store: MemoryStore, pairs: ContradictionPair[], options: ResolveOptions): Promise<ResolveOutcome>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contradiction resolution — ported from memflow `ContradictionModule`,
|
|
3
|
+
* with the LLM moved behind a seam.
|
|
4
|
+
*
|
|
5
|
+
* memflow called `llm.invoke()` inline and regex-scraped JSON out of the
|
|
6
|
+
* reply. Here the judge is INJECTED: any async function answering the
|
|
7
|
+
* verdict shape. The intended production judge is two lines of
|
|
8
|
+
* @jarenjs/ai — `createStructuredOutput({ client, schema:
|
|
9
|
+
* CONTRADICTION_VERDICT_SCHEMA })` over `contradictionMessages(a, b)` —
|
|
10
|
+
* which buys schema-constrained decoding and the bounded repair loop
|
|
11
|
+
* instead of a regex. The tests inject a table.
|
|
12
|
+
*
|
|
13
|
+
* Policy (unchanged): only pairs similar enough to be ABOUT the same
|
|
14
|
+
* thing (>= threshold, default 0.75) are worth judging; the most similar
|
|
15
|
+
* pairs are judged first; at most `maxPairs` LLM calls per pass. On a
|
|
16
|
+
* confirmed contradiction the OLDER record is marked superseded (never
|
|
17
|
+
* deleted — "what did we believe before" stays answerable). A judge
|
|
18
|
+
* failure skips the pair; it never kills the pass.
|
|
19
|
+
*
|
|
20
|
+
* Tolerance is not silence. A pass that skipped every pair because the
|
|
21
|
+
* judge was down and a pass that judged every pair and found nothing
|
|
22
|
+
* are different outcomes, and a measurement that cannot tell them apart
|
|
23
|
+
* cannot say what a threshold did — so the outcome counts both halves
|
|
24
|
+
* of both forks: every attempt is judged or failed, and every confirmed
|
|
25
|
+
* verdict is applied or skipped because its older record was already
|
|
26
|
+
* gone.
|
|
27
|
+
*
|
|
28
|
+
* Only a SYNTHESIZED resolution becomes a new record. When the judge
|
|
29
|
+
* answers with the newer record's own text (or nothing), the newer
|
|
30
|
+
* record IS the resolution — writing it again would content-address to
|
|
31
|
+
* the same id and overwrite the embedded fact with an un-embedded
|
|
32
|
+
* summary, which is how the winner disappears from ranked recall.
|
|
33
|
+
*/
|
|
34
|
+
import { cosineSimilarity } from '@jarenjs/core/vector';
|
|
35
|
+
import { createMemoryUnit } from "./ingest.js";
|
|
36
|
+
export const DEFAULT_CONTRADICTION_THRESHOLD = 0.75;
|
|
37
|
+
export const DEFAULT_MAX_PAIRS = 20;
|
|
38
|
+
/** What a judge must answer. Wire it to @jarenjs/ai `createStructuredOutput`
|
|
39
|
+
* as the `schema` and the repair loop enforces it for free. */
|
|
40
|
+
export const CONTRADICTION_VERDICT_SCHEMA = {
|
|
41
|
+
$id: 'https://tangleai.dev/schemas/contradiction-verdict.json',
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
contradiction: { type: 'boolean' },
|
|
45
|
+
reason: { type: 'string' },
|
|
46
|
+
resolution: { type: 'string' },
|
|
47
|
+
},
|
|
48
|
+
required: ['contradiction'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The judge's chat messages for one pair — kept beside the schema so the
|
|
53
|
+
* prompt and the contract cannot drift apart.
|
|
54
|
+
*/
|
|
55
|
+
export function contradictionMessages(a, b) {
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
role: 'system',
|
|
59
|
+
content: 'You judge whether two memory records contradict each other. '
|
|
60
|
+
+ 'Two records contradict when both cannot be true at once — differing detail or scope is not contradiction. '
|
|
61
|
+
+ 'When they do contradict, state the reason and write a single resolution sentence that reflects the better-evidenced record.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
role: 'user',
|
|
65
|
+
content: `Record A (${a.at}): "${a.text}" (evidence: ${a.evidence})\n`
|
|
66
|
+
+ `Record B (${b.at}): "${b.text}" (evidence: ${b.evidence})\n`
|
|
67
|
+
+ 'Do these contradict?',
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
/** Select the pairs worth judging. Pure. */
|
|
72
|
+
export function planContradictionPairs(units, options = {}) {
|
|
73
|
+
const threshold = options.threshold ?? DEFAULT_CONTRADICTION_THRESHOLD;
|
|
74
|
+
const maxPairs = options.maxPairs ?? DEFAULT_MAX_PAIRS;
|
|
75
|
+
const candidates = [];
|
|
76
|
+
const live = units.filter((u) => !u.supersededBy);
|
|
77
|
+
for (let i = 0; i < live.length; i++) {
|
|
78
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
79
|
+
const a = live[i];
|
|
80
|
+
const b = live[j];
|
|
81
|
+
if (!a.embedding || !b.embedding)
|
|
82
|
+
continue;
|
|
83
|
+
const sim = cosineSimilarity(a.embedding, b.embedding);
|
|
84
|
+
if (sim >= threshold)
|
|
85
|
+
candidates.push({ a, b, similarity: sim });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
candidates.sort((x, y) => y.similarity - x.similarity);
|
|
89
|
+
return candidates.slice(0, maxPairs);
|
|
90
|
+
}
|
|
91
|
+
/** Judge the planned pairs and resolve confirmed contradictions in the store. */
|
|
92
|
+
export async function resolveContradictions(store, pairs, options) {
|
|
93
|
+
let contradictions = 0;
|
|
94
|
+
let judged = 0;
|
|
95
|
+
let judgeFailures = 0;
|
|
96
|
+
let confirmed = 0;
|
|
97
|
+
let applicationSkips = 0;
|
|
98
|
+
const resolutions = [];
|
|
99
|
+
for (const pair of pairs) {
|
|
100
|
+
let verdict = null;
|
|
101
|
+
try {
|
|
102
|
+
verdict = await options.judge(pair.a, pair.b);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
judgeFailures++; // a judge failure skips the pair, never the pass — and is counted
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
judged++;
|
|
109
|
+
if (!verdict?.contradiction)
|
|
110
|
+
continue;
|
|
111
|
+
confirmed++;
|
|
112
|
+
const older = pair.a.at <= pair.b.at ? pair.a : pair.b;
|
|
113
|
+
const newer = older === pair.a ? pair.b : pair.a;
|
|
114
|
+
const supersededAt = options.now();
|
|
115
|
+
const loser = await store.get(older.id);
|
|
116
|
+
if (!loser) {
|
|
117
|
+
applicationSkips++;
|
|
118
|
+
continue;
|
|
119
|
+
} // a prior pass or a host raced us
|
|
120
|
+
loser.supersededBy = newer.id;
|
|
121
|
+
loser.supersededAt = supersededAt;
|
|
122
|
+
loser.supersededReason = verdict.reason ?? 'contradiction detected';
|
|
123
|
+
await store.put(loser);
|
|
124
|
+
if (verdict.resolution !== undefined && verdict.resolution !== newer.text) {
|
|
125
|
+
const resolution = createMemoryUnit({
|
|
126
|
+
text: verdict.resolution,
|
|
127
|
+
evidence: `contradiction resolution of ${older.id} by ${newer.id}: ${verdict.reason ?? 'contradiction detected'}`,
|
|
128
|
+
tags: [...new Set([...older.tags, ...newer.tags, 'resolution'])],
|
|
129
|
+
at: supersededAt,
|
|
130
|
+
kind: 'summary',
|
|
131
|
+
});
|
|
132
|
+
await store.put(resolution);
|
|
133
|
+
resolutions.push(resolution);
|
|
134
|
+
}
|
|
135
|
+
contradictions++;
|
|
136
|
+
}
|
|
137
|
+
return { attempted: pairs.length, judged, judgeFailures, confirmed, contradictions, applicationSkips, resolutions };
|
|
138
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crystallization — merge near-duplicate memories into canonical forms.
|
|
3
|
+
* Ported from memflow `CrystallizerModule`, split into the house shape:
|
|
4
|
+
* a PURE planner that decides what to merge, and an applier that executes
|
|
5
|
+
* the plan against a store. memflow fused deciding and Cypher-executing
|
|
6
|
+
* in one method, which meant the merge policy could only ever be tested
|
|
7
|
+
* against a live Memgraph; here the plan is a value you can assert on.
|
|
8
|
+
*
|
|
9
|
+
* Policy (unchanged from memflow):
|
|
10
|
+
* - pairwise similarity >= threshold (default 0.92) marks a duplicate pair
|
|
11
|
+
* - the higher-confidence record survives; ties keep the first
|
|
12
|
+
* - a record participates in at most one merge per pass — a chain
|
|
13
|
+
* A~B~C collapses over successive passes, not in one ambiguous step
|
|
14
|
+
* - only records embedded by the same identity are compared (the
|
|
15
|
+
* jarenjs rule: vectors from two models never rank against each
|
|
16
|
+
* other); the metric is `@jarenjs/core/vector`'s cosine
|
|
17
|
+
*
|
|
18
|
+
* Application details that changed, deliberately:
|
|
19
|
+
* - the survivor's confidence rises by `boost` (0.05), clamped to
|
|
20
|
+
* [floor, 1]; the floor keeps a record demotable but never erasable
|
|
21
|
+
* - the absorbed record's id lands in `mergedFrom` (provenance — the
|
|
22
|
+
* Cypher version kept `originalIds`, same idea)
|
|
23
|
+
* - tags are unioned, relations are concatenated with per-target
|
|
24
|
+
* weight summing, evidence strings are joined — nothing is dropped
|
|
25
|
+
* - the absorbed record is DELETED from the store; its id in
|
|
26
|
+
* `mergedFrom` is the tombstone
|
|
27
|
+
*
|
|
28
|
+
* The applier counts what it planned as well as what it did: a merge
|
|
29
|
+
* whose keep or remove record vanished between planning and writing is
|
|
30
|
+
* skipped rather than guessed, and that skip is a value, because a pass
|
|
31
|
+
* that merged nothing because nothing matched and a pass that merged
|
|
32
|
+
* nothing because every planned record had been raced away are
|
|
33
|
+
* different outcomes.
|
|
34
|
+
*/
|
|
35
|
+
import type { MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
36
|
+
import type { MemoryStore } from './store.ts';
|
|
37
|
+
export declare const DEFAULT_CRYSTALLIZE_THRESHOLD = 0.92;
|
|
38
|
+
export declare const CONFIDENCE_BOOST = 0.05;
|
|
39
|
+
export declare const CONFIDENCE_FLOOR = 0.1;
|
|
40
|
+
export interface CrystallizeMerge {
|
|
41
|
+
keepId: string;
|
|
42
|
+
removeId: string;
|
|
43
|
+
similarity: number;
|
|
44
|
+
}
|
|
45
|
+
export interface CrystallizePlan {
|
|
46
|
+
merges: CrystallizeMerge[];
|
|
47
|
+
examined: number;
|
|
48
|
+
}
|
|
49
|
+
export interface CrystallizeOptions {
|
|
50
|
+
threshold?: number;
|
|
51
|
+
}
|
|
52
|
+
/** Decide which records to merge. Pure. */
|
|
53
|
+
export declare function planCrystallization(units: MemoryUnit[], options?: CrystallizeOptions): CrystallizePlan;
|
|
54
|
+
export interface ApplyOptions {
|
|
55
|
+
/** Returns RFC 3339 — injected, like every clock in the suite. */
|
|
56
|
+
now: () => string;
|
|
57
|
+
}
|
|
58
|
+
export interface CrystallizeOutcome {
|
|
59
|
+
/** Merges the plan asked for. */
|
|
60
|
+
planned: number;
|
|
61
|
+
/** Merges written. */
|
|
62
|
+
crystallized: number;
|
|
63
|
+
/** Planned merges whose keep or remove record was gone at write time. */
|
|
64
|
+
applicationSkips: number;
|
|
65
|
+
}
|
|
66
|
+
/** Execute a crystallization plan against a store. */
|
|
67
|
+
export declare function applyCrystallization(store: MemoryStore, plan: CrystallizePlan, options: ApplyOptions): Promise<CrystallizeOutcome>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crystallization — merge near-duplicate memories into canonical forms.
|
|
3
|
+
* Ported from memflow `CrystallizerModule`, split into the house shape:
|
|
4
|
+
* a PURE planner that decides what to merge, and an applier that executes
|
|
5
|
+
* the plan against a store. memflow fused deciding and Cypher-executing
|
|
6
|
+
* in one method, which meant the merge policy could only ever be tested
|
|
7
|
+
* against a live Memgraph; here the plan is a value you can assert on.
|
|
8
|
+
*
|
|
9
|
+
* Policy (unchanged from memflow):
|
|
10
|
+
* - pairwise similarity >= threshold (default 0.92) marks a duplicate pair
|
|
11
|
+
* - the higher-confidence record survives; ties keep the first
|
|
12
|
+
* - a record participates in at most one merge per pass — a chain
|
|
13
|
+
* A~B~C collapses over successive passes, not in one ambiguous step
|
|
14
|
+
* - only records embedded by the same identity are compared (the
|
|
15
|
+
* jarenjs rule: vectors from two models never rank against each
|
|
16
|
+
* other); the metric is `@jarenjs/core/vector`'s cosine
|
|
17
|
+
*
|
|
18
|
+
* Application details that changed, deliberately:
|
|
19
|
+
* - the survivor's confidence rises by `boost` (0.05), clamped to
|
|
20
|
+
* [floor, 1]; the floor keeps a record demotable but never erasable
|
|
21
|
+
* - the absorbed record's id lands in `mergedFrom` (provenance — the
|
|
22
|
+
* Cypher version kept `originalIds`, same idea)
|
|
23
|
+
* - tags are unioned, relations are concatenated with per-target
|
|
24
|
+
* weight summing, evidence strings are joined — nothing is dropped
|
|
25
|
+
* - the absorbed record is DELETED from the store; its id in
|
|
26
|
+
* `mergedFrom` is the tombstone
|
|
27
|
+
*
|
|
28
|
+
* The applier counts what it planned as well as what it did: a merge
|
|
29
|
+
* whose keep or remove record vanished between planning and writing is
|
|
30
|
+
* skipped rather than guessed, and that skip is a value, because a pass
|
|
31
|
+
* that merged nothing because nothing matched and a pass that merged
|
|
32
|
+
* nothing because every planned record had been raced away are
|
|
33
|
+
* different outcomes.
|
|
34
|
+
*/
|
|
35
|
+
import { cosineSimilarity } from '@jarenjs/core/vector';
|
|
36
|
+
import { sameIdentity } from '@jarenjs/ai';
|
|
37
|
+
export const DEFAULT_CRYSTALLIZE_THRESHOLD = 0.92;
|
|
38
|
+
export const CONFIDENCE_BOOST = 0.05;
|
|
39
|
+
export const CONFIDENCE_FLOOR = 0.1;
|
|
40
|
+
/** Decide which records to merge. Pure. */
|
|
41
|
+
export function planCrystallization(units, options = {}) {
|
|
42
|
+
const threshold = options.threshold ?? DEFAULT_CRYSTALLIZE_THRESHOLD;
|
|
43
|
+
const candidates = [];
|
|
44
|
+
for (let i = 0; i < units.length; i++) {
|
|
45
|
+
for (let j = i + 1; j < units.length; j++) {
|
|
46
|
+
const a = units[i];
|
|
47
|
+
const b = units[j];
|
|
48
|
+
if (!sameIdentity(a.embeddedBy, b.embeddedBy))
|
|
49
|
+
continue;
|
|
50
|
+
if (a.supersededBy || b.supersededBy)
|
|
51
|
+
continue;
|
|
52
|
+
const sim = cosineSimilarity(a.embedding, b.embedding);
|
|
53
|
+
if (sim >= threshold) {
|
|
54
|
+
const keep = (a.confidence ?? 0.5) >= (b.confidence ?? 0.5) ? a : b;
|
|
55
|
+
const remove = keep === a ? b : a;
|
|
56
|
+
candidates.push({ keepId: keep.id, removeId: remove.id, similarity: sim });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// one merge per record per pass
|
|
61
|
+
const taken = new Set();
|
|
62
|
+
const merges = [];
|
|
63
|
+
for (const m of candidates) {
|
|
64
|
+
if (taken.has(m.keepId) || taken.has(m.removeId))
|
|
65
|
+
continue;
|
|
66
|
+
taken.add(m.keepId);
|
|
67
|
+
taken.add(m.removeId);
|
|
68
|
+
merges.push(m);
|
|
69
|
+
}
|
|
70
|
+
return { merges, examined: units.length };
|
|
71
|
+
}
|
|
72
|
+
/** Execute a crystallization plan against a store. */
|
|
73
|
+
export async function applyCrystallization(store, plan, options) {
|
|
74
|
+
let crystallized = 0;
|
|
75
|
+
let applicationSkips = 0;
|
|
76
|
+
for (const merge of plan.merges) {
|
|
77
|
+
const keep = await store.get(merge.keepId);
|
|
78
|
+
const remove = await store.get(merge.removeId);
|
|
79
|
+
if (!keep || !remove) {
|
|
80
|
+
applicationSkips++;
|
|
81
|
+
continue;
|
|
82
|
+
} // a prior merge or a host raced us; skip, don't guess
|
|
83
|
+
keep.confidence = Math.min(1, Math.max(CONFIDENCE_FLOOR, (keep.confidence ?? 0.5) + CONFIDENCE_BOOST));
|
|
84
|
+
keep.at = options.now();
|
|
85
|
+
keep.mergedFrom = [...(keep.mergedFrom ?? []), remove.id, ...(remove.mergedFrom ?? [])];
|
|
86
|
+
keep.tags = [...new Set([...keep.tags, ...remove.tags])];
|
|
87
|
+
if (remove.evidence && !keep.evidence.includes(remove.evidence)) {
|
|
88
|
+
keep.evidence = `${keep.evidence}; ${remove.evidence}`;
|
|
89
|
+
}
|
|
90
|
+
if (remove.relations?.length) {
|
|
91
|
+
const relations = keep.relations ? [...keep.relations] : [];
|
|
92
|
+
for (const rel of remove.relations) {
|
|
93
|
+
const existing = relations.find((r) => r.target === rel.target && r.relType === rel.relType);
|
|
94
|
+
if (existing)
|
|
95
|
+
existing.weight = (existing.weight ?? 0.5) + (rel.weight ?? 0.5);
|
|
96
|
+
else
|
|
97
|
+
relations.push({ ...rel });
|
|
98
|
+
}
|
|
99
|
+
keep.relations = relations;
|
|
100
|
+
}
|
|
101
|
+
await store.put(keep);
|
|
102
|
+
await store.delete(remove.id);
|
|
103
|
+
crystallized++;
|
|
104
|
+
}
|
|
105
|
+
return { planned: plan.merges.length, crystallized, applicationSkips };
|
|
106
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** @tangleai/memory barrel. */
|
|
2
|
+
export { createMemoryUnitStore } from './store.ts';
|
|
3
|
+
export type { MemoryStore, MemoryUnitStoreOptions } from './store.ts';
|
|
4
|
+
export { createMemoryUnit, memoryId } from './ingest.ts';
|
|
5
|
+
export type { MemoryUnitInput } from './ingest.ts';
|
|
6
|
+
export { noveltyGate, DEFAULT_NOVELTY_THRESHOLD } from './novelty.ts';
|
|
7
|
+
export type { NoveltyOptions, NoveltyOutcome } from './novelty.ts';
|
|
8
|
+
export { planCrystallization, applyCrystallization, DEFAULT_CRYSTALLIZE_THRESHOLD, CONFIDENCE_BOOST, CONFIDENCE_FLOOR, } from './crystallize.ts';
|
|
9
|
+
export type { CrystallizeMerge, CrystallizePlan, CrystallizeOptions, CrystallizeOutcome } from './crystallize.ts';
|
|
10
|
+
export { planContradictionPairs, resolveContradictions, contradictionMessages, CONTRADICTION_VERDICT_SCHEMA, DEFAULT_CONTRADICTION_THRESHOLD, DEFAULT_MAX_PAIRS, } from './contradiction.ts';
|
|
11
|
+
export type { ContradictionVerdict, ContradictionPair, ChatMessage, ResolveOptions, ResolveOutcome, } from './contradiction.ts';
|
|
12
|
+
export { applyOutcome, outcomeAdjustment, DEFAULT_OUTCOME_OPTIONS } from './outcome.ts';
|
|
13
|
+
export type { OutcomeOptions, ApplyOutcomeResult } from './outcome.ts';
|
|
14
|
+
export { rankByEmbedding, recallByEmbedding } from './retrieval.ts';
|
|
15
|
+
export type { RankOptions, RankedMemory, RankedRecall } from './retrieval.ts';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** @tangleai/memory barrel. */
|
|
2
|
+
export { createMemoryUnitStore } from "./store.js";
|
|
3
|
+
export { createMemoryUnit, memoryId } from "./ingest.js";
|
|
4
|
+
export { noveltyGate, DEFAULT_NOVELTY_THRESHOLD } from "./novelty.js";
|
|
5
|
+
export { planCrystallization, applyCrystallization, DEFAULT_CRYSTALLIZE_THRESHOLD, CONFIDENCE_BOOST, CONFIDENCE_FLOOR, } from "./crystallize.js";
|
|
6
|
+
export { planContradictionPairs, resolveContradictions, contradictionMessages, CONTRADICTION_VERDICT_SCHEMA, DEFAULT_CONTRADICTION_THRESHOLD, DEFAULT_MAX_PAIRS, } from "./contradiction.js";
|
|
7
|
+
export { applyOutcome, outcomeAdjustment, DEFAULT_OUTCOME_OPTIONS } from "./outcome.js";
|
|
8
|
+
export { rankByEmbedding, recallByEmbedding } from "./retrieval.js";
|
package/src/ingest.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning raw observations into memory units.
|
|
3
|
+
*
|
|
4
|
+
* Ids are content-addressed — `m-<fnv1a(text)>-<length>` via the same
|
|
5
|
+
* `hashContent` the @jarenjs/ai recall slots use — so ingesting the same
|
|
6
|
+
* observation twice produces the same id and the second write is an
|
|
7
|
+
* overwrite, not a duplicate. Idempotence by construction beats
|
|
8
|
+
* deduplication by policy; the crystallizer then only has to handle
|
|
9
|
+
* NEAR-duplicates, which no hash can catch.
|
|
10
|
+
*/
|
|
11
|
+
import type { EmbeddedBy, MemoryKind, MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
12
|
+
export declare function memoryId(text: string): string;
|
|
13
|
+
export interface MemoryUnitInput {
|
|
14
|
+
text: string;
|
|
15
|
+
/** Where this came from — required at the call site, not defaulted:
|
|
16
|
+
* the jarenjs rule that an unevidenced memory is a guess starts here,
|
|
17
|
+
* where it is cheapest to enforce. */
|
|
18
|
+
evidence: string;
|
|
19
|
+
/** RFC 3339 timestamp (injected, never Date.now here). */
|
|
20
|
+
at: string;
|
|
21
|
+
tags?: string[];
|
|
22
|
+
kind?: MemoryKind;
|
|
23
|
+
/** The vector and its identity travel together, or not at all. */
|
|
24
|
+
embedding?: number[];
|
|
25
|
+
embeddedBy?: EmbeddedBy;
|
|
26
|
+
confidence?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function createMemoryUnit(input: MemoryUnitInput): MemoryUnit;
|
package/src/ingest.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning raw observations into memory units.
|
|
3
|
+
*
|
|
4
|
+
* Ids are content-addressed — `m-<fnv1a(text)>-<length>` via the same
|
|
5
|
+
* `hashContent` the @jarenjs/ai recall slots use — so ingesting the same
|
|
6
|
+
* observation twice produces the same id and the second write is an
|
|
7
|
+
* overwrite, not a duplicate. Idempotence by construction beats
|
|
8
|
+
* deduplication by policy; the crystallizer then only has to handle
|
|
9
|
+
* NEAR-duplicates, which no hash can catch.
|
|
10
|
+
*/
|
|
11
|
+
import { hashContent } from '@jarenjs/core/string';
|
|
12
|
+
export function memoryId(text) {
|
|
13
|
+
return `m-${hashContent(text)}-${text.length}`;
|
|
14
|
+
}
|
|
15
|
+
export function createMemoryUnit(input) {
|
|
16
|
+
const unit = {
|
|
17
|
+
id: memoryId(input.text),
|
|
18
|
+
text: input.text,
|
|
19
|
+
evidence: input.evidence,
|
|
20
|
+
tags: input.tags ?? [],
|
|
21
|
+
at: input.at,
|
|
22
|
+
kind: input.kind ?? 'fact',
|
|
23
|
+
};
|
|
24
|
+
if (input.embedding !== undefined && input.embeddedBy !== undefined) {
|
|
25
|
+
unit.embedding = input.embedding;
|
|
26
|
+
unit.embeddedBy = { ...input.embeddedBy };
|
|
27
|
+
}
|
|
28
|
+
if (input.confidence !== undefined)
|
|
29
|
+
unit.confidence = input.confidence;
|
|
30
|
+
return unit;
|
|
31
|
+
}
|
package/src/novelty.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Novelty gate — LightMem's Tier-1 sensory filter (arXiv 2510.18866),
|
|
3
|
+
* ported from memflow `NoveltyGateModule` as a pure function.
|
|
4
|
+
*
|
|
5
|
+
* A candidate passes if it is sufficiently DIFFERENT from every existing
|
|
6
|
+
* memory and from every candidate already admitted in this batch (the
|
|
7
|
+
* batch self-check is what stops ten copies of the same observation from
|
|
8
|
+
* all being "novel against the store"). A candidate without an embedding
|
|
9
|
+
* passes — novelty cannot be measured, and silently dropping what we
|
|
10
|
+
* cannot measure would bias the store toward whatever the embedder was
|
|
11
|
+
* given first. For the same reason a candidate is only measured against
|
|
12
|
+
* records embedded by the SAME identity (`embeddedBy`): vectors from two
|
|
13
|
+
* models compare into plausible garbage, and jarenjs's rule is that they
|
|
14
|
+
* are never compared — so a record from another embedder is, to this
|
|
15
|
+
* candidate, unmeasurable.
|
|
16
|
+
*
|
|
17
|
+
* The metric is `cosineSimilarity` from `@jarenjs/core/vector` — the one
|
|
18
|
+
* the suite ranks by, and the one under which the pipeline's normalized
|
|
19
|
+
* vectors agree with dot and Euclidean in rank anyway.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no store, no clock, no log. The caller applies the result.
|
|
22
|
+
*/
|
|
23
|
+
import type { MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
24
|
+
export declare const DEFAULT_NOVELTY_THRESHOLD = 0.75;
|
|
25
|
+
export interface NoveltyOptions {
|
|
26
|
+
threshold?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface NoveltyOutcome {
|
|
29
|
+
novel: MemoryUnit[];
|
|
30
|
+
filtered: MemoryUnit[];
|
|
31
|
+
}
|
|
32
|
+
export declare function noveltyGate(candidates: MemoryUnit[], existing: MemoryUnit[], options?: NoveltyOptions): NoveltyOutcome;
|
package/src/novelty.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Novelty gate — LightMem's Tier-1 sensory filter (arXiv 2510.18866),
|
|
3
|
+
* ported from memflow `NoveltyGateModule` as a pure function.
|
|
4
|
+
*
|
|
5
|
+
* A candidate passes if it is sufficiently DIFFERENT from every existing
|
|
6
|
+
* memory and from every candidate already admitted in this batch (the
|
|
7
|
+
* batch self-check is what stops ten copies of the same observation from
|
|
8
|
+
* all being "novel against the store"). A candidate without an embedding
|
|
9
|
+
* passes — novelty cannot be measured, and silently dropping what we
|
|
10
|
+
* cannot measure would bias the store toward whatever the embedder was
|
|
11
|
+
* given first. For the same reason a candidate is only measured against
|
|
12
|
+
* records embedded by the SAME identity (`embeddedBy`): vectors from two
|
|
13
|
+
* models compare into plausible garbage, and jarenjs's rule is that they
|
|
14
|
+
* are never compared — so a record from another embedder is, to this
|
|
15
|
+
* candidate, unmeasurable.
|
|
16
|
+
*
|
|
17
|
+
* The metric is `cosineSimilarity` from `@jarenjs/core/vector` — the one
|
|
18
|
+
* the suite ranks by, and the one under which the pipeline's normalized
|
|
19
|
+
* vectors agree with dot and Euclidean in rank anyway.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no store, no clock, no log. The caller applies the result.
|
|
22
|
+
*/
|
|
23
|
+
import { cosineSimilarity } from '@jarenjs/core/vector';
|
|
24
|
+
import { sameIdentity } from '@jarenjs/ai';
|
|
25
|
+
export const DEFAULT_NOVELTY_THRESHOLD = 0.75;
|
|
26
|
+
export function noveltyGate(candidates, existing, options = {}) {
|
|
27
|
+
const threshold = options.threshold ?? DEFAULT_NOVELTY_THRESHOLD;
|
|
28
|
+
const novel = [];
|
|
29
|
+
const filtered = [];
|
|
30
|
+
const duplicates = (unit, against) => {
|
|
31
|
+
for (const other of against) {
|
|
32
|
+
if (!sameIdentity(unit.embeddedBy, other.embeddedBy))
|
|
33
|
+
continue;
|
|
34
|
+
if (cosineSimilarity(unit.embedding, other.embedding) >= threshold)
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
for (const unit of candidates) {
|
|
40
|
+
if (unit.embedding === undefined || unit.embeddedBy === undefined) {
|
|
41
|
+
novel.push(unit);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (duplicates(unit, existing) || duplicates(unit, novel)) {
|
|
45
|
+
filtered.push(unit);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
novel.push(unit);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { novel, filtered };
|
|
52
|
+
}
|
package/src/outcome.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome learning — ported from memflow `OutcomeLearnerModule`.
|
|
3
|
+
*
|
|
4
|
+
* The one idea worth keeping from the "Karpathy loop": confidence moves
|
|
5
|
+
* on GROUND TRUTH, not on the model's opinion of itself. A host files an
|
|
6
|
+
* `OutcomeReport` after acting on recalled memories — the report carries
|
|
7
|
+
* evidence of what actually happened — and the cited memories move:
|
|
8
|
+
* success boosts, failure penalizes (harder than success boosts, which
|
|
9
|
+
* is the asymmetry that makes the loop conservative), partial nudges.
|
|
10
|
+
* Confidence is clamped to [floor, max]: a memory can be discredited
|
|
11
|
+
* down to the floor but never silently erased by arithmetic — deletion
|
|
12
|
+
* is a separate, auditable decision.
|
|
13
|
+
*/
|
|
14
|
+
import type { OutcomeReport } from '@tangleai/core/schemas/memory';
|
|
15
|
+
import type { MemoryStore } from './store.ts';
|
|
16
|
+
export interface OutcomeOptions {
|
|
17
|
+
successBoost: number;
|
|
18
|
+
failurePenalty: number;
|
|
19
|
+
partialBoost: number;
|
|
20
|
+
minConfidence: number;
|
|
21
|
+
maxConfidence: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_OUTCOME_OPTIONS: OutcomeOptions;
|
|
24
|
+
/** The signed confidence delta for an outcome. Pure. */
|
|
25
|
+
export declare function outcomeAdjustment(outcome: OutcomeReport['outcome'], options?: OutcomeOptions): number;
|
|
26
|
+
export interface ApplyOutcomeResult {
|
|
27
|
+
adjusted: number;
|
|
28
|
+
adjustment: number;
|
|
29
|
+
missing: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Apply an outcome report to the store. */
|
|
32
|
+
export declare function applyOutcome(store: MemoryStore, report: OutcomeReport, config?: {
|
|
33
|
+
options?: OutcomeOptions;
|
|
34
|
+
}): Promise<ApplyOutcomeResult>;
|
package/src/outcome.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome learning — ported from memflow `OutcomeLearnerModule`.
|
|
3
|
+
*
|
|
4
|
+
* The one idea worth keeping from the "Karpathy loop": confidence moves
|
|
5
|
+
* on GROUND TRUTH, not on the model's opinion of itself. A host files an
|
|
6
|
+
* `OutcomeReport` after acting on recalled memories — the report carries
|
|
7
|
+
* evidence of what actually happened — and the cited memories move:
|
|
8
|
+
* success boosts, failure penalizes (harder than success boosts, which
|
|
9
|
+
* is the asymmetry that makes the loop conservative), partial nudges.
|
|
10
|
+
* Confidence is clamped to [floor, max]: a memory can be discredited
|
|
11
|
+
* down to the floor but never silently erased by arithmetic — deletion
|
|
12
|
+
* is a separate, auditable decision.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_OUTCOME_OPTIONS = {
|
|
15
|
+
successBoost: 0.15,
|
|
16
|
+
failurePenalty: 0.25,
|
|
17
|
+
partialBoost: 0.05,
|
|
18
|
+
minConfidence: 0.1,
|
|
19
|
+
maxConfidence: 1.0,
|
|
20
|
+
};
|
|
21
|
+
/** The signed confidence delta for an outcome. Pure. */
|
|
22
|
+
export function outcomeAdjustment(outcome, options = DEFAULT_OUTCOME_OPTIONS) {
|
|
23
|
+
switch (outcome) {
|
|
24
|
+
case 'success': return options.successBoost;
|
|
25
|
+
case 'failure': return -options.failurePenalty;
|
|
26
|
+
case 'partial': return options.partialBoost;
|
|
27
|
+
default: return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Apply an outcome report to the store. */
|
|
31
|
+
export async function applyOutcome(store, report, config = {}) {
|
|
32
|
+
const options = config.options ?? DEFAULT_OUTCOME_OPTIONS;
|
|
33
|
+
const adjustment = outcomeAdjustment(report.outcome, options);
|
|
34
|
+
let adjusted = 0;
|
|
35
|
+
const missing = [];
|
|
36
|
+
for (const id of report.memoryIds) {
|
|
37
|
+
const unit = await store.get(id);
|
|
38
|
+
if (!unit) {
|
|
39
|
+
missing.push(id); // reported, not swallowed — a report citing a
|
|
40
|
+
continue; // vanished memory is a fact the host should see
|
|
41
|
+
}
|
|
42
|
+
unit.confidence = Math.min(options.maxConfidence, Math.max(options.minConfidence, (unit.confidence ?? 0.5) + adjustment));
|
|
43
|
+
unit.at = report.at;
|
|
44
|
+
await store.put(unit);
|
|
45
|
+
adjusted++;
|
|
46
|
+
}
|
|
47
|
+
return { adjusted, adjustment, missing };
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding-ranked retrieval over memory units.
|
|
3
|
+
*
|
|
4
|
+
* This is Tangle's ranker over its OWN store of full units, beside the
|
|
5
|
+
* @jarenjs/ai ledger's `recall({ near })` (which ranks the mirrored
|
|
6
|
+
* ledger records the same way, through the same kernels). Superseded
|
|
7
|
+
* records never surface — they exist for audit, not for recall. Records
|
|
8
|
+
* without embeddings never surface either, which is a real bias (see
|
|
9
|
+
* novelty.ts for the opposite choice on admission) — an un-embedded
|
|
10
|
+
* record can be ADMITTED honestly but cannot be RANKED honestly, so it
|
|
11
|
+
* waits for an embed pass rather than polluting the ranking with a fake
|
|
12
|
+
* score.
|
|
13
|
+
*
|
|
14
|
+
* The identity rule is the ledger's: a vector never ranks against one
|
|
15
|
+
* from another model. Given the query embedder's `identity`, only
|
|
16
|
+
* records embedded by it are ranked; the rest are reported in
|
|
17
|
+
* `skipped`, never scored and never hidden. Without an identity every
|
|
18
|
+
* embedded record is ranked — the caller has declared that it knows
|
|
19
|
+
* what it is comparing.
|
|
20
|
+
*
|
|
21
|
+
* The metric is `cosineSimilarity` from `@jarenjs/core/vector`: the one
|
|
22
|
+
* the suite ranks by, higher-is-better, 0 for a malformed pair.
|
|
23
|
+
*/
|
|
24
|
+
import { type Vector } from '@jarenjs/core/vector';
|
|
25
|
+
import type { EmbeddedBy, MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
26
|
+
export interface RankOptions {
|
|
27
|
+
k?: number;
|
|
28
|
+
minScore?: number;
|
|
29
|
+
/** The query embedder's identity; records embedded by another are skipped. */
|
|
30
|
+
identity?: EmbeddedBy;
|
|
31
|
+
}
|
|
32
|
+
export interface RankedMemory {
|
|
33
|
+
unit: MemoryUnit;
|
|
34
|
+
score: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RankedRecall {
|
|
37
|
+
/** Best first, at most `k`. */
|
|
38
|
+
ranked: RankedMemory[];
|
|
39
|
+
/** Live records that carry no vector, or a vector from another identity. */
|
|
40
|
+
skipped: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Rank the live, comparably-embedded units against a query vector and
|
|
44
|
+
* report what could not be ranked. `queryEmbedding` may be the
|
|
45
|
+
* `Float32Array` an embedder answers or the `number[]` a record stores.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recallByEmbedding(units: MemoryUnit[], queryEmbedding: Vector, options?: RankOptions): RankedRecall;
|
|
48
|
+
/** Best first, at most `k` — `recallByEmbedding` without the skip report. */
|
|
49
|
+
export declare function rankByEmbedding(units: MemoryUnit[], queryEmbedding: Vector, options?: RankOptions): RankedMemory[];
|
package/src/retrieval.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding-ranked retrieval over memory units.
|
|
3
|
+
*
|
|
4
|
+
* This is Tangle's ranker over its OWN store of full units, beside the
|
|
5
|
+
* @jarenjs/ai ledger's `recall({ near })` (which ranks the mirrored
|
|
6
|
+
* ledger records the same way, through the same kernels). Superseded
|
|
7
|
+
* records never surface — they exist for audit, not for recall. Records
|
|
8
|
+
* without embeddings never surface either, which is a real bias (see
|
|
9
|
+
* novelty.ts for the opposite choice on admission) — an un-embedded
|
|
10
|
+
* record can be ADMITTED honestly but cannot be RANKED honestly, so it
|
|
11
|
+
* waits for an embed pass rather than polluting the ranking with a fake
|
|
12
|
+
* score.
|
|
13
|
+
*
|
|
14
|
+
* The identity rule is the ledger's: a vector never ranks against one
|
|
15
|
+
* from another model. Given the query embedder's `identity`, only
|
|
16
|
+
* records embedded by it are ranked; the rest are reported in
|
|
17
|
+
* `skipped`, never scored and never hidden. Without an identity every
|
|
18
|
+
* embedded record is ranked — the caller has declared that it knows
|
|
19
|
+
* what it is comparing.
|
|
20
|
+
*
|
|
21
|
+
* The metric is `cosineSimilarity` from `@jarenjs/core/vector`: the one
|
|
22
|
+
* the suite ranks by, higher-is-better, 0 for a malformed pair.
|
|
23
|
+
*/
|
|
24
|
+
import { cosineSimilarity } from '@jarenjs/core/vector';
|
|
25
|
+
import { sameIdentity } from '@jarenjs/ai';
|
|
26
|
+
/**
|
|
27
|
+
* Rank the live, comparably-embedded units against a query vector and
|
|
28
|
+
* report what could not be ranked. `queryEmbedding` may be the
|
|
29
|
+
* `Float32Array` an embedder answers or the `number[]` a record stores.
|
|
30
|
+
*/
|
|
31
|
+
export function recallByEmbedding(units, queryEmbedding, options = {}) {
|
|
32
|
+
const k = options.k ?? 5;
|
|
33
|
+
const minScore = options.minScore ?? 0;
|
|
34
|
+
const identity = options.identity;
|
|
35
|
+
const scored = [];
|
|
36
|
+
let skipped = 0;
|
|
37
|
+
for (const unit of units) {
|
|
38
|
+
if (unit.supersededBy !== undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const comparable = unit.embedding !== undefined && unit.embeddedBy !== undefined
|
|
41
|
+
&& (identity === undefined || sameIdentity(unit.embeddedBy, identity));
|
|
42
|
+
if (!comparable) {
|
|
43
|
+
skipped++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
scored.push({ unit, score: cosineSimilarity(unit.embedding, queryEmbedding) });
|
|
47
|
+
}
|
|
48
|
+
const ranked = scored
|
|
49
|
+
.filter((s) => s.score >= minScore)
|
|
50
|
+
.sort((a, b) => b.score - a.score)
|
|
51
|
+
.slice(0, k);
|
|
52
|
+
return { ranked, skipped };
|
|
53
|
+
}
|
|
54
|
+
/** Best first, at most `k` — `recallByEmbedding` without the skip report. */
|
|
55
|
+
export function rankByEmbedding(units, queryEmbedding, options = {}) {
|
|
56
|
+
return recallByEmbedding(units, queryEmbedding, options).ranked;
|
|
57
|
+
}
|
package/src/store.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory store seam and its default in-memory implementation.
|
|
3
|
+
*
|
|
4
|
+
* The contract is four async methods — `get`, `put`, `delete`, `list` —
|
|
5
|
+
* the same injection shape as the @jarenjs/ai ledger's storage seam, one
|
|
6
|
+
* level up: this store holds validated memory UNITS, not raw strings.
|
|
7
|
+
* A host backs it with SQLite, OPFS, a graph database, whatever; the
|
|
8
|
+
* policies in this package only ever see the contract.
|
|
9
|
+
*
|
|
10
|
+
* Every `put` is validated against `MEMORY_UNIT_SCHEMA`. The rule is the
|
|
11
|
+
* ledger's rule: a malformed write is rejected at the boundary, because
|
|
12
|
+
* a store that accepts junk makes every later policy pass unsound.
|
|
13
|
+
* Records are JSON-cloned on the way in and out, so no caller holds a
|
|
14
|
+
* live reference into the store — mutation happens through `put`, or
|
|
15
|
+
* not at all.
|
|
16
|
+
*
|
|
17
|
+
* TS note: `JarenValidator<true>` (inferred from `collectErrors: true`)
|
|
18
|
+
* makes `compile()` return a collector whose outcome is a typed
|
|
19
|
+
* `{ valid, errors }` — the shape-reading dance the JS version did is
|
|
20
|
+
* carried by the published d.ts now.
|
|
21
|
+
*/
|
|
22
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
23
|
+
import { type MemoryUnit } from '@tangleai/core/schemas/memory';
|
|
24
|
+
export interface MemoryStore {
|
|
25
|
+
get(id: string): Promise<MemoryUnit | undefined>;
|
|
26
|
+
put(unit: MemoryUnit): Promise<void>;
|
|
27
|
+
delete(id: string): Promise<void>;
|
|
28
|
+
list(): Promise<MemoryUnit[]>;
|
|
29
|
+
}
|
|
30
|
+
export interface MemoryUnitStoreOptions {
|
|
31
|
+
validator?: JarenValidator<true>;
|
|
32
|
+
}
|
|
33
|
+
export declare function createMemoryUnitStore(options?: MemoryUnitStoreOptions): MemoryStore;
|
package/src/store.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory store seam and its default in-memory implementation.
|
|
3
|
+
*
|
|
4
|
+
* The contract is four async methods — `get`, `put`, `delete`, `list` —
|
|
5
|
+
* the same injection shape as the @jarenjs/ai ledger's storage seam, one
|
|
6
|
+
* level up: this store holds validated memory UNITS, not raw strings.
|
|
7
|
+
* A host backs it with SQLite, OPFS, a graph database, whatever; the
|
|
8
|
+
* policies in this package only ever see the contract.
|
|
9
|
+
*
|
|
10
|
+
* Every `put` is validated against `MEMORY_UNIT_SCHEMA`. The rule is the
|
|
11
|
+
* ledger's rule: a malformed write is rejected at the boundary, because
|
|
12
|
+
* a store that accepts junk makes every later policy pass unsound.
|
|
13
|
+
* Records are JSON-cloned on the way in and out, so no caller holds a
|
|
14
|
+
* live reference into the store — mutation happens through `put`, or
|
|
15
|
+
* not at all.
|
|
16
|
+
*
|
|
17
|
+
* TS note: `JarenValidator<true>` (inferred from `collectErrors: true`)
|
|
18
|
+
* makes `compile()` return a collector whose outcome is a typed
|
|
19
|
+
* `{ valid, errors }` — the shape-reading dance the JS version did is
|
|
20
|
+
* carried by the published d.ts now.
|
|
21
|
+
*/
|
|
22
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
23
|
+
import { callerError } from '@tangleai/core/errors';
|
|
24
|
+
import { MEMORY_UNIT_SCHEMA, MEMORY_RELATION_SCHEMA, } from '@tangleai/core/schemas/memory';
|
|
25
|
+
function clone(value) {
|
|
26
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
27
|
+
}
|
|
28
|
+
export function createMemoryUnitStore(options = {}) {
|
|
29
|
+
const validator = options.validator
|
|
30
|
+
?? new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' });
|
|
31
|
+
validator.addSchema(MEMORY_RELATION_SCHEMA);
|
|
32
|
+
const validate = validator.compile(MEMORY_UNIT_SCHEMA);
|
|
33
|
+
const units = new Map();
|
|
34
|
+
return {
|
|
35
|
+
async get(id) {
|
|
36
|
+
return clone(units.get(id));
|
|
37
|
+
},
|
|
38
|
+
async put(unit) {
|
|
39
|
+
const outcome = validate(unit);
|
|
40
|
+
if (outcome.valid !== true) {
|
|
41
|
+
throw callerError(`memory unit rejected by schema: ${JSON.stringify(outcome.errors?.[0] ?? null)}`);
|
|
42
|
+
}
|
|
43
|
+
units.set(unit.id, clone(unit));
|
|
44
|
+
},
|
|
45
|
+
async delete(id) {
|
|
46
|
+
units.delete(id);
|
|
47
|
+
},
|
|
48
|
+
async list() {
|
|
49
|
+
return [...units.values()].map((u) => clone(u));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|