Exposure attribution for creative work
When a monitoring service finds an unauthorized copy of a work, which earlier exposure most likely leaked it, and which kinds of sends are risky before the next release?
Summary
We give rights holders a ranked, explained list of the exposures that most likely leaked a work, every time a copy turns up. HeapDB runs as a sidecar to the provenance or anti-piracy service that illustrators, photographers and music labels already use.
The question comes up when the monitoring service reports an unauthorized copy, often before release. The rights holder wants to know which promo send, share link, client delivery, social post or marketplace listing it most likely came from. Before the next release, the same learned links score planned sends, so you can change risky send settings before anything goes out.
Model
| HeapDB | Creative work |
|---|---|
| Entity | The work (a track, a photo set, an illustration). A copy of work W can only come from W's own exposures. |
| Events | Exposures of the work: type send (promo send, share link, client delivery, marketplace listing) and type post (a public social post or teaser). |
| Features | Only risk-bearing presence: download:on, format:wav (full quality; res:full for images), expiry:none, watermark:off, audience:large. The safe value of a dimension adds no feature, so a watermarked 14-day stream to a few people has none. Under noisy-OR every present feature adds to the link, so encoding safe values would give every send a similar, inflated link. |
| Weight (dose) | Log-scaled audience size: 1 + 0.5 * log10(recipients). A single recipient weighs 1. |
| Effects and kernels | leak_prerelease with LogNormalKernel(median=4 * D, sigma=0.6, tail_sigmas=3.0): send to copy found, support about 24 days. An optional short-lag reupload effect (a copy reappearing after a takedown) could use its own short kernel. |
| Leak | leak=0.02: "probably from somewhere we don't track", such as a studio, a laptop or an untracked share. |
| Rules | public snippet x0: a public teaser snippet cannot be the source of a full-length copy. |
| Attributes | attrs hold the recipient group (promo_pool, press, curator, sync, social, client), the file version (full, edit, stems, snippet) and the watermark flag. They are context for rules and the bridge, not scored features. |
Links start from a sceptical default_prior=(1, 20): most sends never leak, and an unconfirmed feature contributes little. A variant match (which exact file version the copy matches) is applied in a bridge outside the core. In the example it is a filter: only candidates whose file version attribute matches the copy are kept. A real bridge could multiply the score by a match confidence instead.
Learning
- Feedback (certain source). A per-recipient watermark (a canary) that survives in the found copy names the send.
feedback(work, "leak_prerelease", at, confirmed_event_id=...)confirms it. - Observe (no label). Takedowns where the watermark was stripped, or the send had none, call
observe(work, "leak_prerelease", at). Each send in the window gets soft credit; the leak takes the rest. - Settle (coverage rule).
settle(until)adds negative evidence to sends whose window passed with no copy found. "Passed" meansuntilis beyond the send time plus the kernel support. It runs only for works the watcher monitors, withw_negset to the share of leak destinations the watcher covers (0.75 in the example). A copy on a platform nobody watches is not evidence that the send was safe. - Priors. A sceptical default prior per feature. Each work borrows from a global layer, so a new release starts from the catalog's history.
Sidecar architecture
The system of record stays the send log of the promo or delivery tool and the monitoring service's findings. Sends and findings reach HeapDB from an outbox table, change data capture or a webhook stream. A periodic job calls settle with the watcher's coverage watermark.
HeapDB writes back per-finding top_causes to the case view and a link_report plus feature_risk to the send planner. It is rebuildable by replaying the send log and the findings.
Worked example
A synthetic label sends 200 tracks before release to a promo pool, press, curators and sync agents, and posts teaser snippets. The seeded truth: downloads, full-quality WAVs and no expiry each raise the leak chance, and most promo-pool sends have all three. Audience size is in the weight; audience:large has no effect of its own.
Output of 0005-creative-exposure-attribution.py
SCENARIO
200 tracks (168 monitored), 2,257 sends and 300 teaser posts before release
229 leaks simulated, 73 never found (unmonitored, uncovered or post-release);
156 copies found: 63 with a surviving canary (feedback), 93 observed only
settle: 918 sends of monitored tracks passed their window with no copy
ONE LEAK, EXPLAINED (track-155: copy found, matches the full file, canary stripped)
Top causes of 'leak_prerelease' for track-155 at t=2026-03-03T08:04:57Z:
1. #2002 send@2026-02-28T08:51:03Z [download:on,expiry:none,format:wav,watermark:off] w=1.35 score=0.4105
lag 71.23h -> kernel 0.995 x weight 1.35 x link 0.306 [noisy-OR of expiry:none=0.12 (a=3.32, b=25.26), download:on=0.11 (a=3.18, b=25.71), format:wav=0.07 (a=1.82, b=26.21), watermark:off=0.06 (a=1.48, b=24.84)]
2. #2001 send@2026-02-28T04:11:24Z [audience:large,download:on] w=1.95 score=0.3292
lag 75.89h -> kernel 0.979 x weight 1.95 x link 0.173 [noisy-OR of download:on=0.11 (a=3.18, b=25.71), audience:large=0.07 (a=1.88, b=24.86)]
3. #2000 send@2026-02-28T02:38:03Z [download:on,expiry:none] w=1.5 score=0.3107
lag 77.45h -> kernel 0.971 x weight 1.5 x link 0.213 [noisy-OR of expiry:none=0.12 (a=3.32, b=25.26), download:on=0.11 (a=3.18, b=25.71)]
[segments: 2 scanned / 5 pruned / 7 in window; events scored: 6]
variant filter keeps #2001, #2000, #1999: most likely exposure #2001; true source #2001
ACROSS ALL COPIES (link_report, global view, all risk features)
Links of 'leak_prerelease' for (global) (baseline 0.129; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
expiry:none 0.177 [0.149, 0.206] 1.37 457.3 84.65 393.62
download:on 0.171 [0.143, 0.200] 1.32 461.3 82.34 399.99
audience:large 0.124 [0.093, 0.159] 0.96 245.5 33.15 233.37
format:wav 0.093 [0.063, 0.129] 0.72 185.7 19.31 187.35
watermark:off 0.081 [0.046, 0.123] 0.62 110.4 10.59 120.79
BEFORE THE NEXT RELEASE (two planned promo-pool sends of 600 DJs, both watermarked)
A: full WAV, download, no expiry relative risk score 1.095 true P(leak) 0.361
B: stream only, 14-day expiry relative risk score 0.297 true P(leak) 0.024
A / B: HeapDB 3.7x, simulated truth 15.1x (scores rank sends; they are not probabilities)
CHECK
seeded risky settings rank download:on #2, expiry:none #1, format:wav #4 of 5; no-effect audience:large #3
79 held-out copies (true send known, canary stripped): HeapDB top-1 = true send 48/79 (61%); last send 26/79 (33%); largest audience 29/79 (37%)Show the full script (249 lines)
"""Exposure attribution for creative work: which send most likely leaked this track?
Simulates one year of pre-release promotion at a music label: 200 tracks, each
sent before release to a promo pool (DJs), press, playlist curators and sync
agents, plus public teaser snippets on social media. Every send is an exposure
event of the track. Features encode only risk-bearing PRESENCE: download:on,
format:wav (full quality), expiry:none, watermark:off and audience:large. A
watermarked 14-day stream to a few people carries no risk feature. Recipient
group, file version (asset: full, edit, stems, snippet) and the watermark flag
are attrs, not features. Weight is the log-scaled audience size, 1 + 0.5 * log10(recipients).
A monitoring service finds unauthorized copies on the platforms it covers. It
also tells which file version (asset) the copy matches. HeapDB ranks the
earlier sends of that track; a bridge outside the core keeps only candidates
of the matched asset.
Hidden ground truth (per send, noisy-OR, then scaled by the send's weight):
* Seeded risky settings: download:on 0.06, format:wav 0.05, expiry:none 0.04;
watermark:off 0.02. Any send also has a base 0.01 (screen or line recording).
* Confounder: audience:large has no effect of its own (audience size is already
in the weight), but most large sends are promo-pool sends of full WAVs,
downloadable, with no expiry.
* Near-miss: every format:wav send is also download:on, so feedback cannot
fully separate the two.
* Teaser snippets (type post) are public on purpose and never leak a full copy;
a rule scores them 0.
* Noise: 10% of monitored tracks also leak from an untracked source (a studio,
a laptop); 25% of leaks land on platforms the watcher does not cover; 15% of
tracks are not monitored at all.
Learning: a per-recipient watermark (canary) survives in 55% of leaks from
watermarked sends and confirms the source (feedback). Only watermarked sends
can be confirmed this way. Every found copy is observe()d. Sends whose window
passed with no copy are settle()d, only for monitored tracks, with
w_neg = 0.75 (the watcher's platform coverage).
Run:
pip install heapdb
python 0005-creative-exposure-attribution.py
"""
from __future__ import annotations
import math
import random
from heapdb import HeapDB, LogNormalKernel, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 5
N_TRACKS = 200
T0 = 1767571200.0 # 2026-01-05T00:00:00Z; simulation times are seconds after T0
TRUTH = {"download:on": 0.06, "format:wav": 0.05, "expiry:none": 0.04, "watermark:off": 0.02}
BASE = 0.01 # any send: a screen or line recording
SEEDED = ("download:on", "expiry:none", "format:wav")
NO_EFFECT = "audience:large"
UNTRACKED = 0.10 # P(leak from an untracked source) per monitored track
COVERAGE = 0.75 # share of leak destinations the watcher covers
MONITORED = 0.85 # share of tracks enrolled in monitoring
CANARY_SURVIVES = 0.55 # P(per-recipient watermark survives in a leaked copy)
def weight(recipients: int) -> float:
return 1.0 + 0.5 * math.log10(recipients)
def send(t, group, asset, fmt, wm, expiry, n):
"""One exposure: (time, type, presence features, weight, attrs)."""
feats = set()
if fmt != "stream_only":
feats.add("download:on")
if fmt == "wav":
feats.add("format:wav")
if expiry == "none":
feats.add("expiry:none")
if not wm:
feats.add("watermark:off")
if 50 < n <= 5000:
feats.add("audience:large")
return (t, "post" if group == "social" else "send", feats, weight(n),
{"group": group, "asset": asset, "watermarked": wm})
def campaign(rng: random.Random, release: float) -> list:
"""Pre-release sends of one track, 25-45 days before release."""
out = []
def when():
return release - rng.uniform(25, 45) * D
for _ in range(rng.choice([1, 1, 2])): # promo pool: hundreds of DJs
n = rng.randint(200, 800)
r = rng.random()
if r < 0.65:
out.append(send(when(), "promo_pool", rng.choice(["full", "edit"]), "wav", True, "none", n))
elif r < 0.85:
out.append(send(when(), "promo_pool", "full", "stream_only", True, "14d", n))
else:
out.append(send(when(), "promo_pool", "full", "mp3", True, "14d", n))
for _ in range(rng.randint(2, 5)): # press
fmt = "mp3" if rng.random() < 0.5 else "stream_only"
out.append(send(when(), "press", "full", fmt, rng.random() < 0.7,
rng.choice(["none", "14d"]), rng.randint(3, 80)))
for _ in range(rng.randint(3, 8)): # curators, one by one
fmt = "mp3" if rng.random() < 0.25 else "stream_only"
out.append(send(when(), "curator", rng.choice(["full", "edit"]), fmt, True,
"14d" if rng.random() < 0.6 else "none", 1))
for _ in range(rng.randint(0, 2)): # sync agents: WAV stems, small audience
out.append(send(when(), "sync", rng.choice(["full", "stems"]), "wav", rng.random() < 0.5,
rng.choice(["none", "14d"]), rng.randint(2, 6)))
for _ in range(rng.randint(1, 2)): # public teaser snippets
out.append(send(when(), "social", "snippet", "stream_only", False, "none", 50000))
return sorted(out, key=lambda s: s[0])
def p_leak(feats, w) -> float:
miss = 1.0 - BASE
for f in feats:
miss *= 1.0 - TRUTH.get(f, 0.0)
return min(0.95, w * (1.0 - miss))
def leak_lag(rng: random.Random) -> float:
"""Send -> copy found by the watcher: the leak itself plus detection delay."""
return math.exp(rng.gauss(math.log(4 * D), 0.6)) + rng.expovariate(1 / (12 * H))
def main() -> None:
rng = random.Random(SEED)
db = HeapDB(bucket_seconds=2 * D)
db.register_effect("leak_prerelease", LogNormalKernel(median=4 * D, sigma=0.6, tail_sigmas=3.0),
default_prior=(1, 20), leak=0.02, w_neg=COVERAGE)
# A public teaser snippet cannot be the source of a full-length copy.
db.add_rule("leak_prerelease", "public snippet", lambda e: e.attrs.get("asset") == "snippet", 0.0)
tracks, copies = {}, [] # copy: (t_found, track, true send event or None, asset, canary)
n_leaks = n_unseen = 0
for i in range(N_TRACKS):
track = f"track-{i:03d}"
release = rng.uniform(50, 360) * D
monitored = rng.random() < MONITORED
evs = [db.insert(track, T0 + t, typ, feats, weight=w, attrs=at)
for t, typ, feats, w, at in campaign(rng, release)]
tracks[track] = (monitored, evs)
leaks = [(ev.time - T0 + leak_lag(rng), ev, ev.attrs["asset"]) for ev in evs
if ev.type == "send" and rng.random() < p_leak(ev.features, ev.weight)]
if monitored and rng.random() < UNTRACKED:
leaks.append((release - rng.uniform(1, 30) * D, None, rng.choice(["full", "edit"])))
for t, ev, asset in leaks:
n_leaks += 1
if not monitored or t >= release or rng.random() > COVERAGE:
n_unseen += 1
continue
canary = ev is not None and ev.attrs["watermarked"] and rng.random() < CANARY_SURVIVES
copies.append((T0 + t, track, ev, asset, canary))
copies.sort(key=lambda c: c[0])
for t, track, ev, _asset, canary in copies:
db.observe(track, "leak_prerelease", t)
if canary:
db.feedback(track, "leak_prerelease", t, confirmed_event_id=ev.id)
horizon = T0 + 400 * D
rejected = sum(db.settle(horizon, "leak_prerelease", entity=tr).events_rejected
for tr, (mon, _) in tracks.items() if mon)
def candidates(track, t, asset, k=50):
"""top_causes, then the bridge's variant filter: same asset as the copy."""
return [c for c in db.top_causes(track, "leak_prerelease", t, k=k) if c.event.attrs["asset"] == asset]
n_send = sum(1 for _, (_, evs) in tracks.items() for e in evs if e.type == "send")
n_post = sum(len(evs) for _, (_, evs) in tracks.items()) - n_send
n_canary = sum(c[4] for c in copies)
print("SCENARIO")
print(f" {N_TRACKS} tracks ({sum(m for m, _ in tracks.values())} monitored), {n_send:,} sends "
f"and {n_post} teaser posts before release")
print(f" {n_leaks} leaks simulated, {n_unseen} never found (unmonitored, uncovered or post-release);")
print(f" {len(copies)} copies found: {n_canary} with a surviving canary (feedback), "
f"{len(copies) - n_canary} observed only")
print(f" settle: {rejected:,} sends of monitored tracks passed their window with no copy")
# Held out from feedback: copies with a known true send but no surviving canary.
held = [c for c in copies if c[2] is not None and not c[4]]
hit = last_hit = big_hit = 0
pick = None
for t, track, ev, asset, _ in held:
cands = candidates(track, t, asset)
prior = [e for e in tracks[track][1] if e.type == "send" and e.time < t and e.attrs["asset"] == asset]
last = max(prior, key=lambda e: e.time)
big = max(prior, key=lambda e: (e.weight, e.time))
top = cands[0].event if cands else None
hit += top is not None and top.id == ev.id
last_hit += last.id == ev.id
big_hit += big.id == ev.id
raw = db.top_causes(track, "leak_prerelease", t, k=3)
if (pick is None and top is not None and top.id == ev.id and raw.top is not None
and raw.top.event.id != ev.id and len(raw) == 3):
pick = (t, track, ev, asset)
print()
t, track, ev, asset = pick
print(f"ONE LEAK, EXPLAINED ({track}: copy found, matches the {asset} file, canary stripped)")
print(format_result(db.top_causes(track, "leak_prerelease", t, k=3, explain=True)))
kept = candidates(track, t, asset)[:3]
print(f" variant filter keeps {', '.join('#%d' % c.event.id for c in kept)}: most likely exposure "
f"#{kept[0].event.id}; true source #{ev.id}")
print()
print("ACROSS ALL COPIES (link_report, global view, all risk features)")
feats = sorted({f for _, (_, evs) in tracks.items() for e in evs if e.type == "send"
for f in e.features})
rep = db.link_report("leak_prerelease", min_support=20, features=feats)
print("\n".join(str(rep).splitlines()[:10]))
print()
print("BEFORE THE NEXT RELEASE (two planned promo-pool sends of 600 DJs, both watermarked)")
plans = {
"A: full WAV, download, no expiry": {"format:wav", "download:on", "expiry:none",
"audience:large"},
"B: stream only, 14-day expiry": {"audience:large"},
}
risk, true_p = {}, {}
for name, fs in plans.items():
risk[name] = db.feature_risk("leak_prerelease", fs, weight=weight(600), type="send")
true_p[name] = p_leak(fs, weight(600))
print(f" {name:<34} relative risk score {risk[name]:.3f} true P(leak) {true_p[name]:.3f}")
(a, b), (ta, tb) = risk.values(), true_p.values()
print(f" A / B: HeapDB {a / b:.1f}x, simulated truth {ta / tb:.1f}x "
f"(scores rank sends; they are not probabilities)")
order = [r.feature for r in rep]
ranks = {f: order.index(f) + 1 for f in SEEDED + (NO_EFFECT,)}
print()
print("CHECK")
print(" seeded risky settings rank " + ", ".join(f"{f} #{ranks[f]}" for f in SEEDED)
+ f" of {len(order)}; no-effect {NO_EFFECT} #{ranks[NO_EFFECT]}")
print(f" {len(held)} held-out copies (true send known, canary stripped): HeapDB top-1 = true send "
f"{hit}/{len(held)} ({hit / len(held):.0%}); last send {last_hit}/{len(held)} "
f"({last_hit / len(held):.0%}); largest audience {big_hit}/{len(held)} "
f"({big_hit / len(held):.0%})")
# The two separable seeded settings must hold #1 and #2, above the no-effect
# confounder. format:wav is not asserted: it always rides with download:on.
top_two = {ranks["download:on"], ranks["expiry:none"]} == {1, 2}
if (not top_two or ranks[NO_EFFECT] <= 2 or hit <= max(last_hit, big_hit)
or a <= 2 * b):
raise SystemExit(1)
if __name__ == "__main__":
main()- In the explained leak, the copy matches the full file and the canary was stripped. Raw
top_causesranks #2002 first (score=0.4105), a send that carries every risk feature. The variant filter drops it because its file version differs, and keeps #2001, the true source. - Across all copies, the two separable seeded settings hold the top:
expiry:none#1 (lift 1.37) anddownload:on#2 (lift 1.32). The no-effectaudience:largeranks #3 at 0.124, above the seededformat:wav#4 at 0.093. format:wavis under-ranked because every WAV send is alsodownload:on. Proportional credit gives most of the shared credit to the stronger link.audience:largeis the confounder: large sends are mostly promo-pool WAVs with downloads and no expiry.- On 79 held-out copies (true send known, canary stripped), HeapDB's top-1 is the true send for 48/79 (61%). Picking the last send gets 26/79 (33%), and the largest audience 29/79 (37%).
- Before the next release, plan A (full WAV, download, no expiry) gets relative risk score 1.095 and plan B (watermarked stream, 14-day expiry) 0.297. These scores rank sends; they are not probabilities. The ratio is 3.7x against a simulated truth of 15.1x (0.361 vs 0.024).
- The direction is right but the gap is compressed. Plan B's only feature is
audience:large, which picks up credit from the large sends it rides with. Plan A's learned links (expiry:none0.177,download:on0.171) sit well above their seeded values. Leaks from the base rate and from untracked sources are credited to whatever features were present.
Run it:
pip install heapdb
python 0005-creative-exposure-attribution.py # copy the script from "Show the full script" aboveEvaluation
- Metric. Top-1 and top-3 accuracy of the most likely exposure per finding, and the rank of risky send settings in
link_report. - Baseline. The last send before the copy appeared, and the send with the largest audience. HeapDB must beat both on top-1.
- Ground truth. Findings where a canary survived, held out from feedback. A second source is a canary audit: re-check stripped copies with a stronger forensic watermark reader where one exists.
Limits and risks
- A ranked recipient is not proof. The output is the most likely exposure under the model. It must never be used to name a person as the leaker, and it does not support an accusation on its own.
- Evidence bundles hold facts, not scores. A takedown or legal file should contain send logs, file hashes and watermark reads. HeapDB's score helps a person decide where to look.
- Watcher coverage bias. Copies on platforms the watcher does not cover are never seen. Sends whose copies land there look safe. The coverage rule in
settlereduces this, but does not remove it. - Confounded features. If every promo-pool send is a full WAV with downloads on, feedback cannot separate pool, format and download. In the example,
audience:largeoutranksformat:wav. Changing one setting at a time across releases is the only way to tell them apart. - Label selection bias. Hard labels exist only for watermarked sends. Leaks from unwatermarked sends get only soft credit from
observe, sowatermark:offcan look safer than it is. In the example it ranks last, at 0.081. - Associations, not causal effects.
link_reportandfeature_riskshow associations under the model. A person decides whether to change send settings or contact a recipient. - Privacy. Recipient lists are personal data. Keep features at the group level, keep retention short, and use
forget_entitywhen a work is withdrawn.
Prior art
Per-recipient promo watermarking is common practice in music promotion: each DJ or journalist gets a copy marked for them. Content-ID style fingerprint matching finds copies of a work across platforms and says which version a copy matches.
Neither says which send leaked once the watermark has been stripped or the send carried none. HeapDB is a complement: it ranks the candidate sends from timing, audience size and learned links, and learns from the canary hits that do survive. It does not replace watermarking or matching.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- Should variant matching multiply the score by a match confidence instead of filtering, and where should that bridge live?
- How should a recipient who appears in many sends be modeled without turning features into individual suspects?
- Can
w_negbe estimated per platform from the watcher's coverage reports instead of one fixed number? - Should pre-send risk use a joint link for the usual promo-pool settings, given that the per-feature ratio (3.7x) is far below the simulated truth (15.1x)?