lex-identity 0.2.0 → 0.3.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile +6 -3
  4. data/LICENSE +21 -0
  5. data/README.md +230 -0
  6. data/lex-identity.gemspec +15 -12
  7. data/lib/legion/extensions/identity/helpers/browser.rb +44 -0
  8. data/lib/legion/extensions/identity/helpers/callback_server.rb +170 -0
  9. data/lib/legion/extensions/identity/helpers/oauth.rb +62 -0
  10. data/lib/legion/extensions/identity/helpers/pkce.rb +20 -0
  11. data/lib/legion/extensions/identity/helpers/provider.rb +52 -0
  12. data/lib/legion/extensions/identity/runners/base/oauth.rb +140 -0
  13. data/lib/legion/extensions/identity/version.rb +1 -1
  14. data/lib/legion/extensions/identity.rb +8 -14
  15. data/spec/support/provider_contract.rb +49 -0
  16. metadata +67 -33
  17. data/lib/legion/extensions/identity/actors/orphan_check.rb +0 -48
  18. data/lib/legion/extensions/identity/client.rb +0 -23
  19. data/lib/legion/extensions/identity/helpers/dimensions.rb +0 -71
  20. data/lib/legion/extensions/identity/helpers/fingerprint.rb +0 -166
  21. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +0 -76
  22. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +0 -20
  23. data/lib/legion/extensions/identity/runners/entra.rb +0 -223
  24. data/lib/legion/extensions/identity/runners/identity.rb +0 -86
  25. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +0 -104
  26. data/spec/legion/extensions/identity/client_spec.rb +0 -32
  27. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +0 -51
  28. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +0 -66
  29. data/spec/legion/extensions/identity/runners/entra_spec.rb +0 -405
  30. data/spec/legion/extensions/identity/runners/identity_spec.rb +0 -61
  31. data/spec/local_persistence_spec.rb +0 -329
  32. data/spec/spec_helper.rb +0 -33
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Identity
6
+ module Helpers
7
+ module Provider
8
+ def identity_register(**)
9
+ if defined?(Legion::Identity::Resolver) && Legion::Identity::Resolver.respond_to?(:register)
10
+ Legion::Identity::Resolver.register(self)
11
+ :registered
12
+ elsif defined?(Legion::Identity) &&
13
+ Legion::Identity.respond_to?(:pending_registrations) &&
14
+ Legion::Identity.pending_registrations.respond_to?(:<<)
15
+ Legion::Identity.pending_registrations << self
16
+ :pending
17
+ else
18
+ :unavailable
19
+ end
20
+ end
21
+
22
+ def identity_build_lease(credential:, **attributes)
23
+ attributes = attributes.merge(provider: provider_name, credential: credential)
24
+ return attributes unless defined?(Legion::Identity::Lease)
25
+
26
+ Legion::Identity::Lease.new(**attributes)
27
+ end
28
+
29
+ def identity_register_credential(lease:, qualifier: :default, default: false, **)
30
+ unless defined?(Legion::Identity::Broker) && Legion::Identity::Broker.respond_to?(:register_provider)
31
+ raise 'Credential registration requires Legion::Identity::Broker'
32
+ end
33
+
34
+ Legion::Identity::Broker.register_provider(provider_name, provider: self, lease: lease, qualifier: qualifier, default: default)
35
+ end
36
+
37
+ def identity_credentials_available(**)
38
+ return [] unless defined?(Legion::Identity::Broker)
39
+
40
+ Legion::Identity::Broker.credentials_available(provider_name)
41
+ end
42
+
43
+ def identity_token_for(qualifier: nil, **)
44
+ return nil unless defined?(Legion::Identity::Broker)
45
+
46
+ Legion::Identity::Broker.token_for(provider_name, qualifier: qualifier, **)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/identity/helpers/oauth'
4
+ require 'legion/extensions/identity/helpers/browser'
5
+ require 'legion/extensions/identity/helpers/callback_server'
6
+ require 'legion/extensions/identity/helpers/pkce'
7
+ require 'securerandom'
8
+ require 'legion/logging'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Identity
13
+ module Runners
14
+ module Oauth
15
+ extend self
16
+ extend Legion::Extensions::Identity::Helpers::Oauth
17
+ extend Legion::Extensions::Identity::Helpers::Browser
18
+ extend Legion::Extensions::Identity::Helpers::Pkce
19
+
20
+ DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
21
+
22
+ def browser_login(authorization_endpoint:, token_endpoint:, client_id:, scope:, callback_port: 0, callback_timeout: 120, **)
23
+ unless callback_timeout.is_a?(Numeric) && callback_timeout.real? && callback_timeout.finite? && callback_timeout.positive?
24
+ raise ArgumentError, 'callback_timeout must be positive and finite'
25
+ end
26
+
27
+ return { error: 'browser_unavailable', description: 'Interactive browser login requires a graphical session' } unless browser_gui_available?
28
+
29
+ begin
30
+ pkce = pkce_generate
31
+ state = SecureRandom.hex(32)
32
+ server = Legion::Extensions::Identity::Helpers::CallbackServer.new(port: callback_port)
33
+ redirect_uri = server.redirect_uri
34
+ url = oauth_authorize_url(authorization_endpoint: authorization_endpoint, client_id: client_id,
35
+ redirect_uri: redirect_uri, scope: scope, state: state,
36
+ code_challenge: pkce[:code_challenge], code_challenge_method: pkce[:code_challenge_method])
37
+ if browser_open(url: url)
38
+ callback = server.wait_for_callback(state: state, timeout: callback_timeout)
39
+ if callback[:error]
40
+ callback
41
+ else
42
+ exchange_code(token_endpoint: token_endpoint, client_id: client_id, code: callback[:code],
43
+ redirect_uri: redirect_uri, code_verifier: pkce[:code_verifier], scope: scope)
44
+ end
45
+ else
46
+ { error: 'browser_launch_failed', description: 'Unable to launch the browser' }
47
+ end
48
+ rescue StandardError => e
49
+ oauth_runner_error(operation: 'identity.oauth.browser_login', exception: e)
50
+ ensure
51
+ server&.shutdown
52
+ end
53
+ end
54
+
55
+ def request_device_code(device_authorization_endpoint:, client_id:, scope:, **)
56
+ result = oauth_post(endpoint: device_authorization_endpoint,
57
+ form: { client_id: client_id, scope: scope })
58
+ oauth_runner_result(result: result, required_key: :device_code)
59
+ rescue StandardError => e
60
+ oauth_runner_error(operation: 'identity.oauth.request_device_code', exception: e)
61
+ end
62
+
63
+ def exchange_code(token_endpoint:, client_id:, code:, redirect_uri:, code_verifier:, scope: nil, **)
64
+ form = {
65
+ grant_type: 'authorization_code', client_id: client_id, code: code,
66
+ redirect_uri: redirect_uri, code_verifier: code_verifier
67
+ }
68
+ form[:scope] = scope unless scope.nil?
69
+ result = oauth_post(endpoint: token_endpoint, form: form)
70
+ oauth_runner_result(result: result, required_key: :access_token)
71
+ rescue StandardError => e
72
+ oauth_runner_error(operation: 'identity.oauth.exchange_code', exception: e)
73
+ end
74
+
75
+ def refresh_token(token_endpoint:, client_id:, refresh_token:, scope: nil, **)
76
+ form = { grant_type: 'refresh_token', client_id: client_id, refresh_token: refresh_token }
77
+ form[:scope] = scope unless scope.nil?
78
+ result = oauth_post(endpoint: token_endpoint, form: form)
79
+ oauth_runner_result(result: result, required_key: :access_token)
80
+ rescue StandardError => e
81
+ oauth_runner_error(operation: 'identity.oauth.refresh_token', exception: e)
82
+ end
83
+
84
+ def poll_device_code(token_endpoint:, client_id:, device_code:, interval: 5, timeout: 300, **)
85
+ raise ArgumentError, 'interval must be positive' unless interval.positive?
86
+ raise ArgumentError, 'timeout must not be negative' if timeout.negative?
87
+
88
+ deadline = oauth_monotonic_now + timeout
89
+ current_interval = interval
90
+ outcome = { error: 'timeout', description: "Device code flow timed out after #{timeout}s" }
91
+ begin
92
+ while (remaining = deadline - oauth_monotonic_now).positive?
93
+ result = oauth_post(endpoint: token_endpoint,
94
+ form: { grant_type: DEVICE_CODE_GRANT, client_id: client_id, device_code: device_code },
95
+ open_timeout: [remaining, 5].min,
96
+ request_timeout: [remaining, 15].min)
97
+ if result[:error] == 'slow_down'
98
+ current_interval += 5
99
+ elsif result[:error] != 'authorization_pending'
100
+ outcome = oauth_runner_result(result: result, required_key: :access_token)
101
+ break
102
+ end
103
+ sleep_for = [current_interval, deadline - oauth_monotonic_now].min
104
+ break if sleep_for <= 0
105
+
106
+ sleep(sleep_for)
107
+ end
108
+
109
+ outcome
110
+ rescue StandardError => e
111
+ oauth_runner_error(operation: 'identity.oauth.poll_device_code', exception: e)
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def oauth_runner_result(result:, required_key:)
118
+ return { error: result[:error].to_s, description: (result[:error_description] || result[:description]).to_s } if result[:error]
119
+ unless result[required_key].is_a?(String) && !result[required_key].empty?
120
+ return { error: 'invalid_response', description: "OAuth response did not include #{required_key}" }
121
+ end
122
+
123
+ { result: result }
124
+ end
125
+
126
+ def oauth_runner_error(operation:, exception:)
127
+ sanitized = StandardError.new("OAuth runner failed (#{exception.class})")
128
+ sanitized.set_backtrace(exception.backtrace)
129
+ handle_exception(sanitized, level: :error, operation: operation)
130
+ { error: 'request_failed', description: exception.message }
131
+ end
132
+
133
+ def oauth_monotonic_now
134
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Identity
6
- VERSION = '0.2.0'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
9
9
  end
@@ -1,24 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'legion/extensions/identity/version'
4
- require 'legion/extensions/identity/helpers/dimensions'
5
- require 'legion/extensions/identity/helpers/fingerprint'
6
- require 'legion/extensions/identity/helpers/vault_secrets'
7
- require 'legion/extensions/identity/runners/identity'
8
- require 'legion/extensions/identity/runners/entra'
9
- require 'legion/extensions/identity/actors/orphan_check'
5
+ require 'legion/extensions/identity/helpers/provider'
10
6
 
11
7
  module Legion
12
8
  module Extensions
13
9
  module Identity
14
- extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
10
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core, false)
11
+
12
+ def self.identity_provider? = false
13
+ def self.remote_invocable? = false
14
+ def self.transport_required? = false
15
+ def self.crypt_required? = false
15
16
  end
16
17
  end
17
18
  end
18
-
19
- if defined?(Legion::Data::Local)
20
- Legion::Data::Local.register_migrations(
21
- name: :identity,
22
- path: File.join(__dir__, 'identity', 'local_migrations')
23
- )
24
- end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples 'identity provider contract' do
4
+ let(:canonical_name) { 'example-user' }
5
+
6
+ it 'exposes provider metadata with contract types' do
7
+ provider = identity_provider
8
+
9
+ expect(provider.provider_name).to be_a(Symbol)
10
+ expect(%i[auth profile fallback]).to include(provider.provider_type)
11
+ expect(provider.priority).to be_a(Numeric)
12
+ expect(provider.trust_weight).to be_a(Numeric)
13
+ expect(provider.capabilities).to be_an(Array)
14
+ expect(provider.capabilities).to all(be_a(Symbol))
15
+
16
+ trust_levels = if defined?(Legion::Identity::Trust) && Legion::Identity::Trust.respond_to?(:levels)
17
+ Legion::Identity::Trust.levels
18
+ else
19
+ %i[verified authenticated configured cached unverified]
20
+ end
21
+ expect(trust_levels).to include(provider.trust_level)
22
+ end
23
+
24
+ it 'uses the core resolution call contract' do
25
+ provider = identity_provider
26
+ result = case provider.provider_type
27
+ when :profile
28
+ if provider.respond_to?(:resolve_all)
29
+ provider.resolve_all(canonical_name: canonical_name)
30
+ else
31
+ provider.resolve(canonical_name: canonical_name)
32
+ end
33
+ when :auth, :fallback
34
+ provider.resolve
35
+ end
36
+
37
+ expect(result).to be_nil.or be_a(Hash)
38
+ next if result.nil?
39
+
40
+ if %i[auth fallback].include?(provider.provider_type)
41
+ expect(result).to include(:canonical_name, :kind, :source)
42
+ expect(result[:canonical_name]).to match(/\A[a-z0-9][a-z0-9_-]*\z/)
43
+ expect(result[:kind]).not_to be_nil
44
+ expect(result[:source]).not_to be_nil
45
+ else
46
+ expect(result).not_to have_key(:canonical_name)
47
+ end
48
+ end
49
+ end
metadata CHANGED
@@ -1,78 +1,112 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-identity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Esity
7
+ - LegionIO
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: sequel
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: legion-cache
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - ">="
17
31
  - !ruby/object:Gem::Version
18
- version: '5.70'
19
- type: :development
32
+ version: 1.3.0
33
+ type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
37
  - - ">="
24
38
  - !ruby/object:Gem::Version
25
- version: '5.70'
39
+ version: 1.3.0
26
40
  - !ruby/object:Gem::Dependency
27
- name: sqlite3
41
+ name: legion-json
28
42
  requirement: !ruby/object:Gem::Requirement
29
43
  requirements:
30
44
  - - ">="
31
45
  - !ruby/object:Gem::Version
32
- version: '2.0'
33
- type: :development
46
+ version: 1.2.1
47
+ type: :runtime
34
48
  prerelease: false
35
49
  version_requirements: !ruby/object:Gem::Requirement
36
50
  requirements:
37
51
  - - ">="
38
52
  - !ruby/object:Gem::Version
39
- version: '2.0'
40
- description: Human partner identity modeling and behavioral entropy for brain-modeled
41
- agentic AI
42
- email:
43
- - matthewdiverson@gmail.com
53
+ version: 1.2.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-logging
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.5.3
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.5.3
68
+ - !ruby/object:Gem::Dependency
69
+ name: legion-settings
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.3.14
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.3.14
82
+ description: Base extension gem for shared behavior across LegionIO identity provider
83
+ extensions.
44
84
  executables: []
45
85
  extensions: []
46
86
  extra_rdoc_files: []
47
87
  files:
88
+ - CHANGELOG.md
48
89
  - Gemfile
90
+ - LICENSE
91
+ - README.md
49
92
  - lex-identity.gemspec
50
93
  - lib/legion/extensions/identity.rb
51
- - lib/legion/extensions/identity/actors/orphan_check.rb
52
- - lib/legion/extensions/identity/client.rb
53
- - lib/legion/extensions/identity/helpers/dimensions.rb
54
- - lib/legion/extensions/identity/helpers/fingerprint.rb
55
- - lib/legion/extensions/identity/helpers/vault_secrets.rb
56
- - lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb
57
- - lib/legion/extensions/identity/runners/entra.rb
58
- - lib/legion/extensions/identity/runners/identity.rb
94
+ - lib/legion/extensions/identity/helpers/browser.rb
95
+ - lib/legion/extensions/identity/helpers/callback_server.rb
96
+ - lib/legion/extensions/identity/helpers/oauth.rb
97
+ - lib/legion/extensions/identity/helpers/pkce.rb
98
+ - lib/legion/extensions/identity/helpers/provider.rb
99
+ - lib/legion/extensions/identity/runners/base/oauth.rb
59
100
  - lib/legion/extensions/identity/version.rb
60
- - spec/legion/extensions/identity/actors/orphan_check_spec.rb
61
- - spec/legion/extensions/identity/client_spec.rb
62
- - spec/legion/extensions/identity/helpers/dimensions_spec.rb
63
- - spec/legion/extensions/identity/helpers/fingerprint_spec.rb
64
- - spec/legion/extensions/identity/runners/entra_spec.rb
65
- - spec/legion/extensions/identity/runners/identity_spec.rb
66
- - spec/local_persistence_spec.rb
67
- - spec/spec_helper.rb
101
+ - spec/support/provider_contract.rb
68
102
  homepage: https://github.com/LegionIO/lex-identity
69
103
  licenses:
70
104
  - MIT
71
105
  metadata:
72
106
  homepage_uri: https://github.com/LegionIO/lex-identity
73
107
  source_code_uri: https://github.com/LegionIO/lex-identity
74
- documentation_uri: https://github.com/LegionIO/lex-identity
75
- changelog_uri: https://github.com/LegionIO/lex-identity
108
+ documentation_uri: https://github.com/LegionIO/lex-identity/blob/main/README.md
109
+ changelog_uri: https://github.com/LegionIO/lex-identity/blob/main/CHANGELOG.md
76
110
  bug_tracker_uri: https://github.com/LegionIO/lex-identity/issues
77
111
  rubygems_mfa_required: 'true'
78
112
  rdoc_options: []
@@ -91,5 +125,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
125
  requirements: []
92
126
  rubygems_version: 3.6.9
93
127
  specification_version: 4
94
- summary: LEX Identity
128
+ summary: Shared LegionIO identity extension base
95
129
  test_files: []
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/extensions/actors/every'
4
-
5
- module Legion
6
- module Extensions
7
- module Identity
8
- module Actor
9
- # Periodic orphan detection: scans active workers for disabled Entra apps
10
- # or inactive owners. Runs every 4 hours by default.
11
- # Requires legion-data for worker records.
12
- class OrphanCheck < Legion::Extensions::Actors::Every
13
- ORPHAN_CHECK_INTERVAL = 14_400 # 4 hours in seconds
14
-
15
- def runner_class
16
- Legion::Extensions::Identity::Runners::Entra
17
- end
18
-
19
- def runner_function
20
- 'check_orphans'
21
- end
22
-
23
- def time
24
- ORPHAN_CHECK_INTERVAL
25
- end
26
-
27
- def enabled?
28
- defined?(Legion::Data) && Legion::Settings[:data][:connected] != false
29
- rescue StandardError
30
- false
31
- end
32
-
33
- def use_runner?
34
- false
35
- end
36
-
37
- def check_subtask?
38
- false
39
- end
40
-
41
- def generate_task?
42
- false
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/extensions/identity/helpers/dimensions'
4
- require 'legion/extensions/identity/helpers/fingerprint'
5
- require 'legion/extensions/identity/runners/identity'
6
-
7
- module Legion
8
- module Extensions
9
- module Identity
10
- class Client
11
- include Runners::Identity
12
-
13
- def initialize(**)
14
- @identity_fingerprint = Helpers::Fingerprint.new
15
- end
16
-
17
- private
18
-
19
- attr_reader :identity_fingerprint
20
- end
21
- end
22
- end
23
- end
@@ -1,71 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Identity
6
- module Helpers
7
- module Dimensions
8
- # The 6 behavioral dimensions that constitute identity
9
- IDENTITY_DIMENSIONS = %i[
10
- communication_cadence
11
- vocabulary_patterns
12
- emotional_response
13
- decision_patterns
14
- contextual_consistency
15
- temporal_patterns
16
- ].freeze
17
-
18
- # Entropy thresholds (from tick-loop-spec Phase 4)
19
- HIGH_ENTROPY_THRESHOLD = 0.70
20
- LOW_ENTROPY_THRESHOLD = 0.20
21
- OPTIMAL_ENTROPY_RANGE = (0.20..0.70)
22
-
23
- # EMA alpha for dimension updates
24
- OBSERVATION_ALPHA = 0.1
25
-
26
- module_function
27
-
28
- def new_identity_model
29
- IDENTITY_DIMENSIONS.to_h do |dim|
30
- [dim, { mean: 0.5, variance: 0.1, observations: 0, last_observed: nil }]
31
- end
32
- end
33
-
34
- def compute_entropy(observations, model)
35
- return 0.5 if observations.empty?
36
-
37
- divergences = IDENTITY_DIMENSIONS.filter_map do |dim|
38
- obs = observations[dim]
39
- next unless obs
40
-
41
- baseline = model[dim]
42
- next 0.0 unless baseline && baseline[:observations].positive?
43
-
44
- # Weighted divergence from established baseline
45
- (obs - baseline[:mean]).abs / [baseline[:variance].to_f, 0.1].max
46
- end
47
-
48
- return 0.5 if divergences.empty?
49
-
50
- raw = divergences.sum / divergences.size
51
- clamp(raw / 3.0) # normalize: divergence of 3.0 stddevs = entropy 1.0
52
- end
53
-
54
- def classify_entropy(entropy)
55
- if entropy > HIGH_ENTROPY_THRESHOLD
56
- :high_entropy
57
- elsif entropy < LOW_ENTROPY_THRESHOLD
58
- :low_entropy
59
- else
60
- :normal
61
- end
62
- end
63
-
64
- def clamp(value, min = 0.0, max = 1.0)
65
- value.clamp(min, max)
66
- end
67
- end
68
- end
69
- end
70
- end
71
- end