lex-procedural-learning 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: beadefed7bdfbf63394e91a805c43d2fde712ba5f95ec1781f48999e1fb2a0f2
4
+ data.tar.gz: a5627d0dce87ba20527e1251d2564de55008d2f12b7a378da49f338bcf86ccf3
5
+ SHA512:
6
+ metadata.gz: de643f276210c3abaedf90f75df2a1b12fff7aaf9a49dbcf89e6a8456ff6c31fd87b4aba8321b8df2e6e4dcb3555e121074eecf367bbbed1c26f5672872e2333
7
+ data.tar.gz: 062b9255719b45a29a57ba1c0b78a49aa3714f882a89e9908b0a70b32ad8a465c658a22dc71c9fcae7a920a68ce076c72cf88278aa0f8c32eb879055037e7d90
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem 'rspec', '~> 3.13'
9
+ gem 'rubocop', '~> 1.75'
10
+ gem 'rubocop-rspec'
11
+ end
12
+
13
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/procedural_learning/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-procedural-learning'
7
+ spec.version = Legion::Extensions::ProceduralLearning::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Procedural Learning'
12
+ spec.description = "Anderson's ACT-R production rules — skill acquisition from declarative " \
13
+ 'to autonomous for brain-modeled agentic AI'
14
+ spec.homepage = 'https://github.com/LegionIO/lex-procedural-learning'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.4'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-procedural-learning'
20
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-procedural-learning'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-procedural-learning'
22
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-procedural-learning/issues'
23
+ spec.metadata['rubygems_mfa_required'] = 'true'
24
+
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ Dir.glob('{lib,spec}/**/*') + %w[lex-procedural-learning.gemspec Gemfile]
27
+ end
28
+ spec.require_paths = ['lib']
29
+ spec.add_development_dependency 'legion-gaia'
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProceduralLearning
6
+ class Client
7
+ include Runners::ProceduralLearning
8
+
9
+ def initialize(engine: nil)
10
+ @engine = engine || Helpers::LearningEngine.new
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProceduralLearning
6
+ module Helpers
7
+ module Constants
8
+ MAX_SKILLS = 200
9
+ MAX_PRODUCTIONS = 500
10
+ MAX_HISTORY = 300
11
+
12
+ DEFAULT_PROFICIENCY = 0.1
13
+ PROFICIENCY_FLOOR = 0.0
14
+ PROFICIENCY_CEILING = 1.0
15
+
16
+ PRACTICE_GAIN = 0.08
17
+ COMPILATION_THRESHOLD = 0.6
18
+ AUTOMATION_THRESHOLD = 0.85
19
+ DECAY_RATE = 0.01
20
+ STALE_THRESHOLD = 300
21
+
22
+ SKILL_STAGES = %i[declarative associative autonomous].freeze
23
+
24
+ STAGE_LABELS = {
25
+ (0.0...0.3) => :declarative,
26
+ (0.3...0.6) => :associative,
27
+ (0.6...0.85) => :compiled,
28
+ (0.85..1.0) => :autonomous
29
+ }.freeze
30
+
31
+ PROFICIENCY_LABELS = {
32
+ (0.8..) => :expert,
33
+ (0.6...0.8) => :proficient,
34
+ (0.4...0.6) => :intermediate,
35
+ (0.2...0.4) => :beginner,
36
+ (..0.2) => :novice
37
+ }.freeze
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProceduralLearning
6
+ module Helpers
7
+ class LearningEngine
8
+ include Constants
9
+
10
+ attr_reader :history
11
+
12
+ def initialize
13
+ @skills = {}
14
+ @productions = {}
15
+ @history = []
16
+ end
17
+
18
+ def create_skill(name:, domain:)
19
+ evict_oldest_skill if @skills.size >= MAX_SKILLS
20
+
21
+ skill = Skill.new(name: name, domain: domain)
22
+ @skills[skill.id] = skill
23
+ record_history(:skill_created, skill.id)
24
+ skill
25
+ end
26
+
27
+ def add_production(skill_id:, condition:, action:, domain:)
28
+ skill = @skills[skill_id]
29
+ return { success: false, reason: :skill_not_found } unless skill
30
+ return { success: false, reason: :max_productions } if @productions.size >= MAX_PRODUCTIONS
31
+
32
+ production = Production.new(
33
+ condition: condition, action: action,
34
+ domain: domain, skill_id: skill_id
35
+ )
36
+ @productions[production.id] = production
37
+ skill.add_production(production.id)
38
+ record_history(:production_added, production.id)
39
+ production
40
+ end
41
+
42
+ def practice_skill(skill_id:, success:)
43
+ skill = @skills[skill_id]
44
+ return { success: false, reason: :not_found } unless skill
45
+
46
+ skill.practice!(success: success)
47
+ record_history(:practiced, skill_id)
48
+ build_practice_result(skill)
49
+ end
50
+
51
+ def execute_production(production_id:, success:)
52
+ production = @productions[production_id]
53
+ return { success: false, reason: :not_found } unless production
54
+
55
+ production.execute!(success: success)
56
+ record_history(:production_executed, production_id)
57
+ { success: true, production_id: production_id, success_rate: production.success_rate }
58
+ end
59
+
60
+ def skill_assessment(skill_id:)
61
+ skill = @skills[skill_id]
62
+ return { success: false, reason: :not_found } unless skill
63
+
64
+ productions = skill.productions.filter_map { |pid| @productions[pid] }
65
+ build_assessment(skill, productions)
66
+ end
67
+
68
+ def compiled_skills
69
+ @skills.values.select(&:compiled?)
70
+ end
71
+
72
+ def autonomous_skills
73
+ @skills.values.select(&:autonomous?)
74
+ end
75
+
76
+ def by_domain(domain:)
77
+ @skills.values.select { |s| s.domain == domain }
78
+ end
79
+
80
+ def most_practiced(limit: 5)
81
+ @skills.values.sort_by { |s| -s.practice_count }.first(limit)
82
+ end
83
+
84
+ def decay_all
85
+ @skills.each_value(&:decay!)
86
+ end
87
+
88
+ def prune_stale
89
+ stale_ids = @skills.select { |_id, s| s.proficiency <= 0.02 }.keys
90
+ stale_ids.each do |sid|
91
+ remove_skill_productions(sid)
92
+ @skills.delete(sid)
93
+ end
94
+ stale_ids.size
95
+ end
96
+
97
+ def to_h
98
+ {
99
+ total_skills: @skills.size,
100
+ total_productions: @productions.size,
101
+ compiled_count: compiled_skills.size,
102
+ autonomous_count: autonomous_skills.size,
103
+ history_count: @history.size,
104
+ stage_counts: stage_counts
105
+ }
106
+ end
107
+
108
+ private
109
+
110
+ def build_practice_result(skill)
111
+ {
112
+ success: true,
113
+ skill_id: skill.id,
114
+ proficiency: skill.proficiency,
115
+ stage: skill.stage,
116
+ stage_label: skill.stage_label
117
+ }
118
+ end
119
+
120
+ def build_assessment(skill, productions)
121
+ {
122
+ success: true,
123
+ skill: skill.to_h,
124
+ productions: productions.map(&:to_h),
125
+ reliable_count: productions.count(&:reliable?),
126
+ total_executions: productions.sum(&:execution_count)
127
+ }
128
+ end
129
+
130
+ def remove_skill_productions(skill_id)
131
+ @productions.delete_if { |_id, prod| prod.skill_id == skill_id }
132
+ end
133
+
134
+ def evict_oldest_skill
135
+ oldest_id = @skills.min_by { |_id, s| s.last_practiced_at }&.first
136
+ return unless oldest_id
137
+
138
+ remove_skill_productions(oldest_id)
139
+ @skills.delete(oldest_id)
140
+ end
141
+
142
+ def record_history(event, subject_id)
143
+ @history << { event: event, subject_id: subject_id, at: Time.now.utc }
144
+ @history.shift while @history.size > MAX_HISTORY
145
+ end
146
+
147
+ def stage_counts
148
+ @skills.values.each_with_object(Hash.new(0)) do |skill, counts|
149
+ counts[skill.stage] += 1
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module ProceduralLearning
8
+ module Helpers
9
+ class Production
10
+ include Constants
11
+
12
+ attr_reader :id, :condition, :action, :domain, :skill_id,
13
+ :execution_count, :success_count, :created_at, :last_executed_at
14
+
15
+ def initialize(condition:, action:, domain:, skill_id:)
16
+ @id = SecureRandom.uuid
17
+ @condition = condition
18
+ @action = action
19
+ @domain = domain
20
+ @skill_id = skill_id
21
+ @execution_count = 0
22
+ @success_count = 0
23
+ @created_at = Time.now.utc
24
+ @last_executed_at = @created_at
25
+ end
26
+
27
+ def execute!(success:)
28
+ @execution_count += 1
29
+ @success_count += 1 if success
30
+ @last_executed_at = Time.now.utc
31
+ end
32
+
33
+ def success_rate
34
+ return 0.0 if @execution_count.zero?
35
+
36
+ @success_count.to_f / @execution_count
37
+ end
38
+
39
+ def reliable?
40
+ success_rate >= 0.7 && @execution_count >= 3
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ id: @id,
46
+ condition: @condition,
47
+ action: @action,
48
+ domain: @domain,
49
+ skill_id: @skill_id,
50
+ execution_count: @execution_count,
51
+ success_count: @success_count,
52
+ success_rate: success_rate,
53
+ reliable: reliable?,
54
+ created_at: @created_at,
55
+ last_executed_at: @last_executed_at
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module ProceduralLearning
8
+ module Helpers
9
+ class Skill
10
+ include Constants
11
+
12
+ attr_reader :id, :name, :domain, :proficiency, :practice_count,
13
+ :stage, :productions, :created_at, :last_practiced_at
14
+
15
+ def initialize(name:, domain:)
16
+ @id = SecureRandom.uuid
17
+ @name = name
18
+ @domain = domain
19
+ @proficiency = DEFAULT_PROFICIENCY
20
+ @practice_count = 0
21
+ @stage = :declarative
22
+ @productions = []
23
+ @created_at = Time.now.utc
24
+ @last_practiced_at = @created_at
25
+ end
26
+
27
+ def practice!(success:)
28
+ @practice_count += 1
29
+ @last_practiced_at = Time.now.utc
30
+ gain = success ? PRACTICE_GAIN : PRACTICE_GAIN * 0.3
31
+ @proficiency = (@proficiency + gain).clamp(PROFICIENCY_FLOOR, PROFICIENCY_CEILING)
32
+ update_stage
33
+ end
34
+
35
+ def add_production(production_id)
36
+ @productions << production_id unless @productions.include?(production_id)
37
+ end
38
+
39
+ def compiled?
40
+ @proficiency >= COMPILATION_THRESHOLD
41
+ end
42
+
43
+ def autonomous?
44
+ @proficiency >= AUTOMATION_THRESHOLD
45
+ end
46
+
47
+ def stage_label
48
+ STAGE_LABELS.find { |range, _| range.cover?(@proficiency) }&.last || :declarative
49
+ end
50
+
51
+ def proficiency_label
52
+ PROFICIENCY_LABELS.find { |range, _| range.cover?(@proficiency) }&.last || :novice
53
+ end
54
+
55
+ def decay!
56
+ @proficiency = (@proficiency - DECAY_RATE).clamp(PROFICIENCY_FLOOR, PROFICIENCY_CEILING)
57
+ update_stage
58
+ end
59
+
60
+ def stale?
61
+ (Time.now.utc - @last_practiced_at) > STALE_THRESHOLD
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ id: @id,
67
+ name: @name,
68
+ domain: @domain,
69
+ proficiency: @proficiency,
70
+ proficiency_label: proficiency_label,
71
+ stage: @stage,
72
+ stage_label: stage_label,
73
+ practice_count: @practice_count,
74
+ production_count: @productions.size,
75
+ compiled: compiled?,
76
+ autonomous: autonomous?,
77
+ created_at: @created_at,
78
+ last_practiced_at: @last_practiced_at
79
+ }
80
+ end
81
+
82
+ private
83
+
84
+ def update_stage
85
+ @stage = if autonomous?
86
+ :autonomous
87
+ elsif compiled?
88
+ :associative
89
+ else
90
+ :declarative
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProceduralLearning
6
+ module Runners
7
+ module ProceduralLearning
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_skill(name:, domain:, **)
12
+ skill = engine.create_skill(name: name, domain: domain)
13
+ Legion::Logging.debug "[procedural_learning] created skill=#{name} " \
14
+ "domain=#{domain} id=#{skill.id[0..7]}"
15
+ { success: true, skill_id: skill.id, name: name, domain: domain,
16
+ proficiency: skill.proficiency, stage: skill.stage }
17
+ end
18
+
19
+ def add_skill_production(skill_id:, condition:, action:, domain:, **)
20
+ result = engine.add_production(
21
+ skill_id: skill_id, condition: condition,
22
+ action: action, domain: domain
23
+ )
24
+
25
+ return result unless result.is_a?(Helpers::Production)
26
+
27
+ Legion::Logging.debug '[procedural_learning] production added ' \
28
+ "skill=#{skill_id[0..7]} condition=#{condition}"
29
+ { success: true, production_id: result.id, skill_id: skill_id }
30
+ end
31
+
32
+ def practice_skill(skill_id:, success:, **)
33
+ result = engine.practice_skill(skill_id: skill_id, success: success)
34
+ Legion::Logging.debug "[procedural_learning] practice skill=#{skill_id[0..7]} " \
35
+ "success=#{success} proficiency=#{result[:proficiency]&.round(3)}"
36
+ result
37
+ end
38
+
39
+ def execute_production(production_id:, success:, **)
40
+ result = engine.execute_production(production_id: production_id, success: success)
41
+ Legion::Logging.debug "[procedural_learning] execute production=#{production_id[0..7]} " \
42
+ "success=#{success}"
43
+ result
44
+ end
45
+
46
+ def skill_assessment(skill_id:, **)
47
+ result = engine.skill_assessment(skill_id: skill_id)
48
+ Legion::Logging.debug "[procedural_learning] assessment skill=#{skill_id[0..7]}"
49
+ result
50
+ end
51
+
52
+ def compiled_skills(**)
53
+ skills = engine.compiled_skills
54
+ Legion::Logging.debug "[procedural_learning] compiled count=#{skills.size}"
55
+ { success: true, skills: skills.map(&:to_h), count: skills.size }
56
+ end
57
+
58
+ def autonomous_skills(**)
59
+ skills = engine.autonomous_skills
60
+ Legion::Logging.debug "[procedural_learning] autonomous count=#{skills.size}"
61
+ { success: true, skills: skills.map(&:to_h), count: skills.size }
62
+ end
63
+
64
+ def most_practiced_skills(limit: 5, **)
65
+ skills = engine.most_practiced(limit: limit)
66
+ Legion::Logging.debug "[procedural_learning] most_practiced limit=#{limit}"
67
+ { success: true, skills: skills.map(&:to_h), count: skills.size }
68
+ end
69
+
70
+ def update_procedural_learning(**)
71
+ engine.decay_all
72
+ pruned = engine.prune_stale
73
+ Legion::Logging.debug "[procedural_learning] decay+prune pruned=#{pruned}"
74
+ { success: true, pruned: pruned }
75
+ end
76
+
77
+ def procedural_learning_stats(**)
78
+ stats = engine.to_h
79
+ Legion::Logging.debug "[procedural_learning] stats total=#{stats[:total_skills]}"
80
+ { success: true }.merge(stats)
81
+ end
82
+
83
+ private
84
+
85
+ def engine
86
+ @engine ||= Helpers::LearningEngine.new
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProceduralLearning
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/procedural_learning/version'
4
+ require 'legion/extensions/procedural_learning/helpers/constants'
5
+ require 'legion/extensions/procedural_learning/helpers/production'
6
+ require 'legion/extensions/procedural_learning/helpers/skill'
7
+ require 'legion/extensions/procedural_learning/helpers/learning_engine'
8
+ require 'legion/extensions/procedural_learning/runners/procedural_learning'
9
+ require 'legion/extensions/procedural_learning/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module ProceduralLearning
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProceduralLearning::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'creates a skill' do
7
+ result = client.create_skill(name: 'test', domain: :test)
8
+ expect(result[:success]).to be true
9
+ end
10
+
11
+ it 'practices and compiles a skill' do
12
+ created = client.create_skill(name: 'test', domain: :test)
13
+ 8.times { client.practice_skill(skill_id: created[:skill_id], success: true) }
14
+ compiled = client.compiled_skills
15
+ expect(compiled[:count]).to eq(1)
16
+ end
17
+
18
+ it 'returns stats' do
19
+ result = client.procedural_learning_stats
20
+ expect(result[:success]).to be true
21
+ end
22
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProceduralLearning::Helpers::LearningEngine do
4
+ subject(:engine) { described_class.new }
5
+
6
+ let(:skill) { engine.create_skill(name: 'api_retry', domain: :http) }
7
+
8
+ describe '#create_skill' do
9
+ it 'creates and returns a skill' do
10
+ result = skill
11
+ expect(result).to be_a(Legion::Extensions::ProceduralLearning::Helpers::Skill)
12
+ expect(result.name).to eq('api_retry')
13
+ end
14
+
15
+ it 'records history' do
16
+ skill
17
+ expect(engine.history.size).to eq(1)
18
+ end
19
+ end
20
+
21
+ describe '#add_production' do
22
+ it 'creates a production for a skill' do
23
+ result = engine.add_production(
24
+ skill_id: skill.id, condition: 'if_error',
25
+ action: 'retry', domain: :http
26
+ )
27
+ expect(result).to be_a(Legion::Extensions::ProceduralLearning::Helpers::Production)
28
+ end
29
+
30
+ it 'returns error for unknown skill' do
31
+ result = engine.add_production(
32
+ skill_id: 'bad', condition: 'test',
33
+ action: 'test', domain: :test
34
+ )
35
+ expect(result[:success]).to be false
36
+ end
37
+ end
38
+
39
+ describe '#practice_skill' do
40
+ it 'increases proficiency' do
41
+ result = engine.practice_skill(skill_id: skill.id, success: true)
42
+ expect(result[:success]).to be true
43
+ expect(result[:proficiency]).to be > 0.1
44
+ end
45
+
46
+ it 'returns error for unknown skill' do
47
+ result = engine.practice_skill(skill_id: 'bad', success: true)
48
+ expect(result[:success]).to be false
49
+ end
50
+ end
51
+
52
+ describe '#execute_production' do
53
+ it 'executes and records outcome' do
54
+ prod = engine.add_production(
55
+ skill_id: skill.id, condition: 'test',
56
+ action: 'act', domain: :test
57
+ )
58
+ result = engine.execute_production(production_id: prod.id, success: true)
59
+ expect(result[:success]).to be true
60
+ expect(result[:success_rate]).to eq(1.0)
61
+ end
62
+ end
63
+
64
+ describe '#skill_assessment' do
65
+ it 'returns assessment with productions' do
66
+ prod = engine.add_production(
67
+ skill_id: skill.id, condition: 'test',
68
+ action: 'act', domain: :test
69
+ )
70
+ engine.execute_production(production_id: prod.id, success: true)
71
+ result = engine.skill_assessment(skill_id: skill.id)
72
+ expect(result[:success]).to be true
73
+ expect(result[:productions].size).to eq(1)
74
+ end
75
+ end
76
+
77
+ describe '#compiled_skills' do
78
+ it 'returns skills above compilation threshold' do
79
+ 8.times { engine.practice_skill(skill_id: skill.id, success: true) }
80
+ expect(engine.compiled_skills.size).to eq(1)
81
+ end
82
+ end
83
+
84
+ describe '#autonomous_skills' do
85
+ it 'returns skills above automation threshold' do
86
+ 12.times { engine.practice_skill(skill_id: skill.id, success: true) }
87
+ expect(engine.autonomous_skills.size).to eq(1)
88
+ end
89
+ end
90
+
91
+ describe '#by_domain' do
92
+ it 'filters by domain' do
93
+ skill
94
+ engine.create_skill(name: 'other', domain: :dns)
95
+ expect(engine.by_domain(domain: :http).size).to eq(1)
96
+ end
97
+ end
98
+
99
+ describe '#most_practiced' do
100
+ it 'returns skills sorted by practice count' do
101
+ 3.times { engine.practice_skill(skill_id: skill.id, success: true) }
102
+ other = engine.create_skill(name: 'other', domain: :dns)
103
+ engine.practice_skill(skill_id: other.id, success: true)
104
+ results = engine.most_practiced(limit: 2)
105
+ expect(results.first.practice_count).to be >= results.last.practice_count
106
+ end
107
+ end
108
+
109
+ describe '#decay_all' do
110
+ it 'reduces proficiency of all skills' do
111
+ 5.times { engine.practice_skill(skill_id: skill.id, success: true) }
112
+ original = skill.proficiency
113
+ engine.decay_all
114
+ expect(skill.proficiency).to be < original
115
+ end
116
+ end
117
+
118
+ describe '#prune_stale' do
119
+ it 'removes skills with near-zero proficiency' do
120
+ skill
121
+ 50.times { skill.decay! }
122
+ pruned = engine.prune_stale
123
+ expect(pruned).to be >= 0
124
+ end
125
+ end
126
+
127
+ describe '#to_h' do
128
+ it 'returns summary stats' do
129
+ skill
130
+ stats = engine.to_h
131
+ expect(stats[:total_skills]).to eq(1)
132
+ expect(stats).to include(:compiled_count, :autonomous_count, :stage_counts)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProceduralLearning::Helpers::Production do
4
+ subject(:production) do
5
+ described_class.new(condition: 'if_error', action: 'retry_request', domain: :http, skill_id: 'skill-123')
6
+ end
7
+
8
+ describe '#initialize' do
9
+ it 'assigns a UUID' do
10
+ expect(production.id).to match(/\A[0-9a-f-]{36}\z/)
11
+ end
12
+
13
+ it 'stores condition and action' do
14
+ expect(production.condition).to eq('if_error')
15
+ expect(production.action).to eq('retry_request')
16
+ end
17
+
18
+ it 'starts with zero execution count' do
19
+ expect(production.execution_count).to eq(0)
20
+ end
21
+ end
22
+
23
+ describe '#execute!' do
24
+ it 'increments execution count' do
25
+ expect { production.execute!(success: true) }.to change(production, :execution_count).by(1)
26
+ end
27
+
28
+ it 'increments success count on success' do
29
+ expect { production.execute!(success: true) }.to change(production, :success_count).by(1)
30
+ end
31
+
32
+ it 'does not increment success count on failure' do
33
+ expect { production.execute!(success: false) }.not_to change(production, :success_count)
34
+ end
35
+ end
36
+
37
+ describe '#success_rate' do
38
+ it 'returns 0.0 when no executions' do
39
+ expect(production.success_rate).to eq(0.0)
40
+ end
41
+
42
+ it 'computes success ratio' do
43
+ 3.times { production.execute!(success: true) }
44
+ production.execute!(success: false)
45
+ expect(production.success_rate).to eq(0.75)
46
+ end
47
+ end
48
+
49
+ describe '#reliable?' do
50
+ it 'returns false initially' do
51
+ expect(production).not_to be_reliable
52
+ end
53
+
54
+ it 'returns true after sufficient successful executions' do
55
+ 5.times { production.execute!(success: true) }
56
+ expect(production).to be_reliable
57
+ end
58
+ end
59
+
60
+ describe '#to_h' do
61
+ it 'returns hash representation' do
62
+ hash = production.to_h
63
+ expect(hash).to include(:id, :condition, :action, :domain, :success_rate, :reliable)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProceduralLearning::Helpers::Skill do
4
+ subject(:skill) { described_class.new(name: 'api_retry', domain: :http) }
5
+
6
+ describe '#initialize' do
7
+ it 'assigns a UUID' do
8
+ expect(skill.id).to match(/\A[0-9a-f-]{36}\z/)
9
+ end
10
+
11
+ it 'starts at declarative stage' do
12
+ expect(skill.stage).to eq(:declarative)
13
+ end
14
+
15
+ it 'starts with low proficiency' do
16
+ expect(skill.proficiency).to be < 0.2
17
+ end
18
+ end
19
+
20
+ describe '#practice!' do
21
+ it 'increases proficiency on success' do
22
+ original = skill.proficiency
23
+ skill.practice!(success: true)
24
+ expect(skill.proficiency).to be > original
25
+ end
26
+
27
+ it 'increases proficiency less on failure' do
28
+ success_skill = described_class.new(name: 'test', domain: :test)
29
+ failure_skill = described_class.new(name: 'test', domain: :test)
30
+ success_skill.practice!(success: true)
31
+ failure_skill.practice!(success: false)
32
+ expect(success_skill.proficiency).to be > failure_skill.proficiency
33
+ end
34
+
35
+ it 'transitions stage with practice' do
36
+ 10.times { skill.practice!(success: true) }
37
+ expect(skill.stage).not_to eq(:declarative)
38
+ end
39
+ end
40
+
41
+ describe '#compiled?' do
42
+ it 'returns false initially' do
43
+ expect(skill).not_to be_compiled
44
+ end
45
+
46
+ it 'returns true after sufficient practice' do
47
+ 8.times { skill.practice!(success: true) }
48
+ expect(skill).to be_compiled
49
+ end
50
+ end
51
+
52
+ describe '#autonomous?' do
53
+ it 'returns false initially' do
54
+ expect(skill).not_to be_autonomous
55
+ end
56
+
57
+ it 'returns true after extensive practice' do
58
+ 12.times { skill.practice!(success: true) }
59
+ expect(skill).to be_autonomous
60
+ end
61
+ end
62
+
63
+ describe '#stage_label' do
64
+ it 'returns a label symbol' do
65
+ expect(skill.stage_label).to be_a(Symbol)
66
+ end
67
+ end
68
+
69
+ describe '#proficiency_label' do
70
+ it 'returns a label symbol' do
71
+ expect(skill.proficiency_label).to be_a(Symbol)
72
+ end
73
+ end
74
+
75
+ describe '#decay!' do
76
+ it 'reduces proficiency' do
77
+ 5.times { skill.practice!(success: true) }
78
+ original = skill.proficiency
79
+ skill.decay!
80
+ expect(skill.proficiency).to be < original
81
+ end
82
+ end
83
+
84
+ describe '#add_production' do
85
+ it 'adds production id' do
86
+ skill.add_production('prod-123')
87
+ expect(skill.productions).to include('prod-123')
88
+ end
89
+
90
+ it 'does not add duplicates' do
91
+ 2.times { skill.add_production('prod-123') }
92
+ expect(skill.productions.size).to eq(1)
93
+ end
94
+ end
95
+
96
+ describe '#to_h' do
97
+ it 'returns hash representation' do
98
+ hash = skill.to_h
99
+ expect(hash).to include(:id, :name, :domain, :proficiency, :stage, :compiled, :autonomous)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProceduralLearning::Runners::ProceduralLearning do
4
+ let(:runner_host) do
5
+ obj = Object.new
6
+ obj.extend(described_class)
7
+ obj
8
+ end
9
+
10
+ describe '#create_skill' do
11
+ it 'creates a skill' do
12
+ result = runner_host.create_skill(name: 'api_retry', domain: :http)
13
+ expect(result[:success]).to be true
14
+ expect(result[:skill_id]).to be_a(String)
15
+ end
16
+ end
17
+
18
+ describe '#add_skill_production' do
19
+ it 'adds a production to a skill' do
20
+ created = runner_host.create_skill(name: 'test', domain: :test)
21
+ result = runner_host.add_skill_production(
22
+ skill_id: created[:skill_id], condition: 'if_error',
23
+ action: 'retry', domain: :test
24
+ )
25
+ expect(result[:success]).to be true
26
+ end
27
+ end
28
+
29
+ describe '#practice_skill' do
30
+ it 'increases proficiency' do
31
+ created = runner_host.create_skill(name: 'test', domain: :test)
32
+ result = runner_host.practice_skill(skill_id: created[:skill_id], success: true)
33
+ expect(result[:success]).to be true
34
+ expect(result[:proficiency]).to be > 0.1
35
+ end
36
+ end
37
+
38
+ describe '#execute_production' do
39
+ it 'executes a production' do
40
+ created = runner_host.create_skill(name: 'test', domain: :test)
41
+ prod = runner_host.add_skill_production(
42
+ skill_id: created[:skill_id], condition: 'test',
43
+ action: 'act', domain: :test
44
+ )
45
+ result = runner_host.execute_production(production_id: prod[:production_id], success: true)
46
+ expect(result[:success]).to be true
47
+ end
48
+ end
49
+
50
+ describe '#skill_assessment' do
51
+ it 'returns skill assessment' do
52
+ created = runner_host.create_skill(name: 'test', domain: :test)
53
+ result = runner_host.skill_assessment(skill_id: created[:skill_id])
54
+ expect(result[:success]).to be true
55
+ end
56
+ end
57
+
58
+ describe '#compiled_skills' do
59
+ it 'returns compiled skills' do
60
+ result = runner_host.compiled_skills
61
+ expect(result[:success]).to be true
62
+ end
63
+ end
64
+
65
+ describe '#autonomous_skills' do
66
+ it 'returns autonomous skills' do
67
+ result = runner_host.autonomous_skills
68
+ expect(result[:success]).to be true
69
+ end
70
+ end
71
+
72
+ describe '#most_practiced_skills' do
73
+ it 'returns most practiced skills' do
74
+ result = runner_host.most_practiced_skills(limit: 3)
75
+ expect(result[:success]).to be true
76
+ end
77
+ end
78
+
79
+ describe '#update_procedural_learning' do
80
+ it 'runs decay and prune cycle' do
81
+ result = runner_host.update_procedural_learning
82
+ expect(result[:success]).to be true
83
+ expect(result).to include(:pruned)
84
+ end
85
+ end
86
+
87
+ describe '#procedural_learning_stats' do
88
+ it 'returns stats' do
89
+ result = runner_host.procedural_learning_stats
90
+ expect(result[:success]).to be true
91
+ expect(result).to include(:total_skills, :total_productions)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Helpers
6
+ module Lex; end
7
+ end
8
+ end
9
+
10
+ module Logging
11
+ def self.debug(*); end
12
+
13
+ def self.info(*); end
14
+
15
+ def self.warn(*); end
16
+
17
+ def self.error(*); end
18
+ end
19
+ end
20
+
21
+ require 'legion/extensions/procedural_learning'
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.filter_run_when_matching :focus
33
+ config.order = :random
34
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-procedural-learning
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: Anderson's ACT-R production rules — skill acquisition from declarative
27
+ to autonomous for brain-modeled agentic AI
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-procedural-learning.gemspec
36
+ - lib/legion/extensions/procedural_learning.rb
37
+ - lib/legion/extensions/procedural_learning/client.rb
38
+ - lib/legion/extensions/procedural_learning/helpers/constants.rb
39
+ - lib/legion/extensions/procedural_learning/helpers/learning_engine.rb
40
+ - lib/legion/extensions/procedural_learning/helpers/production.rb
41
+ - lib/legion/extensions/procedural_learning/helpers/skill.rb
42
+ - lib/legion/extensions/procedural_learning/runners/procedural_learning.rb
43
+ - lib/legion/extensions/procedural_learning/version.rb
44
+ - spec/legion/extensions/procedural_learning/client_spec.rb
45
+ - spec/legion/extensions/procedural_learning/helpers/learning_engine_spec.rb
46
+ - spec/legion/extensions/procedural_learning/helpers/production_spec.rb
47
+ - spec/legion/extensions/procedural_learning/helpers/skill_spec.rb
48
+ - spec/legion/extensions/procedural_learning/runners/procedural_learning_spec.rb
49
+ - spec/spec_helper.rb
50
+ homepage: https://github.com/LegionIO/lex-procedural-learning
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/LegionIO/lex-procedural-learning
55
+ source_code_uri: https://github.com/LegionIO/lex-procedural-learning
56
+ documentation_uri: https://github.com/LegionIO/lex-procedural-learning
57
+ changelog_uri: https://github.com/LegionIO/lex-procedural-learning
58
+ bug_tracker_uri: https://github.com/LegionIO/lex-procedural-learning/issues
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.4'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.6.9
75
+ specification_version: 4
76
+ summary: LEX Procedural Learning
77
+ test_files: []