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:
David Snelling 2026-06-20 14:40:57 -07:00
parent 606445cd61
commit d02e522a3e
14 changed files with 943 additions and 100 deletions

View file

@ -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·(n1)`, 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