lex-signal-detection 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 613fd2226426c9cfd6c2ee891daf43ccfeb885e1b312efe4528f7563eef23b1f
4
+ data.tar.gz: a7c1a8bea4db11d07dc11f01f6933149cf0c75131b3ea2fd91b593f5538643a8
5
+ SHA512:
6
+ metadata.gz: b41037170a7448215b71c6c92878f2aad996bf3db25cf377570d87e2a45867d9b6df1579ef3a5c7ddfb51fc34fa4822ea6e4953ae00ef28df0a51690acd68c9b
7
+ data.tar.gz: 411168927c8d661236a931f599f69c949c7b0da097e1eee693af5b27ab7dbf95f42c8f0efade5d116957e1adb9a583a9e91826de33b376f554ad15f21d9e0f7b
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/signal_detection/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-signal-detection'
7
+ spec.version = Legion::Extensions::SignalDetection::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Signal Detection'
12
+ spec.description = "Green & Swets' Signal Detection Theory engine for LegionIO: sensitivity (d') and response bias (criterion) modeling"
13
+ spec.homepage = 'https://github.com/LegionIO/lex-signal-detection'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-signal-detection'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-signal-detection'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-signal-detection'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-signal-detection/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-signal-detection.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/signal_detection/helpers/constants'
4
+ require 'legion/extensions/signal_detection/helpers/detector'
5
+ require 'legion/extensions/signal_detection/helpers/detection_engine'
6
+ require 'legion/extensions/signal_detection/runners/signal_detection'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module SignalDetection
11
+ class Client
12
+ include Runners::SignalDetection
13
+
14
+ def initialize(**)
15
+ @detection_engine = Helpers::DetectionEngine.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :detection_engine
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SignalDetection
6
+ module Helpers
7
+ module Constants
8
+ MAX_DETECTORS = 100
9
+ MAX_TRIALS = 1000
10
+ MAX_HISTORY = 300
11
+
12
+ DEFAULT_SENSITIVITY = 1.0
13
+ SENSITIVITY_FLOOR = 0.0
14
+ SENSITIVITY_CEILING = 5.0
15
+
16
+ DEFAULT_CRITERION = 0.0
17
+ CRITERION_FLOOR = -3.0
18
+ CRITERION_CEILING = 3.0
19
+
20
+ LEARNING_RATE = 0.05
21
+ DECAY_RATE = 0.01
22
+
23
+ TRIAL_OUTCOMES = %i[hit miss false_alarm correct_rejection].freeze
24
+
25
+ SENSITIVITY_LABELS = {
26
+ (3.0..) => :exceptional,
27
+ (2.0...3.0) => :excellent,
28
+ (1.0...2.0) => :good,
29
+ (0.5...1.0) => :moderate,
30
+ (..0.5) => :poor
31
+ }.freeze
32
+
33
+ BIAS_LABELS = {
34
+ (1.0..) => :very_conservative,
35
+ (0.3...1.0) => :conservative,
36
+ (-0.3...0.3) => :neutral,
37
+ (-1.0...-0.3) => :liberal,
38
+ (...-1.0) => :very_liberal
39
+ }.freeze
40
+
41
+ module_function
42
+
43
+ def sensitivity_label(d_prime)
44
+ SENSITIVITY_LABELS.find { |range, _| range.cover?(d_prime) }&.last || :poor
45
+ end
46
+
47
+ def bias_label(criterion)
48
+ BIAS_LABELS.find { |range, _| range.cover?(criterion) }&.last || :neutral
49
+ end
50
+
51
+ def clamp_sensitivity(value)
52
+ value.clamp(SENSITIVITY_FLOOR, SENSITIVITY_CEILING)
53
+ end
54
+
55
+ def clamp_criterion(value)
56
+ value.clamp(CRITERION_FLOOR, CRITERION_CEILING)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SignalDetection
6
+ module Helpers
7
+ class DetectionEngine
8
+ def initialize
9
+ @detectors = {}
10
+ end
11
+
12
+ def create_detector(domain:)
13
+ raise ArgumentError, "max detectors reached (#{Constants::MAX_DETECTORS})" if @detectors.size >= Constants::MAX_DETECTORS
14
+
15
+ detector = Detector.new(domain: domain)
16
+ @detectors[detector.id] = detector
17
+ detector
18
+ end
19
+
20
+ def record_trial(detector_id:, signal_present:, responded_present:)
21
+ detector = fetch!(detector_id)
22
+ outcome = classify_outcome(signal_present: signal_present, responded_present: responded_present)
23
+ detector.record_trial(outcome: outcome)
24
+ { detector_id: detector_id, outcome: outcome, trial_count: detector.trial_count }
25
+ end
26
+
27
+ def compute_sensitivity(detector_id:)
28
+ detector = fetch!(detector_id)
29
+ {
30
+ detector_id: detector_id,
31
+ d_prime: detector.compute_dprime,
32
+ criterion: detector.compute_criterion,
33
+ accuracy: detector.accuracy,
34
+ hit_rate: detector.hit_rate,
35
+ false_alarm_rate: detector.false_alarm_rate,
36
+ sensitivity_label: detector.sensitivity_label,
37
+ bias_label: detector.bias_label
38
+ }
39
+ end
40
+
41
+ def adjust_bias(detector_id:, amount:)
42
+ detector = fetch!(detector_id)
43
+ detector.adjust_criterion(amount: amount)
44
+ { detector_id: detector_id, criterion: detector.criterion, bias_label: detector.bias_label }
45
+ end
46
+
47
+ def best_detectors(limit: 5)
48
+ @detectors.values
49
+ .sort_by { |d| -d.sensitivity }
50
+ .first(limit)
51
+ .map(&:to_h)
52
+ end
53
+
54
+ def by_domain(domain:)
55
+ @detectors.values.select { |d| d.domain == domain }.map(&:to_h)
56
+ end
57
+
58
+ def optimal_criterion(detector_id:, signal_probability: 0.5)
59
+ detector = fetch!(detector_id)
60
+ # Optimal criterion for equal cost: c* = 0.5 * ln((1-p)/p) in likelihood ratio terms
61
+ # In SDT criterion units: shift from neutral based on prior probability
62
+ prior_ratio = (1.0 - signal_probability) / signal_probability.clamp(0.001, 0.999)
63
+ optimal = 0.5 * Math.log(prior_ratio)
64
+ {
65
+ detector_id: detector_id,
66
+ optimal_criterion: Constants.clamp_criterion(optimal),
67
+ current_criterion: detector.criterion,
68
+ signal_probability: signal_probability
69
+ }
70
+ end
71
+
72
+ def roc_point(detector_id:)
73
+ detector = fetch!(detector_id)
74
+ {
75
+ detector_id: detector_id,
76
+ hit_rate: detector.hit_rate,
77
+ false_alarm_rate: detector.false_alarm_rate,
78
+ d_prime: detector.sensitivity
79
+ }
80
+ end
81
+
82
+ def decay_all
83
+ count = 0
84
+ @detectors.each_value do |detector|
85
+ next if detector.trial_count.zero?
86
+
87
+ detector.adjust_criterion(amount: Constants::DECAY_RATE * -detector.criterion.clamp(-1, 1))
88
+ count += 1
89
+ end
90
+ count
91
+ end
92
+
93
+ def get(detector_id)
94
+ @detectors[detector_id]
95
+ end
96
+
97
+ def count
98
+ @detectors.size
99
+ end
100
+
101
+ def to_h
102
+ {
103
+ detector_count: @detectors.size,
104
+ detectors: @detectors.transform_values(&:to_h)
105
+ }
106
+ end
107
+
108
+ private
109
+
110
+ def fetch!(detector_id)
111
+ @detectors.fetch(detector_id) { raise KeyError, "detector not found: #{detector_id}" }
112
+ end
113
+
114
+ def classify_outcome(signal_present:, responded_present:)
115
+ if signal_present && responded_present then :hit
116
+ elsif signal_present && !responded_present then :miss
117
+ elsif !signal_present && responded_present then :false_alarm
118
+ else :correct_rejection
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SignalDetection
8
+ module Helpers
9
+ class Detector
10
+ include Constants
11
+
12
+ attr_reader :id, :domain, :hits, :misses, :false_alarms, :correct_rejections,
13
+ :trial_count, :created_at, :last_trial_at, :sensitivity, :criterion
14
+
15
+ def initialize(domain:)
16
+ @id = SecureRandom.uuid
17
+ @domain = domain
18
+ @sensitivity = Constants::DEFAULT_SENSITIVITY
19
+ @criterion = Constants::DEFAULT_CRITERION
20
+ @hits = 0
21
+ @misses = 0
22
+ @false_alarms = 0
23
+ @correct_rejections = 0
24
+ @trial_count = 0
25
+ @created_at = Time.now.utc
26
+ @last_trial_at = nil
27
+ end
28
+
29
+ def record_trial(outcome:)
30
+ raise ArgumentError, "invalid outcome: #{outcome}" unless Constants::TRIAL_OUTCOMES.include?(outcome)
31
+
32
+ case outcome
33
+ when :hit then @hits += 1
34
+ when :miss then @misses += 1
35
+ when :false_alarm then @false_alarms += 1
36
+ when :correct_rejection then @correct_rejections += 1
37
+ end
38
+
39
+ @trial_count += 1
40
+ @last_trial_at = Time.now.utc
41
+
42
+ update_sensitivity
43
+ end
44
+
45
+ def hit_rate
46
+ signal_total = @hits + @misses + 1
47
+ (@hits + 0.5) / signal_total.to_f
48
+ end
49
+
50
+ def false_alarm_rate
51
+ noise_total = @false_alarms + @correct_rejections + 1
52
+ (@false_alarms + 0.5) / noise_total.to_f
53
+ end
54
+
55
+ def compute_dprime
56
+ d = z_score(hit_rate) - z_score(false_alarm_rate)
57
+ Constants.clamp_sensitivity(d)
58
+ end
59
+
60
+ def compute_criterion
61
+ c = -0.5 * (z_score(hit_rate) + z_score(false_alarm_rate))
62
+ Constants.clamp_criterion(c)
63
+ end
64
+
65
+ def accuracy
66
+ return 0.0 if @trial_count.zero?
67
+
68
+ (@hits + @correct_rejections).to_f / @trial_count
69
+ end
70
+
71
+ def sensitivity_label
72
+ Constants.sensitivity_label(@sensitivity)
73
+ end
74
+
75
+ def bias_label
76
+ Constants.bias_label(@criterion)
77
+ end
78
+
79
+ def adjust_criterion(amount:)
80
+ @criterion = Constants.clamp_criterion(@criterion + amount)
81
+ end
82
+
83
+ def to_h
84
+ {
85
+ id: @id,
86
+ domain: @domain,
87
+ sensitivity: @sensitivity,
88
+ criterion: @criterion,
89
+ hits: @hits,
90
+ misses: @misses,
91
+ false_alarms: @false_alarms,
92
+ correct_rejections: @correct_rejections,
93
+ trial_count: @trial_count,
94
+ hit_rate: hit_rate,
95
+ false_alarm_rate: false_alarm_rate,
96
+ accuracy: accuracy,
97
+ sensitivity_label: sensitivity_label,
98
+ bias_label: bias_label,
99
+ created_at: @created_at,
100
+ last_trial_at: @last_trial_at
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def update_sensitivity
107
+ return if @trial_count < 2
108
+
109
+ @sensitivity = compute_dprime
110
+ @criterion = compute_criterion
111
+ end
112
+
113
+ def z_score(probability)
114
+ prob = probability.clamp(0.001, 0.999)
115
+ Math.sqrt(2.0) * erfinv((2.0 * prob) - 1.0)
116
+ end
117
+
118
+ def erfinv(val)
119
+ # Winitzki (2008) rational approximation for inverse error function
120
+ a = 0.147
121
+ ln_term = Math.log(1.0 - (val * val))
122
+ two_pi_a = (2.0 / (Math::PI * a))
123
+ half_ln = ln_term / 2.0
124
+
125
+ inner = two_pi_a + half_ln
126
+ Math.sqrt(Math.sqrt((inner * inner) - (ln_term / a)) - inner) * (val.negative? ? -1 : 1)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SignalDetection
6
+ module Runners
7
+ module SignalDetection
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_detector(domain:, **)
12
+ detector = detection_engine.create_detector(domain: domain)
13
+ Legion::Logging.info "[signal_detection] created detector id=#{detector.id[0..7]} domain=#{domain}"
14
+ { created: true, detector_id: detector.id, domain: domain }
15
+ rescue ArgumentError => e
16
+ Legion::Logging.warn "[signal_detection] create_detector failed: #{e.message}"
17
+ { created: false, reason: e.message }
18
+ end
19
+
20
+ def record_detection_trial(detector_id:, signal_present:, responded_present:, **)
21
+ result = detection_engine.record_trial(
22
+ detector_id: detector_id,
23
+ signal_present: signal_present,
24
+ responded_present: responded_present
25
+ )
26
+ Legion::Logging.debug "[signal_detection] trial: id=#{detector_id[0..7]} outcome=#{result[:outcome]} count=#{result[:trial_count]}"
27
+ result
28
+ rescue KeyError => e
29
+ Legion::Logging.warn "[signal_detection] record_trial failed: #{e.message}"
30
+ { recorded: false, reason: :not_found }
31
+ end
32
+
33
+ def compute_detector_sensitivity(detector_id:, **)
34
+ result = detection_engine.compute_sensitivity(detector_id: detector_id)
35
+ Legion::Logging.debug "[signal_detection] sensitivity: id=#{detector_id[0..7]} " \
36
+ "d_prime=#{result[:d_prime].round(3)} label=#{result[:sensitivity_label]}"
37
+ result
38
+ rescue KeyError => e
39
+ Legion::Logging.warn "[signal_detection] compute_sensitivity failed: #{e.message}"
40
+ { found: false, reason: :not_found }
41
+ end
42
+
43
+ def adjust_detector_bias(detector_id:, amount:, **)
44
+ result = detection_engine.adjust_bias(detector_id: detector_id, amount: amount)
45
+ Legion::Logging.debug "[signal_detection] bias adjusted: id=#{detector_id[0..7]} " \
46
+ "criterion=#{result[:criterion].round(3)} label=#{result[:bias_label]}"
47
+ result
48
+ rescue KeyError => e
49
+ Legion::Logging.warn "[signal_detection] adjust_bias failed: #{e.message}"
50
+ { adjusted: false, reason: :not_found }
51
+ end
52
+
53
+ def best_detectors(limit: 5, **)
54
+ detectors = detection_engine.best_detectors(limit: limit)
55
+ Legion::Logging.debug "[signal_detection] best detectors: count=#{detectors.size} limit=#{limit}"
56
+ { detectors: detectors, count: detectors.size }
57
+ end
58
+
59
+ def domain_detectors(domain:, **)
60
+ detectors = detection_engine.by_domain(domain: domain)
61
+ Legion::Logging.debug "[signal_detection] domain detectors: domain=#{domain} count=#{detectors.size}"
62
+ { detectors: detectors, count: detectors.size, domain: domain }
63
+ end
64
+
65
+ def optimal_detector_criterion(detector_id:, signal_probability: 0.5, **)
66
+ result = detection_engine.optimal_criterion(
67
+ detector_id: detector_id,
68
+ signal_probability: signal_probability
69
+ )
70
+ Legion::Logging.debug "[signal_detection] optimal criterion: id=#{detector_id[0..7]} optimal=#{result[:optimal_criterion].round(3)}"
71
+ result
72
+ rescue KeyError => e
73
+ Legion::Logging.warn "[signal_detection] optimal_criterion failed: #{e.message}"
74
+ { found: false, reason: :not_found }
75
+ end
76
+
77
+ def detector_roc_point(detector_id:, **)
78
+ result = detection_engine.roc_point(detector_id: detector_id)
79
+ Legion::Logging.debug "[signal_detection] roc point: id=#{detector_id[0..7]} " \
80
+ "hr=#{result[:hit_rate].round(3)} far=#{result[:false_alarm_rate].round(3)}"
81
+ result
82
+ rescue KeyError => e
83
+ Legion::Logging.warn "[signal_detection] roc_point failed: #{e.message}"
84
+ { found: false, reason: :not_found }
85
+ end
86
+
87
+ def update_signal_detection(**)
88
+ decayed = detection_engine.decay_all
89
+ Legion::Logging.debug "[signal_detection] decay cycle: detectors_updated=#{decayed}"
90
+ { decayed: decayed }
91
+ end
92
+
93
+ def signal_detection_stats(**)
94
+ {
95
+ total_detectors: detection_engine.count,
96
+ top_detectors: detection_engine.best_detectors(limit: 3)
97
+ }
98
+ end
99
+
100
+ private
101
+
102
+ def detection_engine
103
+ @detection_engine ||= Helpers::DetectionEngine.new
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SignalDetection
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/signal_detection/version'
4
+ require 'legion/extensions/signal_detection/helpers/constants'
5
+ require 'legion/extensions/signal_detection/helpers/detector'
6
+ require 'legion/extensions/signal_detection/helpers/detection_engine'
7
+ require 'legion/extensions/signal_detection/runners/signal_detection'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module SignalDetection
12
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/signal_detection/client'
4
+
5
+ RSpec.describe Legion::Extensions::SignalDetection::Client do
6
+ let(:client) { described_class.new }
7
+
8
+ it 'responds to all runner methods' do
9
+ expect(client).to respond_to(:create_detector)
10
+ expect(client).to respond_to(:record_detection_trial)
11
+ expect(client).to respond_to(:compute_detector_sensitivity)
12
+ expect(client).to respond_to(:adjust_detector_bias)
13
+ expect(client).to respond_to(:best_detectors)
14
+ expect(client).to respond_to(:domain_detectors)
15
+ expect(client).to respond_to(:optimal_detector_criterion)
16
+ expect(client).to respond_to(:detector_roc_point)
17
+ expect(client).to respond_to(:update_signal_detection)
18
+ expect(client).to respond_to(:signal_detection_stats)
19
+ end
20
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SignalDetection::Helpers::Constants do
4
+ describe '.sensitivity_label' do
5
+ it 'labels exceptional for d_prime >= 3.0' do
6
+ expect(described_class.sensitivity_label(3.5)).to eq(:exceptional)
7
+ end
8
+
9
+ it 'labels excellent for 2.0 <= d_prime < 3.0' do
10
+ expect(described_class.sensitivity_label(2.5)).to eq(:excellent)
11
+ end
12
+
13
+ it 'labels good for 1.0 <= d_prime < 2.0' do
14
+ expect(described_class.sensitivity_label(1.5)).to eq(:good)
15
+ end
16
+
17
+ it 'labels moderate for 0.5 <= d_prime < 1.0' do
18
+ expect(described_class.sensitivity_label(0.7)).to eq(:moderate)
19
+ end
20
+
21
+ it 'labels poor for d_prime < 0.5' do
22
+ expect(described_class.sensitivity_label(0.2)).to eq(:poor)
23
+ end
24
+ end
25
+
26
+ describe '.bias_label' do
27
+ it 'labels very_conservative for criterion >= 1.0' do
28
+ expect(described_class.bias_label(1.5)).to eq(:very_conservative)
29
+ end
30
+
31
+ it 'labels conservative for 0.3 <= criterion < 1.0' do
32
+ expect(described_class.bias_label(0.5)).to eq(:conservative)
33
+ end
34
+
35
+ it 'labels neutral for -0.3 <= criterion < 0.3' do
36
+ expect(described_class.bias_label(0.0)).to eq(:neutral)
37
+ end
38
+
39
+ it 'labels liberal for -1.0 <= criterion < -0.3' do
40
+ expect(described_class.bias_label(-0.5)).to eq(:liberal)
41
+ end
42
+
43
+ it 'labels very_liberal for criterion < -1.0' do
44
+ expect(described_class.bias_label(-1.5)).to eq(:very_liberal)
45
+ end
46
+ end
47
+
48
+ describe '.clamp_sensitivity' do
49
+ it 'clamps to floor' do
50
+ expect(described_class.clamp_sensitivity(-1.0)).to eq(0.0)
51
+ end
52
+
53
+ it 'clamps to ceiling' do
54
+ expect(described_class.clamp_sensitivity(10.0)).to eq(5.0)
55
+ end
56
+
57
+ it 'passes through valid values' do
58
+ expect(described_class.clamp_sensitivity(2.5)).to eq(2.5)
59
+ end
60
+ end
61
+
62
+ describe '.clamp_criterion' do
63
+ it 'clamps to floor' do
64
+ expect(described_class.clamp_criterion(-5.0)).to eq(-3.0)
65
+ end
66
+
67
+ it 'clamps to ceiling' do
68
+ expect(described_class.clamp_criterion(5.0)).to eq(3.0)
69
+ end
70
+
71
+ it 'passes through valid values' do
72
+ expect(described_class.clamp_criterion(1.0)).to eq(1.0)
73
+ end
74
+ end
75
+
76
+ describe 'TRIAL_OUTCOMES' do
77
+ it 'includes all four outcomes' do
78
+ expect(described_class::TRIAL_OUTCOMES).to include(:hit, :miss, :false_alarm, :correct_rejection)
79
+ end
80
+
81
+ it 'is frozen' do
82
+ expect(described_class::TRIAL_OUTCOMES).to be_frozen
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SignalDetection::Helpers::DetectionEngine do
4
+ subject(:engine) { described_class.new }
5
+
6
+ let(:detector_id) { engine.create_detector(domain: :vision).id }
7
+
8
+ describe '#create_detector' do
9
+ it 'creates a detector and returns it' do
10
+ detector = engine.create_detector(domain: :auditory)
11
+ expect(detector).to be_a(Legion::Extensions::SignalDetection::Helpers::Detector)
12
+ end
13
+
14
+ it 'increments count' do
15
+ engine.create_detector(domain: :auditory)
16
+ engine.create_detector(domain: :visual)
17
+ expect(engine.count).to eq(2)
18
+ end
19
+
20
+ it 'raises when max detectors reached' do
21
+ stub_const('Legion::Extensions::SignalDetection::Helpers::Constants::MAX_DETECTORS', 1)
22
+ engine.create_detector(domain: :test)
23
+ expect { engine.create_detector(domain: :overflow) }.to raise_error(ArgumentError, /max detectors/)
24
+ end
25
+ end
26
+
27
+ describe '#record_trial' do
28
+ it 'records a hit when signal present and responded present' do
29
+ result = engine.record_trial(detector_id: detector_id, signal_present: true, responded_present: true)
30
+ expect(result[:outcome]).to eq(:hit)
31
+ end
32
+
33
+ it 'records a miss when signal present and not responded' do
34
+ result = engine.record_trial(detector_id: detector_id, signal_present: true, responded_present: false)
35
+ expect(result[:outcome]).to eq(:miss)
36
+ end
37
+
38
+ it 'records a false alarm when no signal and responded present' do
39
+ result = engine.record_trial(detector_id: detector_id, signal_present: false, responded_present: true)
40
+ expect(result[:outcome]).to eq(:false_alarm)
41
+ end
42
+
43
+ it 'records a correct rejection when no signal and not responded' do
44
+ result = engine.record_trial(detector_id: detector_id, signal_present: false, responded_present: false)
45
+ expect(result[:outcome]).to eq(:correct_rejection)
46
+ end
47
+
48
+ it 'raises KeyError for unknown detector' do
49
+ expect do
50
+ engine.record_trial(detector_id: 'bogus', signal_present: true, responded_present: true)
51
+ end.to raise_error(KeyError)
52
+ end
53
+ end
54
+
55
+ describe '#compute_sensitivity' do
56
+ it 'returns d_prime, criterion, accuracy' do
57
+ 5.times { engine.record_trial(detector_id: detector_id, signal_present: true, responded_present: true) }
58
+ 2.times { engine.record_trial(detector_id: detector_id, signal_present: false, responded_present: false) }
59
+ result = engine.compute_sensitivity(detector_id: detector_id)
60
+ expect(result).to include(:d_prime, :criterion, :accuracy, :hit_rate, :false_alarm_rate)
61
+ end
62
+
63
+ it 'raises KeyError for unknown detector' do
64
+ expect { engine.compute_sensitivity(detector_id: 'unknown') }.to raise_error(KeyError)
65
+ end
66
+ end
67
+
68
+ describe '#adjust_bias' do
69
+ it 'shifts the criterion' do
70
+ before = engine.get(detector_id).criterion
71
+ engine.adjust_bias(detector_id: detector_id, amount: 0.5)
72
+ expect(engine.get(detector_id).criterion).to be > before
73
+ end
74
+ end
75
+
76
+ describe '#best_detectors' do
77
+ it 'returns detectors sorted by sensitivity' do
78
+ id1 = engine.create_detector(domain: :A).id
79
+ id2 = engine.create_detector(domain: :B).id
80
+ 10.times { engine.record_trial(detector_id: id1, signal_present: true, responded_present: true) }
81
+ engine.record_trial(detector_id: id1, signal_present: false, responded_present: true)
82
+ 2.times { engine.record_trial(detector_id: id2, signal_present: true, responded_present: true) }
83
+ 10.times { engine.record_trial(detector_id: id2, signal_present: false, responded_present: true) }
84
+ result = engine.best_detectors(limit: 2)
85
+ expect(result.first[:sensitivity]).to be >= result.last[:sensitivity]
86
+ end
87
+
88
+ it 'respects limit' do
89
+ 3.times { |i| engine.create_detector(domain: :"d#{i}") }
90
+ expect(engine.best_detectors(limit: 2).size).to eq(2)
91
+ end
92
+ end
93
+
94
+ describe '#by_domain' do
95
+ it 'filters by domain' do
96
+ engine.create_detector(domain: :audio)
97
+ engine.create_detector(domain: :audio)
98
+ engine.create_detector(domain: :visual)
99
+ result = engine.by_domain(domain: :audio)
100
+ expect(result.size).to eq(2)
101
+ expect(result.all? { |d| d[:domain] == :audio }).to be true
102
+ end
103
+ end
104
+
105
+ describe '#optimal_criterion' do
106
+ it 'returns optimal criterion for balanced prior' do
107
+ result = engine.optimal_criterion(detector_id: detector_id, signal_probability: 0.5)
108
+ expect(result[:optimal_criterion]).to be_within(0.001).of(0.0)
109
+ end
110
+
111
+ it 'returns positive criterion for low signal probability' do
112
+ result = engine.optimal_criterion(detector_id: detector_id, signal_probability: 0.2)
113
+ expect(result[:optimal_criterion]).to be > 0
114
+ end
115
+ end
116
+
117
+ describe '#roc_point' do
118
+ it 'returns hit_rate and false_alarm_rate' do
119
+ result = engine.roc_point(detector_id: detector_id)
120
+ expect(result).to include(:hit_rate, :false_alarm_rate, :d_prime)
121
+ end
122
+ end
123
+
124
+ describe '#decay_all' do
125
+ it 'returns count of detectors updated' do
126
+ engine.record_trial(detector_id: detector_id, signal_present: true, responded_present: true)
127
+ count = engine.decay_all
128
+ expect(count).to eq(1)
129
+ end
130
+
131
+ it 'skips detectors with no trials' do
132
+ count = engine.decay_all
133
+ expect(count).to eq(0)
134
+ end
135
+ end
136
+
137
+ describe '#to_h' do
138
+ it 'returns detector count and map' do
139
+ result = engine.to_h
140
+ expect(result).to include(:detector_count, :detectors)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SignalDetection::Helpers::Detector do
4
+ subject(:detector) { described_class.new(domain: :threat) }
5
+
6
+ describe '#initialize' do
7
+ it 'assigns an id' do
8
+ expect(detector.id).to match(/\A[0-9a-f-]{36}\z/)
9
+ end
10
+
11
+ it 'sets domain' do
12
+ expect(detector.domain).to eq(:threat)
13
+ end
14
+
15
+ it 'starts with default sensitivity' do
16
+ expect(detector.sensitivity).to eq(Legion::Extensions::SignalDetection::Helpers::Constants::DEFAULT_SENSITIVITY)
17
+ end
18
+
19
+ it 'starts with default criterion' do
20
+ expect(detector.criterion).to eq(Legion::Extensions::SignalDetection::Helpers::Constants::DEFAULT_CRITERION)
21
+ end
22
+
23
+ it 'starts with zero counts' do
24
+ expect(detector.hits).to eq(0)
25
+ expect(detector.misses).to eq(0)
26
+ expect(detector.false_alarms).to eq(0)
27
+ expect(detector.correct_rejections).to eq(0)
28
+ expect(detector.trial_count).to eq(0)
29
+ end
30
+ end
31
+
32
+ describe '#record_trial' do
33
+ it 'increments hits on :hit' do
34
+ detector.record_trial(outcome: :hit)
35
+ expect(detector.hits).to eq(1)
36
+ expect(detector.trial_count).to eq(1)
37
+ end
38
+
39
+ it 'increments misses on :miss' do
40
+ detector.record_trial(outcome: :miss)
41
+ expect(detector.misses).to eq(1)
42
+ end
43
+
44
+ it 'increments false_alarms on :false_alarm' do
45
+ detector.record_trial(outcome: :false_alarm)
46
+ expect(detector.false_alarms).to eq(1)
47
+ end
48
+
49
+ it 'increments correct_rejections on :correct_rejection' do
50
+ detector.record_trial(outcome: :correct_rejection)
51
+ expect(detector.correct_rejections).to eq(1)
52
+ end
53
+
54
+ it 'raises on invalid outcome' do
55
+ expect { detector.record_trial(outcome: :bogus) }.to raise_error(ArgumentError, /invalid outcome/)
56
+ end
57
+
58
+ it 'updates last_trial_at' do
59
+ expect(detector.last_trial_at).to be_nil
60
+ detector.record_trial(outcome: :hit)
61
+ expect(detector.last_trial_at).to be_a(Time)
62
+ end
63
+ end
64
+
65
+ describe '#hit_rate' do
66
+ it 'applies Hautus correction' do
67
+ expect(detector.hit_rate).to be_between(0.0, 1.0)
68
+ end
69
+
70
+ it 'increases as hits accumulate' do
71
+ before = detector.hit_rate
72
+ 5.times { detector.record_trial(outcome: :hit) }
73
+ expect(detector.hit_rate).to be > before
74
+ end
75
+ end
76
+
77
+ describe '#false_alarm_rate' do
78
+ it 'applies Hautus correction' do
79
+ expect(detector.false_alarm_rate).to be_between(0.0, 1.0)
80
+ end
81
+
82
+ it 'increases as false alarms accumulate' do
83
+ before = detector.false_alarm_rate
84
+ 5.times { detector.record_trial(outcome: :false_alarm) }
85
+ expect(detector.false_alarm_rate).to be > before
86
+ end
87
+ end
88
+
89
+ describe '#compute_dprime' do
90
+ it 'returns a float within sensitivity bounds' do
91
+ 5.times { detector.record_trial(outcome: :hit) }
92
+ 2.times { detector.record_trial(outcome: :miss) }
93
+ 2.times { detector.record_trial(outcome: :false_alarm) }
94
+ 3.times { detector.record_trial(outcome: :correct_rejection) }
95
+ expect(detector.compute_dprime).to be_between(
96
+ Legion::Extensions::SignalDetection::Helpers::Constants::SENSITIVITY_FLOOR,
97
+ Legion::Extensions::SignalDetection::Helpers::Constants::SENSITIVITY_CEILING
98
+ )
99
+ end
100
+
101
+ it 'is positive when hit_rate > false_alarm_rate' do
102
+ 10.times { detector.record_trial(outcome: :hit) }
103
+ detector.record_trial(outcome: :false_alarm)
104
+ expect(detector.compute_dprime).to be > 0
105
+ end
106
+ end
107
+
108
+ describe '#compute_criterion' do
109
+ it 'returns a float within criterion bounds' do
110
+ 5.times { detector.record_trial(outcome: :hit) }
111
+ 5.times { detector.record_trial(outcome: :miss) }
112
+ result = detector.compute_criterion
113
+ expect(result).to be_between(
114
+ Legion::Extensions::SignalDetection::Helpers::Constants::CRITERION_FLOOR,
115
+ Legion::Extensions::SignalDetection::Helpers::Constants::CRITERION_CEILING
116
+ )
117
+ end
118
+ end
119
+
120
+ describe '#accuracy' do
121
+ it 'returns 0.0 with no trials' do
122
+ expect(detector.accuracy).to eq(0.0)
123
+ end
124
+
125
+ it 'computes correctly' do
126
+ 3.times { detector.record_trial(outcome: :hit) }
127
+ 2.times { detector.record_trial(outcome: :correct_rejection) }
128
+ expect(detector.accuracy).to eq(1.0)
129
+ end
130
+
131
+ it 'decreases with errors' do
132
+ 2.times { detector.record_trial(outcome: :hit) }
133
+ 2.times { detector.record_trial(outcome: :miss) }
134
+ 2.times { detector.record_trial(outcome: :false_alarm) }
135
+ 2.times { detector.record_trial(outcome: :correct_rejection) }
136
+ expect(detector.accuracy).to eq(0.5)
137
+ end
138
+ end
139
+
140
+ describe '#sensitivity_label' do
141
+ it 'returns a symbol' do
142
+ expect(detector.sensitivity_label).to be_a(Symbol)
143
+ end
144
+ end
145
+
146
+ describe '#bias_label' do
147
+ it 'returns a symbol' do
148
+ expect(detector.bias_label).to be_a(Symbol)
149
+ end
150
+ end
151
+
152
+ describe '#adjust_criterion' do
153
+ it 'shifts criterion by amount' do
154
+ initial = detector.criterion
155
+ detector.adjust_criterion(amount: 0.5)
156
+ expect(detector.criterion).to eq(initial + 0.5)
157
+ end
158
+
159
+ it 'clamps at ceiling' do
160
+ detector.adjust_criterion(amount: 10.0)
161
+ expect(detector.criterion).to eq(Legion::Extensions::SignalDetection::Helpers::Constants::CRITERION_CEILING)
162
+ end
163
+
164
+ it 'clamps at floor' do
165
+ detector.adjust_criterion(amount: -10.0)
166
+ expect(detector.criterion).to eq(Legion::Extensions::SignalDetection::Helpers::Constants::CRITERION_FLOOR)
167
+ end
168
+ end
169
+
170
+ describe '#to_h' do
171
+ it 'returns a hash with expected keys' do
172
+ result = detector.to_h
173
+ expect(result).to include(:id, :domain, :sensitivity, :criterion, :hits, :misses,
174
+ :false_alarms, :correct_rejections, :trial_count,
175
+ :hit_rate, :false_alarm_rate, :accuracy,
176
+ :sensitivity_label, :bias_label, :created_at)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/signal_detection/client'
4
+
5
+ RSpec.describe Legion::Extensions::SignalDetection::Runners::SignalDetection do
6
+ let(:client) { Legion::Extensions::SignalDetection::Client.new }
7
+
8
+ describe '#create_detector' do
9
+ it 'creates a detector and returns id' do
10
+ result = client.create_detector(domain: :threat)
11
+ expect(result[:created]).to be true
12
+ expect(result[:detector_id]).to match(/\A[0-9a-f-]{36}\z/)
13
+ expect(result[:domain]).to eq(:threat)
14
+ end
15
+ end
16
+
17
+ describe '#record_detection_trial' do
18
+ it 'records a hit' do
19
+ id = client.create_detector(domain: :test)[:detector_id]
20
+ result = client.record_detection_trial(detector_id: id, signal_present: true, responded_present: true)
21
+ expect(result[:outcome]).to eq(:hit)
22
+ expect(result[:trial_count]).to eq(1)
23
+ end
24
+
25
+ it 'records a miss' do
26
+ id = client.create_detector(domain: :test)[:detector_id]
27
+ result = client.record_detection_trial(detector_id: id, signal_present: true, responded_present: false)
28
+ expect(result[:outcome]).to eq(:miss)
29
+ end
30
+
31
+ it 'records a false alarm' do
32
+ id = client.create_detector(domain: :test)[:detector_id]
33
+ result = client.record_detection_trial(detector_id: id, signal_present: false, responded_present: true)
34
+ expect(result[:outcome]).to eq(:false_alarm)
35
+ end
36
+
37
+ it 'records a correct rejection' do
38
+ id = client.create_detector(domain: :test)[:detector_id]
39
+ result = client.record_detection_trial(detector_id: id, signal_present: false, responded_present: false)
40
+ expect(result[:outcome]).to eq(:correct_rejection)
41
+ end
42
+
43
+ it 'returns not_found for unknown detector' do
44
+ result = client.record_detection_trial(detector_id: 'nonexistent', signal_present: true, responded_present: true)
45
+ expect(result[:reason]).to eq(:not_found)
46
+ end
47
+ end
48
+
49
+ describe '#compute_detector_sensitivity' do
50
+ let(:detector_id) do
51
+ id = client.create_detector(domain: :test)[:detector_id]
52
+ 8.times { client.record_detection_trial(detector_id: id, signal_present: true, responded_present: true) }
53
+ 2.times { client.record_detection_trial(detector_id: id, signal_present: false, responded_present: true) }
54
+ id
55
+ end
56
+
57
+ it 'returns d_prime and criterion' do
58
+ result = client.compute_detector_sensitivity(detector_id: detector_id)
59
+ expect(result).to include(:d_prime, :criterion, :accuracy)
60
+ end
61
+
62
+ it 'returns sensitivity_label' do
63
+ result = client.compute_detector_sensitivity(detector_id: detector_id)
64
+ expect(result[:sensitivity_label]).to be_a(Symbol)
65
+ end
66
+
67
+ it 'returns not_found for unknown detector' do
68
+ result = client.compute_detector_sensitivity(detector_id: 'unknown')
69
+ expect(result[:found]).to be false
70
+ end
71
+ end
72
+
73
+ describe '#adjust_detector_bias' do
74
+ it 'shifts the criterion' do
75
+ id = client.create_detector(domain: :test)[:detector_id]
76
+ result = client.adjust_detector_bias(detector_id: id, amount: 0.5)
77
+ expect(result[:criterion]).to be > 0.0
78
+ expect(result[:bias_label]).to be_a(Symbol)
79
+ end
80
+
81
+ it 'returns not_found for unknown detector' do
82
+ result = client.adjust_detector_bias(detector_id: 'none', amount: 0.1)
83
+ expect(result[:adjusted]).to be false
84
+ end
85
+ end
86
+
87
+ describe '#best_detectors' do
88
+ it 'returns list sorted by sensitivity' do
89
+ client.create_detector(domain: :A)
90
+ client.create_detector(domain: :B)
91
+ result = client.best_detectors(limit: 2)
92
+ expect(result[:count]).to eq(2)
93
+ expect(result[:detectors]).to be_an(Array)
94
+ end
95
+ end
96
+
97
+ describe '#domain_detectors' do
98
+ it 'filters by domain' do
99
+ client.create_detector(domain: :audio)
100
+ client.create_detector(domain: :audio)
101
+ client.create_detector(domain: :visual)
102
+ result = client.domain_detectors(domain: :audio)
103
+ expect(result[:count]).to eq(2)
104
+ expect(result[:domain]).to eq(:audio)
105
+ end
106
+ end
107
+
108
+ describe '#optimal_detector_criterion' do
109
+ it 'returns optimal criterion for balanced prior' do
110
+ id = client.create_detector(domain: :test)[:detector_id]
111
+ result = client.optimal_detector_criterion(detector_id: id, signal_probability: 0.5)
112
+ expect(result[:optimal_criterion]).to be_within(0.001).of(0.0)
113
+ end
114
+
115
+ it 'returns not_found for unknown detector' do
116
+ result = client.optimal_detector_criterion(detector_id: 'none')
117
+ expect(result[:found]).to be false
118
+ end
119
+ end
120
+
121
+ describe '#detector_roc_point' do
122
+ it 'returns hit_rate and false_alarm_rate' do
123
+ id = client.create_detector(domain: :test)[:detector_id]
124
+ result = client.detector_roc_point(detector_id: id)
125
+ expect(result).to include(:hit_rate, :false_alarm_rate, :d_prime)
126
+ end
127
+
128
+ it 'returns not_found for unknown detector' do
129
+ result = client.detector_roc_point(detector_id: 'none')
130
+ expect(result[:found]).to be false
131
+ end
132
+ end
133
+
134
+ describe '#update_signal_detection' do
135
+ it 'runs decay cycle' do
136
+ id = client.create_detector(domain: :test)[:detector_id]
137
+ client.record_detection_trial(detector_id: id, signal_present: true, responded_present: true)
138
+ result = client.update_signal_detection
139
+ expect(result).to include(:decayed)
140
+ end
141
+ end
142
+
143
+ describe '#signal_detection_stats' do
144
+ it 'returns total_detectors and top_detectors' do
145
+ 2.times { |i| client.create_detector(domain: :"d#{i}") }
146
+ result = client.signal_detection_stats
147
+ expect(result[:total_detectors]).to eq(2)
148
+ expect(result[:top_detectors]).to be_an(Array)
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/signal_detection'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-signal-detection
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: 'Green & Swets'' Signal Detection Theory engine for LegionIO: sensitivity
27
+ (d'') and response bias (criterion) modeling'
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-signal-detection.gemspec
36
+ - lib/legion/extensions/signal_detection.rb
37
+ - lib/legion/extensions/signal_detection/client.rb
38
+ - lib/legion/extensions/signal_detection/helpers/constants.rb
39
+ - lib/legion/extensions/signal_detection/helpers/detection_engine.rb
40
+ - lib/legion/extensions/signal_detection/helpers/detector.rb
41
+ - lib/legion/extensions/signal_detection/runners/signal_detection.rb
42
+ - lib/legion/extensions/signal_detection/version.rb
43
+ - spec/legion/extensions/signal_detection/client_spec.rb
44
+ - spec/legion/extensions/signal_detection/helpers/constants_spec.rb
45
+ - spec/legion/extensions/signal_detection/helpers/detection_engine_spec.rb
46
+ - spec/legion/extensions/signal_detection/helpers/detector_spec.rb
47
+ - spec/legion/extensions/signal_detection/runners/signal_detection_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-signal-detection
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-signal-detection
54
+ source_code_uri: https://github.com/LegionIO/lex-signal-detection
55
+ documentation_uri: https://github.com/LegionIO/lex-signal-detection
56
+ changelog_uri: https://github.com/LegionIO/lex-signal-detection
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-signal-detection/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Signal Detection
76
+ test_files: []