lex-habit 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: d0a865cd888c03af9bc77d51dc7ea7721fa49413288f568d8dd5e765a8dc0d2e
4
+ data.tar.gz: 24761157f9e83cfec300b8b0dccba1a1d92e50476793be7802ced4710d71e950
5
+ SHA512:
6
+ metadata.gz: f8f2f8170d0c98b3ae47937ddc8630c12ed373bd43bf15e2e3e5754c01ce23bb4b42c3ceaec8394fcf6beb58c8608d0ad14e65a5ac01a7097668c342d4b2e6b1
7
+ data.tar.gz: 6e49a7a3686f54b7b24d36d501fb4455b53f6d3c2efdacf5b7244f371c701967bc072b83f296b9ac96de895e929244d227c37662232542bb8c9f116709899ca6
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/habit/helpers/constants'
4
+ require 'legion/extensions/habit/helpers/action_sequence'
5
+ require 'legion/extensions/habit/helpers/habit_store'
6
+ require 'legion/extensions/habit/runners/habit'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Habit
11
+ class Client
12
+ include Runners::Habit
13
+
14
+ attr_reader :habit_store
15
+
16
+ def initialize(habit_store: nil, **)
17
+ @habit_store = habit_store || Helpers::HabitStore.new
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Habit
8
+ module Helpers
9
+ class ActionSequence
10
+ include Constants
11
+
12
+ attr_reader :id, :actions, :context, :execution_count, :success_count,
13
+ :strength, :maturity, :last_executed, :created_at
14
+
15
+ def initialize(actions:, context: {})
16
+ @id = SecureRandom.uuid
17
+ @actions = actions.map(&:to_sym)
18
+ @context = context
19
+ @execution_count = 0
20
+ @success_count = 0
21
+ @strength = 0.3
22
+ @maturity = :novel
23
+ @last_executed = nil
24
+ @created_at = Time.now.utc
25
+ end
26
+
27
+ def record_execution(success:)
28
+ @execution_count += 1
29
+ @success_count += 1 if success
30
+ @last_executed = Time.now.utc
31
+
32
+ @strength = if success
33
+ [@strength + Constants::REINFORCEMENT_RATE, 1.0].min
34
+ else
35
+ [@strength - Constants::REINFORCEMENT_RATE, 0.0].max
36
+ end
37
+
38
+ update_maturity
39
+ end
40
+
41
+ def cognitive_cost
42
+ Constants::COGNITIVE_COST[@maturity]
43
+ end
44
+
45
+ def success_rate
46
+ return 0.0 if @execution_count.zero?
47
+
48
+ @success_count.to_f / @execution_count
49
+ end
50
+
51
+ def matches_context?(ctx)
52
+ return true if ctx.empty?
53
+
54
+ relevant = Constants::CONTEXT_DIMENSIONS.select { |d| @context.key?(d) || ctx.key?(d) }
55
+ return true if relevant.empty?
56
+
57
+ matches = relevant.count { |d| @context[d] == ctx[d] }
58
+ matches.to_f / relevant.size >= 0.5
59
+ end
60
+
61
+ def decay
62
+ @strength -= Constants::DECAY_RATE
63
+ @strength >= Constants::HABIT_STRENGTH_FLOOR
64
+ end
65
+
66
+ def mature?
67
+ @maturity == :habitual || @maturity == :automatic
68
+ end
69
+
70
+ def stale?(threshold = 3600)
71
+ return true if @last_executed.nil?
72
+
73
+ (Time.now.utc - @last_executed) > threshold
74
+ end
75
+
76
+ def similarity(other)
77
+ set_a = @actions.uniq
78
+ set_b = other.actions.uniq
79
+ return 0.0 if set_a.empty? && set_b.empty?
80
+
81
+ intersection = (set_a & set_b).size
82
+ union = (set_a | set_b).size
83
+ return 0.0 if union.zero?
84
+
85
+ intersection.to_f / union
86
+ end
87
+
88
+ def to_h
89
+ {
90
+ id: @id,
91
+ actions: @actions,
92
+ context: @context,
93
+ execution_count: @execution_count,
94
+ success_count: @success_count,
95
+ strength: @strength,
96
+ maturity: @maturity,
97
+ cognitive_cost: cognitive_cost,
98
+ success_rate: success_rate,
99
+ last_executed: @last_executed,
100
+ created_at: @created_at
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def update_maturity
107
+ new_stage = Constants::MATURITY_STAGES.reverse.find do |stage|
108
+ @execution_count >= Constants::MATURITY_THRESHOLDS[stage]
109
+ end
110
+ @maturity = new_stage || :novel
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Habit
6
+ module Helpers
7
+ module Constants
8
+ MATURITY_STAGES = %i[novel learning practiced habitual automatic].freeze
9
+
10
+ MATURITY_THRESHOLDS = {
11
+ novel: 0,
12
+ learning: 3,
13
+ practiced: 10,
14
+ habitual: 25,
15
+ automatic: 50
16
+ }.freeze
17
+
18
+ COGNITIVE_COST = {
19
+ novel: 1.0,
20
+ learning: 0.8,
21
+ practiced: 0.5,
22
+ habitual: 0.2,
23
+ automatic: 0.05
24
+ }.freeze
25
+
26
+ REINFORCEMENT_RATE = 0.1
27
+ DECAY_RATE = 0.02
28
+ MIN_SEQUENCE_LENGTH = 2
29
+ MAX_SEQUENCE_LENGTH = 10
30
+ MAX_HABITS = 200
31
+ SIMILARITY_THRESHOLD = 0.7
32
+ CHUNKING_THRESHOLD = 5
33
+ HABIT_STRENGTH_FLOOR = 0.1
34
+
35
+ CONTEXT_DIMENSIONS = %i[domain mood time_of_day trigger].freeze
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Habit
6
+ module Helpers
7
+ class HabitStore # rubocop:disable Metrics/ClassLength
8
+ include Constants
9
+
10
+ MAX_BUFFER_SIZE = 50
11
+
12
+ attr_reader :habits, :action_buffer
13
+
14
+ def initialize
15
+ @habits = {}
16
+ @action_buffer = []
17
+ end
18
+
19
+ def record_action(action, context: {})
20
+ @action_buffer << { action: action.to_sym, context: context }
21
+ @action_buffer.shift if @action_buffer.size > MAX_BUFFER_SIZE
22
+ end
23
+
24
+ def detect_patterns
25
+ new_habits = []
26
+ actions = @action_buffer.map { |e| e[:action] }
27
+ ctx = @action_buffer.last&.dig(:context) || {}
28
+
29
+ (Constants::MIN_SEQUENCE_LENGTH..Constants::MAX_SEQUENCE_LENGTH).each do |len|
30
+ next if actions.size < len
31
+
32
+ process_length(len, actions, ctx, new_habits)
33
+ end
34
+
35
+ new_habits
36
+ end
37
+
38
+ def find_matching(context: {})
39
+ @habits.values
40
+ .select { |h| h.matches_context?(context) }
41
+ .sort_by { |h| -h.strength }
42
+ end
43
+
44
+ def get(id)
45
+ @habits[id]
46
+ end
47
+
48
+ def reinforce(id, success:)
49
+ habit = @habits[id]
50
+ return unless habit
51
+
52
+ habit.record_execution(success: success)
53
+ end
54
+
55
+ def decay_all
56
+ removed = 0
57
+ @habits.each do |id, habit|
58
+ unless habit.decay
59
+ @habits.delete(id)
60
+ removed += 1
61
+ end
62
+ end
63
+ removed
64
+ end
65
+
66
+ def merge_similar
67
+ merged = 0
68
+ @habits.keys.combination(2).each do |id_a, id_b|
69
+ merged += 1 if merge_pair(id_a, id_b)
70
+ end
71
+ merged
72
+ end
73
+
74
+ def by_maturity(stage)
75
+ @habits.values.select { |h| h.maturity == stage }
76
+ end
77
+
78
+ def stats
79
+ habits_list = @habits.values
80
+ per_maturity = Constants::MATURITY_STAGES.to_h { |s| [s, 0] }
81
+ habits_list.each { |h| per_maturity[h.maturity] += 1 }
82
+ avg_strength = habits_list.empty? ? 0.0 : habits_list.sum(&:strength) / habits_list.size
83
+ oldest = habits_list.min_by(&:created_at)
84
+ { total: habits_list.size, per_maturity: per_maturity, avg_strength: avg_strength, oldest: oldest&.to_h }
85
+ end
86
+
87
+ def evict_if_needed
88
+ return unless @habits.size > Constants::MAX_HABITS
89
+
90
+ weakest_id = @habits.min_by { |_id, h| h.strength }.first
91
+ @habits.delete(weakest_id)
92
+ end
93
+
94
+ private
95
+
96
+ def process_length(len, actions, ctx, new_habits)
97
+ count_subsequences(actions, len).each do |subseq, count|
98
+ next if count < Constants::CHUNKING_THRESHOLD
99
+
100
+ record_or_reinforce(subseq, count, ctx, new_habits)
101
+ end
102
+ end
103
+
104
+ def count_subsequences(actions, len)
105
+ counts = Hash.new(0)
106
+ (0..(actions.size - len)).each { |i| counts[actions[i, len]] += 1 }
107
+ counts
108
+ end
109
+
110
+ def record_or_reinforce(subseq, count, ctx, new_habits)
111
+ existing = find_existing_sequence(subseq)
112
+ if existing
113
+ existing.record_execution(success: true)
114
+ else
115
+ create_habit(subseq, count, ctx, new_habits)
116
+ end
117
+ end
118
+
119
+ def create_habit(subseq, count, ctx, new_habits)
120
+ habit = ActionSequence.new(actions: subseq, context: ctx)
121
+ (count - 1).times { habit.record_execution(success: true) }
122
+ @habits[habit.id] = habit
123
+ evict_if_needed
124
+ new_habits << habit
125
+ end
126
+
127
+ def merge_pair(id_a, id_b)
128
+ habit_a = @habits[id_a]
129
+ habit_b = @habits[id_b]
130
+ return false unless habit_a && habit_b
131
+ return false if habit_a.similarity(habit_b) < Constants::SIMILARITY_THRESHOLD
132
+
133
+ @habits.delete(habit_a.strength >= habit_b.strength ? id_b : id_a)
134
+ true
135
+ end
136
+
137
+ def find_existing_sequence(actions)
138
+ @habits.values.find { |h| h.actions == actions.map(&:to_sym) }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Habit
6
+ module Runners
7
+ module Habit
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def observe_action(action:, context: {}, **)
12
+ Legion::Logging.debug "[habit] observe_action: action=#{action} context=#{context}"
13
+ habit_store.record_action(action, context: context)
14
+ detected = habit_store.detect_patterns
15
+ Legion::Logging.info "[habit] patterns detected: #{detected.size}" unless detected.empty?
16
+ {
17
+ recorded: true,
18
+ action: action,
19
+ new_habits_detected: detected.size,
20
+ habits: detected.map(&:to_h)
21
+ }
22
+ end
23
+
24
+ def suggest_habit(context: {}, **)
25
+ matches = habit_store.find_matching(context: context)
26
+ Legion::Logging.debug "[habit] suggest_habit: context=#{context} matches=#{matches.size}"
27
+ if matches.empty?
28
+ { suggestion: nil, reason: :no_matching_habits }
29
+ else
30
+ best = matches.first
31
+ {
32
+ suggestion: best.to_h,
33
+ cognitive_savings: 1.0 - best.cognitive_cost,
34
+ alternatives: matches[1..2]&.map(&:to_h) || []
35
+ }
36
+ end
37
+ end
38
+
39
+ def execute_habit(id:, success: true, **)
40
+ habit = habit_store.get(id)
41
+ return { error: :not_found } unless habit
42
+
43
+ habit.record_execution(success: success)
44
+ Legion::Logging.debug "[habit] execute_habit: id=#{id} success=#{success} maturity=#{habit.maturity}"
45
+ { executed: true, habit: habit.to_h, cognitive_cost: habit.cognitive_cost }
46
+ end
47
+
48
+ def decay_habits(**)
49
+ removed = habit_store.decay_all
50
+ Legion::Logging.debug "[habit] decay_habits: removed=#{removed}"
51
+ { decayed: true, removed_count: removed }
52
+ end
53
+
54
+ def merge_habits(**)
55
+ merged = habit_store.merge_similar
56
+ Legion::Logging.debug "[habit] merge_habits: merged=#{merged}"
57
+ { merged_count: merged }
58
+ end
59
+
60
+ def habit_stats(**)
61
+ habit_store.stats
62
+ end
63
+
64
+ def habit_repertoire(maturity: nil, limit: 20, **)
65
+ habits = maturity ? habit_store.by_maturity(maturity.to_sym) : habit_store.habits.values
66
+ Legion::Logging.debug "[habit] habit_repertoire: maturity=#{maturity} total=#{habits.size}"
67
+ {
68
+ habits: habits.sort_by { |h| -h.strength }.first(limit).map(&:to_h),
69
+ total: habits.size
70
+ }
71
+ end
72
+
73
+ private
74
+
75
+ def habit_store
76
+ @habit_store ||= Helpers::HabitStore.new
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Habit
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/habit/version'
4
+ require 'legion/extensions/habit/helpers/constants'
5
+ require 'legion/extensions/habit/helpers/action_sequence'
6
+ require 'legion/extensions/habit/helpers/habit_store'
7
+ require 'legion/extensions/habit/runners/habit'
8
+ require 'legion/extensions/habit/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Habit
13
+ extend Legion::Extensions::Core if defined?(Legion::Extensions::Core)
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-habit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Iverson
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: Procedural learning and skill acquisition for brain-modeled agentic AI
27
+ — repeated action sequences become chunked habits with decreasing cognitive overhead
28
+ email:
29
+ - matt@legionIO.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/legion/extensions/habit.rb
35
+ - lib/legion/extensions/habit/client.rb
36
+ - lib/legion/extensions/habit/helpers/action_sequence.rb
37
+ - lib/legion/extensions/habit/helpers/constants.rb
38
+ - lib/legion/extensions/habit/helpers/habit_store.rb
39
+ - lib/legion/extensions/habit/runners/habit.rb
40
+ - lib/legion/extensions/habit/version.rb
41
+ homepage: https://github.com/LegionIO/lex-habit
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://github.com/LegionIO/lex-habit
46
+ source_code_uri: https://github.com/LegionIO/lex-habit
47
+ documentation_uri: https://github.com/LegionIO/lex-habit
48
+ changelog_uri: https://github.com/LegionIO/lex-habit
49
+ bug_tracker_uri: https://github.com/LegionIO/lex-habit/issues
50
+ rubygems_mfa_required: 'true'
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.4'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.6.9
66
+ specification_version: 4
67
+ summary: LEX Habit
68
+ test_files: []