semantic bufo search find-bufo.com
bufo
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(bot): phrase-length cooldown scaling, lower min to 3 words

extract cooldown policy into cooldown.zig — shorter phrases get longer
cooldowns (1-word: 4x, 2-word: 2x, 3-word: 1.3x base). rare-bufo
exemption (<0.5%) now only applies to 4+ word phrases.

cold-start stagger seeds new short-phrase bufos over 24 hours to prevent
post floods on deploy. expand exclude list with 13 sensitivity patterns.

start at min_phrase_words=3 (adds ~244 bufos), step down to 2 then 1
after monitoring. also bump zig to 0.16.0-dev.3070 (fixes SIGILL on
fly.io).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+97 -21
+3 -3
bot/Dockerfile
··· 7 7 xz-utils \ 8 8 && rm -rf /var/lib/apt/lists/* 9 9 10 - # install zig 0.16.0-dev.3059+42e33db9d 11 - RUN curl -L https://ziglang.org/builds/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d.tar.xz | tar -xJ -C /usr/local \ 12 - && ln -s /usr/local/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d/zig /usr/local/bin/zig 10 + # install zig 0.16.0-dev.3070+b22eb176b 11 + RUN curl -L https://ziglang.org/builds/zig-x86_64-linux-0.16.0-dev.3070+b22eb176b.tar.xz | tar -xJ -C /usr/local \ 12 + && ln -s /usr/local/zig-x86_64-linux-0.16.0-dev.3070+b22eb176b/zig /usr/local/bin/zig 13 13 14 14 WORKDIR /app 15 15 COPY build.zig build.zig.zon ./
+2 -2
bot/src/config.zig
··· 16 16 .bsky_handle = getenv("BSKY_HANDLE") orelse "find-bufo.com", 17 17 .bsky_app_password = getenv("BSKY_APP_PASSWORD") orelse "", 18 18 .preferred_jetstream = getenv("PREFERRED_JETSTREAM"), 19 - .min_phrase_words = parseU32(getenv("MIN_PHRASE_WORDS"), 4), 19 + .min_phrase_words = parseU32(getenv("MIN_PHRASE_WORDS"), 3), 20 20 .posting_enabled = parseBool(getenv("POSTING_ENABLED")), 21 21 .cooldown_minutes = parseU32(getenv("COOLDOWN_MINUTES"), 120), 22 - .exclude_patterns = getenv("EXCLUDE_PATTERNS") orelse "what-have-you-done,what-have-i-done,sad,crying,cant-take,knife,what-are-you-doing-with-that", 22 + .exclude_patterns = getenv("EXCLUDE_PATTERNS") orelse "what-have-you-done,what-have-i-done,sad,crying,cant-take,knife,what-are-you-doing-with-that,\\brip\\b,fire,ventilator,carnage,riot,grenade,pitchfork,sobbing,breakdown,meltdown,panic,pleading,cry", 23 23 .stats_port = parseU16(getenv("STATS_PORT"), 8080), 24 24 .backend_url = getenv("BACKEND_URL") orelse "https://find-bufo.com", 25 25 };
+48
bot/src/cooldown.zig
··· 1 + /// Cooldown policy for bufo posting. 2 + /// 3 + /// Two independent scaling factors combine multiplicatively: 4 + /// - phrase length: shorter phrases are less specific, get longer cooldowns 5 + /// - match frequency: dominant bufos get quadratically longer cooldowns 6 + /// 7 + /// Rare-bufo exemption: only 4+ word phrases with <0.5% of matches skip cooldown. 8 + 9 + const std = @import("std"); 10 + 11 + /// Quadratic scale factor for match frequency. 12 + /// At 5% of matches: ~1.5x. At 20%: ~9x. At 33%: ~23x. 13 + const SCALE_FACTOR: f64 = 200.0; 14 + 15 + /// Ratio threshold for the rare-bufo exemption (0.5%). 16 + const RARE_THRESHOLD: f64 = 0.005; 17 + 18 + /// Calculate cooldown seconds based on phrase length and match frequency. 19 + /// 20 + /// phrase_len: number of words in the bufo's matching phrase 21 + /// ratio: this bufo's match count / total match count (0.0 to 1.0) 22 + /// base_secs: base cooldown from config (e.g. 7200 for 120 minutes) 23 + pub fn getCooldownSeconds(phrase_len: u32, ratio: f64, base_secs: u64) u64 { 24 + // rare-bufo exemption: specific phrases (4+ words) with negligible match share 25 + if (phrase_len >= 4 and ratio < RARE_THRESHOLD) return 0; 26 + 27 + // length multiplier: 1-word → 4x, 2-word → 2x, 3-word → 1.33x, 4+ → 1x 28 + const clamped: f64 = @floatFromInt(@min(phrase_len, 4)); 29 + const length_mult = 4.0 / clamped; 30 + 31 + // frequency multiplier: quadratic scaling for dominant bufos 32 + const freq_mult = 1.0 + SCALE_FACTOR * ratio * ratio; 33 + 34 + const base_f: f64 = @floatFromInt(base_secs); 35 + return @intFromFloat(base_f * length_mult * freq_mult); 36 + } 37 + 38 + /// Deterministic stagger offset for cold-start spreading. 39 + /// Returns seconds in [0, window) based on a hash of the bufo name. 40 + /// Each bufo gets a different offset so they don't all become eligible at once. 41 + pub fn staggerOffset(bufo_name: []const u8, window: u64) u64 { 42 + if (window == 0) return 0; 43 + var hash: u64 = 5381; 44 + for (bufo_name) |c| { 45 + hash = hash *% 33 +% c; 46 + } 47 + return hash % window; 48 + }
+33 -2
bot/src/main.zig
··· 7 7 const Io = std.Io; 8 8 const zat = @import("zat"); 9 9 const config = @import("config.zig"); 10 + const cooldown = @import("cooldown.zig"); 10 11 const matcher = @import("matcher.zig"); 11 12 const jetstream = @import("jetstream.zig"); 12 13 const bsky = @import("bsky.zig"); ··· 70 71 71 72 // prune tracked posts older than 30 days 72 73 bot_stats.pruneOldPosts(30 * 86400); 74 + 75 + // seed cold-start cooldowns for short-phrase bufos so they don't all fire at once 76 + seedColdStartCooldowns(&m, &bot_stats); 73 77 74 78 // init state 75 79 var state = BotState{ ··· 155 159 156 160 const now = timestamp(io); 157 161 158 - // per-bufo cooldown (scaled by match frequency, persisted across restarts) 162 + // per-bufo cooldown (scaled by phrase length + match frequency) 159 163 const base_secs: u64 = @as(u64, state.config.cooldown_minutes) * 60; 160 - const cooldown_secs: i64 = @intCast(state.stats.getCooldownSeconds(match.name, base_secs)); 164 + const ratio = state.stats.getMatchRatio(match.name); 165 + const cooldown_secs: i64 = @intCast(cooldown.getCooldownSeconds(match.phrase_len, ratio, base_secs)); 161 166 162 167 if (state.stats.getLastPosted(match.name)) |last_posted| { 163 168 if (now - last_posted < cooldown_secs) { ··· 407 412 408 413 fn timestamp(io_arg: Io) i64 { 409 414 return @intCast(@divFloor(Io.Timestamp.now(io_arg, .real).nanoseconds, std.time.ns_per_s)); 415 + } 416 + 417 + /// Seed staggered last_posted timestamps for short-phrase bufos that have no history. 418 + /// Prevents a flood of first-posts when new short-phrase bufos are introduced. 419 + /// Each bufo gets a deterministic offset based on its name hash, spreading 420 + /// eligibility over 24 hours so they trickle in one by one. 421 + fn seedColdStartCooldowns(m: *matcher.Matcher, s: *stats.Stats) void { 422 + const now = timestamp(app_threaded_io.io()); 423 + const spread: u64 = 24 * 3600; // spread new bufos over 24 hours 424 + var seeded: usize = 0; 425 + 426 + for (m.bufos.items) |bufo| { 427 + if (bufo.phrase_len >= 4) continue; 428 + if (s.getLastPosted(bufo.name) != null) continue; 429 + 430 + const offset = cooldown.staggerOffset(bufo.name, spread); 431 + // set last_posted into the future so eligibility = now + offset + cd 432 + // (i64 comparison in cooldown check handles future timestamps correctly) 433 + const seed_time = now + @as(i64, @intCast(offset)); 434 + s.setLastPosted(bufo.name, seed_time); 435 + seeded += 1; 436 + } 437 + 438 + if (seeded > 0) { 439 + std.debug.print("seeded cold-start cooldowns for {} short-phrase bufos (spread over {}hr)\n", .{ seeded, spread / 3600 }); 440 + } 410 441 } 411 442 412 443 fn loadBufos(allocator: Allocator, m: *matcher.Matcher, exclude_patterns: []const u8, io_arg: Io) !void {
+4
bot/src/matcher.zig
··· 6 6 name: []const u8, 7 7 url: []const u8, 8 8 phrase: []const []const u8, 9 + phrase_len: u32, 9 10 }; 10 11 11 12 pub const Match = struct { 12 13 name: []const u8, 13 14 url: []const u8, 15 + phrase_len: u32, 14 16 }; 15 17 16 18 pub const Matcher = struct { ··· 50 52 .name = try self.allocator.dupe(u8, name), 51 53 .url = try self.allocator.dupe(u8, url), 52 54 .phrase = phrase, 55 + .phrase_len = @intCast(phrase.len), 53 56 }); 54 57 } 55 58 ··· 76 79 return .{ 77 80 .name = bufo.name, 78 81 .url = bufo.url, 82 + .phrase_len = bufo.phrase_len, 79 83 }; 80 84 } 81 85 }
+4 -12
bot/src/stats.zig
··· 335 335 } 336 336 } 337 337 338 - /// Quadratic cooldown scaling: bufos that dominate the feed get exponentially longer cooldowns. 339 - /// At 5% of matches: ~1.5x base. At 20%: ~9x base. At 33%: ~23x base. 340 - const COOLDOWN_SCALE_FACTOR: f64 = 200.0; 341 - 342 - pub fn getCooldownSeconds(self: *Stats, bufo_name: []const u8, base_secs: u64) u64 { 338 + /// Returns this bufo's share of total matches (0.0 to 1.0). 339 + pub fn getMatchRatio(self: *Stats, bufo_name: []const u8) f64 { 343 340 self.bufo_mutex.lockUncancelable(io); 344 341 defer self.bufo_mutex.unlock(io); 345 342 ··· 350 347 while (iter.next()) |entry| { 351 348 total_count += entry.value_ptr.count; 352 349 } 353 - if (total_count == 0) return base_secs; 350 + if (total_count == 0) return 0.0; 354 351 355 - const ratio = @as(f64, @floatFromInt(bufo_count)) / @as(f64, @floatFromInt(total_count)); 356 - // rare bufos (< 0.5% of matches) post immediately — no cooldown 357 - if (ratio < 0.005) return 0; 358 - // quadratic: dominant bufos get penalized much harder 359 - const multiplier = 1.0 + COOLDOWN_SCALE_FACTOR * ratio * ratio; 360 - return @intFromFloat(@as(f64, @floatFromInt(base_secs)) * multiplier); 352 + return @as(f64, @floatFromInt(bufo_count)) / @as(f64, @floatFromInt(total_count)); 361 353 } 362 354 363 355 pub fn getLastPosted(self: *Stats, bufo_name: []const u8) ?i64 {
+3 -2
bot/src/stats_template.zig
··· 249 249 \\ 250 250 \\<div class="strategy"> 251 251 \\ <h2 style="margin-top:0">posting strategy</h2> 252 - \\ <p>rare bufos (&lt;0.5% of matches) post immediately &mdash; no cooldown. frequent bufos get 253 - \\ quadratic scaling (a bufo at 30% of matches waits ~10x the base cooldown).</p> 252 + \\ <p>shorter phrases get longer cooldowns (1-word: 4x base, 2-word: 2x, 3-word: 1.3x). 253 + \\ rare bufos (4+ words, &lt;0.5% of matches) post immediately. frequent bufos get 254 + \\ quadratic frequency scaling on top of phrase-length scaling.</p> 254 255 \\ <div class="strategy-rates" id="strategy-rates"></div> 255 256 \\</div> 256 257 \\