factbase 0.18.0 → 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: 90b1ab3d82ed371b4de11b7eceb98137b2a7687bc2625e61d4d54a6538849b34
4
- data.tar.gz: f9fd82d8254d7cfa646a742db6989790791ce157ce53d9e5a310f8f8f8c0db11
3
+ metadata.gz: c20d4add4664149fc5c070ec64321a79c8ac07ef81c69ae2b64bac1e9fd0c4e9
4
+ data.tar.gz: ff9a8f121d9cfafa6ad97a634c79fc2ec834a07ee0d17042eb3fc5fa8c529501
5
5
  SHA512:
6
- metadata.gz: ae0d064e075f304c9fae6f4903c961293d07e186ee6bdf4e180d5f407f5b166f2b3cc345cec612d8184675394a2ee504197d4c13d9ce0f480cffd850d7d10169
7
- data.tar.gz: b53da29f44f9ad5520bed3602c9c7dadf882115856ddb344dfba95a32017cd2e89c4582a7a160baa2ed611ff37acd0ba39e8fb9a8aa8ed7bec62f0acf9f0ee70
6
+ metadata.gz: 3f93283308d6f15766fca09f253da85145efc28975e51259ee88f0954cc6a7f584700de2ff604478bd5a48f1c86153243949a6280732a466412a739f38e9a1e2
7
+ data.tar.gz: e21602f7612821dd2389e831056389900f62a3ef3b1bf998f6ebbe722cfc9684357383c754ad0556a346534cf7a6bc76489706e0f0f58ea23336a918be71b442
data/Gemfile.lock CHANGED
@@ -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
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
@@ -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.18.0' unless const_defined?(:VERSION)
12
+ VERSION = '0.19.0' unless const_defined?(:VERSION)
13
13
  end
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.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko