From f2a4686a83d422ec725da7c203afb27723507663 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 3 Jun 2026 02:42:10 -0500 Subject: [PATCH 1/4] fix: validate response data inputs --- lib/irt_ruby.rb | 1 + lib/irt_ruby/rasch_model.rb | 2 +- lib/irt_ruby/response_data_validator.rb | 51 +++++++++++++++++++++ lib/irt_ruby/three_parameter_model.rb | 2 +- lib/irt_ruby/two_parameter_model.rb | 2 +- spec/irt_ruby/rasch_model_spec.rb | 2 + spec/irt_ruby/three_parameter_model_spec.rb | 2 + spec/irt_ruby/two_parameter_model_spec.rb | 2 + spec/spec_helper.rb | 22 +++++++++ 9 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 lib/irt_ruby/response_data_validator.rb diff --git a/lib/irt_ruby.rb b/lib/irt_ruby.rb index c0bf58d..8fb5314 100644 --- a/lib/irt_ruby.rb +++ b/lib/irt_ruby.rb @@ -2,6 +2,7 @@ require "irt_ruby/version" require "matrix" +require "irt_ruby/response_data_validator" require "irt_ruby/rasch_model" require "irt_ruby/two_parameter_model" require "irt_ruby/three_parameter_model" diff --git a/lib/irt_ruby/rasch_model.rb b/lib/irt_ruby/rasch_model.rb index 0df0e82..b02829c 100644 --- a/lib/irt_ruby/rasch_model.rb +++ b/lib/irt_ruby/rasch_model.rb @@ -20,7 +20,7 @@ def initialize(data, # missing_strategy: :ignore (skip), :treat_as_incorrect, :treat_as_correct @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/lib/irt_ruby/response_data_validator.rb b/lib/irt_ruby/response_data_validator.rb new file mode 100644 index 0000000..9608a33 --- /dev/null +++ b/lib/irt_ruby/response_data_validator.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module IrtRuby + # Validates response data accepted by IRT model constructors. + module ResponseDataValidator + VALID_RESPONSES = [0, 1, nil].freeze + + module_function + + def validate!(data) + raise ArgumentError, "response data must be a Matrix or array of arrays" unless data.respond_to?(:to_a) + + data_array = data.to_a + + raise ArgumentError, "response data must have at least one row" unless data_array.is_a?(Array) && data_array.any? + + validate_rows!(data_array) + validate_values!(data_array) + + data_array + end + + def validate_rows!(data_array) + first_row = data_array.first + + raise ArgumentError, "response data must be a Matrix or array of arrays" unless first_row.is_a?(Array) + + expected_columns = first_row.size + raise ArgumentError, "response data must have at least one column" if expected_columns.zero? + + data_array.each_with_index do |row, index| + raise ArgumentError, "response data row #{index} must be an Array" unless row.is_a?(Array) + + next if row.size == expected_columns + + raise ArgumentError, "response data must be rectangular; row #{index} has #{row.size} columns, expected #{expected_columns}" + end + end + + def validate_values!(data_array) + data_array.each_with_index do |row, row_index| + row.each_with_index do |value, column_index| + next if VALID_RESPONSES.include?(value) + + raise ArgumentError, + "response data contains invalid value #{value.inspect} at row #{row_index}, column #{column_index}; allowed values are 0, 1, and nil" + end + end + end + end +end diff --git a/lib/irt_ruby/three_parameter_model.rb b/lib/irt_ruby/three_parameter_model.rb index c3afcba..d89a62b 100644 --- a/lib/irt_ruby/three_parameter_model.rb +++ b/lib/irt_ruby/three_parameter_model.rb @@ -19,7 +19,7 @@ def initialize(data, decay_factor: 0.5, missing_strategy: :ignore) @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/lib/irt_ruby/two_parameter_model.rb b/lib/irt_ruby/two_parameter_model.rb index fd48a6a..fa7d4c7 100644 --- a/lib/irt_ruby/two_parameter_model.rb +++ b/lib/irt_ruby/two_parameter_model.rb @@ -15,7 +15,7 @@ def initialize(data, max_iter: 1000, tolerance: 1e-6, param_tolerance: 1e-6, learning_rate: 0.01, decay_factor: 0.5, missing_strategy: :ignore) @data = data - @data_array = data.to_a + @data_array = ResponseDataValidator.validate!(data) num_rows = @data_array.size num_cols = @data_array.first.size diff --git a/spec/irt_ruby/rasch_model_spec.rb b/spec/irt_ruby/rasch_model_spec.rb index 2e7a417..b4fa443 100644 --- a/spec/irt_ruby/rasch_model_spec.rb +++ b/spec/irt_ruby/rasch_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::RaschModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/irt_ruby/three_parameter_model_spec.rb b/spec/irt_ruby/three_parameter_model_spec.rb index 8693393..e120ea6 100644 --- a/spec/irt_ruby/three_parameter_model_spec.rb +++ b/spec/irt_ruby/three_parameter_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::ThreeParameterModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/irt_ruby/two_parameter_model_spec.rb b/spec/irt_ruby/two_parameter_model_spec.rb index 6687b87..4c9e906 100644 --- a/spec/irt_ruby/two_parameter_model_spec.rb +++ b/spec/irt_ruby/two_parameter_model_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe IrtRuby::TwoParameterModel do + it_behaves_like "response data validation" + let(:data_array) do [ [1, 1, 0], diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 188b66a..fe5d00b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,28 @@ require "irt_ruby" +RSpec.shared_examples "response data validation" do + it "rejects empty data" do + expect { described_class.new([]) }.to raise_error(ArgumentError, /at least one row/) + end + + it "rejects empty rows" do + expect { described_class.new([[]]) }.to raise_error(ArgumentError, /at least one column/) + end + + it "rejects ragged rows" do + expect { described_class.new([[1, 0], [1]]) }.to raise_error(ArgumentError, /rectangular/) + end + + it "rejects invalid response values" do + expect { described_class.new([[1, 2], [0, nil]]) }.to raise_error(ArgumentError, /invalid value 2/) + end + + it "rejects non-numeric truthy, falsey, and string responses" do + expect { described_class.new([[1, "1"], [false, nil]]) }.to raise_error(ArgumentError, /invalid value/) + end +end + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" From 90b5dcc174266b66635b25d5668091ca11de04e6 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 3 Jun 2026 02:45:42 -0500 Subject: [PATCH 2/4] fix: tighten response data validation --- lib/irt_ruby/rasch_model.rb | 2 ++ lib/irt_ruby/response_data_validator.rb | 10 ++++++---- lib/irt_ruby/three_parameter_model.rb | 2 ++ lib/irt_ruby/two_parameter_model.rb | 2 ++ spec/spec_helper.rb | 5 +++++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/irt_ruby/rasch_model.rb b/lib/irt_ruby/rasch_model.rb index b02829c..086e70e 100644 --- a/lib/irt_ruby/rasch_model.rb +++ b/lib/irt_ruby/rasch_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Rasch model for Item Response Theory (ability - difficulty). # Incorporates: diff --git a/lib/irt_ruby/response_data_validator.rb b/lib/irt_ruby/response_data_validator.rb index 9608a33..4b41f58 100644 --- a/lib/irt_ruby/response_data_validator.rb +++ b/lib/irt_ruby/response_data_validator.rb @@ -3,8 +3,6 @@ module IrtRuby # Validates response data accepted by IRT model constructors. module ResponseDataValidator - VALID_RESPONSES = [0, 1, nil].freeze - module_function def validate!(data) @@ -40,12 +38,16 @@ def validate_rows!(data_array) def validate_values!(data_array) data_array.each_with_index do |row, row_index| row.each_with_index do |value, column_index| - next if VALID_RESPONSES.include?(value) + next if valid_response?(value) raise ArgumentError, - "response data contains invalid value #{value.inspect} at row #{row_index}, column #{column_index}; allowed values are 0, 1, and nil" + "response data contains invalid value #{value.inspect} at row #{row_index + 1}, column #{column_index + 1}; allowed values are 0, 1, and nil" end end end + + def valid_response?(value) + value.nil? || value.eql?(0) || value.eql?(1) + end end end diff --git a/lib/irt_ruby/three_parameter_model.rb b/lib/irt_ruby/three_parameter_model.rb index d89a62b..f172de2 100644 --- a/lib/irt_ruby/three_parameter_model.rb +++ b/lib/irt_ruby/three_parameter_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Three-Parameter model (3PL) for Item Response Theory. # Incorporates: diff --git a/lib/irt_ruby/two_parameter_model.rb b/lib/irt_ruby/two_parameter_model.rb index fa7d4c7..e9a5a98 100644 --- a/lib/irt_ruby/two_parameter_model.rb +++ b/lib/irt_ruby/two_parameter_model.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "irt_ruby/response_data_validator" + module IrtRuby # A class representing the Two-Parameter model (2PL) for IRT. # Incorporates: diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fe5d00b..1829a3c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,11 @@ expect { described_class.new([[1, 2], [0, nil]]) }.to raise_error(ArgumentError, /invalid value 2/) end + it "rejects float response values that compare equal to allowed integers" do + expect { described_class.new([[0.0]]) }.to raise_error(ArgumentError, /invalid value 0\.0/) + expect { described_class.new([[1.0]]) }.to raise_error(ArgumentError, /invalid value 1\.0/) + end + it "rejects non-numeric truthy, falsey, and string responses" do expect { described_class.new([[1, "1"], [false, nil]]) }.to raise_error(ArgumentError, /invalid value/) end From ed1f1ddd575afef33bbbc423f3e82fa014196daf Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 3 Jun 2026 02:49:11 -0500 Subject: [PATCH 3/4] fix: restrict response data input types --- lib/irt_ruby/response_data_validator.rb | 6 +++++- spec/spec_helper.rb | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/irt_ruby/response_data_validator.rb b/lib/irt_ruby/response_data_validator.rb index 4b41f58..0b07721 100644 --- a/lib/irt_ruby/response_data_validator.rb +++ b/lib/irt_ruby/response_data_validator.rb @@ -6,7 +6,7 @@ module ResponseDataValidator module_function def validate!(data) - raise ArgumentError, "response data must be a Matrix or array of arrays" unless data.respond_to?(:to_a) + raise ArgumentError, "response data must be a Matrix or array of arrays" unless valid_data_container?(data) data_array = data.to_a @@ -49,5 +49,9 @@ def validate_values!(data_array) def valid_response?(value) value.nil? || value.eql?(0) || value.eql?(1) end + + def valid_data_container?(data) + data.is_a?(Array) || (defined?(::Matrix) && data.is_a?(::Matrix)) + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1829a3c..0de2018 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -27,6 +27,10 @@ it "rejects non-numeric truthy, falsey, and string responses" do expect { described_class.new([[1, "1"], [false, nil]]) }.to raise_error(ArgumentError, /invalid value/) end + + it "rejects hash input even when it can be converted to an array" do + expect { described_class.new({ [0] => 1 }) }.to raise_error(ArgumentError, /Matrix or array of arrays/) + end end RSpec.configure do |config| From 9b09bec3d9443c287420d9bc37ca8abd843d2f70 Mon Sep 17 00:00:00 2001 From: kholdrex Date: Wed, 3 Jun 2026 02:54:35 -0500 Subject: [PATCH 4/4] docs: clarify response data validation contract --- CHANGELOG.md | 7 +++++++ README.md | 6 ++++++ lib/irt_ruby/response_data_validator.rb | 8 +++++--- spec/spec_helper.rb | 14 +++++++++++--- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f4980..a33215f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Changed +- Clarified that response data must be a `Matrix` or array of arrays containing only integer `0`, integer `1`, or `nil`; floats, strings, booleans, and other values are rejected. + +--- + ## [0.3.0] - 2025-01-14 ### Changed diff --git a/README.md b/README.md index 9ca7521..c3ca0a5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ result = model.fit puts "Abilities: #{result[:abilities]}" puts "Difficulties: #{result[:difficulties]}" ``` + +Response data passed to model constructors must be either a `Matrix` or an +array of arrays. Each response value must be the integer `0`, the integer `1`, +or `nil` for missing data; floats such as `0.0`/`1.0`, strings, booleans, and +other values are rejected. + ### Using 2PL and 3PL Models ```ruby two_pl_model = IrtRuby::TwoParameterModel.new(data) diff --git a/lib/irt_ruby/response_data_validator.rb b/lib/irt_ruby/response_data_validator.rb index 0b07721..daebdcf 100644 --- a/lib/irt_ruby/response_data_validator.rb +++ b/lib/irt_ruby/response_data_validator.rb @@ -2,6 +2,7 @@ module IrtRuby # Validates response data accepted by IRT model constructors. + # @api private module ResponseDataValidator module_function @@ -10,7 +11,7 @@ def validate!(data) data_array = data.to_a - raise ArgumentError, "response data must have at least one row" unless data_array.is_a?(Array) && data_array.any? + raise ArgumentError, "response data must have at least one row" unless data_array.any? validate_rows!(data_array) validate_values!(data_array) @@ -27,11 +28,12 @@ def validate_rows!(data_array) raise ArgumentError, "response data must have at least one column" if expected_columns.zero? data_array.each_with_index do |row, index| - raise ArgumentError, "response data row #{index} must be an Array" unless row.is_a?(Array) + row_number = index + 1 + raise ArgumentError, "response data row #{row_number} must be an Array" unless row.is_a?(Array) next if row.size == expected_columns - raise ArgumentError, "response data must be rectangular; row #{index} has #{row.size} columns, expected #{expected_columns}" + raise ArgumentError, "response data must be rectangular; row #{row_number} has #{row.size} columns, expected #{expected_columns}" end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0de2018..0ddcf0c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,7 +12,7 @@ end it "rejects ragged rows" do - expect { described_class.new([[1, 0], [1]]) }.to raise_error(ArgumentError, /rectangular/) + expect { described_class.new([[1, 0], [1]]) }.to raise_error(ArgumentError, /rectangular; row 2/) end it "rejects invalid response values" do @@ -24,8 +24,16 @@ expect { described_class.new([[1.0]]) }.to raise_error(ArgumentError, /invalid value 1\.0/) end - it "rejects non-numeric truthy, falsey, and string responses" do - expect { described_class.new([[1, "1"], [false, nil]]) }.to raise_error(ArgumentError, /invalid value/) + it "rejects string response values" do + expect { described_class.new([["1"]]) }.to raise_error(ArgumentError, /invalid value "1"/) + end + + it "rejects false response values" do + expect { described_class.new([[false]]) }.to raise_error(ArgumentError, /invalid value false/) + end + + it "rejects true response values" do + expect { described_class.new([[true]]) }.to raise_error(ArgumentError, /invalid value true/) end it "rejects hash input even when it can be converted to an array" do