xavier-ramirez.com
← Identity resolution

Your own activation platform · 03 · 22 Aug 2026 · 11 min read

Profiles & propensity

One feature definition, two paths — and never let the future leak in.

The tempting move is a profile document. One record per person, every service updating the fields it owns, lifetime revenue incremented on each order. It reads beautifully for a week. Then two consumers write the same profile in the same second, a replay double-counts an order, and nobody can answer what that profile looked like in March. This post builds traits as a materialised query over the event log instead — then puts a model on top, and spends most of its length on one bug.
01 · Spine02 · Identity resolution03 · Profiles & propensity04 · Consent & deletion05 · Activation & match rates06 · Measurement

This post deepens the intelligence stage: turning resolved profiles into traits and scores an audience can actually filter on.

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 bug, first

The offline number goes up when the model gets worse

Here is the whole post in one control. You want to predict “buys in the next seven days”. The label window opens on the first of September. The question is only ever this: at what moment did you read the features?

one profile, one training row
Read the features…

↳ Same profile, same label, same model. Only the moment the features were read changes.

revenue_lifetime
$412
orders_lifetime
3
recency_days
4
sessions_30d
7
Offline AUC0.94
Lift on a real holdout0.4%
The features were read from today, so they already contain the purchase the model is being asked to predict. Lifetime revenue includes it. Recency is measured from it. The model does not learn who is about to buy; it learns who just bought — and at scoring time that is precisely the thing it cannot see.

That is the failure the rest of this post is arranged around. Everything before it exists so the fix is even possible, and everything after it exists so the score reaches an audience without going stale.

Traits

A profile is a query, not a document

Post 2 left you with one canonical profile id per connected component. The traits layer does not store facts about that profile; it derives them from the event log on a schedule, writes the result into the warehouse, and stamps a version. Nothing mutates. The document version of this loses on four counts.

Sessionization comes first, because a session is the unit almost every behavioural trait counts. The rule is thirty minutes of inactivity, computed with a window function over each profile’s ordered event stream rather than trusting the session id the client supplied.

sql/traits/01_sessionize.sql
-- A new session starts when the gap since the previous event exceeds 30 minutes.
-- Client-supplied session ids are recorded but never trusted; ts is the server stamp.
CREATE OR REPLACE VIEW event_sessions AS
SELECT
    brand_id,
    env,
    profile_id,
    ts,
    name,
    props,
    -- running count of gaps = a stable per-profile session ordinal
    sum(is_new_session) OVER (
        PARTITION BY brand_id, env, profile_id
        ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS session_seq
FROM (
    SELECT
        *,
        if(
            neighbor(ts, -1) IS NULL
            OR profile_id != neighbor(profile_id, -1)
            OR dateDiff('minute', neighbor(ts, -1), ts) > 30,
            1, 0
        ) AS is_new_session
    FROM events
    ORDER BY brand_id, env, profile_id, ts
);

The roll-up is then a plain aggregate over that view plus orders. It runs on a cadence, computes every trait from scratch for the profiles that had activity, and appends.

sql/traits/02_profile_traits.sql
CREATE TABLE IF NOT EXISTS profile_traits (
    brand_id      LowCardinality(String),
    env           LowCardinality(String),
    profile_id    String,
    computed_at   DateTime,          -- the moment these values were valid
    first_seen_at DateTime,
    sessions_30d  UInt32,
    orders_lifetime UInt32,
    revenue_lifetime Decimal(18, 2),
    aov           Decimal(18, 2),
    days_since_last_order Nullable(UInt16),
    first_touch_channel LowCardinality(String),
    last_touch_channel  LowCardinality(String),
    p_purchase_7d Nullable(Float32),
    scored_at     Nullable(DateTime),
    model_version Nullable(String),
    version       UInt64             -- monotonic; the highest wins at merge time
) ENGINE = ReplacingMergeTree(version)
PARTITION BY brand_id
ORDER BY (brand_id, env, profile_id);
Why a versioned append, and not an update?
Because warehouse mutations are neither cheap nor synchronous — an update rewrites whole parts in the background, and you cannot tell from the client when it landed. A versioned append replaces at merge time instead, which buys idempotence: a backfill and the live roll-up can write the same profile row and the higher version simply wins. No coordination, no lock, no fighting. The honest cost is reads. Until a merge happens, duplicates exist, and de-duplicating at query time is expensive on wide scans — so use it for point lookups and audience compilation, and take the newest version per key inside aggregate scans instead.

The feature store

One definition, two paths

Training reads history in batch. Scoring reads one profile in milliseconds. Those are genuinely different queries — and the moment you write them as two hand-maintained SQL strings, they start to drift. Someone fixes a null-handling bug in the online path, or changes a lookback from thirty days to twenty-eight, and only one side gets it. Nothing errors. The model just quietly gets worse over a quarter.

src/features/registry.ts
export interface FeatureDef {
  readonly name: string;
  readonly type: "float" | "int" | "string";
  /** Warehouse expression over the aliased trait row `t`. */
  readonly expr: string;
}

export const FEATURES: readonly FeatureDef[] = [
  { name: "sessions_30d",  type: "int",    expr: "toInt32(t.sessions_30d)" },
  { name: "revenue_ltv",   type: "float",  expr: "toFloat64(t.revenue_lifetime)" },
  { name: "aov",           type: "float",  expr: "toFloat64(t.aov)" },
  { name: "recency_days",  type: "int",    expr: "toInt32(coalesce(t.days_since_last_order, 999))" },
  { name: "last_channel",  type: "string", expr: "toString(t.last_touch_channel)" },
] as const;

const select = (): string => FEATURES.map((f) => `${f.expr} AS ${f.name}`).join(",\n  ");

/** Offline: every profile, as of a point in time. */
export const offlineQuery = (): string =>
  `SELECT t.profile_id, t.computed_at,\n  ${select()}\nFROM profile_traits_history AS t\nWHERE t.brand_id = {brandId:String} AND t.env = {env:String}`;

/** Online: one profile, current values. */
export const onlineQuery = (): string =>
  `SELECT t.profile_id,\n  ${select()}\nFROM profile_traits AS t FINAL\nWHERE t.brand_id = {brandId:String} AND t.env = {env:String} AND t.profile_id = {profileId:String}`;
src/features/parity.test.ts
import { describe, expect, it } from "vitest";
import { readOffline, readOnline } from "./read.js";
import { FEATURES } from "./registry.js";

describe("offline/online feature parity", () => {
  it("returns identical values for a sampled set of profiles", async () => {
    const scope = { brandId: "northwind", env: "prod" };
    const sample = await readOffline(scope, { asOf: "now", limit: 500 });

    for (const row of sample) {
      const live = await readOnline(scope, row.profile_id);
      for (const f of FEATURES) {
        expect(live[f.name], `${f.name} drifted for ${row.profile_id}`).toEqual(row[f.name]);
      }
    }
  });
});

That test is the entire discipline in one file. It fails loudly the day someone edits one path, which is the only moment the failure is cheap to fix.

Point-in-time

Compute the feature as of the moment you would have had to

Now the mechanics behind the interactive at the top. You take a label window and build the training set from the trait table as it is today. Lifetime revenue already contains the purchase in the label window. Recency is measured from it. You have handed the model the answer inside the question.

The offline score comes back beautiful. Everyone is delighted. Online lift is zero, because at scoring time the model sees a profile before the purchase, and every feature it learned to lean on is in a completely different regime. The model learned “recently bought” and you asked it “will buy”.

sql/features/03_training_set.sql
-- One row per (profile, as_of). Features are strictly <= as_of; the label is strictly after it.
WITH labels AS (
    SELECT
        brand_id, env, profile_id,
        toDateTime('2026-09-01 00:00:00') AS as_of,
        countIf(name = 'purchase'
                AND ts >  toDateTime('2026-09-01 00:00:00')
                AND ts <= toDateTime('2026-09-08 00:00:00')) > 0 AS y
    FROM events
    WHERE brand_id = {brandId:String} AND env = {env:String}
    GROUP BY brand_id, env, profile_id
)
SELECT
    l.profile_id, l.as_of, l.y,
    t.sessions_30d, t.revenue_lifetime, t.aov,
    coalesce(t.days_since_last_order, 999) AS recency_days
FROM labels AS l
ASOF LEFT JOIN profile_traits_history AS t
  ON  l.brand_id   = t.brand_id
  AND l.env        = t.env
  AND l.profile_id = t.profile_id
  AND t.computed_at <= l.as_of;   -- the inequality: latest snapshot at or before as_of

The history table is the same roll-up written without replacement — one row per profile per run, kept for a retention window. It costs storage. It is the only thing that makes a correct training set possible, so it is not optional.

There is a second, subtler leak in the same neighbourhood, and the as-of join does not fix it. If your feature window and your label window overlap even by a day, you have reintroduced the same problem in miniature, and the offline metric moves just enough to look like a real improvement. Worse is survivorship in the sampled population: if you build the training set from profiles that exist in the trait table because they converted, your negatives are not a sample of non-buyers — they are a sample of people who nearly bought. The fix for both is the same as the fix for the first: define the population and the feature cutoff from the boundary alone, and let never-converted profiles into the negative class.

Scoring

A score is only useful if the audience can see it

The model output is not a file in a bucket. It lands back beside the traits, with the time it was scored and the model version next to it, so a marketer can build an audience on it and an investigation six weeks later can ask which model produced a given membership.

src/scoring/writeBack.ts
import type { ClickHouseClient } from "@clickhouse/client";

export interface Score { profileId: string; p: number; }

export async function writeScores(
  ch: ClickHouseClient,
  scope: { brandId: string; env: string },
  modelVersion: string,
  scores: readonly Score[],
): Promise<void> {
  const version = Date.now();
  await ch.insert({
    table: "profile_traits",
    format: "JSONEachRow",
    // Only the key + the scored columns; the highest version per key wins at merge time.
    values: scores.map((s) => ({
      brand_id: scope.brandId, env: scope.env, profile_id: s.profileId,
      p_purchase_7d: s.p, scored_at: new Date().toISOString(), model_version: modelVersion,
      version,
    })),
  });
}
sql/audiences/aud_high_intent_30d.sql
SELECT profile_id
FROM profile_traits FINAL
WHERE brand_id = {brandId:String} AND env = {env:String}
  AND p_purchase_7d >= 0.62
  AND scored_at > now() - INTERVAL 2 DAY   -- never target on a stale score
  AND sessions_30d >= 1;
Run it yourself · open-activation-platform @ v3-profiles
Train the same model twice, once with point-in-time correctness disabled and once with it on, then compare the offline score against holdout lift.
terminal
git clone https://github.com/xaviramirezcom/open-activation-platform
cd open-activation-platform
git checkout v3-profiles
pnpm install
pnpm activate up            # streaming + warehouse + config store, via docker compose
pnpm activate seed          # synthetic multi-brand traffic, orders and spend

pnpm activate traits --brand northwind --env prod       # sessionize + roll up + history

pnpm activate train --brand northwind --point-in-time off   # features read as of "today"
pnpm activate train --brand northwind --point-in-time on    # joined at the label boundary

pnpm activate evaluate --brand northwind --holdout 0.2      # offline score + holdout lift, both
The leaky run prints an offline score around 0.94 and the correct run prints something closer to 0.68 — which is exactly the wrong signal to trust. Then the holdout section prints lift: the 0.94 model delivers roughly nothing over a random audience of the same size, and the 0.68 model delivers real separation. That is the whole post in two lines of output. The better offline number is the broken model, and no metric on the offline side of the pipeline will ever tell you so.

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 →

Explain it back

A propensity model ships with a 0.91 offline score, and a high-propensity audience goes live. A month later it converts indistinguishably from a random audience of the same size. No job failed, no schema changed, match rate is steady, and nothing in the monitoring is red. What is the most likely cause, how do you confirm it in an afternoon, and what would have caught it in CI?
Reveal a model answer

Almost certainly point-in-time leakage: the training set read the trait table as of build time, so features already contained the purchases in the label window. The model learned “bought recently”, which is unavailable at scoring time, so its ranking is close to noise in production — and nothing goes red because leakage is a correctness bug, not an availability one.

Confirm it by rebuilding the training set with an as-of join against the trait history at the label boundary and retraining: if the score collapses from 0.91 to the high sixties, you have your answer in an hour. Also check for feature and label windows that overlap, and for negatives sampled only from converters. CI catches it two ways: the offline/online parity test on the feature registry, and a leakage assertion that fails the build if any feature row used in training was computed after its own boundary.

Bonus consequence — every audience built on that score has been polluting your incrementality reads too, so post 6’s holdout numbers for the last month are unusable.

Next in the series · 04
Consent, isolation & deletion

Traits and scores are only safe to hold if consent is a join key the audience cannot compile without — and if a deletion actually reaches every destination you ever sent this person to.

Make the join structural