lex-identity 0.2.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.
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Local persistence spec for Legion::Extensions::Identity::Helpers::Fingerprint
4
+ #
5
+ # Strategy: stub Legion::Data::Local with a real in-memory Sequel SQLite database
6
+ # when sequel + sqlite3 are available, otherwise use a double that records calls.
7
+ # Either way, the Fingerprint save/load logic is exercised end-to-end.
8
+
9
+ begin
10
+ require 'sequel'
11
+ require 'sqlite3'
12
+ SEQUEL_AVAILABLE = true
13
+ rescue LoadError
14
+ SEQUEL_AVAILABLE = false
15
+ end
16
+
17
+ RSpec.describe Legion::Extensions::Identity::Helpers::Fingerprint, 'local persistence' do
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers for setting up the in-memory DB (shared between contexts)
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def build_in_memory_db
23
+ db = Sequel.sqlite
24
+ db.create_table(:identity_fingerprint) do
25
+ primary_key :id
26
+ String :dimension, null: false, unique: true
27
+ Float :mean, default: 0.0
28
+ Float :variance, default: 0.0
29
+ Integer :observations, default: 0
30
+ DateTime :last_observed
31
+ end
32
+ db.create_table(:identity_meta) do
33
+ primary_key :id
34
+ Integer :observation_count, default: 0
35
+ String :entropy_history, text: true
36
+ end
37
+ db
38
+ end
39
+
40
+ def stub_local(db)
41
+ local_mod = Module.new do
42
+ define_singleton_method(:connection) { db }
43
+ define_singleton_method(:connected?) { true }
44
+ end
45
+ stub_const('Legion::Data', Module.new)
46
+ stub_const('Legion::Data::Local', local_mod)
47
+ end
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # When Sequel + sqlite3 are available: full round-trip integration tests
51
+ # ---------------------------------------------------------------------------
52
+
53
+ if SEQUEL_AVAILABLE
54
+ context 'with an in-memory SQLite database' do
55
+ let(:db) { build_in_memory_db }
56
+
57
+ before { stub_local(db) }
58
+
59
+ describe '#save_to_local' do
60
+ it 'returns true on first save' do
61
+ fp = described_class.new
62
+ expect(fp.save_to_local).to be true
63
+ end
64
+
65
+ it 'persists all 6 dimension rows to identity_fingerprint' do
66
+ fp = described_class.new
67
+ fp.observe(:communication_cadence, 0.8)
68
+ fp.save_to_local
69
+
70
+ rows = db[:identity_fingerprint].all
71
+ expect(rows.size).to eq(6)
72
+ end
73
+
74
+ it 'persists updated mean for observed dimension' do
75
+ fp = described_class.new
76
+ 10.times { fp.observe(:vocabulary_patterns, 0.9) }
77
+ fp.save_to_local
78
+
79
+ row = db[:identity_fingerprint].where(dimension: 'vocabulary_patterns').first
80
+ expect(row[:mean]).to be > 0.5
81
+ expect(row[:observations]).to eq(10)
82
+ end
83
+
84
+ it 'persists observation_count in identity_meta' do
85
+ fp = described_class.new
86
+ 3.times { fp.observe(:communication_cadence, 0.6) }
87
+ fp.save_to_local
88
+
89
+ meta = db[:identity_meta].first
90
+ expect(meta[:observation_count]).to eq(3)
91
+ end
92
+
93
+ it 'persists entropy_history as JSON in identity_meta' do
94
+ fp = described_class.new
95
+ fp.current_entropy(communication_cadence: 0.5)
96
+ fp.current_entropy(vocabulary_patterns: 0.6)
97
+ fp.save_to_local
98
+
99
+ meta = db[:identity_meta].first
100
+ parsed = JSON.parse(meta[:entropy_history])
101
+ expect(parsed.size).to eq(2)
102
+ expect(parsed.first).to have_key('entropy')
103
+ expect(parsed.first).to have_key('at')
104
+ end
105
+
106
+ it 'updates existing rows on second save (upsert)' do
107
+ fp = described_class.new
108
+ fp.observe(:emotional_response, 0.3)
109
+ fp.save_to_local
110
+
111
+ # mutate and save again
112
+ 5.times { fp.observe(:emotional_response, 0.9) }
113
+ fp.save_to_local
114
+
115
+ rows = db[:identity_fingerprint].where(dimension: 'emotional_response').all
116
+ expect(rows.size).to eq(1) # still one row, not two
117
+ expect(rows.first[:observations]).to eq(6)
118
+ end
119
+ end
120
+
121
+ describe '#load_from_local' do
122
+ it 'returns true when called with an empty DB' do
123
+ fp = described_class.new # load_from_local called in initialize
124
+ expect(fp.observation_count).to eq(0)
125
+ end
126
+
127
+ it 'restores model dimensions from DB rows' do
128
+ # Pre-seed DB directly
129
+ db[:identity_fingerprint].insert(
130
+ dimension: 'communication_cadence',
131
+ mean: 0.75, variance: 0.05, observations: 42,
132
+ last_observed: Time.now.utc
133
+ )
134
+
135
+ fp = described_class.new # triggers load_from_local
136
+ expect(fp.model[:communication_cadence][:mean]).to be_within(0.001).of(0.75)
137
+ expect(fp.model[:communication_cadence][:observations]).to eq(42)
138
+ end
139
+
140
+ it 'restores observation_count from identity_meta' do
141
+ db[:identity_meta].insert(observation_count: 57, entropy_history: '[]')
142
+
143
+ fp = described_class.new
144
+ expect(fp.observation_count).to eq(57)
145
+ end
146
+
147
+ it 'restores entropy_history from identity_meta JSON' do
148
+ history = [
149
+ { 'entropy' => 0.3, 'at' => Time.now.utc.iso8601 },
150
+ { 'entropy' => 0.4, 'at' => Time.now.utc.iso8601 }
151
+ ]
152
+ db[:identity_meta].insert(observation_count: 0, entropy_history: JSON.generate(history))
153
+
154
+ fp = described_class.new
155
+ expect(fp.entropy_history.size).to eq(2)
156
+ expect(fp.entropy_history.first[:entropy]).to be_within(0.001).of(0.3)
157
+ expect(fp.entropy_history.first[:at]).to be_a(Time)
158
+ end
159
+
160
+ it 'ignores DB rows for unknown dimensions' do
161
+ db[:identity_fingerprint].insert(
162
+ dimension: 'nonexistent_dimension',
163
+ mean: 0.9, variance: 0.1, observations: 5,
164
+ last_observed: nil
165
+ )
166
+
167
+ fp = described_class.new # must not raise
168
+ expect(fp.model.keys).to match_array(
169
+ %i[communication_cadence vocabulary_patterns emotional_response
170
+ decision_patterns contextual_consistency temporal_patterns]
171
+ )
172
+ end
173
+ end
174
+
175
+ describe 'full round-trip' do
176
+ it 'survives a save-then-load cycle with identical state' do
177
+ # Build state in first fingerprint instance
178
+ fp1 = described_class.new
179
+ 12.times { fp1.observe(:communication_cadence, 0.7) }
180
+ 8.times { fp1.observe(:vocabulary_patterns, 0.6) }
181
+ fp1.current_entropy(communication_cadence: 0.65)
182
+ fp1.current_entropy(vocabulary_patterns: 0.55)
183
+ fp1.save_to_local
184
+
185
+ # Load into a fresh instance (DB already populated)
186
+ fp2 = described_class.new
187
+
188
+ expect(fp2.observation_count).to eq(fp1.observation_count)
189
+ expect(fp2.entropy_history.size).to eq(fp1.entropy_history.size)
190
+
191
+ dims = %i[communication_cadence vocabulary_patterns]
192
+ dims.each do |dim|
193
+ expect(fp2.model[dim][:mean]).to be_within(0.0001).of(fp1.model[dim][:mean])
194
+ expect(fp2.model[dim][:variance]).to be_within(0.0001).of(fp1.model[dim][:variance])
195
+ expect(fp2.model[dim][:observations]).to eq(fp1.model[dim][:observations])
196
+ end
197
+ end
198
+
199
+ it 'preserves maturity after round-trip' do
200
+ fp1 = described_class.new
201
+ 15.times { fp1.observe(:decision_patterns, 0.5) }
202
+ expect(fp1.maturity).to eq(:developing)
203
+ fp1.save_to_local
204
+
205
+ fp2 = described_class.new
206
+ expect(fp2.maturity).to eq(:developing)
207
+ end
208
+
209
+ it 'preserves entropy trend direction after round-trip' do
210
+ fp1 = described_class.new
211
+ # Ascending entropy values — second half larger than first half
212
+ [0.1, 0.1, 0.1, 0.1, 0.7, 0.8, 0.9, 0.95, 0.98, 1.0].each do |e|
213
+ fp1.instance_variable_get(:@entropy_history) << { entropy: e, at: Time.now.utc }
214
+ end
215
+ fp1.save_to_local
216
+
217
+ fp2 = described_class.new
218
+ expect(fp2.entropy_history.map { |h| h[:entropy] }).to eq(
219
+ fp1.entropy_history.map { |h| h[:entropy] }
220
+ )
221
+ end
222
+ end
223
+ end
224
+
225
+ else
226
+ # ---------------------------------------------------------------------------
227
+ # Fallback: double-based tests when Sequel is not in the bundle
228
+ # ---------------------------------------------------------------------------
229
+
230
+ context 'when Sequel is not available (double-based fallback)' do
231
+ let(:fingerprint_rows) { {} }
232
+ let(:meta_rows) { [] }
233
+
234
+ let(:fp_dataset) do
235
+ d = double('fingerprint_dataset')
236
+ allow(d).to receive(:where) { |args|
237
+ scoped = double('scoped_fp_dataset')
238
+ allow(scoped).to receive(:first) { fingerprint_rows[args[:dimension]] }
239
+ allow(scoped).to receive(:update) { |row|
240
+ fingerprint_rows[row[:dimension] || args[:dimension]] = row
241
+ }
242
+ scoped
243
+ }
244
+ allow(d).to receive(:insert) { |row| fingerprint_rows[row[:dimension]] = row }
245
+ allow(d).to receive(:each) { |&block| fingerprint_rows.each_value(&block) }
246
+ allow(d).to receive(:first) { fingerprint_rows.values.first }
247
+ d
248
+ end
249
+
250
+ let(:meta_dataset) do
251
+ d = double('meta_dataset')
252
+ allow(d).to receive(:first) { meta_rows.first }
253
+ allow(d).to receive(:insert) { |row| meta_rows << row }
254
+ allow(d).to receive(:where) do
255
+ scoped = double('scoped_meta_dataset')
256
+ allow(scoped).to receive(:update) { |row| meta_rows[0] = meta_rows[0]&.merge(row) }
257
+ scoped
258
+ end
259
+ d
260
+ end
261
+
262
+ let(:db) do
263
+ d = double('Sequel::Database')
264
+ allow(d).to receive(:[]).with(:identity_fingerprint).and_return(fp_dataset)
265
+ allow(d).to receive(:[]).with(:identity_meta).and_return(meta_dataset)
266
+ d
267
+ end
268
+
269
+ before do
270
+ local_mod = Module.new do
271
+ define_singleton_method(:connection) { nil } # overridden per example
272
+ define_singleton_method(:connected?) { true }
273
+ end
274
+ stub_const('Legion::Data', Module.new)
275
+ stub_const('Legion::Data::Local', local_mod)
276
+ allow(Legion::Data::Local).to receive(:connection).and_return(db)
277
+ end
278
+
279
+ it 'calls insert on the fingerprint dataset during save_to_local' do
280
+ fp = described_class.new
281
+ expect(fp_dataset).to receive(:insert).at_least(:once)
282
+ fp.save_to_local
283
+ end
284
+
285
+ it 'calls insert on the meta dataset during save_to_local' do
286
+ fp = described_class.new
287
+ expect(meta_dataset).to receive(:insert).once
288
+ fp.save_to_local
289
+ end
290
+
291
+ it 'does not raise when local is unavailable (Legion::Data::Local not defined)' do
292
+ # Remove the stub so defined? check returns false
293
+ hide_const('Legion::Data::Local')
294
+ expect { described_class.new }.not_to raise_error
295
+ end
296
+ end
297
+ end
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Behaviour when Legion::Data::Local is not defined (always run)
301
+ # ---------------------------------------------------------------------------
302
+
303
+ describe 'when Legion::Data::Local is not available' do
304
+ before do
305
+ hide_const('Legion::Data::Local') if defined?(Legion::Data::Local)
306
+ end
307
+
308
+ it 'initialize completes without error' do
309
+ expect { described_class.new }.not_to raise_error
310
+ end
311
+
312
+ it 'save_to_local returns nil (guard short-circuits)' do
313
+ fp = described_class.new
314
+ expect(fp.save_to_local).to be_nil
315
+ end
316
+
317
+ it 'load_from_local returns nil (guard short-circuits)' do
318
+ fp = described_class.new
319
+ expect(fp.load_from_local).to be_nil
320
+ end
321
+
322
+ it 'model starts with fresh defaults' do
323
+ fp = described_class.new
324
+ expect(fp.model[:communication_cadence][:mean]).to eq(0.5)
325
+ expect(fp.observation_count).to eq(0)
326
+ expect(fp.entropy_history).to be_empty
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ # Stub framework classes not available outside the full Legion runtime
6
+ module Legion
7
+ module Logging
8
+ def self.debug(_msg); end
9
+
10
+ def self.info(_msg); end
11
+
12
+ def self.warn(_msg); end
13
+
14
+ def self.error(_msg); end
15
+ end
16
+
17
+ module Extensions
18
+ module Actors
19
+ class Every; end # rubocop:disable Lint/EmptyClass
20
+ end
21
+ end
22
+ end
23
+
24
+ # Prevent re-require of actor base when identity.rb loads orphan_check
25
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
26
+
27
+ require 'legion/extensions/identity'
28
+
29
+ RSpec.configure do |config|
30
+ config.example_status_persistence_file_path = '.rspec_status'
31
+ config.disable_monkey_patching!
32
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
33
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-identity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.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: sequel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.70'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '5.70'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !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
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - Gemfile
49
+ - lex-identity.gemspec
50
+ - 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
59
+ - 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
68
+ homepage: https://github.com/LegionIO/lex-identity
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/LegionIO/lex-identity
73
+ 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
76
+ bug_tracker_uri: https://github.com/LegionIO/lex-identity/issues
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '3.4'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.6.9
93
+ specification_version: 4
94
+ summary: LEX Identity
95
+ test_files: []