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 +7 -0
- data/Gemfile +11 -0
- data/lex-signal-detection.gemspec +29 -0
- data/lib/legion/extensions/signal_detection/client.rb +24 -0
- data/lib/legion/extensions/signal_detection/helpers/constants.rb +62 -0
- data/lib/legion/extensions/signal_detection/helpers/detection_engine.rb +125 -0
- data/lib/legion/extensions/signal_detection/helpers/detector.rb +132 -0
- data/lib/legion/extensions/signal_detection/runners/signal_detection.rb +109 -0
- data/lib/legion/extensions/signal_detection/version.rb +9 -0
- data/lib/legion/extensions/signal_detection.rb +15 -0
- data/spec/legion/extensions/signal_detection/client_spec.rb +20 -0
- data/spec/legion/extensions/signal_detection/helpers/constants_spec.rb +85 -0
- data/spec/legion/extensions/signal_detection/helpers/detection_engine_spec.rb +143 -0
- data/spec/legion/extensions/signal_detection/helpers/detector_spec.rb +179 -0
- data/spec/legion/extensions/signal_detection/runners/signal_detection_spec.rb +151 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
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,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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|