[object Object]

← back to Dw Pairs Well

auto-save: 2026-07-12T23:51:20 (1 files) — server.js

f1897392fc668d396dd4df3cce8472210c5ce675 · 2026-07-12 23:51:28 -0700 · Steve Abrams

Files touched

Diff

commit f1897392fc668d396dd4df3cce8472210c5ce675
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 23:51:28 2026 -0700

    auto-save: 2026-07-12T23:51:20 (1 files) — server.js
---
 server.js | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 57 insertions(+), 4 deletions(-)

diff --git a/server.js b/server.js
index 98f69b9..dbbdc66 100644
--- a/server.js
+++ b/server.js
@@ -308,7 +308,45 @@ async function clipSimilarTop(source, k) {
   }
 }
 
-function pickPairs(source, candidates, k = 24) {
+// CLIP style-world sub-score for PAIRS. A coordinate should share the hero's style
+// world WITHOUT visually competing — so mid-similarity is rewarded and near-duplicates
+// (same pattern re-colored) are penalized. Neutral (0) when the candidate has no
+// vector, so the un-embedded tail is never punished.
+const CLIP_NEAR_DUP  = 0.88;  // ≥ this = visually the same design → penalize
+const CLIP_STYLE_HI  = 0.55;  // sweet spot lower bound → +4
+const CLIP_STYLE_LO  = 0.45;  // weak style kinship → +2
+function clipPairBonus(cos) {
+  if (cos == null || !Number.isFinite(cos)) return 0;
+  if (cos >= CLIP_NEAR_DUP) return -4;
+  if (cos >= CLIP_STYLE_HI) return 4;
+  if (cos >= CLIP_STYLE_LO) return 2;
+  return 0;
+}
+
+// Bulk source-vs-candidates cosine map from the visual-search service.
+// Returns {} on any miss (service down / source not embedded) — blend degrades to
+// the pure tag+palette scorer, never blocks the response.
+async function clipPairwiseMap(source, candidates) {
+  if (!source.dw_sku) return {};
+  try {
+    const skus = candidates.map(c => c.dw_sku).filter(Boolean);
+    if (!skus.length) return {};
+    const r = await fetch(`${VISUAL_SEARCH_URL}/pairwise`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ dw_sku: source.dw_sku, candidates: skus }),
+      signal: AbortSignal.timeout(4000)
+    });
+    if (!r.ok) return {};
+    const d = await r.json();
+    return (d && d.ok && d.cosines) ? d.cosines : {};
+  } catch (e) {
+    console.error('clipPairwiseMap fallback →', e.message);
+    return {};
+  }
+}
+
+function pickPairs(source, candidates, k = 24, clipMap = null) {
   const sCls = classify(parseTags(source.tags));
   const sourcePalette = paletteOf(source);
 
@@ -321,15 +359,18 @@ function pickPairs(source, candidates, k = 24) {
 
     // Strong bias: motif is the whole point of this feature.
     const motifBonus = motif ? 6 : 0;
-    const totalScore = tagScore.score + overlap.score + motifBonus;
+    const clipCos = clipMap ? clipMap[c.dw_sku] : null;
+    const clipBonus = clipPairBonus(clipCos);
+    const totalScore = tagScore.score + overlap.score + motifBonus + clipBonus;
 
     const why = [];
     if (motif)            why.push(`Coordinating ${motif}`);
     if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 4).join(', ')}`);
+    if (clipBonus > 0)    why.push(`Same style world (CLIP ${Number(clipCos).toFixed(2)})`);
     if (overlap.avgDE != null)   why.push(`palette ΔE avg ${overlap.avgDE.toFixed(1)}`);
     if (tagScore.why.length) why.push(tagScore.why[0]);
 
-    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp, _overlapScore: overlap.score };
+    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp, _overlapScore: overlap.score, _clipCos: clipCos };
   });
 
   // HARD FILTER per Steve's rule: every coordinate MUST use colors from the source palette.
@@ -1034,7 +1075,18 @@ app.get('/api/pairs', async (req, res) => {
     // Limit query: caller can request up to 24 via ?limit=
     const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
     const candidates = await loadCandidates(source, 1200);
-    const top = pickPairs(source, candidates, requestedK);
+
+    // ?engine=clip blends a CLIP style-world sub-score into the tag+palette scorer
+    // (palette hard gate unchanged). clipPairwiseMap returns {} on any miss, which
+    // makes the blend a no-op — the response never depends on the service being up.
+    let usedEngine = 'tag';
+    let clipMap = null;
+    if (req.query.engine === 'clip') {
+      clipMap = await clipPairwiseMap(source, candidates);
+      if (Object.keys(clipMap).length) usedEngine = 'clip-blend';
+      else clipMap = null;
+    }
+    const top = pickPairs(source, candidates, requestedK, clipMap);
 
     // Collect every hex we need paint matches for: source palette + each pair palette
     const sourcePalette = paletteOf(source);
@@ -1063,6 +1115,7 @@ app.get('/api/pairs', async (req, res) => {
     res.set('Cache-Control', 'public, max-age=300');
     res.json({
       ok: true,
+      engine: usedEngine,
       source: {
         dw_sku: source.dw_sku,
         handle: source.handle,

← 399cce5 auto-save: 2026-07-12T23:21:12 (1 files) — server.js  ·  back to Dw Pairs Well  ·  pairs: PAIRS_CLIP_DEFAULT env flag (default OFF) blends CLIP ce3fda2 →