Module 7: Strategy To Roadmap

The strategic filter

Description

The previous lesson gave you the label —servesDimensions— but stopped right before the decisive question: does that label, compared against Mercado's real strategy, make the bet belong to the game or not? This lesson builds the complete rule: the strategic filter, strategicFilter, which decides whether a bet belongs to the strategy before anyone cares how much its riceScore is worth. It's, literally, the module's security-check analogy: whoever has a valid pass gets through, no matter how organized everyone else is.

Connection to the module. This is the hinge lesson of the whole module. Lessons 2 (translating to bets) and 4-7 (the high-RICE trap, coherence, saying no, the bridge with RICE) are, each, a different facet of the same function you build here. Master this lesson and the rest of the module largely becomes applying the same instrument from different angles.

An everyday analogy: the party's bouncer

Imagine a party with a guest list. At the door there's a bouncer with exactly one question to ask: is your name on the list? They don't ask if you're well dressed, don't ask if you brought an expensive gift, don't ask if you're charming or know someone inside. The most elegant person of the night can show up, with the best gift, the best conversation —and if their name isn't on the list, the bouncer doesn't let them in. And someone can show up in plain clothes with empty hands, and if their name is on the list, they get in with no further questions.

The bouncer isn't being unfair or arbitrary: they're applying the one criterion that's theirs to apply at the door. Once inside, other things do matter —who dances better, who tells the best story—, but those are questions for after the door, not at it. The strategic filter is exactly that bouncer: at the roadmap's door, the only question it asks is "does this bet belong to the strategy?" — how well it scores on RICE is a legitimate, important, and completely different question, asked afterward, only among those who've already gotten through the door.

Worked example: Mercado's complete backlog, ranked by RICE

We run strategicFilter on Mercado's complete quarter backlog: the five bets you already know from product-thinking-for-engineers-guide (fasterCheckout, recommendations, sellerTools, reviews, improvedSearch), plus a sixth bet that never appeared in that guide —lowestPriceMatch, matching the market's lowest price—, each with the strategic dimension it claims to serve and its riceScore already calculated.

function riceScore({ reach, impact, confidence, effort }) {
  return (reach * impact * confidence) / effort;
}

// Pedagogical model: a bet "belongs to the strategy" if at least one of the
// dimensions it serves is in strategy.winOn (what the strategy chose to WIN
// on, modules 3-4) AND none is in strategy.avoid (what the strategy
// explicitly chose NOT to fight on, module 2/5). Serving a neutral
// dimension (neither winOn nor avoid) isn't enough to belong: strategic
// coherence requires REINFORCING the chosen game, not just not getting in its way.
function strategicFilter(backlog, strategy) {
  return backlog.map((bet) => {
    const reinforces = bet.servesDimensions.filter((d) => strategy.winOn.includes(d));
    const conflicts = bet.servesDimensions.filter((d) => strategy.avoid.includes(d));
    return {
      feature: bet.feature,
      servesDimensions: bet.servesDimensions,
      reinforces,
      conflicts,
      inStrategy: reinforces.length > 0 && conflicts.length === 0,
      riceScore: Number(riceScore(bet.rice).toFixed(2)),
    };
  });
}

const mercadoStrategy = { winOn: ['curatedDiscovery', 'sellerTrust'], avoid: ['price'] };

// The complete backlog: the five bets from product-thinking + the trap
// bet (lowestPriceMatch), each with the dimension it claims to serve.
const backlog = [
  { feature: 'fasterCheckout', servesDimensions: ['convenience'], rice: { reach: 8000, impact: 2, confidence: 0.8, effort: 2 } },
  { feature: 'recommendations', servesDimensions: ['curatedDiscovery'], rice: { reach: 5000, impact: 1, confidence: 0.5, effort: 3 } },
  { feature: 'sellerTools', servesDimensions: ['sellerTrust'], rice: { reach: 1200, impact: 2, confidence: 0.8, effort: 2 } },
  { feature: 'reviews', servesDimensions: ['sellerTrust'], rice: { reach: 6000, impact: 0.5, confidence: 0.8, effort: 1 } },
  { feature: 'improvedSearch', servesDimensions: ['catalogBreadth'], rice: { reach: 9000, impact: 1, confidence: 0.5, effort: 3 } },
  { feature: 'lowestPriceMatch', servesDimensions: ['price'], rice: { reach: 9500, impact: 3, confidence: 0.8, effort: 3 } },
];

console.log('=== strategicFilter: Mercado\'s complete backlog, ranked by riceScore ===\n');
const filtered = strategicFilter(backlog, mercadoStrategy).sort((a, b) => b.riceScore - a.riceScore);
console.table(filtered);

const inStrategy = filtered.filter((b) => b.inStrategy);
const outOfStrategy = filtered.filter((b) => !b.inStrategy);
console.log('Pass the filter (compete for priority via RICE): ' + inStrategy.map((b) => b.feature).join(', '));
console.log('Fall out (regardless of riceScore): ' + outOfStrategy.map((b) => b.feature).join(', '));

What to expect. Running the file with Node produces exactly this output:

=== strategicFilter: Mercado's complete backlog, ranked by riceScore ===

┌─────────┬────────────────────┬────────────────────────┬────────────────────────┬─────────────┬────────────┬───────────┐
│ (index) │      feature       │    servesDimensions    │       reinforces       │  conflicts  │ inStrategy │ riceScore │
├─────────┼────────────────────┼────────────────────────┼────────────────────────┼─────────────┼────────────┼───────────┤
│    0    │ 'lowestPriceMatch' │      [ 'price' ]       │           []           │ [ 'price' ] │   false    │   7600    │
│    1    │  'fasterCheckout'  │   [ 'convenience' ]    │           []           │     []      │   false    │   6400    │
│    2    │     'reviews'      │   [ 'sellerTrust' ]    │   [ 'sellerTrust' ]    │     []      │    true    │   2400    │
│    3    │  'improvedSearch'  │  [ 'catalogBreadth' ]  │           []           │     []      │   false    │   1500    │
│    4    │   'sellerTools'    │   [ 'sellerTrust' ]    │   [ 'sellerTrust' ]    │     []      │    true    │    960    │
│    5    │ 'recommendations'  │ [ 'curatedDiscovery' ] │ [ 'curatedDiscovery' ] │     []      │    true    │  833.33   │
└─────────┴────────────────────┴────────────────────────┴────────────────────────┴─────────────┴────────────┴───────────┘
Pass the filter (compete for priority via RICE): reviews, sellerTools, recommendations
Fall out (regardless of riceScore): lowestPriceMatch, fasterCheckout, improvedSearch

Read this table sorted top to bottom, by riceScore, and you're going to see the module's entire argument in six rows. The two bets with the highest riceScore of the whole backlog —lowestPriceMatch at 7600 and fasterCheckout at 6400— have inStrategy: false. It's not a strange tie or an edge case: they are, literally, the first and second place on the table, and neither one gets past the bouncer. lowestPriceMatch falls out for the clearest reason possible —conflicts isn't empty, it competes exactly on price, the dimension Mercado's strategy decided not to fight on. fasterCheckout falls out for a subtler reason, and therefore a more important one to understand: it doesn't compete on any forbidden dimension (conflicts: []), but it also doesn't reinforce either of the two dimensions where Mercado chose to win (reinforces: []) — it serves convenience, a real dimension, but neutral for this strategy. Serving something that isn't forbidden isn't the same as belonging.

The three that do pass —reviews, sellerTools, recommendations— each have reinforces with at least one element: all three reinforce curatedDiscovery or sellerTrust, the exact two dimensions where Mercado's strategy, since module 2, chose to invest. Notice that these three also have the three lowest riceScore values in the whole backlog. That's not a coincidence of this example — it's, almost always, the shape the real problem takes: the most obviously profitable bets by pure RICE tend to be the most generic ones, the ones that serve any e-commerce product regardless of its particular strategy, precisely because they aren't concentrated on any specific game.

Deep dive: why "neutral" isn't enough to belong

strategicFilter's most important design decision —and the easiest to overlook— is in this line: inStrategy: reinforces.length > 0 && conflicts.length === 0. Notice it does not just say conflicts.length === 0. If the filter let through any bet that simply doesn't conflict with avoid, fasterCheckout would have passed —it doesn't compete on price, after all—, and the entire filter would lose its force: almost any reasonable bet in an e-commerce backlog avoids competing directly on price, so a filter that only requires "no conflict" would let almost everything through, exactly like a bouncer who lets in anyone who isn't carrying a bomb, instead of requiring their name to be on the list.

Requiring reinforces.length > 0 —that the bet actively reinforce at least one winOn dimension— is what turns the filter into a real strategy instrument, not just an obvious-threat detector. The question the filter asks isn't "does this hurt our strategy?" —a very low bar almost any reasonable bet clears— but "does this actively build the specific game we chose to play?" — a much more demanding bar, and the only one that truly deserves the name "strategic." This is the exact reason fasterCheckout, with the second-best RICE in the whole backlog, doesn't pass: building it does Mercado no harm, but it also doesn't advance the specific game Mercado, with effort, across five complete modules, decided to play.

Common mistakes

Prioritizing by RICE without filtering by strategy first. What happens: the team takes the complete backlog, scores it with RICE (as in product-thinking-for-engineers-guide, module 3), and starts building top-down without first asking whether each bet belongs to the current strategy. Why it happens: RICE produces a clean, sortable number, and sorting by a number feels like the hard decision has already been made — the more qualitative belonging question feels less urgent than an already-computed ranking. How to spot it: if your team can name their next bet's riceScore but can't name, in one sentence, which strategic dimension it reinforces, the filter never ran. How to fix it: as you saw in this lesson's example, run strategicFilter before looking at any bet's riceScore — the "efficient" bet that most leads you astray is almost always the one with the best number and the worst belonging.

Treating "doesn't conflict" as if it were "belongs." What happens: someone defends a neutral bet —like fasterCheckout in this lesson's example— with the argument that "it doesn't hurt the strategy," and treats that as if it were enough to build it with priority. Why it happens: it's easier to argue the absence of harm (a low bar) than the presence of active reinforcement (a higher, more specific bar) — and "doesn't hurt" sounds, in a quick conversation, almost as good as "helps." How to spot it: explicitly ask "which winOn dimension does this reinforce?" — if the answer takes a while to arrive or settles for "it doesn't compete with anything we're avoiding," the bet is neutral, not strategic. How to fix it: always require reinforces.length > 0, not just conflicts.length === 0 — this lesson's exact line of code that separates a real filter from a mere threat detector.

Applying the filter with a poorly defined strategy and letting almost everything through. What happens: someone builds a strategy object with an overly broad winOn —for example, including five or six dimensions instead of the two or three that truly define the competitive advantage— and the filter, while technically correct, stops discriminating anything, because almost any bet reinforces some of the many listed dimensions. Why it happens: defining a narrow winOn feels risky —what if we leave out something important?— so the temptation is to widen it "just in case." How to spot it: if more than eighty percent of the backlog passes the filter, be suspicious of the winOn, not the backlog — a filter that almost never filters anything isn't doing its job, no matter how correct the code is. How to fix it: go back to module 3's positioning and module 4's differentiation — the correct winOn is narrow on purpose, the same two or three dimensions where the product truly concentrates its advantage, not a list of everything that "would be nice to improve."

Exercises

Exercise 1 — Predict before running it. Without running code, for a hypothetical bet bulkOrdersForResellers with servesDimensions: ['catalogBreadth', 'sellerTrust'], would it pass the filter with Mercado's strategy (winOn: ['curatedDiscovery', 'sellerTrust'], avoid: ['price'])? Justify with inStrategy's exact rule.

See solution

Yes, it would pass. reinforces would be ['sellerTrust'] (the only one of its two dimensions that's in winOn) — with a length greater than zero, it meets the first condition. conflicts would be [], because neither of its two dimensions (catalogBreadth, sellerTrust) is in avoid (['price']) — it meets the second condition. inStrategy would be true. The exercise shows something important: a bet can serve several dimensions at once, some within winOn and others neutral (like catalogBreadth here), and still pass the filter as long as at least one reinforces and none conflict — the filter doesn't require all served dimensions to be strategic, just that at least one is and none are forbidden.

Exercise 2 — Find the edge case. A bet freeReturns has servesDimensions: ['sellerTrust', 'price'] — it improves buyer trust, but also means absorbing costs that pressure price downward. Does it pass the filter? Why might the result surprise someone who only looks at the first dimension in the list?

See solution

It doesn't pass. reinforces would be ['sellerTrust'] (length greater than zero), but conflicts would be ['price'] (also length greater than zero, because price is in avoid). The inStrategy condition requires conflicts.length === 0, so the final result is false, regardless of the bet also reinforcing a winning dimension. This surprises anyone who only looks at the first dimension in the list and concludes "it serves sellerTrust, so it passes" — the filter doesn't average or weigh between reinforcing dimensions and conflicting dimensions: a single conflict is enough to fall out, no matter how many winning dimensions it also serves. It's a strict rule on purpose: mixing a real advantage with a compromise on the forbidden dimension isn't a partially good bet, it's a bet that dilutes the strategy through the back door.

Exercise 3 — Explain the result to someone who only saw the riceScore. An engineering colleague, who only checked the backlog's RICE numbers (without seeing strategicFilter), asks why the team isn't going to build lowestPriceMatch first, since it has the highest riceScore of all. Write, in a paragraph, the answer.

See solution

A sample answer: "You're right that lowestPriceMatch wins by a landslide if we only look at RICE — 7600 versus 6400 for second place. But RICE measures one thing: expected value per unit of cost, without ever asking whether that value is aligned with the game we chose to play. Our strategy, since module 2, is explicit: we win with curated discovery and seller trust, not by competing on price — in fact, we actively decided NOT to fight that battle, because a player at a generic megastore's scale is always going to beat us there. lowestPriceMatch is, literally, walking into the fight we already decided to avoid. It doesn't matter how efficient the bet is in the abstract: it doesn't belong to the game we're playing, and building it first just because the number is big would be exactly the kind of decision that precisely executes the wrong strategy."

Summary and next step

This lesson built the module's central instrument: strategicFilter, which decides whether a bet belongs to the strategy by comparing the dimensions it claims to serve (servesDimensions) against what the strategy chose to win on (winOn) and what it decided not to fight on (avoid). On Mercado's complete backlog, you saw that the two highest-riceScore bets —lowestPriceMatch and fasterCheckout— fall out, one from direct conflict and the other from being merely neutral, while the three bets with the lowest riceScorereviews, sellerTools, recommendations— are precisely the ones that truly reinforce the chosen game.

Before moving on you should be able to: explain from memory why inStrategy requires reinforces.length > 0 and not just conflicts.length === 0, and apply that distinction to diagnose whether any given bet belongs to a given strategy.

Lesson 4 stops, in even more detail, on the most dangerous pattern you just saw: what makes a high-RICE, out-of-strategy bet such an easy trap to fall into, and why the formula's large numerator is exactly what makes it convincing.

Resources

  • Roger Martin, "Decoding the Strategy Choice Cascade" — rogermartin.medium.com/decoding-the-strategy-choice-cascade-475d40555eb1. Martin's cascade works, at bottom, like this lesson's same filter applied to every level of decision: every choice must belong to the choice above, or it's discarded, no matter how attractive it looks in isolation. In English.
  • Melissa Perri, Escaping the Build Traporeilly.com/library/view/escaping-the-build/9781491973767. The build trap, seen through this filter: a team can be completely busy building high-RICE bets and still fall into the trap, if none of those bets first pass the strategic-belonging question. In English.
  • Marty Cagan (Silicon Valley Product Group), "Product Strategy" — svpg.com/product-strategy-overview. Cagan describes product strategy as the criterion that makes it possible to say no to objectively good ideas — the same function strategicFilter automates in code for Mercado's case. In English.