This commit is contained in:
Brennan Wilkes (Text Groove) 2026-02-02 17:06:07 -08:00
parent 91f6288b43
commit 5f7eeb5205
3 changed files with 106 additions and 3 deletions

78
viz/app/linker/price.js Normal file
View file

@ -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);
};
}

View file

@ -63,6 +63,7 @@ export function recommendSimilar(
mappedSkus,
isIgnoredPairFn,
sizePenaltyFn,
pricePenaltyFn,
sameStoreFn,
sameGroupFn
) {
@ -170,6 +171,11 @@ export function recommendSimilar(
s0 *= sizePenaltyFn(pinnedSku, itSku);
}
// Price penalty early
if (typeof pricePenaltyFn === "function") {
s0 *= pricePenaltyFn(pinnedSku, itSku);
}
// Age handling early
const itAge = extractAgeFromText(itNorm);
if (pinAge && itAge) {
@ -216,6 +222,11 @@ export function recommendSimilar(
if (s <= 0) continue;
}
if (typeof pricePenaltyFn === "function") {
s *= pricePenaltyFn(pinnedSku, itSku);
if (s <= 0) continue;
}
const itAge = extractAgeFromText(itNorm);
if (pinAge && itAge) {
if (pinAge === itAge) s *= 2.0;
@ -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);
@ -456,6 +468,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) {
if (aAge === bAge) s *= 1.6;
@ -495,6 +509,11 @@ export function recommendSimilar(
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;
else s *= 0.15;
@ -577,4 +596,3 @@ export function recommendSimilar(
}
return h >>> 0;
}

View file

@ -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
);