lex-language 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: 38927cf1b9e51ee052d7d3760a10e7030ba57e08658776dc955669fb7efcbc87
4
+ data.tar.gz: 615a29cdd55c75591edc3b3ce62ee3cb9bacefbed3a2c064ddbcdb5413e75914
5
+ SHA512:
6
+ metadata.gz: de1666f187f15312b478e0df40edfdbe916810abfb75bd5c1af8e96c6512910a1ca70ee47dfc1a128e52cccffcc8c9e1ad4ac4024c3fc6c73e7db13816e32312
7
+ data.tar.gz: 14182d7c1c7d1a615bf88cb1394e2a018c18d0f9b2cfa8e176f506c04798a3ac634f74668f0a4391cf133f4b81f51f6a948037a4067cbb7660d82977191603c5
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem 'rake', '~> 13.0'
9
+ gem 'rspec', '~> 3.0'
10
+ gem 'rubocop', '~> 1.21'
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'simplecov', require: false
13
+ end
14
+
15
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LegionIO
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,74 @@
1
+ # lex-language
2
+
3
+ Symbolic-to-linguistic grounding for LegionIO agents. Part of the LegionIO cognitive architecture extension ecosystem (LEX).
4
+
5
+ ## What It Does
6
+
7
+ `lex-language` bridges raw memory traces and meaningful knowledge expression. Given a set of memory traces for a domain, it aggregates them by type priority, extracts key facts at configurable depth, assesses knowledge level, and returns a structured summary or prose narrative. A TTL-based lexicon cache avoids reprocessing unchanged trace sets. Also determines whether the agent's knowledge is sufficient to answer specific queries.
8
+
9
+ Key capabilities:
10
+
11
+ - **Domain summarization**: brief (3 facts), standard (7 facts), detailed (all traces above minimum strength)
12
+ - **Knowledge assessment**: rich (>=10 traces), moderate (>=5), sparse (<5)
13
+ - **Wonder resolution**: checks if a query domain has enough traces to answer
14
+ - **Lexicon cache**: 5-minute TTL per domain to avoid repeated processing
15
+ - **Knowledge map**: snapshot of all cached domain summaries
16
+
17
+ ## Installation
18
+
19
+ Add to your Gemfile:
20
+
21
+ ```ruby
22
+ gem 'lex-language'
23
+ ```
24
+
25
+ Or install directly:
26
+
27
+ ```
28
+ gem install lex-language
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ require 'legion/extensions/language'
35
+
36
+ # Fetch traces from lex-memory first, then summarize
37
+ client = Legion::Extensions::Language::Client.new
38
+
39
+ summary = client.summarize(domain: :networking, traces: traces, depth: :standard)
40
+ # => { domain: :networking, trace_count: 12, knowledge_level: :rich,
41
+ # key_facts: ['...', '...', ...], strength_stats: { min: 0.3, max: 0.9, avg: 0.65 } }
42
+
43
+ # Assess knowledge in a domain
44
+ knowledge = client.what_do_i_know(domain: :networking, traces: traces)
45
+
46
+ # Check if a wonder can be answered
47
+ result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
48
+ # => { can_answer: true, trace_count: 12, threshold: 3 }
49
+
50
+ # View all cached knowledge
51
+ map = client.knowledge_map
52
+ ```
53
+
54
+ ## Runner Methods
55
+
56
+ | Method | Description |
57
+ |---|---|
58
+ | `summarize` | Summarize domain traces at specified depth (brief/standard/detailed) |
59
+ | `what_do_i_know` | Detailed knowledge assessment for a domain |
60
+ | `can_answer_wonder?` | Check if trace count meets the resolution threshold |
61
+ | `knowledge_map` | All cached domain summaries |
62
+ | `language_stats` | Cached domain count, total traces processed |
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ bundle install
68
+ bundle exec rspec
69
+ bundle exec rubocop
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/language/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-language'
7
+ spec.version = Legion::Extensions::Language::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Language'
12
+ spec.description = 'Symbolic-to-linguistic grounding layer for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-language'
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-language'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-language'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-language'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-language/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-language.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/language/helpers/constants'
4
+ require 'legion/extensions/language/helpers/summarizer'
5
+ require 'legion/extensions/language/helpers/lexicon'
6
+ require 'legion/extensions/language/runners/language'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Language
11
+ class Client
12
+ include Runners::Language
13
+
14
+ attr_reader :lexicon
15
+
16
+ def initialize(lexicon: nil, **)
17
+ @lexicon = lexicon || Helpers::Lexicon.new
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Language
6
+ module Helpers
7
+ module Constants
8
+ # Maximum traces to consider in a single summarization
9
+ MAX_TRACES_PER_SUMMARY = 50
10
+
11
+ # Minimum strength threshold for including a trace in a summary
12
+ MIN_SUMMARY_STRENGTH = 0.1
13
+
14
+ # Summary depth levels
15
+ DEPTHS = %i[brief standard detailed].freeze
16
+
17
+ # Trace type priority for summary ordering
18
+ TYPE_PRIORITY = {
19
+ firmware: 0,
20
+ identity: 1,
21
+ procedural: 2,
22
+ semantic: 3,
23
+ trust: 4,
24
+ episodic: 5,
25
+ sensory: 6
26
+ }.freeze
27
+
28
+ # Knowledge quality thresholds
29
+ KNOWLEDGE_RICH = 10 # traces for "rich knowledge"
30
+ KNOWLEDGE_MODERATE = 5 # traces for "moderate knowledge"
31
+ KNOWLEDGE_SPARSE = 1 # traces for "sparse knowledge"
32
+
33
+ # Wonder resolution: minimum traces to consider a domain "known"
34
+ RESOLUTION_THRESHOLD = 3
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Language
6
+ module Helpers
7
+ class Lexicon
8
+ attr_reader :domain_summaries
9
+
10
+ def initialize
11
+ @domain_summaries = {}
12
+ end
13
+
14
+ def store_summary(domain, summary)
15
+ domain = domain.to_sym
16
+ @domain_summaries[domain] = summary.merge(cached_at: Time.now.utc)
17
+ end
18
+
19
+ def get_summary(domain)
20
+ @domain_summaries[domain.to_sym]
21
+ end
22
+
23
+ def known_domains
24
+ @domain_summaries.keys
25
+ end
26
+
27
+ def knowledge_map
28
+ @domain_summaries.transform_values do |summary|
29
+ {
30
+ knowledge_level: summary[:knowledge_level],
31
+ trace_count: summary[:trace_count],
32
+ cached_at: summary[:cached_at]
33
+ }
34
+ end
35
+ end
36
+
37
+ def stale?(domain, max_age: 300)
38
+ summary = get_summary(domain)
39
+ return true unless summary
40
+
41
+ (Time.now.utc - summary[:cached_at]) > max_age
42
+ end
43
+
44
+ def clear(domain = nil)
45
+ if domain
46
+ @domain_summaries.delete(domain.to_sym)
47
+ else
48
+ @domain_summaries.clear
49
+ end
50
+ end
51
+
52
+ def size
53
+ @domain_summaries.size
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Language
6
+ module Helpers
7
+ module Summarizer
8
+ module_function
9
+
10
+ def summarize_domain(traces, domain:, depth: :standard)
11
+ return empty_summary(domain) if traces.empty?
12
+
13
+ grouped = group_by_type(traces)
14
+ knowledge_level = classify_knowledge(traces.size)
15
+
16
+ {
17
+ domain: domain,
18
+ knowledge_level: knowledge_level,
19
+ trace_count: traces.size,
20
+ type_breakdown: type_breakdown(grouped),
21
+ key_facts: extract_key_facts(grouped, depth: depth),
22
+ strength_stats: strength_stats(traces),
23
+ emotional_tone: emotional_tone(traces),
24
+ freshness: freshness_assessment(traces)
25
+ }
26
+ end
27
+
28
+ def group_by_type(traces)
29
+ grouped = traces.group_by { |t| t[:trace_type] || :unknown }
30
+ grouped.sort_by { |type, _| Constants::TYPE_PRIORITY[type] || 99 }.to_h
31
+ end
32
+
33
+ def type_breakdown(grouped)
34
+ grouped.transform_values(&:size)
35
+ end
36
+
37
+ def extract_key_facts(grouped, depth: :standard)
38
+ limit = case depth
39
+ when :brief then 3
40
+ when :detailed then 15
41
+ else 7
42
+ end
43
+
44
+ facts = []
45
+ grouped.each do |type, traces|
46
+ sorted = traces.sort_by { |t| -(t[:strength] || 0) }
47
+ count = [sorted.size, limit_per_type(type, limit)].min
48
+ sorted.first(count).each do |trace|
49
+ facts << format_fact(trace, type)
50
+ end
51
+ end
52
+
53
+ facts.first(limit)
54
+ end
55
+
56
+ def format_fact(trace, type)
57
+ content = extract_content_text(trace[:content_payload])
58
+ {
59
+ trace_id: trace[:trace_id],
60
+ type: type,
61
+ content: content,
62
+ strength: (trace[:strength] || 0).round(3),
63
+ confidence: (trace[:confidence] || 0.5).round(3),
64
+ domain_tags: trace[:domain_tags] || []
65
+ }
66
+ end
67
+
68
+ def extract_content_text(payload)
69
+ case payload
70
+ when String then payload
71
+ when Hash then payload[:text] || payload[:content] || payload[:summary] || payload.to_s
72
+ when Array then payload.first.to_s
73
+ else payload.to_s
74
+ end
75
+ end
76
+
77
+ def classify_knowledge(count)
78
+ if count >= Constants::KNOWLEDGE_RICH then :rich
79
+ elsif count >= Constants::KNOWLEDGE_MODERATE then :moderate
80
+ elsif count >= Constants::KNOWLEDGE_SPARSE then :sparse
81
+ else :none
82
+ end
83
+ end
84
+
85
+ def strength_stats(traces)
86
+ strengths = traces.map { |t| t[:strength] || 0 }
87
+ {
88
+ mean: (strengths.sum / strengths.size).round(3),
89
+ max: strengths.max.round(3),
90
+ min: strengths.min.round(3),
91
+ strong: strengths.count { |s| s >= 0.5 },
92
+ weak: strengths.count { |s| s < 0.3 }
93
+ }
94
+ end
95
+
96
+ def emotional_tone(traces)
97
+ valences = traces.map { |t| t[:emotional_valence] || 0.0 }
98
+ intensities = traces.map { |t| t[:emotional_intensity] || 0.0 }
99
+
100
+ avg_valence = valences.sum / valences.size
101
+ avg_intensity = intensities.sum / intensities.size
102
+
103
+ {
104
+ avg_valence: avg_valence.round(3),
105
+ avg_intensity: avg_intensity.round(3),
106
+ tone: tone_label(avg_valence, avg_intensity)
107
+ }
108
+ end
109
+
110
+ def tone_label(valence, intensity)
111
+ if intensity < 0.2 then :neutral
112
+ elsif valence > 0.3 then :positive
113
+ elsif valence < -0.3 then :negative
114
+ else :mixed
115
+ end
116
+ end
117
+
118
+ def freshness_assessment(traces)
119
+ now = Time.now.utc
120
+ ages = traces.map { |t| now - (t[:last_reinforced] || t[:created_at] || now) }
121
+ avg_age = ages.sum / ages.size
122
+
123
+ {
124
+ avg_age_seconds: avg_age.round(1),
125
+ freshest: ages.min.round(1),
126
+ stalest: ages.max.round(1),
127
+ label: freshness_label(avg_age)
128
+ }
129
+ end
130
+
131
+ def freshness_label(avg_age)
132
+ if avg_age < 60 then :very_fresh
133
+ elsif avg_age < 3600 then :fresh
134
+ elsif avg_age < 86_400 then :aging
135
+ else :stale
136
+ end
137
+ end
138
+
139
+ def empty_summary(domain)
140
+ {
141
+ domain: domain,
142
+ knowledge_level: :none,
143
+ trace_count: 0,
144
+ type_breakdown: {},
145
+ key_facts: [],
146
+ strength_stats: { mean: 0.0, max: 0.0, min: 0.0, strong: 0, weak: 0 },
147
+ emotional_tone: { avg_valence: 0.0, avg_intensity: 0.0, tone: :neutral },
148
+ freshness: { avg_age_seconds: 0.0, freshest: 0.0, stalest: 0.0, label: :stale }
149
+ }
150
+ end
151
+
152
+ def limit_per_type(type, total_limit)
153
+ case type
154
+ when :firmware, :identity then [total_limit, 3].min
155
+ when :procedural then [total_limit, 2].min
156
+ else [total_limit, 5].min
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Language
6
+ module Runners
7
+ module Language
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def summarize(domain:, depth: :standard, traces: [], **)
12
+ traces = filter_traces(traces, domain)
13
+ summary = Helpers::Summarizer.summarize_domain(traces, domain: domain.to_sym, depth: depth.to_sym)
14
+ lexicon.store_summary(domain, summary)
15
+
16
+ Legion::Logging.debug "[language] summarize domain=#{domain} traces=#{traces.size} " \
17
+ "knowledge=#{summary[:knowledge_level]}"
18
+ summary
19
+ end
20
+
21
+ def what_do_i_know(domain:, depth: :standard, traces: [], **)
22
+ summary = if lexicon.stale?(domain)
23
+ summarize(domain: domain, depth: depth, traces: traces)
24
+ else
25
+ lexicon.get_summary(domain)
26
+ end
27
+
28
+ prose = generate_knowledge_prose(summary)
29
+
30
+ {
31
+ domain: domain.to_sym,
32
+ knowledge_level: summary[:knowledge_level],
33
+ prose: prose,
34
+ fact_count: summary[:key_facts]&.size || 0,
35
+ summary: summary
36
+ }
37
+ end
38
+
39
+ def can_answer_wonder?(wonder:, traces: [], **)
40
+ domain = wonder.is_a?(Hash) ? wonder[:domain] : :general
41
+ relevant = filter_traces(traces, domain)
42
+
43
+ answerable = relevant.size >= Helpers::Constants::RESOLUTION_THRESHOLD
44
+ confidence = answerable ? compute_answer_confidence(relevant) : 0.0
45
+
46
+ {
47
+ answerable: answerable,
48
+ confidence: confidence.round(3),
49
+ domain: domain,
50
+ trace_count: relevant.size,
51
+ threshold: Helpers::Constants::RESOLUTION_THRESHOLD
52
+ }
53
+ end
54
+
55
+ def knowledge_map(**)
56
+ {
57
+ domains: lexicon.knowledge_map,
58
+ known_domains: lexicon.known_domains,
59
+ total_domains: lexicon.size
60
+ }
61
+ end
62
+
63
+ def language_stats(**)
64
+ {
65
+ cached_domains: lexicon.size,
66
+ known_domains: lexicon.known_domains,
67
+ knowledge_map: lexicon.knowledge_map
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def lexicon
74
+ @lexicon ||= Helpers::Lexicon.new
75
+ end
76
+
77
+ def filter_traces(traces, domain)
78
+ return [] unless traces.is_a?(Array)
79
+
80
+ domain_sym = domain.to_sym
81
+ domain_str = domain.to_s
82
+
83
+ matching = traces.select do |t|
84
+ tags = t[:domain_tags] || []
85
+ tags.any? { |tag| tag.to_s == domain_str || tag.to_sym == domain_sym }
86
+ end
87
+
88
+ matching
89
+ .select { |t| (t[:strength] || 0) >= Helpers::Constants::MIN_SUMMARY_STRENGTH }
90
+ .sort_by { |t| -(t[:strength] || 0) }
91
+ .first(Helpers::Constants::MAX_TRACES_PER_SUMMARY)
92
+ end
93
+
94
+ def generate_knowledge_prose(summary)
95
+ domain = summary[:domain]
96
+ level = summary[:knowledge_level]
97
+ count = summary[:trace_count]
98
+ facts = summary[:key_facts] || []
99
+
100
+ base = "About #{domain}: I have #{level} knowledge (#{count} traces)."
101
+
102
+ if facts.empty?
103
+ "#{base} No specific facts available."
104
+ else
105
+ fact_lines = facts.first(5).map { |f| "- #{truncate(f[:content], 120)}" }
106
+ "#{base}\nKey facts:\n#{fact_lines.join("\n")}"
107
+ end
108
+ end
109
+
110
+ def compute_answer_confidence(traces)
111
+ return 0.0 if traces.empty?
112
+
113
+ strengths = traces.map { |t| t[:strength] || 0.0 }
114
+ confidences = traces.map { |t| t[:confidence] || 0.5 }
115
+
116
+ avg_strength = strengths.sum / strengths.size
117
+ avg_confidence = confidences.sum / confidences.size
118
+
119
+ ((avg_strength * 0.5) + (avg_confidence * 0.5)).clamp(0.0, 1.0)
120
+ end
121
+
122
+ def truncate(text, max_length)
123
+ text = text.to_s
124
+ text.length > max_length ? "#{text[0...max_length]}..." : text
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Language
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/language/version'
4
+ require 'legion/extensions/language/helpers/constants'
5
+ require 'legion/extensions/language/helpers/summarizer'
6
+ require 'legion/extensions/language/helpers/lexicon'
7
+ require 'legion/extensions/language/runners/language'
8
+ require 'legion/extensions/language/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Language
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Language::Client do
4
+ describe '#initialize' do
5
+ it 'creates a default lexicon' do
6
+ client = described_class.new
7
+ expect(client.lexicon).to be_a(Legion::Extensions::Language::Helpers::Lexicon)
8
+ end
9
+
10
+ it 'accepts an injected lexicon' do
11
+ lexicon = Legion::Extensions::Language::Helpers::Lexicon.new
12
+ client = described_class.new(lexicon: lexicon)
13
+ expect(client.lexicon).to equal(lexicon)
14
+ end
15
+ end
16
+
17
+ it 'includes Runners::Language' do
18
+ expect(described_class.ancestors).to include(Legion::Extensions::Language::Runners::Language)
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Language::Helpers::Constants do
4
+ it 'defines MAX_TRACES_PER_SUMMARY' do
5
+ expect(described_class::MAX_TRACES_PER_SUMMARY).to eq(50)
6
+ end
7
+
8
+ it 'defines MIN_SUMMARY_STRENGTH' do
9
+ expect(described_class::MIN_SUMMARY_STRENGTH).to eq(0.1)
10
+ end
11
+
12
+ it 'defines DEPTHS as frozen array of symbols' do
13
+ expect(described_class::DEPTHS).to eq(%i[brief standard detailed])
14
+ expect(described_class::DEPTHS).to be_frozen
15
+ end
16
+
17
+ it 'defines TYPE_PRIORITY as frozen hash' do
18
+ expect(described_class::TYPE_PRIORITY).to include(firmware: 0, sensory: 6)
19
+ expect(described_class::TYPE_PRIORITY).to be_frozen
20
+ end
21
+
22
+ it 'defines knowledge thresholds' do
23
+ expect(described_class::KNOWLEDGE_RICH).to eq(10)
24
+ expect(described_class::KNOWLEDGE_MODERATE).to eq(5)
25
+ expect(described_class::KNOWLEDGE_SPARSE).to eq(1)
26
+ end
27
+
28
+ it 'defines RESOLUTION_THRESHOLD' do
29
+ expect(described_class::RESOLUTION_THRESHOLD).to eq(3)
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Language::Helpers::Lexicon do
4
+ subject(:lexicon) { described_class.new }
5
+
6
+ let(:summary) do
7
+ {
8
+ domain: :networking,
9
+ knowledge_level: :moderate,
10
+ trace_count: 7,
11
+ key_facts: [{ content: 'fact 1' }]
12
+ }
13
+ end
14
+
15
+ describe '#store_summary' do
16
+ it 'stores a summary keyed by domain symbol' do
17
+ lexicon.store_summary(:networking, summary)
18
+ expect(lexicon.get_summary(:networking)).to include(domain: :networking)
19
+ end
20
+
21
+ it 'adds a cached_at timestamp' do
22
+ lexicon.store_summary('networking', summary)
23
+ stored = lexicon.get_summary(:networking)
24
+ expect(stored[:cached_at]).to be_a(Time)
25
+ end
26
+
27
+ it 'converts string domain to symbol' do
28
+ lexicon.store_summary('security', summary)
29
+ expect(lexicon.get_summary(:security)).not_to be_nil
30
+ end
31
+ end
32
+
33
+ describe '#get_summary' do
34
+ it 'returns nil for unknown domain' do
35
+ expect(lexicon.get_summary(:unknown)).to be_nil
36
+ end
37
+
38
+ it 'returns stored summary' do
39
+ lexicon.store_summary(:networking, summary)
40
+ result = lexicon.get_summary(:networking)
41
+ expect(result[:knowledge_level]).to eq(:moderate)
42
+ end
43
+ end
44
+
45
+ describe '#known_domains' do
46
+ it 'returns empty array initially' do
47
+ expect(lexicon.known_domains).to eq([])
48
+ end
49
+
50
+ it 'returns stored domain keys' do
51
+ lexicon.store_summary(:networking, summary)
52
+ lexicon.store_summary(:security, summary)
53
+ expect(lexicon.known_domains).to contain_exactly(:networking, :security)
54
+ end
55
+ end
56
+
57
+ describe '#knowledge_map' do
58
+ it 'returns empty hash initially' do
59
+ expect(lexicon.knowledge_map).to eq({})
60
+ end
61
+
62
+ it 'returns condensed view of all summaries' do
63
+ lexicon.store_summary(:networking, summary)
64
+ map = lexicon.knowledge_map
65
+ expect(map[:networking]).to include(knowledge_level: :moderate, trace_count: 7)
66
+ expect(map[:networking]).to have_key(:cached_at)
67
+ end
68
+ end
69
+
70
+ describe '#stale?' do
71
+ it 'returns true for unknown domain' do
72
+ expect(lexicon.stale?(:unknown)).to be true
73
+ end
74
+
75
+ it 'returns false for recently stored summary' do
76
+ lexicon.store_summary(:networking, summary)
77
+ expect(lexicon.stale?(:networking)).to be false
78
+ end
79
+
80
+ it 'returns true when max_age exceeded' do
81
+ lexicon.store_summary(:networking, summary)
82
+ stored = lexicon.get_summary(:networking)
83
+ stored[:cached_at] = Time.now.utc - 600
84
+ expect(lexicon.stale?(:networking, max_age: 300)).to be true
85
+ end
86
+ end
87
+
88
+ describe '#clear' do
89
+ before do
90
+ lexicon.store_summary(:networking, summary)
91
+ lexicon.store_summary(:security, summary)
92
+ end
93
+
94
+ it 'clears a specific domain' do
95
+ lexicon.clear(:networking)
96
+ expect(lexicon.get_summary(:networking)).to be_nil
97
+ expect(lexicon.get_summary(:security)).not_to be_nil
98
+ end
99
+
100
+ it 'clears all domains when called without argument' do
101
+ lexicon.clear
102
+ expect(lexicon.size).to eq(0)
103
+ end
104
+ end
105
+
106
+ describe '#size' do
107
+ it 'returns 0 initially' do
108
+ expect(lexicon.size).to eq(0)
109
+ end
110
+
111
+ it 'reflects stored summary count' do
112
+ lexicon.store_summary(:networking, summary)
113
+ expect(lexicon.size).to eq(1)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Language::Helpers::Summarizer do
4
+ let(:now) { Time.now.utc }
5
+
6
+ let(:base_trace) do
7
+ {
8
+ trace_id: 'trace-1',
9
+ trace_type: :semantic,
10
+ content_payload: 'Networking uses TCP/IP',
11
+ strength: 0.7,
12
+ confidence: 0.8,
13
+ domain_tags: [:networking],
14
+ emotional_valence: 0.2,
15
+ emotional_intensity: 0.3,
16
+ last_reinforced: now,
17
+ created_at: now
18
+ }
19
+ end
20
+
21
+ def make_traces(count, overrides = {})
22
+ count.times.map do |i|
23
+ base_trace.merge(trace_id: "trace-#{i}").merge(overrides)
24
+ end
25
+ end
26
+
27
+ describe '.summarize_domain' do
28
+ it 'returns empty summary for no traces' do
29
+ result = described_class.summarize_domain([], domain: :networking)
30
+ expect(result[:knowledge_level]).to eq(:none)
31
+ expect(result[:trace_count]).to eq(0)
32
+ expect(result[:key_facts]).to eq([])
33
+ end
34
+
35
+ it 'summarizes traces into structured result' do
36
+ traces = make_traces(3)
37
+ result = described_class.summarize_domain(traces, domain: :networking)
38
+
39
+ expect(result[:domain]).to eq(:networking)
40
+ expect(result[:knowledge_level]).to eq(:sparse)
41
+ expect(result[:trace_count]).to eq(3)
42
+ expect(result).to have_key(:type_breakdown)
43
+ expect(result).to have_key(:key_facts)
44
+ expect(result).to have_key(:strength_stats)
45
+ expect(result).to have_key(:emotional_tone)
46
+ expect(result).to have_key(:freshness)
47
+ end
48
+
49
+ it 'classifies rich knowledge at 10+ traces' do
50
+ traces = make_traces(12)
51
+ result = described_class.summarize_domain(traces, domain: :networking)
52
+ expect(result[:knowledge_level]).to eq(:rich)
53
+ end
54
+
55
+ it 'classifies moderate knowledge at 5-9 traces' do
56
+ traces = make_traces(7)
57
+ result = described_class.summarize_domain(traces, domain: :networking)
58
+ expect(result[:knowledge_level]).to eq(:moderate)
59
+ end
60
+
61
+ it 'classifies sparse knowledge at 1-4 traces' do
62
+ traces = make_traces(2)
63
+ result = described_class.summarize_domain(traces, domain: :networking)
64
+ expect(result[:knowledge_level]).to eq(:sparse)
65
+ end
66
+ end
67
+
68
+ describe '.group_by_type' do
69
+ it 'groups traces by trace_type' do
70
+ traces = [
71
+ base_trace.merge(trace_type: :firmware),
72
+ base_trace.merge(trace_type: :semantic),
73
+ base_trace.merge(trace_type: :firmware)
74
+ ]
75
+ grouped = described_class.group_by_type(traces)
76
+ expect(grouped[:firmware].size).to eq(2)
77
+ expect(grouped[:semantic].size).to eq(1)
78
+ end
79
+
80
+ it 'orders by TYPE_PRIORITY' do
81
+ traces = [
82
+ base_trace.merge(trace_type: :sensory),
83
+ base_trace.merge(trace_type: :firmware)
84
+ ]
85
+ grouped = described_class.group_by_type(traces)
86
+ expect(grouped.keys.first).to eq(:firmware)
87
+ end
88
+ end
89
+
90
+ describe '.extract_key_facts' do
91
+ let(:grouped) do
92
+ { semantic: make_traces(10) }
93
+ end
94
+
95
+ it 'limits facts by depth :brief' do
96
+ facts = described_class.extract_key_facts(grouped, depth: :brief)
97
+ expect(facts.size).to be <= 3
98
+ end
99
+
100
+ it 'limits facts by depth :standard' do
101
+ facts = described_class.extract_key_facts(grouped, depth: :standard)
102
+ expect(facts.size).to be <= 7
103
+ end
104
+
105
+ it 'limits facts by depth :detailed' do
106
+ facts = described_class.extract_key_facts(grouped, depth: :detailed)
107
+ expect(facts.size).to be <= 15
108
+ end
109
+
110
+ it 'returns structured fact hashes' do
111
+ facts = described_class.extract_key_facts(grouped)
112
+ fact = facts.first
113
+ expect(fact).to include(:trace_id, :type, :content, :strength, :confidence, :domain_tags)
114
+ end
115
+ end
116
+
117
+ describe '.extract_content_text' do
118
+ it 'handles String payload' do
119
+ expect(described_class.extract_content_text('hello')).to eq('hello')
120
+ end
121
+
122
+ it 'handles Hash with :text key' do
123
+ expect(described_class.extract_content_text({ text: 'hello' })).to eq('hello')
124
+ end
125
+
126
+ it 'handles Hash with :content key' do
127
+ expect(described_class.extract_content_text({ content: 'world' })).to eq('world')
128
+ end
129
+
130
+ it 'handles Hash with :summary key' do
131
+ expect(described_class.extract_content_text({ summary: 'sum' })).to eq('sum')
132
+ end
133
+
134
+ it 'handles Array payload' do
135
+ expect(described_class.extract_content_text(%w[first second])).to eq('first')
136
+ end
137
+
138
+ it 'converts other types to string' do
139
+ expect(described_class.extract_content_text(42)).to eq('42')
140
+ end
141
+ end
142
+
143
+ describe '.strength_stats' do
144
+ it 'computes mean, max, min, strong, weak counts' do
145
+ traces = [
146
+ base_trace.merge(strength: 0.8),
147
+ base_trace.merge(strength: 0.2),
148
+ base_trace.merge(strength: 0.5)
149
+ ]
150
+ stats = described_class.strength_stats(traces)
151
+ expect(stats[:mean]).to eq(0.5)
152
+ expect(stats[:max]).to eq(0.8)
153
+ expect(stats[:min]).to eq(0.2)
154
+ expect(stats[:strong]).to eq(2) # 0.8 and 0.5
155
+ expect(stats[:weak]).to eq(1) # 0.2
156
+ end
157
+ end
158
+
159
+ describe '.emotional_tone' do
160
+ it 'computes average valence and intensity' do
161
+ traces = [
162
+ base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.6),
163
+ base_trace.merge(emotional_valence: 0.3, emotional_intensity: 0.4)
164
+ ]
165
+ tone = described_class.emotional_tone(traces)
166
+ expect(tone[:avg_valence]).to eq(0.4)
167
+ expect(tone[:avg_intensity]).to eq(0.5)
168
+ end
169
+
170
+ it 'classifies neutral tone for low intensity' do
171
+ traces = [base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.1)]
172
+ tone = described_class.emotional_tone(traces)
173
+ expect(tone[:tone]).to eq(:neutral)
174
+ end
175
+
176
+ it 'classifies positive tone for high valence' do
177
+ traces = [base_trace.merge(emotional_valence: 0.5, emotional_intensity: 0.5)]
178
+ tone = described_class.emotional_tone(traces)
179
+ expect(tone[:tone]).to eq(:positive)
180
+ end
181
+
182
+ it 'classifies negative tone for low valence' do
183
+ traces = [base_trace.merge(emotional_valence: -0.5, emotional_intensity: 0.5)]
184
+ tone = described_class.emotional_tone(traces)
185
+ expect(tone[:tone]).to eq(:negative)
186
+ end
187
+
188
+ it 'classifies mixed tone for moderate valence' do
189
+ traces = [base_trace.merge(emotional_valence: 0.0, emotional_intensity: 0.5)]
190
+ tone = described_class.emotional_tone(traces)
191
+ expect(tone[:tone]).to eq(:mixed)
192
+ end
193
+ end
194
+
195
+ describe '.freshness_assessment' do
196
+ it 'classifies very_fresh traces' do
197
+ traces = [base_trace.merge(last_reinforced: Time.now.utc)]
198
+ result = described_class.freshness_assessment(traces)
199
+ expect(result[:label]).to eq(:very_fresh)
200
+ end
201
+
202
+ it 'classifies stale traces' do
203
+ traces = [base_trace.merge(last_reinforced: Time.now.utc - 100_000)]
204
+ result = described_class.freshness_assessment(traces)
205
+ expect(result[:label]).to eq(:stale)
206
+ end
207
+
208
+ it 'returns avg_age_seconds, freshest, stalest' do
209
+ result = described_class.freshness_assessment([base_trace])
210
+ expect(result).to have_key(:avg_age_seconds)
211
+ expect(result).to have_key(:freshest)
212
+ expect(result).to have_key(:stalest)
213
+ end
214
+ end
215
+
216
+ describe '.empty_summary' do
217
+ it 'returns zeroed-out summary for domain' do
218
+ result = described_class.empty_summary(:networking)
219
+ expect(result[:domain]).to eq(:networking)
220
+ expect(result[:knowledge_level]).to eq(:none)
221
+ expect(result[:trace_count]).to eq(0)
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Language::Runners::Language do
4
+ let(:lexicon) { Legion::Extensions::Language::Helpers::Lexicon.new }
5
+ let(:client) { Legion::Extensions::Language::Client.new(lexicon: lexicon) }
6
+
7
+ let(:now) { Time.now.utc }
8
+ let(:base_trace) do
9
+ {
10
+ trace_id: 'trace-1',
11
+ trace_type: :semantic,
12
+ content_payload: 'Networking uses TCP/IP',
13
+ strength: 0.7,
14
+ confidence: 0.8,
15
+ domain_tags: [:networking],
16
+ emotional_valence: 0.2,
17
+ emotional_intensity: 0.3,
18
+ last_reinforced: now,
19
+ created_at: now
20
+ }
21
+ end
22
+
23
+ def make_traces(count, domain: :networking)
24
+ count.times.map do |i|
25
+ base_trace.merge(
26
+ trace_id: "trace-#{i}",
27
+ domain_tags: [domain],
28
+ strength: 0.3 + (rand * 0.5)
29
+ )
30
+ end
31
+ end
32
+
33
+ describe '#summarize' do
34
+ it 'returns a summary hash for the domain' do
35
+ traces = make_traces(5)
36
+ result = client.summarize(domain: :networking, traces: traces)
37
+ expect(result[:domain]).to eq(:networking)
38
+ expect(result[:knowledge_level]).to eq(:moderate)
39
+ end
40
+
41
+ it 'stores the summary in the lexicon' do
42
+ traces = make_traces(5)
43
+ client.summarize(domain: :networking, traces: traces)
44
+ expect(lexicon.get_summary(:networking)).not_to be_nil
45
+ end
46
+
47
+ it 'filters traces by domain' do
48
+ traces = make_traces(5, domain: :networking) + make_traces(3, domain: :security)
49
+ result = client.summarize(domain: :networking, traces: traces)
50
+ expect(result[:trace_count]).to eq(5)
51
+ end
52
+
53
+ it 'respects minimum strength threshold' do
54
+ weak_traces = make_traces(3).map { |t| t.merge(strength: 0.05) }
55
+ strong_traces = make_traces(2)
56
+ result = client.summarize(domain: :networking, traces: weak_traces + strong_traces)
57
+ expect(result[:trace_count]).to eq(2)
58
+ end
59
+
60
+ it 'accepts string domain and converts to symbol' do
61
+ traces = make_traces(3)
62
+ result = client.summarize(domain: 'networking', traces: traces)
63
+ expect(result[:domain]).to eq(:networking)
64
+ end
65
+ end
66
+
67
+ describe '#what_do_i_know' do
68
+ it 'returns structured knowledge with prose' do
69
+ traces = make_traces(5)
70
+ result = client.what_do_i_know(domain: :networking, traces: traces)
71
+ expect(result[:domain]).to eq(:networking)
72
+ expect(result[:knowledge_level]).to be_a(Symbol)
73
+ expect(result[:prose]).to be_a(String)
74
+ expect(result[:fact_count]).to be >= 0
75
+ expect(result).to have_key(:summary)
76
+ end
77
+
78
+ it 'uses cached summary when not stale' do
79
+ traces = make_traces(5)
80
+ client.summarize(domain: :networking, traces: traces)
81
+
82
+ result = client.what_do_i_know(domain: :networking, traces: [])
83
+ expect(result[:knowledge_level]).to eq(:moderate)
84
+ end
85
+
86
+ it 're-summarizes when cache is stale' do
87
+ traces = make_traces(5)
88
+ client.summarize(domain: :networking, traces: traces)
89
+
90
+ stored = lexicon.get_summary(:networking)
91
+ stored[:cached_at] = Time.now.utc - 600
92
+
93
+ new_traces = make_traces(12)
94
+ result = client.what_do_i_know(domain: :networking, traces: new_traces)
95
+ expect(result[:knowledge_level]).to eq(:rich)
96
+ end
97
+
98
+ it 'includes key facts in prose when available' do
99
+ traces = make_traces(5)
100
+ result = client.what_do_i_know(domain: :networking, traces: traces)
101
+ expect(result[:prose]).to include('Key facts')
102
+ end
103
+
104
+ it 'handles no-facts prose' do
105
+ result = client.what_do_i_know(domain: :empty, traces: [])
106
+ expect(result[:prose]).to include('No specific facts')
107
+ end
108
+ end
109
+
110
+ describe '#can_answer_wonder?' do
111
+ it 'returns answerable when enough traces exist' do
112
+ traces = make_traces(5)
113
+ result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
114
+ expect(result[:answerable]).to be true
115
+ expect(result[:confidence]).to be > 0.0
116
+ end
117
+
118
+ it 'returns not answerable when traces below threshold' do
119
+ traces = make_traces(2)
120
+ result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
121
+ expect(result[:answerable]).to be false
122
+ expect(result[:confidence]).to eq(0.0)
123
+ end
124
+
125
+ it 'extracts domain from wonder hash' do
126
+ traces = make_traces(5, domain: :security)
127
+ result = client.can_answer_wonder?(wonder: { domain: :security }, traces: traces)
128
+ expect(result[:domain]).to eq(:security)
129
+ end
130
+
131
+ it 'defaults to :general when wonder is not a hash' do
132
+ result = client.can_answer_wonder?(wonder: 'what is life?', traces: [])
133
+ expect(result[:domain]).to eq(:general)
134
+ end
135
+
136
+ it 'includes trace_count and threshold' do
137
+ traces = make_traces(4)
138
+ result = client.can_answer_wonder?(wonder: { domain: :networking }, traces: traces)
139
+ expect(result[:trace_count]).to eq(4)
140
+ expect(result[:threshold]).to eq(3)
141
+ end
142
+ end
143
+
144
+ describe '#knowledge_map' do
145
+ it 'returns domains, known_domains, and total_domains' do
146
+ traces = make_traces(5)
147
+ client.summarize(domain: :networking, traces: traces)
148
+ result = client.knowledge_map
149
+ expect(result[:known_domains]).to include(:networking)
150
+ expect(result[:total_domains]).to eq(1)
151
+ expect(result[:domains]).to have_key(:networking)
152
+ end
153
+
154
+ it 'returns empty map with no summaries' do
155
+ result = client.knowledge_map
156
+ expect(result[:total_domains]).to eq(0)
157
+ expect(result[:known_domains]).to eq([])
158
+ end
159
+ end
160
+
161
+ describe '#language_stats' do
162
+ it 'returns cached_domains, known_domains, knowledge_map' do
163
+ result = client.language_stats
164
+ expect(result).to have_key(:cached_domains)
165
+ expect(result).to have_key(:known_domains)
166
+ expect(result).to have_key(:knowledge_map)
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/language'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-language
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: Symbolic-to-linguistic grounding layer for brain-modeled agentic AI
27
+ email:
28
+ - matthewdiverson@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - Gemfile
34
+ - LICENSE
35
+ - README.md
36
+ - lex-language.gemspec
37
+ - lib/legion/extensions/language.rb
38
+ - lib/legion/extensions/language/client.rb
39
+ - lib/legion/extensions/language/helpers/constants.rb
40
+ - lib/legion/extensions/language/helpers/lexicon.rb
41
+ - lib/legion/extensions/language/helpers/summarizer.rb
42
+ - lib/legion/extensions/language/runners/language.rb
43
+ - lib/legion/extensions/language/version.rb
44
+ - spec/legion/extensions/language/client_spec.rb
45
+ - spec/legion/extensions/language/helpers/constants_spec.rb
46
+ - spec/legion/extensions/language/helpers/lexicon_spec.rb
47
+ - spec/legion/extensions/language/helpers/summarizer_spec.rb
48
+ - spec/legion/extensions/language/runners/language_spec.rb
49
+ - spec/spec_helper.rb
50
+ homepage: https://github.com/LegionIO/lex-language
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/LegionIO/lex-language
55
+ source_code_uri: https://github.com/LegionIO/lex-language
56
+ documentation_uri: https://github.com/LegionIO/lex-language
57
+ changelog_uri: https://github.com/LegionIO/lex-language
58
+ bug_tracker_uri: https://github.com/LegionIO/lex-language/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 Language
77
+ test_files: []