Attribution engine — spec
Table of Contents
1. Purpose
The attribution engine is the classical multi-touch layer. Given a journey (a vector of touchpoints), each of six models returns a credit vector whose sum is 1.0. No model is privileged; the point of having six is that they disagree.
The engine sits under the attribution-audit v5 layer, which adds
what none of the six models can supply on its own: provenance labels,
credit-conservation across claimants, and an incrementality holdout.
The six models compute credit; the audit layer computes whether
credit was earned. The distinction matters: five of the six are
purely rules — they will happily attribute credit to an entirely
asserted (stuffed) touchpoint. The Shapley model is more principled
in structure but our characteristic function is a linear stub
(v(S) = |S|/n) because with $0.00 revenue we have no signal to fit
a real conversion-probability function (Shapley 1953).
ORACLE: src/wal_sh/adtech/attribution/core.cljc (pure), browser.cljs
(UTM parsing + sessionStorage persistence), server.clj (JVM
corpus replay).
2. Requirements
- E1
- Every model is a pure function
journey -> [assignment]where assignment has:touchpoint,:credit,:model. - E2
- For any non-empty journey,
(sum credits) ≈ 1.0within floating-point epsilon. - E3
- Empty journey returns empty vector for every model.
- E4
time-decayis host-pure: caller suppliesnow(millis) so noSystem/currentTimeMillisorDate.now()is called fromcore.- E5
- Half-life default is 7 days (
default-half-life-ms), a literal ported verbatim from the JS module. - E6
- Shapley over distinct channels; the characteristic function
is a linear stub
v(S) = |S|/ndocumented in the docstring. Per-channel Shapley then splits proportionally across touchpoints sharing that channel. - E7
- UTM classification is pure over a parsed query-string map;
the browser adapter passes
document.referrerhostname when no UTM is present. Classification is:channel,:source,:medium,:campaign. - E8
- Six models are dispatched by keyword in
modelsmap;compare-modelsruns all six against one journey.time-decayis folded in explicitly because it takesnow. - E9
- Credits are floating-point, not rationals; property tests use an epsilon-tolerant sum-check.
3. Contract signature
Pure (core.cljc):
(touchpoint channel source medium campaign [timestamp]) ; -> map
(last-click journey) ; -> [{:touchpoint :credit :model}]
(first-click journey) ; -> ...
(linear journey) ; -> ...
(time-decay journey now [half-life-ms]) ; -> ...
(u-shape journey) ; -> ... (40/40/20 middle-split)
(data-driven journey) ; -> Shapley over channels
(compare-models journey now [half-life-ms]) ; -> {model-name -> assignments}
(classify-from-utm utm [referrer-host]) ; -> {:channel :source :medium :campaign}
(total-credit assignments) ; -> sum, should ≈ 1.0
Browser surface:
window._attribution = {
track: (fn [] -> touchpoint) ; parse URL/referrer, append to journey
models: (fn [] -> {model -> assignments}) ; compare all six for current journey
clear: (fn [] -> nil) ; wipe sessionStorage journey
}
4. Model semantics
| Model | Weight distribution | ||
|---|---|---|---|
last-click |
100 % to final touchpoint | ||
first-click |
100 % to first touchpoint | ||
linear |
1/n to each |
||
time-decay |
2^(-Δt/half-life), normalised |
||
u-shape |
40 % first, 40 % last, 20 % split across middle | ||
data-driven |
Shapley over distinct channels; v(S)\ |
S\ | /n= |
For n=1 u-shape returns 100 %; for n=2, 50/50. Middle-split is
0.2 / (n-2) for n≥3.
5. Related literature
- Shapley's original paper introducing the value function that the data-driven model attempts to instantiate (Shapley 1953). A production DDA needs a fitted conversion-probability characteristic function; we have the structure but not the signal.
- Vickrey on incentive-compatible pricing (Vickrey 1961) and Edelman et al. on GSP auctions (Edelman, Ostrovsky, and Schwarz 2007) motivate why last-click and first-click persist despite their obvious flaws: they are cheap, they are stable, and the incentives at the pricing layer don't reward better models.
- Bashir et al. trace how a single retargeted-ad journey is fragmented across ad exchanges in practice — the multi-touch journey we model with six touchpoints is often 20+ in the wild (Bashir et al. 2016).
- Papadopoulos et al. on cookie synchronization (Papadopoulos, Kourtellis, and Markatos 2019) documents why "journey" is itself a fiction: user identity across the touches is reconstructed by cross-domain cookie matches, not a stable identifier.
- Cook et al.'s tracker-advertiser relationship inference (Cook, Nithyanand, and Shafiq 2020) shows the ecosystem topology that makes multi-touch attribution structurally impossible to verify from outside — the "journey" is opaque per definition.
- Google's Attribution Reporting API (Google 2022) is the modern proposal to make server-side attribution work without cross-site cookies; our six-model engine is the ledger-side complement.
6. Cross-references
- attribution-audit/spec.org — v5 layer that audits this engine's credit vectors for provenance + credit-conservation.
- stuffing-detectors/spec.org — four detectors that flag when a claimant's credit vector is inconsistent with the observed signal timeline.
- beacon/spec.org — touchpoints are shaped like beacon extras
(
:source,:medium,:campaign). - ORACLE:
src/wal_sh/adtech/attribution/core.cljc(pure — six models)src/wal_sh/adtech/attribution/browser.cljs(UTM + sessionStorage)src/wal_sh/adtech/attribution/server.clj(corpus replay)
- [BROKEN LINK: No match for fuzzy expression: *2007–2013: Programmatic exchange era] on the rise and reign of last-click.
7. Open questions
- Data-driven Shapley with a real characteristic function. The
current stub
v(S) = |S|/nis documented as such; running the engine over real revenue events would require a fitted model which we don't have data for and don't want to fake. A simulation-mode with synthesized revenue would let us test the structural correctness of the Shapley aggregation without lying about the underlying signal. - Journey inference. The engine assumes a caller-supplied journey. In production a journey is inferred from sessionStorage across visits, and cross-device attribution is the standing hard problem. We record only in-session touches, which sidesteps the hard problem by pretending it's not there.