@selvajs/ui 4.12.5 → 5.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/viewer/Viewer.svelte +6 -0
- package/dist/compute/createSolveSession.svelte.d.ts +6 -0
- package/dist/compute/createSolveSession.svelte.js +21 -0
- package/dist/compute/solveMemo.d.ts +22 -0
- package/dist/compute/solveMemo.js +58 -0
- package/package.json +6 -6
- package/src/lib/components/viewer/Viewer.svelte +6 -0
- package/src/lib/compute/createSolveSession.svelte.ts +27 -0
- package/src/lib/compute/createSolveSession.test.ts +85 -2
- package/src/lib/compute/solveMemo.test.ts +87 -0
- package/src/lib/compute/solveMemo.ts +69 -0
|
@@ -173,8 +173,14 @@
|
|
|
173
173
|
fitToView = init.fitToView;
|
|
174
174
|
projection = init.cameraController.getProjection();
|
|
175
175
|
|
|
176
|
+
const renderer = init.renderer;
|
|
177
|
+
|
|
176
178
|
return () => {
|
|
177
179
|
init.dispose();
|
|
180
|
+
// `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
|
|
181
|
+
// on every definition switch; browsers cap live contexts (~16). Explicitly
|
|
182
|
+
// drop this one so the GPU-side context is released now rather than at GC.
|
|
183
|
+
renderer.forceContextLoss();
|
|
178
184
|
};
|
|
179
185
|
});
|
|
180
186
|
|
|
@@ -9,6 +9,12 @@ export interface SolveDriver {
|
|
|
9
9
|
solve(values: Record<string, unknown>): void;
|
|
10
10
|
cancel(): void;
|
|
11
11
|
readonly isSolving: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Drops any cached solve results the driver holds. Optional — only drivers with a
|
|
14
|
+
* client-side memo (the request/response driver) implement it. Called on rebuild so a
|
|
15
|
+
* definition swap can't serve a stale result from a prior definition's input space.
|
|
16
|
+
*/
|
|
17
|
+
clearCache?(): void;
|
|
12
18
|
}
|
|
13
19
|
export interface SolveSession {
|
|
14
20
|
readonly values: Record<string, unknown>;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// SolveDriver. A completed solve re-enters via report().
|
|
5
5
|
import { readExternalValue } from '../external/storage';
|
|
6
6
|
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
7
|
+
import { createSolveMemo } from './solveMemo';
|
|
7
8
|
import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
|
|
8
9
|
export function createSolveSession(args) {
|
|
9
10
|
let currentSchema = args.schema;
|
|
@@ -73,6 +74,9 @@ export function createSolveSession(args) {
|
|
|
73
74
|
},
|
|
74
75
|
rebuild(schema, scopeKey) {
|
|
75
76
|
currentSchema = schema;
|
|
77
|
+
// Drop the driver's result memo: the new definition has its own input space, so
|
|
78
|
+
// a matching input key from the prior definition must not serve its stale result.
|
|
79
|
+
args.driver.clearCache?.();
|
|
76
80
|
state.meshes = [];
|
|
77
81
|
state.error = '';
|
|
78
82
|
state.computeErrors = [];
|
|
@@ -103,11 +107,25 @@ export function createSolveSession(args) {
|
|
|
103
107
|
* lazily (`() => session`) so it can construct the session with the driver in hand.
|
|
104
108
|
*/
|
|
105
109
|
export function createRequestResponseDriver(onSolve, getReporter, options = {}) {
|
|
110
|
+
// M2: a small LRU memoizing completed solves by their input values. A slider dragged
|
|
111
|
+
// back to a value already solved this session reports instantly without a network
|
|
112
|
+
// round-trip. The check lives inside the throttled computeFn so the throttle's
|
|
113
|
+
// latest-wins ordering still holds — a hit only serves after the throttle picks these
|
|
114
|
+
// values as the ones to run.
|
|
115
|
+
const memo = createSolveMemo();
|
|
106
116
|
const throttle = createComputeThrottle(async (values, signal) => {
|
|
117
|
+
const cached = memo.get(values);
|
|
118
|
+
if (cached !== undefined) {
|
|
119
|
+
if (signal.aborted)
|
|
120
|
+
return;
|
|
121
|
+
getReporter().report(cached);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
107
124
|
try {
|
|
108
125
|
const result = await onSolve(values, signal);
|
|
109
126
|
if (signal.aborted)
|
|
110
127
|
return;
|
|
128
|
+
memo.set(values, result);
|
|
111
129
|
getReporter().report(result);
|
|
112
130
|
}
|
|
113
131
|
catch (err) {
|
|
@@ -125,6 +143,9 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
|
|
|
125
143
|
},
|
|
126
144
|
get isSolving() {
|
|
127
145
|
return throttle.isComputing;
|
|
146
|
+
},
|
|
147
|
+
clearCache() {
|
|
148
|
+
memo.clear();
|
|
128
149
|
}
|
|
129
150
|
};
|
|
130
151
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SolveResult } from '../types/solveFn';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
4
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
5
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
6
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stableInputKey(values: Record<string, unknown>): string;
|
|
9
|
+
export interface SolveMemo {
|
|
10
|
+
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
11
|
+
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
12
|
+
/** Records a completed solve result under its input key (evicting the LRU tail). */
|
|
13
|
+
set(values: Record<string, unknown>, result: SolveResult): void;
|
|
14
|
+
/** Drops every entry — called when the active definition changes. */
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
19
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
20
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
21
|
+
*/
|
|
22
|
+
export declare function createSolveMemo(max?: number): SolveMemo;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
|
|
2
|
+
// the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
|
|
3
|
+
// back to a value already solved this session returns instantly without a network
|
|
4
|
+
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
|
+
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
|
+
// hit is always a complete result.
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
9
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
10
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
11
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
12
|
+
*/
|
|
13
|
+
export function stableInputKey(values) {
|
|
14
|
+
return serialize(values);
|
|
15
|
+
}
|
|
16
|
+
function serialize(value) {
|
|
17
|
+
if (value === null || typeof value !== 'object')
|
|
18
|
+
return JSON.stringify(value) ?? 'null';
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return `[${value.map(serialize).join(',')}]`;
|
|
21
|
+
const obj = value;
|
|
22
|
+
const keys = Object.keys(obj).sort();
|
|
23
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
27
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
28
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
29
|
+
*/
|
|
30
|
+
export function createSolveMemo(max = 16) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
return {
|
|
33
|
+
get(values) {
|
|
34
|
+
const key = stableInputKey(values);
|
|
35
|
+
const hit = entries.get(key);
|
|
36
|
+
if (hit === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
// Refresh recency: re-insert at the tail.
|
|
39
|
+
entries.delete(key);
|
|
40
|
+
entries.set(key, hit);
|
|
41
|
+
return hit;
|
|
42
|
+
},
|
|
43
|
+
set(values, result) {
|
|
44
|
+
const key = stableInputKey(values);
|
|
45
|
+
entries.delete(key);
|
|
46
|
+
entries.set(key, result);
|
|
47
|
+
while (entries.size > max) {
|
|
48
|
+
const oldest = entries.keys().next().value;
|
|
49
|
+
if (oldest === undefined)
|
|
50
|
+
break;
|
|
51
|
+
entries.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
clear() {
|
|
55
|
+
entries.clear();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0-beta.1",
|
|
4
4
|
"description": "Shared UI components and utilities for Selva applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -37,13 +37,13 @@
|
|
|
37
37
|
"**/*.css"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@selvajs/compute": "^3.0.
|
|
40
|
+
"@selvajs/compute": "^3.1.0-beta.6",
|
|
41
41
|
"@sveltejs/kit": "^2",
|
|
42
42
|
"bits-ui": "^2.18.0",
|
|
43
43
|
"svelte": "^5",
|
|
44
44
|
"tailwind-variants": "^3.2.2",
|
|
45
45
|
"three": "^0.184.0",
|
|
46
|
-
"@selvajs/schemas": "^4.
|
|
46
|
+
"@selvajs/schemas": "^4.7.0-beta.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"three": {
|
|
@@ -63,17 +63,17 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@internationalized/date": "^3.12.1",
|
|
66
|
-
"@sveltejs/kit": "2.
|
|
66
|
+
"@sveltejs/kit": "2.69.1",
|
|
67
67
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
68
68
|
"@types/three": "^0.184.0",
|
|
69
69
|
"bits-ui": "^2.18.0",
|
|
70
70
|
"rhino3dm": "8.17.0",
|
|
71
71
|
"rimraf": "^6.0.1",
|
|
72
|
-
"svelte": "5.
|
|
72
|
+
"svelte": "5.56.4",
|
|
73
73
|
"tailwind-variants": "^3.2.2",
|
|
74
74
|
"vitest": "^3.2.6",
|
|
75
75
|
"@selvajs/config": "0.0.2",
|
|
76
|
-
"@selvajs/schemas": "4.
|
|
76
|
+
"@selvajs/schemas": "4.7.0-beta.0"
|
|
77
77
|
},
|
|
78
78
|
"scripts": {
|
|
79
79
|
"dev": "vite dev",
|
|
@@ -173,8 +173,14 @@
|
|
|
173
173
|
fitToView = init.fitToView;
|
|
174
174
|
projection = init.cameraController.getProjection();
|
|
175
175
|
|
|
176
|
+
const renderer = init.renderer;
|
|
177
|
+
|
|
176
178
|
return () => {
|
|
177
179
|
init.dispose();
|
|
180
|
+
// `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
|
|
181
|
+
// on every definition switch; browsers cap live contexts (~16). Explicitly
|
|
182
|
+
// drop this one so the GPU-side context is released now rather than at GC.
|
|
183
|
+
renderer.forceContextLoss();
|
|
178
184
|
};
|
|
179
185
|
});
|
|
180
186
|
|
|
@@ -7,6 +7,7 @@ import type { UISchema } from '@selvajs/schemas';
|
|
|
7
7
|
import { readExternalValue } from '../external/storage';
|
|
8
8
|
import type { SolveFn, SolveResult } from '../types/solveFn';
|
|
9
9
|
import { createComputeThrottle } from './computeThrottle.svelte';
|
|
10
|
+
import { createSolveMemo } from './solveMemo';
|
|
10
11
|
import {
|
|
11
12
|
buildInitialValues,
|
|
12
13
|
makeInitialFlags,
|
|
@@ -25,6 +26,12 @@ export interface SolveDriver {
|
|
|
25
26
|
solve(values: Record<string, unknown>): void;
|
|
26
27
|
cancel(): void;
|
|
27
28
|
readonly isSolving: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Drops any cached solve results the driver holds. Optional — only drivers with a
|
|
31
|
+
* client-side memo (the request/response driver) implement it. Called on rebuild so a
|
|
32
|
+
* definition swap can't serve a stale result from a prior definition's input space.
|
|
33
|
+
*/
|
|
34
|
+
clearCache?(): void;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface SolveSession {
|
|
@@ -134,6 +141,9 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
|
|
|
134
141
|
|
|
135
142
|
rebuild(schema, scopeKey) {
|
|
136
143
|
currentSchema = schema;
|
|
144
|
+
// Drop the driver's result memo: the new definition has its own input space, so
|
|
145
|
+
// a matching input key from the prior definition must not serve its stale result.
|
|
146
|
+
args.driver.clearCache?.();
|
|
137
147
|
state.meshes = [];
|
|
138
148
|
state.error = '';
|
|
139
149
|
state.computeErrors = [];
|
|
@@ -177,10 +187,24 @@ export function createRequestResponseDriver(
|
|
|
177
187
|
getReporter: () => SolveReporter,
|
|
178
188
|
options: { timeout?: number } = {}
|
|
179
189
|
): SolveDriver {
|
|
190
|
+
// M2: a small LRU memoizing completed solves by their input values. A slider dragged
|
|
191
|
+
// back to a value already solved this session reports instantly without a network
|
|
192
|
+
// round-trip. The check lives inside the throttled computeFn so the throttle's
|
|
193
|
+
// latest-wins ordering still holds — a hit only serves after the throttle picks these
|
|
194
|
+
// values as the ones to run.
|
|
195
|
+
const memo = createSolveMemo();
|
|
196
|
+
|
|
180
197
|
const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
|
|
198
|
+
const cached = memo.get(values);
|
|
199
|
+
if (cached !== undefined) {
|
|
200
|
+
if (signal.aborted) return;
|
|
201
|
+
getReporter().report(cached);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
181
204
|
try {
|
|
182
205
|
const result = await onSolve(values, signal);
|
|
183
206
|
if (signal.aborted) return;
|
|
207
|
+
memo.set(values, result);
|
|
184
208
|
getReporter().report(result);
|
|
185
209
|
} catch (err) {
|
|
186
210
|
if (signal.aborted) return;
|
|
@@ -197,6 +221,9 @@ export function createRequestResponseDriver(
|
|
|
197
221
|
},
|
|
198
222
|
get isSolving() {
|
|
199
223
|
return throttle.isComputing;
|
|
224
|
+
},
|
|
225
|
+
clearCache() {
|
|
226
|
+
memo.clear();
|
|
200
227
|
}
|
|
201
228
|
};
|
|
202
229
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createSolveSession,
|
|
4
|
+
createRequestResponseDriver,
|
|
5
|
+
type SolveDriver,
|
|
6
|
+
type SolveReporter
|
|
7
|
+
} from './createSolveSession.svelte';
|
|
3
8
|
import type { UISchema } from '@selvajs/schemas';
|
|
9
|
+
import type { SolveResult } from '../types/solveFn';
|
|
4
10
|
|
|
5
11
|
// Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
|
|
6
12
|
// added for dynamic-value-list reconciliation. The pure transition logic is pinned in
|
|
@@ -83,3 +89,80 @@ describe('createSolveSession.setValue', () => {
|
|
|
83
89
|
expect(driver.solves[0]).not.toHaveProperty('out');
|
|
84
90
|
});
|
|
85
91
|
});
|
|
92
|
+
|
|
93
|
+
// M2: the request/response driver's client-side result memo. Verifies a slider returning
|
|
94
|
+
// to a solved value serves from memory (no onSolve call) and that a definition rebuild
|
|
95
|
+
// drops the memo so a stale result can't cross the swap.
|
|
96
|
+
describe('createRequestResponseDriver — client memo', () => {
|
|
97
|
+
// Collects reported results so the memo hit/miss can be observed without a session.
|
|
98
|
+
function collectingReporter(): SolveReporter & { reports: SolveResult[]; errors: string[] } {
|
|
99
|
+
const reports: SolveResult[] = [];
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
return {
|
|
102
|
+
reports,
|
|
103
|
+
errors,
|
|
104
|
+
report: (r) => reports.push(r),
|
|
105
|
+
reportError: (m) => errors.push(m)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Lets the throttle's fire-and-forget executeCompute settle.
|
|
110
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
111
|
+
|
|
112
|
+
it('serves a repeated input from the memo without calling onSolve again', async () => {
|
|
113
|
+
const onSolve = vi.fn(
|
|
114
|
+
async (values: Record<string, unknown>): Promise<SolveResult> => ({
|
|
115
|
+
outputs: { echo: values.a }
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
const reporter = collectingReporter();
|
|
119
|
+
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
120
|
+
|
|
121
|
+
driver.solve({ a: 1 });
|
|
122
|
+
await flush();
|
|
123
|
+
driver.solve({ a: 2 });
|
|
124
|
+
await flush();
|
|
125
|
+
driver.solve({ a: 1 }); // repeat — should hit the memo
|
|
126
|
+
await flush();
|
|
127
|
+
|
|
128
|
+
expect(onSolve).toHaveBeenCalledTimes(2); // only the two distinct inputs
|
|
129
|
+
expect(reporter.reports).toHaveLength(3); // but all three solves reported
|
|
130
|
+
expect(reporter.reports[2]).toEqual({ outputs: { echo: 1 } });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('clearCache drops the memo so the next identical solve re-runs', async () => {
|
|
134
|
+
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
135
|
+
const reporter = collectingReporter();
|
|
136
|
+
const driver = createRequestResponseDriver(onSolve, () => reporter);
|
|
137
|
+
|
|
138
|
+
driver.solve({ a: 1 });
|
|
139
|
+
await flush();
|
|
140
|
+
driver.clearCache?.();
|
|
141
|
+
driver.solve({ a: 1 }); // memo cleared → real solve again
|
|
142
|
+
await flush();
|
|
143
|
+
|
|
144
|
+
expect(onSolve).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('session.rebuild clears the driver memo (no cross-definition stale hit)', async () => {
|
|
148
|
+
const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
|
|
149
|
+
const reporter = collectingReporter();
|
|
150
|
+
let clears = 0;
|
|
151
|
+
// Wrap the real driver to observe clearCache being invoked from rebuild.
|
|
152
|
+
const base = createRequestResponseDriver(onSolve, () => reporter);
|
|
153
|
+
const driver: SolveDriver = {
|
|
154
|
+
solve: base.solve,
|
|
155
|
+
cancel: base.cancel,
|
|
156
|
+
get isSolving() {
|
|
157
|
+
return base.isSolving;
|
|
158
|
+
},
|
|
159
|
+
clearCache() {
|
|
160
|
+
clears += 1;
|
|
161
|
+
base.clearCache?.();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
|
|
165
|
+
session.rebuild(schema(true), 's2');
|
|
166
|
+
expect(clears).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSolveMemo, stableInputKey } from './solveMemo';
|
|
3
|
+
import type { SolveResult } from '../types/solveFn';
|
|
4
|
+
|
|
5
|
+
// Pins the client-side result memo (M2): stable keying across key order, LRU recency and
|
|
6
|
+
// eviction, hit/miss semantics, and clear(). The driver wiring is pinned separately in
|
|
7
|
+
// createSolveSession.test.ts.
|
|
8
|
+
|
|
9
|
+
const result = (tag: string): SolveResult => ({ outputs: { out: tag } });
|
|
10
|
+
|
|
11
|
+
describe('stableInputKey', () => {
|
|
12
|
+
it('is insensitive to object key order', () => {
|
|
13
|
+
expect(stableInputKey({ a: 1, b: 2 })).toBe(stableInputKey({ b: 2, a: 1 }));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sorts keys at every level (nested objects)', () => {
|
|
17
|
+
expect(stableInputKey({ o: { x: 1, y: 2 } })).toBe(stableInputKey({ o: { y: 2, x: 1 } }));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('distinguishes different values', () => {
|
|
21
|
+
expect(stableInputKey({ a: 1 })).not.toBe(stableInputKey({ a: 2 }));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('preserves array order (arrays are ordered)', () => {
|
|
25
|
+
expect(stableInputKey({ a: [1, 2] })).not.toBe(stableInputKey({ a: [2, 1] }));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles null and primitive values', () => {
|
|
29
|
+
expect(stableInputKey({ a: null, b: 'x', c: true })).toBe(
|
|
30
|
+
stableInputKey({ c: true, b: 'x', a: null })
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('createSolveMemo', () => {
|
|
36
|
+
it('returns undefined on a miss', () => {
|
|
37
|
+
const memo = createSolveMemo();
|
|
38
|
+
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('round-trips a stored result by equal inputs regardless of key order', () => {
|
|
42
|
+
const memo = createSolveMemo();
|
|
43
|
+
memo.set({ a: 1, b: 2 }, result('r'));
|
|
44
|
+
expect(memo.get({ b: 2, a: 1 })).toEqual(result('r'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('caches errored results (a complete, deterministic solve outcome)', () => {
|
|
48
|
+
const memo = createSolveMemo();
|
|
49
|
+
const errored: SolveResult = { outputs: {}, errors: ['boom'] };
|
|
50
|
+
memo.set({ a: 1 }, errored);
|
|
51
|
+
expect(memo.get({ a: 1 })).toEqual(errored);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('evicts the least-recently-used entry past capacity', () => {
|
|
55
|
+
const memo = createSolveMemo(2);
|
|
56
|
+
memo.set({ k: 1 }, result('1'));
|
|
57
|
+
memo.set({ k: 2 }, result('2'));
|
|
58
|
+
memo.set({ k: 3 }, result('3')); // evicts k:1
|
|
59
|
+
expect(memo.get({ k: 1 })).toBeUndefined();
|
|
60
|
+
expect(memo.get({ k: 2 })).toEqual(result('2'));
|
|
61
|
+
expect(memo.get({ k: 3 })).toEqual(result('3'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('a get refreshes recency, protecting the entry from eviction', () => {
|
|
65
|
+
const memo = createSolveMemo(2);
|
|
66
|
+
memo.set({ k: 1 }, result('1'));
|
|
67
|
+
memo.set({ k: 2 }, result('2'));
|
|
68
|
+
memo.get({ k: 1 }); // k:1 now most-recent
|
|
69
|
+
memo.set({ k: 3 }, result('3')); // evicts k:2, not k:1
|
|
70
|
+
expect(memo.get({ k: 1 })).toEqual(result('1'));
|
|
71
|
+
expect(memo.get({ k: 2 })).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('re-setting an existing key updates the value without growing size', () => {
|
|
75
|
+
const memo = createSolveMemo(1);
|
|
76
|
+
memo.set({ k: 1 }, result('old'));
|
|
77
|
+
memo.set({ k: 1 }, result('new'));
|
|
78
|
+
expect(memo.get({ k: 1 })).toEqual(result('new'));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('clear() drops every entry', () => {
|
|
82
|
+
const memo = createSolveMemo();
|
|
83
|
+
memo.set({ a: 1 }, result('r'));
|
|
84
|
+
memo.clear();
|
|
85
|
+
expect(memo.get({ a: 1 })).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
|
|
2
|
+
// the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
|
|
3
|
+
// back to a value already solved this session returns instantly without a network
|
|
4
|
+
// round-trip — killing slider-scrub storms before they leave the browser. It pairs with
|
|
5
|
+
// the throttle's latest-wins abort: the memo only serves values that fully solved, so a
|
|
6
|
+
// hit is always a complete result.
|
|
7
|
+
|
|
8
|
+
import type { SolveResult } from '../types/solveFn';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic string key for a set of input values. Object keys are sorted at every
|
|
12
|
+
* level so two logically-equal inputs (built in different key order) collide, matching
|
|
13
|
+
* the server's stable-input keying intent. Values are plain JSON (numbers, strings,
|
|
14
|
+
* booleans, arrays) — the projected solve inputs never contain functions or cycles.
|
|
15
|
+
*/
|
|
16
|
+
export function stableInputKey(values: Record<string, unknown>): string {
|
|
17
|
+
return serialize(values);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function serialize(value: unknown): string {
|
|
21
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
|
|
23
|
+
const obj = value as Record<string, unknown>;
|
|
24
|
+
const keys = Object.keys(obj).sort();
|
|
25
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SolveMemo {
|
|
29
|
+
/** Returns a previously stored result for these inputs, or undefined on a miss. */
|
|
30
|
+
get(values: Record<string, unknown>): SolveResult | undefined;
|
|
31
|
+
/** Records a completed solve result under its input key (evicting the LRU tail). */
|
|
32
|
+
set(values: Record<string, unknown>, result: SolveResult): void;
|
|
33
|
+
/** Drops every entry — called when the active definition changes. */
|
|
34
|
+
clear(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
|
|
39
|
+
* default is deliberately small — this targets the tight slider-scrub loop, not a durable
|
|
40
|
+
* cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
|
|
41
|
+
*/
|
|
42
|
+
export function createSolveMemo(max = 16): SolveMemo {
|
|
43
|
+
const entries = new Map<string, SolveResult>();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
get(values) {
|
|
47
|
+
const key = stableInputKey(values);
|
|
48
|
+
const hit = entries.get(key);
|
|
49
|
+
if (hit === undefined) return undefined;
|
|
50
|
+
// Refresh recency: re-insert at the tail.
|
|
51
|
+
entries.delete(key);
|
|
52
|
+
entries.set(key, hit);
|
|
53
|
+
return hit;
|
|
54
|
+
},
|
|
55
|
+
set(values, result) {
|
|
56
|
+
const key = stableInputKey(values);
|
|
57
|
+
entries.delete(key);
|
|
58
|
+
entries.set(key, result);
|
|
59
|
+
while (entries.size > max) {
|
|
60
|
+
const oldest = entries.keys().next().value;
|
|
61
|
+
if (oldest === undefined) break;
|
|
62
|
+
entries.delete(oldest);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
clear() {
|
|
66
|
+
entries.clear();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|