otwarchive-symphonyarchive/app/models/potential_matcher/potential_matcher_constrained.rb

128 lines
4.2 KiB
Ruby
Raw Permalink Normal View History

2026-03-11 22:22:11 +00:00
# frozen_string_literal: true
# Generates potential matches when the matching settings restrict who can be
# matched with whom. Uses PromptBatches to speed up matching. Does not generate
# assignments.
#
# The runtime is asymptotically quadratic in the number of signups, but the
# batching keeps the constants relatively low.
class PotentialMatcherConstrained
attr_reader :collection, :settings, :batch_size
attr_reader :index_tag_type, :index_optional
def initialize(collection, index_tag_type = nil, batch_size = 100)
@collection = collection
@settings = collection.challenge.potential_match_settings
@batch_size = batch_size
@required_types = @settings.required_types
@index_tag_type = index_tag_type || @required_types.first
@index_optional = @settings.include_optional?(@index_tag_type)
# Set up a new progress object for recording our progress.
@progress = PotentialMatcherProgress.new(collection)
# Set up a structure for holding multiple PotentialMatchBuilders.
@builders = {}
end
private
# Makes a batch for the given set of signups.
# Passes @index_tag_type and @index_optional to the constructor, so that the
# prompt batch knows how to build its indices properly.
def make_batch(signups, prompt_type)
PromptBatch.new(signups, prompt_type, @index_tag_type, @index_optional)
end
# Try matching the two prompts.
def try_match_prompts(request, offer)
return unless request.matches?(offer, @settings)
request_signup = request.challenge_signup
offer_signup = offer.challenge_signup
pair_key = "#{request_signup.id}|#{offer_signup.id}"
@builders[pair_key] ||= PotentialMatchBuilder.new(
request_signup, offer_signup, @settings
)
# We've already checked that the request matches the offer, so we can use
# add_prompt_match instead of try_prompt_match (to avoid duplicating work).
@builders[pair_key].add_prompt_match(request, offer)
end
# Builds and saves all PotentialMatches using @builders, then clears out the
# @builders table for the next batch.
def save_potential_matches
return if @builders.empty?
PotentialMatch.transaction do
@builders.each_value do |builder|
match = builder.build_potential_match
match.save unless match.nil?
end
end
@builders.clear
end
# Tries to calculate all pairs of matching prompts between the given
# challenge signup (to be used as a request), and the batch of offers.
def build_matches_for_request(request_signup, offer_batch)
request_signup.requests.each do |request|
offer_candidates = offer_batch.candidates_for_matching(request)
offer_candidates.each do |offer|
try_match_prompts(request, offer)
end
end
end
# Generates (and saves) all PotentialMatches for the given request batch and
# offer batch.
def make_batch_matches(request_batch, offer_batch)
@progress.start_subtask(request_batch.signups.size)
request_batch.signups.each do |request_signup|
build_matches_for_request(request_signup, offer_batch)
save_potential_matches
@progress.increment
end
@progress.end_subtask
end
public
# Generates all potential matches for the collection.
def generate
# These two lines won't trigger SQL queries (which is good, because that'd
# be an awful lot of data to load). They're just defining relations that we
# can call find_in_batches on.
offers = @collection.signups.with_offer_tags
requests = @collection.signups.with_request_tags
# We process a quadratic number of batch pairs.
batch_count = 1 + (@collection.signups.count - 1) / @batch_size
@progress.start_subtask(batch_count * batch_count)
offers.find_in_batches(batch_size: @batch_size) do |offer_signups|
break if PotentialMatch.canceled?(@collection)
offer_batch = make_batch(offer_signups, :offers)
requests.find_in_batches(batch_size: @batch_size) do |request_signups|
break if PotentialMatch.canceled?(@collection)
request_batch = make_batch(request_signups, :requests)
make_batch_matches(request_batch, offer_batch)
@progress.increment
end
end
@progress.end_subtask
end
end