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 +7 -0
- data/Gemfile +15 -0
- data/LICENSE +21 -0
- data/README.md +75 -0
- data/lex-curiosity.gemspec +29 -0
- data/lib/legion/extensions/curiosity/client.rb +24 -0
- data/lib/legion/extensions/curiosity/helpers/constants.rb +26 -0
- data/lib/legion/extensions/curiosity/helpers/gap_detector.rb +163 -0
- data/lib/legion/extensions/curiosity/helpers/wonder.rb +69 -0
- data/lib/legion/extensions/curiosity/helpers/wonder_store.rb +145 -0
- data/lib/legion/extensions/curiosity/runners/curiosity.rb +159 -0
- data/lib/legion/extensions/curiosity/version.rb +9 -0
- data/lib/legion/extensions/curiosity.rb +18 -0
- data/spec/legion/extensions/curiosity/client_spec.rb +27 -0
- data/spec/legion/extensions/curiosity/helpers/gap_detector_spec.rb +118 -0
- data/spec/legion/extensions/curiosity/helpers/wonder_spec.rb +130 -0
- data/spec/legion/extensions/curiosity/helpers/wonder_store_spec.rb +136 -0
- data/spec/legion/extensions/curiosity/runners/curiosity_spec.rb +159 -0
- data/spec/spec_helper.rb +20 -0
- metadata +78 -0
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
|