factbase 0.17.1 → 0.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91ef3ed499ea15abb77bc39cfed30eb9af704921e86c3c03c7bdd9b13cb44270
4
- data.tar.gz: 3c2e80d00620e0de14ec4be35ff08b28393793c69742dda9bda55c4a5ae01c8f
3
+ metadata.gz: c20d4add4664149fc5c070ec64321a79c8ac07ef81c69ae2b64bac1e9fd0c4e9
4
+ data.tar.gz: ff9a8f121d9cfafa6ad97a634c79fc2ec834a07ee0d17042eb3fc5fa8c529501
5
5
  SHA512:
6
- metadata.gz: 9c6e18a32a5ae773e78672e449cce031a2c7641b1291b9f637ccb28afb6afddc87091955ce06e75c0e35326f4db37b04fac5b827b1cdc203a8b0262c105ca2c3
7
- data.tar.gz: 923207d8494313e1af4fae0ecf3068372a5dde28924840edbe1b6d625127afb7e4eb2cf44c9f86a28d9d7fc650336abce9c9b99cd492b3ff29bcbb4cf214c83e
6
+ metadata.gz: 3f93283308d6f15766fca09f253da85145efc28975e51259ee88f0954cc6a7f584700de2ff604478bd5a48f1c86153243949a6280732a466412a739f38e9a1e2
7
+ data.tar.gz: e21602f7612821dd2389e831056389900f62a3ef3b1bf998f6ebbe722cfc9684357383c754ad0556a346534cf7a6bc76489706e0f0f58ea23336a918be71b442
data/Gemfile CHANGED
@@ -12,7 +12,7 @@ gem 'minitest-reporters', '~>1.7', require: false
12
12
  gem 'os', '~>1.1', require: false
13
13
  gem 'qbash', '~>0.4', require: false
14
14
  gem 'rake', '~>13.2', require: false
15
- gem 'rdoc', '6.16.1', require: false # GPL
15
+ gem 'rdoc', '6.17.0', require: false # GPL
16
16
  gem 'rubocop', '~>1.74', require: false
17
17
  gem 'rubocop-minitest', '~>0.38', require: false
18
18
  gem 'rubocop-performance', '~>1.25', require: false
data/Gemfile.lock CHANGED
@@ -68,7 +68,7 @@ GEM
68
68
  racc (1.8.1)
69
69
  rainbow (3.1.1)
70
70
  rake (13.3.1)
71
- rdoc (6.16.1)
71
+ rdoc (6.17.0)
72
72
  erb
73
73
  psych (>= 4.0.0)
74
74
  tsort
@@ -126,6 +126,7 @@ PLATFORMS
126
126
  arm64-darwin-22
127
127
  arm64-darwin-23
128
128
  arm64-darwin-24
129
+ arm64-darwin-25
129
130
  x64-mingw-ucrt
130
131
  x86_64-darwin-20
131
132
  x86_64-darwin-21
@@ -140,7 +141,7 @@ DEPENDENCIES
140
141
  os (~> 1.1)
141
142
  qbash (~> 0.4)
142
143
  rake (~> 13.2)
143
- rdoc (= 6.16.1)
144
+ rdoc (= 6.17.0)
144
145
  rubocop (~> 1.74)
145
146
  rubocop-minitest (~> 0.38)
146
147
  rubocop-performance (~> 1.25)
data/README.md CHANGED
@@ -210,36 +210,54 @@ This is the result of the benchmark:
210
210
 
211
211
  <!-- benchmark_begin -->
212
212
  ```text
213
- user
214
- insert 20000 facts 0.637574
215
- export 20000 facts 0.019855
216
- import 410750 bytes (20000 facts) 0.035065
217
- insert 10 facts 0.044615
218
- query 10 times w/txn 2.455739
219
- query 10 times w/o txn 0.050423
220
- modify 10 attrs w/txn 1.894240
221
- delete 10 facts w/txn 1.043079
222
- (and (eq what 'issue-was-closed') (exists... -> 200 1.279827
223
- (and (eq what 'issue-was-closed') (exists... -> 200/txn 1.263302
224
- (and (eq what 'issue-was-closed') (exists... -> zero 1.253773
225
- (and (eq what 'issue-was-closed') (exists... -> zero/txn 1.292489
226
- (gt time '2024-03-23T03:21:43Z') 0.398074
227
- (gt cost 50) 0.254020
228
- (eq title 'Object Thinking 5000') 0.037238
229
- (and (eq foo 42.998) (or (gt bar 200) (absent z... 0.047599
230
- (and (exists foo) (not (exists blue))) 1.115156
231
- (eq id (agg (always) (max id))) 0.711832
232
- (join "c<=cost,b<=bar" (eq id (agg (always) (ma... 1.393622
233
- (and (eq what "foo") (join "w<=what" (and (eq i... 7.428505
234
- delete! 0.272962
235
- Taped.append() x50000 0.020619
236
- Taped.each() x125 1.721876
237
- Taped.delete_if() x375 0.844673
213
+
214
+ query all facts from an empty factbase 0.00
215
+ insert 20000 facts 0.67
216
+ export 20000 facts 0.02
217
+ import 411032 bytes (20000 facts) 0.03
218
+ insert 10 facts 0.04
219
+ query 10 times w/txn 2.52
220
+ query 10 times w/o txn 0.05
221
+ modify 10 attrs w/txn 1.84
222
+ delete 10 facts w/txn 2.98
223
+ build index on 5000 facts 0.05
224
+ export 5000 facts with index 0.03
225
+ import 5000 facts with persisted index 0.05
226
+ query 5000 facts using persisted index 0.11
227
+ export 5000 facts without index 0.00
228
+ import 5000 facts without index 0.04
229
+ query 5000 facts building index on-the-fly 0.10
230
+ (and (eq what 'issue-was-closed') (exists... -> 200 1.21
231
+ (and (eq what 'issue-was-closed') (exists... -> 200/txn 1.20
232
+ (and (eq what 'issue-was-closed') (exists... -> zero 1.21
233
+ (and (eq what 'issue-was-closed') (exists... -> zero/txn 1.23
234
+ transaction rollback on factbase with 100000 facts 0.25
235
+ (gt time '2024-03-23T03:21:43Z') 0.33
236
+ (gt cost 50) 0.21
237
+ (eq title 'Object Thinking 5000') 0.05
238
+ (and (eq foo 42.998) (or (gt bar 200) (absent z... 0.05
239
+ (and (exists foo) (not (exists blue))) 1.68
240
+ (eq id (agg (always) (max id))) 2.76
241
+ (join "c<=cost,b<=bar" (eq id (agg (always) (ma... 4.02
242
+ (and (eq what "foo") (join "w<=what" (and (eq i... 7.68
243
+ delete! 0.56
244
+ (and (eq issue *) (eq repository *) (eq what '*') (eq where '*')) 2.75
245
+ Taped.append() x50000 0.16
246
+ Taped.each() x125 1.63
247
+ Taped.delete_if() x375 0.84
248
+ 50000 facts: read-only txn (no copy needed) 7.60
249
+ 50000 facts: rollback txn (no copy needed) 7.47
250
+ 50000 facts: insert in txn (copy triggered) 3.64
251
+ 50000 facts: modify in txn (copy triggered) 39.42
252
+ 100000 facts: read-only txn (no copy needed) 15.23
253
+ 100000 facts: rollback txn (no copy needed) 15.17
254
+ 100000 facts: insert in txn (copy triggered) 7.45
255
+ 100000 facts: modify in txn (copy triggered) 73.97
238
256
  ```
239
257
 
240
258
  The results were calculated in [this GHA job][benchmark-gha]
241
- on 2025-10-15 at 14:53,
259
+ on 2025-12-11 at 13:29,
242
260
  on Linux with 4 CPUs.
243
261
  <!-- benchmark_end -->
244
262
 
245
- [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/18533080805
263
+ [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/20134646978
@@ -6,7 +6,7 @@
6
6
  require 'others'
7
7
  require_relative '../../factbase'
8
8
 
9
- # A single fact in a factbase, which is sentitive to changes.
9
+ # A single fact in a factbase, which is sensitive to changes.
10
10
  #
11
11
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
12
12
  # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
@@ -15,9 +15,11 @@ class Factbase::CachedFact
15
15
  # Ctor.
16
16
  # @param [Factbase::Fact] origin The original fact
17
17
  # @param [Hash] cache Cache of queries (to clean it on attribute addition)
18
- def initialize(origin, cache)
18
+ # @param [Boolean] fresh True if this is a newly inserted fact (not yet in cache)
19
+ def initialize(origin, cache, fresh: false)
19
20
  @origin = origin
20
21
  @cache = cache
22
+ @fresh = fresh
21
23
  end
22
24
 
23
25
  def to_s
@@ -26,7 +28,9 @@ class Factbase::CachedFact
26
28
 
27
29
  # When a method is missing, this method is called.
28
30
  others do |*args|
29
- @cache.clear if args[0].to_s.end_with?('=')
31
+ # Only clear cache when modifying properties on existing (non-fresh) facts
32
+ # Fresh facts are not in the cache yet, so modifications don't affect it
33
+ @cache.clear if args[0].to_s.end_with?('=') && !@fresh
30
34
  @origin.send(*args)
31
35
  end
32
36
  end
@@ -31,8 +31,7 @@ class Factbase::CachedFactbase
31
31
  # Insert a new fact and return it.
32
32
  # @return [Factbase::Fact] The fact just inserted
33
33
  def insert
34
- @cache.clear
35
- Factbase::CachedFact.new(@origin.insert, @cache)
34
+ Factbase::CachedFact.new(@origin.insert, @cache, fresh: true)
36
35
  end
37
36
 
38
37
  # Convert a query to a term.
@@ -13,13 +13,19 @@ class Factbase::IndexedAbsent
13
13
  def predict(maps, _fb, _params)
14
14
  return nil if @idx.nil?
15
15
  key = [maps.object_id, @term.operands.first, @term.op]
16
- if @idx[key].nil?
17
- @idx[key] = []
16
+ entry = @idx[key]
17
+ maps_array = maps.to_a
18
+ if entry.nil?
19
+ entry = { facts: [], indexed_count: 0 }
20
+ @idx[key] = entry
21
+ end
22
+ if entry[:indexed_count] < maps_array.size
18
23
  prop = @term.operands.first.to_s
19
- maps.to_a.each do |m|
20
- @idx[key].append(m) if m[prop].nil?
24
+ maps_array[entry[:indexed_count]..].each do |m|
25
+ entry[:facts] << m if m[prop].nil?
21
26
  end
27
+ entry[:indexed_count] = maps_array.size
22
28
  end
23
- (maps & []) | @idx[key]
29
+ (maps & []) | entry[:facts]
24
30
  end
25
31
  end
@@ -18,14 +18,20 @@ class Factbase::IndexedAnd
18
18
  && @term.operands.all? { |o| o.operands.first.is_a?(Symbol) && _scalar?(o.operands[1]) }
19
19
  props = @term.operands.map { |o| o.operands.first }.sort
20
20
  key = [maps.object_id, props, :multi_and_eq]
21
- if @idx[key].nil?
22
- @idx[key] = {}
23
- maps.to_a.each do |m|
21
+ entry = @idx[key]
22
+ maps_array = maps.to_a
23
+ if entry.nil?
24
+ entry = { index: {}, indexed_count: 0 }
25
+ @idx[key] = entry
26
+ end
27
+ if entry[:indexed_count] < maps_array.size
28
+ maps_array[entry[:indexed_count]..].each do |m|
24
29
  _all_tuples(m, props).each do |t|
25
- @idx[key][t] = [] if @idx[key][t].nil?
26
- @idx[key][t].append(m)
30
+ entry[:index][t] ||= []
31
+ entry[:index][t] << m
27
32
  end
28
33
  end
34
+ entry[:indexed_count] = maps_array.size
29
35
  end
30
36
  tuples = Enumerator.product(
31
37
  *@term.operands.sort_by { |o| o.operands.first }.map do |o|
@@ -36,7 +42,7 @@ class Factbase::IndexedAnd
36
42
  end
37
43
  end
38
44
  )
39
- j = tuples.map { |t| @idx[key][t] || [] }.reduce(&:|)
45
+ j = tuples.map { |t| entry[:index][t] || [] }.reduce(&:|)
40
46
  r = (maps & []) | j
41
47
  else
42
48
  @term.operands.each do |o|
@@ -14,15 +14,21 @@ class Factbase::IndexedEq
14
14
  return nil if @idx.nil?
15
15
  key = [maps.object_id, @term.operands.first, @term.op]
16
16
  return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
17
- if @idx[key].nil?
18
- @idx[key] = {}
17
+ entry = @idx[key]
18
+ maps_array = maps.to_a
19
+ if entry.nil?
20
+ entry = { index: {}, indexed_count: 0 }
21
+ @idx[key] = entry
22
+ end
23
+ if entry[:indexed_count] < maps_array.size
19
24
  prop = @term.operands.first.to_s
20
- maps.to_a.each do |m|
25
+ maps_array[entry[:indexed_count]..].each do |m|
21
26
  m[prop]&.each do |v|
22
- @idx[key][v] = [] if @idx[key][v].nil?
23
- @idx[key][v].append(m)
27
+ entry[:index][v] ||= []
28
+ entry[:index][v] << m
24
29
  end
25
30
  end
31
+ entry[:indexed_count] = maps_array.size
26
32
  end
27
33
  vv =
28
34
  if @term.operands[1].is_a?(Symbol)
@@ -33,7 +39,7 @@ class Factbase::IndexedEq
33
39
  if vv.empty?
34
40
  (maps & [])
35
41
  else
36
- j = vv.map { |v| @idx[key][v] || [] }.reduce(&:|)
42
+ j = vv.map { |v| entry[:index][v] || [] }.reduce(&:|)
37
43
  (maps & []) | j
38
44
  end
39
45
  end
@@ -13,13 +13,19 @@ class Factbase::IndexedExists
13
13
  def predict(maps, _fb, _params)
14
14
  return nil if @idx.nil?
15
15
  key = [maps.object_id, @term.operands.first, @term.op]
16
- if @idx[key].nil?
17
- @idx[key] = []
16
+ entry = @idx[key]
17
+ maps_array = maps.to_a
18
+ if entry.nil?
19
+ entry = { facts: [], indexed_count: 0 }
20
+ @idx[key] = entry
21
+ end
22
+ if entry[:indexed_count] < maps_array.size
18
23
  prop = @term.operands.first.to_s
19
- maps.to_a.each do |m|
20
- @idx[key].append(m) unless m[prop].nil?
24
+ maps_array[entry[:indexed_count]..].each do |m|
25
+ entry[:facts] << m unless m[prop].nil?
21
26
  end
27
+ entry[:indexed_count] = maps_array.size
22
28
  end
23
- (maps & []) | @idx[key]
29
+ (maps & []) | entry[:facts]
24
30
  end
25
31
  end
@@ -6,7 +6,7 @@
6
6
  require 'others'
7
7
  require_relative '../../factbase'
8
8
 
9
- # A single fact in a factbase, which is sentitive to changes.
9
+ # A single fact in a factbase, which is sensitive to changes.
10
10
  #
11
11
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
12
12
  # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
@@ -15,9 +15,11 @@ class Factbase::IndexedFact
15
15
  # Ctor.
16
16
  # @param [Factbase::Fact] origin The original fact
17
17
  # @param [Hash] idx The index
18
- def initialize(origin, idx)
18
+ # @param [Boolean] fresh True if this is a newly inserted fact (not yet in index)
19
+ def initialize(origin, idx, fresh: false)
19
20
  @origin = origin
20
21
  @idx = idx
22
+ @fresh = fresh
21
23
  end
22
24
 
23
25
  def to_s
@@ -26,7 +28,9 @@ class Factbase::IndexedFact
26
28
 
27
29
  # When a method is missing, this method is called.
28
30
  others do |*args|
29
- @idx.clear if args[0].to_s.end_with?('=')
31
+ # Only clear index when modifying properties on existing (non-fresh) facts
32
+ # Fresh facts are not in the index yet, so modifications don't affect it
33
+ @idx.clear if args[0].to_s.end_with?('=') && !@fresh
30
34
  @origin.send(*args)
31
35
  end
32
36
  end
@@ -31,8 +31,7 @@ class Factbase::IndexedFactbase
31
31
  # Insert a new fact and return it.
32
32
  # @return [Factbase::Fact] The fact just inserted
33
33
  def insert
34
- @idx.clear
35
- Factbase::IndexedFact.new(@origin.insert, @idx)
34
+ Factbase::IndexedFact.new(@origin.insert, @idx, fresh: true)
36
35
  end
37
36
 
38
37
  # Convert a query to a term.
@@ -15,21 +15,27 @@ class Factbase::IndexedGt
15
15
  return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
16
16
  prop = @term.operands.first.to_s
17
17
  cache_key = [maps.object_id, @term.operands.first, :sorted]
18
- if @idx[cache_key].nil?
19
- @idx[cache_key] = []
20
- maps.to_a.each do |m|
18
+ entry = @idx[cache_key]
19
+ maps_array = maps.to_a
20
+ if entry.nil?
21
+ entry = { sorted: [], indexed_count: 0 }
22
+ @idx[cache_key] = entry
23
+ end
24
+ if entry[:indexed_count] < maps_array.size
25
+ maps_array[entry[:indexed_count]..].each do |m|
21
26
  values = m[prop]
22
27
  next if values.nil?
23
28
  values.each do |v|
24
- @idx[cache_key] << [v, m]
29
+ entry[:sorted] << [v, m]
25
30
  end
26
31
  end
27
- @idx[cache_key].sort_by! { |pair| pair[0] }
32
+ entry[:sorted].sort_by! { |pair| pair[0] }
33
+ entry[:indexed_count] = maps_array.size
28
34
  end
29
35
  threshold = @term.operands[1].is_a?(Symbol) ? params[@term.operands[1].to_s]&.first : @term.operands[1]
30
36
  return nil if threshold.nil?
31
- i = @idx[cache_key].bsearch_index { |pair| pair[0] > threshold } || @idx[cache_key].size
32
- result = @idx[cache_key][i..].map { |pair| pair[1] }.uniq
37
+ i = entry[:sorted].bsearch_index { |pair| pair[0] > threshold } || entry[:sorted].size
38
+ result = entry[:sorted][i..].map { |pair| pair[1] }.uniq
33
39
  (maps & []) | result
34
40
  end
35
41
 
@@ -15,21 +15,32 @@ class Factbase::IndexedLt
15
15
  return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
16
16
  prop = @term.operands.first.to_s
17
17
  cache_key = [maps.object_id, @term.operands.first, :sorted]
18
- if @idx[cache_key].nil?
19
- @idx[cache_key] = []
20
- maps.to_a.each do |m|
18
+ entry = @idx[cache_key]
19
+ maps_array = maps.to_a
20
+ if entry.nil?
21
+ entry = { sorted: [], indexed_count: 0 }
22
+ @idx[cache_key] = entry
23
+ end
24
+ if entry[:indexed_count] < maps_array.size
25
+ new_pairs = []
26
+ maps_array[entry[:indexed_count]..].each do |m|
21
27
  values = m[prop]
22
28
  next if values.nil?
23
29
  values.each do |v|
24
- @idx[cache_key] << [v, m]
30
+ new_pairs << [v, m]
25
31
  end
26
32
  end
27
- @idx[cache_key].sort_by! { |pair| pair[0] }
33
+ unless new_pairs.empty?
34
+ entry[:sorted].concat(new_pairs)
35
+ entry[:sorted].sort_by! { |pair| pair[0] }
36
+ end
37
+ entry[:indexed_count] = maps_array.size
28
38
  end
39
+
29
40
  threshold = @term.operands[1].is_a?(Symbol) ? params[@term.operands[1].to_s]&.first : @term.operands[1]
30
41
  return nil if threshold.nil?
31
- i = @idx[cache_key].bsearch_index { |pair| pair[0] >= threshold } || @idx[cache_key].size
32
- result = @idx[cache_key][0...i].map { |pair| pair[1] }.uniq
42
+ i = entry[:sorted].bsearch_index { |pair| pair[0] >= threshold } || entry[:sorted].size
43
+ result = entry[:sorted][0...i].map { |pair| pair[1] }.uniq
33
44
  (maps & []) | result
34
45
  end
35
46
 
@@ -13,16 +13,25 @@ class Factbase::IndexedNot
13
13
  def predict(maps, fb, params)
14
14
  return nil if @idx.nil?
15
15
  key = [maps.object_id, @term.operands.first, @term.op]
16
- if @idx[key].nil?
16
+ entry = @idx[key]
17
+ maps_array = maps.to_a
18
+ if entry.nil?
19
+ entry = { facts: nil, indexed_count: 0, yes_set: nil }
20
+ @idx[key] = entry
21
+ end
22
+ if entry[:indexed_count] < maps_array.size
17
23
  yes = @term.operands.first.predict(maps, fb, params)
18
24
  if yes.nil?
19
- @idx[key] = { r: nil }
25
+ entry[:facts] = nil
26
+ entry[:yes_set] = nil
20
27
  else
21
- yes = yes.to_a.to_set
22
- @idx[key] = { r: maps.to_a.reject { |m| yes.include?(m) } }
28
+ yes_set = yes.to_a.to_set
29
+ entry[:yes_set] = yes_set
30
+ entry[:facts] = maps_array.reject { |m| yes_set.include?(m) }
23
31
  end
32
+ entry[:indexed_count] = maps_array.size
24
33
  end
25
- r = @idx[key][:r]
34
+ r = entry[:facts]
26
35
  if r.nil?
27
36
  nil
28
37
  else
@@ -13,13 +13,19 @@ class Factbase::IndexedOne
13
13
  def predict(maps, _fb, _params)
14
14
  return nil if @idx.nil?
15
15
  key = [maps.object_id, @term.operands.first, @term.op]
16
- if @idx[key].nil?
17
- @idx[key] = []
16
+ entry = @idx[key]
17
+ maps_array = maps.to_a
18
+ if entry.nil?
19
+ entry = { facts: [], indexed_count: 0 }
20
+ @idx[key] = entry
21
+ end
22
+ if entry[:indexed_count] < maps_array.size
18
23
  prop = @term.operands.first.to_s
19
- maps.to_a.each do |m|
20
- @idx[key].append(m) if !m[prop].nil? && m[prop].size == 1
24
+ maps_array[entry[:indexed_count]..].each do |m|
25
+ entry[:facts] << m if !m[prop].nil? && m[prop].size == 1
21
26
  end
27
+ entry[:indexed_count] = maps_array.size
22
28
  end
23
- (maps & []) | @idx[key]
29
+ (maps & []) | entry[:facts]
24
30
  end
25
31
  end
@@ -16,10 +16,19 @@ class Factbase::IndexedUnique
16
16
  def predict(maps, _fb, _params)
17
17
  return nil if @idx.nil?
18
18
  key = [maps.object_id, @term.operands.first, @term.op]
19
- if @idx[key].nil?
19
+ entry = @idx[key]
20
+ maps_array = maps.to_a
21
+ if entry.nil?
22
+ entry = { facts: [], indexed_count: 0 }
23
+ @idx[key] = entry
24
+ end
25
+ if entry[:indexed_count] < maps_array.size
20
26
  props = @term.operands.map(&:to_s)
21
- @idx[key] = maps.to_a.select { |m| props.all? { |p| !m[p].nil? } }
27
+ maps_array[entry[:indexed_count]..].each do |m|
28
+ entry[:facts] << m if props.all? { |p| !m[p].nil? }
29
+ end
30
+ entry[:indexed_count] = maps_array.size
22
31
  end
23
- (maps & []) | @idx[key]
32
+ (maps & []) | entry[:facts]
24
33
  end
25
34
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+ require_relative 'taped'
8
+ require_relative 'lazy_taped_hash'
9
+
10
+ # A lazy decorator of an Array with HashMaps that defers copying until modification.
11
+ class Factbase::LazyTaped
12
+ def initialize(origin)
13
+ @origin = origin
14
+ @copied = false
15
+ @maps = nil
16
+ @pairs = nil
17
+ @inserted = []
18
+ @deleted = []
19
+ @added = []
20
+ end
21
+
22
+ # Returns a hash mapping copied maps to their originals.
23
+ # This is used during transaction commit to identify which original facts
24
+ # were modified, allowing the factbase to update the correct entries.
25
+ def pairs
26
+ return {} unless @pairs
27
+ result = {}.compare_by_identity
28
+ @pairs.each { |copied, original| result[copied] = original }
29
+ result
30
+ end
31
+
32
+ # Returns the unique object IDs of maps that were inserted (newly created).
33
+ # This is used during transaction commit to identify new facts that need
34
+ # to be added to the factbase.
35
+ def inserted
36
+ @inserted.uniq
37
+ end
38
+
39
+ # Returns the unique object IDs of maps that were deleted.
40
+ # This is used during transaction commit to identify facts that need
41
+ # to be removed from the factbase.
42
+ def deleted
43
+ @deleted.uniq
44
+ end
45
+
46
+ # Returns the unique object IDs of maps that were modified (properties added).
47
+ # This is used during transaction commit to track the churn (number of changes).
48
+ def added
49
+ @added.uniq
50
+ end
51
+
52
+ def find_by_object_id(oid)
53
+ (@maps || @origin).find { |m| m.object_id == oid }
54
+ end
55
+
56
+ def size
57
+ (@maps || @origin).size
58
+ end
59
+
60
+ def empty?
61
+ (@maps || @origin).empty?
62
+ end
63
+
64
+ def <<(map)
65
+ ensure_copied
66
+ @maps << map
67
+ @inserted.append(map.object_id)
68
+ end
69
+
70
+ def each
71
+ return to_enum(__method__) unless block_given?
72
+ if @copied
73
+ @maps.each do |m|
74
+ yield Factbase::Taped::TapedHash.new(m, @added)
75
+ end
76
+ else
77
+ @origin.each do |m|
78
+ yield LazyTapedHash.new(m, self, @added)
79
+ end
80
+ end
81
+ end
82
+
83
+ def delete_if
84
+ ensure_copied
85
+ @maps.delete_if do |m|
86
+ r = yield m
87
+ @deleted.append(@pairs[m].object_id) if r
88
+ r
89
+ end
90
+ end
91
+
92
+ def to_a
93
+ (@maps || @origin).to_a
94
+ end
95
+
96
+ def &(other)
97
+ if other == [] || (@maps || @origin).empty?
98
+ return Factbase::Taped.new([], inserted: @inserted, deleted: @deleted, added: @added)
99
+ end
100
+ join(other, &:&)
101
+ end
102
+
103
+ def |(other)
104
+ return Factbase::Taped.new(to_a, inserted: @inserted, deleted: @deleted, added: @added) if other == []
105
+ if (@maps || @origin).empty?
106
+ return Factbase::Taped.new(other, inserted: @inserted, deleted: @deleted, added: @added)
107
+ end
108
+ join(other, &:|)
109
+ end
110
+
111
+ def ensure_copied
112
+ return if @copied
113
+ @pairs = {}.compare_by_identity
114
+ @maps =
115
+ @origin.map do |m|
116
+ n = m.transform_values(&:dup)
117
+ @pairs[n] = m
118
+ n
119
+ end
120
+ @copied = true
121
+ end
122
+
123
+ def get_copied_map(original_map)
124
+ ensure_copied
125
+ @maps.find { |m| @pairs[m].equal?(original_map) }
126
+ end
127
+
128
+ private
129
+
130
+ def join(other)
131
+ n = yield (@maps || @origin).to_a, other.to_a
132
+ raise 'Cannot join with another Taped' if other.respond_to?(:inserted)
133
+ raise 'Can only join with array' unless other.is_a?(Array)
134
+ Factbase::Taped.new(
135
+ n,
136
+ inserted: @inserted,
137
+ deleted: @deleted,
138
+ added: @added
139
+ )
140
+ end
141
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+
8
+ class Factbase::LazyTaped
9
+ # Decorator of Array that triggers copy-on-write.
10
+ # @todo #424:30min Add dedicated unit tests for LazyTapedArray class.
11
+ # Currently this class is tested indirectly through LazyTaped tests.
12
+ # We should add explicit tests for all public methods including each, [],
13
+ # to_a, any?, <<, and uniq! to ensure proper copy-on-write behavior.
14
+ class LazyTapedArray
15
+ # Creates a new lazy array wrapper.
16
+ # @param origin [Array] The original array to wrap
17
+ # @param key [String] The key in the parent hash where this array is stored
18
+ # @param taped_hash [LazyTapedHash] The parent hash wrapper that owns this array
19
+ # @param added [Array] Accumulator for tracking object IDs of modified facts
20
+ def initialize(origin, key, taped_hash, added)
21
+ @origin = origin
22
+ @key = key
23
+ @taped_hash = taped_hash
24
+ @added = added
25
+ end
26
+
27
+ def each(&)
28
+ return to_enum(__method__) unless block_given?
29
+ current_array.each(&)
30
+ end
31
+
32
+ def [](idx)
33
+ current_array[idx]
34
+ end
35
+
36
+ def to_a
37
+ current_array.to_a
38
+ end
39
+
40
+ def any?(pattern = nil, &)
41
+ pattern ? current_array.any?(pattern) : current_array.any?(&)
42
+ end
43
+
44
+ def <<(item)
45
+ @taped_hash.ensure_copied_map
46
+ @added.append(@taped_hash.tracking_id)
47
+ @taped_hash.get_copied_array(@key) << item
48
+ end
49
+
50
+ def uniq!
51
+ @taped_hash.ensure_copied_map
52
+ @added.append(@taped_hash.tracking_id)
53
+ @taped_hash.get_copied_array(@key).uniq!
54
+ end
55
+
56
+ private
57
+
58
+ def current_array
59
+ @taped_hash.copied? ? @taped_hash.get_copied_array(@key) : @origin
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+ require_relative 'lazy_taped_array'
8
+
9
+ class Factbase::LazyTaped
10
+ # Decorator of Hash that triggers copy-on-write.
11
+ # @todo #424:30min Add dedicated unit tests for LazyTapedHash class.
12
+ # Currently this class is tested indirectly through LazyTaped tests.
13
+ # We should add explicit tests for all public methods including keys, map,
14
+ # bracket access, bracket assignment, and the copy-on-write behavior.
15
+ class LazyTapedHash
16
+ # Creates a new LazyTapedHash decorator.
17
+ # @param origin [Hash] The original hash being wrapped (not yet copied)
18
+ # @param lazy_taped [Factbase::LazyTaped] The parent LazyTaped instance that manages copy-on-write
19
+ # @param added [Array] Array to track object IDs of maps that have been modified
20
+ def initialize(origin, lazy_taped, added)
21
+ @origin = origin
22
+ @lazy_taped = lazy_taped
23
+ @added = added
24
+ @copied_map = nil
25
+ end
26
+
27
+ def keys
28
+ current_map.keys
29
+ end
30
+
31
+ def map(&)
32
+ current_map.map(&)
33
+ end
34
+
35
+ def [](key)
36
+ v = current_map[key]
37
+ v = LazyTapedArray.new(v, key, self, @added) if v.is_a?(Array)
38
+ v
39
+ end
40
+
41
+ def []=(key, value)
42
+ ensure_copied_map
43
+ @copied_map[key] = value
44
+ @added.append(@copied_map.object_id)
45
+ end
46
+
47
+ def ensure_copied_map
48
+ return if @copied_map
49
+ @copied_map = @lazy_taped.get_copied_map(@origin)
50
+ end
51
+
52
+ def get_copied_array(key)
53
+ ensure_copied_map
54
+ @copied_map[key]
55
+ end
56
+
57
+ def tracking_id
58
+ @copied_map ? @copied_map.object_id : @origin.object_id
59
+ end
60
+
61
+ def copied?
62
+ !@copied_map.nil?
63
+ end
64
+
65
+ private
66
+
67
+ def current_map
68
+ @copied_map || @origin
69
+ end
70
+
71
+ def method_missing(method, *, &)
72
+ current_map.send(method, *, &)
73
+ end
74
+
75
+ def respond_to_missing?(method, include_private = false)
76
+ current_map.respond_to?(method, include_private)
77
+ end
78
+ end
79
+ end
data/lib/factbase/term.rb CHANGED
@@ -86,7 +86,7 @@ require_relative 'terms/max'
86
86
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
87
87
  # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
88
88
  # License:: MIT
89
- class Factbase::Term
89
+ class Factbase::Term < Factbase::TermBase
90
90
  # The operator of this term
91
91
  # @return [Symbol] The operator
92
92
  attr_reader :op
@@ -95,13 +95,11 @@ class Factbase::Term
95
95
  # @return [Array] The operands
96
96
  attr_reader :operands
97
97
 
98
- require_relative 'terms/shared'
99
- include Factbase::TermShared
100
-
101
98
  # Ctor.
102
99
  # @param [Symbol] operator Operator
103
100
  # @param [Array] operands Operands
104
101
  def initialize(operator, operands)
102
+ super()
105
103
  @op = operator
106
104
  @operands = operands
107
105
  @terms = {
@@ -26,7 +26,7 @@ class Factbase::Agg < Factbase::TermBase
26
26
  raise "A term is expected, but '#{selector}' provided"
27
27
  end
28
28
  term = @operands[1]
29
- unless term.is_a?(Factbase::Term) || selector.is_a?(Factbase::TermBase)
29
+ unless term.is_a?(Factbase::Term) || term.is_a?(Factbase::TermBase)
30
30
  raise "A term is expected, but '#{term}' provided"
31
31
  end
32
32
  subset = fb.query(selector, maps).each(fb, fact).to_a
@@ -10,8 +10,61 @@
10
10
 
11
11
  # Base class for all terms.
12
12
  class Factbase::TermBase
13
- require_relative 'shared'
14
- include Factbase::TermShared
13
+ # Turns it into a string.
14
+ # @return [String] The string of it
15
+ def to_s
16
+ @to_s ||=
17
+ begin
18
+ items = []
19
+ items << @op
20
+ items +=
21
+ @operands.map do |o|
22
+ if o.is_a?(String)
23
+ "'#{o.gsub("'", "\\\\'").gsub('"', '\\\\"')}'"
24
+ elsif o.is_a?(Time)
25
+ o.utc.iso8601
26
+ else
27
+ o.to_s
28
+ end
29
+ end
30
+ "(#{items.join(' ')})"
31
+ end
32
+ end
15
33
 
16
- protected :assert_args, :_by_symbol, :_values, :to_s
34
+ private
35
+
36
+ def assert_args(num)
37
+ c = @operands.size
38
+ raise "Too many (#{c}) operands for '#{@op}' (#{num} expected)" if c > num
39
+ raise "Too few (#{c}) operands for '#{@op}' (#{num} expected)" if c < num
40
+ end
41
+
42
+ def _by_symbol(pos, fact)
43
+ o = @operands[pos]
44
+ raise "A symbol expected at ##{pos}, but '#{o}' (#{o.class}) provided" unless o.is_a?(Symbol)
45
+ k = o.to_s
46
+ fact[k]
47
+ end
48
+
49
+ # @return [Array|nil] Either array of values or NIL
50
+ def _values(pos, fact, maps, fb)
51
+ v = @operands[pos]
52
+ v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::Term)
53
+ v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::TermBase)
54
+ v = fact[v.to_s] if v.is_a?(Symbol)
55
+ return v if v.nil?
56
+ unless v.is_a?(Array)
57
+ v =
58
+ if v.respond_to?(:each)
59
+ v.to_a
60
+ else
61
+ [v]
62
+ end
63
+ end
64
+ raise 'Why not array?' unless v.is_a?(Array)
65
+ unless v.all? { |i| [Float, Integer, String, Time, TrueClass, FalseClass].any? { |t| i.is_a?(t) } }
66
+ raise 'Wrong type inside'
67
+ end
68
+ v
69
+ end
17
70
  end
@@ -9,5 +9,5 @@
9
9
  # License:: MIT
10
10
  class Factbase
11
11
  # Current version of the gem (changed by .rultor.yml on every release)
12
- VERSION = '0.17.1' unless const_defined?(:VERSION)
12
+ VERSION = '0.19.0' unless const_defined?(:VERSION)
13
13
  end
data/lib/factbase.rb CHANGED
@@ -164,17 +164,8 @@ class Factbase
164
164
  #
165
165
  # @return [Factbase::Churn] How many facts have been changed (zero if rolled back)
166
166
  def txn
167
- pairs = {}
168
- before =
169
- @maps.map do |m|
170
- n = m.transform_values(&:dup)
171
- # rubocop:disable Lint/HashCompareByIdentity
172
- pairs[n.object_id] = m.object_id
173
- # rubocop:enable Lint/HashCompareByIdentity
174
- n
175
- end
176
- require_relative 'factbase/taped'
177
- taped = Factbase::Taped.new(before)
167
+ require_relative 'factbase/lazy_taped'
168
+ taped = Factbase::LazyTaped.new(@maps)
178
169
  require_relative 'factbase/churn'
179
170
  churn = Factbase::Churn.new
180
171
  catch :commit do
@@ -188,30 +179,32 @@ class Factbase
188
179
  rescue Factbase::Rollback
189
180
  return churn
190
181
  end
191
- seen = []
192
- garbage = []
182
+ seen = {}.compare_by_identity
183
+ garbage = {}.compare_by_identity
184
+ pairs = taped.pairs
193
185
  taped.deleted.each do |oid|
194
- garbage << pairs[oid]
195
- seen << oid
186
+ original = @maps.find { |m| m.object_id == oid }
187
+ next if original.nil?
188
+ garbage[original] = true
196
189
  churn.append(0, 1, 0)
197
190
  end
198
191
  taped.inserted.each do |oid|
199
- next if seen.include?(oid)
200
192
  b = taped.find_by_object_id(oid)
201
193
  next if b.nil?
202
- seen << oid
194
+ next if seen.key?(b)
195
+ seen[b] = true
203
196
  @maps << b
204
197
  churn.append(1, 0, 0)
205
198
  end
206
199
  taped.added.each do |oid|
207
- next if seen.include?(oid)
208
200
  b = taped.find_by_object_id(oid)
209
201
  next if b.nil?
210
- garbage << pairs[oid]
202
+ next if seen.key?(b)
203
+ garbage[pairs[b]] = true
211
204
  @maps << b
212
205
  churn.append(0, 0, 1)
213
206
  end
214
- @maps.delete_if { |m| garbage.include?(m.object_id) } unless garbage.empty?
207
+ @maps.delete_if { |m| garbage.key?(m) } unless garbage.empty?
215
208
  churn
216
209
  end
217
210
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factbase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.1
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -195,6 +195,9 @@ files:
195
195
  - lib/factbase/indexed/indexed_term.rb
196
196
  - lib/factbase/indexed/indexed_unique.rb
197
197
  - lib/factbase/inv.rb
198
+ - lib/factbase/lazy_taped.rb
199
+ - lib/factbase/lazy_taped_array.rb
200
+ - lib/factbase/lazy_taped_hash.rb
198
201
  - lib/factbase/light.rb
199
202
  - lib/factbase/logged.rb
200
203
  - lib/factbase/pre.rb
@@ -248,7 +251,6 @@ files:
248
251
  - lib/factbase/terms/or.rb
249
252
  - lib/factbase/terms/plus.rb
250
253
  - lib/factbase/terms/prev.rb
251
- - lib/factbase/terms/shared.rb
252
254
  - lib/factbase/terms/simplified.rb
253
255
  - lib/factbase/terms/size.rb
254
256
  - lib/factbase/terms/sorted.rb
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
- # SPDX-License-Identifier: MIT
5
-
6
- # This module provides shared methods for Factbase terms, including argument validation,
7
- # symbol-based lookups, and handling of operand values.
8
- # @todo #302:30min Remove this module and move its methods to Factbase::TermBase.
9
- # Currently, we use it because we are required to inject all thesse methods into Factbase::Term.
10
- # When all the terms will inherit from Factbase::TermBase, we can remove this module.
11
- module Factbase::TermShared
12
- # Turns it into a string.
13
- # @return [String] The string of it
14
- def to_s
15
- @to_s ||=
16
- begin
17
- items = []
18
- items << @op
19
- items +=
20
- @operands.map do |o|
21
- if o.is_a?(String)
22
- "'#{o.gsub("'", "\\\\'").gsub('"', '\\\\"')}'"
23
- elsif o.is_a?(Time)
24
- o.utc.iso8601
25
- else
26
- o.to_s
27
- end
28
- end
29
- "(#{items.join(' ')})"
30
- end
31
- end
32
-
33
- private
34
-
35
- def assert_args(num)
36
- c = @operands.size
37
- raise "Too many (#{c}) operands for '#{@op}' (#{num} expected)" if c > num
38
- raise "Too few (#{c}) operands for '#{@op}' (#{num} expected)" if c < num
39
- end
40
-
41
- def _by_symbol(pos, fact)
42
- o = @operands[pos]
43
- raise "A symbol expected at ##{pos}, but '#{o}' (#{o.class}) provided" unless o.is_a?(Symbol)
44
- k = o.to_s
45
- fact[k]
46
- end
47
-
48
- # @return [Array|nil] Either array of values or NIL
49
- def _values(pos, fact, maps, fb)
50
- v = @operands[pos]
51
- v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::Term)
52
- v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::TermBase)
53
- v = fact[v.to_s] if v.is_a?(Symbol)
54
- return v if v.nil?
55
- unless v.is_a?(Array)
56
- v =
57
- if v.respond_to?(:each)
58
- v.to_a
59
- else
60
- [v]
61
- end
62
- end
63
- raise 'Why not array?' unless v.is_a?(Array)
64
- unless v.all? { |i| [Float, Integer, String, Time, TrueClass, FalseClass].any? { |t| i.is_a?(t) } }
65
- raise 'Wrong type inside'
66
- end
67
- v
68
- end
69
- end