diff --git a/.changeset/store-getter-memoization.md b/.changeset/store-getter-memoization.md new file mode 100644 index 00000000..ccaa4209 --- /dev/null +++ b/.changeset/store-getter-memoization.md @@ -0,0 +1,7 @@ +--- +"@geajs/core": patch +--- + +### @geajs/core (patch) + +- **Store getter memoization**: Prototype-level getters on Store subclasses are now memoized with reactive dependency tracking. Cached results are invalidated when tracked fields change. Getters reading `_`-prefixed internal fields or function-valued fields are marked uncacheable. Chained getters propagate dependencies to parent getters. diff --git a/packages/gea/benchmarks/getter-memo.bench.ts b/packages/gea/benchmarks/getter-memo.bench.ts new file mode 100644 index 00000000..75de22f7 --- /dev/null +++ b/packages/gea/benchmarks/getter-memo.bench.ts @@ -0,0 +1,106 @@ +/** + * Benchmark: store getter memoization with reactive dependency tracking + * PR #41: Cache prototype getter results; invalidate on observed field changes + * + * Run: npx tsx --conditions source packages/gea/benchmarks/getter-memo.bench.ts + */ +import { Store } from '../src/lib/store.ts' + +function heapMB() { + return process.memoryUsage().heapUsed / 1024 / 1024 +} + +function bench(fn: () => void, iters: number): number { + for (let i = 0; i < 20; i++) fn() // warm-up + const t0 = performance.now() + for (let i = 0; i < iters; i++) fn() + return performance.now() - t0 +} + +// ---------- Store with computed getters ---------- +class ProductStore extends Store { + price = 100 + tax = 0.18 + discount = 0 + quantity = 5 + name = 'Widget' + + get subtotal() { + return this.price * this.quantity + } + + get taxAmount() { + return this.subtotal * this.tax + } + + get discountAmount() { + return this.subtotal * this.discount + } + + get total() { + return this.subtotal + this.taxAmount - this.discountAmount + } + + get summary() { + return `${this.name}: $${this.total.toFixed(2)} (qty: ${this.quantity})` + } +} + +const ITERS = 100_000 + +console.log('\n=== store getter memoization benchmark ===') +console.log('Store: ProductStore with 5 chained computed getters\n') + +const store = new ProductStore() + +// --- Scenario 1: Warm reads (no state change — pure cache hits) --- +if (global.gc) global.gc() +const h0 = heapMB() + +const warmMs = bench(() => { + void store.total + void store.summary + void store.subtotal +}, ITERS) + +if (global.gc) global.gc() +const h1 = heapMB() + +// --- Scenario 2: Mixed reads + invalidation --- +let counter = 0 +const invalidMs = bench(() => { + void store.total + if (counter % 100 === 0) { + store.price = 100 + ((counter / 100) % 5) // invalidate every 100 reads, cycling 100-104 + } + counter++ + void store.summary +}, ITERS) + +if (global.gc) global.gc() +const h2 = heapMB() + +// --- Per-getter timing --- +const singleGetterMs = bench(() => { + void store.total +}, ITERS) + +console.log(`${'Scenario'.padEnd(36)} ${'Time (ms)'.padStart(10)} ${'Per-call (ns)'.padStart(14)}`) +console.log('-'.repeat(62)) + +function row(label: string, ms: number) { + const perCallNs = ((ms / ITERS) * 1_000_000).toFixed(0) + console.log(`${label.padEnd(36)} ${ms.toFixed(2).padStart(10)} ${perCallNs.padStart(14)}`) +} + +row(`Warm reads (${ITERS.toLocaleString()} × 3 getters)`, warmMs) +row(`Single getter (${ITERS.toLocaleString()} reads)`, singleGetterMs) +row(`Mixed read+invalidate (1% churn)`, invalidMs) + +console.log() +console.log(`Heap after warm reads: ${h1.toFixed(2)} MB (delta: ${(h1-h0).toFixed(3)} MB)`) +console.log(`Heap after invalidation mix: ${h2.toFixed(2)} MB (delta: ${(h2-h1).toFixed(3)} MB)`) +console.log() +console.log('Memoized getters: repeated reads hit the cache (no recomputation).') +console.log('Invalidation: changing a dependency clears only the affected getters.') +console.log('Memory overhead: one WeakMap entry + dep set per getter instance.\n') diff --git a/packages/gea/benchmarks/sort-permutation.bench.ts b/packages/gea/benchmarks/sort-permutation.bench.ts new file mode 100644 index 00000000..b60c5a0e --- /dev/null +++ b/packages/gea/benchmarks/sort-permutation.bench.ts @@ -0,0 +1,102 @@ +/** + * Benchmark: sort/reverse permutation O(n²) → O(n) + * PR #38: Replace indexOf-in-loop with Map-based bucket lookup + * + * Run: npx tsx packages/gea/benchmarks/sort-permutation.bench.ts + */ + +function heapMB() { + return process.memoryUsage().heapUsed / 1024 / 1024 +} + +// ---------- OLD implementation (O(n²)) ---------- +function computePermutationOld(prev: any[], next: any[]): number[] { + return next.map((v) => { + const idx = prev.indexOf(v) + prev[idx] = undefined // mark consumed + return idx + }) +} + +// ---------- NEW implementation (O(n)) ---------- +function computePermutationNew(prev: any[], next: any[]): number[] { + const idxMap = new Map() + for (let i = 0; i < prev.length; i++) { + const a = idxMap.get(prev[i]) + a ? a.push(i) : idxMap.set(prev[i], [i]) + } + const cursors = new Map() + return next.map((v) => { + const bucket = idxMap.get(v)! + const cursor = cursors.get(v) ?? 0 + cursors.set(v, cursor + 1) + return bucket[cursor] + }) +} + +// ---------- Benchmark harness ---------- +function bench(label: string, fn: () => void, iters: number): number { + // warm-up + for (let i = 0; i < 5; i++) fn() + const t0 = performance.now() + for (let i = 0; i < iters; i++) fn() + return performance.now() - t0 +} + +function makeArray(size: number): number[] { + return Array.from({ length: size }, (_, i) => i) +} + +function shuffleArray(arr: number[]): number[] { + const a = arr.slice() + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} + +const SIZES = [100, 1000, 5000, 10000] +const ITERS = 200 + +console.log('\n=== sort/reverse permutation benchmark ===') +console.log(`${'Size'.padEnd(8)} ${'Old (ms)'.padStart(10)} ${'New (ms)'.padStart(10)} ${'Speedup'.padStart(10)} ${'Heap Δ (MB)'.padStart(12)}`) +console.log('-'.repeat(56)) + +for (const size of SIZES) { + const base = makeArray(size) + const shuffled = shuffleArray(base) + + const h0 = heapMB() + + const oldMs = bench( + 'old', + () => { + const prev = base.slice() + computePermutationOld(prev, shuffled) + }, + ITERS, + ) + + const h1 = heapMB() + + const newMs = bench( + 'new', + () => { + const prev = base.slice() + computePermutationNew(prev, shuffled) + }, + ITERS, + ) + + const h2 = heapMB() + const heapDelta = (h2 - h1).toFixed(3) + const speedup = (oldMs / newMs).toFixed(1) + + console.log( + `${String(size).padEnd(8)} ${oldMs.toFixed(2).padStart(10)} ${newMs.toFixed(2).padStart(10)} ${(speedup + 'x').padStart(10)} ${heapDelta.padStart(12)}`, + ) +} + +console.log('\nAll sizes: new implementation is O(n) vs O(n²) old.') +console.log('Speedup scales with array size. Heap delta is minimal.\n') diff --git a/packages/gea/src/lib/store.ts b/packages/gea/src/lib/store.ts index 7003e52c..338f71bc 100644 --- a/packages/gea/src/lib/store.ts +++ b/packages/gea/src/lib/store.ts @@ -152,6 +152,94 @@ function shouldWrapNestedReactiveValue(value: any): boolean { const getByPathParts = (obj: any, pathParts: string[]): any => pathParts.reduce((o: any, k: string) => o?.[k], obj) +/** Per-prototype getter name cache — computed once per subclass. */ +const _protoGetterCache = new WeakMap>() + +function _getProtoGetters(t: Store): Set { + const proto = Object.getPrototypeOf(t) + let cache = _protoGetterCache.get(proto) + if (!cache) { + cache = new Set() + let p: any = proto + while (p && p !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(p)) { + if (name === 'constructor') continue + const desc = Object.getOwnPropertyDescriptor(p, name) + if (desc?.get) cache.add(name) + } + p = Object.getPrototypeOf(p) + } + _protoGetterCache.set(proto, cache) + } + return cache +} + +interface GetterMemo { + cache: Map + fieldDeps: Map> + fieldToGetters: Map> + stack: string[] + stackSet: Set + uncacheable: Set +} + +const _getterMemo = new WeakMap() + +function _gm(t: Store): GetterMemo { + let m = _getterMemo.get(t) + if (!m) { + m = { cache: new Map(), fieldDeps: new Map(), fieldToGetters: new Map(), stack: [], stackSet: new Set(), uncacheable: new Set() } + _getterMemo.set(t, m) + } + return m +} + +function _gmRecordDep(m: GetterMemo, fieldName: string): void { + const getter = m.stack[m.stack.length - 1] + let deps = m.fieldDeps.get(getter) + if (!deps) { deps = new Set(); m.fieldDeps.set(getter, deps) } + if (!deps.has(fieldName)) { + deps.add(fieldName) + let gtrs = m.fieldToGetters.get(fieldName) + if (!gtrs) { gtrs = new Set(); m.fieldToGetters.set(fieldName, gtrs) } + gtrs.add(getter) + } +} + +function _gmMergeChild(m: GetterMemo, childGetter: string): void { + const parentGetter = m.stack[m.stack.length - 1] + const childDeps = m.fieldDeps.get(childGetter) + if (!childDeps || !childDeps.size) return + let parentDeps = m.fieldDeps.get(parentGetter) + if (!parentDeps) { parentDeps = new Set(); m.fieldDeps.set(parentGetter, parentDeps) } + for (const field of childDeps) { + if (!parentDeps.has(field)) { + parentDeps.add(field) + let gtrs = m.fieldToGetters.get(field) + if (!gtrs) { gtrs = new Set(); m.fieldToGetters.set(field, gtrs) } + gtrs.add(parentGetter) + } + } +} + +function _gmClearDeps(m: GetterMemo, getterName: string): void { + const oldDeps = m.fieldDeps.get(getterName) + if (!oldDeps) return + for (const field of oldDeps) { + const gtrs = m.fieldToGetters.get(field) + if (gtrs) { gtrs.delete(getterName); if (!gtrs.size) m.fieldToGetters.delete(field) } + } + m.fieldDeps.delete(getterName) +} + +function _gmInvalidate(t: Store, fieldName: string): void { + const m = _getterMemo.get(t) + if (!m) return + const gtrs = m.fieldToGetters.get(fieldName) + if (!gtrs || !gtrs.size) return + for (const getter of gtrs) m.cache.delete(getter) +} + function _wrapItem(store: Store, arr: any[], i: number, basePath: string, baseParts: string[]): any { const raw = arr[i] return shouldWrapNestedReactiveValue(raw) @@ -602,8 +690,15 @@ function _flushChanges(raw: Store, p: StoreInstancePrivate): void { } function _pushAndSchedule(raw: Store, changes: StoreChange | StoreChange[], p: StoreInstancePrivate): void { - if (_isArr(changes)) for (const c of changes) p.pendingChanges.push(c) - else p.pendingChanges.push(changes) + if (_isArr(changes)) { + for (const c of changes) { + p.pendingChanges.push(c) + if (c.pathParts.length > 0) _gmInvalidate(raw, c.pathParts[0]) + } + } else { + p.pendingChanges.push(changes) + if (changes.pathParts.length > 0) _gmInvalidate(raw, changes.pathParts[0]) + } if (p.pendingBatchKind !== 2) { p.pendingBatchKind = 2 p.pendingBatchArrayPathParts = null @@ -626,6 +721,7 @@ function _isAppend(oldArr: any[], newArr: any[], unwrap: boolean): boolean { function _queueChange(raw: Store, change: StoreChange, p: StoreInstancePrivate): void { p.pendingChanges.push(change) + if (change.pathParts.length > 0) _gmInvalidate(raw, change.pathParts[0]) if ( p.pendingBatchKind !== 2 && !(p.pendingBatchKind === 1 && p.pendingBatchArrayPathParts === change.arrayPathParts) @@ -1076,8 +1172,50 @@ export class Store { } static rootGetValue(t: Store, prop: string, receiver: any): any { - if (!_hasOwn.call(t, prop)) return Reflect.get(t, prop, receiver) + if (!_hasOwn.call(t, prop)) { + // Memoize user-defined getters on Store subclasses + if (_getProtoGetters(t).has(prop)) { + const m = _gm(t) + if (!m.stackSet.has(prop)) { + if (m.cache.has(prop)) { + if (m.stack.length > 0) _gmMergeChild(m, prop) + return m.cache.get(prop) + } + _gmClearDeps(m, prop) + m.uncacheable.delete(prop) + m.stack.push(prop) + m.stackSet.add(prop) + let value: any + try { + value = Reflect.get(t, prop, receiver) + } finally { + m.stack.pop() + m.stackSet.delete(prop) + if (m.uncacheable.has(prop) && m.stack.length > 0) { + m.uncacheable.add(m.stack[m.stack.length - 1]) + } + } + if (m.stack.length > 0) _gmMergeChild(m, prop) + const deps = m.fieldDeps.get(prop) + if (deps && deps.size > 0 && !m.uncacheable.has(prop)) { + m.cache.set(prop, value) + } + return value + } + } + return Reflect.get(t, prop, receiver) + } const value = (t as any)[prop] + // Track deps for any currently-computing getter + const gm = _getterMemo.get(t) + if (gm && gm.stack.length > 0) { + if (typeof value === 'function' || (prop as string).startsWith('_')) { + // Function fields and internal (_-prefixed) fields prevent caching + gm.uncacheable.add(gm.stack[gm.stack.length - 1]) + } else { + _gmRecordDep(gm, prop) + } + } if (typeof value === 'function') return value if (value != null && typeof value === 'object') { if (!_isPlain(value)) return value @@ -1107,6 +1245,7 @@ export class Store { p.topLevelProxies.delete(prop) } ;(t as any)[prop] = value + _gmInvalidate(t, prop) _pushAndSchedule(t, _mkChange(hadProp ? 'update' : 'add', prop, t, pathParts, value, oldValue), p) return true } @@ -1120,6 +1259,7 @@ export class Store { _dropOld(p, oldValue) p.topLevelProxies.delete(prop) ;(t as any)[prop] = value + _gmInvalidate(t, prop) _commitObjSet(t, !hadProp, prop, t, pathParts, value, oldValue, false, p) return true } @@ -1132,6 +1272,7 @@ export class Store { _dropOld(dp, oldValue) dp.topLevelProxies.delete(prop) delete (t as any)[prop] + _gmInvalidate(t, prop) _pushAndSchedule(t, [_mkChange('delete', prop, t, _rootPathPartsCache(dp, prop), undefined, oldValue)], dp) return true } diff --git a/packages/gea/tests/store-getter-memoization.test.ts b/packages/gea/tests/store-getter-memoization.test.ts new file mode 100644 index 00000000..3c536a10 --- /dev/null +++ b/packages/gea/tests/store-getter-memoization.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { Store } from '../src/lib/store' + +async function flush() { + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) +} + +class TodoStore extends Store { + todos: { done: boolean; text: string }[] = [] + filter: string = 'all' + + get filteredTodos() { + if (this.filter === 'done') return this.todos.filter((t) => t.done) + if (this.filter === 'active') return this.todos.filter((t) => !t.done) + return this.todos + } + + get count() { + return this.todos.length + } +} + +class ChainedStore extends Store { + items: number[] = [1, 2, 3, 4, 5] + + get evens() { + return this.items.filter((n) => n % 2 === 0) + } + + get evenCount() { + return this.evens.length + } +} + +describe('Store getter memoization', () => { + it('returns the same value on repeated access', () => { + const store = new TodoStore() + store.todos = [{ done: false, text: 'a' }] + const first = store.filteredTodos + const second = store.filteredTodos + assert.equal(first, second, 'repeated access should return same cached reference') + }) + + it('invalidates cache when a direct dependency changes', () => { + const store = new TodoStore() + store.todos = [{ done: false, text: 'a' }, { done: true, text: 'b' }] + store.filter = 'done' + const before = store.filteredTodos + assert.equal(before.length, 1) + + store.filter = 'active' + const after = store.filteredTodos + assert.equal(after.length, 1) + assert.notEqual(before, after, 'cache should be invalidated after dependency change') + }) + + it('recomputes correctly after array mutation', async () => { + const store = new TodoStore() + store.todos = [] + assert.equal(store.count, 0) + + store.todos = [{ done: false, text: 'x' }] + assert.equal(store.count, 1) + }) + + it('handles chained getters (getter depending on getter)', () => { + const store = new ChainedStore() + const firstEvenCount = store.evenCount + assert.equal(firstEvenCount, 2) + + assert.equal(store.evenCount, 2) + + store.items = [1, 2, 3, 4, 5, 6] + const newEvenCount = store.evenCount + assert.equal(newEvenCount, 3) + }) + + it('does not share cache between different store instances', () => { + const a = new TodoStore() + const b = new TodoStore() + a.todos = [{ done: false, text: 'a' }] + b.todos = [] + + assert.equal(a.count, 1) + assert.equal(b.count, 0) + + a.todos = [] + assert.equal(a.count, 0) + assert.equal(b.count, 0) + }) + + it('tracks computation count', () => { + let calls = 0 + class CountStore extends Store { + value = 42 + get doubled() { + calls++ + return this.value * 2 + } + } + + const store = new CountStore() + assert.equal(store.doubled, 84) + assert.equal(store.doubled, 84) + assert.equal(store.doubled, 84) + assert.equal(calls, 1, 'getter should only be computed once when deps do not change') + + store.value = 10 + assert.equal(store.doubled, 20) + assert.equal(calls, 2, 'getter should recompute after dependency change') + + store.value = 10 // same value + assert.equal(store.doubled, 20) + assert.equal(calls, 2, 'getter should NOT recompute when value is unchanged') + }) + + it('handles push() mutation invalidating getter cache', async () => { + class ListStore extends Store { + items: number[] = [] + get total() { + return this.items.reduce((s, n) => s + n, 0) + } + } + const store = new ListStore() + assert.equal(store.total, 0) + store.items.push(5) + await flush() + assert.equal(store.total, 5) + }) +}) + +describe('Store getter memoization – uncacheable getters', () => { + it('does not cache getter that reads an internal (_-prefixed) field', () => { + let calls = 0 + class MixedStore extends Store { + value = 5 + _factor = 2 + get total() { + calls++ + return this.value * (this as any)._factor + } + } + const store = new MixedStore() + assert.equal(store.total, 10) + assert.equal(store.total, 10) + assert.equal(calls, 2, 'getter reading internal field must not be cached') + + ;(store as any)._factor = 3 + assert.equal(store.total, 15, 'updated value when _factor changes') + }) + + it('does not cache getter that calls a function-valued own property', () => { + let calls = 0 + class FnStore extends Store { + value = 5 + multiply = (x: number) => x * 2 + get doubled() { + calls++ + return this.multiply(this.value) + } + } + const store = new FnStore() + assert.equal(store.doubled, 10) + assert.equal(store.doubled, 10) + assert.equal(calls, 2, 'getter calling function field must not be cached') + }) + + it('marks parent getter uncacheable when child getter is uncacheable', () => { + let childCalls = 0 + class ChainedUncacheable extends Store { + value = 5 + _scale = 2 + get inner() { + childCalls++ + return this.value * (this as any)._scale + } + get outer() { + return this.inner + 1 + } + } + const store = new ChainedUncacheable() + assert.equal(store.outer, 11) + assert.equal(store.outer, 11) + assert.equal(childCalls, 2, 'parent of uncacheable getter must also recompute') + }) +})