feat: add aggregation engine with incremental SUM/COUNT/AVG/MIN/MAX, GROUP BY, and time windows
Add a write-time incremental aggregation engine that maintains running
totals on every add/update/delete for O(1) read performance. Integrates
into brain.find({ aggregate }) for a unified query API.
Core features:
- AggregationIndex with defineAggregate()/removeAggregate() API
- Five aggregation operations: SUM, COUNT, AVG, MIN, MAX
- GROUP BY with multiple dimensions including time windows
- Time window bucketing: hour, day, week, month, quarter, year, custom
- Materialization of results as NounType.Measurement entities
- Debounced persistence of definitions and state to storage
- Definition change detection via FNV-1a hashing with auto-rebuild
- Infinite loop prevention for materialized entities
- 'aggregation' plugin provider key for native acceleration
- Lazy initialization (created on first defineAggregate() call)
- 73 tests (unit + integration) covering all functionality
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:57:53 -08:00
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { Brainy } from '../../src/brainy'
|
|
|
|
|
import { NounType } from '../../src/types/graphTypes'
|
|
|
|
|
import type { AggregateDefinition } from '../../src/types/brainy.types'
|
|
|
|
|
|
|
|
|
|
describe('Aggregation Engine Integration', () => {
|
|
|
|
|
let brain: Brainy<any>
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
2026-06-09 14:58:25 -07:00
|
|
|
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
feat: add aggregation engine with incremental SUM/COUNT/AVG/MIN/MAX, GROUP BY, and time windows
Add a write-time incremental aggregation engine that maintains running
totals on every add/update/delete for O(1) read performance. Integrates
into brain.find({ aggregate }) for a unified query API.
Core features:
- AggregationIndex with defineAggregate()/removeAggregate() API
- Five aggregation operations: SUM, COUNT, AVG, MIN, MAX
- GROUP BY with multiple dimensions including time windows
- Time window bucketing: hour, day, week, month, quarter, year, custom
- Materialization of results as NounType.Measurement entities
- Debounced persistence of definitions and state to storage
- Definition change detection via FNV-1a hashing with auto-rebuild
- Infinite loop prevention for materialized entities
- 'aggregation' plugin provider key for native acceleration
- Lazy initialization (created on first defineAggregate() call)
- 73 tests (unit + integration) covering all functionality
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:57:53 -08:00
|
|
|
await brain.init()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Core: Define + Add + Query =============
|
|
|
|
|
|
|
|
|
|
describe('define → add → query lifecycle', () => {
|
|
|
|
|
it('should accumulate metrics as entities are added', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'spending',
|
|
|
|
|
source: { type: NounType.Event, where: { domain: 'financial' } },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' },
|
|
|
|
|
average: { op: 'avg', field: 'amount' },
|
|
|
|
|
highest: { op: 'max', field: 'amount' },
|
|
|
|
|
lowest: { op: 'min', field: 'amount' }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Add transactions
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Coffee',
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'financial', category: 'food', amount: 5 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Lunch',
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'financial', category: 'food', amount: 15 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Uber',
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'financial', category: 'transport', amount: 25 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Query all groups
|
|
|
|
|
const results = await brain.find({ aggregate: 'spending' })
|
|
|
|
|
expect(results).toHaveLength(2)
|
|
|
|
|
|
|
|
|
|
// Check food group
|
|
|
|
|
const food = results.find(r => r.metadata?.category === 'food')!
|
|
|
|
|
expect(food).toBeDefined()
|
|
|
|
|
expect(food.metadata.total).toBe(20)
|
|
|
|
|
expect(food.metadata.count).toBe(2)
|
|
|
|
|
expect(food.metadata.average).toBe(10)
|
|
|
|
|
expect(food.metadata.highest).toBe(15)
|
|
|
|
|
expect(food.metadata.lowest).toBe(5)
|
|
|
|
|
|
|
|
|
|
// Check transport group
|
|
|
|
|
const transport = results.find(r => r.metadata?.category === 'transport')!
|
|
|
|
|
expect(transport).toBeDefined()
|
|
|
|
|
expect(transport.metadata.total).toBe(25)
|
|
|
|
|
expect(transport.metadata.count).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should filter aggregate results with where clause', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_cat',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Coffee', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', amount: 5 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Uber', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'transport', amount: 25 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
aggregate: 'by_cat',
|
|
|
|
|
where: { category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
expect(results).toHaveLength(1)
|
|
|
|
|
expect(results[0].metadata.category).toBe('food')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should support sorting and pagination on aggregates', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_cat',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const categories = ['food', 'transport', 'housing', 'entertainment']
|
|
|
|
|
for (let i = 0; i < categories.length; i++) {
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: `Expense ${i}`, type: NounType.Event,
|
|
|
|
|
metadata: { category: categories[i], amount: (i + 1) * 100 }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort descending, limit 2
|
|
|
|
|
const results = await brain.find({
|
|
|
|
|
aggregate: { name: 'by_cat', orderBy: 'total', order: 'desc', limit: 2 }
|
|
|
|
|
})
|
|
|
|
|
expect(results).toHaveLength(2)
|
|
|
|
|
expect(results[0].metadata.total).toBe(400)
|
|
|
|
|
expect(results[1].metadata.total).toBe(300)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Incremental Updates =============
|
|
|
|
|
|
|
|
|
|
describe('incremental updates', () => {
|
|
|
|
|
it('should update aggregates when entities are modified', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'totals',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const id = await brain.add({
|
|
|
|
|
data: 'Coffee', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', amount: 5 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify initial state
|
|
|
|
|
let results = await brain.find({ aggregate: 'totals' })
|
|
|
|
|
expect(results[0].metadata.total).toBe(5)
|
|
|
|
|
|
|
|
|
|
// Update amount
|
|
|
|
|
await brain.update({
|
|
|
|
|
id,
|
|
|
|
|
metadata: { category: 'food', amount: 15 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
results = await brain.find({ aggregate: 'totals' })
|
|
|
|
|
expect(results[0].metadata.total).toBe(15)
|
|
|
|
|
expect(results[0].metadata.count).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should update aggregates when entities are deleted', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'totals',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const id1 = await brain.add({
|
|
|
|
|
data: 'Coffee', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', amount: 5 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Lunch', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', amount: 15 }
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-11 14:51:00 -07:00
|
|
|
await brain.remove(id1)
|
feat: add aggregation engine with incremental SUM/COUNT/AVG/MIN/MAX, GROUP BY, and time windows
Add a write-time incremental aggregation engine that maintains running
totals on every add/update/delete for O(1) read performance. Integrates
into brain.find({ aggregate }) for a unified query API.
Core features:
- AggregationIndex with defineAggregate()/removeAggregate() API
- Five aggregation operations: SUM, COUNT, AVG, MIN, MAX
- GROUP BY with multiple dimensions including time windows
- Time window bucketing: hour, day, week, month, quarter, year, custom
- Materialization of results as NounType.Measurement entities
- Debounced persistence of definitions and state to storage
- Definition change detection via FNV-1a hashing with auto-rebuild
- Infinite loop prevention for materialized entities
- 'aggregation' plugin provider key for native acceleration
- Lazy initialization (created on first defineAggregate() call)
- 73 tests (unit + integration) covering all functionality
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:57:53 -08:00
|
|
|
|
|
|
|
|
const results = await brain.find({ aggregate: 'totals' })
|
|
|
|
|
expect(results).toHaveLength(1)
|
|
|
|
|
expect(results[0].metadata.total).toBe(15)
|
|
|
|
|
expect(results[0].metadata.count).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Time Windows =============
|
|
|
|
|
|
|
|
|
|
describe('time-windowed aggregates', () => {
|
|
|
|
|
it('should group by month using time window', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'monthly',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: [
|
|
|
|
|
'category',
|
|
|
|
|
{ field: 'date', window: 'month' }
|
|
|
|
|
],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const jan = Date.UTC(2024, 0, 15)
|
|
|
|
|
const feb = Date.UTC(2024, 1, 15)
|
|
|
|
|
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Coffee Jan', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', date: jan, amount: 100 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Coffee Feb', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', date: feb, amount: 200 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Lunch Jan', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', date: jan, amount: 50 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({ aggregate: 'monthly' })
|
|
|
|
|
expect(results).toHaveLength(2)
|
|
|
|
|
|
|
|
|
|
const janGroup = results.find(r => r.metadata.date === '2024-01')!
|
|
|
|
|
const febGroup = results.find(r => r.metadata.date === '2024-02')!
|
|
|
|
|
expect(janGroup.metadata.total).toBe(150)
|
|
|
|
|
expect(janGroup.metadata.count).toBe(2)
|
|
|
|
|
expect(febGroup.metadata.total).toBe(200)
|
|
|
|
|
expect(febGroup.metadata.count).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Non-matching entities =============
|
|
|
|
|
|
|
|
|
|
describe('source filtering', () => {
|
|
|
|
|
it('should only aggregate entities matching the source filter', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'financial_only',
|
|
|
|
|
source: { type: NounType.Event, where: { domain: 'financial' } },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Matching entity
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Purchase', type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'financial', category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Non-matching: wrong type
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Person', type: NounType.Person,
|
|
|
|
|
metadata: { domain: 'financial', category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Non-matching: wrong domain
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Game event', type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'gaming', category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({ aggregate: 'financial_only' })
|
|
|
|
|
expect(results).toHaveLength(1)
|
|
|
|
|
expect(results[0].metadata.count).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Multiple Aggregates =============
|
|
|
|
|
|
|
|
|
|
describe('multiple overlapping aggregates', () => {
|
|
|
|
|
it('should maintain independent aggregates on the same entities', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_category',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_merchant',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['merchant'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Coffee', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', merchant: 'Starbucks', amount: 5 }
|
|
|
|
|
})
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Tea', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food', merchant: 'Teavana', amount: 4 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const catResults = await brain.find({ aggregate: 'by_category' })
|
|
|
|
|
const merchResults = await brain.find({ aggregate: 'by_merchant' })
|
|
|
|
|
|
|
|
|
|
expect(catResults).toHaveLength(1)
|
|
|
|
|
expect(catResults[0].metadata.total).toBe(9)
|
|
|
|
|
|
|
|
|
|
expect(merchResults).toHaveLength(2)
|
|
|
|
|
const starbucks = merchResults.find(r => r.metadata.merchant === 'Starbucks')!
|
|
|
|
|
const teavana = merchResults.find(r => r.metadata.merchant === 'Teavana')!
|
|
|
|
|
expect(starbucks.metadata.total).toBe(5)
|
|
|
|
|
expect(teavana.metadata.total).toBe(4)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Error Handling =============
|
|
|
|
|
|
|
|
|
|
describe('error handling', () => {
|
|
|
|
|
it('should throw when querying an undefined aggregate', async () => {
|
|
|
|
|
await expect(brain.find({ aggregate: 'nonexistent' }))
|
|
|
|
|
.rejects.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should throw when defining aggregate without groupBy', () => {
|
|
|
|
|
expect(() => brain.defineAggregate({
|
|
|
|
|
name: 'bad',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: [],
|
|
|
|
|
metrics: { count: { op: 'count' } }
|
|
|
|
|
})).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= removeAggregate =============
|
|
|
|
|
|
|
|
|
|
describe('removeAggregate', () => {
|
|
|
|
|
it('should remove aggregate and reject subsequent queries', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'temp',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Test', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Aggregate exists
|
|
|
|
|
const results = await brain.find({ aggregate: 'temp' })
|
|
|
|
|
expect(results).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
// Remove it
|
|
|
|
|
brain.removeAggregate('temp')
|
|
|
|
|
|
|
|
|
|
// Should throw on query
|
|
|
|
|
await expect(brain.find({ aggregate: 'temp' })).rejects.toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Result format =============
|
|
|
|
|
|
|
|
|
|
describe('result format', () => {
|
|
|
|
|
it('should return Result<T>[] with Measurement type', async () => {
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'test',
|
|
|
|
|
source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: 'Test', type: NounType.Event,
|
|
|
|
|
metadata: { category: 'food' }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const results = await brain.find({ aggregate: 'test' })
|
|
|
|
|
expect(results).toHaveLength(1)
|
|
|
|
|
|
|
|
|
|
const result = results[0]
|
|
|
|
|
expect(result.id).toBeDefined()
|
|
|
|
|
expect(result.score).toBe(1.0)
|
|
|
|
|
expect(result.type).toBe(NounType.Measurement)
|
|
|
|
|
expect(result.entity).toBeDefined()
|
|
|
|
|
expect(result.entity.type).toBe(NounType.Measurement)
|
|
|
|
|
expect(result.entity.service).toBe('brainy:aggregation')
|
|
|
|
|
expect(result.metadata.__aggregate).toBe('test')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============= Scale test =============
|
|
|
|
|
|
|
|
|
|
describe('scale', () => {
|
|
|
|
|
it('should handle 100 entities with 3 aggregates', async () => {
|
|
|
|
|
// Note: Full scale testing (10K+ entities) is in unit tests which skip
|
|
|
|
|
// embedding overhead. This integration test verifies end-to-end wiring
|
|
|
|
|
// with real Brainy operations including embeddings.
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_category', source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' }, count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'by_merchant', source: { type: NounType.Event },
|
|
|
|
|
groupBy: ['merchant'],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' } }
|
|
|
|
|
})
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
name: 'monthly', source: { type: NounType.Event },
|
|
|
|
|
groupBy: [{ field: 'date', window: 'month' }],
|
|
|
|
|
metrics: { total: { op: 'sum', field: 'amount' }, count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const categories = ['food', 'transport', 'housing', 'entertainment', 'utilities']
|
|
|
|
|
const merchants = ['Starbucks', 'Uber', 'Amazon', 'Netflix', 'Con Edison']
|
|
|
|
|
const baseDate = Date.UTC(2024, 0, 1)
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: `Transaction ${i}`,
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: {
|
|
|
|
|
category: categories[i % 5],
|
|
|
|
|
merchant: merchants[i % 5],
|
|
|
|
|
amount: Math.round(Math.random() * 10000) / 100,
|
|
|
|
|
date: baseDate + (i % 90) * 86400_000
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify category aggregate
|
|
|
|
|
const catResults = await brain.find({ aggregate: 'by_category' })
|
|
|
|
|
expect(catResults).toHaveLength(5)
|
|
|
|
|
const totalCount = catResults.reduce((sum, r) => sum + (r.metadata.count as number), 0)
|
|
|
|
|
expect(totalCount).toBe(100)
|
|
|
|
|
|
|
|
|
|
// Verify merchant aggregate
|
|
|
|
|
const merchResults = await brain.find({ aggregate: 'by_merchant' })
|
|
|
|
|
expect(merchResults).toHaveLength(5)
|
|
|
|
|
|
|
|
|
|
// Verify monthly aggregate
|
|
|
|
|
const monthlyResults = await brain.find({ aggregate: 'monthly' })
|
|
|
|
|
expect(monthlyResults.length).toBeGreaterThan(0)
|
|
|
|
|
const monthlyTotalCount = monthlyResults.reduce(
|
|
|
|
|
(sum, r) => sum + (r.metadata.count as number), 0
|
|
|
|
|
)
|
|
|
|
|
expect(monthlyTotalCount).toBe(100)
|
|
|
|
|
}, 60_000)
|
|
|
|
|
})
|
|
|
|
|
})
|