DEV Community

Sarthak Aggarwal
Sarthak Aggarwal

Posted on

Two people, one holiday: why you can't just average their preferences

Two people. One holiday. Each has their own idea of a good time, and only one booking gets made.

I ran into this building honeymoon.wiki, a free reference for honeymoon destinations with a matchmaker for couples. Each partner answers separately, often on their own phone, and the app has to produce one shortlist they're both happy with.

The naive solution is obvious and wrong, so here's what actually works.

Why averaging fails

Score each destination for each partner, average the two, sort descending. Done in one line.

Then you look at the output and it's useless.

Say A wants a quiet island and B wants a city with nightlife. A place that scores 1.0 for A and 0.2 for B averages 0.6. A place that scores 0.62 for both also averages 0.6. The mean treats these as equivalent. They are not remotely equivalent: the first one means one person spends their honeymoon somewhere they actively didn't want.

Averaging optimises the couple as a single blended entity. But there is no blended entity — there are two people, and one of them will remember this trip for thirty years.

Why taking the minimum also fails

The opposite fix is to score each destination by its worst individual score, a maximin rule:

const couple = Math.min(a.score, b.score);
Enter fullscreen mode Exit fullscreen mode

This fixes the lopsidedness and introduces a new problem: it's relentlessly mediocre. Any destination where either partner has a mild reservation gets crushed, so the winners are bland compromises that neither person is excited about. It also throws away real information — once you take the min, a 0.9 and a 0.65 look identical to a 0.65 and a 0.65.

The blend

What shipped is a weighted combination of both:

const couple =
  0.6 * ((x.score + y.score) / 2) +   // how good is this for us on average
  0.4 * Math.min(x.score, y.score);    // how bad is it for the less happy one
Enter fullscreen mode Exit fullscreen mode

The mean term keeps genuinely great destinations at the top. The min term is a penalty that grows with the gap between the two partners, so a 1.0/0.2 split lands well below an 0.8/0.8 one.

Run the earlier example through it:

  • 1.0 and 0.2 → 0.6 × 0.6 + 0.4 × 0.2 = 0.44
  • 0.62 and 0.62 → 0.6 × 0.62 + 0.4 × 0.62 = 0.62

The balanced option now wins clearly, which is the behaviour you want. And a 0.9/0.85 destination still beats both, so excellence isn't punished.

The 60/40 split is a judgement call, not a derivation. It's the point where, in testing, the results stopped looking lopsided without turning into mush. If you build something similar, expect to tune this against real output rather than reason your way to a number.

Deal-breakers are vetoes, not penalties

Preferences are soft; some things are not. "I'm not doing a long-haul flight" or "nowhere with a cyclone season while we're there" isn't a low score, it's a hard no.

Those are handled completely separately from scoring, as a union of both partners' filters:

const filters = [...new Set([...pa.filters, ...pb.filters])];
const ra = rank(ctx, { ...pa, filters }, trip);
const rb = new Map(rank(ctx, { ...pb, filters }, trip).map((r) => [r.id, r]));
Enter fullscreen mode Exit fullscreen mode

Both partners are ranked against the combined filter set, so either person's deal-breaker removes a destination for both. A veto that only applies to the person who cast it would be meaningless — you can't send half a couple somewhere.

Blocked destinations aren't deleted either. They're sorted to the bottom and keep a blocked reason, because "Santorini would have been your top match, but it's ruled out by the no-long-flights filter" is far more useful than silently vanishing.

The score each partner gets

Before the couple blend runs, each partner's score for a destination is itself a weighted sum:

const BASE = { fit: 0.4, season: 0.2, budget: 0.15, access: 0.1, moments: 0.15 };
Enter fullscreen mode Exit fullscreen mode
  • fit (40%) — cosine-style match between what they said they want and ~40 trait scores per destination (beach, snow, seclusion, nightlife, diving, wine…)
  • season (20%) — how good that destination is in their month
  • budget (15%) — the most comfortable tier their budget actually covers
  • access (10%) — flight time plus visa friction
  • moments (15%) — whether the specific experiences they picked exist there

The season term is where a lot of the real signal lives, because it is derived rather than editorial. Ten years of ERA5 reanalysis per destination, averaged into monthly normals, then scored:

let s = 0.45 * temp + 0.45 * rain + 0.1 * sun;
s *= 1 - 0.45 * storm;              // cyclone/typhoon season penalty
const verdict = s >= 0.8 ? 'best' : s >= 0.64 ? 'good' : s >= 0.45 ? 'mixed' : 'avoid';
Enter fullscreen mode Exit fullscreen mode

Each destination has its own comfortable temperature band, so 30°C is ideal for a tropical island and too hot for a European old town. Without that, the model just ranks everywhere by "warmer is better".

Visa friction as a number

One small piece I like. Travel difficulty isn't binary, so visa requirements become a coefficient:

const VISA_FRICTION = { home: 0, free: 0, eta: 0.15, voa: 0.2, evisa: 0.35, visa: 0.85, none: 1 };
Enter fullscreen mode Exit fullscreen mode

Visa-free is free. A visa on arrival is a minor cost. A full embassy application is 0.85, nearly as bad as impossible. Crucially this is computed per passport, and for couples the stricter of the two passports wins, because they have to travel together. Two partners on different passports genuinely get a different shortlist than either would alone.

Showing your working

The part that mattered most for trust: every match carries the reasons. misses records what didn't fit, and both partners' individual percentages survive into the result alongside the couple score:

return {
  id: x.id,
  a: x.score, b: y.score,     // each partner's raw score
  couple, pct: toPct(couple),
  pctA: x.pct, pctB: y.pct,   // and as percentages
  blocked: x.blocked ?? y.blocked,
  misses: [...new Set([...x.misses, ...y.misses])],
};
Enter fullscreen mode Exit fullscreen mode

A recommendation engine that just emits a ranked list is asking for blind trust. One that can say "92% for you, 78% for them, and here's the one thing they wanted that this place doesn't have" is something people will actually argue with productively — which is the point, since they're going to discuss it anyway.

Takeaways if you build something similar

  1. Don't average two people's preferences. It hides exactly the cases you most need to catch.
  2. Don't take the minimum either. Blend the mean with the min and tune the ratio against real output.
  3. Separate hard constraints from soft preferences. Vetoes are set operations, not low scores.
  4. Keep the individual scores in the result. Aggregate-only output is unexplainable and untrustworthy.
  5. Derive what you can. The weather term is the strongest part of the model precisely because nobody hand-wrote it.

The matchmaker is live at honeymoon.wiki/match if you want to poke at the behaviour, and the scoring rules are written up on the methodology page.

The underlying datasets — the weather atlas and the cost index — are CC BY 4.0 and downloadable as CSV or JSON from the open data page, so you can run your own scoring over them if you want to try a different aggregation rule.

Top comments (0)