feat(8.0): id-normalization (#18) + aggregation min/max delete-safety + RC-safe release
- id-normalization (#18): `brain.newId()` (UUID v7) + v7 default ids; non-UUID string ids are transparently normalized to a stable UUID v5 on creation AND every lookup — get/update/remove, relate(from,to), related, find({connected}), getMany, removeMany, the transact op-handler, and db.with() overlays — with the caller's original key preserved under `_originalId`. The engine only ever sees a UUID; real UUIDs pass through untouched. universal/uuid.ts gains v5/v7/isUUID + BRAINY_ID_NAMESPACE; new utils/idNormalization.ts (coerceNewEntityId / resolveEntityId). - aggregation min/max delete-safety: `queryAggregate` no longer leaves a stale min/max after deleting the current extreme — min/max now track a value multiset and recompute in-memory (no entity scan; that scan was the prior delete-then-hang a consumer reported). Other metrics were already exact across deletes. Regression test proves resolve-after-delete + correct new min/max. - release.sh RC-safe: explicit version arg, prerelease detection (npm `--tag rc` + GitHub `--prerelease`), full `test:ci` gate (unit + integration), current-branch push, npm-access verification after publish. - native-engine references point at `@soulcraft/cor` (8.0's partner) in user-facing strings/docs. Unit 1461/0 · integration 599/0 · tsc clean.
This commit is contained in:
parent
606445cd61
commit
d02e522a3e
14 changed files with 943 additions and 100 deletions
|
|
@ -200,9 +200,11 @@ function updateMetricAdd(state: MetricState, val: number, op: AggregationOp): vo
|
|||
state.m2 = (state.m2 ?? 0) + (val - oldMean) * (val - mean)
|
||||
}
|
||||
|
||||
// Percentile: track the numeric value multiset (matches Cortex's value_counts) for the
|
||||
// exact-percentile computation. (distinctCount is tracked separately, on raw values.)
|
||||
if (op === 'percentile') {
|
||||
// Track the numeric value multiset for percentile AND min/max. min/max need it
|
||||
// to recompute the extreme after a delete WITHOUT scanning entities — that scan
|
||||
// was the 7.x infinite-loop / hang (materialized aggregates fed back in).
|
||||
// (distinctCount is tracked separately, on raw values.)
|
||||
if (op === 'percentile' || op === 'min' || op === 'max') {
|
||||
if (!state.valueCounts) state.valueCounts = {}
|
||||
const key = String(val)
|
||||
state.valueCounts[key] = (state.valueCounts[key] ?? 0) + 1
|
||||
|
|
@ -214,9 +216,9 @@ function updateMetricAdd(state: MetricState, val: number, op: AggregationOp): vo
|
|||
* Note: removing from Welford's is the inverse update.
|
||||
*/
|
||||
function updateMetricRemove(state: MetricState, val: number, op: AggregationOp): void {
|
||||
// Percentile: decrement the numeric value multiset (drop the key at zero).
|
||||
// (distinctCount is decremented separately, on raw values.)
|
||||
if (op === 'percentile' && state.valueCounts) {
|
||||
// Decrement the numeric value multiset for percentile AND min/max (drop the key
|
||||
// at zero). (distinctCount is decremented separately, on raw values.)
|
||||
if ((op === 'percentile' || op === 'min' || op === 'max') && state.valueCounts) {
|
||||
const key = String(val)
|
||||
const c = state.valueCounts[key]
|
||||
if (c !== undefined) {
|
||||
|
|
@ -241,6 +243,29 @@ function updateMetricRemove(state: MetricState, val: number, op: AggregationOp):
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a metric's min/max from its value multiset — O(distinct values), fully
|
||||
* in-memory, NO entity scan. Called lazily on query when a delete of the current
|
||||
* extreme marked it stale. (Scanning entities to recompute is what hung 7.x.)
|
||||
*/
|
||||
function recomputeMinMaxFromCounts(state: MetricState): void {
|
||||
const keys = state.valueCounts ? Object.keys(state.valueCounts) : []
|
||||
if (keys.length === 0) {
|
||||
state.min = Infinity
|
||||
state.max = -Infinity
|
||||
return
|
||||
}
|
||||
let mn = Infinity
|
||||
let mx = -Infinity
|
||||
for (const k of keys) {
|
||||
const n = Number(k)
|
||||
if (n < mn) mn = n
|
||||
if (n > mx) mx = n
|
||||
}
|
||||
state.min = mn
|
||||
state.max = mx
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact percentile over a value multiset, using linear interpolation between closest ranks
|
||||
* (numpy 'linear' / type-7): `rank = p·(n−1)`, interpolating between the floor and ceil
|
||||
|
|
@ -610,7 +635,7 @@ export class AggregationIndex {
|
|||
// Collect all groups
|
||||
let results: AggregateResult[] = []
|
||||
|
||||
for (const group of stateMap.values()) {
|
||||
for (const [serialized, group] of stateMap.entries()) {
|
||||
// Skip empty groups (all metrics at zero count)
|
||||
const hasData = Object.values(group.metrics).some(m => m.count > 0)
|
||||
if (!hasData) continue
|
||||
|
|
@ -639,11 +664,24 @@ export class AggregationIndex {
|
|||
metrics[metricName] = state.count > 0 ? state.sum / state.count : 0
|
||||
break
|
||||
case 'min':
|
||||
metrics[metricName] = state.min === Infinity ? 0 : state.min
|
||||
break
|
||||
case 'max':
|
||||
metrics[metricName] = state.max === -Infinity ? 0 : state.max
|
||||
case 'max': {
|
||||
// Lazily recompute the extreme from the value multiset if a delete of
|
||||
// the current min/max marked it stale (hang-free; no entity scan).
|
||||
const staleSet = this.staleMinMax.get(params.name)
|
||||
if (staleSet && staleSet.has(`${serialized}:${metricName}`)) {
|
||||
recomputeMinMaxFromCounts(state)
|
||||
staleSet.delete(`${serialized}:${metricName}`)
|
||||
}
|
||||
metrics[metricName] =
|
||||
metricDef.op === 'min'
|
||||
? state.min === Infinity
|
||||
? 0
|
||||
: state.min
|
||||
: state.max === -Infinity
|
||||
? 0
|
||||
: state.max
|
||||
break
|
||||
}
|
||||
case 'variance':
|
||||
metrics[metricName] = state.count > 1 ? (state.m2 ?? 0) / (state.count - 1) : 0
|
||||
break
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue