Skip to content

Commit

Permalink
feat: modded alg to stop recommending outfits with the same core items (
Browse files Browse the repository at this point in the history
#128)

* feat: modded alg to stop recommending outfits with the same core items

* nit: linting

* fix: correct date formatting
  • Loading branch information
rak3rman authored Jan 8, 2025
1 parent 109913d commit 74d468f
Show file tree
Hide file tree
Showing 3 changed files with 62 additions and 42 deletions.
81 changes: 46 additions & 35 deletions hono/src/services/outfits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,18 +222,18 @@ app.get('/suggest', zValidator('query', suggestionsValidation), injectDB, async
ARRAY_AGG(DISTINCT ${itemsToOutfits.itemId}::text)
FILTER (WHERE ${itemsToOutfits.itemType}::text IN ('layer', 'top', 'bottom'))
`,
similarOutfitsCount: sql`
recentlyWornItemCount: sql`
(
SELECT COUNT(DISTINCT o2.id)
FROM outfits o2
JOIN items_to_outfits io2 ON io2.outfit_id = o2.id
WHERE o2.wear_date > NOW() - INTERVAL '14 days'
AND io2.item_type::text IN ('layer', 'top', 'bottom')
AND io2.item_id IN (
SELECT io1.item_id
FROM items_to_outfits io1
WHERE io1.outfit_id = ${outfits.id}
AND io1.item_type::text IN ('layer', 'top', 'bottom')
SELECT COUNT(DISTINCT io1.item_id)
FROM items_to_outfits io1
WHERE io1.outfit_id = ${outfits.id}
AND io1.item_type::text IN ('layer', 'top', 'bottom')
AND EXISTS (
SELECT 1
FROM outfits o2
JOIN items_to_outfits io2 ON io2.outfit_id = o2.id
WHERE o2.wear_date > NOW() - make_interval(days => ${recencyThreshold})
AND io2.item_id = io1.item_id
)
)::integer
`,
Expand Down Expand Up @@ -305,10 +305,17 @@ app.get('/suggest', zValidator('query', suggestionsValidation), injectDB, async
)
const seasonal_score = Math.trunc((outfit.seasonalRelevance as number) * 15)

// Increase similarity penalty
const similarity_penalty = Math.trunc(
Math.max(-45, (outfit.similarOutfitsCount as number) * -20)
)
// Calculate exponential penalty for recently worn items
const recentlyWornCount = outfit.recentlyWornItemCount as number
const similarity_penalty = (() => {
if (recentlyWornCount === 0) return 0

// Exponential penalty based on number of recently worn items
// 1 item: -20, 2 items: -45, 3 items: -80, 4+ items: -125
const basePenalty = -20
const penaltyMultiplier = Math.pow(1.5, recentlyWornCount)
return Math.trunc(Math.max(-125, basePenalty * penaltyMultiplier))
})()

return {
base_score,
Expand All @@ -321,15 +328,35 @@ app.get('/suggest', zValidator('query', suggestionsValidation), injectDB, async
}
}

const scoredOutfits = outfitsWithScores.map((outfit) => ({
const scoredOutfits = outfitsWithScores
// First, group outfits by their core items and keep only the most recent one
.reduce((acc, outfit) => {
const coreItemsKey = (outfit.coreItems as string[])
.filter((id) => id) // Remove any null/undefined values
.sort()
.join('|')

// Only keep this outfit if it's more recent than existing one with same core items
if (
!acc.has(coreItemsKey) ||
new Date(outfit.lastWorn as string) > new Date(acc.get(coreItemsKey)!.lastWorn as string)
) {
acc.set(coreItemsKey, outfit)
}
return acc
}, new Map())
.values()

// Convert to array and calculate scores
const uniqueOutfits = [...scoredOutfits].map((outfit) => ({
outfitId: outfit.id,
score: Object.values(calculateScores(outfit)).reduce((a, b) => a + b, 0),
}))

const suggestedOutfits = await c.get('db').query.outfits.findMany({
where: inArray(
outfits.id,
scoredOutfits
uniqueOutfits
.sort((a, b) => b.score - a.score)
.slice(pageNumber * pageSize, pageNumber * pageSize + pageSize + 1)
.map((o) => o.outfitId)
Expand Down Expand Up @@ -373,31 +400,15 @@ app.get('/suggest', zValidator('query', suggestionsValidation), injectDB, async
days_since_worn: outfitScore?.daysSinceWorn || 0,
same_day_count: outfitScore?.sameDayOfWeekCount || 0,
seasonal_relevance: outfitScore?.seasonalRelevance || 0,
similar_outfits_count: outfitScore?.similarOutfitsCount || 0,
recently_worn_items: outfitScore?.recentlyWornItemCount || 0,
core_items: outfitScore?.coreItems || [],
},
},
}
})
.sort((a, b) => b.scoring_details.total_score - a.scoring_details.total_score)

// Filter out duplicate outfits with same core items, keeping highest scored version
const uniqueOutfits = outfitsWithDetails
.reduce((acc, outfit) => {
const coreItemsKey = (outfit.scoring_details.raw_data.core_items as string[]).sort().join('|')

if (
!acc.has(coreItemsKey) ||
(acc.get(coreItemsKey)?.scoring_details.total_score || 0) <
outfit.scoring_details.total_score
) {
acc.set(coreItemsKey, outfit)
}
return acc
}, new Map())
.values()

const filteredOutfits = [...uniqueOutfits]
const filteredOutfits = [...outfitsWithDetails]
.sort((a, b) => b.scoring_details.total_score - a.scoring_details.total_score)
.slice(0, pageSize + 1)

Expand Down
21 changes: 15 additions & 6 deletions hono/test/smoke/outfits.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,24 +153,31 @@ describe('[Smoke] Outfits: Seeded [basic-small-seed]', () => {
}
}
expect(Array.isArray(resJSON.suggestions)).toBe(true)

// Validate scoring details structure and types
const scoringDetails = resJSON.suggestions[0].scoring_details
expect(scoringDetails).toBeDefined()

// Validate score values match expected ranges and types
expect(scoringDetails.base_score).toBeGreaterThanOrEqual(0)
expect(scoringDetails.base_score).toBeLessThanOrEqual(60) // Max rating (4) * 15

expect(scoringDetails.items_score).toBeGreaterThanOrEqual(0)
expect(scoringDetails.items_score).toBeLessThanOrEqual(32) // Max rating (4) * 8

expect(scoringDetails.time_factor).toBeGreaterThanOrEqual(-10)
expect(scoringDetails.time_factor).toBeLessThanOrEqual(20)

expect(scoringDetails.frequency_score).toBeGreaterThanOrEqual(0)
expect(scoringDetails.frequency_score).toBeLessThanOrEqual(20)
expect(scoringDetails.frequency_score).toBeLessThanOrEqual(20) // Never worn bonus

expect(scoringDetails.day_of_week_score).toBeGreaterThanOrEqual(0)
expect(scoringDetails.day_of_week_score).toBeLessThanOrEqual(15)
expect(scoringDetails.day_of_week_score).toBeLessThanOrEqual(15) // Max confidence * 15

expect(scoringDetails.seasonal_score).toBeGreaterThanOrEqual(0)
expect(scoringDetails.seasonal_score).toBeLessThanOrEqual(15)
expect(scoringDetails.similarity_penalty).toBeGreaterThanOrEqual(-45)
expect(scoringDetails.seasonal_score).toBeLessThanOrEqual(15) // Max seasonal relevance * 15

expect(scoringDetails.similarity_penalty).toBeGreaterThanOrEqual(-125) // Max penalty for 4+ items
expect(scoringDetails.similarity_penalty).toBeLessThanOrEqual(0)

// Validate total score is sum of all components
Expand All @@ -187,6 +194,7 @@ describe('[Smoke] Outfits: Seeded [basic-small-seed]', () => {
// Validate raw data structure and types
const rawData = scoringDetails.raw_data
expect(rawData).toBeDefined()

expect(Number.isInteger(rawData.wear_count)).toBe(true)
expect(rawData.wear_count).toBeGreaterThanOrEqual(0)

Expand All @@ -200,14 +208,15 @@ describe('[Smoke] Outfits: Seeded [basic-small-seed]', () => {
expect(rawData.seasonal_relevance).toBeGreaterThanOrEqual(0)
expect(rawData.seasonal_relevance).toBeLessThanOrEqual(1)

expect(Number.isInteger(rawData.similar_outfits_count)).toBe(true)
expect(rawData.similar_outfits_count).toBeGreaterThanOrEqual(0)
expect(Number.isInteger(rawData.recently_worn_items)).toBe(true)
expect(rawData.recently_worn_items).toBeGreaterThanOrEqual(0)

expect(Array.isArray(rawData.core_items)).toBe(true)
rawData.core_items.forEach((item) => {
expect(typeof item).toBe('string')
expect(item.length).toBe(24) // CUID length
})

expect(resJSON.generated_at).toBeDefined()
expect(resJSON.metadata.wardrobe_size).toEqual(5)
expect(resJSON.metadata.recency_threshold).toEqual(3)
Expand Down
2 changes: 1 addition & 1 deletion hono/test/utils/factory/outfits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface OutfitSuggestionAPI extends OutfitAPI {
days_since_worn: number
same_day_count: number
seasonal_relevance: number
similar_outfits_count: number
recently_worn_items: number
core_items: string[]
}
}
Expand Down

0 comments on commit 74d468f

Please sign in to comment.