Plan Flog
Plan Flog: flog gives every Ruby method a pain score; this gives every plan one. Fan-in hurts (joins hide coupling), depth hurts (chains hide latency), unlabeled edges hurt (anonymous data flow), and orphans hurt (dead code that runs). One number per task, one number per plan, and a threshold that means "refactor me". Yes, it's opinionated. So is flog. That's the point.
Testing & Verification
Round 11
Ryan Davis (zenspider)
exit 0
bundle exec ruby examples/plan_flog.rb
a real captured run
PLAN FLOG (pain points per plan; > 12 total means refactor me) tidy pipeline 0.0 fine labeled diamond 1.5 fine ledger=1.5 the monster 24.5 REFACTOR ME do_everything=14.7 orphan=5.0 the monster's breakdown, because a score you can't argue with is a score you can't learn from: do_everything costs 14.7 - five EXTRA join inputs at 1.5 coupling each, plus six anonymous ones at 1.2. the orphan costs 5.0 flat: it runs on every execution and feeds nothing, which is either a bug or a billing strategy. and the tidy pipeline scores 0.0, because a pipe is free and boring plans should be. numbers don't refactor code and they don't refactor plans - but they end the meeting about whether the monster is fine. it's a 25. it's not fine.
source
# frozen_string_literal: true # Plan Flog: flog gives every Ruby method a pain score; this gives # every plan one. Fan-in hurts (joins hide coupling), depth hurts # (chains hide latency), unlabeled edges hurt (anonymous data flow), # and orphans hurt (dead code that runs). One number per task, one # number per plan, and a threshold that means "refactor me". Yes, # it's opinionated. So is flog. That's the point. # # bundle exec ruby examples/plan_flog.rb # # Runs offline; three plans walk in, one gets told the truth. require class="s">"bundler/setup" require class="s">"agentic" def task_named(name) Agentic:class="y">:Task.new(description: name, agent_spec: {class="s">"name" => name, class="s">"instructions" => class="s">"w"}) end def tidy_pipeline o = Agentic:class="y">:PlanOrchestrator.new fetch, clean, render = %w[fetch clean render].map { |n| task_named(n) } o.add_task(fetch) o.add_task(clean, [fetch]) o.add_task(render, [clean]) o end def labeled_diamond o = Agentic:class="y">:PlanOrchestrator.new orders, refunds, ledger, report = %w[orders refunds ledger report].map { |n| task_named(n) } o.add_task(orders) o.add_task(refunds) o.add_task(ledger, needs: {sales: orders, credits: refunds}) o.add_task(report, [ledger]) o end def the_monster o = Agentic:class="y">:PlanOrchestrator.new sources = 6.times.map { |i| task_named(class="s">"src#{i}") } sources.each { |s| o.add_task(s) } god = task_named(class="s">"do_everything") o.add_task(god, sources) # six unlabeled inputs chain = god 4.times do |i| step = task_named(class="s">"then#{i}") o.add_task(step, [chain]) chain = step end o.add_task(task_named(class="s">"orphan")) # added in a refactor, feeds nothing... but runs o end # The scoring, flog-style: pain per structural sin def flog(graph) stats = graph[class="y">:stats] labeled = graph[class="y">:edges].count { |e| e[class="y">:label] } scores = graph[class="y">:tasks].keys.to_h do |id| fan_in = graph[class="y">:dependencies][id].size fan_out = graph[class="y">:edges].count { |e| e[class="y">:from] == id } unlabeled_in = (fan_in >= 2) ? graph[class="y">:edges].count { |e| e[class="y">:to] == id && !e[class="y">:label] } : 0 orphan = (stats[class="y">:roots].include?(id) && stats[class="y">:leaves].include?(id) && graph[class="y">:tasks].size > 1) ? 5.0 : 0 score = [fan_in - 1, 0].max * 1.5 + # a pipe is free; every EXTRA join input is coupling [fan_out - 2, 0].max * 1.0 + # fan-out past 2 spreads blame (stats[class="y">:depth][id] - 3).clamp(0, 99) * 0.8 + # depth past 3 hides latency unlabeled_in * 1.2 + # anonymous inputs, where they can be confused orphan # runs, feeds nothing: pay attention [id, score] end [scores, labeled] end PLANS = { class="s">"tidy pipeline" => tidy_pipeline, class="s">"labeled diamond" => labeled_diamond, class="s">"the monster" => the_monster }.freeze puts class="s">"PLAN FLOG (pain points per plan; > 12 total means refactor me)" puts PLANS.each do |name, orchestrator| graph = orchestrator.graph scores, = flog(graph) total = scores.values.sum names = graph[class="y">:tasks].transform_values(&class="y">:description) worst = scores.max_by(2) { |_, s| s }.select { |_, s| s > 0 } verdict = if total > 12 class="s">"REFACTOR ME" else ((total > 6) ? class="s">"watch it" : class="s">"fine") end puts format(class="s">" %-18s %5.1f %-12s %s", name, total, verdict, worst.map { |id, s| class="s">"#{names[id]}=#{s.round(1)}" }.join(class="s">" ")) end puts monster = the_monster.graph scores, = flog(monster) names = monster[class="y">:tasks].transform_values(&class="y">:description) top = scores.max_by { |_, s| s } total = scores.values.sum puts class="s">" the monster's breakdown, because a score you can't argue with" puts class="s">" is a score you can't learn from: #{names[top[0]]} costs #{top[1].round(1)} - five" puts class="s">" EXTRA join inputs at 1.5 coupling each, plus six anonymous ones" puts class="s">" at 1.2. the orphan costs 5.0 flat: it runs on every execution" puts class="s">" and feeds nothing, which is either a bug or a billing strategy." puts class="s">" and the tidy pipeline scores 0.0, because a pipe is free and" puts class="s">" boring plans should be." puts puts class="s">" numbers don't refactor code and they don't refactor plans -" puts class="s">" but they end the meeting about whether the monster is fine." puts class="s">" it's a #{total.round}. it's not fine."