From 9f406c0c53223db0dd817e8e538733938b336f83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 06:45:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(ads-simulator):=20address=20code=20review?= =?UTF-8?q?=20=E2=80=94=20CLI=20validation,=20constants,=20time-window=20a?= =?UTF-8?q?ggregation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues from code review resolved: 1. CLI argument validation (brittle --output handling) - Add USAGE string and die() helper; process.exit(1) on all error paths - Detect --output as last arg with no path - Detect --output followed by another flag (e.g. --output --summary-only) - Detect mutually exclusive --summary-only + --json-only - Detect --output combined with --summary-only or --json-only - Reject unknown flags with clear error message 2. Centralize magic numbers as named constants - IMPRESSIONS_BASE (10000), OPT_ITERATIONS (3), TOP_COMBOS (20), TOP_AD_COUNT (3) - All formerly hardcoded values now reference these constants - simulation_config now exposes top_combos_per_ad, optimization_iterations, top_ads_selected so the JSON output is self-documenting - Log progress strings use RAW_ADS.length and OPT_ITERATIONS, not literals - note string in simulation_config computed dynamically from dimension lengths 3. selectTopAds time-window aggregation (arbitrary first element) - Now iterates ALL TOP_COMBOS combinations (not just top-5) per channel - Accumulates ROI-weighted counts for each time_bucket and day_of_week - Selects the highest ROI-weighted time bucket as the recommended window - Selects top-3 days by accumulated ROI weight - reason field now reports the count of combinations used in the aggregation https://claude.ai/code/session_01QSkvVSYsiNQ9udQ3fvVopJ --- scripts/telegram-ads-simulator.js | 174 ++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 56 deletions(-) diff --git a/scripts/telegram-ads-simulator.js b/scripts/telegram-ads-simulator.js index 05187cc..c6a9dd9 100644 --- a/scripts/telegram-ads-simulator.js +++ b/scripts/telegram-ads-simulator.js @@ -5,10 +5,12 @@ * telegram-ads-simulator.js * RuneWager Telegram Ads Simulator v1.0 * - * Validates 30 Telegram ad creatives for compliance, simulates user behavior + * Validates Telegram ad creatives for compliance, simulates user behavior * across time buckets, days of week, channel types, and user personas, runs - * an auto-optimization loop across 3 iterations, and outputs structured JSON - * followed by a plain-text human-readable summary. + * an auto-optimization loop, and outputs structured JSON followed by a + * plain-text human-readable summary. + * Key tuning constants (TOP_COMBOS, OPT_ITERATIONS, etc.) are centralized + * in the CONSTANTS block below. * * Usage: * node scripts/telegram-ads-simulator.js @@ -27,10 +29,30 @@ const path = require('path'); // CONSTANTS // ============================================================ -const MAX_LENGTH = 160; -const BOT_LINK = 'https://t.me/RuneWager_bot'; +const MAX_LENGTH = 160; +const BOT_LINK = 'https://t.me/RuneWager_bot'; const TELEGRAM_LINK_RE = /^https:\/\/(t\.me|telegram\.me)\/[A-Za-z0-9_]+(?:\/\d+)?$/; -const FORBIDDEN_WORDS = ['cashout', 'withdraw', 'payout']; +const FORBIDDEN_WORDS = ['cashout', 'withdraw', 'payout']; + +// Simulation tuning — change here to adjust the whole simulation +const IMPRESSIONS_BASE = 10000; // Baseline impressions per ad run at bid=1.0 +const OPT_ITERATIONS = 3; // Number of copy-optimization iterations to run +const TOP_COMBOS = 20; // Top combinations stored per ad in by_time_day_channel_persona +const TOP_AD_COUNT = 3; // Number of top ads to select and report + +const USAGE = [ + 'Usage: node telegram-ads-simulator.js [options]', + '', + 'Options:', + ' --output Write full JSON + summary to ; print summary to stdout.', + ' --summary-only Print only the human-readable plain-text summary.', + ' --json-only Print only the machine-readable JSON.', + ' (no flags) Print JSON then summary to stdout.', + '', + 'Constraints:', + ' --summary-only and --json-only are mutually exclusive.', + ' --output cannot be combined with --summary-only or --json-only.', +].join('\n'); // ============================================================ // RAW AD CANDIDATES (30 variants) @@ -464,7 +486,7 @@ function bidSensitivity(baselineCTR, baselineCPC, baselineROI) { return { bid, - estimated_impressions: Math.round(10000 * profile.impressionFactor), + estimated_impressions: Math.round(IMPRESSIONS_BASE * profile.impressionFactor), CTR: adjCTR, CPC, ROI_index: ROI, @@ -622,7 +644,7 @@ function runAdSimulation(validatedAd) { } } - // Aggregate baseline across all 1,512 combinations + // Aggregate baseline across all combinations const n = allCombos.length; const mean = key => r4(allCombos.reduce((s, c) => s + c[key], 0) / n); @@ -636,11 +658,11 @@ function runAdSimulation(validatedAd) { ROI_index: mean('ROI_index'), }; - // Top 20 combinations by ROI_index (sorted copy, no mutation of original) + // Top combinations by ROI_index (sorted copy, no mutation of original) const top20 = allCombos .slice() .sort((a, b) => b.ROI_index - a.ROI_index) - .slice(0, 20); + .slice(0, TOP_COMBOS); // Bid sensitivity at baseline aggregate metrics const bid_sensitivity = bidSensitivity( @@ -649,8 +671,8 @@ function runAdSimulation(validatedAd) { baseline_metrics.ROI_index, ); - // 3-iteration optimization - const optimized_versions = [1, 2, 3].map(iter => ({ + // Optimization iterations + const optimized_versions = Array.from({ length: OPT_ITERATIONS }, (_, i) => i + 1).map(iter => ({ iteration: iter, text: optimizeText(text, category, iter), metrics: iterMetrics(baseline_metrics, iter), @@ -681,7 +703,7 @@ function runAdSimulation(validatedAd) { */ function selectTopAds(adResults, validatedAds) { const scored = adResults.map(r => { - const m = r.optimized_versions[2].metrics; // iter-3 metrics + const m = r.optimized_versions[OPT_ITERATIONS - 1].metrics; const score = m.ROI_index * 0.50 + m.overall_prize_redemption_rate * 100 * 0.30 + m.overall_leaderboard_participation_rate * 100 * 0.20; @@ -690,35 +712,51 @@ function selectTopAds(adResults, validatedAds) { scored.sort((a, b) => b.score - a.score); - return scored.slice(0, 3).map(({ ad_id, result }) => { - const iter3 = result.optimized_versions[2]; - const m = iter3.metrics; + return scored.slice(0, TOP_AD_COUNT).map(({ ad_id, result }) => { + const finalIter = result.optimized_versions[OPT_ITERATIONS - 1]; + const m = finalIter.metrics; // Best bid: highest ROI_index from bid_sensitivity const bestBidEntry = result.bid_sensitivity .slice() .sort((a, b) => b.ROI_index - a.ROI_index)[0]; - // Aggregate best time windows from top-5 combinations - const top5 = result.by_time_day_channel_persona.slice(0, 5); + // Aggregate best time windows across ALL top combinations (not just top-5). + // For each channel, accumulate ROI-weighted counts for every time bucket and + // day of week that appears in the top-combo set, then select the most + // ROI-weighted time bucket and top-3 days per channel. const windowMap = {}; - for (const combo of top5) { + for (const combo of result.by_time_day_channel_persona) { const key = combo.channel_type; - if (!windowMap[key]) windowMap[key] = { times: new Set(), days: new Set() }; - windowMap[key].times.add(combo.time_bucket); - windowMap[key].days.add(combo.day_of_week); + if (!windowMap[key]) { + windowMap[key] = { timeWeight: {}, dayWeight: {}, combos: 0 }; + } + const w = windowMap[key]; + w.timeWeight[combo.time_bucket] = (w.timeWeight[combo.time_bucket] || 0) + combo.ROI_index; + w.dayWeight[combo.day_of_week] = (w.dayWeight[combo.day_of_week] || 0) + combo.ROI_index; + w.combos++; } - const best_time_windows = Object.entries(windowMap).map(([ch, v]) => ({ - time_bucket: [...v.times][0] || '16:00-20:00', - days_of_week: [...v.days], - channel_types: [ch], - reason: `Top ROI_index combination for ${ch} in this ad`, - })); + const best_time_windows = Object.entries(windowMap).map(([ch, v]) => { + // Pick the single time bucket with the highest accumulated ROI weight + const topTime = Object.entries(v.timeWeight) + .sort((a, b) => b[1] - a[1])[0][0]; + // Pick the top-3 days by accumulated ROI weight + const topDays = Object.entries(v.dayWeight) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([day]) => day); + return { + time_bucket: topTime, + days_of_week: topDays, + channel_types: [ch], + reason: `Highest ROI-weighted time/day for ${ch} across ${v.combos} top combinations`, + }; + }); return { ad_id, - final_text: iter3.text, + final_text: finalIter.text, best_bid: bestBidEntry.bid, best_time_windows, summary_metrics: { @@ -862,7 +900,7 @@ function generateSummary(output) { lines.push(`Total ads validated: ${output.validated_ads.length}`); lines.push(`Fully compliant (no changes needed): ${compliant}`); lines.push(`Auto-fixed (minor edits applied): ${violations}`); - lines.push('All 30 ads are Telegram Ads compliant after validation.'); + lines.push(`All ${output.validated_ads.length} ads are Telegram Ads compliant after validation.`); lines.push(''); lines.push(`Simulation ran: ${output.validated_ads.length} ads x ${output.simulation_config.time_buckets.length} time buckets x ${output.simulation_config.days_of_week.length} days x ${output.simulation_config.channel_types.length} channels x ${output.simulation_config.personas.length} personas = ${output.validated_ads.length * output.simulation_config.time_buckets.length * output.simulation_config.days_of_week.length * output.simulation_config.channel_types.length * output.simulation_config.personas.length} combinations`); lines.push(''); @@ -881,42 +919,68 @@ function pct(v) { return (v * 100).toFixed(2) + '%'; } /** * Orchestrates the full simulation pipeline: - * 1. Validate all 30 ads - * 2. Run simulation across all dimension combinations - * 3. Run 3-iteration optimization loop - * 4. Select top 3 ads - * 5. Output PART A (JSON) then PART B (plain-text summary) + * 1. Parse and validate CLI arguments — fail fast on bad input + * 2. Validate all ads for Telegram Ads compliance + * 3. Run simulation across all dimension combinations + * 4. Run OPT_ITERATIONS-iteration optimization loop + * 5. Select top TOP_AD_COUNT ads + * 6. Output PART A (JSON) then PART B (plain-text summary) */ function main() { const args = process.argv.slice(2); - const outputIdx = args.indexOf('--output'); - const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : null; + + // ---- Argument parsing ---- const summaryOnly = args.includes('--summary-only'); - const jsonOnly = args.includes('--json-only'); + const jsonOnly = args.includes('--json-only'); + const outputIdx = args.indexOf('--output'); + const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : null; + + // Fail fast with a clear message on invalid argument combinations + const die = msg => { + process.stderr.write(`Error: ${msg}\n\n${USAGE}\n`); + process.exit(1); + }; + if (summaryOnly && jsonOnly) { + die('--summary-only and --json-only are mutually exclusive.'); + } + if (outputIdx >= 0 && (!outputFile || outputFile.startsWith('--'))) { + die('--output requires a file path argument (e.g. --output results/ads.json).'); + } + if (outputFile && (summaryOnly || jsonOnly)) { + die('--output cannot be combined with --summary-only or --json-only.'); + } + const KNOWN_FLAGS = new Set(['--output', '--summary-only', '--json-only']); + for (const arg of args) { + if (arg.startsWith('--') && !KNOWN_FLAGS.has(arg)) { + die(`Unknown flag "${arg}".`); + } + } + + // ---- Simulation ---- const log = msg => process.stderr.write(msg + '\n'); + const adCount = RAW_ADS.length; log('RuneWager Telegram Ads Simulator v1.0'); log('--------------------------------------'); - log('Step 1: Validating 30 ad creatives for Telegram Ads compliance...'); + log(`Step 1: Validating ${adCount} ad creatives for Telegram Ads compliance...`); const validated_ads = RAW_ADS.map((text, i) => validateAd(text, i)); const fixedCount = validated_ads.filter(a => !a.compliant).length; - log(` Done. ${validated_ads.length - fixedCount} fully compliant, ${fixedCount} auto-fixed.`); + log(` Done. ${adCount - fixedCount} fully compliant, ${fixedCount} auto-fixed.`); - const totalCombos = validated_ads.length - * TIME_BUCKETS.length * DAYS_OF_WEEK.length - * CHANNEL_TYPES.length * PERSONAS.length; + const combosPerAd = TIME_BUCKETS.length * DAYS_OF_WEEK.length * CHANNEL_TYPES.length * PERSONAS.length; + const totalCombos = adCount * combosPerAd; log(`Step 2: Running simulation (${totalCombos.toLocaleString()} total combinations)...`); const ad_results = validated_ads.map((ad, i) => { - if ((i + 1) % 5 === 0 || i === 0) log(` Ad ${i + 1}/30 simulated...`); + if ((i + 1) % 5 === 0 || i === 0) log(` Ad ${i + 1}/${adCount} simulated...`); return runAdSimulation(ad); }); - log(' Done. Baseline metrics, bid sensitivity, and 3-iteration optimization computed.'); + log(` Done. Baseline metrics, bid sensitivity, and ${OPT_ITERATIONS}-iteration optimization computed.`); - log('Step 3: Selecting top 3 ads by composite score...'); + log(`Step 3: Selecting top ${TOP_AD_COUNT} ads by composite score...`); const top_ads = selectTopAds(ad_results, validated_ads); log(` Top ads: #${top_ads.map(a => a.ad_id).join(', #')}`); @@ -929,8 +993,11 @@ function main() { violations: a.violations, })), simulation_config: { - impressions_per_simulation: 10000, + impressions_per_simulation: IMPRESSIONS_BASE, base_bid: 1.0, + top_combos_per_ad: TOP_COMBOS, + optimization_iterations: OPT_ITERATIONS, + top_ads_selected: TOP_AD_COUNT, time_buckets: TIME_BUCKETS, days_of_week: DAYS_OF_WEEK, channel_types: CHANNEL_TYPES, @@ -951,7 +1018,7 @@ function main() { HighRollerCasinoChat: 'High-value casino players. Best prize redemption and bonus completion rates.', CasualGamingChannel: 'Casual gaming community. Lower conversion for casino-style content.', }, - note: 'by_time_day_channel_persona contains the top 20 combinations by ROI_index per ad (out of 1,512 total combinations each).', + note: `by_time_day_channel_persona contains the top ${TOP_COMBOS} combinations by ROI_index per ad (out of ${combosPerAd} total combinations each).`, }, ad_results, top_ads, @@ -959,16 +1026,13 @@ function main() { log('Step 4: Generating output...'); - const jsonStr = JSON.stringify(output, null, 2); + const jsonStr = JSON.stringify(output, null, 2); const summaryStr = generateSummary(output); if (outputFile) { - // Write full output to file - const fullContent = jsonStr + '\n\n' + summaryStr + '\n'; fs.mkdirSync(path.dirname(path.resolve(outputFile)), { recursive: true }); - fs.writeFileSync(outputFile, fullContent, 'utf8'); + fs.writeFileSync(outputFile, jsonStr + '\n\n' + summaryStr + '\n', 'utf8'); log(` Full output written to: ${outputFile}`); - // Always print summary to stdout when using --output process.stdout.write(summaryStr + '\n'); } else if (summaryOnly) { process.stdout.write(summaryStr + '\n'); @@ -976,9 +1040,7 @@ function main() { process.stdout.write(jsonStr + '\n'); } else { // Default: PART A (JSON) then PART B (summary) to stdout - process.stdout.write(jsonStr + '\n'); - process.stdout.write('\n'); - process.stdout.write(summaryStr + '\n'); + process.stdout.write(jsonStr + '\n\n' + summaryStr + '\n'); } log('Simulation complete.');