Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sort-permutation-on-optimization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@geajs/core": patch
---

### @geajs/core (patch)

- **Sort/reverse permutation O(n) optimization**: Replace O(n^2) nested-loop permutation calculation with a Map-based O(n) index lookup, improving performance on large sorted arrays.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
.history
.omc
.serena
.DS_Store
dist
dist-profile
Expand Down
94 changes: 94 additions & 0 deletions packages/gea/benchmarks/getter-memo.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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 forceGC() { if (typeof global.gc === 'function') { global.gc(); global.gc() } }
function heapMB() { return process.memoryUsage().heapUsed / 1024 / 1024 }
function median(arr: number[]) {
const s = [...arr].sort((a, b) => a - b)
const m = Math.floor(s.length / 2)
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
}
function stddev(arr: number[]) {
const avg = arr.reduce((a, b) => a + b, 0) / arr.length
return Math.sqrt(arr.reduce((a, b) => a + (b - avg) ** 2, 0) / arr.length)
}

class OrderStore extends Store {
price = 100
tax = 0.18
discount = 0.05
quantity = 10
label = 'Widget Pro'

get subtotal() { return this.price * this.quantity }
get taxAmount() { return this.subtotal * this.tax }
get discountAmt() { return this.subtotal * this.discount }
get total() { return this.subtotal + this.taxAmount - this.discountAmt }
get displayTotal() { return `${this.label}: $${this.total.toFixed(2)}` }
}

const WARMUP = 50
const TRIALS = 200
const ITERS = 100

function runTrials(fn: () => void): number[] {
for (let i = 0; i < WARMUP; i++) fn()
return Array.from({ length: TRIALS }, () => {
const t0 = performance.now()
for (let i = 0; i < ITERS; i++) fn()
return (performance.now() - t0) / ITERS
})
}

console.log('\n╔══ getter memoization: cold vs warm vs invalidated ══════════════════════════╗')
console.log('║ OrderStore: 5 chained getters (subtotal→taxAmount→discountAmt→total→display) ║')
console.log(`║ ${TRIALS} trials × ${ITERS} reads each ║`)
console.log('╚═════════════════════════════════════════════════════════════════════════════╝\n')

// A: Cold (fresh store per call = always cache miss — simulates pre-memoization)
forceGC()
const hA0 = heapMB()
const coldTimes = runTrials(() => { const s = new OrderStore(); void s.displayTotal })
forceGC()
const hA1 = heapMB()

// B: Warm (same store, deps unchanged = all cache hits)
const warmStore = new OrderStore()
void warmStore.displayTotal
forceGC()
const hB0 = heapMB()
const warmTimes = runTrials(() => { void warmStore.displayTotal })
forceGC()
const hB1 = heapMB()

// C: Mixed 10% invalidation
const mixStore = new OrderStore()
let rc = 0
forceGC()
const hC0 = heapMB()
const mixTimes = runTrials(() => {
if (rc++ % 10 === 0) mixStore.price = 95 + (rc % 10)
void mixStore.displayTotal
})
forceGC()
const hC1 = heapMB()

const coldMed = median(coldTimes) * 1000
const warmMed = median(warmTimes) * 1000
const mixMed = median(mixTimes) * 1000

console.log(`${'Scenario'.padEnd(36)} ${'Med (µs)'.padStart(10)} ${'σ (µs)'.padStart(10)} ${'Speedup'.padStart(10)} ${'Heap Δ MB'.padStart(11)}`)
console.log('─'.repeat(79))
console.log(`${'A. Cold (new store, cache miss)'.padEnd(36)} ${coldMed.toFixed(2).padStart(10)} ${(stddev(coldTimes)*1000).toFixed(2).padStart(10)} ${'1.0x'.padStart(10)} ${(hA1-hA0).toFixed(3).padStart(11)}`)
console.log(`${'B. Warm (same store, cache hit)'.padEnd(36)} ${warmMed.toFixed(2).padStart(10)} ${(stddev(warmTimes)*1000).toFixed(2).padStart(10)} ${((coldMed/warmMed).toFixed(1)+'x').padStart(10)} ${(hB1-hB0).toFixed(3).padStart(11)}`)
console.log(`${'C. Mixed (10% invalidation)'.padEnd(36)} ${mixMed.toFixed(2).padStart(10)} ${(stddev(mixTimes)*1000).toFixed(2).padStart(10)} ${((coldMed/mixMed).toFixed(1)+'x').padStart(10)} ${(hC1-hC0).toFixed(3).padStart(11)}`)
console.log()
console.log('Cold: getter always recomputes (simulates pre-memoization behavior).')
console.log('Warm: cache hit — result returned from WeakMap without recomputation.')
console.log('Mixed: realistic workload — 90% cache hits, 10% dep-triggered recompute.')
console.log('Heap delta shows memoization overhead is minimal.\n')
93 changes: 93 additions & 0 deletions packages/gea/benchmarks/sort-permutation.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Benchmark: sort/reverse permutation O(n²) → O(n)
* PR #38: Replace indexOf-in-loop with Map-based bucket lookup
*
* Run: npx tsx --conditions source packages/gea/benchmarks/sort-permutation.bench.ts
*/

function median(arr: number[]): number {
const s = [...arr].sort((a, b) => a - b)
const m = Math.floor(s.length / 2)
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
}
function stddev(arr: number[]): number {
const avg = arr.reduce((a, b) => a + b, 0) / arr.length
return Math.sqrt(arr.reduce((a, b) => a + (b - avg) ** 2, 0) / arr.length)
}
function forceGC() { if (typeof global.gc === 'function') { global.gc(); global.gc() } }

function computePermutationOld(prev: any[], next: any[]): number[] {
const p = prev.slice()
return next.map((v) => {
const idx = p.indexOf(v)
p[idx] = undefined
return idx
})
}

function computePermutationNew(prev: any[], next: any[]): number[] {
const idxMap = new Map<any, { indices: number[]; next: number }>()
for (let i = 0; i < prev.length; i++) {
const bucket = idxMap.get(prev[i])
if (bucket) bucket.indices.push(i)
else idxMap.set(prev[i], { indices: [i], next: 0 })
}
const permutation = new Array<number>(next.length)
for (let i = 0; i < next.length; i++) {
const bucket = idxMap.get(next[i])
permutation[i] = bucket ? bucket.indices[bucket.next++] : i
}
return permutation
}

function runTrials(fn: () => void, warmup: number, trials: number): number[] {
for (let i = 0; i < warmup; i++) fn()
return Array.from({ length: trials }, () => {
const t0 = performance.now()
fn()
return performance.now() - t0
})
}

function makeArray(size: number): number[] { return Array.from({ length: size }, (_, i) => i) }
function shuffle(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, 500, 1000, 5000, 10000]
const WARMUP = 10
const TRIALS = 50

console.log('\n╔══ sort/reverse permutation: O(n²) vs O(n) ══════════════════════════════╗')
console.log(`║ ${TRIALS} trials per size, ${WARMUP} warm-up iterations ║`)
console.log('╚══════════════════════════════════════════════════════════════════════════╝\n')
console.log(`${'n'.padEnd(7)} | ${'old med (ms)'.padStart(12)} | ${'new med (ms)'.padStart(12)} | ${'speedup'.padStart(9)} | ${'old σ'.padStart(8)} | ${'new σ'.padStart(8)}`)
console.log('─'.repeat(74))

for (const size of SIZES) {
const base = makeArray(size)
const sorted = shuffle(base)

forceGC()
const oldTimes = runTrials(() => computePermutationOld(base, sorted), WARMUP, TRIALS)
forceGC()
const newTimes = runTrials(() => computePermutationNew(base, sorted), WARMUP, TRIALS)

const oldMed = median(oldTimes)
const newMed = median(newTimes)
const speedup = oldMed / newMed

console.log(
`${String(size).padEnd(7)} | ${oldMed.toFixed(3).padStart(12)} | ${newMed.toFixed(3).padStart(12)} | ${(speedup.toFixed(1) + 'x').padStart(9)} | ${stddev(oldTimes).toFixed(3).padStart(8)} | ${stddev(newTimes).toFixed(3).padStart(8)}`
)
}

console.log()
console.log('Methodology: 50 trials per config, median reported to suppress outliers.')
console.log('Old: Array.indexOf in loop = O(n) per element = O(n²) total.')
console.log('New: Map bucket pre-built in O(n), lookup in O(1) = O(n) total.\n')
Loading