From 23c4845a89be51673c3fb14ca7f895e6c02ec683 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:55:14 +0000 Subject: [PATCH 1/9] Initial plan From 4ecc41a80a93225d3d8fdb4c5eb8ca47035887e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:57:46 +0000 Subject: [PATCH 2/9] Add ASW-based group faultline strength calculation Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 229 ++++++++++++++++++++++++++++++++++++++ test/models/group_test.rb | 69 +++++++++++- 2 files changed, 295 insertions(+), 3 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 307c943d7..3f3effe95 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -38,6 +38,14 @@ def calc_diversity_score ) end + def calc_faultline_strength + Group.calc_faultline_strength_for_group( + users: users.includes( :gender, :primary_language, + :cip_code, :reactions, + home_state: [:home_country] ) + ) + end + def self.calc_diversity_score_for_proposed_group( emails: ) users = User.joins( :emails ).where( emails: { email: emails.split( /\s*,\s*/ ) } ) .includes( :gender, :primary_language, @@ -108,8 +116,229 @@ def self.calc_diversity_score_for_group( users: ) ds end + def self.calc_faultline_strength_for_proposed_group( emails: ) + users = User.joins( :emails ).where( emails: { email: emails.split( /\s*,\s*/ ) } ) + .includes( :gender, :primary_language, + :cip_code, :reactions, + home_state: [:home_country] ) + + Group.calc_faultline_strength_for_group users: + end + + def self.calc_faultline_strength_for_group( users: ) + profiles = faultline_profiles_for users.uniq + return 0.0 if profiles.count < 3 + + distance_matrix = faultline_distance_matrix_for profiles + clusterings = faultline_clusterings_for distance_matrix + k_values = 2..( profiles.count - 1 ) + asw_scores = k_values.filter_map do | k | + clusters = clusterings[k] + next if clusters.nil? + + faultline_average_silhouette_width_for clusters, distance_matrix + end + + [asw_scores.max || 0.0, 0.0].max.round( 4 ) + end + private + class << self + private + + def faultline_profiles_for( users ) + now = Date.current + users.map do | user | + state = user.home_state unless user.home_state_no_response == true + country = state&.home_country + country = nil if country&.no_response == true + + impairments = '' + impairments += user.impairment_visual ? 'v' : '' + impairments += user.impairment_auditory ? 'a' : '' + impairments += user.impairment_motor ? 'm' : '' + impairments += user.impairment_cognitive ? 'c' : '' + impairments += user.impairment_other ? 'o' : '' + impairments = 'u' if impairments.blank? + + { + categorical: { + state: state&.id, + country: country&.id, + cip_code: ( user.cip_code&.gov_code&.zero? ? nil : user.cip_code&.id ), + gender: ( user.gender_code == '__' ? nil : user.gender&.id ), + primary_language: ( user.primary_language_code == '__' ? nil : user.primary_language&.id ), + impairment: impairments + }, + numeric: { + age: user.date_of_birth? ? now.year - user.date_of_birth.year : nil, + university_years: user.started_school? ? now.year - user.started_school.year : nil + } + } + end + end + + def faultline_distance_matrix_for( profiles ) + categorical_keys = profiles.first[:categorical].keys + numeric_keys = profiles.first[:numeric].keys + categorical_weights = faultline_categorical_weights_for profiles, categorical_keys + numeric_ranges = faultline_numeric_ranges_for profiles, numeric_keys + point_count = profiles.count + + Array.new( point_count ) do | i | + Array.new( point_count ) do | j | + if i == j + 0.0 + else + faultline_distance_between_profiles( + profile_one: profiles[i], + profile_two: profiles[j], + categorical_keys:, + categorical_weights:, + numeric_keys:, + numeric_ranges: + ) + end + end + end + end + + def faultline_categorical_weights_for( profiles, keys ) + keys.to_h do | key | + values = profiles.filter_map { | profile | profile[:categorical][key] }.uniq + weight = values.count > 1 ? ( 1.0 / values.count ) : 0.0 + [key, weight] + end + end + + def faultline_numeric_ranges_for( profiles, keys ) + keys.to_h do | key | + values = profiles.filter_map { | profile | profile[:numeric][key] } + range = values.empty? ? 0.0 : ( values.max - values.min ).to_f + [key, range] + end + end + + def faultline_distance_between_profiles( + profile_one:, + profile_two:, + categorical_keys:, + categorical_weights:, + numeric_keys:, + numeric_ranges: + ) + distance_sum = 0.0 + weight_sum = 0.0 + + categorical_keys.each do | key | + value_one = profile_one[:categorical][key] + value_two = profile_two[:categorical][key] + next if value_one.nil? || value_two.nil? + + weight = categorical_weights[key] + next if weight.zero? + + distance_sum += ( value_one == value_two ? 0.0 : 1.0 ) * weight + weight_sum += weight + end + + numeric_keys.each do | key | + value_one = profile_one[:numeric][key] + value_two = profile_two[:numeric][key] + next if value_one.nil? || value_two.nil? + + range = numeric_ranges[key] + next if range.zero? + + distance_sum += ( value_one - value_two ).abs / range + weight_sum += 1.0 + end + + return 0.0 if weight_sum.zero? + + distance_sum / weight_sum + end + + def faultline_clusterings_for( distance_matrix ) + clusters = distance_matrix.each_index.map { | i | [i] } + clusterings = { clusters.count => clusters.map( &:dup ) } + + while clusters.count > 1 + left_cluster, right_cluster = faultline_closest_clusters_for clusters, distance_matrix + merged_cluster = left_cluster + right_cluster + clusters = ( clusters - [left_cluster, right_cluster] ) << merged_cluster + clusterings[clusters.count] = clusters.map( &:dup ) + end + + clusterings + end + + def faultline_closest_clusters_for( clusters, distance_matrix ) + best_pair = [clusters[0], clusters[1]] + best_distance = faultline_average_linkage_distance_for best_pair[0], best_pair[1], distance_matrix + + clusters.combination( 2 ) do | first_cluster, second_cluster | + distance = faultline_average_linkage_distance_for first_cluster, second_cluster, distance_matrix + next unless distance < best_distance + + best_distance = distance + best_pair = [first_cluster, second_cluster] + end + + best_pair + end + + def faultline_average_linkage_distance_for( cluster_one, cluster_two, distance_matrix ) + total_distance = 0.0 + pair_count = 0 + + cluster_one.each do | point_one | + cluster_two.each do | point_two | + total_distance += distance_matrix[point_one][point_two] + pair_count += 1 + end + end + + total_distance / pair_count + end + + def faultline_average_silhouette_width_for( clusters, distance_matrix ) + cluster_map = {} + clusters.each_with_index do | cluster, cluster_index | + cluster.each { | point| cluster_map[point] = cluster_index } + end + + silhouette_values = distance_matrix.each_index.map do | point_index | + current_cluster = clusters[cluster_map[point_index]] + faultline_silhouette_for_point point_index, current_cluster, clusters, distance_matrix + end + + silhouette_values.sum / silhouette_values.count + end + + def faultline_silhouette_for_point( point_index, current_cluster, clusters, distance_matrix ) + return 0.0 if current_cluster.count <= 1 + + within_cluster = current_cluster - [point_index] + a_value = faultline_average_distance_for point_index, within_cluster, distance_matrix + b_value = clusters.reject { | cluster| cluster.equal?( current_cluster ) } + .map { | cluster| faultline_average_distance_for point_index, cluster, distance_matrix } + .min + + denominator = [a_value, b_value].max + return 0.0 if denominator.zero? + + ( b_value - a_value ) / denominator + end + + def faultline_average_distance_for( point_index, other_points, distance_matrix ) + return 0.0 if other_points.empty? + + other_points.sum { | other_point| distance_matrix[point_index][other_point] }.to_f / other_points.count + end + end + def store_load_state @initial_member_state = '' user_ids.sort.each do | user_id | diff --git a/test/models/group_test.rb b/test/models/group_test.rb index ae9150765..7ee5d863b 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -1,9 +1,72 @@ # frozen_string_literal: true require 'test_helper' +require 'ostruct' class GroupTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test 'faultline strength is zero for groups with fewer than three users' do + users = [ + faultline_user( group: :a ), + faultline_user( group: :a ) + ] + + assert_equal 0.0, Group.calc_faultline_strength_for_group( users: ) + end + + test 'faultline strength is higher for aligned subgroups than homogeneous groups' do + homogeneous_users = [ + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :a ) + ] + polarized_users = [ + faultline_user( group: :a ), + faultline_user( group: :a ), + faultline_user( group: :b ), + faultline_user( group: :b ) + ] + + homogeneous_strength = Group.calc_faultline_strength_for_group( users: homogeneous_users ) + polarized_strength = Group.calc_faultline_strength_for_group( users: polarized_users ) + + assert_in_delta 0.0, homogeneous_strength, 0.0001 + assert_operator polarized_strength, :>, homogeneous_strength + assert_operator polarized_strength, :>=, 0.25 + end + + private + + def faultline_user( group: ) + country_id = ( group == :a ? 1 : 2 ) + state_id = ( group == :a ? 11 : 22 ) + gender_id = ( group == :a ? 101 : 202 ) + language_id = ( group == :a ? 301 : 302 ) + cip_id = ( group == :a ? 401 : 402 ) + cip_gov_code = ( group == :a ? 11_0101 : 26_0101 ) + started_school_year = ( group == :a ? 2023 : 2018 ) + birth_year = ( group == :a ? 2004 : 1997 ) + + OpenStruct.new( + home_state: OpenStruct.new( + id: state_id, + home_country: OpenStruct.new( id: country_id, no_response: false ) + ), + home_state_no_response: false, + cip_code: OpenStruct.new( id: cip_id, gov_code: cip_gov_code ), + gender: OpenStruct.new( id: gender_id ), + gender_code: ( group == :a ? 'M' : 'F' ), + primary_language: OpenStruct.new( id: language_id ), + primary_language_code: ( group == :a ? 'en' : 'fr' ), + impairment_visual: ( group == :b ), + impairment_auditory: false, + impairment_motor: false, + impairment_cognitive: false, + impairment_other: false, + date_of_birth: Date.new( birth_year, 1, 1 ), + started_school: Date.new( started_school_year, 1, 1 ), + date_of_birth?: true, + started_school?: true + ) + end end From 63e77c4e1f8bba3969678d1b1a32745638494bf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:58:44 +0000 Subject: [PATCH 3/9] Address review feedback for faultline strength methods Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 15 ++++++++------- test/models/group_test.rb | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 3f3effe95..6d6799a7b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -41,7 +41,7 @@ def calc_diversity_score def calc_faultline_strength Group.calc_faultline_strength_for_group( users: users.includes( :gender, :primary_language, - :cip_code, :reactions, + :cip_code, home_state: [:home_country] ) ) end @@ -118,15 +118,16 @@ def self.calc_diversity_score_for_group( users: ) def self.calc_faultline_strength_for_proposed_group( emails: ) users = User.joins( :emails ).where( emails: { email: emails.split( /\s*,\s*/ ) } ) + .distinct .includes( :gender, :primary_language, - :cip_code, :reactions, + :cip_code, home_state: [:home_country] ) Group.calc_faultline_strength_for_group users: end def self.calc_faultline_strength_for_group( users: ) - profiles = faultline_profiles_for users.uniq + profiles = faultline_profiles_for users return 0.0 if profiles.count < 3 distance_matrix = faultline_distance_matrix_for profiles @@ -306,7 +307,7 @@ def faultline_average_linkage_distance_for( cluster_one, cluster_two, distance_m def faultline_average_silhouette_width_for( clusters, distance_matrix ) cluster_map = {} clusters.each_with_index do | cluster, cluster_index | - cluster.each { | point| cluster_map[point] = cluster_index } + cluster.each { | point | cluster_map[point] = cluster_index } end silhouette_values = distance_matrix.each_index.map do | point_index | @@ -322,8 +323,8 @@ def faultline_silhouette_for_point( point_index, current_cluster, clusters, dist within_cluster = current_cluster - [point_index] a_value = faultline_average_distance_for point_index, within_cluster, distance_matrix - b_value = clusters.reject { | cluster| cluster.equal?( current_cluster ) } - .map { | cluster| faultline_average_distance_for point_index, cluster, distance_matrix } + b_value = clusters.reject { | cluster | cluster.equal?( current_cluster ) } + .map { | cluster | faultline_average_distance_for point_index, cluster, distance_matrix } .min denominator = [a_value, b_value].max @@ -335,7 +336,7 @@ def faultline_silhouette_for_point( point_index, current_cluster, clusters, dist def faultline_average_distance_for( point_index, other_points, distance_matrix ) return 0.0 if other_points.empty? - other_points.sum { | other_point| distance_matrix[point_index][other_point] }.to_f / other_points.count + other_points.sum { | other_point | distance_matrix[point_index][other_point] }.to_f / other_points.count end end diff --git a/test/models/group_test.rb b/test/models/group_test.rb index 7ee5d863b..9c6766836 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -35,8 +35,43 @@ class GroupTest < ActiveSupport::TestCase assert_operator polarized_strength, :>=, 0.25 end + test 'proposed-group faultline strength deduplicates users by email join' do + relation = MockFaultlineUserRelation.new + captured_users = nil + + User.stub :joins, relation do + Group.stub :calc_faultline_strength_for_group, ->( users: ) { captured_users = users; 0.42 } do + result = Group.calc_faultline_strength_for_proposed_group( + emails: 'a@example.com, a@example.com, b@example.com' + ) + + assert_equal 0.42, result + end + end + + assert relation.distinct_called + assert_same relation, captured_users + end + private + class MockFaultlineUserRelation + attr_reader :distinct_called + + def where( **_kwargs ) + self + end + + def distinct + @distinct_called = true + self + end + + def includes( *_args ) + self + end + end + def faultline_user( group: ) country_id = ( group == :a ? 1 : 2 ) state_id = ( group == :a ? 11 : 22 ) From db8bef1a317b6d4dd2090e1904a304fc73d2de3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:59:21 +0000 Subject: [PATCH 4/9] Normalize faultline proposed-group email matching Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 8 +++++++- test/models/group_test.rb | 14 +++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 6d6799a7b..9d1d7d04c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -117,7 +117,13 @@ def self.calc_diversity_score_for_group( users: ) end def self.calc_faultline_strength_for_proposed_group( emails: ) - users = User.joins( :emails ).where( emails: { email: emails.split( /\s*,\s*/ ) } ) + normalized_emails = emails.split( /\s*,\s*/ ).filter_map do | email | + parsed = email.strip.downcase + parsed.presence + end.uniq + return 0.0 if normalized_emails.empty? + + users = User.joins( :emails ).where( 'LOWER(emails.email) IN (?)', normalized_emails ) .distinct .includes( :gender, :primary_language, :cip_code, diff --git a/test/models/group_test.rb b/test/models/group_test.rb index 9c6766836..2ca816325 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -42,23 +42,31 @@ class GroupTest < ActiveSupport::TestCase User.stub :joins, relation do Group.stub :calc_faultline_strength_for_group, ->( users: ) { captured_users = users; 0.42 } do result = Group.calc_faultline_strength_for_proposed_group( - emails: 'a@example.com, a@example.com, b@example.com' + emails: ' A@example.com, a@example.com, , B@example.com ' ) assert_equal 0.42, result end end + assert_equal 'LOWER(emails.email) IN (?)', relation.where_clause + assert_equal %w[a@example.com b@example.com], relation.where_emails assert relation.distinct_called assert_same relation, captured_users end + test 'proposed-group faultline strength returns zero when no valid emails are provided' do + assert_equal 0.0, Group.calc_faultline_strength_for_proposed_group( emails: ' , , ' ) + end + private class MockFaultlineUserRelation - attr_reader :distinct_called + attr_reader :distinct_called, :where_clause, :where_emails - def where( **_kwargs ) + def where( clause, emails ) + @where_clause = clause + @where_emails = emails self end From 8cbcd18492c9aa9971f8495912d8aa86c94f7407 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:59:55 +0000 Subject: [PATCH 5/9] Improve faultline age and tenure year calculations Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 9d1d7d04c..2f233e722 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -179,13 +179,20 @@ def faultline_profiles_for( users ) impairment: impairments }, numeric: { - age: user.date_of_birth? ? now.year - user.date_of_birth.year : nil, - university_years: user.started_school? ? now.year - user.started_school.year : nil + age: user.date_of_birth? ? faultline_elapsed_years_for( now, user.date_of_birth ) : nil, + university_years: user.started_school? ? faultline_elapsed_years_for( now, user.started_school ) : nil } } end end + def faultline_elapsed_years_for( current_date, past_date ) + years = current_date.year - past_date.year + anniversary_passed = current_date.month > past_date.month || + ( current_date.month == past_date.month && current_date.day >= past_date.day ) + anniversary_passed ? years : years - 1 + end + def faultline_distance_matrix_for( profiles ) categorical_keys = profiles.first[:categorical].keys numeric_keys = profiles.first[:numeric].keys From 10d32a5cbd24f302204e6373c3c36dec8e624403 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:23:52 +0000 Subject: [PATCH 6/9] Add cucumber coverage for faultline strength scoring Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- features/generate_faultline_strength.feature | 49 +++++++++++++++++++ .../generate_faultline_strength_steps.rb | 26 ++++++++++ 2 files changed, 75 insertions(+) create mode 100644 features/generate_faultline_strength.feature create mode 100644 features/step_definitions/generate_faultline_strength_steps.rb diff --git a/features/generate_faultline_strength.feature b/features/generate_faultline_strength.feature new file mode 100644 index 000000000..0985f4965 --- /dev/null +++ b/features/generate_faultline_strength.feature @@ -0,0 +1,49 @@ +Feature: Generate faultline strength (ASW) + Test our ability to generate faultline strength for groups + and proposed groups using normalized email matching. + + Background: + Given a user has signed up + Given the user "has" had demographics requested + Given there is a course with an assessed project + Given the user is the instructor for the course + Given the project has a group with 4 confirmed users + + Scenario: With no demographics entered, faultline strength will be 0 + When we update the group's faultline strength + Then the group's faultline strength is 0 + + Scenario: Two aligned subgroups produce a positive faultline strength + Given the "gender" of the "first" "group" user is "m" + Given the "gender" of the "second" "group" user is "m" + Given the "gender" of the "third" "group" user is "f" + Given the "gender" of the "last" "group" user is "f" + Given the "language" of the "first" "group" user is "en" + Given the "language" of the "second" "group" user is "en" + Given the "language" of the "third" "group" user is "fr" + Given the "language" of the "last" "group" user is "fr" + Given the "cip" of the "first" "group" user is "13" + Given the "cip" of the "second" "group" user is "13" + Given the "cip" of the "third" "group" user is "47" + Given the "cip" of the "last" "group" user is "47" + Given the "first" "group" user is from "NY" in "US" + Given the "second" "group" user is from "NY" in "US" + Given the "third" "group" user is from "VT" in "US" + Given the "last" "group" user is from "VT" in "US" + Given the "dob" of the "first" "group" user is "1/1/2000" + Given the "dob" of the "second" "group" user is "1/1/2000" + Given the "dob" of the "third" "group" user is "1/1/1990" + Given the "dob" of the "last" "group" user is "1/1/1990" + Given the "uni_date" of the "first" "group" user is "1/1/2018" + Given the "uni_date" of the "second" "group" user is "1/1/2018" + Given the "uni_date" of the "third" "group" user is "1/1/2010" + Given the "uni_date" of the "last" "group" user is "1/1/2010" + When we update the group's faultline strength + Then the group's faultline strength is greater than 0.25 + + Scenario: Proposed-group faultline score handles case, spaces, and duplicate emails + Given the "gender" of the "first" "group" user is "m" + Given the "gender" of the "second" "group" user is "m" + Given the "gender" of the "third" "group" user is "f" + Given the "gender" of the "last" "group" user is "f" + Then the normalized proposed-group faultline score matches the group's users diff --git a/features/step_definitions/generate_faultline_strength_steps.rb b/features/step_definitions/generate_faultline_strength_steps.rb new file mode 100644 index 000000000..3fe3e1454 --- /dev/null +++ b/features/step_definitions/generate_faultline_strength_steps.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +When( /^we update the group's faultline strength$/ ) do + @faultline_strength = @group.calc_faultline_strength +end + +Then( /^the group's faultline strength is (\d+(?:\.\d+)?)$/ ) do | score | + @faultline_strength.should be_within( 0.0001 ).of( score.to_f ) +end + +Then( /^the group's faultline strength is greater than (\d+(?:\.\d+)?)$/ ) do | score | + @faultline_strength.should > score.to_f +end + +Then( /^the normalized proposed-group faultline score matches the group's users$/ ) do + baseline = Group.calc_faultline_strength_for_group( users: @group.users ) + messy_emails = [ + @users.first.email.upcase, + @users.first.email, + ' ', + @users.last.email.upcase + ].join( ', ' ) + + normalized_score = Group.calc_faultline_strength_for_proposed_group( emails: messy_emails ) + normalized_score.should be_within( 0.0001 ).of( baseline ) +end From 7b34c9da77922673b4e5c00d88a56a50ddc26ad3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:56:39 +0000 Subject: [PATCH 7/9] Document faultline formulas with research references Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/models/group.rb b/app/models/group.rb index 2f233e722..acd1bdc9e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -133,6 +133,13 @@ def self.calc_faultline_strength_for_proposed_group( emails: ) end def self.calc_faultline_strength_for_group( users: ) + # Faultline strength implementation follows the ASW-based approach described in: + # Keivani et al., "Team Faultline Measures: A Computational Comparison and a New + # Approach to Multiple Subgroups" (Organizational Research Methods, 2013), + # https://journals.sagepub.com/doi/10.1177/1094428113484970 + # and + # Keivani et al., "Team Faultline Measures: Rescaling the Weights of Diversity + # Attributes", https://scholarspace.manoa.hawaii.edu/server/api/core/bitstreams/9882f536-7820-4c22-9230-53d2c9f6dfb9/content profiles = faultline_profiles_for users return 0.0 if profiles.count < 3 @@ -221,6 +228,8 @@ def faultline_distance_matrix_for( profiles ) def faultline_categorical_weights_for( profiles, keys ) keys.to_h do | key | values = profiles.filter_map { | profile | profile[:categorical][key] }.uniq + # Rescaling categorical diversity-attribute contribution (1 / number of + # observed categories for the attribute), following Keivani et al. weight = values.count > 1 ? ( 1.0 / values.count ) : 0.0 [key, weight] end From e0666b6b93f8c6f1d07ec4bc600bf63cfc755c0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:01:40 +0000 Subject: [PATCH 8/9] Fix faultline nil-demographic delegation errors Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/models/group.rb | 7 ++++--- test/models/group_test.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index acd1bdc9e..dff4ed6dc 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -164,7 +164,8 @@ class << self def faultline_profiles_for( users ) now = Date.current users.map do | user | - state = user.home_state unless user.home_state_no_response == true + state = user.home_state + state = nil if state&.no_response == true country = state&.home_country country = nil if country&.no_response == true @@ -181,8 +182,8 @@ def faultline_profiles_for( users ) state: state&.id, country: country&.id, cip_code: ( user.cip_code&.gov_code&.zero? ? nil : user.cip_code&.id ), - gender: ( user.gender_code == '__' ? nil : user.gender&.id ), - primary_language: ( user.primary_language_code == '__' ? nil : user.primary_language&.id ), + gender: ( user.gender.nil? || user.gender_code == '__' ? nil : user.gender.id ), + primary_language: ( user.primary_language.nil? || user.primary_language_code == '__' ? nil : user.primary_language.id ), impairment: impairments }, numeric: { diff --git a/test/models/group_test.rb b/test/models/group_test.rb index 2ca816325..67f141557 100644 --- a/test/models/group_test.rb +++ b/test/models/group_test.rb @@ -35,6 +35,19 @@ class GroupTest < ActiveSupport::TestCase assert_operator polarized_strength, :>=, 0.25 end + test 'faultline strength handles nil demographics without delegation errors' do + users = [ + faultline_user_without_demographics, + faultline_user_without_demographics, + faultline_user_without_demographics, + faultline_user_without_demographics + ] + + assert_nothing_raised do + assert_in_delta 0.0, Group.calc_faultline_strength_for_group( users: ), 0.0001 + end + end + test 'proposed-group faultline strength deduplicates users by email join' do relation = MockFaultlineUserRelation.new captured_users = nil @@ -112,4 +125,22 @@ def faultline_user( group: ) started_school?: true ) end + + def faultline_user_without_demographics + OpenStruct.new( + home_state: nil, + cip_code: nil, + gender: nil, + primary_language: nil, + impairment_visual: false, + impairment_auditory: false, + impairment_motor: false, + impairment_cognitive: false, + impairment_other: false, + date_of_birth: nil, + started_school: nil, + date_of_birth?: false, + started_school?: false + ) + end end From df2da570da9eb4d37692a739c47176ded1429086 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:07:45 +0000 Subject: [PATCH 9/9] Fix proposed-group faultline cucumber email set Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- .../generate_faultline_strength_steps.rb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/features/step_definitions/generate_faultline_strength_steps.rb b/features/step_definitions/generate_faultline_strength_steps.rb index 3fe3e1454..99406e1d9 100644 --- a/features/step_definitions/generate_faultline_strength_steps.rb +++ b/features/step_definitions/generate_faultline_strength_steps.rb @@ -14,12 +14,10 @@ Then( /^the normalized proposed-group faultline score matches the group's users$/ ) do baseline = Group.calc_faultline_strength_for_group( users: @group.users ) - messy_emails = [ - @users.first.email.upcase, - @users.first.email, - ' ', - @users.last.email.upcase - ].join( ', ' ) + messy_emails = @users.map { | user | " #{user.email.upcase} " } + .push( @users.first.email ) + .push( ' ' ) + .join( ', ' ) normalized_score = Group.calc_faultline_strength_for_proposed_group( emails: messy_emails ) normalized_score.should be_within( 0.0001 ).of( baseline )