xavier-ramirez.com
← Activation & match rates

Your own activation platform · 06 · 24 Aug 2026 · 11 min read

Measurement & incrementality

Attribution divides credit — incrementality asks whether it mattered.

The tempting version is a dashboard. Conversions on one axis, channels on the other, a return-on-spend column sorted descending, and a budget meeting that moves money toward the top row. It feels like measurement because it has numbers in it. But every row in that table describes conversions that already happened, and none of them answers the only question worth asking: what would have happened if you had spent nothing.
01 · Spine02 · Identity resolution03 · Profiles & propensity04 · Consent & deletion05 · Activation & match rates06 · Measurement

This post deepens the stage after activation — the one that decides whether the five stages before it were worth running.

Worked exampleThis series uses a multi-brand customer data & activation platform as its running example — clickstream from direct-to-consumer storefronts, orders, ad spend, and the audiences the platform pushes back out to ad and email destinations. The pipeline itself is domain-agnostic; the data just happens to belong to commerce brands.

The interval

Can this test see the thing you are hoping for?

Start at the end, because the arithmetic below decides whether the rest of the post is worth running. Three controls: how big the audience is, how much of it you are willing not to market to, and how big the effect really is.

can this test see it?
Held back from marketing
The effect the ads really have

↳ Leave the effect at +1.2% and drag the audience all the way to 5M. The interval still spans zero — that lift is not measurable at any audience a brand actually has.

Holdout arm
25k
Treated arm
225k
95% interval
-6.2 … 8.6%
Design floor
14.7%
No detectable effect. The interval spans zero, so this test is equally consistent with the audience making no difference — the smallest lift it could resolve is 14.7%.

The design floor is the lift this arrangement is built to catch four times out of five. It is a property of the arithmetic, not of the campaign, and you can compute it before spending a cent — which is the entire reason to compute it first.

A large share of the tests people actually run cannot detect the effect they are hoping for, and instead produce a number, with a decimal point, that is noise. You can know that before you spend a cent — which is the entire reason to compute it first.

The problem

Last click makes your best channel the one that would have happened anyway

Follow one shopper. She has a cart from Tuesday, an email in her inbox, and every intention of buying. On Thursday she types the brand name into a search box, clicks the ad at the top because it is at the top, and completes the order. Last click hands that channel the entire purchase. Nothing about the ad changed her mind; it was standing between her and a checkout she was already walking toward.

What last click reports
9.4×

Every order from someone who added to cart four days ago and was always going to come back, counted as revenue the ad produced.

What the holdout measures
0.6×

The same audience, the same window, compared against people just like them who were never shown the ad at all.

Be fair to attribution. It is genuinely useful for diagnosis and pacing: which creative is getting clicks today, whether spend is tracking to plan, where a sudden drop in traffic came from. Those are real jobs. It is just not evidence that money caused revenue, and it should never be the number in a sentence containing the word “drove”.

The holdout

Hold out in your platform, not in theirs

A holdout is the counterfactual you buy by not spending. The mechanism is one line of arithmetic: hash the profile id together with the experiment id, take it modulo ten thousand, and compare against the holdout size. Nothing is stored, so nothing can be lost, and the assignment can be re-derived from first principles by an analyst months later who was not in the room.

src/measurement/holdout.ts
import { createHash } from "node:crypto";

export interface Experiment {
  experimentId: string;        // "exp_prospecting_q3"
  brandId: string;             // "northwind"
  audienceId: string;          // "aud_high_intent_30d"
  destination: "meta" | "google" | "klaviyo" | "webhook";
  holdoutBps: number;          // 1000 = 10% held out
  startsAt: string;            // ISO 8601
  plannedEndAt: string;        // fixed before the first sync — see Power
  primaryMetric: "revenue_per_profile";
  mdeRelative: number;         // 0.05 = detect a 5% relative lift
  owner: string;               // "growth@northwind"
}

/** Stable, storage-free assignment. Re-derivable months later from these two strings. */
export function isHeldOut(profileId: string, exp: Experiment): boolean {
  const digest = createHash("sha256").update(`${profileId}:${exp.experimentId}`).digest();
  return digest.readUInt32BE(0) % 10_000 < exp.holdoutBps;
}
src/activation/sync-engine.ts
import type { MemberRow } from "./types.js";
import { isHeldOut, type Experiment } from "../measurement/holdout.js";

const compiled: MemberRow[] = await compileAudience(scope, audience);       // post 5
const experiments: Experiment[] = await experimentsFor(scope, audience.id); // zero or one

const treated: MemberRow[] = compiled.filter(
  (p) => !experiments.some((exp) => isHeldOut(p.profileId, exp)),
);

// Diff happens AFTER the subtraction, so a held-out profile that was synced
// before the experiment started is emitted as a removal, not silently left behind.
const { adds, removes } = diffAgainstSnapshot(treated, lastSnapshot);
await destination.send({ syncId, adds, removes });
await audit.write({ ...counts, holdoutBps: experiments[0]?.holdoutBps ?? 0,
                    experimentId: experiments[0]?.experimentId });
Why not use the ad platform’s own lift test?
Run them. They can randomise at the impression level, which you cannot do from outside — you never see the auction. They are cheap to start and the results are often good. But note what you are accepting: the party running the test also sells the media and defines the conversion window. You cannot pool a result from one platform with a result from another into one view of a customer, because the assignment lives in two systems that never met. And you cannot re-analyse raw assignment later, when someone asks whether the effect held for repeat buyers. Run both; own one.

The overlap trap deserves its own paragraph, because it is silent. Two experiments run on populations that intersect — a prospecting test on one audience and an email test on a lifecycle audience sharing forty percent of its members — and neither knows about the other. Each treats the other’s treatment as noise, which inflates variance and hides a real effect. Worse, if the overlap is uneven between arms, one test reads the other’s lift as its own. The fix is boring: a registry of running experiments per brand, and a check at creation that refuses or flags an intersection above a threshold.

Power

Decide the sample size before you look

The uncomfortable arithmetic goes first, not last. At a three percent baseline conversion rate, detecting a five percent relative lift — three percent against 3.15 percent — needs roughly two hundred thousand profiles per arm. Most audiences on most brands are not that big, which is exactly what the interactive at the top of this post is showing you.

src/measurement/power.ts
/** Two-sided z-test for a difference in proportions. Returns profiles required PER ARM. */
export function sampleSizePerArm(
  baselineRate: number,      // 0.03
  relativeLift: number,      // 0.05
  alpha = 0.05,
  power = 0.8,
): number {
  const p1 = baselineRate;
  const p2 = baselineRate * (1 + relativeLift);
  const pBar = (p1 + p2) / 2;
  const zA = 1.959964;                                   // two-sided alpha = 0.05
  const zB = 0.841621;                                   // power = 0.80
  const numerator =
    zA * Math.sqrt(2 * pBar * (1 - pBar)) + zB * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2));
  return Math.ceil((numerator ** 2) / ((p2 - p1) ** 2));  // ~196k at 3% baseline, 5% lift
}

/** Inverted: given the audience you actually have, what is the smallest lift you can see? */
export function minDetectableLift(baselineRate: number, perArm: number): number {
  let lo = 0.001, hi = 2.0;
  for (let i = 0; i < 40; i++) {
    const mid = (lo + hi) / 2;
    if (sampleSizePerArm(baselineRate, mid) > perArm) lo = mid; else hi = mid;
  }
  return hi;
}

The readout

Incremental revenue, incremental cost per acquisition, and an interval

The query is unglamorous, which is the point. Assign every profile in the compiled audience to an arm with the same hash the sync engine used, join conversions in the window, and compare per-profile means. No attribution model appears anywhere.

sql/lift_readout.sql
SELECT
  arm,
  count()                                         AS profiles,
  countIf(orders > 0)                             AS converters,
  countIf(orders > 0) / count()                   AS cvr,
  sum(revenue) / count()                          AS revenue_per_profile,
  stddevSamp(revenue)                             AS revenue_sd
FROM (
  SELECT
    p.profile_id                                                        AS profile_id,
    if(holdout_bucket(p.profile_id, {exp:String}) < {bps:UInt16},
       'holdout', 'treated')                                            AS arm,
    countIf(o.order_id != '')                                           AS orders,
    sum(o.revenue)                                                      AS revenue
  FROM audience_snapshot AS p
  LEFT JOIN orders AS o
    ON o.brand_id = p.brand_id
   AND o.profile_id = p.profile_id
   AND o.ts BETWEEN {start:DateTime} AND {end:DateTime}
  WHERE p.brand_id = {brand:String} AND p.audience_id = {aud:String}
  GROUP BY profile_id, arm
)
GROUP BY arm;
src/measurement/readout.ts
export interface LiftReadout {
  incrementalRevenue: number;        // (rpp_treated - rpp_holdout) * treatedProfiles
  incrementalCac: number;            // spend / incrementalConversions
  ciLow: number; ciHigh: number;     // 95% on incremental revenue
  includesZero: boolean;
  underpowered: boolean;             // observed n < the design's requirement
}

Two things to say out loud. A result whose interval includes zero is a result — it says the effect, if any, is smaller than this test could see, and it should be reported in exactly those words rather than quietly not reported. And a negative point estimate on a retargeting audience is the single most common honest finding in this whole discipline; it usually means you were paying to reach people already on their way to checkout, and the ads mostly bought attention you already had.

The governance tie-back is short. A holdout is a group of real people you deliberately chose not to market to, for a quarter, to learn something. That is a decision with a subject, an owner and a date, and it belongs in the same append-only audit log as every sync and every deletion — the holdout size and experiment id are already fields on post 4’s audit record.

Run it yourself · open-activation-platform @ v6-measurement
The seed data is generated by a simulator that knows which purchases were caused by ads and which were going to happen regardless — so the ground truth exists, and can be checked.
terminal
git clone https://github.com/xaviramirezcom/open-activation-platform
cd open-activation-platform
git checkout v6-measurement
pnpm install
pnpm activate up            # streaming + warehouse + config store, via docker compose
pnpm activate seed          # synthetic multi-brand traffic, orders and spend

pnpm activate experiment create exp_prospecting_q3 \
  --brand northwind --audience aud_high_intent_30d --dest meta --holdout-bps 1000
pnpm activate sync --audience aud_high_intent_30d --dest meta --days 28

pnpm activate report attribution --audience aud_high_intent_30d --model last_click
pnpm activate report lift        --audience aud_high_intent_30d --exp exp_prospecting_q3
The attribution run reports something like a nine-times return on the audience, because it is counting orders from people who added to cart four days ago and were always going to come back. The lift run, on the same audience over the same window, reports incremental revenue near zero with an interval that comfortably spans it — and the simulator’s ground-truth file agrees, because it generated most of those purchases with the ad flag off. Two numbers, one audience, one of them wrong by construction. Watching that gap open on your own seeded data is more persuasive than any argument in this post.

Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →

What you have now

  1. Collection you control at the edge. A first-party server-side collector, server-stamped time, and events keyed so a device's history stays ordered — not a pixel whose behaviour a browser vendor decides for you.
  2. Identities that can be un-merged. Profiles derived from a link graph with provenance on every link, so the merge that was wrong is a removable observation rather than permanent damage.
  3. Features that do not leak the future. One feature definition, two paths, computed as of the start of the label window — the difference between an offline number you brag about and an online lift that exists.
  4. Consent enforced by structure, deletion that fans out. Consent as a join key with no code path around it, and a tombstone that reaches your warehouse, your graph, your audiences and every destination you ever synced to.
  5. Syncs that send deltas and report their own match rate. Diffs rather than full replaces, idempotent batches with checkpoints, dead-lettered rejects, and a freshness target that catches the expired token before the month does.
  6. A number that survives “compared to what?”. A deterministic holdout the sync engine enforces, a power calculation done first, and a readout with an interval on it.
The part that is not the code

That is the platform, and it is honest to say the parts shown here are the easy parts. A stream consumer, a component solver, a diff engine, a hash-based holdout — these are solved problems with known shapes, and a competent team can build all of them in a quarter. The hard part in any real company is none of that. It is the organisational agreement about what a customer is: whether a household counts as one, whether an agency’s login counts as your marketer, whether a returned order still makes someone a buyer, whether last quarter’s consent still applies. Every one of those is a product decision wearing an engineering costume, and no amount of correct code resolves them. Build the pipeline so that when the answers change, you can change them in one place — and so that the audit log can always say who decided, and when.

Explain it back

A growth team runs a clean holdout on a retargeting audience. The mechanics are right: deterministic assignment, sync-engine exclusion, a fixed end date. The readout comes back with a lift of 1.2% and a 95% interval spanning −4% to +6%. They post it in the channel as “we measured a 1.2% lift” and propose scaling the audience. What is wrong with that sentence, what should the readout have said, and what should the team do next?
Reveal a model answer

The sentence reports a point estimate as if it were the finding. It is not — the interval contains zero, and it contains −4%, so the test is equally consistent with the audience losing money. “1.2%” is the centre of a range whose honest summary is “we could not detect an effect”. The mechanism is that the test was underpowered for the effect size that matters: at a retargeting-scale audience and a low baseline conversion rate, the smallest lift this design could resolve was several times larger than anything plausible.

The readout should have said: no detectable effect, 95% interval −4% to +6%, smallest detectable lift 9%, so effects below that are invisible to this test.

Next: do not scale on this. Either pool across a longer window or several brands to buy power, or accept that a channel this size cannot be measured at five percent resolution and test something coarser — hold out the entire audience for a month and measure the total. The bonus consequence is political rather than statistical. Once “1.2% lift” is in a channel, it gets pasted into a deck, and it becomes a number nobody can retract without seeming to attack the team that produced it. The interval is what stops that, which is why it belongs in the sentence and not in a footnote.

The series ends here · start again at 00
Six stages, from an anonymous page view to a number you can defend

Every stage in this platform is a bet that two sessions were the same person. Re-read the map with all six posts behind you and the chain reads differently — every hop is now a place you know how to lose someone, and how not to.

Back to the map