FIX: drastically simplify auto-close topic logic

Get rid of this nonsensical maximum-flow algorithm :fired:
This commit is contained in:
Régis Hanol 2014-12-06 16:29:54 +01:00
parent da4e19af5b
commit 86c4c947a3
3 changed files with 16 additions and 141 deletions

View file

@ -1,6 +1,5 @@
require_dependency 'rate_limiter' require_dependency 'rate_limiter'
require_dependency 'system_message' require_dependency 'system_message'
require_dependency 'maximum_flow'
class PostAction < ActiveRecord::Base class PostAction < ActiveRecord::Base
class AlreadyActed < StandardError; end class AlreadyActed < StandardError; end
@ -387,70 +386,20 @@ class PostAction < ActiveRecord::Base
def self.auto_close_if_treshold_reached(topic) def self.auto_close_if_treshold_reached(topic)
return if topic.closed? return if topic.closed?
# 1) retrieve a list of pairs (user_id, post_id) representing active flags
flags = PostAction.active flags = PostAction.active
.flags .flags
.joins(:post) .joins(:post)
.where("posts.topic_id = ?", topic.id) .where("posts.topic_id = ?", topic.id)
.where.not(user_id: Discourse::SYSTEM_USER_ID) .where.not(user_id: Discourse::SYSTEM_USER_ID)
.pluck(:user_id, :post_id) .group("post_actions.user_id")
.pluck("post_actions.user_id, COUNT(post_id)")
# check we have enough flags # we need a minimum number of unique flaggers
return if flags.count < SiteSetting.num_flags_to_close_topic return if flags.count < SiteSetting.num_flaggers_to_close_topic
# we need a minimum number of flags
return if flags.sum { |f| f[1] } < SiteSetting.num_flags_to_close_topic
# 2) build sets of unique user_ids and post_ids # the threshold has been reached, we will close the topic waiting for intervention
user_ids = Set.new
post_ids = Set.new
flags.each do |f|
user_ids << f[0]
post_ids << f[1]
end
# check we have enough flaggers
return if user_ids.count < SiteSetting.num_flaggers_to_close_topic
# check we have enough posts flagged
min_post_required = SiteSetting.num_flags_to_close_topic / MAXIMUM_FLAGS_PER_POST
return if post_ids.count < min_post_required
# 3) now we have a maximum flow problem...
# the network will have
# - edges from the 'source' to each flaggers with a capacity of '# of flags casted by that user'
# - edges for each flags with a capacity of 1
# - edges from each posts to the 'sink' with a capacity of MAXIMUM_FLAGS_PER_POST
# first, we need to count the # of flags casted by each users
flags_casted_by_user = {}
flags.each { |flag| flags_casted_by_user[flag[0]] = flags.count { |f| f[0] == flag[0] } }
# then, we need to build a list of all the vertices
# ('source' being the first and 'sink" being the last)
index_of_user_id = {}
index_of_post_id = {}
user_ids.each_with_index { |user_id, index| index_of_user_id[user_id] = 1 + index }
post_ids.each_with_index { |post_id, index| index_of_post_id[post_id] = 1 + index + user_ids.count }
source = 0
sink = user_ids.count + post_ids.count + 1
n = sink + 1
# then, we need to build a map of all the edges (with their respective capacity)
# initially, everything is 0 (ie. no edge)
capacities = Array.new(n) { Array.new(n, 0) }
# from the 'source' -> all user_ids with a capacity of '# of flags casted by that user'
user_ids.each { |user_id| capacities[source][index_of_user_id[user_id]] = flags_casted_by_user[user_id] }
# for each pair (user_id, post_id) with a capacity of 1
flags.each { |f| capacities[index_of_user_id[f[0]]][index_of_post_id[f[1]]] = 1 }
# from each post_ids -> sink with a capacity of MAXIMUM_FLAGS_PER_POST
index_of_post_id.values.each { |i| capacities[i][sink] = MAXIMUM_FLAGS_PER_POST }
# finally, we use the 'relabel to front' algorithm to solve the maximum flow problem
maximum_flow = MaximumFlow.new.relabel_to_front(capacities, source, sink)
return if maximum_flow < SiteSetting.num_flags_to_close_topic
# 4) the threshold has been reached, we will close the topic waiting for intervention
message = I18n.t("temporarily_closed_due_to_flags") message = I18n.t("temporarily_closed_due_to_flags")
topic.update_status("closed", true, Discourse.system_user, message) topic.update_status("closed", true, Discourse.system_user, message)
end end

View file

@ -1,69 +0,0 @@
# cf. http://en.wikipedia.org/wiki/Maximum_flow_problem
class MaximumFlow
# cf. http://en.wikipedia.org/wiki/Push%E2%80%93relabel_maximum_flow_algorithm
def relabel_to_front(capacities, source, sink)
n = capacities.length
flow = Array.new(n) { Array.new(n, 0) }
height = Array.new(n, 0)
excess = Array.new(n, 0)
seen = Array.new(n, 0)
queue = (0...n).select { |i| i != source && i != sink }.to_a
height[source] = n - 1
excess[source] = Float::INFINITY
(0...n).each { |v| push(source, v, capacities, flow, excess) }
p = 0
while p < queue.length
u = queue[p]
h = height[u]
discharge(u, capacities, flow, excess, seen, height, n)
if height[u] > h
queue.unshift(queue.delete_at(p))
p = 0
else
p += 1
end
end
flow[source].reduce(:+)
end
private
def push(u, v, capacities, flow, excess)
residual_capacity = capacities[u][v] - flow[u][v]
send = [excess[u], residual_capacity].min
flow[u][v] += send
flow[v][u] -= send
excess[u] -= send
excess[v] += send
end
def discharge(u, capacities, flow, excess, seen, height, n)
while excess[u] > 0
if seen[u] < n
v = seen[u]
if capacities[u][v] - flow[u][v] > 0 && height[u] > height[v]
push(u, v, capacities, flow, excess)
else
seen[u] += 1
end
else
relabel(u, capacities, flow, height, n)
seen[u] = 0
end
end
end
def relabel(u, capacities, flow, height, n)
min_height = Float::INFINITY
(0...n).each do |v|
if capacities[u][v] - flow[u][v] > 0
min_height = [min_height, height[v]].min
height[u] = min_height + 1
end
end
end
end

View file

@ -372,23 +372,20 @@ describe PostAction do
it "will automatically close a topic due to large community flagging" do it "will automatically close a topic due to large community flagging" do
SiteSetting.stubs(:flags_required_to_hide_post).returns(0) SiteSetting.stubs(:flags_required_to_hide_post).returns(0)
SiteSetting.stubs(:num_flags_to_close_topic).returns(12)
SiteSetting.stubs(:num_flaggers_to_close_topic).returns(5) SiteSetting.stubs(:num_flags_to_close_topic).returns(3)
SiteSetting.stubs(:num_flaggers_to_close_topic).returns(2)
topic = Fabricate(:topic) topic = Fabricate(:topic)
post1 = create_post(topic: topic) post1 = create_post(topic: topic)
post2 = create_post(topic: topic) post2 = create_post(topic: topic)
post3 = create_post(topic: topic) post3 = create_post(topic: topic)
post4 = create_post(topic: topic)
flagger1 = Fabricate(:user) flagger1 = Fabricate(:user)
flagger2 = Fabricate(:user) flagger2 = Fabricate(:user)
flagger3 = Fabricate(:user)
flagger4 = Fabricate(:user)
flagger5 = Fabricate(:user)
# reaching `num_flaggers_to_close_topic` isn't enough # reaching `num_flaggers_to_close_topic` isn't enough
[flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger| [flagger1, flagger2].each do |flagger|
PostAction.act(flagger, post1, PostActionType.types[:inappropriate]) PostAction.act(flagger, post1, PostActionType.types[:inappropriate])
end end
@ -398,20 +395,18 @@ describe PostAction do
PostAction.where(post: post1).delete_all PostAction.where(post: post1).delete_all
# reaching `num_flags_to_close_topic` isn't enough # reaching `num_flags_to_close_topic` isn't enough
[flagger1, flagger2, flagger3].each do |flagger| [post1, post2, post3].each do |post|
[post1, post2, post3, post4].each do |post| PostAction.act(flagger1, post, PostActionType.types[:inappropriate])
PostAction.act(flagger, post, PostActionType.types[:inappropriate])
end
end end
topic.reload.closed.should == false topic.reload.closed.should == false
# clean up # clean up
PostAction.where(post: [post1, post2, post3, post4]).delete_all PostAction.where(post: [post1, post2, post3]).delete_all
# reaching both should close the topic # reaching both should close the topic
[flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger| [flagger1, flagger2].each do |flagger|
[post1, post2, post3, post4].each do |post| [post1, post2, post3].each do |post|
PostAction.act(flagger, post, PostActionType.types[:inappropriate]) PostAction.act(flagger, post, PostActionType.types[:inappropriate])
end end
end end