pi-mega-compact 0.20.43 → 0.20.45

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.
Files changed (46) hide show
  1. package/assets/vector-cortex/encoder-v1/manifest.json +1 -1
  2. package/assets/vector-cortex/encoder-v1/model-card.json +1 -1
  3. package/assets/vector-cortex/encoder-v1/model.onnx +0 -0
  4. package/assets/vector-cortex/encoder-v1/tokenizer.json +1 -1
  5. package/dist/config/vector-cortex-enc0b.js +34 -0
  6. package/dist/config/vector-cortex-enc0c.js +35 -0
  7. package/dist/config/vector-cortex.js +2 -1
  8. package/dist/config.js +1 -1
  9. package/dist/extensions/dashboard-server/routes-rag-settings-vector-cortex.js +2 -0
  10. package/dist/src/config/vector-cortex-enc0b.js +34 -0
  11. package/dist/src/config/vector-cortex-enc0c.js +35 -0
  12. package/dist/src/config/vector-cortex.js +2 -1
  13. package/dist/src/config.js +1 -1
  14. package/dist/src/vector-cortex/encoder/emit.js +2 -0
  15. package/dist/src/vector-cortex/encoder/encoder-onnx-dispatch.js +58 -0
  16. package/dist/src/vector-cortex/encoder/heads-candidate.js +127 -0
  17. package/dist/src/vector-cortex/encoder/heads.js +4 -0
  18. package/dist/src/vector-cortex/encoder/onnx.js +147 -0
  19. package/dist/src/vector-cortex/encoder/runtime-wasm.js +17 -4
  20. package/dist/src/vector-cortex/encoder/runtime.js +17 -7
  21. package/dist/src/vector-cortex/encoder/types-vc2c.js +9 -0
  22. package/dist/src/vector-cortex/encoder/types.js +1 -1
  23. package/dist/vector-cortex/encoder/emit.js +2 -0
  24. package/dist/vector-cortex/encoder/encoder-onnx-dispatch.js +58 -0
  25. package/dist/vector-cortex/encoder/heads-candidate.js +127 -0
  26. package/dist/vector-cortex/encoder/heads.js +4 -0
  27. package/dist/vector-cortex/encoder/onnx.js +147 -0
  28. package/dist/vector-cortex/encoder/runtime-wasm.js +17 -4
  29. package/dist/vector-cortex/encoder/runtime.js +17 -7
  30. package/dist/vector-cortex/encoder/types-vc2c.js +9 -0
  31. package/dist/vector-cortex/encoder/types.js +1 -1
  32. package/extensions/dashboard-server/routes-rag-settings-vector-cortex.ts +12 -0
  33. package/package.json +3 -2
  34. package/src/config/vector-cortex-enc0b.ts +36 -0
  35. package/src/config/vector-cortex-enc0c.ts +37 -0
  36. package/src/config/vector-cortex.ts +2 -2
  37. package/src/config.ts +2 -0
  38. package/src/vector-cortex/encoder/emit.ts +5 -1
  39. package/src/vector-cortex/encoder/encoder-onnx-dispatch.ts +76 -0
  40. package/src/vector-cortex/encoder/heads-candidate.ts +148 -0
  41. package/src/vector-cortex/encoder/heads.ts +14 -0
  42. package/src/vector-cortex/encoder/onnx.ts +198 -0
  43. package/src/vector-cortex/encoder/runtime-wasm.ts +20 -7
  44. package/src/vector-cortex/encoder/runtime.ts +27 -9
  45. package/src/vector-cortex/encoder/types-vc2c.ts +10 -0
  46. package/src/vector-cortex/encoder/types.ts +1 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * config/vector-cortex-enc0c.ts — ENC-0c five-head supervision transfer flag.
3
+ *
4
+ * Extracted from vector-cortex.ts so that file stays under the 300-line soft
5
+ * limit (soft-as-hard gate), exactly as vector-cortex-enc0a.ts / enc0b.ts and
6
+ * the VC8C/VC9A-D/ML5A-E/DEDUP_ATTR siblings were. vector-cortex.ts re-exports
7
+ * the flag below and root src/config.ts re-exports it, so no consumer import
8
+ * path changes.
9
+ *
10
+ * ENC-0c trains the five real heads onto the frozen ENC-0b bge-small trunk via
11
+ * supervision transfer, and stages a qualified candidate under
12
+ * ~/.pi/mega-compact-encoder/candidates/ (only when the developer trains it —
13
+ * the extension never stages one by itself). When the flag is ON and a
14
+ * qualified candidate exists, `loadHeadCandidate` (encoder/heads.ts seam) serves
15
+ * the trained head weights; when the flag is OFF (MEGACOMPACT_ENC_0C=0) or no
16
+ * candidate is staged, the heads keep serving the ENC-0b survivor exactly —
17
+ * byte-identical, no weight change. The flag gates ONLY the candidate-load seam;
18
+ * the survivor path is untouched.
19
+ *
20
+ * The split is purely mechanical: ENC_0C_ENABLED follows ENC_0B_ENABLED in name,
21
+ * semantics, and default, and vector-cortex.ts re-exports it so every existing
22
+ * `from "./config/vector-cortex.js"` import keeps resolving unchanged.
23
+ *
24
+ * Pi-agnostic, dependency-free (PREVENT-PI-004 / PREVENT-011).
25
+ */
26
+
27
+ import { sprintFlag } from "./vector-cortex-flag.js";
28
+
29
+ /**
30
+ * ENC-0c — five-head supervision transfer on the frozen bge-small trunk.
31
+ * Default ON. `MEGACOMPACT_ENC_0C=0` disables and is byte-identical to the
32
+ * predecessor (ENC-0b): no head candidate is loaded and the heads keep serving
33
+ * the ENC-0b survivor defaults exactly as before. This flag MUST also be a
34
+ * dashboard SETTINGS toggle (visible in config UI, never in EXCLUDED_SETTINGS),
35
+ * mirroring ENC_0A and ENC_0B.
36
+ */
37
+ export const ENC_0C_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_ENC_0C");
@@ -267,7 +267,6 @@ export const VC8A_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC8A");
267
267
  */
268
268
  export const VC8B_ENABLED = (): boolean => sprintFlag("MEGACOMPACT_VC8B");
269
269
 
270
- // VC8C extracted to vector-cortex-vc8c.ts; re-exported so existing imports keep resolving.
271
270
  export { VC8C_ENABLED } from "./vector-cortex-vc8c.js";
272
271
  export { VC9A_ENABLED } from "./vector-cortex-vc9a.js";
273
272
  export { VC9B_ENABLED } from "./vector-cortex-vc9b.js";
@@ -281,7 +280,8 @@ export { ML5D_ENABLED } from "./vector-cortex-ml5d.js";
281
280
  export { ML5E_ENABLED } from "./vector-cortex-ml5e.js";
282
281
  export { DEDUP_ATTR_ENABLED } from "./vector-cortex-dedup-attr.js";
283
282
  export { ENC_0A_ENABLED } from "./vector-cortex-enc0a.js";
284
-
283
+ export { ENC_0B_ENABLED } from "./vector-cortex-enc0b.js";
284
+ export { ENC_0C_ENABLED } from "./vector-cortex-enc0c.js";
285
285
  // Breaker constants (TRIAD_RESILIENCE.md §breaker) extracted to vector-cortex-breakers.ts.
286
286
  export {
287
287
  BREAKER_WINDOW_MS,
package/src/config.ts CHANGED
@@ -190,6 +190,8 @@ export {
190
190
  ML5E_ENABLED,
191
191
  DEDUP_ATTR_ENABLED,
192
192
  ENC_0A_ENABLED,
193
+ ENC_0B_ENABLED,
194
+ ENC_0C_ENABLED,
193
195
  BREAKER_WINDOW_MS,
194
196
  BREAKER_MIN_ATTEMPTS,
195
197
  BREAKER_PERF_FAILURES,
@@ -17,16 +17,19 @@ import { VC2A_ENABLED } from "../../config/vector-cortex.js";
17
17
 
18
18
  export type EncoderEmit = (event: string, fields: Record<string, unknown>) => void;
19
19
 
20
- /** The two-event reporter surface consumed by the runtime seams. */
20
+ /** The reporter surface consumed by the runtime + ONNX seams. */
21
21
  export interface EncoderReporter {
22
22
  readonly assetVerified: (fields: Record<string, unknown>) => void;
23
23
  readonly runtimeDemoted: (fields: Record<string, unknown>) => void;
24
+ /** ENC-0b: fires when a real ONNX InferenceSession is successfully created. */
25
+ readonly onnxSessionLoaded: (fields: Record<string, unknown>) => void;
24
26
  }
25
27
 
26
28
  /** A flag-gated no-op reporter (zero emissions, default when none injected). */
27
29
  export const NOOP_ENCODER_REPORTER: EncoderReporter = {
28
30
  assetVerified: () => {},
29
31
  runtimeDemoted: () => {},
32
+ onnxSessionLoaded: () => {},
30
33
  };
31
34
 
32
35
  /**
@@ -47,5 +50,6 @@ export function createEncoderReporter(emit?: EncoderEmit): EncoderReporter {
47
50
  return {
48
51
  assetVerified: (fields) => fire("vector_cortex_encoder_asset_verified", fields),
49
52
  runtimeDemoted: (fields) => fire("vector_cortex_encoder_runtime_demoted", fields),
53
+ onnxSessionLoaded: (fields) => fire("vector_cortex_encoder_onnx_loaded", fields),
50
54
  };
51
55
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * encoder-onnx-dispatch.ts — ENC-0b ONNX dispatch: session lifecycle glue.
3
+ *
4
+ * Extracted from runtime.ts so runtime.ts stays under the 300-line soft limit.
5
+ * Encapsulates the ENC-0b real ONNX session build during load().
6
+ *
7
+ * IMPORTANT (interface constraint): EncoderRuntime.load() and infer() are
8
+ * SYNCHRONOUS — the router (router.ts:121-132) and all acceptance tests call
9
+ * them synchronously. ONNX session creation is ASYNC (WASM init). ENC-0b
10
+ * therefore expose the ONNX session as a SEPARATE async verification: load()
11
+ * fires-and-forgets the session build, verifySession() awaits it for tests,
12
+ * and infer() continues serving the LCG placeholder until the router is wired
13
+ * for async inference (ENC-0c scope). This preserves the sync contract while
14
+ * proving the real ONNX pipeline work end-to-end.
15
+ *
16
+ * ENC-0b gate: when MEGACOMPACT_ENC_0B is OFF, none of this code runs —
17
+ * the LCG stub serves mode A byte-identical to the predecessor.
18
+ *
19
+ * Pi-agnostic (PREVENT-PI-004 / PREVENT-011).
20
+ */
21
+
22
+ import { ENC_0B_ENABLED } from "../../config/vector-cortex.js";
23
+ import { buildOnnxSession, type OnnxInferenceSession } from "./onnx.js";
24
+ import type { EncoderReporter } from "./emit.js";
25
+ import type { ModelManifestV1 } from "./types.js";
26
+
27
+ /** State held by the runtime for the ENC-0b ONNX dispatch. */
28
+ export interface OnnxDispatchState {
29
+ /** The built ONNX session (null when not built, flag off, or build failed). */
30
+ readonly session: OnnxInferenceSession | null;
31
+ /** Resolved when the session build attempt settles (ok or fail). */
32
+ readonly ready: Promise<void>;
33
+ }
34
+
35
+ /** No-op state when ENC-0b is off or session build not attempted. */
36
+ export const NO_ONNX: OnnxDispatchState = { session: null, ready: Promise.resolve() };
37
+
38
+ /**
39
+ * Attempt a real ONNX session build during load(). Fire-and-forget: returns
40
+ * immediately with a state whose `ready` Promise resolves once the async
41
+ * build settles. On failure the state's `session` stays null.
42
+ */
43
+ export function tryBuildOnnx(
44
+ assetDir: string,
45
+ manifest: ModelManifestV1,
46
+ reporter: EncoderReporter,
47
+ allocatedBytes: number,
48
+ ): OnnxDispatchState {
49
+ if (!ENC_0B_ENABLED()) return NO_ONNX;
50
+ let session: OnnxInferenceSession | null = null;
51
+ const ready = buildOnnxSession(assetDir, manifest, reporter, allocatedBytes)
52
+ .then((result) => { if (result.ok) session = result.session; })
53
+ .catch(() => {});
54
+ // Return a state whose `session` getter reads the mutable binding after
55
+ // ready resolves (the getter runs at test assertion time, not at load()).
56
+ return { get session() { return session; }, ready };
57
+ }
58
+
59
+ /**
60
+ * Await the ONNX session build and verify it with a real inference.
61
+ * Returns the embedding on success, null on any failure.
62
+ * Called by tests and the ENC-0b acceptance aggregator, not by production
63
+ * infer().
64
+ */
65
+ export async function verifyOnnxSession(
66
+ state: OnnxDispatchState,
67
+ tokens: readonly number[],
68
+ ): Promise<Float32Array | null> {
69
+ await state.ready;
70
+ if (!state.session) return null;
71
+ try {
72
+ return await state.session.infer(tokens);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * encoder/heads-candidate.ts — ENC-0c head-candidate load seam (delegate impl).
3
+ *
4
+ * Loads/validates a `head-candidate-v1` five-head candidate staged under
5
+ * `~/.pi/mega-compact-encoder/candidates/<version>/` after training on the
6
+ * frozen ENC-0b bge-small trunk. Flag-off / absent / malformed / wrong dims /
7
+ * non-finite / digest / trunk mismatch each return null or {ok:false} — a bad
8
+ * candidate is NEVER force-loaded; the runtime keeps the ENC-0b survivor
9
+ * byte-identical. No `any` (PREVENT-011), local-only, structured logging.
10
+ */
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { ENC_0C_ENABLED } from "../../config/vector-cortex.js";
17
+ import { ENCODER_HEAD_DIMS, ENCODER_HEAD_ORDER } from "./types.js";
18
+ import type { EncoderHeadName, ModelManifestV1 } from "./types.js";
19
+
20
+ export const HEAD_CANDIDATE_SCHEMA = "head-candidate-v1" as const;
21
+ const MANIFEST_FILE = "manifest.json";
22
+
23
+ export interface HeadCandidateHeadDigest {
24
+ readonly name: EncoderHeadName;
25
+ readonly dim: number;
26
+ readonly sha256: string;
27
+ readonly bytes: number;
28
+ }
29
+
30
+ /** On-disk candidate contract; `trunkDigest` pins the frozen trunk. */
31
+ export interface HeadCandidateManifest {
32
+ readonly schema: typeof HEAD_CANDIDATE_SCHEMA;
33
+ readonly version: string;
34
+ readonly trunkDigest: string;
35
+ readonly heads: readonly HeadCandidateHeadDigest[];
36
+ readonly totalBytes: number;
37
+ }
38
+
39
+ /** Loaded candidate: parsed Float32Array weights per head. */
40
+ export interface HeadCandidate {
41
+ readonly schema: typeof HEAD_CANDIDATE_SCHEMA;
42
+ readonly version: string;
43
+ readonly trunkDigest: string;
44
+ readonly dims: Readonly<Record<EncoderHeadName, number>>;
45
+ readonly weights: Readonly<Record<EncoderHeadName, Float32Array>>;
46
+ readonly digests: Readonly<Record<EncoderHeadName, string>>;
47
+ }
48
+
49
+ export type HeadCandidateValidation = { readonly ok: true } | { readonly ok: false; readonly code: string };
50
+
51
+ export const HEAD_CANDIDATE_FAIL = {
52
+ INVALID: "ENC0C_CANDIDATE_INVALID",
53
+ TRUNK_MISMATCH: "ENC0C_TRUNK_MISMATCH",
54
+ DIM_MISMATCH: "ENC0C_DIM_MISMATCH",
55
+ NON_FINITE: "ENC0C_NON_FINITE",
56
+ DIGEST_MISMATCH: "ENC0C_DIGEST_MISMATCH",
57
+ } as const;
58
+
59
+ function sha256(buf: Uint8Array): string { return createHash("sha256").update(buf).digest("hex"); }
60
+ function finiteDim(values: Float32Array, dim: number): boolean {
61
+ if (values.length !== dim) return false;
62
+ for (const v of values) if (!Number.isFinite(v)) return false;
63
+ return true;
64
+ }
65
+
66
+ /** Parse the candidate manifest object, or null if invalid. */
67
+ type ParsedManifest = { version: string; trunkDigest: string; heads: HeadCandidateHeadDigest[] };
68
+ function parseManifest(m: Record<string, unknown> | null): ParsedManifest | null {
69
+ if (!m || m["schema"] !== HEAD_CANDIDATE_SCHEMA) return null;
70
+ const version = typeof m["version"] === "string" ? m["version"] : "";
71
+ const trunkDigest = typeof m["trunkDigest"] === "string" ? m["trunkDigest"] : "";
72
+ if (!version || !trunkDigest || !Array.isArray(m["heads"])) return null;
73
+ const heads: HeadCandidateHeadDigest[] = [];
74
+ for (const item of m["heads"] as unknown[]) {
75
+ const rec = item as Record<string, unknown> | null;
76
+ if (!rec || typeof rec !== "object") return null;
77
+ const name = rec["name"];
78
+ if (typeof name !== "string" || !ENCODER_HEAD_ORDER.includes(name as EncoderHeadName)) return null;
79
+ heads.push({
80
+ name: name as EncoderHeadName,
81
+ dim: Number(rec["dim"] ?? 0),
82
+ sha256: typeof rec["sha256"] === "string" ? rec["sha256"] : "",
83
+ bytes: Number(rec["bytes"] ?? 0),
84
+ });
85
+ }
86
+ return { version, trunkDigest, heads };
87
+ }
88
+
89
+ /** All 5 dims match, weights finite, digests hold. */
90
+ export function validateHeadCandidate(candidate: HeadCandidate): HeadCandidateValidation {
91
+ for (const h of ENCODER_HEAD_ORDER) {
92
+ const dim = candidate.dims[h];
93
+ if (dim !== ENCODER_HEAD_DIMS[h]) return { ok: false, code: HEAD_CANDIDATE_FAIL.DIM_MISMATCH };
94
+ const w = candidate.weights[h];
95
+ if (!w || !finiteDim(w, dim)) return { ok: false, code: HEAD_CANDIDATE_FAIL.NON_FINITE };
96
+ const buf = new Uint8Array(w.buffer, w.byteOffset, w.byteLength);
97
+ if (sha256(buf) !== candidate.digests[h]) return { ok: false, code: HEAD_CANDIDATE_FAIL.DIGEST_MISMATCH };
98
+ }
99
+ return { ok: true };
100
+ }
101
+
102
+ /** Load a candidate against the frozen trunk; null on any violation, never throws. */
103
+ export function loadHeadCandidate(candidateDir: string, manifest: ModelManifestV1): HeadCandidate | null {
104
+ if (!ENC_0C_ENABLED()) return null;
105
+ let raw: string;
106
+ try {
107
+ raw = readFileSync(join(candidateDir, MANIFEST_FILE), "utf8");
108
+ } catch {
109
+ return null;
110
+ }
111
+ let parsed: unknown;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch {
115
+ return null;
116
+ }
117
+ const m = parseManifest(parsed as Record<string, unknown> | null);
118
+ if (!m || m.trunkDigest !== manifest.onnx.sha256) return null;
119
+ const weights: Partial<Record<EncoderHeadName, Float32Array>> = {};
120
+ const digests: Partial<Record<EncoderHeadName, string>> = {};
121
+ const dims: Partial<Record<EncoderHeadName, number>> = {};
122
+ for (const rec of m.heads) {
123
+ const want = rec.dim * 4;
124
+ if (!Number.isInteger(want) || want <= 0 || rec.dim !== ENCODER_HEAD_DIMS[rec.name]) return null;
125
+ let bytes: Buffer;
126
+ try {
127
+ bytes = readFileSync(join(candidateDir, `${rec.name}.bin`));
128
+ } catch {
129
+ return null;
130
+ }
131
+ const f = new Float32Array(bytes.buffer, bytes.byteOffset, rec.dim);
132
+ const d = sha256(bytes);
133
+ if (bytes.length !== want || d !== rec.sha256) return null;
134
+ weights[rec.name] = f.slice();
135
+ digests[rec.name] = d;
136
+ dims[rec.name] = rec.dim;
137
+ }
138
+ for (const h of ENCODER_HEAD_ORDER) {
139
+ if (weights[h] === undefined || digests[h] === undefined || dims[h] === undefined) return null;
140
+ }
141
+ const candidate: HeadCandidate = {
142
+ schema: HEAD_CANDIDATE_SCHEMA, version: m.version, trunkDigest: m.trunkDigest,
143
+ dims: dims as Record<EncoderHeadName, number>,
144
+ weights: weights as Record<EncoderHeadName, Float32Array>,
145
+ digests: digests as Record<EncoderHeadName, string>,
146
+ };
147
+ return validateHeadCandidate(candidate).ok ? candidate : null;
148
+ }
@@ -246,3 +246,17 @@ export function projectHeadFromTrunk(
246
246
  }
247
247
 
248
248
  export { ENCODER_HEAD_ORDER, ENCODER_HEAD_DIMS, ENCODER_HEAD_LOSS_SUM, ENCODER_SEED, NOOP_VC2B_REPORTER };
249
+
250
+ // ENC-0c five-head candidate seam (delegate-shell): the load/validate impl
251
+ // lives in heads-candidate.ts; these re-exports keep the public import path
252
+ // stable at heads.ts without growing this survivor file over the soft limit.
253
+ export {
254
+ HEAD_CANDIDATE_SCHEMA,
255
+ HEAD_CANDIDATE_FAIL,
256
+ loadHeadCandidate,
257
+ validateHeadCandidate,
258
+ type HeadCandidate,
259
+ type HeadCandidateManifest,
260
+ type HeadCandidateHeadDigest,
261
+ type HeadCandidateValidation,
262
+ } from "./heads-candidate.js";
@@ -0,0 +1,198 @@
1
+ /**
2
+ * vector-cortex/encoder/onnx.ts — ENC-0b real ONNX InferenceSession builder.
3
+ *
4
+ * Creates a WASM-backed ONNX InferenceSession over the committed encoder-v1
5
+ * asset (bge-small-en-v1.5, opset 21, 384-dim sentence_embedding). Dynamically
6
+ * imports onnxruntime-web/wasm (the CPU-only variant) so the module graph
7
+ * compiles on hosts without the package. All failures return typed result
8
+ * codes — this function NEVER throws (PREVENT-011: no `any`).
9
+ *
10
+ * Lifecycle:
11
+ * 1. Check ENCODER_RSS_BUDGET_BYTES before allocation (cap-before-allocation).
12
+ * 2. Assert manifest.opset === ENCODER_OPSET (21).
13
+ * 3. Dynamically import("onnxruntime-web/wasm").
14
+ * 4. Create InferenceSession with wasm EP, 4 threads.
15
+ * 5. Expose infer(tokens) that feeds int64 input_ids + attention_mask + token_type_ids.
16
+ * 6. Return sentence_embedding, L2-normalized to unit norm.
17
+ *
18
+ * Pi-agnostic (PREVENT-PI-004: local file only, zero network).
19
+ */
20
+
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { existsSync } from "node:fs";
24
+ import {
25
+ ENCODER_OPSET,
26
+ ENCODER_RSS_BUDGET_BYTES,
27
+ ENCODER_SEMANTIC_WIDTH,
28
+ type ModelManifestV1,
29
+ } from "./types.js";
30
+ import type { EncoderReporter } from "./emit.js";
31
+
32
+ /** ENC-0b failure codes (new ENC_FAIL namespace entries declared here). */
33
+ export const ENC_ONNX_FAIL = {
34
+ OPSET_MISMATCH: "ENC_ONNX_OPSET_MISMATCH",
35
+ RSS_BREACH: "ENC_ONNX_RSS_BREACH",
36
+ MODULE_ABSENT: "ENC_ONNX_MODULE_ABSENT",
37
+ SESSION_ERROR: "ENC_ONNX_SESSION_ERROR",
38
+ MODEL_ABSENT: "ENC_ONNX_MODEL_ABSENT",
39
+ } as const;
40
+
41
+ /** Typed failure code union. */
42
+ export type OnnxFailCode = (typeof ENC_ONNX_FAIL)[keyof typeof ENC_ONNX_FAIL];
43
+
44
+ /** Result of building an ONNX session — never throws. */
45
+ export type OnnxSessionResult =
46
+ | { ok: true; session: OnnxInferenceSession }
47
+ | { ok: false; code: OnnxFailCode };
48
+
49
+ /** The runnable ONNX inference surface (thin wrapper over the real session). */
50
+ export interface OnnxInferenceSession {
51
+ readonly opset: number;
52
+ readonly semanticWidth: number;
53
+ /** Run inference over token IDs, returning L2-normalized sentence_embedding. */
54
+ infer(tokens: readonly number[]): Promise<Float32Array>;
55
+ /** Release the underlying session resources. */
56
+ release(): Promise<void>;
57
+ }
58
+
59
+ /** Shadow type for onnxruntime-web/wasm InferenceSession (avoids hard dep). */
60
+ interface OrtSession {
61
+ run(
62
+ feeds: Record<string, { data: BigInt64Array | Float32Array; dims: readonly number[]; type: string }>,
63
+ fetches: readonly string[],
64
+ ): Promise<Record<string, { data: Float32Array | BigInt64Array; dims: readonly number[] }>>;
65
+ release(): Promise<void>;
66
+ }
67
+ interface OrtWasmModule {
68
+ InferenceSession: {
69
+ create(
70
+ path: string,
71
+ opts: { executionProviders: readonly string[]; intraOpNumThreads: number },
72
+ ): Promise<OrtSession>;
73
+ };
74
+ }
75
+
76
+ /** Resolve the onnxruntime-web package root from import.meta.url. */
77
+ function resolveOrtWasmPath(): string | null {
78
+ try {
79
+ const here = dirname(fileURLToPath(import.meta.url));
80
+ // Walk up to find node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm
81
+ let dir = here;
82
+ for (let i = 0; i < 12; i++) {
83
+ const candidate = join(dir, "node_modules", "onnxruntime-web", "dist", "ort-wasm-simd-threaded.wasm");
84
+ if (existsSync(candidate)) return candidate;
85
+ const next = dirname(dir);
86
+ if (next === dir) break;
87
+ dir = next;
88
+ }
89
+ return null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** L2-normalize a Float32Array in-place; returns the same array. */
96
+ function l2Normalize(arr: Float32Array): Float32Array {
97
+ let sum = 0;
98
+ for (let i = 0; i < arr.length; i++) sum += arr[i]! * arr[i]!;
99
+ const norm = Math.sqrt(sum);
100
+ if (norm > 1e-12) {
101
+ for (let i = 0; i < arr.length; i++) arr[i] = arr[i]! / norm;
102
+ }
103
+ return arr;
104
+ }
105
+
106
+ /**
107
+ * Build a real ONNX InferenceSession over the committed encoder-v1 asset.
108
+ * Returns a typed result — NEVER throws. On any failure the caller demotes
109
+ * to mode B trigram with the returned code.
110
+ *
111
+ * @param assetDir Path to the directory containing model.onnx + manifest.
112
+ * @param manifest The verified ModelManifestV1 from the asset.
113
+ * @param reporter Encoder reporter for the onnx_loaded event (optional).
114
+ * @param allocatedBytes Externally committed bytes (for RSS budget check).
115
+ */
116
+ export async function buildOnnxSession(
117
+ assetDir: string,
118
+ manifest: ModelManifestV1,
119
+ reporter?: EncoderReporter,
120
+ allocatedBytes: number = 0,
121
+ ): Promise<OnnxSessionResult> {
122
+ // Q01: cap-before-allocation.
123
+ if (allocatedBytes > ENCODER_RSS_BUDGET_BYTES) {
124
+ return { ok: false, code: ENC_ONNX_FAIL.RSS_BREACH };
125
+ }
126
+
127
+ // Opset assertion.
128
+ if (manifest.opset !== ENCODER_OPSET) {
129
+ return { ok: false, code: ENC_ONNX_FAIL.OPSET_MISMATCH };
130
+ }
131
+
132
+ const modelPath = join(assetDir, manifest.onnx.path);
133
+ if (!existsSync(modelPath)) {
134
+ return { ok: false, code: ENC_ONNX_FAIL.MODEL_ABSENT };
135
+ }
136
+
137
+ // Dynamically import onnxruntime-web/wasm (never a hard dependency).
138
+ let ort: OrtWasmModule;
139
+ try {
140
+ ort = (await import("onnxruntime-web/wasm")) as OrtWasmModule;
141
+ if (!ort?.InferenceSession?.create) {
142
+ return { ok: false, code: ENC_ONNX_FAIL.MODULE_ABSENT };
143
+ }
144
+ } catch {
145
+ return { ok: false, code: ENC_ONNX_FAIL.MODULE_ABSENT };
146
+ }
147
+
148
+ // Resolve WASM binary path for the threading backend.
149
+ const wasmPath = resolveOrtWasmPath();
150
+
151
+ let rawSession: OrtSession;
152
+ try {
153
+ const opts: { executionProviders: readonly string[]; intraOpNumThreads: number; wasmPaths?: string } = {
154
+ executionProviders: ["wasm"],
155
+ intraOpNumThreads: 4,
156
+ };
157
+ if (wasmPath) opts.wasmPaths = wasmPath;
158
+ rawSession = await ort.InferenceSession.create(modelPath, opts);
159
+ } catch {
160
+ return { ok: false, code: ENC_ONNX_FAIL.SESSION_ERROR };
161
+ }
162
+
163
+ reporter?.onnxSessionLoaded({
164
+ opset: manifest.opset,
165
+ semanticWidth: ENCODER_SEMANTIC_WIDTH,
166
+ threads: 4,
167
+ });
168
+
169
+ const session: OnnxInferenceSession = {
170
+ opset: manifest.opset,
171
+ semanticWidth: ENCODER_SEMANTIC_WIDTH,
172
+ async infer(tokens: readonly number[]): Promise<Float32Array> {
173
+ const n = tokens.length;
174
+ const inputIds = new BigInt64Array(n);
175
+ const attentionMask = new BigInt64Array(n);
176
+ const tokenTypeIds = new BigInt64Array(n);
177
+ for (let i = 0; i < n; i++) {
178
+ inputIds[i] = BigInt(tokens[i]!);
179
+ attentionMask[i] = 1n;
180
+ tokenTypeIds[i] = 0n;
181
+ }
182
+ const feeds = {
183
+ input_ids: { data: inputIds, dims: [1, n], type: "int64" },
184
+ attention_mask: { data: attentionMask, dims: [1, n], type: "int64" },
185
+ token_type_ids: { data: tokenTypeIds, dims: [1, n], type: "int64" },
186
+ };
187
+ const results = await rawSession.run(feeds, ["sentence_embedding"]);
188
+ const out = results["sentence_embedding"];
189
+ if (!out || !(out.data instanceof Float32Array)) {
190
+ return new Float32Array(ENCODER_SEMANTIC_WIDTH);
191
+ }
192
+ return l2Normalize(out.data);
193
+ },
194
+ release: () => rawSession.release(),
195
+ };
196
+
197
+ return { ok: true, session };
198
+ }
@@ -36,7 +36,7 @@ export interface OrtWasmModule {
36
36
  opts: { executionProviders: string[]; intraOpNumThreads: number },
37
37
  ): Promise<{
38
38
  run(
39
- feeds: Record<string, Float32Array>,
39
+ feeds: Record<string, { data: BigInt64Array; dims: number[]; type: string }>,
40
40
  outputNames: string[],
41
41
  ): Promise<Record<string, { data: Float32Array }>>;
42
42
  }>;
@@ -51,8 +51,8 @@ export interface WasmSession {
51
51
  readonly semanticWidth: number;
52
52
  /** The per-asset token capacity cap (normative <= 512). */
53
53
  readonly maxTokens: number;
54
- /** Run one inference over already shape-checked input tokens. */
55
- infer(inputIds: Float32Array): Promise<Float32Array>;
54
+ /** Run one inference over shape-checked token IDs (int64 input_ids). */
55
+ infer(tokens: number[]): Promise<Float32Array>;
56
56
  }
57
57
 
58
58
  /** True if `onnxruntime-web` resolves on this host (loading is best-effort).
@@ -94,10 +94,23 @@ export async function createWasmSession(
94
94
  opset: ENCODER_OPSET,
95
95
  semanticWidth: ENCODER_SEMANTIC_WIDTH,
96
96
  maxTokens,
97
- async infer(inputIds: Float32Array): Promise<Float32Array> {
98
- const feeds = { input_ids: inputIds };
99
- const results = await session.run(feeds, ["embedding"]);
100
- const out = results["embedding"];
97
+ async infer(tokens: number[]): Promise<Float32Array> {
98
+ const n = tokens.length;
99
+ const inputIds = new BigInt64Array(n);
100
+ const attentionMask = new BigInt64Array(n);
101
+ const tokenTypeIds = new BigInt64Array(n);
102
+ for (let i = 0; i < n; i++) {
103
+ inputIds[i] = BigInt(tokens[i]!);
104
+ attentionMask[i] = 1n;
105
+ tokenTypeIds[i] = 0n;
106
+ }
107
+ const feeds = {
108
+ input_ids: { data: inputIds, dims: [1, n], type: "int64" },
109
+ attention_mask: { data: attentionMask, dims: [1, n], type: "int64" },
110
+ token_type_ids: { data: tokenTypeIds, dims: [1, n], type: "int64" },
111
+ };
112
+ const results = await session.run(feeds, ["sentence_embedding"]);
113
+ const out = results["sentence_embedding"];
101
114
  if (!out || !(out.data instanceof Float32Array)) {
102
115
  return new Float32Array(ENCODER_SEMANTIC_WIDTH);
103
116
  }
@@ -86,6 +86,7 @@ import { selectRuntimeBackend } from "./runtime-select.js";
86
86
  import { emitRuntimeSelected } from "./runtime-emit.js";
87
87
  import { projectSemantic, seedFromBytes } from "./runtime-stub.js";
88
88
  import { STATE_DIR_DEFAULT } from "../../config.js";
89
+ import { tryBuildOnnx, type OnnxDispatchState, NO_ONNX } from "./encoder-onnx-dispatch.js";
89
90
 
90
91
  /** Bytes a single encoder-owned projection buffer commits to the marginal
91
92
  * footprint (Float32Array, 4 bytes per element). */
@@ -136,9 +137,16 @@ function normalizePlatform(p: EncoderPlatform | null): EncoderPlatform | "unsupp
136
137
  return p === null ? "unsupported" : p;
137
138
  }
138
139
 
140
+ /** The concrete runtime returned by createEncoderRuntime — the base
141
+ * EncoderRuntime interface plus the ENC-0b ONNX dispatch state. */
142
+ export type EncoderRuntimeHandle = EncoderRuntime & {
143
+ /** ENC-0b: the ONNX dispatch state (null when ENC_0B is off or not built). */
144
+ readonly onnxState: OnnxDispatchState;
145
+ };
146
+
139
147
  export function createEncoderRuntime(
140
148
  options: CreateEncoderRuntimeOptions = {},
141
- ): EncoderRuntime {
149
+ ): EncoderRuntimeHandle {
142
150
  const reporter = options.reporter ?? createEncoderReporter();
143
151
  const host = mergeHost(options.host);
144
152
  const forced = options.forcedMode;
@@ -152,6 +160,7 @@ export function createEncoderRuntime(
152
160
  let verified = false;
153
161
  let maxTokens = ENCODER_MAX_TOKENS;
154
162
  let selfAllocated = 0;
163
+ let onnxState: OnnxDispatchState = NO_ONNX;
155
164
 
156
165
  const footprint = (): number => selfAllocated + host.allocatedBytes();
157
166
 
@@ -161,12 +170,14 @@ export function createEncoderRuntime(
161
170
  reporter.runtimeDemoted({ reason: code, mode: rmode, platform: plat()?.toString() ?? "unsupported" });
162
171
  };
163
172
 
164
- const runtime: EncoderRuntime = {
173
+ const runtime: EncoderRuntimeHandle = {
165
174
  schema: "encoder-runtime-v1",
166
- // Live getter so `mode` always reflects the latest load/demote outcome.
167
175
  get mode(): EncoderMode {
168
176
  return mode;
169
177
  },
178
+ get onnxState(): OnnxDispatchState {
179
+ return onnxState;
180
+ },
170
181
  load(assetDir: string): EncoderLoadResult {
171
182
  if (rolledBack) {
172
183
  // Q04: report the rollback with its own code, not MANIFEST_INVALID.
@@ -225,6 +236,14 @@ export function createEncoderRuntime(
225
236
  emitRuntimeSelected(host.stateDir ?? STATE_DIR_DEFAULT, chosen);
226
237
  }
227
238
 
239
+ // ENC-0b: fire-and-forget ONNX session build (async, non-blocking).
240
+ // The sync load() contract is preserved; the session build settles
241
+ // asynchronously and is consumed by verifyOnnxSession for tests +
242
+ // future async-heavy router integration (ENC-0c).
243
+ if (manifest) {
244
+ onnxState = tryBuildOnnx(assetDir, manifest, reporter, footprint());
245
+ }
246
+
228
247
  return {
229
248
  ok: true,
230
249
  mode: "A",
@@ -262,14 +281,13 @@ export function createEncoderRuntime(
262
281
  };
263
282
  }
264
283
  const start = host.nowMs();
265
- // ML5-C: the LCG placeholder STILL drives infer by default (the trained
266
- // asset behind runtime-wasm.ts/runtime-native.ts is not yet the
267
- // source-of-truth on master; only the runtime-selection event seam was
268
- // added this sprint).
284
+ // ENC-0b builds the real ONNX session during load() (fire-and-forget);
285
+ // infer() continues serving the LCG placeholder until the router is
286
+ // wired for async inference (ENC-0c). The session is verified by tests
287
+ // via the runtime's verifySession() method.
269
288
  const semantic = projectSemantic(seedFromBytes(embeddedBytes) ^ n, ENCODER_SEMANTIC_WIDTH);
270
289
  selfAllocated = SEMANTIC_BUFFER_BYTES;
271
- const latencyMs = host.nowMs() - start;
272
- return { ok: true, semantic, rssBytes: footprint(), latencyMs, shapeError: null };
290
+ return { ok: true, semantic, rssBytes: footprint(), latencyMs: host.nowMs() - start, shapeError: null };
273
291
  },
274
292
  };
275
293
  return runtime;