scout-ai 1.1.6 → 1.1.7

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.
@@ -0,0 +1,206 @@
1
+ require_relative 'paths'
2
+
3
+ module Entity
4
+ module Adjacent
5
+ def path_to(adjacency, entities, threshold = nil, max_steps = nil)
6
+ if Array === self
7
+ self.collect{|entity| entity.path_to(adjacency, entities, threshold, max_steps)}
8
+ else
9
+ if adjacency.type == :flat
10
+ max_steps ||= threshold
11
+ Paths.dijkstra(adjacency, self, entities, max_steps)
12
+ else
13
+ Paths.weighted_dijkstra(adjacency, self, entities, threshold, max_steps)
14
+ end
15
+ end
16
+ end
17
+
18
+ def random_paths_to(adjacency, l, times, entities)
19
+ if Array === self
20
+ self.inject([]){|acc,entity| acc += entity.random_paths_to(adjacency, l, times, entities)}
21
+ else
22
+ paths = []
23
+ times.times do
24
+ paths << Paths.random_weighted_dijkstra(adjacency, l, self, entities)
25
+ end
26
+ paths
27
+ end
28
+ end
29
+
30
+ # list of neighbours up to a given radius using unweighted adjacency
31
+ # adjacency: Hash[String => Array[String]] or TSV(:flat) treated as adjacency
32
+ # k: maximum number of steps
33
+ # Returns an Array of Arrays (one per entity when self is an array), each
34
+ # containing the reachable entities (as plain values) within k steps
35
+ def neighborhood(adjacency, k)
36
+ if Array === self
37
+ self.collect{|entity| entity.neighborhood(adjacency, k)}
38
+ else
39
+ adj_hash = adjacency.respond_to?(:include?) ? adjacency : adjacency.to_hash
40
+ Paths.neighborhood(adj_hash, self, k)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ module AssociationItem
47
+
48
+ # Dijkstra over a list of AssociationItems, using an optional block to
49
+ # compute edge weights.
50
+ def self.dijkstra(associations, start_node, end_node = nil, threshold = nil, max_steps = nil, &block)
51
+ adjacency = {}
52
+
53
+ associations.each do |m|
54
+ s, t, _sep = m.split "~"
55
+ next if s.nil? || t.nil? || s.strip.empty? || t.strip.empty?
56
+ adjacency[s] ||= Set.new
57
+ adjacency[s] << t
58
+ next unless m.undirected
59
+ adjacency[t] ||= Set.new
60
+ adjacency[t] << s
61
+ end
62
+
63
+ return nil unless adjacency.include? start_node
64
+
65
+ active = PriorityQueue.new
66
+ distances = Hash.new { 1.0 / 0.0 }
67
+ parents = Hash.new
68
+
69
+ active[start_node] << 0
70
+ best = 1.0 / 0.0
71
+ found = false
72
+ node_dist_cache = {}
73
+
74
+ until active.empty?
75
+ u = active.priorities.first
76
+ distance = active.shift
77
+ distances[u] = distance
78
+ path = Paths.extract_path(parents, start_node, u) if parents.key?(u)
79
+ next if max_steps && path && path.length > max_steps
80
+ next unless adjacency.include?(u) && adjacency[u] && !adjacency[u].empty?
81
+ adjacency[u].each do |v|
82
+ node_dist = node_dist_cache[[u,v]] ||= (block_given? ? block.call(u,v) : 1)
83
+ next if node_dist.nil? || (threshold && node_dist > threshold)
84
+ d = distance + node_dist
85
+ next unless d < distances[v] && d < best # we can't relax this one
86
+ active[v] << d
87
+ distances[v] = d
88
+ parents[v] = u
89
+ if String === end_node ? (end_node == v) : (end_node && end_node.include?(v))
90
+ best = d
91
+ found = true
92
+ end
93
+ end
94
+ end
95
+
96
+ return nil unless found
97
+
98
+ if end_node
99
+ end_node = (end_node & parents.keys).first unless String === end_node
100
+ return nil unless parents.include? end_node
101
+ Paths.extract_path(parents, start_node, end_node)
102
+ else
103
+ parents
104
+ end
105
+ end
106
+
107
+ # Connected components from a list of AssociationItems.
108
+ # Returns an Array of Arrays of node identifiers.
109
+ def self.components(associations, undirected: true)
110
+ inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
111
+
112
+ adjacency = Hash.new { |h,k| h[k] = [] }
113
+ nodes = Set.new
114
+ targets = inc.fields
115
+
116
+ inc.each do |src, row|
117
+ Array(row).each_with_index do |val, i|
118
+ next if val.nil?
119
+ t = targets[i]
120
+ adjacency[src] << t
121
+ nodes << src << t
122
+ adjacency[t] << src if undirected
123
+ end
124
+ end
125
+
126
+ components = []
127
+ visited = Set.new
128
+
129
+ nodes.each do |n|
130
+ next if visited.include?(n)
131
+ comp = []
132
+ queue = [n]
133
+ visited << n
134
+ until queue.empty?
135
+ u = queue.shift
136
+ comp << u
137
+ adjacency[u].each do |v|
138
+ next if visited.include?(v)
139
+ visited << v
140
+ queue << v
141
+ end
142
+ end
143
+ components << comp
144
+ end
145
+
146
+ components
147
+ end
148
+
149
+ # Degree per node from an AssociationItem list.
150
+ # direction: :out, :in, :both
151
+ def self.degrees(associations, direction: :both)
152
+ inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
153
+ deg = Hash.new(0)
154
+ targets = inc.fields
155
+
156
+ inc.each do |src, row|
157
+ Array(row).each_with_index do |val, i|
158
+ next if val.nil?
159
+ t = targets[i]
160
+ case direction
161
+ when :out
162
+ deg[src] += 1
163
+ when :in
164
+ deg[t] += 1
165
+ when :both
166
+ deg[src] += 1
167
+ deg[t] += 1
168
+ else
169
+ raise ArgumentError, "Unknown direction: #{direction.inspect}"
170
+ end
171
+ end
172
+ end
173
+
174
+ deg
175
+ end
176
+
177
+ # Induced subgraph: keep only edges whose endpoints are in the given node set.
178
+ def self.subset_by_nodes(associations, nodes)
179
+ node_set = nodes.to_set
180
+ associations.select do |m|
181
+ s = m.source rescue nil
182
+ t = m.target rescue nil
183
+ next false if s.nil? || t.nil?
184
+ node_set.include?(s) && node_set.include?(t)
185
+ end
186
+ end
187
+
188
+ # Neighborhood within k steps inside a fixed subgraph, using unweighted BFS
189
+ # over adjacency built from associations.
190
+ def self.neighborhood(associations, seeds, k)
191
+ inc = associations.respond_to?(:incidence) ? associations.incidence : AssociationItem.incidence(associations)
192
+ adjacency = Hash.new { |h,k| h[k] = [] }
193
+ targets = inc.fields
194
+
195
+ inc.each do |src, row|
196
+ Array(row).each_with_index do |val, i|
197
+ next if val.nil?
198
+ t = targets[i]
199
+ adjacency[src] << t
200
+ adjacency[t] << src
201
+ end
202
+ end
203
+
204
+ Paths.neighborhood(adjacency, seeds, k)
205
+ end
206
+ end
@@ -0,0 +1,142 @@
1
+ require 'scout/knowledge_base'
2
+ require 'set'
3
+
4
+ class KnowledgeBase
5
+ # Outward k-hop expansion from seeds over a KB database
6
+ # db: database name (Symbol or String)
7
+ # seeds: Entity or Array of Entities/ids
8
+ # depth: Integer
9
+ # direction: :out, :in, :both
10
+ # filter: optional Proc taking an AssociationItem and returning true/false
11
+ #
12
+ # Returns [visited_nodes, edges], where:
13
+ # - visited_nodes is an Array of identifiers (as returned by AssociationItem.source/target)
14
+ # - edges is an Array of AssociationItems traversed during the expansion
15
+ def radial_expand(db, seeds, depth:, direction: :out, filter: nil)
16
+ current = Array(seeds).compact
17
+ return [[], []] if current.empty?
18
+
19
+ visited = Set.new(current)
20
+ edges = []
21
+
22
+ 1.upto(depth) do
23
+ next_front = []
24
+
25
+ current.each do |entity|
26
+ step_edges = case direction
27
+ when :out
28
+ children(db, entity)
29
+ when :in
30
+ parents(db, entity)
31
+ when :both
32
+ children(db, entity) + parents(db, entity)
33
+ else
34
+ raise ArgumentError, "Unknown direction: #{direction.inspect}"
35
+ end
36
+
37
+ step_edges.each do |item|
38
+ next if filter && ! filter.call(item)
39
+
40
+ s = item.source
41
+ t = item.target
42
+
43
+ edges << item
44
+
45
+ targets = case direction
46
+ when :out then [t]
47
+ when :in then [s]
48
+ when :both then [s, t]
49
+ end
50
+
51
+ targets.each do |node|
52
+ next if visited.include?(node)
53
+ visited << node
54
+ next_front << node
55
+ end
56
+ end
57
+ end
58
+
59
+ break if next_front.empty?
60
+ current = next_front
61
+ end
62
+
63
+ [visited.to_a, edges.uniq]
64
+ end
65
+
66
+ # Return radial layers around seeds as an array of arrays of nodes, plus edges.
67
+ # layers[0] are the seeds, layers[1] nodes at distance 1, ... up to depth or
68
+ # until no more nodes are reached.
69
+ def radial_layers(db, seeds, depth:, direction: :out, filter: nil)
70
+ current = Array(seeds).compact
71
+ return [[], []] if current.empty?
72
+
73
+ layers = []
74
+ visited = Set.new(current)
75
+ edges = []
76
+
77
+ layers << current.dup
78
+
79
+ 1.upto(depth) do
80
+ next_front = []
81
+
82
+ current.each do |entity|
83
+ step_edges = case direction
84
+ when :out
85
+ children(db, entity)
86
+ when :in
87
+ parents(db, entity)
88
+ when :both
89
+ children(db, entity) + parents(db, entity)
90
+ else
91
+ raise ArgumentError, "Unknown direction: #{direction.inspect}"
92
+ end
93
+
94
+ step_edges.each do |item|
95
+ next if filter && ! filter.call(item)
96
+
97
+ s = item.source
98
+ t = item.target
99
+
100
+ edges << item
101
+
102
+ targets = case direction
103
+ when :out then [t]
104
+ when :in then [s]
105
+ when :both then [s, t]
106
+ end
107
+
108
+ targets.each do |node|
109
+ next if visited.include?(node)
110
+ visited << node
111
+ next_front << node
112
+ end
113
+ end
114
+ end
115
+
116
+ break if next_front.empty?
117
+ layers << next_front
118
+ current = next_front
119
+ end
120
+
121
+ [layers, edges.uniq]
122
+ end
123
+
124
+ # Directional convenience wrappers
125
+ def radial_expand_out(db, seeds, depth:, filter: nil)
126
+ radial_expand(db, seeds, depth: depth, direction: :out, filter: filter)
127
+ end
128
+
129
+ def radial_expand_in(db, seeds, depth:, filter: nil)
130
+ radial_expand(db, seeds, depth: depth, direction: :in, filter: filter)
131
+ end
132
+
133
+ def radial_expand_both(db, seeds, depth:, filter: nil)
134
+ radial_expand(db, seeds, depth: depth, direction: :both, filter: filter)
135
+ end
136
+
137
+ # Convenience: only return the edges in a k-hop subgraph around seeds
138
+ def subgraph_around(db, seeds, depth:, direction: :out, filter: nil)
139
+ _visited, edges = radial_expand(db, seeds, depth: depth, direction: direction, filter: filter)
140
+ edges
141
+ end
142
+ end
@@ -0,0 +1,177 @@
1
+ require 'fc'
2
+
3
+ module Paths
4
+
5
+ def self.extract_path(parents, start_node, end_node)
6
+ path = [end_node]
7
+ while not path.last === start_node
8
+ path << parents[path.last]
9
+ end
10
+ path
11
+ end
12
+
13
+ def self.dijkstra(adjacency, start_node, end_node = nil, max_steps = nil)
14
+
15
+ return nil unless adjacency.include? start_node
16
+
17
+ case end_node
18
+ when String
19
+ return nil unless adjacency.values.flatten.include? end_node
20
+ when Array
21
+ return nil unless (adjacency.values.flatten & end_node).any?
22
+ end
23
+
24
+ active = FastContainers::PriorityQueue.new(:min)
25
+ distances = Hash.new { 1.0 / 0.0 }
26
+ parents = Hash.new
27
+
28
+ active.push(start_node, 0)
29
+ best = 1.0 / 0.0
30
+ until active.empty?
31
+ u = active.top
32
+ distance = active.top_key
33
+ active.pop
34
+
35
+ distances[u] = distance
36
+ d = distance + 1
37
+ path = extract_path(parents, start_node, u)
38
+ next if path.length > max_steps if max_steps
39
+ adjacency[u].each do |v|
40
+ next unless d < distances[v] and d < best # we can't relax this one
41
+ best = d if (String === end_node ? end_node == v : end_node.include?(v))
42
+ active.push(v,d) if adjacency.include? v
43
+ distances[v] = d
44
+
45
+
46
+ parents[v] = u
47
+ end
48
+ end
49
+
50
+ if end_node
51
+ end_node = end_node.select{|n| parents.keys.include? n}.first unless String === end_node
52
+ return nil if not parents.include? end_node
53
+ extract_path(parents, start_node, end_node)
54
+ else
55
+ parents
56
+ end
57
+ end
58
+
59
+ def self.weighted_dijkstra(adjacency, start_node, end_node = nil, threshold = nil, max_steps = nil)
60
+ return nil unless adjacency.include? start_node
61
+
62
+ active = FastContainers::PriorityQueue.new(:min)
63
+ distances = Hash.new { 1.0 / 0.0 }
64
+ parents = Hash.new
65
+
66
+ active.push(start_node, 0)
67
+ best = 1.0 / 0.0
68
+ found = false
69
+ until active.empty?
70
+ u = active.top
71
+ distance = active.top_key
72
+ active.pop
73
+ distances[u] = distance
74
+ path = extract_path(parents, start_node, u)
75
+ next if path.length > max_steps if max_steps
76
+ next if not adjacency.include?(u) or (adjacency[u].nil? or adjacency[u].empty? )
77
+ NamedArray.zip_fields(adjacency[u]).each do |v,node_dist|
78
+ node_dist = node_dist.to_f
79
+ next if node_dist.nil? or (threshold and node_dist > threshold)
80
+ d = distance + node_dist
81
+ next unless d < distances[v] and d < best # we can't relax this one
82
+ active.push(v, d)
83
+ distances[v] = d
84
+ parents[v] = u
85
+ if (String === end_node ? end_node == v : end_node.include?(v))
86
+ best = d
87
+ found = true
88
+ end
89
+ end
90
+ end
91
+
92
+ return nil unless found
93
+
94
+ if end_node
95
+ end_node = (end_node & parents.keys).first unless String === end_node
96
+ return nil if not parents.include? end_node
97
+ extract_path(parents, start_node, end_node)
98
+ else
99
+ parents
100
+ end
101
+ end
102
+
103
+ def self.random_weighted_dijkstra(adjacency, l, start_node, end_node = nil)
104
+ return nil unless adjacency.include? start_node
105
+
106
+ active = PriorityQueue.new
107
+ distances = Hash.new { 1.0 / 0.0 }
108
+ parents = Hash.new
109
+
110
+ active[start_node] << 0
111
+ best = 1.0 / 0.0
112
+ until active.empty?
113
+ u = active.priorities.first
114
+ distance = active.shift
115
+ distances[u] = distance
116
+ next if not adjacency.include?(u) or adjacency[u].nil? or adjacency[u].empty?
117
+ NamedArray.zip_fields(adjacency[u]).each do |v,node_dist|
118
+ next if node_dist.nil?
119
+ d = distance + (node_dist * (l + rand))
120
+ next unless d < distances[v] and d < best # we can't relax this one
121
+ active[v] << distances[v] = d
122
+ parents[v] = u
123
+ best = d if (String === end_node ? end_node == v : end_node.include?(v))
124
+ end
125
+ end
126
+
127
+ if end_node
128
+ end_node = (end_node & parents.keys).first unless String === end_node
129
+ return nil if not parents.include? end_node
130
+ path = [end_node]
131
+ while not path.last === start_node
132
+ path << parents[path.last]
133
+ end
134
+ path
135
+ else
136
+ parents
137
+ end
138
+ end
139
+
140
+ # New: breadth‑first exploration from one or many start nodes (unweighted)
141
+ # adjacency: Hash[String => Array[String]]
142
+ # sources: String or Array[String]
143
+ # max_steps: Integer or nil (no limit)
144
+ # Returns Hash[node => distance_from_any_source]
145
+ def self.breadth_first(adjacency, sources, max_steps = nil)
146
+ sources = [sources] unless Array === sources
147
+ distances = {}
148
+ queue = []
149
+
150
+ sources.each do |s|
151
+ next unless adjacency.include?(s)
152
+ distances[s] = 0
153
+ queue << s
154
+ end
155
+
156
+ until queue.empty?
157
+ u = queue.shift
158
+ d = distances[u]
159
+ next if max_steps && d >= max_steps
160
+ next unless adjacency.include?(u)
161
+ adjacency[u].each do |v|
162
+ next if distances.key?(v)
163
+ distances[v] = d + 1
164
+ queue << v
165
+ end
166
+ end
167
+
168
+ distances
169
+ end
170
+
171
+ # New: enumerate nodes within k steps of a set of sources (unweighted)
172
+ # Convenience wrapper over breadth_first
173
+ def self.neighborhood(adjacency, sources, k)
174
+ distances = breadth_first(adjacency, sources, k)
175
+ distances.keys
176
+ end
177
+ end
data/scout-ai.gemspec CHANGED
@@ -2,11 +2,11 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: scout-ai 1.1.6 ruby lib
5
+ # stub: scout-ai 1.1.7 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "scout-ai".freeze
9
- s.version = "1.1.6".freeze
9
+ s.version = "1.1.7".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
@@ -79,6 +79,9 @@ Gem::Specification.new do |s|
79
79
  "lib/scout/model/python/torch/load_and_save.rb",
80
80
  "lib/scout/model/util/run.rb",
81
81
  "lib/scout/model/util/save.rb",
82
+ "lib/scout/network/entity.rb",
83
+ "lib/scout/network/knowledge_base.rb",
84
+ "lib/scout/network/paths.rb",
82
85
  "python/scout_ai/__init__.py",
83
86
  "python/scout_ai/huggingface/data.py",
84
87
  "python/scout_ai/huggingface/eval.py",
@@ -135,11 +138,14 @@ Gem::Specification.new do |s|
135
138
  "test/scout/model/python/torch/test_helpers.rb",
136
139
  "test/scout/model/test_base.rb",
137
140
  "test/scout/model/util/test_save.rb",
141
+ "test/scout/network/test_entity.rb",
142
+ "test/scout/network/test_knowledge_base.rb",
143
+ "test/scout/network/test_paths.rb",
138
144
  "test/test_helper.rb"
139
145
  ]
140
146
  s.homepage = "http://github.com/mikisvaz/scout-ai".freeze
141
147
  s.licenses = ["MIT".freeze]
142
- s.rubygems_version = "3.7.0.dev".freeze
148
+ s.rubygems_version = "3.7.2".freeze
143
149
  s.summary = "AI gear for scouts".freeze
144
150
 
145
151
  s.specification_version = 4
@@ -19,7 +19,7 @@ Some text
19
19
  assert(Float === emb.first)
20
20
  end
21
21
 
22
- def test_tool
22
+ def __test_tool
23
23
  prompt =<<-EOF
24
24
  What is the weather in London. Should I take my umbrella? Use the provided tool
25
25
  EOF
@@ -13,9 +13,25 @@ class TestLLMAgent < Test::Unit::TestCase
13
13
 
14
14
  agent = LLM::Agent.new knowledge_base: kb
15
15
 
16
- sss 0
17
16
  ppp agent.ask "Who is Miguel's brother-in-law. Brother in law is your spouses sibling or your sibling's spouse"
18
17
  end
19
18
  end
19
+
20
+ def test_workflow_eval
21
+ agent = LLM::Agent.new
22
+ agent.workflow do
23
+ input :c_degrees, :float, "Degrees Celsius"
24
+
25
+ task :c_to_f => :float do |c_degrees|
26
+ (c_degrees * 9.0 / 5.0) + 32.0
27
+ end
28
+
29
+ export :c_to_f
30
+ end
31
+
32
+ agent.user "Convert 30 celsius into faranheit"
33
+ res = agent.json_format({conversion: {type: :number}})
34
+ assert_equal 86.0, res['conversion']
35
+ end
20
36
  end
21
37
 
@@ -0,0 +1,47 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\\1')
3
+
4
+ require 'scout/network/entity'
5
+ require 'scout/knowledge_base'
6
+
7
+ class TestNetworkEntityAssociationItem < Test::Unit::TestCase
8
+ def build_pairs
9
+ kb = KnowledgeBase.new tmpdir
10
+ kb.register :brothers, datafile_test(:person).brothers, undirected: true
11
+ kb.all(:brothers)
12
+ end
13
+
14
+ def test_components
15
+ pairs = build_pairs
16
+ comps = AssociationItem.components(pairs)
17
+ # All nodes from brothers should appear in the (single) component
18
+ flat = comps.flatten
19
+ %w[Miki Isa Clei Guille].each do |name|
20
+ assert_include flat, name
21
+ end
22
+ assert_equal 1, comps.length
23
+ end
24
+
25
+ def test_degrees
26
+ pairs = build_pairs
27
+ deg = AssociationItem.degrees(pairs)
28
+ # In brothers example, Miki and Isa each connected to at least one
29
+ assert deg['Miki'] > 0
30
+ assert deg['Isa'] > 0
31
+ end
32
+
33
+ def test_subset_by_nodes
34
+ pairs = build_pairs
35
+ sub = AssociationItem.subset_by_nodes(pairs, %w[Miki Isa])
36
+ assert sub.any? { |e| [e.source, e.target].sort == %w[Isa Miki].sort }
37
+ # Edges involving others should be filtered out
38
+ refute sub.any? { |e| (e.source == 'Clei') || (e.target == 'Clei') }
39
+ end
40
+
41
+ def test_neighborhood_inside_subgraph
42
+ pairs = build_pairs
43
+ neigh = AssociationItem.neighborhood(pairs, 'Miki', 1)
44
+ assert_include neigh, 'Miki'
45
+ assert_include neigh, 'Isa'
46
+ end
47
+ end