lex-dissonance 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: 93ff7acbd3ccecfcaf1bcdf7ea395eb09507a278423c1a5fb5f0349e7ef59c43
4
+ data.tar.gz: ca429797ec391379c0eb88c0ecd07c429686b0f4166433e3716ad05e9db25634
5
+ SHA512:
6
+ metadata.gz: 3619a6c4544ae2ac31410c59ca0e2b6cbec8ea687283718e83ccbf3b1d57bb7c20ccfe115e925f2876d29025d573971e7429ad1c45535777308477b8e699e640
7
+ data.tar.gz: 3acc77063b498cda63078d5261eba333c6d14affc1c618b4f85f8e5e5aa319031a9b01ea57cd9b89cdf9fa203805ad35d92f992fc5eacdbbab8b687f8b9f3a04
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.0'
8
+ gem 'rubocop', '~> 1.0', require: false
9
+
10
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/dissonance/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-dissonance'
7
+ spec.version = Legion::Extensions::Dissonance::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Dissonance'
12
+ spec.description = 'Cognitive dissonance modeling — contradiction detection, belief tracking, and resolution strategies for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-dissonance'
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-dissonance'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-dissonance'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-dissonance'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-dissonance/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-dissonance.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/dissonance/helpers/constants'
4
+ require 'legion/extensions/dissonance/helpers/belief'
5
+ require 'legion/extensions/dissonance/helpers/dissonance_event'
6
+ require 'legion/extensions/dissonance/helpers/dissonance_model'
7
+ require 'legion/extensions/dissonance/runners/dissonance'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Dissonance
12
+ class Client
13
+ include Runners::Dissonance
14
+
15
+ attr_reader :model
16
+
17
+ def initialize(model: nil, **)
18
+ @model = model || Helpers::DissonanceModel.new
19
+ @dissonance_model = @model
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :dissonance_model
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Dissonance
8
+ module Helpers
9
+ class Belief
10
+ attr_reader :id, :domain, :content, :confidence, :importance, :created_at
11
+
12
+ def initialize(domain:, content:, confidence: 0.7, importance: :moderate)
13
+ @id = SecureRandom.uuid
14
+ @domain = domain
15
+ @content = content
16
+ @confidence = confidence.clamp(0.0, 1.0)
17
+ @importance = importance
18
+ @created_at = Time.now.utc
19
+ end
20
+
21
+ def contradicts?(other)
22
+ return false if id == other.id
23
+ return false unless domain == other.domain
24
+
25
+ content.strip.downcase != other.content.strip.downcase
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ id: id,
31
+ domain: domain,
32
+ content: content,
33
+ confidence: confidence,
34
+ importance: importance,
35
+ created_at: created_at
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Dissonance
6
+ module Helpers
7
+ module Constants
8
+ DISSONANCE_THRESHOLD = 0.4
9
+ MAX_BELIEFS = 200
10
+ MAX_DISSONANCE_EVENTS = 100
11
+ DECAY_RATE = 0.03
12
+ RESOLUTION_RELIEF = 0.3
13
+ RATIONALIZATION_FACTOR = 0.5
14
+ IMPORTANCE_WEIGHTS = { core: 1.0, significant: 0.7, moderate: 0.5, peripheral: 0.25 }.freeze
15
+ RESOLUTION_STRATEGIES = %i[belief_revision rationalization avoidance].freeze
16
+ STRESS_CEILING = 1.0
17
+ STRESS_FLOOR = 0.0
18
+ CONTRADICTION_TYPES = %i[direct inverse conditional temporal].freeze
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Dissonance
8
+ module Helpers
9
+ class DissonanceEvent
10
+ attr_reader :id, :belief_a_id, :belief_b_id, :domain, :magnitude,
11
+ :contradiction_type, :resolved, :resolution_strategy, :timestamp
12
+
13
+ def initialize(belief_a_id:, belief_b_id:, domain:, magnitude:, contradiction_type: :direct)
14
+ @id = SecureRandom.uuid
15
+ @belief_a_id = belief_a_id
16
+ @belief_b_id = belief_b_id
17
+ @domain = domain
18
+ @magnitude = magnitude.clamp(0.0, 1.0)
19
+ @contradiction_type = contradiction_type
20
+ @resolved = false
21
+ @resolution_strategy = nil
22
+ @timestamp = Time.now.utc
23
+ end
24
+
25
+ def resolve!(strategy)
26
+ @resolved = true
27
+ @resolution_strategy = strategy
28
+ self
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ id: id,
34
+ belief_a_id: belief_a_id,
35
+ belief_b_id: belief_b_id,
36
+ domain: domain,
37
+ magnitude: magnitude,
38
+ contradiction_type: contradiction_type,
39
+ resolved: resolved,
40
+ resolution_strategy: resolution_strategy,
41
+ timestamp: timestamp
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Dissonance
6
+ module Helpers
7
+ class DissonanceModel
8
+ include Constants
9
+
10
+ attr_reader :beliefs, :events, :stress
11
+
12
+ def initialize
13
+ @beliefs = {}
14
+ @events = {}
15
+ @stress = STRESS_FLOOR
16
+ end
17
+
18
+ def add_belief(domain:, content:, confidence: 0.7, importance: :moderate)
19
+ prune_beliefs if @beliefs.size >= MAX_BELIEFS
20
+
21
+ belief = Belief.new(domain: domain, content: content,
22
+ confidence: confidence, importance: importance)
23
+ @beliefs[belief.id] = belief
24
+
25
+ new_events = detect_contradictions_for(belief)
26
+ new_events.each { |ev| @events[ev.id] = ev }
27
+
28
+ { belief: belief, new_dissonance_events: new_events }
29
+ end
30
+
31
+ def detect_contradictions
32
+ new_events = []
33
+ belief_list = @beliefs.values
34
+
35
+ belief_list.each_with_index do |bel_a, idx|
36
+ belief_list[(idx + 1)..].each do |bel_b|
37
+ next unless bel_a.contradicts?(bel_b)
38
+ next if contradiction_tracked?(bel_a.id, bel_b.id)
39
+
40
+ ev = build_event(bel_a, bel_b)
41
+ @events[ev.id] = ev
42
+ new_events << ev
43
+ end
44
+ end
45
+
46
+ new_events
47
+ end
48
+
49
+ def resolve(event_id, strategy:)
50
+ event = @events[event_id]
51
+ return nil unless event
52
+ return nil if event.resolved
53
+ return nil unless RESOLUTION_STRATEGIES.include?(strategy)
54
+
55
+ event.resolve!(strategy)
56
+ relief = compute_relief(strategy)
57
+ @stress = (@stress - relief).clamp(STRESS_FLOOR, STRESS_CEILING)
58
+ event
59
+ end
60
+
61
+ def stress_level
62
+ @stress
63
+ end
64
+
65
+ def domain_stress(domain)
66
+ unresolved = unresolved_events.select { |ev| ev.domain == domain }
67
+ return STRESS_FLOOR if unresolved.empty?
68
+
69
+ raw = unresolved.sum(&:magnitude) / MAX_DISSONANCE_EVENTS.to_f
70
+ raw.clamp(STRESS_FLOOR, STRESS_CEILING)
71
+ end
72
+
73
+ def unresolved_events
74
+ @events.values.reject(&:resolved)
75
+ end
76
+
77
+ def decay
78
+ unresolved = unresolved_events
79
+ if unresolved.any?
80
+ increment = DECAY_RATE * unresolved.size
81
+ @stress = (@stress + increment).clamp(STRESS_FLOOR, STRESS_CEILING)
82
+ else
83
+ @stress = (@stress - DECAY_RATE).clamp(STRESS_FLOOR, STRESS_CEILING)
84
+ end
85
+ @stress
86
+ end
87
+
88
+ def to_h
89
+ {
90
+ beliefs: @beliefs.values.map(&:to_h),
91
+ events: @events.values.map(&:to_h),
92
+ stress: @stress,
93
+ unresolved_count: unresolved_events.size,
94
+ total_beliefs: @beliefs.size,
95
+ total_events: @events.size
96
+ }
97
+ end
98
+
99
+ private
100
+
101
+ def detect_contradictions_for(new_belief)
102
+ new_events = []
103
+
104
+ @beliefs.each_value do |existing|
105
+ next if existing.id == new_belief.id
106
+ next unless new_belief.contradicts?(existing)
107
+ next if contradiction_tracked?(new_belief.id, existing.id)
108
+
109
+ ev = build_event(new_belief, existing)
110
+ new_events << ev
111
+ end
112
+
113
+ new_events
114
+ end
115
+
116
+ def build_event(bel_a, bel_b)
117
+ magnitude = compute_magnitude(bel_a, bel_b)
118
+ DissonanceEvent.new(
119
+ belief_a_id: bel_a.id,
120
+ belief_b_id: bel_b.id,
121
+ domain: bel_a.domain,
122
+ magnitude: magnitude,
123
+ contradiction_type: :direct
124
+ )
125
+ end
126
+
127
+ def compute_magnitude(bel_a, bel_b)
128
+ weight_a = IMPORTANCE_WEIGHTS.fetch(bel_a.importance, 0.5)
129
+ weight_b = IMPORTANCE_WEIGHTS.fetch(bel_b.importance, 0.5)
130
+ avg_weight = (weight_a + weight_b) / 2.0
131
+ avg_confidence = (bel_a.confidence + bel_b.confidence) / 2.0
132
+ (avg_weight * avg_confidence).clamp(0.0, 1.0)
133
+ end
134
+
135
+ def compute_relief(strategy)
136
+ base = RESOLUTION_RELIEF
137
+ strategy == :rationalization ? base * RATIONALIZATION_FACTOR : base
138
+ end
139
+
140
+ def contradiction_tracked?(id_a, id_b)
141
+ @events.values.any? do |ev|
142
+ (ev.belief_a_id == id_a && ev.belief_b_id == id_b) ||
143
+ (ev.belief_a_id == id_b && ev.belief_b_id == id_a)
144
+ end
145
+ end
146
+
147
+ def prune_beliefs
148
+ oldest = @beliefs.values.min_by(&:created_at)
149
+ @beliefs.delete(oldest.id) if oldest
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Dissonance
6
+ module Runners
7
+ module Dissonance
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def add_belief(domain:, content:, confidence: 0.7, importance: :moderate, **)
12
+ unless Helpers::Constants::IMPORTANCE_WEIGHTS.key?(importance)
13
+ return { success: false, error: :invalid_importance,
14
+ valid: Helpers::Constants::IMPORTANCE_WEIGHTS.keys }
15
+ end
16
+
17
+ result = dissonance_model.add_belief(domain: domain, content: content,
18
+ confidence: confidence, importance: importance)
19
+ belief = result[:belief]
20
+ new_evs = result[:new_dissonance_events]
21
+
22
+ Legion::Logging.debug "[dissonance] add_belief: id=#{belief.id[0..7]} domain=#{domain} importance=#{importance} new_events=#{new_evs.size}"
23
+
24
+ {
25
+ success: true,
26
+ belief_id: belief.id,
27
+ domain: domain,
28
+ new_dissonance_events: new_evs.map(&:to_h),
29
+ dissonance_triggered: new_evs.any? { |ev| ev.magnitude >= Helpers::Constants::DISSONANCE_THRESHOLD }
30
+ }
31
+ end
32
+
33
+ def update_dissonance(**)
34
+ dissonance_model.detect_contradictions
35
+ new_stress = dissonance_model.decay
36
+ unresolved = dissonance_model.unresolved_events
37
+
38
+ Legion::Logging.debug "[dissonance] update: stress=#{new_stress.round(3)} unresolved=#{unresolved.size}"
39
+
40
+ {
41
+ success: true,
42
+ stress: new_stress,
43
+ unresolved_count: unresolved.size,
44
+ above_threshold: unresolved.any? { |ev| ev.magnitude >= Helpers::Constants::DISSONANCE_THRESHOLD }
45
+ }
46
+ end
47
+
48
+ def resolve_dissonance(event_id:, strategy: :belief_revision, **)
49
+ unless Helpers::Constants::RESOLUTION_STRATEGIES.include?(strategy)
50
+ return { success: false, error: :invalid_strategy,
51
+ valid: Helpers::Constants::RESOLUTION_STRATEGIES }
52
+ end
53
+
54
+ event = dissonance_model.resolve(event_id, strategy: strategy)
55
+ if event
56
+ Legion::Logging.debug "[dissonance] resolved: id=#{event_id[0..7]} strategy=#{strategy}"
57
+ { success: true, resolved: true, strategy: strategy, event: event.to_h }
58
+ else
59
+ Legion::Logging.debug "[dissonance] resolve failed: id=#{event_id[0..7]} not_found_or_already_resolved"
60
+ { success: false, error: :not_found_or_already_resolved }
61
+ end
62
+ end
63
+
64
+ def dissonance_status(**)
65
+ model = dissonance_model
66
+ snapshot = model.to_h
67
+
68
+ Legion::Logging.debug "[dissonance] status: stress=#{snapshot[:stress].round(3)} " \
69
+ "beliefs=#{snapshot[:total_beliefs]} unresolved=#{snapshot[:unresolved_count]}"
70
+
71
+ {
72
+ success: true,
73
+ stress: snapshot[:stress],
74
+ total_beliefs: snapshot[:total_beliefs],
75
+ total_events: snapshot[:total_events],
76
+ unresolved_count: snapshot[:unresolved_count]
77
+ }
78
+ end
79
+
80
+ def domain_dissonance(domain:, **)
81
+ stress = dissonance_model.domain_stress(domain)
82
+ unresolved = dissonance_model.unresolved_events.select { |ev| ev.domain == domain }
83
+
84
+ Legion::Logging.debug "[dissonance] domain_dissonance: domain=#{domain} stress=#{stress.round(3)} unresolved=#{unresolved.size}"
85
+
86
+ {
87
+ success: true,
88
+ domain: domain,
89
+ stress: stress,
90
+ unresolved_count: unresolved.size,
91
+ events: unresolved.map(&:to_h)
92
+ }
93
+ end
94
+
95
+ def beliefs_for(domain:, **)
96
+ beliefs = dissonance_model.beliefs.values.select { |b| b.domain == domain }
97
+
98
+ Legion::Logging.debug "[dissonance] beliefs_for: domain=#{domain} count=#{beliefs.size}"
99
+
100
+ {
101
+ success: true,
102
+ domain: domain,
103
+ count: beliefs.size,
104
+ beliefs: beliefs.map(&:to_h)
105
+ }
106
+ end
107
+
108
+ def unresolved(**)
109
+ events = dissonance_model.unresolved_events
110
+
111
+ Legion::Logging.debug "[dissonance] unresolved: count=#{events.size}"
112
+
113
+ {
114
+ success: true,
115
+ count: events.size,
116
+ events: events.map(&:to_h)
117
+ }
118
+ end
119
+
120
+ def dissonance_stats(**)
121
+ model = dissonance_model
122
+ snapshot = model.to_h
123
+
124
+ domains = model.beliefs.values.map(&:domain).uniq
125
+ domain_stresses = domains.to_h { |d| [d, model.domain_stress(d)] }
126
+
127
+ unresolved = model.unresolved_events
128
+ resolved = model.events.values.select(&:resolved)
129
+
130
+ resolution_breakdown = Helpers::Constants::RESOLUTION_STRATEGIES.to_h do |s|
131
+ [s, resolved.count { |ev| ev.resolution_strategy == s }]
132
+ end
133
+
134
+ Legion::Logging.debug "[dissonance] stats: beliefs=#{snapshot[:total_beliefs]} " \
135
+ "events=#{snapshot[:total_events]} stress=#{snapshot[:stress].round(3)}"
136
+
137
+ {
138
+ success: true,
139
+ stress: snapshot[:stress],
140
+ total_beliefs: snapshot[:total_beliefs],
141
+ total_events: snapshot[:total_events],
142
+ unresolved_count: unresolved.size,
143
+ resolved_count: resolved.size,
144
+ domain_stresses: domain_stresses,
145
+ resolution_breakdown: resolution_breakdown,
146
+ above_threshold: unresolved.any? { |ev| ev.magnitude >= Helpers::Constants::DISSONANCE_THRESHOLD }
147
+ }
148
+ end
149
+
150
+ private
151
+
152
+ def dissonance_model
153
+ @dissonance_model ||= Helpers::DissonanceModel.new
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Dissonance
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/dissonance/version'
4
+ require 'legion/extensions/dissonance/helpers/constants'
5
+ require 'legion/extensions/dissonance/helpers/belief'
6
+ require 'legion/extensions/dissonance/helpers/dissonance_event'
7
+ require 'legion/extensions/dissonance/helpers/dissonance_model'
8
+ require 'legion/extensions/dissonance/runners/dissonance'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Dissonance
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/dissonance/client'
4
+
5
+ RSpec.describe Legion::Extensions::Dissonance::Client do
6
+ describe '#initialize' do
7
+ it 'creates a default DissonanceModel when none provided' do
8
+ client = described_class.new
9
+ expect(client.model).to be_a(Legion::Extensions::Dissonance::Helpers::DissonanceModel)
10
+ end
11
+
12
+ it 'accepts a custom model' do
13
+ custom_model = Legion::Extensions::Dissonance::Helpers::DissonanceModel.new
14
+ client = described_class.new(model: custom_model)
15
+ expect(client.model).to be(custom_model)
16
+ end
17
+ end
18
+
19
+ describe 'responds to all runner methods' do
20
+ let(:client) { described_class.new }
21
+
22
+ it { expect(client).to respond_to(:add_belief) }
23
+ it { expect(client).to respond_to(:update_dissonance) }
24
+ it { expect(client).to respond_to(:resolve_dissonance) }
25
+ it { expect(client).to respond_to(:dissonance_status) }
26
+ it { expect(client).to respond_to(:domain_dissonance) }
27
+ it { expect(client).to respond_to(:beliefs_for) }
28
+ it { expect(client).to respond_to(:unresolved) }
29
+ it { expect(client).to respond_to(:dissonance_stats) }
30
+ end
31
+
32
+ describe 'state isolation between instances' do
33
+ it 'two clients do not share belief state' do
34
+ client1 = described_class.new
35
+ client2 = described_class.new
36
+ client1.add_belief(domain: 'x', content: 'some belief')
37
+ result = client2.beliefs_for(domain: 'x')
38
+ expect(result[:count]).to eq(0)
39
+ end
40
+ end
41
+
42
+ describe 'custom model integration' do
43
+ it 'uses the beliefs already in a pre-populated model' do
44
+ model = Legion::Extensions::Dissonance::Helpers::DissonanceModel.new
45
+ model.add_belief(domain: 'shared', content: 'a shared belief')
46
+ client = described_class.new(model: model)
47
+ result = client.beliefs_for(domain: 'shared')
48
+ expect(result[:count]).to eq(1)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Dissonance::Helpers::Belief do
4
+ let(:belief) { described_class.new(domain: 'ethics', content: 'honesty is required', confidence: 0.9, importance: :core) }
5
+
6
+ describe '#initialize' do
7
+ it 'assigns a uuid id' do
8
+ expect(belief.id).to match(/\A[0-9a-f-]{36}\z/)
9
+ end
10
+
11
+ it 'assigns domain' do
12
+ expect(belief.domain).to eq('ethics')
13
+ end
14
+
15
+ it 'assigns content' do
16
+ expect(belief.content).to eq('honesty is required')
17
+ end
18
+
19
+ it 'assigns confidence' do
20
+ expect(belief.confidence).to eq(0.9)
21
+ end
22
+
23
+ it 'assigns importance' do
24
+ expect(belief.importance).to eq(:core)
25
+ end
26
+
27
+ it 'assigns created_at as UTC Time' do
28
+ expect(belief.created_at).to be_a(Time)
29
+ end
30
+
31
+ it 'clamps confidence to 0-1 range (above)' do
32
+ b = described_class.new(domain: 'd', content: 'c', confidence: 1.5)
33
+ expect(b.confidence).to eq(1.0)
34
+ end
35
+
36
+ it 'clamps confidence to 0-1 range (below)' do
37
+ b = described_class.new(domain: 'd', content: 'c', confidence: -0.5)
38
+ expect(b.confidence).to eq(0.0)
39
+ end
40
+
41
+ it 'defaults confidence to 0.7' do
42
+ b = described_class.new(domain: 'd', content: 'c')
43
+ expect(b.confidence).to eq(0.7)
44
+ end
45
+
46
+ it 'defaults importance to :moderate' do
47
+ b = described_class.new(domain: 'd', content: 'c')
48
+ expect(b.importance).to eq(:moderate)
49
+ end
50
+ end
51
+
52
+ describe '#contradicts?' do
53
+ let(:other_same) do
54
+ described_class.new(domain: 'ethics', content: 'honesty is required', confidence: 0.8, importance: :moderate)
55
+ end
56
+
57
+ let(:other_different) do
58
+ described_class.new(domain: 'ethics', content: 'deception is acceptable', confidence: 0.6, importance: :moderate)
59
+ end
60
+
61
+ let(:other_domain) do
62
+ described_class.new(domain: 'safety', content: 'honesty is required', confidence: 0.8, importance: :moderate)
63
+ end
64
+
65
+ it 'returns false for same content' do
66
+ expect(belief.contradicts?(other_same)).to be false
67
+ end
68
+
69
+ it 'returns true for different content in same domain' do
70
+ expect(belief.contradicts?(other_different)).to be true
71
+ end
72
+
73
+ it 'returns false for different domain' do
74
+ expect(belief.contradicts?(other_domain)).to be false
75
+ end
76
+
77
+ it 'returns false when compared to itself' do
78
+ expect(belief.contradicts?(belief)).to be false
79
+ end
80
+
81
+ it 'is case-insensitive for content comparison' do
82
+ upper = described_class.new(domain: 'ethics', content: 'HONESTY IS REQUIRED', confidence: 0.8, importance: :moderate)
83
+ expect(belief.contradicts?(upper)).to be false
84
+ end
85
+
86
+ it 'ignores leading/trailing whitespace in content comparison' do
87
+ padded = described_class.new(domain: 'ethics', content: ' honesty is required ', confidence: 0.8, importance: :moderate)
88
+ expect(belief.contradicts?(padded)).to be false
89
+ end
90
+ end
91
+
92
+ describe '#to_h' do
93
+ it 'returns a hash with all fields' do
94
+ h = belief.to_h
95
+ expect(h[:id]).to eq(belief.id)
96
+ expect(h[:domain]).to eq('ethics')
97
+ expect(h[:content]).to eq('honesty is required')
98
+ expect(h[:confidence]).to eq(0.9)
99
+ expect(h[:importance]).to eq(:core)
100
+ expect(h[:created_at]).to be_a(Time)
101
+ end
102
+ end
103
+ end