@@ -160,20 +160,69 @@ def _run(args):
160160HATER_MIN_NEG_RATIO = 5.0 # Min negative:positive ratio to qualify as hater (was 1:3)
161161USE_WORD_BOUNDARY = True # Use word-boundary regex in brand keyword matching
162162
163- # ─── v0.3.0: Title assignment rules (ISSUE #2) ─────────────────────
164- # Priority order (first match wins). Each entry is (label, emoji, condition_fn_string).
165- # Conditions are checked in order top-to-bottom.
163+ # ─── v0.4.0: Title assignment rules (ISSUE #1) ─────────────────────
164+ def _peak_hour_bucket (s ):
165+ """Return time-of-day bucket name or None."""
166+ hour_dist = s .get ("hour_dist" , {}) or {}
167+ if not hour_dist :
168+ return None
169+ peak = max (hour_dist , key = hour_dist .get )
170+ if peak < 6 :
171+ return "early_bird"
172+ if peak < 12 :
173+ return "morning"
174+ if peak < 14 :
175+ return "lunch"
176+ if peak < 18 :
177+ return "afternoon"
178+ return "night_owl"
179+
166180TITLE_RULES = [
167- # (priority_order, "title", "emoji", condition_description)
168- # Each checked in assign_title() in priority order
181+ # (priority, name, emoji, check_fn(stats_dict, rank_idx))
182+ # Lower number = more specific, checked first
183+ (1 , "Foundation" , "\U0001F451 " ,
184+ lambda s , r : r is not None and r <= TITLE_FOUNDATION_RANK ),
185+ (2 , "OG" , "\U0001F3DB \uFE0F " ,
186+ lambda s , r : ((s .get ("first_seen" ) or "9999" )[:10 ] < TITLE_OG_FIRST_SEEN
187+ and (s .get ("total_chunks" ) or 0 ) >= TITLE_OG_MIN_CHUNKS )),
188+ (3 , "Veteran" , "\U0001F396 \uFE0F " ,
189+ lambda s , r : ((s .get ("first_seen" ) or "9999" )[:10 ] < TITLE_VETERAN_FIRST_SEEN
190+ and (s .get ("max_streak" ) or 0 ) >= TITLE_VETERAN_MIN_STREAK )),
191+ (4 , "Pioneer" , "\U0001F48E " ,
192+ lambda s , r : ((s .get ("first_seen" ) or "9999" )[:10 ] < TITLE_PIONEER_FIRST_SEEN )),
193+ (5 , "Globetrotter" , "\U0001F30D " ,
194+ lambda s , r : (len (s .get ("channels" , set ()) or set ()) >= TITLE_GLOBETROTTER_MIN_CHANNELS
195+ and (s .get ("total_chunks" ) or 0 ) >= TITLE_GLOBETROTTER_MIN_CHUNKS )),
196+ (6 , "Trailblazer" , "\U0001F680 " ,
197+ lambda s , r : (s .get ("total_chunks" ) or 0 ) >= TITLE_TRAILBLAZER_MIN_CHUNKS ),
198+ (7 , "Discord Whisperer" , "\U0001F5E3 \uFE0F " ,
199+ lambda s , r : ((s .get ("msg_per_chunk" ) or 0 ) > TITLE_WHISPERER_MIN_MSG_RATIO
200+ and (s .get ("total_chunks" ) or 0 ) >= TITLE_WHISPERER_MIN_CHUNKS )),
201+ (8 , "Polymath" , "\U0001F52C " ,
202+ lambda s , r : len (s .get ("channels" , set ()) or set ()) >= TITLE_POLYMATH_MIN_CHANNELS ),
203+ (9 , "Sage" , "\U0001F9E0 " ,
204+ lambda s , r : ((s .get ("msg_per_chunk" ) or 0 ) > TITLE_SAGE_MIN_MSG_PER_CHUNK
205+ and (s .get ("total_chunks" ) or 0 ) >= TITLE_SAGE_MIN_CHUNKS )),
206+ (10 , "Night Owl" , "\U0001F989 " ,
207+ lambda s , r : _peak_hour_bucket (s ) == "night_owl" ),
208+ (11 , "Early Bird" , "\U0001F305 " ,
209+ lambda s , r : _peak_hour_bucket (s ) == "early_bird" ),
210+ (12 , "Morning Poster" , "\u2615 " ,
211+ lambda s , r : _peak_hour_bucket (s ) == "morning" ),
212+ (13 , "Lunch Break" , "\U0001F96A " ,
213+ lambda s , r : _peak_hour_bucket (s ) == "lunch" ),
214+ (14 , "Afternoon Poster" , "\U0001F31E " ,
215+ lambda s , r : _peak_hour_bucket (s ) == "afternoon" ),
216+ (15 , "Lurker" , "\U0001F575 \uFE0F " ,
217+ lambda s , r : ((s .get ("total_chunks" ) or 0 ) >= TITLE_LURKER_MIN_CHUNKS
218+ and (s .get ("msg_per_chunk" ) or 0 ) < TITLE_LURKER_MAX_MSG_RATIO )),
219+ (16 , "Linker" , "\U0001F517 " ,
220+ lambda s , r : (s .get ("link_rate" ) or 0 ) > TITLE_LINKER_MIN_RATE ),
221+ (17 , "Code Wizard" , "\U0001F9D9 " ,
222+ lambda s , r : (s .get ("code_rate" ) or 0 ) > TITLE_CODE_WIZARD_MIN_RATE ),
223+ (18 , "Builder" , "\U0001F3D7 \uFE0F " ,
224+ lambda s , r : (s .get ("total_chunks" ) or 0 ) >= TITLE_BUILDER_MIN_CHUNKS ),
169225]
170- # Title thresholds: checked by assign_title(). Rarest/most prestigious first.
171- # Top 5 by rank -> "Pioneer" (uses rank_idx, handled during leaderboard building)
172- # msg_per_chunk > 15 -> "Sage"
173- # streaks >= 30 -> "Streak Master"
174- # 100+ chunks -> "Trailblazer"
175- # 4+ channels active -> "Polymath"
176- # 50+ chunks -> "Builder"
177226
178227# ─── v0.3.0: GPU tier emojis (ISSUE #8, #17) ───────────────────────
179228GPU_TIER_EMOJI = {
@@ -187,6 +236,39 @@ def _run(args):
187236 "integrated" : "\U0001F4BB " ,
188237}
189238
239+ # ─── v0.4.0: Title rule thresholds (ISSUE #1) ────────────
240+ TITLE_FOUNDATION_RANK = 5
241+ TITLE_OG_FIRST_SEEN = "2025-01-01"
242+ TITLE_OG_MIN_CHUNKS = 50
243+ TITLE_PIONEER_FIRST_SEEN = "2025-01-01"
244+ TITLE_VETERAN_FIRST_SEEN = "2025-06-01"
245+ TITLE_VETERAN_MIN_STREAK = 30
246+ TITLE_TRAILBLAZER_MIN_CHUNKS = 100
247+ TITLE_BUILDER_MIN_CHUNKS = 50
248+ TITLE_POLYMATH_MIN_CHANNELS = 4
249+ TITLE_SAGE_MIN_MSG_PER_CHUNK = 15
250+ TITLE_SAGE_MIN_CHUNKS = 10
251+ TITLE_WHISPERER_MIN_MSG_RATIO = 20
252+ TITLE_WHISPERER_MIN_CHUNKS = 20
253+ TITLE_LURKER_MIN_CHUNKS = 50
254+ TITLE_LURKER_MAX_MSG_RATIO = 3
255+ TITLE_LINKER_MIN_RATE = 0.15
256+ TITLE_CODE_WIZARD_MIN_RATE = 0.3
257+ TITLE_EMOJI_MASTER_MIN_RATE = 0.4
258+ TITLE_QUESTIONER_MIN_RATE = 0.4
259+ TITLE_GLOBETROTTER_MIN_CHANNELS = 4
260+ TITLE_GLOBETROTTER_MIN_CHUNKS = 50
261+
262+ # ─── v0.4.0: Brand lover/hater thresholds (ISSUE #13) ──────
263+ BRAND_LOVER_AWARD_MIN_TOTAL = 10
264+ BRAND_LOVER_AWARD_MIN_POS_RATIO = 3.0
265+ BRAND_HATER_AWARD_MIN_TOTAL = 10
266+ BRAND_HATER_AWARD_MIN_NEG_RATIO = 3.0
267+
268+ # ─── v0.4.0: Known community roles (ISSUE #15) ─────────────
269+ MOD_USERS = {"teknium" }
270+ DEVELOPER_USERS = {"4rgo" , "teknium" }
271+
190272# ─── T11: GPU power ranking list ────────────────────────────────────
191273GPU_LIST = [
192274 ( 1 , "GB200 Grace+Blackwell" , "384GB (2x B200)" , "frontier" , "#facc15" ),
@@ -517,12 +599,14 @@ def assign_medal(rank, name):
517599 return ["ribbon" , "Ribbon" ]
518600
519601
520- def assign_awards (user_stats ):
602+ def assign_awards (user_stats , author_name = None ):
521603 """Stable contribution awards from lifetime stats. Only go up.
522604
523605 ISSUE #4: 8 new awards added (Linker, Convo-starter, Emoji Master,
524606 Questioner, Helper, Mentor, OG, Resurrected). Existing awards have
525607 priority (checked first). Cap at 2 awards per user.
608+ ISSUE #13: Brand lover/hater awards added.
609+ ISSUE #15: Mod/Developer/Contributor role pills added (separate field).
526610 """
527611 awards = []
528612 channels = len (user_stats .get ("channels" , set ()) or set ())
@@ -587,33 +671,31 @@ def assign_awards(user_stats):
587671 if max_gap_days > 120 :
588672 awards .append (["resurrected" , "Resurrected" ])
589673
674+ # ISSUE #13: Brand lover/hater awards (checked after standard awards)
675+ if len (awards ) < 2 :
676+ _shills = user_stats .get ("shill_brands" , []) or []
677+ for _s in _shills :
678+ _bm = BRAND_META .get (_s ["brand" ])
679+ if _bm and (_s .get ("count" ) or 0 ) >= BRAND_LOVER_AWARD_MIN_TOTAL :
680+ awards .append (["lover" , f"{ _bm [1 ]} lover" ])
681+ break
682+
683+ if len (awards ) < 2 :
684+ _haters = user_stats .get ("hater_brands" , []) or []
685+ for _h in _haters :
686+ _bm = BRAND_META .get (_h ["brand" ])
687+ if _bm and (_h .get ("count" ) or 0 ) >= BRAND_HATER_AWARD_MIN_TOTAL :
688+ awards .append (["hater" , f"{ _bm [1 ]} hater" ])
689+ break
690+
590691 return awards [:2 ]
591692
592693
593694def assign_title (s , rank_idx = None ):
594- """Return a single per-user title string based on strongest contribution signal.
595-
596- ISSUE #2: Priority-ordered checks. First match wins.
597- Titles: Pioneer, Sage, Streak Master, Trailblazer, Polymath, Builder.
598- """
599- chunks = s .get ("total_chunks" , 0 ) or 0
600- msgs = s .get ("total_messages" , 0 ) or 0
601- channels_n = len (s .get ("channels" , set ()) or set ())
602- msg_per_chunk = s .get ("msg_per_chunk" , 0 ) or 0
603- max_streak = s .get ("max_streak" , 0 ) or 0
604-
605- if rank_idx is not None and rank_idx <= 5 :
606- return "\U0001F48E Pioneer"
607- if msg_per_chunk > 15 and chunks > 10 :
608- return "\U0001F9E0 Sage"
609- if max_streak >= 30 :
610- return "\U0001F525 Streak Master"
611- if chunks >= 100 :
612- return "\U0001F680 Trailblazer"
613- if channels_n >= 4 :
614- return "\U0001F9EC Polymath"
615- if chunks >= 50 :
616- return "\U0001F6E0 \uFE0F Builder"
695+ """Evaluate all TITLE_RULES and return the best match (highest priority)."""
696+ for _priority , _name , _emoji , _check_fn in TITLE_RULES :
697+ if _check_fn (s , rank_idx ):
698+ return f"{ _emoji } { _name } "
617699 return None
618700
619701
@@ -706,6 +788,74 @@ def compute_style_heuristic(s):
706788 return None
707789
708790
791+ def compute_style_pills (s ):
792+ """Return a list of individual style pill strings (ISSUE #10).
793+
794+ Each pill is one trait: channel activity, time of day, message length,
795+ linker, code wizard, emoji master, questioner.
796+ """
797+ if not s .get ("total_chunks" ):
798+ return []
799+ pills = []
800+ chunks = s .get ("total_chunks" , 0 )
801+ msgs = s .get ("total_messages" , 0 )
802+ chans = sorted (s .get ("channels" , set ()) or set ())
803+ chan_short = {"hermes-agent" : "hermes" , "community-projects-showcase" : "projects" ,
804+ "plugins-skills-and-skins" : "plugins" , "developers" : "devs" }
805+ chans_disp = [chan_short .get (c , c ) for c in chans ]
806+
807+ # Channel activity
808+ if chunks <= 3 :
809+ if chans_disp :
810+ chan_str = f"in #{ chans_disp [0 ]} " if len (chans_disp ) == 1 else f"in #{ ' + #' .join (chans_disp )} "
811+ pills .append (f"Brief contributor { chan_str } " )
812+ return pills
813+ if len (chans_disp ) == 1 :
814+ pills .append (f"active in #{ chans_disp [0 ]} " )
815+ elif len (chans_disp ) <= 3 :
816+ pills .append (f"active in #{ ' + #' .join (chans_disp )} " )
817+ else :
818+ pills .append (f"polymath across all { len (chans_disp )} channels" )
819+
820+ # Time of day
821+ hour_dist = s .get ("hour_dist" , {}) or {}
822+ if hour_dist :
823+ peak_hour = max (hour_dist , key = hour_dist .get )
824+ if peak_hour < 6 :
825+ pills .append ("night owl (UTC)" )
826+ elif peak_hour < 12 :
827+ pills .append ("morning poster (UTC)" )
828+ elif peak_hour < 18 :
829+ pills .append ("afternoon poster (UTC)" )
830+ else :
831+ pills .append ("evening poster (UTC)" )
832+
833+ # Message length
834+ avg_msg_len = round (s .get ("total_msg_len" , 0 ) / max (msgs , 1 ))
835+ if avg_msg_len > 350 :
836+ pills .append (f"long-form (avg { avg_msg_len } chars)" )
837+ elif avg_msg_len < 100 :
838+ pills .append (f"concise (avg { avg_msg_len } chars)" )
839+ else :
840+ pills .append (f"mid-length (avg { avg_msg_len } chars)" )
841+
842+ # Style traits
843+ link_rate = s .get ("link_rate" , 0 ) or 0
844+ if link_rate > 0.15 :
845+ pills .append ("linker" )
846+ code_rate = s .get ("code_rate" , 0 ) or 0
847+ if code_rate > 0.3 :
848+ pills .append ("code wizard" )
849+ emoji_rate = s .get ("emoji_rate" , 0 ) or 0
850+ if emoji_rate > 0.4 :
851+ pills .append ("emoji master" )
852+ q_rate = s .get ("question_rate" , 0 ) or 0
853+ if q_rate > 0.4 :
854+ pills .append ("questioner" )
855+
856+ return pills
857+
858+
709859# ─── T10: Brand detection ─────────────────────────────────────────
710860def _match_brand_kw (content , kw ):
711861 """Match a keyword against content — supports \\ b regex patterns."""
@@ -753,17 +903,17 @@ def assign_shill_hater(brand_sentiment):
753903 continue
754904 # All positive (no negative mentions ever)
755905 if pos > 0 and neg == 0 and pos >= BRAND_MENTION_THRESHOLD :
756- shill .append ({"brand" : brand , "count" : pos , "ratio" : 9999.99 })
906+ shill .append ({"brand" : brand , "count" : pos , "ratio" : 9999.99 , "total" : total })
757907 # All negative (no positive mentions ever)
758908 elif neg > 0 and pos == 0 and neg >= BRAND_MENTION_THRESHOLD :
759- hater .append ({"brand" : brand , "count" : neg , "ratio" : 0.0 })
909+ hater .append ({"brand" : brand , "count" : neg , "ratio" : 0.0 , "total" : total })
760910 # Mixed: check ratios
761911 elif pos + neg >= BRAND_MENTION_THRESHOLD :
762912 ratio = pos / max (neg , 1 )
763913 if ratio >= SHILL_MIN_POS_RATIO :
764- shill .append ({"brand" : brand , "count" : pos , "ratio" : round (ratio , 2 )})
914+ shill .append ({"brand" : brand , "count" : pos , "ratio" : round (ratio , 2 ), "total" : total })
765915 elif ratio <= (1.0 / HATER_MIN_NEG_RATIO ):
766- hater .append ({"brand" : brand , "count" : neg , "ratio" : round (ratio , 2 )})
916+ hater .append ({"brand" : brand , "count" : neg , "ratio" : round (ratio , 2 ), "total" : total })
767917 return shill , hater
768918
769919
@@ -1191,8 +1341,11 @@ def main():
11911341 for rank_idx , (author , s ) in enumerate (_ranked , start = 1 ):
11921342 tc = s ["total_chunks" ]
11931343 medal = assign_medal (rank_idx , author )
1194- awards = assign_awards (s )
1344+ awards = assign_awards (s , author )
11951345 title = assign_title (s , rank_idx )
1346+ # Cap shill/hater at top-2 by total mention count (ISSUE #9)
1347+ _shills = sorted (s .get ("shill_brands" , []) or [], key = lambda x : x .get ("total" , 0 ), reverse = True )[:2 ]
1348+ _haters = sorted (s .get ("hater_brands" , []) or [], key = lambda x : x .get ("total" , 0 ), reverse = True )[:2 ]
11961349 leaderboard .append ({
11971350 "name" : author , "total_chunks" : tc ,
11981351 "total_messages" : s ["total_messages" ],
@@ -1206,10 +1359,13 @@ def main():
12061359 "awards" : awards ,
12071360 "title" : title ,
12081361 "style_heuristic" : compute_style_heuristic (s ),
1362+ "style_pills" : compute_style_pills (s ),
12091363 "style_summary" : STYLE_CACHE .get (author , {}).get ("summary" ),
1210- "shill" : s . get ( "shill_brands" , []) ,
1211- "hater" : s . get ( "hater_brands" , []) ,
1364+ "shill" : _shills ,
1365+ "hater" : _haters ,
12121366 "developed" : s .get ("developed_repos" , []),
1367+ "mod" : author in MOD_USERS ,
1368+ "developer" : author in DEVELOPER_USERS ,
12131369 })
12141370
12151371 # Per-user mood context (T14a) — last 20 chunks sorted by recency
0 commit comments