lex-curiosity 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: 623f3def5e2ab5884f76c5ec7c2b4b07e64b24c7b689d4e53bb1f3f08dff70f1
4
+ data.tar.gz: 4b13a988bf90bbee281996ccf907684d75f171a4efc2457e72aef519df6078d2
5
+ SHA512:
6
+ metadata.gz: fa60920e8f1415c0983b9630b0f7ce6b8839666ace8dcf03cf12eecacaed9ffeb8aa6dcdc6e8d353c9af08327ac9e5c41e4d9867d12d84725071c8f0ea570e91
7
+ data.tar.gz: 409581811ece58563bd7253c0c9aa341034f9557563d4256b7db4885db8a2565b3ff770daca46cfe69dc658c55420aa63cf9275c872db9461ef1f8da931562aa
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rake'
8
+ gem 'rspec'
9
+ gem 'rspec_junit_formatter'
10
+ gem 'rubocop', require: false
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'simplecov'
13
+ end
14
+
15
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Esity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # lex-curiosity
2
+
3
+ Intrinsic curiosity engine for the LegionIO brain-modeled cognitive architecture.
4
+
5
+ ## What It Does
6
+
7
+ Models the dopaminergic curiosity/reward system — the intrinsic motivation to explore knowledge gaps. The agent doesn't just process information when presented; it actively wonders about what it doesn't know and seeks to learn.
8
+
9
+ Two forms of curiosity:
10
+ - **Diversive curiosity**: general scanning for novelty (high-novelty, low-familiarity signals)
11
+ - **Specific curiosity**: focused pursuit of a particular knowledge gap (low-confidence predictions, contradictions)
12
+
13
+ ## Core Concept: The Wonder
14
+
15
+ A "wonder" is a structured curiosity item — a question the agent has generated about a gap in its knowledge.
16
+
17
+ ```ruby
18
+ client = Legion::Extensions::Curiosity::Client.new
19
+
20
+ # Detect gaps from tick phase results
21
+ result = client.detect_gaps(prior_results: {
22
+ memory_retrieval: { traces: [], domain: :kubernetes },
23
+ prediction_engine: { confidence: 0.3, domain: :vault }
24
+ })
25
+ # => { gaps_detected: 2, wonders_created: 2, curiosity_intensity: 0.15, ... }
26
+
27
+ # Manually generate a wonder
28
+ wonder = client.generate_wonder(
29
+ question: 'How does Consul ACL token inheritance work?',
30
+ domain: :consul,
31
+ gap_type: :incomplete
32
+ )
33
+
34
+ # Explore and resolve
35
+ client.explore_wonder(wonder_id: wonder[:wonder_id])
36
+ client.resolve_wonder(
37
+ wonder_id: wonder[:wonder_id],
38
+ resolution: 'Child tokens inherit parent policy',
39
+ actual_gain: 0.8
40
+ )
41
+
42
+ # Get the curiosity-driven agenda
43
+ client.form_agenda
44
+ # => { agenda_items: [{ type: :curious, summary: "...", weight: 0.7 }], source: :curiosity }
45
+
46
+ # Decay salience over time
47
+ client.decay_wonders(hours_elapsed: 1.0)
48
+ ```
49
+
50
+ ## Gap Types
51
+
52
+ | Type | Trigger |
53
+ |------|---------|
54
+ | `:unknown` | Domain with no memory traces |
55
+ | `:uncertain` | Low-confidence predictions |
56
+ | `:contradictory` | Conflicting memory traces |
57
+ | `:incomplete` | Partial pattern matches or weak traces |
58
+
59
+ ## Integration
60
+
61
+ Wires into lex-tick phases:
62
+ - `working_memory_integration` → `detect_gaps`
63
+ - `agenda_formation` → `form_agenda`
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ bundle install
69
+ bundle exec rspec
70
+ bundle exec rubocop
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/curiosity/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-curiosity'
7
+ spec.version = Legion::Extensions::Curiosity::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Curiosity'
12
+ spec.description = 'Intrinsic curiosity engine for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-curiosity'
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-curiosity'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-curiosity'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-curiosity'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-curiosity/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-curiosity.gemspec Gemfile LICENSE README.md]
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/curiosity/helpers/constants'
4
+ require 'legion/extensions/curiosity/helpers/wonder'
5
+ require 'legion/extensions/curiosity/helpers/wonder_store'
6
+ require 'legion/extensions/curiosity/helpers/gap_detector'
7
+ require 'legion/extensions/curiosity/runners/curiosity'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Curiosity
12
+ # Standalone client for curiosity operations without the full framework.
13
+ class Client
14
+ include Runners::Curiosity
15
+
16
+ attr_reader :wonder_store
17
+
18
+ def initialize(store: nil, **)
19
+ @wonder_store = store || Helpers::WonderStore.new
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Curiosity
6
+ module Helpers
7
+ module Constants
8
+ GAP_TYPES = %i[unknown uncertain contradictory incomplete].freeze
9
+
10
+ MAX_WONDERS = 20
11
+ WONDER_DECAY_RATE = 0.02 # salience decay per hour
12
+ WONDER_STALE_THRESHOLD = 259_200 # 3 days in seconds
13
+ MAX_EXPLORATION_ATTEMPTS = 5
14
+ INFORMATION_GAIN_THRESHOLD = 0.3 # minimum expected gain to create wonder
15
+ CURIOSITY_REWARD_MULTIPLIER = 1.5 # emotional reward on resolution
16
+ DOMAIN_BALANCE_FACTOR = 0.7 # penalize overrepresented domains
17
+ DIVERSIVE_NOVELTY_THRESHOLD = 0.6 # novelty above which diversive curiosity triggers
18
+ SPECIFIC_GAP_THRESHOLD = 0.5 # gap score above which specific curiosity triggers
19
+ EXPLORATION_COOLDOWN = 300 # seconds between re-exploration of same wonder
20
+ LOW_CONFIDENCE_THRESHOLD = 0.5 # prediction confidence below this triggers wonder
21
+ EMPTY_RETRIEVAL_THRESHOLD = 2 # fewer traces than this = unknown domain
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Curiosity
6
+ module Helpers
7
+ # Analyzes tick phase results for knowledge gaps that drive curiosity.
8
+ module GapDetector
9
+ module_function
10
+
11
+ def detect(prior_results)
12
+ detectors = %i[memory_retrieval prediction_engine emotional_evaluation contradiction_resolution]
13
+ methods = %i[detect_memory_gaps detect_prediction_gaps detect_emotional_gaps detect_contradiction_gaps]
14
+
15
+ gaps = detectors.zip(methods).flat_map { |key, method| send(method, prior_results[key]) }
16
+
17
+ gaps
18
+ .select { |g| g[:information_gain] >= Constants::INFORMATION_GAIN_THRESHOLD }
19
+ .sort_by { |g| -((g[:salience] * 0.6) + (g[:information_gain] * 0.4)) }
20
+ end
21
+
22
+ def detect_memory_gaps(result)
23
+ return [] unless result.is_a?(Hash)
24
+
25
+ gaps = []
26
+ traces = result[:traces]
27
+ domain = result[:domain] || :general
28
+
29
+ gaps << unknown_domain_gap(traces, domain) if sparse_traces?(traces)
30
+ gaps << incomplete_knowledge_gap(traces, domain) if weak_traces?(traces)
31
+ gaps.compact
32
+ end
33
+
34
+ def detect_prediction_gaps(result)
35
+ return [] unless result.is_a?(Hash)
36
+
37
+ gaps = []
38
+ gaps << low_confidence_gap(result) if low_confidence?(result)
39
+ gaps << failed_prediction_gap(result) if failed_prediction?(result)
40
+ gaps
41
+ end
42
+
43
+ def detect_emotional_gaps(result)
44
+ return [] unless result.is_a?(Hash)
45
+
46
+ valence = result[:valence]
47
+ return [] unless valence.is_a?(Hash) && novel_unfamiliar?(valence)
48
+
49
+ [novel_unfamiliar_gap(result, valence)]
50
+ end
51
+
52
+ def detect_contradiction_gaps(result)
53
+ return [] unless result.is_a?(Hash)
54
+
55
+ conflicts = result[:active_conflicts] || result[:conflicts]
56
+ return [] unless conflicts.is_a?(Array) && !conflicts.empty?
57
+
58
+ conflicts.first(3).map { |c| contradiction_gap(c) }
59
+ end
60
+
61
+ # -- private helpers below --
62
+
63
+ def sparse_traces?(traces)
64
+ traces.is_a?(Array) && traces.size < Constants::EMPTY_RETRIEVAL_THRESHOLD
65
+ end
66
+
67
+ def weak_traces?(traces)
68
+ return false unless traces.is_a?(Array)
69
+
70
+ traces.any? { |t| t.is_a?(Hash) && (t[:strength] || 1.0) < 0.3 }
71
+ end
72
+
73
+ def low_confidence?(result)
74
+ c = result[:confidence]
75
+ c.is_a?(Numeric) && c < Constants::LOW_CONFIDENCE_THRESHOLD
76
+ end
77
+
78
+ def failed_prediction?(result)
79
+ result[:status] == :failed || result[:error]
80
+ end
81
+
82
+ def novel_unfamiliar?(valence)
83
+ (valence[:novelty] || 0.0) > Constants::DIVERSIVE_NOVELTY_THRESHOLD &&
84
+ (valence[:familiarity] || 1.0) < 0.3
85
+ end
86
+
87
+ def unknown_domain_gap(traces, domain)
88
+ {
89
+ gap_type: :unknown,
90
+ domain: domain,
91
+ question: "What do I know about #{domain}?",
92
+ salience: 0.6,
93
+ information_gain: 0.7,
94
+ source_trace_ids: traces&.filter_map { |t| t[:trace_id] } || []
95
+ }
96
+ end
97
+
98
+ def incomplete_knowledge_gap(traces, domain)
99
+ weak = traces.select { |t| t.is_a?(Hash) && (t[:strength] || 1.0) < 0.3 }
100
+ return nil if weak.empty?
101
+
102
+ {
103
+ gap_type: :incomplete,
104
+ domain: domain,
105
+ question: 'Why are my memories about this topic weak?',
106
+ salience: 0.4,
107
+ information_gain: 0.5,
108
+ source_trace_ids: weak.filter_map { |t| t[:trace_id] }
109
+ }
110
+ end
111
+
112
+ def low_confidence_gap(result)
113
+ confidence = result[:confidence]
114
+ {
115
+ gap_type: :uncertain,
116
+ domain: result[:domain] || :general,
117
+ question: "Why is my prediction confidence low (#{(confidence * 100).round}%)?",
118
+ salience: 0.7,
119
+ information_gain: 0.6,
120
+ source_trace_ids: []
121
+ }
122
+ end
123
+
124
+ def failed_prediction_gap(result)
125
+ {
126
+ gap_type: :uncertain,
127
+ domain: result[:domain] || :general,
128
+ question: 'What caused this prediction failure?',
129
+ salience: 0.8,
130
+ information_gain: 0.7,
131
+ source_trace_ids: []
132
+ }
133
+ end
134
+
135
+ def novel_unfamiliar_gap(result, valence)
136
+ novelty = valence[:novelty] || 0.0
137
+ familiarity = valence[:familiarity] || 1.0
138
+ {
139
+ gap_type: :unknown,
140
+ domain: result[:domain] || :general,
141
+ question: 'This is novel and unfamiliar — what is it?',
142
+ salience: novelty,
143
+ information_gain: (1.0 - familiarity) * 0.8,
144
+ source_trace_ids: []
145
+ }
146
+ end
147
+
148
+ def contradiction_gap(conflict)
149
+ domain = conflict.is_a?(Hash) ? (conflict[:domain] || :general) : :general
150
+ {
151
+ gap_type: :contradictory,
152
+ domain: domain,
153
+ question: "Why do I have contradictory knowledge about #{domain}?",
154
+ salience: 0.8,
155
+ information_gain: 0.7,
156
+ source_trace_ids: conflict.is_a?(Hash) ? Array(conflict[:trace_ids]) : []
157
+ }
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Curiosity
8
+ module Helpers
9
+ module Wonder
10
+ module_function
11
+
12
+ def new_wonder(question:, domain: :general, gap_type: :unknown,
13
+ salience: 0.5, information_gain: 0.5,
14
+ source_trace_ids: [], emotional_valence: {})
15
+ raise ArgumentError, "invalid gap_type: #{gap_type}" unless Constants::GAP_TYPES.include?(gap_type)
16
+
17
+ {
18
+ wonder_id: SecureRandom.uuid,
19
+ question: question,
20
+ domain: domain.to_sym,
21
+ gap_type: gap_type,
22
+ salience: salience.clamp(0.0, 1.0),
23
+ information_gain: information_gain.clamp(0.0, 1.0),
24
+ attempts: 0,
25
+ created_at: Time.now.utc,
26
+ last_explored_at: nil,
27
+ resolved: false,
28
+ resolution: nil,
29
+ source_trace_ids: Array(source_trace_ids),
30
+ emotional_valence: emotional_valence
31
+ }
32
+ end
33
+
34
+ def score(wonder)
35
+ return 0.0 if wonder[:resolved]
36
+
37
+ base = (wonder[:salience] * 0.6) + (wonder[:information_gain] * 0.4)
38
+ attempt_penalty = [wonder[:attempts] * 0.1, 0.5].min
39
+ base - attempt_penalty
40
+ end
41
+
42
+ def stale?(wonder)
43
+ age = Time.now.utc - wonder[:created_at]
44
+ age > Constants::WONDER_STALE_THRESHOLD
45
+ end
46
+
47
+ def explorable?(wonder)
48
+ return false if wonder[:resolved]
49
+ return false if wonder[:attempts] >= Constants::MAX_EXPLORATION_ATTEMPTS
50
+
51
+ if wonder[:last_explored_at]
52
+ cooldown = Constants::EXPLORATION_COOLDOWN * (2**[wonder[:attempts] - 1, 0].max)
53
+ return false if (Time.now.utc - wonder[:last_explored_at]) < cooldown
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ def decay_salience(wonder, hours_elapsed: 1.0)
60
+ return wonder if wonder[:resolved]
61
+
62
+ decayed = wonder[:salience] - (Constants::WONDER_DECAY_RATE * hours_elapsed)
63
+ wonder.merge(salience: [decayed, 0.0].max)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Curiosity
6
+ module Helpers
7
+ # In-memory priority queue for wonder items with domain balancing and decay.
8
+ class WonderStore
9
+ attr_reader :resolved_count, :total_generated
10
+
11
+ def initialize
12
+ @wonders = {}
13
+ @resolved_count = 0
14
+ @total_generated = 0
15
+ @domain_resolution_rates = Hash.new { |h, k| h[k] = { resolved: 0, total: 0 } }
16
+ end
17
+
18
+ def store(wonder)
19
+ prune_if_full
20
+ @wonders[wonder[:wonder_id]] = wonder
21
+ @total_generated += 1
22
+ @domain_resolution_rates[wonder[:domain]][:total] += 1
23
+ wonder
24
+ end
25
+
26
+ def get(wonder_id)
27
+ @wonders[wonder_id]
28
+ end
29
+
30
+ def update(wonder_id, attrs)
31
+ wonder = @wonders[wonder_id]
32
+ return nil unless wonder
33
+
34
+ @wonders[wonder_id] = wonder.merge(attrs)
35
+ end
36
+
37
+ def delete(wonder_id)
38
+ @wonders.delete(wonder_id)
39
+ end
40
+
41
+ def active_wonders
42
+ @wonders.values.reject { |w| w[:resolved] }
43
+ end
44
+
45
+ def resolved_wonders
46
+ @wonders.values.select { |w| w[:resolved] }
47
+ end
48
+
49
+ def top(limit: 5, exclude_domains: [])
50
+ active_wonders
51
+ .reject { |w| exclude_domains.include?(w[:domain]) }
52
+ .sort_by { |w| -Wonder.score(w) }
53
+ .first(limit)
54
+ end
55
+
56
+ def top_balanced(limit: 5)
57
+ domain_counts = active_wonders.group_by { |w| w[:domain] }
58
+ .transform_values(&:size)
59
+ max_domain = domain_counts.values.max || 1
60
+
61
+ active_wonders
62
+ .sort_by do |w|
63
+ domain_penalty = (domain_counts[w[:domain]].to_f / max_domain) * Constants::DOMAIN_BALANCE_FACTOR
64
+ -(Wonder.score(w) - domain_penalty)
65
+ end
66
+ .first(limit)
67
+ end
68
+
69
+ def by_domain(domain)
70
+ active_wonders.select { |w| w[:domain] == domain.to_sym }
71
+ end
72
+
73
+ def mark_resolved(wonder_id, resolution:, actual_gain: 0.5)
74
+ wonder = @wonders[wonder_id]
75
+ return nil unless wonder
76
+
77
+ @wonders[wonder_id] = wonder.merge(
78
+ resolved: true,
79
+ resolution: resolution,
80
+ actual_gain: actual_gain
81
+ )
82
+ @resolved_count += 1
83
+ @domain_resolution_rates[wonder[:domain]][:resolved] += 1
84
+ @wonders[wonder_id]
85
+ end
86
+
87
+ def decay_all(hours_elapsed: 1.0)
88
+ pruned = 0
89
+ @wonders.each do |id, wonder|
90
+ next if wonder[:resolved]
91
+
92
+ decayed = Wonder.decay_salience(wonder, hours_elapsed: hours_elapsed)
93
+ if decayed[:salience] <= 0.0 || Wonder.stale?(decayed)
94
+ @wonders.delete(id)
95
+ pruned += 1
96
+ else
97
+ @wonders[id] = decayed
98
+ end
99
+ end
100
+ pruned
101
+ end
102
+
103
+ def count
104
+ @wonders.size
105
+ end
106
+
107
+ def active_count
108
+ active_wonders.size
109
+ end
110
+
111
+ def domain_stats
112
+ stats = Hash.new { |h, k| h[k] = { active: 0, resolved: 0 } }
113
+ @wonders.each_value do |w|
114
+ key = w[:resolved] ? :resolved : :active
115
+ stats[w[:domain]][key] += 1
116
+ end
117
+ stats
118
+ end
119
+
120
+ def resolution_rate
121
+ return 0.0 if @total_generated.zero?
122
+
123
+ @resolved_count.to_f / @total_generated
124
+ end
125
+
126
+ def domain_resolution_rate(domain)
127
+ rates = @domain_resolution_rates[domain.to_sym]
128
+ return 0.0 if rates[:total].zero?
129
+
130
+ rates[:resolved].to_f / rates[:total]
131
+ end
132
+
133
+ private
134
+
135
+ def prune_if_full
136
+ return unless active_count >= Constants::MAX_WONDERS
137
+
138
+ lowest = active_wonders.min_by { |w| Wonder.score(w) }
139
+ @wonders.delete(lowest[:wonder_id]) if lowest
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end