/** * Rank related charts for investigation (context/family + optional series correlation). */ /** * @param {string} seedId * @param {Record} catalog * @param {Map }>|null} [loaded] * @param {{ limit?: number, weights?: Array<{ id: string, weight: number }>|Record|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} */ 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} a * @param {Map} 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 }