Files
peardata/shared/related-metrics.js
T
Raven Scott a7648dc090
CI / test (push) Successful in 1m1s
Release rolling / release (push) Successful in 6m59s
Updates
2026-07-18 20:32:05 -04:00

140 lines
3.7 KiB
JavaScript

/**
* Rank related charts for investigation (context/family + optional series correlation).
*/
/**
* @param {string} seedId
* @param {Record<string, object>} catalog
* @param {Map<string, { dims: Map<string, number[]> }>|null} [loaded]
* @param {{ limit?: number, weights?: Array<{ id: string, weight: number }>|Record<string, number>|null }} [opts]
* @returns {Array<{ id: string, score: number, reason: string }>}
*/
export function rankRelatedCharts(seedId, catalog, loaded = null, opts = {}) {
const seed = catalog?.[seedId]
if (!seed) return []
const limit = opts.limit ?? 12
/** @type {Map<string, number>} */
const weightMap = new Map()
if (Array.isArray(opts.weights)) {
for (const w of opts.weights) {
if (w?.id) weightMap.set(w.id, Number(w.weight) || 0)
}
} else if (opts.weights && typeof opts.weights === 'object') {
for (const [id, w] of Object.entries(opts.weights)) weightMap.set(id, Number(w) || 0)
}
/** @type {Array<{ id: string, score: number, reason: string }>} */
const out = []
for (const [id, meta] of Object.entries(catalog || {})) {
if (id === seedId) continue
let score = 0
/** @type {string[]} */
const reasons = []
if (meta.context && seed.context) {
if (meta.context === seed.context) {
score += 4
reasons.push('same context')
} else if (sharePrefix(String(meta.context), String(seed.context))) {
score += 2
reasons.push('related context')
}
}
if (meta.family && seed.family && meta.family === seed.family) {
score += 2
reasons.push('same family')
}
if (meta.plugin && seed.plugin && meta.plugin === seed.plugin) {
score += 1
reasons.push('same plugin')
}
if (meta.units && seed.units && meta.units === seed.units) {
score += 0.5
reasons.push('same units')
}
const seedCard = loaded?.get(seedId)
const other = loaded?.get(id)
if (seedCard?.dims?.size && other?.dims?.size) {
const corr = maxAbsCorrelation(seedCard.dims, other.dims)
if (corr >= 0.75) {
score += corr * 3
reasons.push(`corr ${corr.toFixed(2)}`)
} else if (corr >= 0.55) {
score += corr
reasons.push(`corr ${corr.toFixed(2)}`)
}
}
const aw = weightMap.get(id) || 0
if (aw > 0) {
score += aw * 2.5
reasons.push(`weight ${aw.toFixed(2)}`)
}
if (score > 0) out.push({ id, score, reason: reasons.join(', ') })
}
out.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
return out.slice(0, limit)
}
/**
* @param {string} a
* @param {string} b
*/
function sharePrefix(a, b) {
const aa = a.split('.')
const bb = b.split('.')
if (aa.length < 2 || bb.length < 2) return false
return aa[0] === bb[0] && (aa[1] === bb[1] || aa.length === 1 || bb.length === 1)
}
/**
* @param {Map<string, number[]>} a
* @param {Map<string, number[]>} b
*/
function maxAbsCorrelation(a, dimsB) {
let best = 0
for (const seriesA of a.values()) {
for (const seriesB of dimsB.values()) {
const c = Math.abs(pearson(seriesA, seriesB))
if (c > best) best = c
}
}
return best
}
/**
* @param {number[]} x
* @param {number[]} y
*/
export function pearson(x, y) {
const n = Math.min(x.length, y.length)
if (n < 8) return 0
const xs = x.slice(-n)
const ys = y.slice(-n)
let sx = 0
let sy = 0
for (let i = 0; i < n; i++) {
sx += xs[i]
sy += ys[i]
}
const mx = sx / n
const my = sy / n
let num = 0
let dx = 0
let dy = 0
for (let i = 0; i < n; i++) {
const a = xs[i] - mx
const b = ys[i] - my
num += a * b
dx += a * a
dy += b * b
}
const den = Math.sqrt(dx * dy)
if (!den) return 0
return num / den
}