{"name":"Creative testing machine workflow","nodes":[{"id":"cc000001-1111-4222-8333-444455556601","name":"Daily Test Review","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-700,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"cc000002-1111-4222-8333-444455556602","name":"Fetch Test Campaign Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-440,180],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"ad"},{"name":"fields","value":"ad_id,ad_name,adset_id,adset_name,campaign_name,created_time,spend,impressions,clicks,ctr,cpm,frequency,actions,action_values,purchase_roas,video_thruplay_watched_actions"},{"name":"action_attribution_windows","value":"7d_click,1d_view"},{"name":"date_preset","value":"maximum"},{"name":"filtering","value":"[{\"field\":\"campaign.name\",\"operator\":\"CONTAIN\",\"value\":\"CREATIVE TEST\"}]"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"cc000003-1111-4222-8333-444455556603","name":"Read Creative Brief Queue","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-440,420],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Brief Queue"},"options":{}}},{"id":"cc000004-1111-4222-8333-444455556604","name":"Merge Tests With Brief Queue","type":"n8n-nodes-base.merge","typeVersion":3,"position":[-180,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"cc000005-1111-4222-8333-444455556605","name":"Evaluate Test Slots","type":"n8n-nodes-base.code","typeVersion":2,"position":[80,300],"parameters":{"jsCode":"// ============================================================================\n// CREATIVE TESTING MACHINE — slot evaluation\n// Inputs: (a) Meta ad-level insights for the TEST campaign, (b) the brief queue\n// sheet (one row per creative brief: brief_id, angle, hook, status, slot).\n// Output: one decision per creative in a test slot + queue state carried along.\n// ============================================================================\nconst CFG = {\n  testSlots: 8,             // how many creatives may be in test at once\n  minSpend: 120,            // no verdict before a creative has had a fair shot\n  minImpressions: 3000,\n  minTestDays: 3,           // and before it has cleared learning noise\n  maxTestDays: 10,          // hard stop: a slot cannot be squatted forever\n  controlRoas: 2.1,         // the current scale-campaign benchmark\n  winnerRoasRatio: 1.15,    // must beat control by 15% to earn scale budget\n  minCtr: 0.9,              // % — below this the hook is not landing\n  freqCeiling: 2.5,\n  cpaTarget: 42\n};\n\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find((a) => a.action_type === type);\n  return hit ? num(hit.value) : 0;\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- split the merged stream back into insights rows and brief rows --------\nconst insights = rows.filter((r) => r.ad_id || r.ad_name);\nconst briefs = rows.filter((r) => r.brief_id && !r.ad_id);\n\n// brief metadata keyed by the ad name convention: \"TEST | <brief_id> | <angle>\"\nconst briefById = new Map(briefs.map((b) => [String(b.brief_id), b]));\nconst briefIdFromName = (name) => {\n  const parts = String(name || '').split('|').map((s) => s.trim());\n  return parts.length > 1 ? parts[1] : '';\n};\n\n// queued briefs that have never run — the refill pool, oldest first\nconst queued = briefs\n  .filter((b) => String(b.status || 'queued').toLowerCase() === 'queued')\n  .sort((a, b) => String(a.queued_at || '').localeCompare(String(b.queued_at || '')));\n\n// ---- dedupe insights (breakdowns can return one row per ad per day) --------\nconst byAd = new Map();\nfor (const r of insights) {\n  const id = String(r.ad_id || '');\n  if (!id) continue;\n  const prev = byAd.get(id);\n  if (!prev) { byAd.set(id, { ...r }); continue; }\n  prev.spend = num(prev.spend) + num(r.spend);\n  prev.impressions = num(prev.impressions) + num(r.impressions);\n  prev.clicks = num(prev.clicks) + num(r.clicks);\n  prev.actions = [...(prev.actions || []), ...(r.actions || [])];\n  prev.action_values = [...(prev.action_values || []), ...(r.action_values || [])];\n}\n\nconst decisions = [];\nlet occupied = 0;\n\nfor (const r of byAd.values()) {\n  // lifetime insights also return ads this workflow archived on earlier runs —\n  // they are not occupying a slot and must not be re-judged\n  if (String(r.ad_name || '').includes('ARCHIVED:') || String(r.ad_name || '').includes('| SCALED')) continue;\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks = num(r.clicks);\n  const frequency = num(r.frequency);\n  const purchases = pick(r.actions, 'purchase') + pick(r.actions, 'omni_purchase');\n  const revenue = pick(r.action_values, 'purchase');\n  const roas = Array.isArray(r.purchase_roas)\n    ? pick(r.purchase_roas, 'purchase')\n    : num(r.purchase_roas) || (spend ? revenue / spend : 0);\n  const ctr = impressions ? (clicks / impressions) * 100 : num(r.ctr);\n  const cpa = purchases > 0 ? spend / purchases : null;\n  const hookRate = impressions ? (num(pick(r.video_thruplay_watched_actions, 'video_view')) / impressions) * 100 : 0;\n\n  const created = r.created_time ? new Date(r.created_time) : null;\n  const daysLive = created ? Math.max(0, (Date.now() - created.getTime()) / 86400000) : CFG.minTestDays;\n\n  const briefId = String(r.brief_id || briefIdFromName(r.ad_name));\n  const brief = briefById.get(briefId) || {};\n\n  occupied += 1;\n\n  // ---- 1. has this creative earned a verdict yet? -------------------------\n  const dataEnough = spend >= CFG.minSpend && impressions >= CFG.minImpressions && daysLive >= CFG.minTestDays;\n  const timedOut = daysLive >= CFG.maxTestDays;\n  const readyToJudge = dataEnough || timedOut;\n\n  // ---- 2. score the creative against the control ---------------------------\n  const roasIndex = CFG.controlRoas ? roas / CFG.controlRoas : 0;      // 1.0 = matches scale campaign\n  const ctrIndex = CFG.minCtr ? ctr / CFG.minCtr : 0;\n  const efficiency = cpa !== null ? CFG.cpaTarget / cpa : 0;           // >1 = cheaper than target\n  // weighted: profit dominates, click-through is the tiebreaker, fatigue penalises\n  const score = Math.round(\n    (roasIndex * 60 + efficiency * 25 + Math.min(ctrIndex, 2) * 15 -\n     (frequency > CFG.freqCeiling ? (frequency - CFG.freqCeiling) * 10 : 0)) * 10\n  ) / 10;\n\n  const isWinner = readyToJudge &&\n    purchases >= 3 &&\n    roas >= CFG.controlRoas * CFG.winnerRoasRatio &&\n    (cpa === null || cpa <= CFG.cpaTarget) &&\n    frequency <= CFG.freqCeiling;\n\n  // ---- 3. loser reasons, in the order a buyer would say them --------------\n  const reasons = [];\n  if (readyToJudge && !isWinner) {\n    if (purchases === 0) reasons.push('0 purchases on $' + spend.toFixed(0));\n    else if (roas < CFG.controlRoas) reasons.push('ROAS ' + roas.toFixed(2) + ' below control ' + CFG.controlRoas);\n    else if (roas < CFG.controlRoas * CFG.winnerRoasRatio) reasons.push('ROAS ' + roas.toFixed(2) + ' only matched control, no lift');\n    if (cpa !== null && cpa > CFG.cpaTarget) reasons.push('CPA $' + cpa.toFixed(2) + ' vs $' + CFG.cpaTarget + ' target');\n    if (ctr < CFG.minCtr) reasons.push('CTR ' + ctr.toFixed(2) + '% — hook not landing');\n    if (frequency > CFG.freqCeiling) reasons.push('frequency ' + frequency.toFixed(2) + ' already fatigued');\n    if (timedOut && !dataEnough) reasons.push('hit day ' + CFG.maxTestDays + ' without enough delivery to judge');\n  }\n\n  decisions.push({\n    json: {\n      ad_id: String(r.ad_id),\n      ad_name: r.ad_name || '(unnamed)',\n      adset_id: r.adset_id || null,\n      campaign_name: r.campaign_name || '',\n      brief_id: briefId || null,\n      angle: brief.angle || r.angle || 'unlabelled',\n      hook: brief.hook || '',\n      slot: brief.slot || null,\n      spend: Math.round(spend * 100) / 100,\n      impressions,\n      clicks,\n      ctr: Math.round(ctr * 100) / 100,\n      hook_rate: Math.round(hookRate * 100) / 100,\n      frequency: Math.round(frequency * 100) / 100,\n      purchases,\n      revenue: Math.round(revenue * 100) / 100,\n      roas: Math.round(roas * 100) / 100,\n      cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n      days_live: Math.round(daysLive * 10) / 10,\n      score,\n      roas_index: Math.round(roasIndex * 100) / 100,\n      ready_to_judge: readyToJudge,\n      is_winner: isWinner,\n      verdict: !readyToJudge ? 'still_testing' : (isWinner ? 'promote' : 'archive'),\n      archive_reason: reasons.join(' + ') || null,\n      control_roas: CFG.controlRoas,\n      slots_total: CFG.testSlots,\n      slots_occupied: occupied,\n      queued_briefs: queued.length,\n      next_briefs: queued.slice(0, CFG.testSlots).map((b) => ({\n        brief_id: b.brief_id, angle: b.angle, hook: b.hook, creative_id: b.creative_id || null\n      })),\n      evaluated_at: new Date().toISOString()\n    }\n  });\n}\n\n// best creatives first so the report and the promote loop read top-down\ndecisions.sort((a, b) => b.json.score - a.json.score);\nreturn decisions;"}},{"id":"cc000006-1111-4222-8333-444455556606","name":"Past Minimum Test Spend?","type":"n8n-nodes-base.if","typeVersion":2,"position":[340,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"r1","leftValue":"={{ $json.ready_to_judge }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"cc000007-1111-4222-8333-444455556607","name":"Hold Creative In Test","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[600,820],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"ad_name","value":"={{ $json.ad_name }}","type":"string"},{"id":"h2","name":"brief_id","value":"={{ $json.brief_id }}","type":"string"},{"id":"h3","name":"angle","value":"={{ $json.angle }}","type":"string"},{"id":"h4","name":"spend","value":"={{ $json.spend }}","type":"number"},{"id":"h5","name":"roas","value":"={{ $json.roas }}","type":"number"},{"id":"h6","name":"days_live","value":"={{ $json.days_live }}","type":"number"},{"id":"h7","name":"score","value":"={{ $json.score }}","type":"number"},{"id":"h8","name":"state","value":"still_testing","type":"string"},{"id":"h9","name":"note","value":"=needs {{ 120 - $json.spend > 0 ? (120 - $json.spend).toFixed(0) : 0 }} more spend before a verdict","type":"string"}]},"options":{}}},{"id":"cc000008-1111-4222-8333-444455556608","name":"Winner Beats Control?","type":"n8n-nodes-base.if","typeVersion":2,"position":[600,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"w1","leftValue":"={{ $json.is_winner }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"cc000009-1111-4222-8333-444455556609","name":"Loop Over Winners","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[860,180],"parameters":{"batchSize":1,"options":{}}},{"id":"cc00000a-1111-4222-8333-44445555660a","name":"Copy Winner Into Scale Campaign","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1120,20],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.ad_id }}/copies","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ adset_id: \"SCALE_ADSET_ID\", status_option: \"ACTIVE\", rename_options: { rename_suffix: \" | SCALED\" } }) }}","options":{}}},{"id":"cc00000b-1111-4222-8333-44445555660b","name":"Mark Creative Promoted","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1380,20],"parameters":{"assignments":{"assignments":[{"id":"p1","name":"ad_id","value":"={{ $('Loop Over Winners').item.json.ad_id }}","type":"string"},{"id":"p2","name":"ad_name","value":"={{ $('Loop Over Winners').item.json.ad_name }}","type":"string"},{"id":"p3","name":"brief_id","value":"={{ $('Loop Over Winners').item.json.brief_id }}","type":"string"},{"id":"p4","name":"angle","value":"={{ $('Loop Over Winners').item.json.angle }}","type":"string"},{"id":"p5","name":"spend","value":"={{ $('Loop Over Winners').item.json.spend }}","type":"number"},{"id":"p6","name":"revenue","value":"={{ $('Loop Over Winners').item.json.revenue }}","type":"number"},{"id":"p7","name":"roas","value":"={{ $('Loop Over Winners').item.json.roas }}","type":"number"},{"id":"p8","name":"cpa","value":"={{ $('Loop Over Winners').item.json.cpa }}","type":"number"},{"id":"p9","name":"purchases","value":"={{ $('Loop Over Winners').item.json.purchases }}","type":"number"},{"id":"p10","name":"score","value":"={{ $('Loop Over Winners').item.json.score }}","type":"number"},{"id":"p11","name":"control_roas","value":"={{ $('Loop Over Winners').item.json.control_roas }}","type":"number"},{"id":"p12","name":"scaled_ad_id","value":"={{ $json.copied_ad_id || $json.id }}","type":"string"},{"id":"p13","name":"promoted","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"cc00000c-1111-4222-8333-44445555660c","name":"Log Promotion To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1640,20],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Promotions"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"cc00000d-1111-4222-8333-44445555660d","name":"Build Testing Report","type":"n8n-nodes-base.code","typeVersion":2,"position":[1380,180],"parameters":{"jsCode":"// Batch report for the buyer: what got promoted, what the queue looks like now.\nconst rows = $input.all().map((i) => i.json);\nconst promoted = rows.filter((r) => r.promoted === true);\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\nif (!promoted.length) {\n  return [{ json: {\n    promoted_count: 0,\n    slack_text: '🧪 *Creative test review* — no creative cleared the winner bar this run. Tests keep running, losers were archived and their slots refilled.'\n  } }];\n}\n\nconst totalSpend = promoted.reduce((s, r) => s + Number(r.spend || 0), 0);\nconst totalRev = promoted.reduce((s, r) => s + Number(r.revenue || 0), 0);\nconst blended = totalSpend ? totalRev / totalSpend : 0;\n\nconst lines = promoted\n  .sort((a, b) => Number(b.score) - Number(a.score))\n  .map((r, i) => (i + 1) + '. *' + r.ad_name + '* (' + r.angle + ')\\n' +\n    '     ROAS ' + Number(r.roas).toFixed(2) + ' vs control ' + r.control_roas +\n    ' · ' + money(r.spend) + ' spent · ' + r.purchases + ' purchases · CPA ' +\n    (r.cpa === null ? 'n/a' : money(r.cpa)) + ' · score ' + r.score);\n\nconst slack_text = [\n  '🏆 *' + promoted.length + ' creative' + (promoted.length > 1 ? 's' : '') + ' promoted to the scale campaign*',\n  'Blended test ROAS of the winners: ' + blended.toFixed(2) + ' on ' + money(totalSpend) + ' of test spend',\n  '',\n  ...lines,\n  '',\n  'Losers were paused with a written reason and their slots refilled from the brief queue.'\n].join('\\n');\n\nreturn [{ json: {\n  promoted_count: promoted.length,\n  test_spend: Math.round(totalSpend),\n  test_revenue: Math.round(totalRev),\n  blended_roas: Math.round(blended * 100) / 100,\n  slack_text\n} }];"}},{"id":"cc00000e-1111-4222-8333-44445555660e","name":"Post Testing Report","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1640,180],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-testing","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"cc00000f-1111-4222-8333-44445555660f","name":"Archive Losing Creative","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[860,560],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.ad_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: \"PAUSED\", name: $json.ad_name + \" | ARCHIVED: \" + ($json.archive_reason || \"failed test\") }) }}","options":{}}},{"id":"cc00001a-1111-4222-8333-44445555661a","name":"Mark Creative Archived","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1120,560],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"ad_id","value":"={{ $('Evaluate Test Slots').item.json.ad_id }}","type":"string"},{"id":"a2","name":"ad_name","value":"={{ $('Evaluate Test Slots').item.json.ad_name }}","type":"string"},{"id":"a3","name":"brief_id","value":"={{ $('Evaluate Test Slots').item.json.brief_id }}","type":"string"},{"id":"a4","name":"angle","value":"={{ $('Evaluate Test Slots').item.json.angle }}","type":"string"},{"id":"a5","name":"spend","value":"={{ $('Evaluate Test Slots').item.json.spend }}","type":"number"},{"id":"a6","name":"roas","value":"={{ $('Evaluate Test Slots').item.json.roas }}","type":"number"},{"id":"a7","name":"cpa","value":"={{ $('Evaluate Test Slots').item.json.cpa }}","type":"number"},{"id":"a8","name":"purchases","value":"={{ $('Evaluate Test Slots').item.json.purchases }}","type":"number"},{"id":"a9","name":"score","value":"={{ $('Evaluate Test Slots').item.json.score }}","type":"number"},{"id":"a10","name":"archive_reason","value":"={{ $('Evaluate Test Slots').item.json.archive_reason }}","type":"string"},{"id":"a11","name":"slots_total","value":"={{ $('Evaluate Test Slots').item.json.slots_total }}","type":"number"},{"id":"a12","name":"slots_occupied","value":"={{ $('Evaluate Test Slots').item.json.slots_occupied }}","type":"number"},{"id":"a13","name":"next_briefs","value":"={{ $('Evaluate Test Slots').item.json.next_briefs }}","type":"array"},{"id":"a14","name":"state","value":"archived","type":"string"},{"id":"a15","name":"archived","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"cc000010-1111-4222-8333-444455556610","name":"Log Test Ledger","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1380,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Test Ledger"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"cc000011-1111-4222-8333-444455556611","name":"Refill Free Test Slots","type":"n8n-nodes-base.code","typeVersion":2,"position":[1640,560],"parameters":{"jsCode":"// A slot just freed up (a loser was archived). Pull the next brief off the\n// queue and turn it into a ready-to-launch ad payload for the test campaign.\n// Rows that are merely \"still_testing\" pass through here too — they must never\n// trigger a launch, so everything below is derived from archived rows only.\nconst rows = $input.all().map((i) => i.json);\nconst archivedRows = rows.filter((r) => r.archived === true);\nif (!archivedRows.length) return [];\n\nconst first = archivedRows[0];\nconst slotsTotal = Number(first.slots_total || 8);\nconst occupied = Number(first.slots_occupied || 0);\nconst archived = archivedRows.length;\n\n// free slots = capacity - what is still live after this run's archives\nconst free = Math.max(0, slotsTotal - Math.max(0, occupied - archived));\nif (!free) return [];\n\nconst pool = Array.isArray(first.next_briefs) ? first.next_briefs : [];\nconst seen = new Set();\nconst launches = [];\n\nfor (const b of pool) {\n  if (launches.length >= free) break;\n  const id = String(b.brief_id || '');\n  if (!id || seen.has(id)) continue;   // never launch the same brief twice in one run\n  seen.add(id);\n  launches.push({ json: {\n    brief_id: id,\n    angle: b.angle || 'unlabelled',\n    hook: b.hook || '',\n    creative_id: b.creative_id || null,\n    ad_name: 'TEST | ' + id + ' | ' + (b.angle || 'unlabelled'),\n    slot: launches.length + 1,\n    daily_budget_cents: 2500,       // every slot gets the same $25/day so results compare\n    launched_at: new Date().toISOString()\n  } });\n}\n\nif (!launches.length) {\n  return [{ json: {\n    brief_id: null,\n    ad_name: 'NO BRIEFS QUEUED',\n    angle: 'queue empty',\n    hook: '',\n    slot: 0,\n    daily_budget_cents: 0,\n    launched_at: new Date().toISOString()\n  } }];\n}\nreturn launches;"}},{"id":"cc000012-1111-4222-8333-444455556612","name":"Launch Brief Into Test Slot","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1900,560],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/act_ID/ads","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ name: $json.ad_name, adset_id: \"TEST_ADSET_ID\", creative: { creative_id: $json.creative_id }, status: \"ACTIVE\" }) }}","options":{}}},{"id":"cc000013-1111-4222-8333-444455556613","name":"Announce New Tests","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2160,560],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-testing","mode":"name"},"text":"=🧪 Slot {{ $('Refill Free Test Slots').item.json.slot }} refilled — *{{ $('Refill Free Test Slots').item.json.ad_name }}*\nAngle: {{ $('Refill Free Test Slots').item.json.angle }}\nHook: {{ $('Refill Free Test Slots').item.json.hook }}\nBudget: $25/day, verdict in ~3 days or $120 spend.","otherOptions":{}}},{"id":"cc000014-1111-4222-8333-444455556614","name":"Note Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-740,0],"parameters":{"content":"## 1. READ THE TESTING QUEUE\nLifetime insights for every ad in the CREATIVE TEST campaign, merged with the brief queue sheet (brief_id, angle, hook, creative_id, status). 8 test slots, $25/day each so results are comparable.","height":280,"width":440,"color":4}},{"id":"cc000015-1111-4222-8333-444455556615","name":"Note Verdict","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[60,0],"parameters":{"content":"## 2. VERDICT RULES\nNo verdict before $120 spend + 3k impressions + 3 days (or day 10 timeout). Winner = 3+ purchases, ROAS >= 1.15x the 2.1 control, CPA <= $42, frequency <= 2.5. Everything else is archived with the exact reason written into the ad name.","height":300,"width":460,"color":3}},{"id":"cc000016-1111-4222-8333-444455556616","name":"Note Recycle","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1600,780],"parameters":{"content":"## 3. PROMOTE, ARCHIVE, REFILL\nWinners are copied into the scale ad set and logged. Losers are paused with a reason, written to the ledger, and every freed slot is immediately refilled from the next queued brief — the machine never idles.","height":280,"width":460,"color":5}}],"connections":{"Daily Test Review":{"main":[[{"node":"Fetch Test Campaign Insights","type":"main","index":0},{"node":"Read Creative Brief Queue","type":"main","index":0}]]},"Fetch Test Campaign Insights":{"main":[[{"node":"Merge Tests With Brief Queue","type":"main","index":0}]]},"Read Creative Brief Queue":{"main":[[{"node":"Merge Tests With Brief Queue","type":"main","index":1}]]},"Merge Tests With Brief Queue":{"main":[[{"node":"Evaluate Test Slots","type":"main","index":0}]]},"Evaluate Test Slots":{"main":[[{"node":"Past Minimum Test Spend?","type":"main","index":0}]]},"Past Minimum Test Spend?":{"main":[[{"node":"Winner Beats Control?","type":"main","index":0}],[{"node":"Hold Creative In Test","type":"main","index":0}]]},"Hold Creative In Test":{"main":[[{"node":"Log Test Ledger","type":"main","index":0}]]},"Winner Beats Control?":{"main":[[{"node":"Loop Over Winners","type":"main","index":0}],[{"node":"Archive Losing Creative","type":"main","index":0}]]},"Loop Over Winners":{"main":[[{"node":"Build Testing Report","type":"main","index":0}],[{"node":"Copy Winner Into Scale Campaign","type":"main","index":0}]]},"Copy Winner Into Scale Campaign":{"main":[[{"node":"Mark Creative Promoted","type":"main","index":0}]]},"Mark Creative Promoted":{"main":[[{"node":"Log Promotion To Sheet","type":"main","index":0}]]},"Log Promotion To Sheet":{"main":[[{"node":"Loop Over Winners","type":"main","index":0}]]},"Build Testing Report":{"main":[[{"node":"Post Testing Report","type":"main","index":0}]]},"Archive Losing Creative":{"main":[[{"node":"Mark Creative Archived","type":"main","index":0}]]},"Mark Creative Archived":{"main":[[{"node":"Log Test Ledger","type":"main","index":0}]]},"Log Test Ledger":{"main":[[{"node":"Refill Free Test Slots","type":"main","index":0}]]},"Refill Free Test Slots":{"main":[[{"node":"Launch Brief Into Test Slot","type":"main","index":0}]]},"Launch Brief Into Test Slot":{"main":[[{"node":"Announce New Tests","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}