Skip to content
SlashClone

Enrichment waterfall planner

@gtmengineer/enrichment-waterfall-planner

Plan an enrichment waterfall with real math. Give it your providers with cost, match rate, and accuracy (or a sample test run), and it tries every order to find the cheapest setup that hits your fill target, with cost per usable record, the marginal cost of each step, and where to stop. Vendor-neutral.

SkillDatav1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// enrichment-waterfall-planner: find the provider order (and where to stop) for an
  3. 3// enrichment waterfall. Models fill rate, accuracy, optional verification, and cost
  4. 4// per lookup or per match, from your estimates or from a sample test run.
  5. 5// Node 22, built-ins only. Vendor-neutral: providers are whatever you name them.
  6. 6//
  7. 7// Usage:
  8. 8// node scripts/waterfall.mjs <providers.json> [--sample sample.csv] [--out out] [--name waterfall]
  9. 9
  10. 10import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
  11. 11import { join } from "node:path";
  12. 13function fail(msg) {
  13. 14 console.error(`enrichment-waterfall-planner: ${msg}`);
  14. 15 process.exit(1);
  15. 18const args = process.argv.slice(2);
  16. 19const opts = { out: "out", name: "waterfall", sample: null };
  17. 20const pos = [];
  18. 21for (let i = 0; i < args.length; i++) {
  19. 22 if (args[i] === "--out") opts.out = args[++i];
  20. 23 else if (args[i] === "--name") opts.name = args[++i];
  21. 24 else if (args[i] === "--sample") opts.sample = args[++i];
  22. 25 else if (args[i] === "--help" || args[i] === "-h") {
  23. 26 console.log("Usage: node scripts/waterfall.mjs <providers.json> [--sample sample.csv] [--out out] [--name waterfall]");
  24. 27 process.exit(0);
  25. 28 } else pos.push(args[i]);
  26. 30if (pos.length !== 1) fail("give a providers JSON file. Try --help.");
  27. 32const read = (p) => {
  28. 33 try {
  29. 34 return readFileSync(p, "utf8");
  30. 35 } catch {
  31. 36 fail(`cannot read ${p}`);
  32. 37 }
  33. 38};
  34. 39let cfg;
  35. 40try {
  36. 41 cfg = JSON.parse(read(pos[0]));
  37. 42} catch (e) {
  38. 43 fail(`${pos[0]} is not valid JSON: ${e.message}`);
  39. 45const providers = cfg.providers;
  40. 46if (!Array.isArray(providers) || providers.length === 0) fail("providers must be a non-empty array");
  41. 47if (providers.length > 8) fail("at most 8 providers (the planner tries every order)");
  42. 48const N = Number(cfg.records ?? 1000);
  43. 49const target = cfg.min_fill_rate != null ? Number(cfg.min_fill_rate) : null;
  44. 50const maxPerRecord = cfg.max_cost_per_record != null ? Number(cfg.max_cost_per_record) : null;
  45. 51const verifyCost = cfg.verify ? Number(cfg.verify.cost ?? 0) : null;
  46. 52const names = new Set();
  47. 53for (const p of providers) {
  48. 54 if (!p.name) fail("every provider needs a name");
  49. 55 if (names.has(p.name)) fail(`duplicate provider name "${p.name}"`);
  50. 56 names.add(p.name);
  51. 57 if (!(Number(p.cost) >= 0)) fail(`${p.name}: cost must be a number of at least 0`);
  52. 58 p.cost = Number(p.cost);
  53. 59 p.charge = p.charge === "per_match" ? "per_match" : "per_lookup";
  54. 60 p.accuracy = p.accuracy == null ? 1 : Number(p.accuracy);
  55. 61 if (!(p.accuracy > 0 && p.accuracy <= 1)) fail(`${p.name}: accuracy must be between 0 and 1`);
  56. 62 if (!opts.sample) {
  57. 63 p.match_rate = Number(p.match_rate);
  58. 64 if (!(p.match_rate >= 0 && p.match_rate <= 1)) fail(`${p.name}: match_rate must be between 0 and 1 (or pass --sample)`);
  59. 65 }
  60. 68// ---------- sample ----------
  61. 69let sample = null;
  62. 70if (opts.sample) {
  63. 71 const text = read(opts.sample).replace(/\r\n?/g, "\n");
  64. 72 const lines = text.split("\n").filter((l) => l.trim());
  65. 73 const split = (l) => {
  66. 74 const out = [];
  67. 75 let f = "";
  68. 76 let q = false;
  69. 77 for (let i = 0; i < l.length; i++) {
  70. 78 const c = l[i];
  71. 79 if (q) {
  72. 80 if (c === '"' && l[i + 1] === '"') {
  73. 81 f += '"';
  74. 82 i++;
  75. 83 } else if (c === '"') q = false;
  76. 84 else f += c;
  77. 85 } else if (c === '"') q = true;
  78. 86 else if (c === ",") {
  79. 87 out.push(f.trim());
  80. 88 f = "";
  81. 89 } else f += c;
  82. 90 }
  83. 91 out.push(f.trim());
  84. 92 return out;
  85. 93 };
  86. 94 const header = split(lines[0]);
  87. 95 for (const p of providers) if (!header.includes(p.name)) fail(`sample has no column named "${p.name}". Columns: ${header.join(", ")}`);
  88. 96 sample = lines.slice(1).map((l) => {
  89. 97 const cells = split(l);
  90. 98 return Object.fromEntries(providers.map((p) => [p.name, (cells[header.indexOf(p.name)] || "") !== ""]));
  91. 99 });
  92. 100 if (!sample.length) fail("sample has no rows");
  93. 101 for (const p of providers) p.match_rate = sample.filter((r) => r[p.name]).length / sample.length;
  94. 104// ---------- simulate one order ----------
  95. 105// Each record carries probability mass that it still needs a value. A provider that
  96. 106// returns a value stops the waterfall for that record, unless verification is on and
  97. 107// the value fails it (probability 1 - accuracy), in which case the record continues.
  98. 108function simulate(order) {
  99. 109 const steps = order.map((p) => ({ provider: p.name, reach: 0, found: 0, usable: 0, cost: 0 }));
  100. 110 const run = (has) => {
  101. 111 let mass = 1;
  102. 112 order.forEach((p, i) => {
  103. 113 if (mass <= 0) return;
  104. 114 const s = steps[i];
  105. 115 s.reach += mass;
  106. 116 const hit = has(p) * mass;
  107. 117 s.found += hit;
  108. 118 s.usable += hit * p.accuracy;
  109. 119 s.cost += p.charge === "per_lookup" ? mass * p.cost : hit * p.cost;
  110. 120 if (verifyCost != null) s.cost += hit * verifyCost;
  111. 121 mass -= verifyCost != null ? hit * p.accuracy : hit;
  112. 122 });
  113. 123 };
  114. 124 if (sample) {
  115. 125 for (const r of sample) run((p) => (r[p.name] ? 1 : 0));
  116. 126 const scale = N / sample.length;
  117. 127 for (const s of steps) for (const k of ["reach", "found", "usable", "cost"]) s[k] *= scale;
  118. 128 } else {
  119. 129 run((p) => p.match_rate);
  120. 130 for (const s of steps) for (const k of ["reach", "found", "usable", "cost"]) s[k] *= N;
  121. 131 }
  122. 132 let cumUsable = 0;
  123. 133 let cumCost = 0;
  124. 134 for (const s of steps) {
  125. 135 s.marginal_cost_per_usable = s.usable > 0 ? s.cost / s.usable : null;
  126. 136 cumUsable += s.usable;
  127. 137 cumCost += s.cost;
  128. 138 s.cumulative_fill_rate = cumUsable / N;
  129. 139 s.cumulative_cost = cumCost;
  130. 140 }
  131. 141 return { order: order.map((p) => p.name), steps, usable: cumUsable, fill_rate: cumUsable / N, cost: cumCost, cost_per_usable: cumUsable ? cumCost / cumUsable : null };
  132. 144function* permutations(arr) {
  133. 145 if (arr.length <= 1) {
  134. 146 yield arr;
  135. 147 return;
  136. 148 }
  137. 149 for (let i = 0; i < arr.length; i++) for (const rest of permutations([...arr.slice(0, i), ...arr.slice(i + 1)])) yield [arr[i], ...rest];
  138. 151// every ordered subset = every prefix of every permutation
  139. 152const seen = new Map();
  140. 153for (const perm of permutations(providers))
  141. 154 for (let k = 1; k <= perm.length; k++) {
  142. 155 const key = perm.slice(0, k).map((p) => p.name).join(">");
  143. 156 if (!seen.has(key)) seen.set(key, simulate(perm.slice(0, k)));
  144. 157 }
  145. 158const all = [...seen.values()];
  146. 159const EPS = 1e-9;
  147. 160const bestByDepth = [];
  148. 161for (let k = 1; k <= providers.length; k++) {
  149. 162 const opts2 = all.filter((r) => r.order.length === k);
  150. 163 const maxFill = Math.max(...opts2.map((r) => r.fill_rate));
  151. 164 // best = highest fill, then lowest cost
  152. 165 const best = opts2.filter((r) => r.fill_rate >= maxFill - 0.005).sort((a, b) => a.cost - b.cost)[0];
  153. 166 bestByDepth.push(best);
  154. 168let recommended;
  155. 169let why;
  156. 170if (target != null) {
  157. 171 const ok = all.filter((r) => r.fill_rate >= target - EPS).sort((a, b) => a.cost - b.cost || a.order.length - b.order.length);
  158. 172 if (ok.length) {
  159. 173 recommended = ok[0];
  160. 174 why = `cheapest setup that reaches the ${(target * 100).toFixed(0)}% usable fill target`;
  161. 175 } else {
  162. 176 recommended = bestByDepth[bestByDepth.length - 1];
  163. 177 why = `no setup reaches ${(target * 100).toFixed(0)}%; this is the highest fill at the lowest cost`;
  164. 178 }
  165. 179} else {
  166. 180 recommended = bestByDepth[bestByDepth.length - 1];
  167. 181 why = "highest usable fill at the lowest cost (no target set)";
  168. 183// trim tail steps whose marginal cost is above the cap
  169. 184const cutSuggestions = [];
  170. 185if (maxPerRecord != null)
  171. 186 for (const s of recommended.steps)
  172. 187 if (s.marginal_cost_per_usable != null && s.marginal_cost_per_usable > maxPerRecord)
  173. 188 cutSuggestions.push(`${s.provider}: each extra usable record costs ${s.marginal_cost_per_usable.toFixed(3)}, above your cap of ${maxPerRecord}`);
  174. 190const r2 = (x) => (x == null ? null : Math.round(x * 100) / 100);
  175. 191const r3 = (x) => (x == null ? null : Math.round(x * 1000) / 1000);
  176. 192const pctS = (x) => `${(x * 100).toFixed(1)}%`;
  177. 193const shape = (r) => ({
  178. 194 order: r.order,
  179. 195 usable_records: Math.round(r.usable),
  180. 196 fill_rate: r3(r.fill_rate),
  181. 197 total_cost: r2(r.cost),
  182. 198 cost_per_usable: r3(r.cost_per_usable),
  183. 199 steps: r.steps.map((s) => ({
  184. 200 provider: s.provider,
  185. 201 lookups: Math.round(s.reach),
  186. 202 found: Math.round(s.found),
  187. 203 usable: Math.round(s.usable),
  188. 204 cost: r2(s.cost),
  189. 205 marginal_cost_per_usable: r3(s.marginal_cost_per_usable),
  190. 206 cumulative_fill_rate: r3(s.cumulative_fill_rate),
  191. 207 })),
  192. 208});
  193. 209const result = {
  194. 210 data_point: cfg.data_point || "data point",
  195. 211 records: N,
  196. 212 source: sample ? `sample of ${sample.length} records` : "estimated match rates (independence assumed)",
  197. 213 verification: verifyCost != null,
  198. 214 provider_rates: providers.map((p) => ({ name: p.name, match_rate: r3(p.match_rate), accuracy: p.accuracy, cost: p.cost, charge: p.charge })),
  199. 215 recommended: { ...shape(recommended), why },
  200. 216 best_by_depth: bestByDepth.map(shape),
  201. 217 cut_suggestions: cutSuggestions,
  202. 218};
  203. 220const md = [`# Enrichment waterfall: ${result.data_point}`, ""];
  204. 221md.push(`${N.toLocaleString("en-US")} records. Rates from ${result.source}. Verification ${verifyCost != null ? `on (${verifyCost} per found value)` : "off"}.`, "");
  205. 222md.push(`**Recommended order: ${recommended.order.join(" > ")}**: ${pctS(recommended.fill_rate)} usable fill, total ${r2(recommended.cost)}, ${r3(recommended.cost_per_usable)} per usable record (${why}).`, "");
  206. 223md.push("| Step | Provider | Lookups | Found | Usable | Step cost | Cost per extra usable | Cumulative fill |", "| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: |");
  207. 224shape(recommended).steps.forEach((s, i) =>
  208. 225 md.push(`| ${i + 1} | ${s.provider} | ${s.lookups} | ${s.found} | ${s.usable} | ${s.cost} | ${s.marginal_cost_per_usable ?? "-"} | ${pctS(s.cumulative_fill_rate)} |`),
  209. 226);
  210. 227md.push("", "## Best setup by number of providers", "", "| Providers | Order | Usable fill | Total cost | Per usable |", "| ---: | --- | ---: | ---: | ---: |");
  211. 228bestByDepth.forEach((b, i) => md.push(`| ${i + 1} | ${b.order.join(" > ")} | ${pctS(b.fill_rate)} | ${r2(b.cost)} | ${r3(b.cost_per_usable)} |`));
  212. 229if (cutSuggestions.length) md.push("", "## Consider cutting", "", ...cutSuggestions.map((c) => `- ${c}`));
  213. 230md.push("", "Estimates, not guarantees. Re-test on a fresh sample before committing spend.", "");
  214. 232mkdirSync(opts.out, { recursive: true });
  215. 233writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
  216. 234writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
  217. 235console.log(`Recommended ${recommended.order.join(" > ")}: ${pctS(recommended.fill_rate)} usable fill at ${r3(recommended.cost_per_usable)} per usable record. Wrote ${join(opts.out, opts.name)}.{md,json}`);