From 5f7eeb5205dd34074840b239f9f346ad7b44ce06 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Mon, 2 Feb 2026 17:06:07 -0800 Subject: [PATCH] link sku --- viz/app/linker/price.js | 78 +++++++++++++++++++++++++++++++++++ viz/app/linker/suggestions.js | 22 +++++++++- viz/app/linker_page.js | 9 +++- 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 viz/app/linker/price.js diff --git a/viz/app/linker/price.js b/viz/app/linker/price.js new file mode 100644 index 0000000..29d92c4 --- /dev/null +++ b/viz/app/linker/price.js @@ -0,0 +1,78 @@ +// viz/app/linker/price.js +export function buildPricePenaltyForPair({ allAgg, rules, kPerGroup = 6 }) { + // canonSku -> sorted array of up to kPerGroup lowest prices + const groupPrices = new Map(); + + function insertPrice(arr, p) { + // keep sorted ascending, cap length + let i = 0; + while (i < arr.length && arr[i] <= p) i++; + arr.splice(i, 0, p); + if (arr.length > kPerGroup) arr.length = kPerGroup; + } + + for (const it of allAgg || []) { + if (!it) continue; + const sku = String(it.sku || ""); + if (!sku) continue; + + const p = it.cheapestPriceNum; + if (p == null || !(p > 0)) continue; + + const canon = String((rules && rules.canonicalSku && rules.canonicalSku(sku)) || sku); + let arr = groupPrices.get(canon); + if (!arr) groupPrices.set(canon, (arr = [])); + insertPrice(arr, p); + } + + function bestRelativeGap(prA, prB) { + // min |a-b| / min(a,b) + let best = Infinity; + for (let i = 0; i < prA.length; i++) { + const a = prA[i]; + for (let j = 0; j < prB.length; j++) { + const b = prB[j]; + const gap = Math.abs(a - b) / Math.max(1e-9, Math.min(a, b)); + if (gap < best) best = gap; + if (best <= 0.001) return best; + } + } + return best; + } + + function gapToMultiplier(gap) { + // gap = 0.40 => 40% relative difference + // <=35%: no penalty + // 35-50%: ease down to ~0.75 + // >50%: continue down gently, floor at 0.35 + if (!(gap >= 0)) return 1.0; + if (gap <= 0.35) return 1.0; + + if (gap <= 0.50) { + const t = (gap - 0.35) / 0.15; // 0..1 + return 1.0 - 0.25 * t; // 1.00 -> 0.75 + } + + const m = 0.75 * (0.5 / gap); + return Math.max(0.35, m); + } + + return function pricePenaltyForPair(aSku, bSku) { + const a = String(aSku || ""); + const b = String(bSku || ""); + if (!a || !b) return 1.0; + + const aCanon = String((rules && rules.canonicalSku && rules.canonicalSku(a)) || a); + const bCanon = String((rules && rules.canonicalSku && rules.canonicalSku(b)) || b); + + const prA = groupPrices.get(aCanon); + const prB = groupPrices.get(bCanon); + if (!prA || !prB || !prA.length || !prB.length) return 1.0; + + const gap = bestRelativeGap(prA, prB); + if (!isFinite(gap)) return 1.0; + + return gapToMultiplier(gap); + }; + } + \ No newline at end of file diff --git a/viz/app/linker/suggestions.js b/viz/app/linker/suggestions.js index 980a1ee..51941cf 100644 --- a/viz/app/linker/suggestions.js +++ b/viz/app/linker/suggestions.js @@ -63,6 +63,7 @@ export function recommendSimilar( mappedSkus, isIgnoredPairFn, sizePenaltyFn, + pricePenaltyFn, sameStoreFn, sameGroupFn ) { @@ -169,6 +170,11 @@ export function recommendSimilar( if (typeof sizePenaltyFn === "function") { s0 *= sizePenaltyFn(pinnedSku, itSku); } + + // Price penalty early + if (typeof pricePenaltyFn === "function") { + s0 *= pricePenaltyFn(pinnedSku, itSku); + } // Age handling early const itAge = extractAgeFromText(itNorm); @@ -215,6 +221,11 @@ export function recommendSimilar( s *= sizePenaltyFn(pinnedSku, itSku); if (s <= 0) continue; } + + if (typeof pricePenaltyFn === "function") { + s *= pricePenaltyFn(pinnedSku, itSku); + if (s <= 0) continue; + } const itAge = extractAgeFromText(itNorm); if (pinAge && itAge) { @@ -263,7 +274,8 @@ export function recommendSimilar( limitPairs, isIgnoredPairFn, sameStoreFn, - sizePenaltyFn // ✅ NEW: pass sizePenaltyForPair in + sizePenaltyFn, // ✅ NEW: pass sizePenaltyForPair in + pricePenaltyFn ) { const itemsAll = allAgg.filter((it) => !!it); @@ -455,6 +467,8 @@ export function recommendSimilar( } if (typeof sizePenaltyFn === "function") s *= sizePenaltyFn(aSku, bSku); + + if (typeof pricePenaltyFn === "function") s *= pricePenaltyFn(aSku, bSku); const bAge = extractAgeFromText(bNorm); if (aAge && bAge) { @@ -494,6 +508,11 @@ export function recommendSimilar( s *= sizePenaltyFn(aSku, bSku); if (s <= 0) continue; } + + if (typeof pricePenaltyFn === "function") { + s *= pricePenaltyFn(aSku, bSku); + if (s <= 0) continue; + } if (aAge && x.bAge) { if (aAge === x.bAge) s *= 2.0; @@ -577,4 +596,3 @@ export function recommendSimilar( } return h >>> 0; } - diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index 7f11276..69773f3 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -31,6 +31,7 @@ import { buildCanonStoreCache, makeSameStoreCanonFn } from "./linker/store_cache import { buildSizePenaltyForPair } from "./linker/size.js"; import { pickPreferredCanonical } from "./linker/canonical_pref.js"; import { smwsKeyFromName } from "./linker/similarity.js"; +import { buildPricePenaltyForPair } from "./linker/price.js"; import { topSuggestions, recommendSimilar, @@ -124,10 +125,14 @@ export async function renderSkuLinker($app) { // ✅ canonical-group size cache + helper let sizePenaltyForPair = buildSizePenaltyForPair({ allRows, allAgg, rules }); + // ✅ canonical-group price cache + helper + let pricePenaltyForPair = buildPricePenaltyForPair({ allAgg, rules }); + function rebuildCachesAfterRulesReload() { CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules); sameStoreCanon = makeSameStoreCanonFn(rules, CANON_STORE_CACHE); sizePenaltyForPair = buildSizePenaltyForPair({ allRows, allAgg, rules }); + pricePenaltyForPair = buildPricePenaltyForPair({ allAgg, rules }); } function isIgnoredPair(a, b) { @@ -156,7 +161,8 @@ export async function renderSkuLinker($app) { 28, isIgnoredPair, sameStoreCanon, - sizePenaltyForPair // ✅ NEW + sizePenaltyForPair, // ✅ NEW + pricePenaltyForPair // ✅ NEW ); return initialPairs; @@ -240,6 +246,7 @@ export async function renderSkuLinker($app) { mappedSkus, isIgnoredPair, sizePenaltyForPair, + pricePenaltyForPair, sameStoreCanon, sameGroup );