@seanhogg/builderforce-memory 2026.6.31 → 2026.6.34
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/README.md +3 -1
- package/dist/cognition/EvermindCognition.d.ts +46 -7
- package/dist/cognition/EvermindCognition.d.ts.map +1 -1
- package/dist/cognition/EvermindCognition.js +95 -25
- package/dist/cognition/EvermindCognition.js.map +1 -1
- package/dist/cognition/canonicalize.d.ts +37 -0
- package/dist/cognition/canonicalize.d.ts.map +1 -0
- package/dist/cognition/canonicalize.js +58 -0
- package/dist/cognition/canonicalize.js.map +1 -0
- package/dist/cognition/index.d.ts +4 -0
- package/dist/cognition/index.d.ts.map +1 -1
- package/dist/cognition/index.js +2 -0
- package/dist/cognition/index.js.map +1 -1
- package/dist/cognition/sanitize.d.ts +58 -0
- package/dist/cognition/sanitize.d.ts.map +1 -0
- package/dist/cognition/sanitize.js +103 -0
- package/dist/cognition/sanitize.js.map +1 -0
- package/dist/distillation/DistillationEngine.d.ts +32 -1
- package/dist/distillation/DistillationEngine.d.ts.map +1 -1
- package/dist/distillation/DistillationEngine.js +53 -5
- package/dist/distillation/DistillationEngine.js.map +1 -1
- package/dist/distillation/index.d.ts +1 -1
- package/dist/distillation/index.d.ts.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/memory/MemoryStore.d.ts +35 -0
- package/dist/memory/MemoryStore.d.ts.map +1 -1
- package/dist/memory/MemoryStore.js +86 -13
- package/dist/memory/MemoryStore.js.map +1 -1
- package/dist/publish/huggingface.d.ts +77 -0
- package/dist/publish/huggingface.d.ts.map +1 -0
- package/dist/publish/huggingface.js +88 -0
- package/dist/publish/huggingface.js.map +1 -0
- package/dist/publish/index.d.ts +7 -0
- package/dist/publish/index.d.ts.map +1 -0
- package/dist/publish/index.js +6 -0
- package/dist/publish/index.js.map +1 -0
- package/dist/retrieval/HybridRetriever.d.ts +5 -0
- package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
- package/dist/retrieval/HybridRetriever.js +9 -7
- package/dist/retrieval/HybridRetriever.js.map +1 -1
- package/dist/retrieval/hnsw.d.ts +71 -0
- package/dist/retrieval/hnsw.d.ts.map +1 -0
- package/dist/retrieval/hnsw.js +201 -0
- package/dist/retrieval/hnsw.js.map +1 -0
- package/dist/retrieval/index.d.ts +2 -0
- package/dist/retrieval/index.d.ts.map +1 -1
- package/dist/retrieval/index.js +1 -0
- package/dist/retrieval/index.js.map +1 -1
- package/dist/workflow/steps.d.ts.map +1 -1
- package/dist/workflow/steps.js +73 -1
- package/dist/workflow/steps.js.map +1 -1
- package/dist/workflow/templates.d.ts.map +1 -1
- package/dist/workflow/templates.js +3 -1
- package/dist/workflow/templates.js.map +1 -1
- package/package.json +3 -3
- package/src/cognition/EvermindCognition.ts +123 -27
- package/src/cognition/canonicalize.ts +61 -0
- package/src/cognition/index.ts +12 -0
- package/src/cognition/sanitize.ts +126 -0
- package/src/distillation/DistillationEngine.ts +72 -5
- package/src/distillation/index.ts +1 -1
- package/src/index.ts +18 -1
- package/src/memory/MemoryStore.ts +107 -12
- package/src/publish/huggingface.ts +139 -0
- package/src/publish/index.ts +7 -0
- package/src/retrieval/HybridRetriever.ts +14 -7
- package/src/retrieval/hnsw.ts +242 -0
- package/src/retrieval/index.ts +3 -0
- package/src/workflow/steps.ts +93 -0
- package/src/workflow/templates.ts +3 -1
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* candidate vectors → BM25-only; no overlap → dense-only.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { cosineSimilarity } from '../similarity/index.js';
|
|
18
17
|
import { bm25Search, type Bm25Options } from './bm25.js';
|
|
19
18
|
import { reciprocalRankFusion, maximalMarginalRelevance, type MmrCandidate } from './fusion.js';
|
|
19
|
+
import { denseSearch } from './hnsw.js';
|
|
20
20
|
|
|
21
21
|
export interface RetrievalCandidate {
|
|
22
22
|
id: string;
|
|
@@ -46,6 +46,11 @@ export interface HybridRetrieveOptions {
|
|
|
46
46
|
mmrLambda?: number;
|
|
47
47
|
/** BM25 tuning. */
|
|
48
48
|
bm25?: Bm25Options;
|
|
49
|
+
/**
|
|
50
|
+
* Candidate count at/above which the dense pass uses an HNSW ANN index
|
|
51
|
+
* instead of an exact cosine scan (O(log N) vs O(N)). Default 256.
|
|
52
|
+
*/
|
|
53
|
+
annThreshold?: number;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
export interface HybridHit {
|
|
@@ -70,14 +75,16 @@ export function hybridRetrieve(
|
|
|
70
75
|
|
|
71
76
|
const byId = new Map(candidates.map(c => [c.id, c]));
|
|
72
77
|
|
|
73
|
-
// ── Dense ranking (cosine)
|
|
78
|
+
// ── Dense ranking (cosine, ANN-accelerated above the threshold) ────────────
|
|
74
79
|
let denseIds: string[] = [];
|
|
75
80
|
if (query.vector) {
|
|
76
|
-
|
|
77
|
-
.filter(c => c.vector && c.vector.length > 0)
|
|
78
|
-
.map(c => ({ id: c.id,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
const vectored = candidates
|
|
82
|
+
.filter((c): c is RetrievalCandidate & { vector: Float32Array } => !!c.vector && c.vector.length > 0)
|
|
83
|
+
.map(c => ({ id: c.id, vector: c.vector }));
|
|
84
|
+
// Rank the full vectored set (k = all) so fusion sees the complete dense
|
|
85
|
+
// ordering; denseSearch uses an exact scan under the threshold and HNSW
|
|
86
|
+
// above it, so behaviour is identical for small stores.
|
|
87
|
+
denseIds = denseSearch(query.vector, vectored, vectored.length, opts.annThreshold ?? 256).map(h => h.id);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
// ── Sparse ranking (BM25) ─────────────────────────────────────────────────
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hnsw.ts — a pure-TypeScript HNSW approximate-nearest-neighbour index.
|
|
3
|
+
*
|
|
4
|
+
* Recall over the fact store was an O(N) cosine scan per query, so the
|
|
5
|
+
* "lifetime of always-current knowledge" thesis hit a wall around ~10⁴ facts.
|
|
6
|
+
* HNSW (Hierarchical Navigable Small World) gives O(log N) expected query time
|
|
7
|
+
* with high recall, and this implementation is dependency-free so it keeps the
|
|
8
|
+
* engine/runtime zero-dep promise. For small sets an exact scan is both simpler
|
|
9
|
+
* and faster, so callers use {@link denseSearch} which switches to HNSW only
|
|
10
|
+
* above a threshold.
|
|
11
|
+
*
|
|
12
|
+
* Distance is cosine similarity (higher = closer); the graph stores `1 - cos`
|
|
13
|
+
* as the metric so "nearer" is "smaller". Construction is deterministic given a
|
|
14
|
+
* seed, so builds (and tests) reproduce exactly.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { cosineSimilarity } from '../similarity/index.js';
|
|
18
|
+
|
|
19
|
+
export interface HnswOptions {
|
|
20
|
+
/** Max neighbours per node on layers > 0. Default 16. */
|
|
21
|
+
M?: number;
|
|
22
|
+
/** Candidate-list size during construction. Default 200. */
|
|
23
|
+
efConstruction?: number;
|
|
24
|
+
/** Default candidate-list size during search. Default 50. */
|
|
25
|
+
efSearch?: number;
|
|
26
|
+
/** Deterministic seed for layer assignment. Default 0x5eed. */
|
|
27
|
+
seed?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchHit {
|
|
31
|
+
id: string;
|
|
32
|
+
/** Cosine similarity to the query (higher = closer). */
|
|
33
|
+
score: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Node {
|
|
37
|
+
id: string;
|
|
38
|
+
vector: Float32Array;
|
|
39
|
+
/** neighbours[layer] = list of node indices. */
|
|
40
|
+
neighbours: number[][];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A small deterministic LCG → float in [0,1). Mirrors the engine's SeededRng. */
|
|
44
|
+
function makeRng(seed: number): () => number {
|
|
45
|
+
let s = (seed >>> 0) || 1;
|
|
46
|
+
return () => {
|
|
47
|
+
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
|
|
48
|
+
return s / 0x1_0000_0000;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class HnswIndex {
|
|
53
|
+
private readonly _M: number;
|
|
54
|
+
private readonly _Mmax0: number;
|
|
55
|
+
private readonly _efC: number;
|
|
56
|
+
private readonly _efS: number;
|
|
57
|
+
private readonly _mL: number;
|
|
58
|
+
private readonly _rng: () => number;
|
|
59
|
+
|
|
60
|
+
private readonly _nodes: Node[] = [];
|
|
61
|
+
private readonly _idToIdx = new Map<string, number>();
|
|
62
|
+
private _entry = -1; // index of the entry-point node
|
|
63
|
+
private _maxLayer = -1;
|
|
64
|
+
|
|
65
|
+
constructor(opts: HnswOptions = {}) {
|
|
66
|
+
this._M = Math.max(2, opts.M ?? 16);
|
|
67
|
+
this._Mmax0 = this._M * 2;
|
|
68
|
+
this._efC = Math.max(this._M, opts.efConstruction ?? 200);
|
|
69
|
+
this._efS = Math.max(1, opts.efSearch ?? 50);
|
|
70
|
+
this._mL = 1 / Math.log(this._M);
|
|
71
|
+
this._rng = makeRng(opts.seed ?? 0x5eed);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get size(): number {
|
|
75
|
+
return this._nodes.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(id: string): boolean {
|
|
79
|
+
return this._idToIdx.has(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private _dist(a: Float32Array, b: Float32Array): number {
|
|
83
|
+
return 1 - cosineSimilarity(a, b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private _randomLayer(): number {
|
|
87
|
+
return Math.floor(-Math.log(Math.max(this._rng(), 1e-12)) * this._mL);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Insert a vector. Ids are unique — re-adding a known id is ignored. */
|
|
91
|
+
add(id: string, vector: Float32Array): void {
|
|
92
|
+
if (this._idToIdx.has(id)) return;
|
|
93
|
+
const layer = this._randomLayer();
|
|
94
|
+
const node: Node = { id, vector, neighbours: Array.from({ length: layer + 1 }, () => []) };
|
|
95
|
+
const idx = this._nodes.length;
|
|
96
|
+
this._nodes.push(node);
|
|
97
|
+
this._idToIdx.set(id, idx);
|
|
98
|
+
|
|
99
|
+
if (this._entry === -1) {
|
|
100
|
+
this._entry = idx;
|
|
101
|
+
this._maxLayer = layer;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Descend from the top to just above the new node's top layer.
|
|
106
|
+
let ep = this._entry;
|
|
107
|
+
for (let lc = this._maxLayer; lc > layer; lc--) {
|
|
108
|
+
ep = this._greedyClosest(vector, ep, lc);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// From the node's top layer down to 0: find neighbours and link.
|
|
112
|
+
for (let lc = Math.min(layer, this._maxLayer); lc >= 0; lc--) {
|
|
113
|
+
const candidates = this._searchLayer(vector, ep, this._efC, lc);
|
|
114
|
+
const Mmax = lc === 0 ? this._Mmax0 : this._M;
|
|
115
|
+
const selected = candidates.slice(0, this._M).map((c) => c.idx);
|
|
116
|
+
node.neighbours[lc] = selected.slice();
|
|
117
|
+
// Add reverse links, pruning each neighbour back to Mmax.
|
|
118
|
+
for (const nIdx of selected) {
|
|
119
|
+
const nb = this._nodes[nIdx]!;
|
|
120
|
+
const list = nb.neighbours[lc] ?? (nb.neighbours[lc] = []);
|
|
121
|
+
list.push(idx);
|
|
122
|
+
if (list.length > Mmax) this._prune(nb, lc, Mmax);
|
|
123
|
+
}
|
|
124
|
+
ep = candidates[0]?.idx ?? ep;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (layer > this._maxLayer) {
|
|
128
|
+
this._maxLayer = layer;
|
|
129
|
+
this._entry = idx;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Top-k nearest by cosine similarity (descending). */
|
|
134
|
+
search(query: Float32Array, k: number, ef = this._efS): SearchHit[] {
|
|
135
|
+
if (this._entry === -1 || k <= 0) return [];
|
|
136
|
+
let ep = this._entry;
|
|
137
|
+
for (let lc = this._maxLayer; lc > 0; lc--) {
|
|
138
|
+
ep = this._greedyClosest(query, ep, lc);
|
|
139
|
+
}
|
|
140
|
+
const found = this._searchLayer(query, ep, Math.max(ef, k), 0);
|
|
141
|
+
return found.slice(0, k).map((c) => ({ id: this._nodes[c.idx]!.id, score: 1 - c.dist }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clear(): void {
|
|
145
|
+
this._nodes.length = 0;
|
|
146
|
+
this._idToIdx.clear();
|
|
147
|
+
this._entry = -1;
|
|
148
|
+
this._maxLayer = -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** Greedy hill-climb to the single closest node to `q` on layer `lc`. */
|
|
154
|
+
private _greedyClosest(q: Float32Array, start: number, lc: number): number {
|
|
155
|
+
let best = start;
|
|
156
|
+
let bestDist = this._dist(q, this._nodes[best]!.vector);
|
|
157
|
+
let improved = true;
|
|
158
|
+
while (improved) {
|
|
159
|
+
improved = false;
|
|
160
|
+
for (const nIdx of this._nodes[best]!.neighbours[lc] ?? []) {
|
|
161
|
+
const d = this._dist(q, this._nodes[nIdx]!.vector);
|
|
162
|
+
if (d < bestDist) {
|
|
163
|
+
bestDist = d;
|
|
164
|
+
best = nIdx;
|
|
165
|
+
improved = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return best;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ef-search on a single layer: returns candidates sorted nearest-first.
|
|
174
|
+
* A bounded best-first expansion (the standard HNSW layer search).
|
|
175
|
+
*/
|
|
176
|
+
private _searchLayer(q: Float32Array, entry: number, ef: number, lc: number): Array<{ idx: number; dist: number }> {
|
|
177
|
+
const visited = new Set<number>([entry]);
|
|
178
|
+
const entryDist = this._dist(q, this._nodes[entry]!.vector);
|
|
179
|
+
// `candidates` is a min-heap-ish frontier; `results` keeps the best ef.
|
|
180
|
+
const candidates: Array<{ idx: number; dist: number }> = [{ idx: entry, dist: entryDist }];
|
|
181
|
+
const results: Array<{ idx: number; dist: number }> = [{ idx: entry, dist: entryDist }];
|
|
182
|
+
|
|
183
|
+
while (candidates.length > 0) {
|
|
184
|
+
// Pop nearest candidate.
|
|
185
|
+
let ci = 0;
|
|
186
|
+
for (let i = 1; i < candidates.length; i++) if (candidates[i]!.dist < candidates[ci]!.dist) ci = i;
|
|
187
|
+
const cur = candidates.splice(ci, 1)[0]!;
|
|
188
|
+
// Farthest in results.
|
|
189
|
+
const worst = results.reduce((m, r) => (r.dist > m.dist ? r : m), results[0]!);
|
|
190
|
+
if (cur.dist > worst.dist && results.length >= ef) break;
|
|
191
|
+
|
|
192
|
+
for (const nIdx of this._nodes[cur.idx]!.neighbours[lc] ?? []) {
|
|
193
|
+
if (visited.has(nIdx)) continue;
|
|
194
|
+
visited.add(nIdx);
|
|
195
|
+
const d = this._dist(q, this._nodes[nIdx]!.vector);
|
|
196
|
+
const farthest = results.reduce((m, r) => (r.dist > m.dist ? r : m), results[0]!);
|
|
197
|
+
if (results.length < ef || d < farthest.dist) {
|
|
198
|
+
candidates.push({ idx: nIdx, dist: d });
|
|
199
|
+
results.push({ idx: nIdx, dist: d });
|
|
200
|
+
if (results.length > ef) {
|
|
201
|
+
// Drop the current farthest.
|
|
202
|
+
let wi = 0;
|
|
203
|
+
for (let i = 1; i < results.length; i++) if (results[i]!.dist > results[wi]!.dist) wi = i;
|
|
204
|
+
results.splice(wi, 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return results.sort((a, b) => a.dist - b.dist);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Keep only the `Mmax` closest neighbours of `node` on layer `lc`. */
|
|
213
|
+
private _prune(node: Node, lc: number, Mmax: number): void {
|
|
214
|
+
const list = node.neighbours[lc]!;
|
|
215
|
+
list.sort((a, b) => this._dist(node.vector, this._nodes[a]!.vector) - this._dist(node.vector, this._nodes[b]!.vector));
|
|
216
|
+
node.neighbours[lc] = list.slice(0, Mmax);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Dense KNN with an exact-scan fast path: below `threshold` candidates a linear
|
|
222
|
+
* cosine scan is simpler and faster; at/above it, build an HNSW index and query
|
|
223
|
+
* it. Returned hits are cosine-similarity descending. Pure given its inputs.
|
|
224
|
+
*/
|
|
225
|
+
export function denseSearch(
|
|
226
|
+
query: Float32Array,
|
|
227
|
+
items: Array<{ id: string; vector: Float32Array }>,
|
|
228
|
+
k: number,
|
|
229
|
+
threshold = 256,
|
|
230
|
+
hnsw?: HnswOptions,
|
|
231
|
+
): SearchHit[] {
|
|
232
|
+
if (items.length === 0 || k <= 0) return [];
|
|
233
|
+
if (items.length < threshold) {
|
|
234
|
+
return items
|
|
235
|
+
.map((it) => ({ id: it.id, score: cosineSimilarity(query, it.vector) }))
|
|
236
|
+
.sort((a, b) => b.score - a.score)
|
|
237
|
+
.slice(0, k);
|
|
238
|
+
}
|
|
239
|
+
const index = new HnswIndex(hnsw);
|
|
240
|
+
for (const it of items) index.add(it.id, it.vector);
|
|
241
|
+
return index.search(query, k);
|
|
242
|
+
}
|
package/src/retrieval/index.ts
CHANGED
package/src/workflow/steps.ts
CHANGED
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
EvermindLMTrainer,
|
|
16
16
|
BPETokenizer,
|
|
17
17
|
EvermindModelPackage,
|
|
18
|
+
exportEvermind,
|
|
19
|
+
benchmarkText,
|
|
20
|
+
type ExportFormat,
|
|
21
|
+
type BenchmarkReport,
|
|
18
22
|
} from "@seanhogg/builderforce-memory-engine";
|
|
19
23
|
import { EvermindCognition } from "../cognition/index.js";
|
|
20
24
|
import { hybridRetrieve, chunkText } from "../retrieval/index.js";
|
|
@@ -39,6 +43,15 @@ function pNum(cfg: WorkflowStepConfig, key: string, def: number): number {
|
|
|
39
43
|
const v = cfg.params?.[key];
|
|
40
44
|
return typeof v === "number" && Number.isFinite(v) ? v : def;
|
|
41
45
|
}
|
|
46
|
+
function pBool(cfg: WorkflowStepConfig, key: string, def: boolean): boolean {
|
|
47
|
+
const v = cfg.params?.[key];
|
|
48
|
+
return typeof v === "boolean" ? v : def;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Byte length of an emitted export file (text or binary). */
|
|
52
|
+
function fileBytes(data: Uint8Array | string): number {
|
|
53
|
+
return typeof data === "string" ? new TextEncoder().encode(data).length : data.length;
|
|
54
|
+
}
|
|
42
55
|
|
|
43
56
|
function mkStep(cfg: WorkflowStepConfig, layer: string, defaultLabel: string, run: StackStep["run"]): StackStep {
|
|
44
57
|
return { id: cfg.id, layer, label: cfg.label ?? defaultLabel, run };
|
|
@@ -214,6 +227,50 @@ export const BUILTIN_STEPS: Record<string, Registered> = {
|
|
|
214
227
|
return `sample: "${out.slice(0, 40)}"`;
|
|
215
228
|
}),
|
|
216
229
|
},
|
|
230
|
+
benchmark: {
|
|
231
|
+
info: {
|
|
232
|
+
type: "benchmark",
|
|
233
|
+
layer: "BUILD",
|
|
234
|
+
label: "Benchmark",
|
|
235
|
+
description:
|
|
236
|
+
"Score the trained model on held-out text — perplexity, bits/token, top-1/top-k accuracy, throughput (params: evalCorpus, topK, maxPerplexity, minTop1)",
|
|
237
|
+
},
|
|
238
|
+
factory: (cfg) =>
|
|
239
|
+
mkStep(cfg, "BUILD", "Benchmark model (held-out)", async (ctx) => {
|
|
240
|
+
const model = ctx.bag.model as EvermindLM | undefined;
|
|
241
|
+
const tok = ctx.bag.tokenizer as BPETokenizer | undefined;
|
|
242
|
+
if (!model || !tok) throw new Error("no trained model — add 'train-model' first");
|
|
243
|
+
// Score on held-out text: the eval corpus must be text the model did not
|
|
244
|
+
// train on, otherwise perplexity is meaningless. Default to a small
|
|
245
|
+
// held-out probe distinct from the training KNOWLEDGE corpus.
|
|
246
|
+
const evalCorpus = pStr(
|
|
247
|
+
cfg,
|
|
248
|
+
"evalCorpus",
|
|
249
|
+
"Agents recall facts and act on them. The planning loop retrieves context before generating.",
|
|
250
|
+
);
|
|
251
|
+
const topK = pNum(cfg, "topK", 5);
|
|
252
|
+
const report: BenchmarkReport = benchmarkText(model, tok, evalCorpus, { topK });
|
|
253
|
+
if (report.tokens === 0) {
|
|
254
|
+
throw new Error("eval corpus produced no scorable tokens — provide longer held-out text");
|
|
255
|
+
}
|
|
256
|
+
ctx.bag.benchmark = report;
|
|
257
|
+
|
|
258
|
+
// Optional quality gates — only enforced when a threshold is configured.
|
|
259
|
+
const maxPerplexity = pNum(cfg, "maxPerplexity", 0);
|
|
260
|
+
if (maxPerplexity > 0 && report.perplexity > maxPerplexity) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`perplexity ${report.perplexity.toFixed(2)} exceeds max ${maxPerplexity} (model underfit — raise epochs/lr or enlarge the corpus)`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const minTop1 = pNum(cfg, "minTop1", 0);
|
|
266
|
+
if (minTop1 > 0 && report.top1Accuracy < minTop1) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`top-1 accuracy ${(report.top1Accuracy * 100).toFixed(1)}% below min ${(minTop1 * 100).toFixed(0)}%`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return `ppl ${report.perplexity.toFixed(2)}, ${report.bitsPerToken.toFixed(2)} bits/tok, top1 ${(report.top1Accuracy * 100).toFixed(0)}%, top${report.topK} ${(report.topKAccuracy * 100).toFixed(0)}% over ${report.tokens} tok`;
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
217
274
|
"dataset-quality": {
|
|
218
275
|
info: { type: "dataset-quality", layer: "BUILD", label: "Dataset quality", description: "Validate the corpus is trainable before spending epochs (params: minSequences, minWords, maxDuplicateRatio)" },
|
|
219
276
|
factory: (cfg) =>
|
|
@@ -322,4 +379,40 @@ export const BUILTIN_STEPS: Record<string, Registered> = {
|
|
|
322
379
|
return `packaged ${blob.byteLength} bytes${tok ? " + tokenizer" : ""}`;
|
|
323
380
|
}),
|
|
324
381
|
},
|
|
382
|
+
export: {
|
|
383
|
+
info: {
|
|
384
|
+
type: "export",
|
|
385
|
+
layer: "BUILD",
|
|
386
|
+
label: "Export",
|
|
387
|
+
description:
|
|
388
|
+
"Export the trained model to a publishable format (params: format=huggingface|onnx|safetensors|gguf, name, version, license, fp16)",
|
|
389
|
+
},
|
|
390
|
+
factory: (cfg) =>
|
|
391
|
+
mkStep(cfg, "BUILD", "Export model (publishable)", async (ctx) => {
|
|
392
|
+
const model = ctx.bag.model as EvermindLM | undefined;
|
|
393
|
+
if (!model) throw new Error("no trained model — add 'train-model' first");
|
|
394
|
+
const tok = ctx.bag.tokenizer as BPETokenizer | undefined;
|
|
395
|
+
const format = pStr(cfg, "format", "huggingface") as ExportFormat;
|
|
396
|
+
if (format === "huggingface" && !tok) {
|
|
397
|
+
throw new Error("huggingface export needs a tokenizer — add 'train-tokenizer' first");
|
|
398
|
+
}
|
|
399
|
+
const result = exportEvermind(
|
|
400
|
+
model,
|
|
401
|
+
format,
|
|
402
|
+
{
|
|
403
|
+
name: pStr(cfg, "name", "Evermind"),
|
|
404
|
+
version: pStr(cfg, "version", "1.0.0"),
|
|
405
|
+
license: pStr(cfg, "license", "mit"),
|
|
406
|
+
author: pStr(cfg, "author", ""),
|
|
407
|
+
description: pStr(cfg, "description", "workflow-built Evermind model"),
|
|
408
|
+
fp16: pBool(cfg, "fp16", false),
|
|
409
|
+
},
|
|
410
|
+
tok,
|
|
411
|
+
);
|
|
412
|
+
const total = result.files.reduce((n, f) => n + fileBytes(f.data), 0);
|
|
413
|
+
// Land the file set as a workflow OUTPUT so a publish step / portal can ship it.
|
|
414
|
+
ctx.artifacts.export = { format, files: result.files, paramCount: result.paramCount };
|
|
415
|
+
return `exported ${format}: ${result.files.length} file(s), ${total} bytes, ${result.paramCount} params`;
|
|
416
|
+
}),
|
|
417
|
+
},
|
|
325
418
|
};
|
|
@@ -55,7 +55,7 @@ const DEFAULT_CORPUS = [
|
|
|
55
55
|
export const TRAIN_LLM: WorkflowConfig = {
|
|
56
56
|
id: "train-llm",
|
|
57
57
|
name: "Create an LLM",
|
|
58
|
-
description: "Train a custom EvermindLM on your corpus, validate it end to end,
|
|
58
|
+
description: "Train a custom EvermindLM on your corpus, validate it end to end, package it as a portable .evermind artifact, and export a publishable Hugging Face repo (safetensors + ONNX + GGUF).",
|
|
59
59
|
steps: [
|
|
60
60
|
{ id: "tok", type: "train-tokenizer", params: { corpus: DEFAULT_CORPUS, numMerges: 120 } },
|
|
61
61
|
{ id: "data", type: "dataset-quality" },
|
|
@@ -63,7 +63,9 @@ export const TRAIN_LLM: WorkflowConfig = {
|
|
|
63
63
|
{ id: "converge", type: "convergence" },
|
|
64
64
|
{ id: "eval", type: "evaluate" },
|
|
65
65
|
{ id: "gen", type: "generate-check" },
|
|
66
|
+
{ id: "bench", type: "benchmark" },
|
|
66
67
|
{ id: "pkg", type: "roundtrip", params: { name: "my-llm" } },
|
|
68
|
+
{ id: "export", type: "export", params: { format: "huggingface", name: "my-llm", version: "1.0.0" } },
|
|
67
69
|
],
|
|
68
70
|
};
|
|
69
71
|
|