factbase 0.19.6 → 0.19.8

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: 99e8fdb60e5fb1e8083b76db845cfad8a7b6be35700c543819ce631f8ac3ca19
4
- data.tar.gz: 1fd106429c8a3aa39e56683060b5e61395e705abe336290da63fb1a35e748ec5
3
+ metadata.gz: 03d4be418b18984640fa333733a6b688aa2879b2e7def6d0ad901bce47490cd2
4
+ data.tar.gz: c6f24e2c444eb03eb739489d4cd09f927ece90a8c44f480c3b04fef1876b574d
5
5
  SHA512:
6
- metadata.gz: e03d29fb285a301e48723690f3bdad751a0bc229bd559d901e22fdb196a4e263d5b75bbc6fef2da5480f07f1a4d22d510e630f7f7a7af8988d2b981d6a4b0b10
7
- data.tar.gz: c41be62abd514f4ea0fde5e47fba206f27a8bd89a82a17e03c55a3908e5d9c98e909eca6bb571e183f8c2f11421617f0a20b31db132c1107a91eea0fb12cb0c1
6
+ metadata.gz: f73778d27a87c5a436769565032c5b064b24354ce62fad61822fd29941664e96c6d70f8481bf146bc72ef32669fc4969bae050eb70ef2f5c1fa0e19ee9395000
7
+ data.tar.gz: 299d0afc68fa48166fd4e01366e82ce1d22f8aa8c2f1dc2cfc9bd397763080f8db1762b20caa7738bafd0a5d606e9fa1725c13f400e13b40193ff13528b05b59
data/Gemfile.lock CHANGED
@@ -25,6 +25,7 @@ GEM
25
25
  date (3.5.1)
26
26
  decoor (0.1.0)
27
27
  docile (1.4.1)
28
+ drb (2.2.3)
28
29
  elapsed (0.3.1)
29
30
  loog (~> 0.6)
30
31
  tago (~> 0.1)
@@ -34,10 +35,11 @@ GEM
34
35
  language_server-protocol (3.17.0.5)
35
36
  lint_roller (1.1.0)
36
37
  logger (1.7.0)
37
- loog (0.7.2)
38
+ loog (0.8.0)
38
39
  ellipsized
39
40
  logger (~> 1.0)
40
- minitest (6.0.1)
41
+ minitest (6.0.2)
42
+ drb (~> 2.0)
41
43
  prism (~> 1.5)
42
44
  minitest-reporters (1.7.1)
43
45
  ansi
@@ -55,14 +57,14 @@ GEM
55
57
  os (1.1.4)
56
58
  others (0.1.1)
57
59
  parallel (1.27.0)
58
- parser (3.3.10.1)
60
+ parser (3.3.10.2)
59
61
  ast (~> 2.4.1)
60
62
  racc
61
63
  prism (1.9.0)
62
64
  psych (5.3.1)
63
65
  date
64
66
  stringio
65
- qbash (0.7.2)
67
+ qbash (0.8.0)
66
68
  backtrace (> 0)
67
69
  elapsed (> 0)
68
70
  loog (> 0)
@@ -76,7 +78,7 @@ GEM
76
78
  tsort
77
79
  regexp_parser (2.11.3)
78
80
  rexml (3.4.4)
79
- rubocop (1.84.1)
81
+ rubocop (1.84.2)
80
82
  json (~> 2.3)
81
83
  language_server-protocol (~> 3.17.0.2)
82
84
  lint_roller (~> 1.1.0)
@@ -90,7 +92,7 @@ GEM
90
92
  rubocop-ast (1.49.0)
91
93
  parser (>= 3.3.7.2)
92
94
  prism (~> 1.7)
93
- rubocop-minitest (0.38.2)
95
+ rubocop-minitest (0.39.1)
94
96
  lint_roller (~> 1.1)
95
97
  rubocop (>= 1.75.0, < 2.0)
96
98
  rubocop-ast (>= 1.38.0, < 2.0)
data/README.md CHANGED
@@ -227,52 +227,89 @@ This is the result of the benchmark:
227
227
  ```text
228
228
 
229
229
  query all facts from an empty factbase 0.00
230
- insert 20000 facts 0.67
230
+ insert 20000 facts 0.63
231
231
  export 20000 facts 0.02
232
- import 410821 bytes (20000 facts) 0.01
233
- insert 10 facts 0.04
234
- query 10 times w/txn 2.36
235
- query 10 times w/o txn 0.07
236
- modify 10 attrs w/txn 1.71
237
- delete 10 facts w/txn 3.38
238
- build index on 5000 facts 0.05
239
- export 5000 facts with index 0.05
232
+ import 411003 bytes (20000 facts) 0.01
233
+ insert 10 facts 0.03
234
+ query 10 times w/txn 2.03
235
+ query 10 times w/o txn 0.13
236
+ modify 10 attrs w/txn 1.51
237
+ delete 10 facts w/txn 2.97
238
+ build index on 5000 facts 0.03
239
+ export 5000 facts with index 0.04
240
240
  import 5000 facts with persisted index 0.03
241
- query 5000 facts using persisted index 0.10
242
- export 5000 facts without index 0.00
241
+ query 5000 facts using persisted index 0.07
242
+ export 5000 facts without index 0.01
243
243
  import 5000 facts without index 0.01
244
- query 5000 facts building index on-the-fly 0.09
245
- (and (eq what 'issue-was-closed') (exists... -> 200 1.11
244
+ query 5000 facts building index on-the-fly 0.07
245
+ query 15k facts sel: 20% card: 10 absent plain 0.61
246
+ query 15k facts sel: 20% card: 10 absent indexed(cold) 0.16
247
+ query 15k facts sel: 20% card: 10 absent indexed(warm) 0.13
248
+ query 15k facts sel: 20% card: 10 exists plain 0.56
249
+ query 15k facts sel: 20% card: 10 exists indexed(cold) 0.14
250
+ query 15k facts sel: 20% card: 10 exists indexed(warm) 0.13
251
+ query 15k facts sel: 20% card: 10 eq plain 0.83
252
+ query 15k facts sel: 20% card: 10 eq indexed(cold) 0.22
253
+ query 15k facts sel: 20% card: 10 eq indexed(warm) 0.24
254
+ query 15k facts sel: 20% card: 10 not plain 1.11
255
+ query 15k facts sel: 20% card: 10 not indexed(cold) 0.50
256
+ query 15k facts sel: 20% card: 10 not indexed(warm) 0.43
257
+ query 15k facts sel: 20% card: 10 gt plain 0.84
258
+ query 15k facts sel: 20% card: 10 gt indexed(cold) 0.26
259
+ query 15k facts sel: 20% card: 10 gt indexed(warm) 0.19
260
+ query 15k facts sel: 20% card: 10 lt plain 0.84
261
+ query 15k facts sel: 20% card: 10 lt indexed(cold) 0.27
262
+ query 15k facts sel: 20% card: 10 lt indexed(warm) 0.20
263
+ query 15k facts sel: 20% card: 10 and eq plain 1.39
264
+ query 15k facts sel: 20% card: 10 and eq indexed(cold) 0.85
265
+ query 15k facts sel: 20% card: 10 and eq indexed(warm) 0.46
266
+ query 15k facts sel: 20% card: 10 and complex plain 1.32
267
+ query 15k facts sel: 20% card: 10 and complex indexed(cold) 0.48
268
+ query 15k facts sel: 20% card: 10 and complex indexed(warm) 0.43
269
+ query 15k facts sel: 20% card: 10 one plain 0.72
270
+ query 15k facts sel: 20% card: 10 one indexed(cold) 0.19
271
+ query 15k facts sel: 20% card: 10 one indexed(warm) 0.15
272
+ query 15k facts sel: 20% card: 10 or plain 2.02
273
+ query 15k facts sel: 20% card: 10 or indexed(cold) 0.42
274
+ query 15k facts sel: 20% card: 10 or indexed(warm) 0.36
275
+ query 15k facts sel: 20% card: 10 unique plain 1.86
276
+ query 15k facts sel: 20% card: 10 unique indexed(cold) 0.61
277
+ query 15k facts sel: 20% card: 10 unique indexed(warm) 0.37
278
+ (and (eq what 'issue-was-closed') (exists... -> 200 1.04
246
279
  (and (eq what 'issue-was-closed') (exists... -> 200/txn 1.23
247
- (and (eq what 'issue-was-closed') (exists... -> zero 1.18
248
- (and (eq what 'issue-was-closed') (exists... -> zero/txn 1.30
249
- transaction rollback on factbase with 100000 facts 0.28
250
- (gt time '2024-03-23T03:21:43Z') 0.37
251
- (gt cost 50) 0.18
280
+ (and (eq what 'issue-was-closed') (exists... -> zero 1.06
281
+ (and (eq what 'issue-was-closed') (exists... -> zero/txn 1.23
282
+ transaction rollback on factbase with 100000 facts 0.26
283
+ (gt time '2024-03-23T03:21:43Z') 0.22
284
+ (gt cost 50) 0.10
252
285
  (eq title 'Object Thinking 5000') 0.03
253
- (and (eq foo 42.998) (or (gt bar 200) (absent z... 0.03
254
- (and (exists foo) (not (exists blue))) 1.73
255
- (eq id (agg (always) (max id))) 2.77
256
- (join "c<=cost,b<=bar" (eq id (agg (always) (ma... 4.70
257
- (and (eq what "foo") (join "w<=what" (and (eq i... 7.58
258
- delete! 0.51
259
- (and (eq issue *) (eq repository *) (eq what '*') (eq where '*')) 0.43
286
+ (and (eq foo 42.998) (or (gt bar 200) (absent z... 0.02
287
+ (and (exists foo) (not (exists blue))) 1.12
288
+ (eq id (agg (always) (max id))) 2.76
289
+ (join "c<=cost,b<=bar" (eq id (agg (always) (ma... 4.58
290
+ (and (eq what "foo") (join "w<=what" (and (eq i... 7.26
291
+ delete! 0.42
292
+ (and (eq issue *) (eq repository *) (eq what '*') (eq where '*')) 0.38
260
293
  Taped.append() x50000 0.02
261
- Taped.each() x125 1.15
262
- Taped.delete_if() x375 0.97
263
- 50000 facts: read-only txn (no copy needed) 5.30
264
- 50000 facts: rollback txn (no copy needed) 5.16
265
- 50000 facts: insert in txn (copy triggered) 3.23
266
- 50000 facts: modify in txn (copy triggered) 34.56
267
- 100000 facts: read-only txn (no copy needed) 12.28
268
- 100000 facts: rollback txn (no copy needed) 12.19
269
- 100000 facts: insert in txn (copy triggered) 6.57
270
- 100000 facts: modify in txn (copy triggered) 70.11
294
+ Taped.each() x125 1.08
295
+ Taped.delete_if() x375 0.86
296
+ 50000 facts: plain read (no txn) 4.06
297
+ 50000 facts: read-only txn (no copy) 4.85
298
+ 50000 facts: plain insert (no txn) 0.00
299
+ 50000 facts: insert in txn (copy triggered) 3.31
300
+ 50000 facts: plain modify (no txn) 28.21
301
+ 50000 facts: modify in txn (copy triggered) 35.14
302
+ 100000 facts: plain read (no txn) 8.06
303
+ 100000 facts: read-only txn (no copy) 9.94
304
+ 100000 facts: plain insert (no txn) 0.00
305
+ 100000 facts: insert in txn (copy triggered) 6.68
306
+ 100000 facts: plain modify (no txn) 56.21
307
+ 100000 facts: modify in txn (copy triggered) 70.09
271
308
  ```
272
309
 
273
310
  The results were calculated in [this GHA job][benchmark-gha]
274
- on 2026-02-04 at 05:19,
311
+ on 2026-02-17 at 18:48,
275
312
  on Linux with 4 CPUs.
276
313
  <!-- benchmark_end -->
277
314
 
278
- [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/21659492807
315
+ [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/22111136309
data/Rakefile CHANGED
@@ -88,7 +88,7 @@ desc 'Profile a benchmark (e.g., flamegraph[bench_slow_query])'
88
88
  task :flamegraph, [:name] do |_t, args|
89
89
  require 'stackprof'
90
90
  bname = args[:name] || 'all'
91
- puts "Starting profiling for '#{bname}'..."
91
+ puts "Starting profiling for '#{bname}'..." # rubocop:disable Lint/Debugger
92
92
  StackProf.run(mode: :cpu, out: 'stackprof-cpu-myapp.dump', raw: true) do
93
93
  Rake::Task['benchmark'].invoke(bname)
94
94
  end
@@ -11,25 +11,21 @@ class Factbase::IndexedAbsent
11
11
  end
12
12
 
13
13
  def predict(maps, _fb, _params)
14
- return nil if @idx.nil?
15
- key = [maps.object_id, @term.operands.first, @term.op]
14
+ prop = @term.operands[0].to_s
15
+ key = [maps.object_id, prop, @term.op]
16
+ @idx[key] ||= { facts: [], count: 0 }
16
17
  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
23
- prop = @term.operands.first.to_s
24
- maps_array[entry[:indexed_count]..].each do |m|
25
- entry[:facts] << m if m[prop].nil?
26
- end
27
- entry[:indexed_count] = maps_array.size
28
- end
29
- if maps.respond_to?(:ensure_copied!)
30
- maps & entry[:facts]
31
- else
32
- (maps & []) | entry[:facts]
18
+ _feed(maps.to_a, entry, prop)
19
+ maps.respond_to?(:repack) ? maps.repack(entry[:facts]) : entry[:facts]
20
+ end
21
+
22
+ private
23
+
24
+ def _feed(facts, entry, prop)
25
+ return unless entry[:count] < facts.size
26
+ facts[entry[:count]..].each do |f|
27
+ entry[:facts] << f if f[prop].nil?
33
28
  end
29
+ entry[:count] = facts.size
34
30
  end
35
31
  end
@@ -43,12 +43,7 @@ class Factbase::IndexedAnd
43
43
  end
44
44
  )
45
45
  j = tuples.flat_map { |t| entry[:index][t] || [] }.uniq(&:object_id)
46
- r =
47
- if maps.respond_to?(:inserted)
48
- maps & j
49
- else
50
- j
51
- end
46
+ r = maps.respond_to?(:repack) ? maps.repack(j) : j
52
47
  else
53
48
  @term.operands.each do |o|
54
49
  n = o.predict(maps, fb, params)
@@ -56,7 +51,8 @@ class Factbase::IndexedAnd
56
51
  if r.nil?
57
52
  r = n
58
53
  elsif n.size < r.size * 8 # to skip some obvious matchings
59
- r &= n.to_a
54
+ ids = n.to_set(&:object_id)
55
+ r = r.select { |f| ids.include?(f.object_id) }
60
56
  end
61
57
  break if r.size < maps.size / 32 # it's already small enough
62
58
  break if r.size < 128 # it's obviously already small enough
@@ -11,37 +11,18 @@ class Factbase::IndexedEq
11
11
  end
12
12
 
13
13
  def predict(maps, _fb, params)
14
- return nil if @idx.nil?
15
- key = [maps.object_id, @term.operands.first, @term.op]
16
- return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
14
+ first_operand = @term.operands[0]
15
+ second_operand = @term.operands[1]
16
+ return unless first_operand.is_a?(Symbol) && _scalar?(second_operand)
17
+ first_operand = first_operand.to_s
18
+ key = [maps.object_id, first_operand, @term.op]
19
+ @idx[key] ||= { facts: {}, count: 0 }
17
20
  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
24
- prop = @term.operands.first.to_s
25
- maps_array[entry[:indexed_count]..].each do |m|
26
- m[prop]&.each do |v|
27
- entry[:index][v] ||= []
28
- entry[:index][v] << m
29
- end
30
- end
31
- entry[:indexed_count] = maps_array.size
32
- end
33
- vv =
34
- if @term.operands[1].is_a?(Symbol)
35
- params[@term.operands[1].to_s] || []
36
- else
37
- [@term.operands[1]]
38
- end
39
- j = vv.flat_map { |v| entry[:index][v] || [] }.uniq(&:object_id)
40
- if maps.respond_to?(:inserted)
41
- maps & j
42
- else
43
- j
44
- end
21
+ _feed(maps.to_a, entry, first_operand)
22
+ keys = _resolve(second_operand, params)
23
+ matches = keys.flat_map { |k| entry[:facts][k] || [] }
24
+ matches = matches.uniq(&:object_id) if keys.size > 1
25
+ maps.respond_to?(:repack) ? maps.repack(matches) : matches
45
26
  end
46
27
 
47
28
  private
@@ -49,4 +30,20 @@ class Factbase::IndexedEq
49
30
  def _scalar?(item)
50
31
  item.is_a?(String) || item.is_a?(Time) || item.is_a?(Integer) || item.is_a?(Float) || item.is_a?(Symbol)
51
32
  end
33
+
34
+ def _feed(facts, entry, operand)
35
+ return unless entry[:count] < facts.size
36
+ facts[entry[:count]..].each do |m|
37
+ m[operand]&.each do |v|
38
+ entry[:facts][v] ||= []
39
+ entry[:facts][v] << m
40
+ end
41
+ end
42
+ entry[:count] = facts.size
43
+ end
44
+
45
+ def _resolve(operand, params)
46
+ return Array(operand) unless operand.is_a?(Symbol)
47
+ params[operand.to_s] || []
48
+ end
52
49
  end
@@ -20,14 +20,13 @@ class Factbase::IndexedExists
20
20
  key = [maps.object_id, operand, @term.op]
21
21
  @idx[key] = { facts: [], count: 0 } if @idx[key].nil?
22
22
  entry = @idx[key]
23
- feed(maps.to_a, entry, operand)
24
- return maps.repack(entry[:facts]) if maps.respond_to?(:repack)
25
- entry[:facts]
23
+ _feed(maps.to_a, entry, operand)
24
+ maps.respond_to?(:repack) ? maps.repack(entry[:facts]) : entry[:facts]
26
25
  end
27
26
 
28
27
  private
29
28
 
30
- def feed(facts, entry, operand)
29
+ def _feed(facts, entry, operand)
31
30
  facts[entry[:count]..].each do |m|
32
31
  entry[:facts] << m unless m[operand].nil?
33
32
  end
@@ -11,36 +11,17 @@ class Factbase::IndexedGt
11
11
  end
12
12
 
13
13
  def predict(maps, _fb, params)
14
- return nil if @idx.nil?
15
- return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
16
- prop = @term.operands.first.to_s
17
- cache_key = [maps.object_id, @term.operands.first, :sorted]
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|
26
- values = m[prop]
27
- next if values.nil?
28
- values.each do |v|
29
- entry[:sorted] << [v, m]
30
- end
31
- end
32
- entry[:sorted].sort_by! { |pair| pair[0] }
33
- entry[:indexed_count] = maps_array.size
34
- end
35
- threshold = @term.operands[1].is_a?(Symbol) ? params[@term.operands[1].to_s]&.first : @term.operands[1]
36
- return nil if threshold.nil?
37
- i = entry[:sorted].bsearch_index { |pair| pair[0] > threshold } || entry[:sorted].size
38
- result = entry[:sorted][i..].map { |pair| pair[1] }.uniq
39
- if maps.respond_to?(:ensure_copied!)
40
- maps & result
41
- else
42
- (maps & []) | result
43
- end
14
+ op1, op2 = @term.operands
15
+ return unless op1.is_a?(Symbol) && _scalar?(op2)
16
+ prop = op1.to_s
17
+ target = op2.is_a?(Symbol) ? params[op2.to_s]&.first : op2
18
+ return maps || [] if target.nil?
19
+ key = [maps.object_id, prop, :facts]
20
+ @idx[key] ||= { facts: [], count: 0 }
21
+ entry = @idx[key]
22
+ _feed(maps.to_a, entry, prop)
23
+ matched = _search(entry, target)
24
+ maps.respond_to?(:repack) ? maps.repack(matched) : matched
44
25
  end
45
26
 
46
27
  private
@@ -48,4 +29,21 @@ class Factbase::IndexedGt
48
29
  def _scalar?(item)
49
30
  item.is_a?(String) || item.is_a?(Time) || item.is_a?(Integer) || item.is_a?(Float) || item.is_a?(Symbol)
50
31
  end
32
+
33
+ def _feed(facts, entry, prop)
34
+ return unless entry[:count] < facts.size
35
+ facts[entry[:count]..].each do |fact|
36
+ fact[prop]&.each do |v|
37
+ entry[:facts] << [v, fact]
38
+ end
39
+ end
40
+ entry[:facts].sort_by! { |pair| pair[0] }
41
+ entry[:count] = facts.size
42
+ end
43
+
44
+ def _search(entry, target)
45
+ idx = entry[:facts].bsearch_index { |v, _| v > target }
46
+ return [] if idx.nil?
47
+ entry[:facts][idx..].map { |_, f| f }.uniq(&:object_id)
48
+ end
51
49
  end
@@ -11,41 +11,17 @@ class Factbase::IndexedLt
11
11
  end
12
12
 
13
13
  def predict(maps, _fb, params)
14
- return nil if @idx.nil?
15
- return unless @term.operands.first.is_a?(Symbol) && _scalar?(@term.operands[1])
16
- prop = @term.operands.first.to_s
17
- cache_key = [maps.object_id, @term.operands.first, :sorted]
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|
27
- values = m[prop]
28
- next if values.nil?
29
- values.each do |v|
30
- new_pairs << [v, m]
31
- end
32
- end
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
38
- end
39
-
40
- threshold = @term.operands[1].is_a?(Symbol) ? params[@term.operands[1].to_s]&.first : @term.operands[1]
41
- return nil if threshold.nil?
42
- i = entry[:sorted].bsearch_index { |pair| pair[0] >= threshold } || entry[:sorted].size
43
- result = entry[:sorted][0...i].map { |pair| pair[1] }.uniq
44
- if maps.respond_to?(:ensure_copied!)
45
- maps & result
46
- else
47
- (maps & []) | result
48
- end
14
+ op1, op2 = @term.operands
15
+ return unless op1.is_a?(Symbol) && _scalar?(op2)
16
+ prop = op1.to_s
17
+ target = op2.is_a?(Symbol) ? params[op2.to_s]&.first : op2
18
+ return maps || [] if target.nil?
19
+ key = [maps.object_id, prop, :facts]
20
+ @idx[key] ||= { facts: [], count: 0 }
21
+ entry = @idx[key]
22
+ _feed(maps.to_a, entry, prop)
23
+ matched = _search(entry, target)
24
+ maps.respond_to?(:repack) ? maps.repack(matched) : matched
49
25
  end
50
26
 
51
27
  private
@@ -53,4 +29,21 @@ class Factbase::IndexedLt
53
29
  def _scalar?(item)
54
30
  item.is_a?(String) || item.is_a?(Time) || item.is_a?(Integer) || item.is_a?(Float) || item.is_a?(Symbol)
55
31
  end
32
+
33
+ def _feed(facts, entry, prop)
34
+ return unless entry[:count] < facts.size
35
+ facts[entry[:count]..].each do |fact|
36
+ fact[prop]&.each do |v|
37
+ entry[:facts] << [v, fact]
38
+ end
39
+ end
40
+ entry[:facts].sort_by! { |pair| pair[0] }
41
+ entry[:count] = facts.size
42
+ end
43
+
44
+ def _search(entry, target)
45
+ idx = entry[:facts].bsearch_index { |v, _| v >= target }
46
+ res = idx.nil? ? entry[:facts] : entry[:facts][0...idx]
47
+ res.map { |_, f| f }.uniq(&:object_id)
48
+ end
56
49
  end
@@ -11,33 +11,30 @@ class Factbase::IndexedNot
11
11
  end
12
12
 
13
13
  def predict(maps, fb, params)
14
- return nil if @idx.nil?
15
- key = [maps.object_id, @term.operands.first, @term.op]
14
+ sub = @term.operands.first
15
+ key = [maps.object_id, sub, @term.op]
16
+ @idx[key] ||= { facts: nil, count: 0, yes_set: nil }
16
17
  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
18
+ _feed(maps.to_a, entry) do
19
+ sub.predict(maps, fb, params)
21
20
  end
22
- if entry[:indexed_count] < maps_array.size
23
- yes = @term.operands.first.predict(maps, fb, params)
24
- if yes.nil?
25
- entry[:facts] = nil
26
- entry[:yes_set] = nil
27
- else
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) }
31
- end
32
- entry[:indexed_count] = maps_array.size
33
- end
34
- r = entry[:facts]
35
- if r.nil?
36
- nil
37
- elsif maps.respond_to?(:ensure_copied!)
38
- maps & r
21
+ return nil if entry[:facts].nil?
22
+ maps.respond_to?(:repack) ? maps.repack(entry[:facts]) : entry[:facts]
23
+ end
24
+
25
+ private
26
+
27
+ def _feed(facts, entry)
28
+ return unless entry[:count] < facts.size
29
+ yes = yield
30
+ if yes.nil?
31
+ entry[:facts] = nil
32
+ entry[:yes_set] = nil
39
33
  else
40
- (maps & []) | r
34
+ yes_set = yes.to_a.to_set
35
+ entry[:yes_set] = yes_set
36
+ entry[:facts] = facts.reject { |m| yes_set.include?(m) }
41
37
  end
38
+ entry[:count] = facts.size
42
39
  end
43
40
  end
@@ -11,25 +11,21 @@ class Factbase::IndexedOne
11
11
  end
12
12
 
13
13
  def predict(maps, _fb, _params)
14
- return nil if @idx.nil?
15
- key = [maps.object_id, @term.operands.first, @term.op]
14
+ prop = @term.operands.first.to_s
15
+ key = [maps.object_id, prop, @term.op]
16
+ @idx[key] ||= { facts: [], count: 0 }
16
17
  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
23
- prop = @term.operands.first.to_s
24
- maps_array[entry[:indexed_count]..].each do |m|
25
- entry[:facts] << m if !m[prop].nil? && m[prop].size == 1
26
- end
27
- entry[:indexed_count] = maps_array.size
28
- end
29
- if maps.respond_to?(:ensure_copied!)
30
- maps & entry[:facts]
31
- else
32
- (maps & []) | entry[:facts]
18
+ _feed(maps.to_a, entry, prop)
19
+ maps.respond_to?(:repack) ? maps.repack(entry[:facts]) : entry[:facts]
20
+ end
21
+
22
+ private
23
+
24
+ def _feed(facts, entry, prop)
25
+ return unless entry[:count] < facts.size
26
+ facts[entry[:count]..].each do |f|
27
+ entry[:facts] << f if !f[prop].nil? && f[prop].size == 1
33
28
  end
29
+ entry[:count] = facts.size
34
30
  end
35
31
  end
@@ -33,12 +33,8 @@ class Factbase::IndexedUnique
33
33
  idx_key = [maps.object_id, @term.op.to_s, bucket_key]
34
34
  entry = (@idx[idx_key] ||= { buckets: {}, count: 0 })
35
35
  feed(maps.to_a, entry, operands, bucket_key)
36
- bucket = entry[:buckets][bucket_key]
37
- if maps.respond_to?(:ensure_copied!)
38
- maps & (bucket[:facts] || [])
39
- else
40
- (maps & []) | (bucket[:facts] || [])
41
- end
36
+ matches = entry[:buckets][bucket_key][:facts]
37
+ maps.respond_to?(:repack) ? maps.repack(matches) : matches
42
38
  end
43
39
 
44
40
  private
@@ -11,23 +11,23 @@ require_relative 'lazy_taped_hash'
11
11
  class Factbase::LazyTaped
12
12
  def initialize(origin)
13
13
  @origin = origin
14
+ @staged = []
14
15
  @copied = false
15
- @maps = nil
16
- @pairs = nil
17
- @inverted_pairs = nil
16
+ @copies = {}.compare_by_identity
18
17
  @inserted = []
19
18
  @deleted = []
20
19
  @added = []
21
20
  end
22
21
 
23
- # Returns a hash mapping copied maps to their originals.
24
- # This is used during transaction commit to identify which original facts
25
- # were modified, allowing the factbase to update the correct entries.
26
- def pairs
27
- return {} unless @pairs
28
- result = {}.compare_by_identity
29
- @pairs.each { |copied, original| result[copied] = original }
30
- result
22
+ # Returns the original map this copy was derived from.
23
+ # Returns nil if the base hasn't been copied yet or if the fact is new.
24
+ def source_of(copy)
25
+ return nil unless @copied
26
+ @copies.key(copy)
27
+ end
28
+
29
+ def copied?
30
+ @copied
31
31
  end
32
32
 
33
33
  # Returns the unique object IDs of maps that were inserted (newly created).
@@ -51,101 +51,104 @@ class Factbase::LazyTaped
51
51
  end
52
52
 
53
53
  def find_by_object_id(oid)
54
- (@maps || @origin).find { |m| m.object_id == oid }
54
+ r = @staged.find { |m| m.object_id == oid }
55
+ r = @origin.find { |m| m.object_id == oid } if r.nil? && !copied?
56
+ r
55
57
  end
56
58
 
57
59
  def size
58
- (@maps || @origin).size
60
+ copied? ? @staged.size : (@origin.size + @staged.size)
59
61
  end
60
62
 
61
63
  def empty?
62
- (@maps || @origin).empty?
64
+ copied? ? @staged.empty? : (@origin.empty? && @staged.empty?)
63
65
  end
64
66
 
65
67
  def <<(map)
66
- ensure_copied!
67
- @maps << map
68
+ @staged << map
69
+ _track(map, map)
68
70
  @inserted.append(map.object_id)
69
71
  end
70
72
 
71
73
  def each
72
74
  return to_enum(__method__) unless block_given?
73
- if @copied
74
- @maps.each do |m|
75
- yield Factbase::Taped::TapedHash.new(m, @added)
76
- end
77
- else
78
- @origin.each do |m|
79
- yield LazyTapedHash.new(m, self, @added)
75
+ st_size = @staged.size
76
+ orig_size = @origin.size
77
+ unless copied?
78
+ orig_size.times do |i|
79
+ m = @origin[i]
80
+ yield _tape(m) unless m.nil?
80
81
  end
81
82
  end
83
+ st_size.times do |i|
84
+ m = @staged[i]
85
+ yield _tape(m) unless m.nil?
86
+ end
82
87
  end
83
88
 
84
89
  def delete_if
85
90
  ensure_copied!
86
- @maps.delete_if do |m|
91
+ @staged.delete_if do |m|
87
92
  r = yield m
88
- @deleted.append(@pairs[m].object_id) if r
93
+ @deleted.append(source_of(m).object_id) if r
89
94
  r
90
95
  end
91
96
  end
92
97
 
93
98
  def to_a
94
- (@maps || @origin).to_a
99
+ (copied? ? @staged : (@origin + @staged)).to_a
95
100
  end
96
101
 
97
102
  def repack(other)
98
103
  ensure_copied!
99
- copied = other.map { |o| @inverted_pairs[o] || o }
104
+ copied = other.map { |o| @copies[o] || o }
100
105
  Factbase::Taped.new(copied, inserted: @inserted, deleted: @deleted, added: @added)
101
106
  end
102
107
 
103
108
  def &(other)
104
- if other == [] || (@maps || @origin).empty?
105
- return Factbase::Taped.new([], inserted: @inserted, deleted: @deleted, added: @added)
106
- end
107
- join(other, &:&)
109
+ return Factbase::Taped.new([], inserted: @inserted, deleted: @deleted, added: @added) if other == []
110
+ return Factbase::Taped.new([], inserted: @inserted, deleted: @deleted, added: @added) if empty?
111
+ _join(other, &:&)
108
112
  end
109
113
 
110
114
  def |(other)
111
115
  return Factbase::Taped.new(to_a, inserted: @inserted, deleted: @deleted, added: @added) if other == []
112
- if (@maps || @origin).empty?
113
- return Factbase::Taped.new(other, inserted: @inserted, deleted: @deleted, added: @added)
114
- end
115
- join(other, &:|)
116
+ return Factbase::Taped.new(other, inserted: @inserted, deleted: @deleted, added: @added) if empty?
117
+ _join(other, &:|)
116
118
  end
117
119
 
118
120
  def ensure_copied!
119
- return if @copied
120
- @pairs = {}.compare_by_identity
121
- @inverted_pairs = {}.compare_by_identity
122
- @maps =
123
- @origin.map do |m|
124
- n = m.transform_values(&:dup)
125
- @pairs[n] = m
126
- @inverted_pairs[m] = n
127
- n
128
- end
121
+ return if copied?
122
+ @origin.each do |o|
123
+ c = o.transform_values(&:dup)
124
+ _track(c, o)
125
+ @staged << c
126
+ end
129
127
  @copied = true
130
128
  end
131
129
 
132
130
  def get_copied_map(original_map)
133
131
  ensure_copied!
134
- @maps.find { |m| @pairs[m].equal?(original_map) }
132
+ @copies[original_map] || original_map
135
133
  end
136
134
 
137
135
  private
138
136
 
139
- def join(other)
137
+ def _join(other)
140
138
  ensure_copied!
141
- n = yield (@maps || @origin).to_a, other.to_a
139
+ n = yield to_a, other.to_a
142
140
  raise 'Cannot join with another Taped' if other.respond_to?(:inserted)
143
141
  raise 'Can only join with array' unless other.is_a?(Array)
144
- Factbase::Taped.new(
145
- n,
146
- inserted: @inserted,
147
- deleted: @deleted,
148
- added: @added
149
- )
142
+ Factbase::Taped.new(n, inserted: @inserted, deleted: @deleted, added: @added)
143
+ end
144
+
145
+ def _track(copy, original)
146
+ @copies[original] = copy
147
+ end
148
+
149
+ def _tape(map)
150
+ return LazyTapedHash.new(map, self, @added) unless copied?
151
+ copy = @copies[map] || map
152
+ Factbase::Taped::TapedHash.new(copy, @added)
150
153
  end
151
154
  end
@@ -7,10 +7,6 @@ require_relative '../factbase'
7
7
 
8
8
  class Factbase::LazyTaped
9
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
10
  class LazyTapedArray
15
11
  # Creates a new lazy array wrapper.
16
12
  # @param origin [Array] The original array to wrap
@@ -8,10 +8,6 @@ require_relative 'lazy_taped_array'
8
8
 
9
9
  class Factbase::LazyTaped
10
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
11
  class LazyTapedHash
16
12
  # Creates a new LazyTapedHash decorator.
17
13
  # @param origin [Hash] The original hash being wrapped (not yet copied)
@@ -27,7 +27,7 @@ class Factbase::Traced < Factbase::TermBase
27
27
  t = @operands[0]
28
28
  raise "A term is expected, but '#{t}' provided" unless t.is_a?(Factbase::Term)
29
29
  r = t.evaluate(fact, maps, fb)
30
- puts "#{self} -> #{r}"
30
+ puts "#{self} -> #{r}" # rubocop:disable Lint/Debugger
31
31
  r
32
32
  end
33
33
  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.19.6' unless const_defined?(:VERSION)
12
+ VERSION = '0.19.8' unless const_defined?(:VERSION)
13
13
  end
data/lib/factbase.rb CHANGED
@@ -181,7 +181,6 @@ class Factbase
181
181
  end
182
182
  seen = {}.compare_by_identity
183
183
  garbage = {}.compare_by_identity
184
- pairs = taped.pairs
185
184
  taped.deleted.each do |oid|
186
185
  original = @maps.find { |m| m.object_id == oid }
187
186
  next if original.nil?
@@ -200,7 +199,8 @@ class Factbase
200
199
  b = taped.find_by_object_id(oid)
201
200
  next if b.nil?
202
201
  next if seen.key?(b)
203
- garbage[pairs[b]] = true
202
+ original = taped.source_of(b)
203
+ garbage[original] = true if original
204
204
  @maps << b
205
205
  churn.append(0, 0, 1)
206
206
  end
data/lib/fuzz.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ # Fuzzing generator for Factbase.
7
+ #
8
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
9
+ # Author:: Philip Belousov (belousovfilip@gmail.com)
10
+ # Copyright:: Copyright (c) 2024-2026 Yegor Bugayenko
11
+ # License:: MIT
12
+ class Factbase::Fuzz
13
+ LABELS = ['bug', 'enhancement', 'documentation', 'duplicate', 'question', 'good first issue', 'help wanted'].freeze
14
+ AUTHORS = ['Noah Williams', 'Mason Jones', 'Rocket Man 🚀', 'Иван Иванович', '黒さん', 'Σωκράτης', 'المنطق سي'].freeze
15
+ TITLES = ['Clean Code', 'Adding more elegance', 'Удаление статики', 'Re de Müller-Lyer', '纯代码', '✨✨✨'].freeze
16
+ MESSAGES = [
17
+ 'Good point, thanks',
18
+ 'This is not an object, it is a data holder!',
19
+ 'Why is this method static? Please refactor.',
20
+ 'I dont like this name. It is not a noun.',
21
+ 'Please add a unit test for this change.',
22
+ 'NULL is evil, never use it here.',
23
+ 'Pure elegance! Very object-oriented.',
24
+ 'Finally, a clean decorator! Good job.',
25
+ 'Exquisite! No getters, no setters, just behavior.',
26
+ 'This PR makes me happy. It is very elegant.',
27
+ 'Исправь кодировку!',
28
+ 'デザインが悪い (Poor design)',
29
+ 'C’est magnifique! ',
30
+ 'Λογική χωρίς ',
31
+ 'المنطق سيء للغاية',
32
+ 'Este código no es elegante',
33
+ '❤️❤️❤️'
34
+ ].freeze
35
+
36
+ def initialize
37
+ @next_num = 0
38
+ @max_comments = 10
39
+ raise 'Not enough messages for fuzzing' if MESSAGES.size < @max_comments
40
+ end
41
+
42
+ def self.make(count = 1000)
43
+ raise "Count must be positive: #{count}" if count.negative?
44
+ fb = Factbase.new
45
+ Factbase::Fuzz.new.feed(fb, count)
46
+ fb
47
+ end
48
+
49
+ def feed(fb, count = 1)
50
+ raise "Count must be positive: #{count}" if count.negative?
51
+ count.times do
52
+ pull_request(fb, @next_num += 1)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def pull_request(fb, idx)
59
+ f = fb.insert
60
+ f.number = idx
61
+ f.ready = rand(2)
62
+ f.cost = rand(1..32)
63
+ f.kind = 'pull_request'
64
+ f.author = AUTHORS.sample
65
+ f.diff_size = rand(10..5000)
66
+ f.state = %w[open merged closed].sample
67
+ f.title = "#{TITLES.sample} in #{LABELS.sample}"
68
+ f.test_coverage = rand(0.0..100.0).round(2)
69
+ f.created_at = Time.now - rand(1..(60 * 60 * 24 * 180))
70
+ MESSAGES.sample(idx % (@max_comments + 1)).each do |comment|
71
+ f.comments = comment
72
+ end
73
+ f
74
+ end
75
+ 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.19.6
4
+ version: 0.19.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -271,6 +271,7 @@ files:
271
271
  - lib/factbase/to_xml.rb
272
272
  - lib/factbase/to_yaml.rb
273
273
  - lib/factbase/version.rb
274
+ - lib/fuzz.rb
274
275
  homepage: https://github.com/yegor256/factbase.rb
275
276
  licenses:
276
277
  - MIT