lex-imagination 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 +13 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/lex-imagination.gemspec +29 -0
- data/lib/legion/extensions/imagination/client.rb +17 -0
- data/lib/legion/extensions/imagination/helpers/constants.rb +51 -0
- data/lib/legion/extensions/imagination/helpers/scenario.rb +119 -0
- data/lib/legion/extensions/imagination/helpers/simulation_store.rb +76 -0
- data/lib/legion/extensions/imagination/runners/imagination.rb +206 -0
- data/lib/legion/extensions/imagination/version.rb +9 -0
- data/lib/legion/extensions/imagination.rb +17 -0
- data/spec/legion/extensions/imagination/client_spec.rb +20 -0
- data/spec/legion/extensions/imagination/helpers/constants_spec.rb +25 -0
- data/spec/legion/extensions/imagination/helpers/scenario_spec.rb +111 -0
- data/spec/legion/extensions/imagination/helpers/simulation_store_spec.rb +89 -0
- data/spec/legion/extensions/imagination/runners/imagination_spec.rb +99 -0
- data/spec/spec_helper.rb +35 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 796ec9d97b9cc1782cf232c968efd46f14dcdc7612b4f1122368071729c86bd4
|
|
4
|
+
data.tar.gz: ac83b7c6f4aa92c5017bfed3afd2ef19a193f11054a84f726faffd27d94b159f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a0f5965a249e2eee28d411d9d03755bf38c70cc5194ca3ae6c88eeb6d30b7f3b012c63112f11307b4e3b054da20f4fc1aa132d61f92308389641ea8a499e3bf
|
|
7
|
+
data.tar.gz: 79402cb3ff9b14aa04bcd3b15f3ccaa6217c2aacdae953e8c26f9894daa133b2aecd5ef0197b7a907c9218cf891a2143d13fcabbfa836360cd25dc1ee1512ae1
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Iverson
|
|
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,82 @@
|
|
|
1
|
+
# lex-imagination
|
|
2
|
+
|
|
3
|
+
Counterfactual simulation and mental rehearsal for LegionIO agents. Part of the LegionIO cognitive architecture extension ecosystem (LEX).
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
`lex-imagination` lets an agent mentally rehearse actions before committing to them. Given a list of candidate actions, it builds scenarios with success, failure, and wildcard outcomes, evaluates each via a weighted composite score (expected value, risk, reversibility, novelty, alignment), and returns a ranked recommendation. Deep what-if chains trace consequences recursively. Actual outcomes can be recorded to track simulation accuracy over time.
|
|
8
|
+
|
|
9
|
+
Key capabilities:
|
|
10
|
+
|
|
11
|
+
- **Multi-scenario simulation**: evaluate up to 5 candidate actions per call
|
|
12
|
+
- **Composite scoring**: expected value (35%), risk (25%), reversibility (20%), novelty (10%), alignment (10%)
|
|
13
|
+
- **Recursive what-if chains**: trace consequences up to depth 3
|
|
14
|
+
- **Head-to-head comparison**: compare two actions directly
|
|
15
|
+
- **Outcome tracking**: record actual results to measure simulation accuracy
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Add to your Gemfile:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'lex-imagination'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install directly:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
gem install lex-imagination
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require 'legion/extensions/imagination'
|
|
35
|
+
|
|
36
|
+
client = Legion::Extensions::Imagination::Client.new
|
|
37
|
+
|
|
38
|
+
# Simulate candidate actions
|
|
39
|
+
result = client.simulate(
|
|
40
|
+
actions: ['deploy_hotfix', 'wait_for_review', 'rollback'],
|
|
41
|
+
context: { familiar: true, alignment: 0.8, risky: false },
|
|
42
|
+
risk_tolerance: :moderate
|
|
43
|
+
)
|
|
44
|
+
# => { recommendation: { action: 'deploy_hotfix', composite: 0.72, confidence: :high },
|
|
45
|
+
# scenarios: [...], simulation_id: "..." }
|
|
46
|
+
|
|
47
|
+
# Deep what-if analysis
|
|
48
|
+
chain = client.what_if(action: 'merge_without_tests', context: { risky: true }, depth: 3)
|
|
49
|
+
# => { consequence_chain: [...], overall_valence: :negative_trajectory }
|
|
50
|
+
|
|
51
|
+
# Compare two options
|
|
52
|
+
comparison = client.compare(action_a: 'refactor_first', action_b: 'ship_now', context: {})
|
|
53
|
+
# => { winner: :a, margin: 0.15, decisive: true }
|
|
54
|
+
|
|
55
|
+
# Record what actually happened
|
|
56
|
+
client.record_actual_outcome(simulation_id: result[:simulation_id], actual_outcome: :success)
|
|
57
|
+
|
|
58
|
+
# Stats
|
|
59
|
+
client.imagination_stats
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Runner Methods
|
|
63
|
+
|
|
64
|
+
| Method | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `simulate` | Build and rank scenarios for a list of candidate actions |
|
|
67
|
+
| `what_if` | Recursive consequence chain for a single action |
|
|
68
|
+
| `compare` | Head-to-head composite score comparison of two actions |
|
|
69
|
+
| `record_actual_outcome` | Log actual result for accuracy tracking |
|
|
70
|
+
| `imagination_stats` | Total simulations, accuracy, avg composite |
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bundle install
|
|
76
|
+
bundle exec rspec
|
|
77
|
+
bundle exec rubocop
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/imagination/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-imagination'
|
|
7
|
+
spec.version = Legion::Extensions::Imagination::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Imagination'
|
|
12
|
+
spec.description = 'Counterfactual simulation engine — mental rehearsal of actions before committing'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-imagination'
|
|
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-imagination'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-imagination'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-imagination'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-imagination/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-imagination.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Imagination
|
|
6
|
+
class Client
|
|
7
|
+
include Runners::Imagination
|
|
8
|
+
|
|
9
|
+
attr_reader :simulation_store
|
|
10
|
+
|
|
11
|
+
def initialize(simulation_store: nil, **)
|
|
12
|
+
@simulation_store = simulation_store || Helpers::SimulationStore.new
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Imagination
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Maximum scenarios per simulation
|
|
9
|
+
MAX_SCENARIOS = 5
|
|
10
|
+
|
|
11
|
+
# Maximum simulation depth (chained consequence steps)
|
|
12
|
+
MAX_DEPTH = 3
|
|
13
|
+
|
|
14
|
+
# Simulation history capacity
|
|
15
|
+
MAX_SIMULATIONS = 100
|
|
16
|
+
|
|
17
|
+
# Confidence thresholds
|
|
18
|
+
HIGH_CONFIDENCE = 0.7
|
|
19
|
+
MEDIUM_CONFIDENCE = 0.4
|
|
20
|
+
LOW_CONFIDENCE = 0.2
|
|
21
|
+
|
|
22
|
+
# Outcome valence labels
|
|
23
|
+
OUTCOME_VALENCES = %i[very_positive positive neutral negative very_negative].freeze
|
|
24
|
+
|
|
25
|
+
# Risk tolerance levels
|
|
26
|
+
RISK_TOLERANCES = %i[conservative moderate aggressive].freeze
|
|
27
|
+
|
|
28
|
+
# Default evaluation weights
|
|
29
|
+
EVALUATION_WEIGHTS = {
|
|
30
|
+
expected_value: 0.3,
|
|
31
|
+
risk: 0.25,
|
|
32
|
+
reversibility: 0.2,
|
|
33
|
+
alignment: 0.15,
|
|
34
|
+
novelty: 0.1
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Scenario outcome types
|
|
38
|
+
OUTCOME_TYPES = %i[success partial_success neutral partial_failure failure wildcard].freeze
|
|
39
|
+
|
|
40
|
+
# Imagination modes
|
|
41
|
+
MODES = %i[
|
|
42
|
+
prospective
|
|
43
|
+
retrospective
|
|
44
|
+
counterfactual
|
|
45
|
+
exploratory
|
|
46
|
+
].freeze
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Imagination
|
|
6
|
+
module Helpers
|
|
7
|
+
class Scenario
|
|
8
|
+
attr_reader :scenario_id, :action, :context, :outcomes, :evaluation, :created_at
|
|
9
|
+
|
|
10
|
+
def initialize(action:, context: {})
|
|
11
|
+
@scenario_id = SecureRandom.uuid
|
|
12
|
+
@action = action
|
|
13
|
+
@context = context
|
|
14
|
+
@outcomes = []
|
|
15
|
+
@evaluation = nil
|
|
16
|
+
@created_at = Time.now.utc
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_outcome(outcome)
|
|
20
|
+
@outcomes << {
|
|
21
|
+
outcome_id: SecureRandom.uuid,
|
|
22
|
+
type: outcome[:type] || :neutral,
|
|
23
|
+
likelihood: (outcome[:likelihood] || 0.5).clamp(0.0, 1.0),
|
|
24
|
+
valence: classify_valence(outcome[:value] || 0.0),
|
|
25
|
+
value: (outcome[:value] || 0.0).clamp(-1.0, 1.0),
|
|
26
|
+
description: outcome[:description],
|
|
27
|
+
consequences: outcome[:consequences] || [],
|
|
28
|
+
reversible: outcome.fetch(:reversible, true)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def expected_value
|
|
33
|
+
return 0.0 if @outcomes.empty?
|
|
34
|
+
|
|
35
|
+
@outcomes.sum { |o| o[:likelihood] * o[:value] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def risk_score
|
|
39
|
+
return 0.0 if @outcomes.empty?
|
|
40
|
+
|
|
41
|
+
negative_outcomes = @outcomes.select { |o| o[:value].negative? }
|
|
42
|
+
return 0.0 if negative_outcomes.empty?
|
|
43
|
+
|
|
44
|
+
negative_outcomes.sum { |o| o[:likelihood] * o[:value].abs }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def best_outcome
|
|
48
|
+
@outcomes.max_by { |o| o[:value] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def worst_outcome
|
|
52
|
+
@outcomes.min_by { |o| o[:value] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reversibility
|
|
56
|
+
return 1.0 if @outcomes.empty?
|
|
57
|
+
|
|
58
|
+
reversible_count = @outcomes.count { |o| o[:reversible] }
|
|
59
|
+
reversible_count.to_f / @outcomes.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def evaluate(weights: Constants::EVALUATION_WEIGHTS, alignment: 0.5, novelty: 0.5)
|
|
63
|
+
@evaluation = {
|
|
64
|
+
expected_value: expected_value,
|
|
65
|
+
risk: risk_score,
|
|
66
|
+
reversibility: reversibility,
|
|
67
|
+
alignment: alignment,
|
|
68
|
+
novelty: novelty,
|
|
69
|
+
composite: compute_composite(weights, alignment, novelty),
|
|
70
|
+
evaluated_at: Time.now.utc
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
scenario_id: @scenario_id,
|
|
77
|
+
action: @action,
|
|
78
|
+
context: @context,
|
|
79
|
+
outcomes: @outcomes,
|
|
80
|
+
expected_value: expected_value,
|
|
81
|
+
risk_score: risk_score,
|
|
82
|
+
reversibility: reversibility,
|
|
83
|
+
evaluation: @evaluation,
|
|
84
|
+
created_at: @created_at
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def classify_valence(value)
|
|
91
|
+
if value > 0.5
|
|
92
|
+
:very_positive
|
|
93
|
+
elsif value > 0.1
|
|
94
|
+
:positive
|
|
95
|
+
elsif value > -0.1
|
|
96
|
+
:neutral
|
|
97
|
+
elsif value > -0.5
|
|
98
|
+
:negative
|
|
99
|
+
else
|
|
100
|
+
:very_negative
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compute_composite(weights, alignment, novelty)
|
|
105
|
+
ev_score = (expected_value + 1.0) / 2.0
|
|
106
|
+
risk_penalty = 1.0 - risk_score
|
|
107
|
+
rev_score = reversibility
|
|
108
|
+
|
|
109
|
+
(weights[:expected_value] * ev_score) +
|
|
110
|
+
(weights[:risk] * risk_penalty) +
|
|
111
|
+
(weights[:reversibility] * rev_score) +
|
|
112
|
+
(weights[:alignment] * alignment) +
|
|
113
|
+
(weights[:novelty] * novelty)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Imagination
|
|
6
|
+
module Helpers
|
|
7
|
+
class SimulationStore
|
|
8
|
+
attr_reader :simulations
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@simulations = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def store(simulation)
|
|
15
|
+
@simulations << simulation
|
|
16
|
+
@simulations = @simulations.last(Constants::MAX_SIMULATIONS)
|
|
17
|
+
simulation
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(simulation_id)
|
|
21
|
+
@simulations.find { |s| s[:simulation_id] == simulation_id }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recent(limit: 10)
|
|
25
|
+
@simulations.last(limit)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def by_mode(mode)
|
|
29
|
+
@simulations.select { |s| s[:mode] == mode }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def best_decisions
|
|
33
|
+
@simulations.select { |s| s[:recommendation] }
|
|
34
|
+
.sort_by { |s| -(s.dig(:recommendation, :composite) || 0) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def accuracy_check(simulation_id, actual_outcome:)
|
|
38
|
+
sim = get(simulation_id)
|
|
39
|
+
return nil unless sim
|
|
40
|
+
|
|
41
|
+
sim[:actual_outcome] = actual_outcome
|
|
42
|
+
sim[:accurate] = outcome_matches?(sim, actual_outcome)
|
|
43
|
+
sim[:accurate]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def simulation_accuracy
|
|
47
|
+
checked = @simulations.select { |s| s.key?(:accurate) }
|
|
48
|
+
return nil if checked.empty?
|
|
49
|
+
|
|
50
|
+
correct = checked.count { |s| s[:accurate] }
|
|
51
|
+
correct.to_f / checked.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def size
|
|
55
|
+
@simulations.size
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
@simulations.clear
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def outcome_matches?(simulation, actual)
|
|
65
|
+
recommended = simulation.dig(:recommendation, :action)
|
|
66
|
+
return false unless recommended
|
|
67
|
+
|
|
68
|
+
actual_valence = actual[:valence] || :neutral
|
|
69
|
+
positive_outcomes = %i[very_positive positive]
|
|
70
|
+
positive_outcomes.include?(actual_valence)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Imagination
|
|
6
|
+
module Runners
|
|
7
|
+
module Imagination
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def simulate(actions:, context: {}, mode: :prospective, risk_tolerance: :moderate, **)
|
|
12
|
+
scenarios = actions.first(Helpers::Constants::MAX_SCENARIOS).map do |action|
|
|
13
|
+
build_scenario(action, context)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
scenarios.each { |s| s.evaluate(alignment: context[:alignment] || 0.5, novelty: context[:novelty] || 0.5) }
|
|
17
|
+
|
|
18
|
+
ranked = rank_scenarios(scenarios, risk_tolerance)
|
|
19
|
+
recommendation = ranked.first
|
|
20
|
+
|
|
21
|
+
simulation = {
|
|
22
|
+
simulation_id: SecureRandom.uuid,
|
|
23
|
+
mode: mode.to_sym,
|
|
24
|
+
scenarios: scenarios.map(&:to_h),
|
|
25
|
+
recommendation: recommendation ? format_recommendation(recommendation) : nil,
|
|
26
|
+
risk_tolerance: risk_tolerance,
|
|
27
|
+
simulated_at: Time.now.utc
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
simulation_store.store(simulation)
|
|
31
|
+
|
|
32
|
+
Legion::Logging.debug "[imagination] simulated #{scenarios.size} scenarios, " \
|
|
33
|
+
"recommended=#{recommendation&.action || 'none'}"
|
|
34
|
+
|
|
35
|
+
simulation
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def what_if(action:, context: {}, depth: 1, **)
|
|
39
|
+
actual_depth = [depth, Helpers::Constants::MAX_DEPTH].min
|
|
40
|
+
scenario = build_scenario(action, context)
|
|
41
|
+
chain = build_consequence_chain(scenario, actual_depth)
|
|
42
|
+
scenario.evaluate
|
|
43
|
+
|
|
44
|
+
Legion::Logging.debug "[imagination] what_if: action=#{action} depth=#{actual_depth} " \
|
|
45
|
+
"ev=#{scenario.expected_value.round(2)}"
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
scenario: scenario.to_h,
|
|
49
|
+
consequence_chain: chain,
|
|
50
|
+
depth: actual_depth,
|
|
51
|
+
overall_valence: classify_chain_valence(chain)
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compare(action_a:, action_b:, context: {}, **)
|
|
56
|
+
scenario_a = build_scenario(action_a, context)
|
|
57
|
+
scenario_b = build_scenario(action_b, context)
|
|
58
|
+
scenario_a.evaluate
|
|
59
|
+
scenario_b.evaluate
|
|
60
|
+
|
|
61
|
+
winner = scenario_a.evaluation[:composite] >= scenario_b.evaluation[:composite] ? :a : :b
|
|
62
|
+
margin = (scenario_a.evaluation[:composite] - scenario_b.evaluation[:composite]).abs
|
|
63
|
+
|
|
64
|
+
Legion::Logging.debug "[imagination] compare: #{action_a} vs #{action_b} -> winner=#{winner} margin=#{margin.round(3)}"
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
scenario_a: scenario_a.to_h,
|
|
68
|
+
scenario_b: scenario_b.to_h,
|
|
69
|
+
winner: winner,
|
|
70
|
+
margin: margin,
|
|
71
|
+
decisive: margin > 0.1
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_actual_outcome(simulation_id:, actual_outcome: {}, **)
|
|
76
|
+
result = simulation_store.accuracy_check(simulation_id, actual_outcome: actual_outcome)
|
|
77
|
+
if result.nil?
|
|
78
|
+
{ error: :not_found }
|
|
79
|
+
else
|
|
80
|
+
Legion::Logging.info "[imagination] outcome recorded: simulation=#{simulation_id} accurate=#{result}"
|
|
81
|
+
{ simulation_id: simulation_id, accurate: result, overall_accuracy: simulation_store.simulation_accuracy }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def imagination_stats(**)
|
|
86
|
+
{
|
|
87
|
+
total_simulations: simulation_store.size,
|
|
88
|
+
accuracy: simulation_store.simulation_accuracy,
|
|
89
|
+
by_mode: mode_distribution,
|
|
90
|
+
recent_count: simulation_store.recent(limit: 10).size
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def simulation_store
|
|
97
|
+
@simulation_store ||= Helpers::SimulationStore.new
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_scenario(action, context)
|
|
101
|
+
scenario = Helpers::Scenario.new(action: action, context: context)
|
|
102
|
+
|
|
103
|
+
scenario.add_outcome(type: :success, likelihood: estimate_success_likelihood(action, context),
|
|
104
|
+
value: estimate_positive_value(context), description: "#{action} succeeds",
|
|
105
|
+
reversible: true)
|
|
106
|
+
|
|
107
|
+
scenario.add_outcome(type: :failure, likelihood: estimate_failure_likelihood(action, context),
|
|
108
|
+
value: estimate_negative_value(context), description: "#{action} fails",
|
|
109
|
+
reversible: context.fetch(:reversible, true))
|
|
110
|
+
|
|
111
|
+
if context[:wildcard]
|
|
112
|
+
scenario.add_outcome(type: :wildcard, likelihood: 0.1,
|
|
113
|
+
value: context[:wildcard_value] || 0.0,
|
|
114
|
+
description: context[:wildcard], reversible: true)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
scenario
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def estimate_success_likelihood(_action, context)
|
|
121
|
+
base = 0.5
|
|
122
|
+
base += 0.1 if context[:familiar]
|
|
123
|
+
base += 0.1 if context[:alignment].is_a?(Numeric) && context[:alignment] > 0.6
|
|
124
|
+
base -= 0.1 if context[:risky]
|
|
125
|
+
base.clamp(0.1, 0.9)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def estimate_failure_likelihood(_action, context)
|
|
129
|
+
1.0 - estimate_success_likelihood(nil, context)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def estimate_positive_value(context)
|
|
133
|
+
(context[:positive_value] || 0.6).clamp(0.0, 1.0)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def estimate_negative_value(context)
|
|
137
|
+
-(context[:negative_value] || 0.4).clamp(0.0, 1.0)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def rank_scenarios(scenarios, risk_tolerance)
|
|
141
|
+
scenarios.sort_by do |s|
|
|
142
|
+
composite = s.evaluation[:composite]
|
|
143
|
+
risk_adjust = case risk_tolerance.to_sym
|
|
144
|
+
when :conservative then composite - (s.risk_score * 0.3)
|
|
145
|
+
when :aggressive then composite + (s.expected_value * 0.2)
|
|
146
|
+
else composite
|
|
147
|
+
end
|
|
148
|
+
-risk_adjust
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def format_recommendation(scenario)
|
|
153
|
+
{
|
|
154
|
+
action: scenario.action,
|
|
155
|
+
composite: scenario.evaluation[:composite],
|
|
156
|
+
expected_value: scenario.expected_value,
|
|
157
|
+
risk: scenario.risk_score,
|
|
158
|
+
reversibility: scenario.reversibility,
|
|
159
|
+
confidence: classify_confidence(scenario.evaluation[:composite])
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def classify_confidence(composite)
|
|
164
|
+
if composite >= Helpers::Constants::HIGH_CONFIDENCE
|
|
165
|
+
:high
|
|
166
|
+
elsif composite >= Helpers::Constants::MEDIUM_CONFIDENCE
|
|
167
|
+
:medium
|
|
168
|
+
else
|
|
169
|
+
:low
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_consequence_chain(scenario, depth)
|
|
174
|
+
chain = [{ depth: 0, expected_value: scenario.expected_value, risk: scenario.risk_score }]
|
|
175
|
+
|
|
176
|
+
depth.times do |d|
|
|
177
|
+
prev = chain.last
|
|
178
|
+
dampened_ev = prev[:expected_value] * 0.7
|
|
179
|
+
dampened_risk = prev[:risk] * 0.8
|
|
180
|
+
chain << { depth: d + 1, expected_value: dampened_ev, risk: dampened_risk }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
chain
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def classify_chain_valence(chain)
|
|
187
|
+
total_ev = chain.sum { |c| c[:expected_value] }
|
|
188
|
+
if total_ev > 0.3
|
|
189
|
+
:positive_trajectory
|
|
190
|
+
elsif total_ev < -0.3
|
|
191
|
+
:negative_trajectory
|
|
192
|
+
else
|
|
193
|
+
:uncertain_trajectory
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def mode_distribution
|
|
198
|
+
dist = Hash.new(0)
|
|
199
|
+
simulation_store.simulations.each { |s| dist[s[:mode]] += 1 }
|
|
200
|
+
dist
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'legion/extensions/imagination/version'
|
|
5
|
+
require 'legion/extensions/imagination/helpers/constants'
|
|
6
|
+
require 'legion/extensions/imagination/helpers/scenario'
|
|
7
|
+
require 'legion/extensions/imagination/helpers/simulation_store'
|
|
8
|
+
require 'legion/extensions/imagination/runners/imagination'
|
|
9
|
+
require 'legion/extensions/imagination/client'
|
|
10
|
+
|
|
11
|
+
module Legion
|
|
12
|
+
module Extensions
|
|
13
|
+
module Imagination
|
|
14
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Imagination::Client do
|
|
4
|
+
it 'creates default simulation store' do
|
|
5
|
+
client = described_class.new
|
|
6
|
+
expect(client.simulation_store).to be_a(Legion::Extensions::Imagination::Helpers::SimulationStore)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
it 'accepts injected simulation store' do
|
|
10
|
+
store = Legion::Extensions::Imagination::Helpers::SimulationStore.new
|
|
11
|
+
client = described_class.new(simulation_store: store)
|
|
12
|
+
expect(client.simulation_store).to equal(store)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'includes Imagination runner methods' do
|
|
16
|
+
client = described_class.new
|
|
17
|
+
expect(client).to respond_to(:simulate, :what_if, :compare,
|
|
18
|
+
:record_actual_outcome, :imagination_stats)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Imagination::Helpers::Constants do
|
|
4
|
+
it 'defines 4 imagination modes' do
|
|
5
|
+
expect(described_class::MODES.size).to eq(4)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'defines 6 outcome types' do
|
|
9
|
+
expect(described_class::OUTCOME_TYPES.size).to eq(6)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'defines 3 risk tolerances' do
|
|
13
|
+
expect(described_class::RISK_TOLERANCES).to contain_exactly(:conservative, :moderate, :aggressive)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'defines evaluation weights summing to 1.0' do
|
|
17
|
+
total = described_class::EVALUATION_WEIGHTS.values.sum
|
|
18
|
+
expect(total).to be_within(0.001).of(1.0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'defines ordered confidence thresholds' do
|
|
22
|
+
expect(described_class::LOW_CONFIDENCE).to be < described_class::MEDIUM_CONFIDENCE
|
|
23
|
+
expect(described_class::MEDIUM_CONFIDENCE).to be < described_class::HIGH_CONFIDENCE
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Imagination::Helpers::Scenario do
|
|
4
|
+
subject(:scenario) { described_class.new(action: 'deploy', context: { env: :prod }) }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'assigns a UUID' do
|
|
8
|
+
expect(scenario.scenario_id).to match(/\A[0-9a-f-]{36}\z/)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'stores action and context' do
|
|
12
|
+
expect(scenario.action).to eq('deploy')
|
|
13
|
+
expect(scenario.context[:env]).to eq(:prod)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#add_outcome' do
|
|
18
|
+
it 'adds an outcome' do
|
|
19
|
+
scenario.add_outcome(type: :success, likelihood: 0.7, value: 0.8)
|
|
20
|
+
expect(scenario.outcomes.size).to eq(1)
|
|
21
|
+
expect(scenario.outcomes.first[:type]).to eq(:success)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'clamps likelihood to 0-1' do
|
|
25
|
+
scenario.add_outcome(likelihood: 1.5, value: 0.5)
|
|
26
|
+
expect(scenario.outcomes.first[:likelihood]).to eq(1.0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'classifies valence' do
|
|
30
|
+
scenario.add_outcome(value: 0.8)
|
|
31
|
+
expect(scenario.outcomes.first[:valence]).to eq(:very_positive)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#expected_value' do
|
|
36
|
+
it 'returns 0 with no outcomes' do
|
|
37
|
+
expect(scenario.expected_value).to eq(0.0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'computes weighted expected value' do
|
|
41
|
+
scenario.add_outcome(likelihood: 0.7, value: 0.8)
|
|
42
|
+
scenario.add_outcome(likelihood: 0.3, value: -0.5)
|
|
43
|
+
ev = scenario.expected_value
|
|
44
|
+
expect(ev).to be_within(0.001).of((0.7 * 0.8) + (0.3 * -0.5))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#risk_score' do
|
|
49
|
+
it 'returns 0 with no negative outcomes' do
|
|
50
|
+
scenario.add_outcome(likelihood: 0.5, value: 0.5)
|
|
51
|
+
expect(scenario.risk_score).to eq(0.0)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'computes risk from negative outcomes' do
|
|
55
|
+
scenario.add_outcome(likelihood: 0.3, value: -0.6)
|
|
56
|
+
expect(scenario.risk_score).to be_within(0.001).of(0.3 * 0.6)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#best_outcome / #worst_outcome' do
|
|
61
|
+
before do
|
|
62
|
+
scenario.add_outcome(value: 0.8, likelihood: 0.5)
|
|
63
|
+
scenario.add_outcome(value: -0.3, likelihood: 0.5)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns highest value outcome as best' do
|
|
67
|
+
expect(scenario.best_outcome[:value]).to eq(0.8)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns lowest value outcome as worst' do
|
|
71
|
+
expect(scenario.worst_outcome[:value]).to eq(-0.3)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#reversibility' do
|
|
76
|
+
it 'returns 1.0 when all outcomes are reversible' do
|
|
77
|
+
scenario.add_outcome(value: 0.5, reversible: true)
|
|
78
|
+
expect(scenario.reversibility).to eq(1.0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns 0.0 when no outcomes are reversible' do
|
|
82
|
+
scenario.add_outcome(value: 0.5, reversible: false)
|
|
83
|
+
expect(scenario.reversibility).to eq(0.0)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#evaluate' do
|
|
88
|
+
before do
|
|
89
|
+
scenario.add_outcome(likelihood: 0.7, value: 0.6)
|
|
90
|
+
scenario.add_outcome(likelihood: 0.3, value: -0.3)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'produces evaluation hash' do
|
|
94
|
+
scenario.evaluate
|
|
95
|
+
expect(scenario.evaluation).to include(:expected_value, :risk, :reversibility, :composite)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'produces a composite score' do
|
|
99
|
+
scenario.evaluate
|
|
100
|
+
expect(scenario.evaluation[:composite]).to be_a(Numeric)
|
|
101
|
+
expect(scenario.evaluation[:composite]).to be > 0
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe '#to_h' do
|
|
106
|
+
it 'returns complete scenario hash' do
|
|
107
|
+
h = scenario.to_h
|
|
108
|
+
expect(h).to include(:scenario_id, :action, :context, :outcomes, :expected_value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Imagination::Helpers::SimulationStore do
|
|
4
|
+
subject(:store) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:simulation) do
|
|
7
|
+
{
|
|
8
|
+
simulation_id: SecureRandom.uuid,
|
|
9
|
+
mode: :prospective,
|
|
10
|
+
scenarios: [],
|
|
11
|
+
recommendation: { action: 'deploy', composite: 0.8 },
|
|
12
|
+
simulated_at: Time.now.utc
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#store' do
|
|
17
|
+
it 'stores a simulation' do
|
|
18
|
+
store.store(simulation)
|
|
19
|
+
expect(store.size).to eq(1)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'caps at MAX_SIMULATIONS' do
|
|
23
|
+
(Legion::Extensions::Imagination::Helpers::Constants::MAX_SIMULATIONS + 5).times do |i|
|
|
24
|
+
store.store(simulation.merge(simulation_id: "sim-#{i}"))
|
|
25
|
+
end
|
|
26
|
+
expect(store.size).to eq(Legion::Extensions::Imagination::Helpers::Constants::MAX_SIMULATIONS)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#get' do
|
|
31
|
+
it 'retrieves by ID' do
|
|
32
|
+
store.store(simulation)
|
|
33
|
+
found = store.get(simulation[:simulation_id])
|
|
34
|
+
expect(found).to eq(simulation)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns nil for missing ID' do
|
|
38
|
+
expect(store.get('nonexistent')).to be_nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#recent' do
|
|
43
|
+
it 'returns last N simulations' do
|
|
44
|
+
3.times { |i| store.store(simulation.merge(simulation_id: "sim-#{i}")) }
|
|
45
|
+
expect(store.recent(limit: 2).size).to eq(2)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '#by_mode' do
|
|
50
|
+
it 'filters by mode' do
|
|
51
|
+
store.store(simulation.merge(mode: :prospective))
|
|
52
|
+
store.store(simulation.merge(simulation_id: 'x', mode: :counterfactual))
|
|
53
|
+
expect(store.by_mode(:prospective).size).to eq(1)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#accuracy_check' do
|
|
58
|
+
it 'records actual outcome' do
|
|
59
|
+
store.store(simulation)
|
|
60
|
+
result = store.accuracy_check(simulation[:simulation_id], actual_outcome: { valence: :positive })
|
|
61
|
+
expect(result).to be true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'returns nil for missing simulation' do
|
|
65
|
+
result = store.accuracy_check('nonexistent', actual_outcome: {})
|
|
66
|
+
expect(result).to be_nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#simulation_accuracy' do
|
|
71
|
+
it 'returns nil with no checked simulations' do
|
|
72
|
+
expect(store.simulation_accuracy).to be_nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'computes accuracy' do
|
|
76
|
+
store.store(simulation)
|
|
77
|
+
store.accuracy_check(simulation[:simulation_id], actual_outcome: { valence: :positive })
|
|
78
|
+
expect(store.simulation_accuracy).to eq(1.0)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#clear' do
|
|
83
|
+
it 'removes all simulations' do
|
|
84
|
+
store.store(simulation)
|
|
85
|
+
store.clear
|
|
86
|
+
expect(store.size).to eq(0)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Imagination::Runners::Imagination do
|
|
4
|
+
let(:client) { Legion::Extensions::Imagination::Client.new }
|
|
5
|
+
|
|
6
|
+
describe '#simulate' do
|
|
7
|
+
it 'simulates multiple actions' do
|
|
8
|
+
result = client.simulate(actions: %w[deploy rollback wait])
|
|
9
|
+
expect(result[:scenarios].size).to eq(3)
|
|
10
|
+
expect(result).to have_key(:recommendation)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'limits to MAX_SCENARIOS' do
|
|
14
|
+
actions = (1..10).map { |i| "action_#{i}" }
|
|
15
|
+
result = client.simulate(actions: actions)
|
|
16
|
+
expect(result[:scenarios].size).to be <= Legion::Extensions::Imagination::Helpers::Constants::MAX_SCENARIOS
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'stores simulation in history' do
|
|
20
|
+
client.simulate(actions: %w[deploy])
|
|
21
|
+
expect(client.simulation_store.size).to eq(1)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'recommends the best action' do
|
|
25
|
+
result = client.simulate(
|
|
26
|
+
actions: %w[safe_action risky_action],
|
|
27
|
+
context: { familiar: true, alignment: 0.8 },
|
|
28
|
+
risk_tolerance: :conservative
|
|
29
|
+
)
|
|
30
|
+
expect(result[:recommendation]).to have_key(:action)
|
|
31
|
+
expect(result[:recommendation]).to have_key(:composite)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'respects risk tolerance' do
|
|
35
|
+
conservative = client.simulate(actions: %w[deploy], risk_tolerance: :conservative)
|
|
36
|
+
aggressive = client.simulate(actions: %w[deploy], risk_tolerance: :aggressive)
|
|
37
|
+
expect(conservative[:recommendation]).not_to be_nil
|
|
38
|
+
expect(aggressive[:recommendation]).not_to be_nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#what_if' do
|
|
43
|
+
it 'returns scenario with consequence chain' do
|
|
44
|
+
result = client.what_if(action: 'merge_without_tests', depth: 2)
|
|
45
|
+
expect(result[:consequence_chain].size).to eq(3)
|
|
46
|
+
expect(result[:depth]).to eq(2)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'limits depth to MAX_DEPTH' do
|
|
50
|
+
result = client.what_if(action: 'test', depth: 100)
|
|
51
|
+
expect(result[:depth]).to eq(Legion::Extensions::Imagination::Helpers::Constants::MAX_DEPTH)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'returns overall valence' do
|
|
55
|
+
result = client.what_if(action: 'safe_deploy', context: { familiar: true })
|
|
56
|
+
expect(result[:overall_valence]).to be_a(Symbol)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#compare' do
|
|
61
|
+
it 'compares two actions' do
|
|
62
|
+
result = client.compare(action_a: 'refactor', action_b: 'ship_now')
|
|
63
|
+
expect(%i[a b]).to include(result[:winner])
|
|
64
|
+
expect(result).to have_key(:margin)
|
|
65
|
+
expect(result).to have_key(:decisive)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'returns scenario details for both' do
|
|
69
|
+
result = client.compare(action_a: 'a', action_b: 'b')
|
|
70
|
+
expect(result[:scenario_a]).to have_key(:expected_value)
|
|
71
|
+
expect(result[:scenario_b]).to have_key(:expected_value)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#record_actual_outcome' do
|
|
76
|
+
it 'records outcome for existing simulation' do
|
|
77
|
+
sim = client.simulate(actions: %w[deploy])
|
|
78
|
+
result = client.record_actual_outcome(
|
|
79
|
+
simulation_id: sim[:simulation_id],
|
|
80
|
+
actual_outcome: { valence: :positive }
|
|
81
|
+
)
|
|
82
|
+
expect(result[:accurate]).not_to be_nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'returns error for missing simulation' do
|
|
86
|
+
result = client.record_actual_outcome(simulation_id: 'nonexistent', actual_outcome: {})
|
|
87
|
+
expect(result[:error]).to eq(:not_found)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#imagination_stats' do
|
|
92
|
+
it 'returns stats summary' do
|
|
93
|
+
client.simulate(actions: %w[deploy])
|
|
94
|
+
result = client.imagination_stats
|
|
95
|
+
expect(result[:total_simulations]).to eq(1)
|
|
96
|
+
expect(result).to have_key(:by_mode)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Logging
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def debug(*); end
|
|
8
|
+
|
|
9
|
+
def info(*); end
|
|
10
|
+
|
|
11
|
+
def warn(*); end
|
|
12
|
+
|
|
13
|
+
def error(*); end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Extensions
|
|
17
|
+
module Helpers; end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require 'legion/extensions/imagination'
|
|
22
|
+
|
|
23
|
+
RSpec.configure do |config|
|
|
24
|
+
config.expect_with :rspec do |expectations|
|
|
25
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config.mock_with :rspec do |mocks|
|
|
29
|
+
mocks.verify_partial_doubles = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
33
|
+
config.order = :random
|
|
34
|
+
Kernel.srand config.seed
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-imagination
|
|
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: Counterfactual simulation engine — mental rehearsal of actions before
|
|
27
|
+
committing
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lex-imagination.gemspec
|
|
38
|
+
- lib/legion/extensions/imagination.rb
|
|
39
|
+
- lib/legion/extensions/imagination/client.rb
|
|
40
|
+
- lib/legion/extensions/imagination/helpers/constants.rb
|
|
41
|
+
- lib/legion/extensions/imagination/helpers/scenario.rb
|
|
42
|
+
- lib/legion/extensions/imagination/helpers/simulation_store.rb
|
|
43
|
+
- lib/legion/extensions/imagination/runners/imagination.rb
|
|
44
|
+
- lib/legion/extensions/imagination/version.rb
|
|
45
|
+
- spec/legion/extensions/imagination/client_spec.rb
|
|
46
|
+
- spec/legion/extensions/imagination/helpers/constants_spec.rb
|
|
47
|
+
- spec/legion/extensions/imagination/helpers/scenario_spec.rb
|
|
48
|
+
- spec/legion/extensions/imagination/helpers/simulation_store_spec.rb
|
|
49
|
+
- spec/legion/extensions/imagination/runners/imagination_spec.rb
|
|
50
|
+
- spec/spec_helper.rb
|
|
51
|
+
homepage: https://github.com/LegionIO/lex-imagination
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/LegionIO/lex-imagination
|
|
56
|
+
source_code_uri: https://github.com/LegionIO/lex-imagination
|
|
57
|
+
documentation_uri: https://github.com/LegionIO/lex-imagination
|
|
58
|
+
changelog_uri: https://github.com/LegionIO/lex-imagination
|
|
59
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-imagination/issues
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.4'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.6.9
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: LEX Imagination
|
|
78
|
+
test_files: []
|