factbase 0.19.6 → 0.19.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99e8fdb60e5fb1e8083b76db845cfad8a7b6be35700c543819ce631f8ac3ca19
4
- data.tar.gz: 1fd106429c8a3aa39e56683060b5e61395e705abe336290da63fb1a35e748ec5
3
+ metadata.gz: 6c38336019e70a00d0845dfdbcc4a0fb033a4be8c3fd0ff8595c3de8bdc25cc1
4
+ data.tar.gz: 55b820f5d37a074a1a5f7ca2f9dafeb8d5180cdfd52ee354a855e2c402398d7d
5
5
  SHA512:
6
- metadata.gz: e03d29fb285a301e48723690f3bdad751a0bc229bd559d901e22fdb196a4e263d5b75bbc6fef2da5480f07f1a4d22d510e630f7f7a7af8988d2b981d6a4b0b10
7
- data.tar.gz: c41be62abd514f4ea0fde5e47fba206f27a8bd89a82a17e03c55a3908e5d9c98e909eca6bb571e183f8c2f11421617f0a20b31db132c1107a91eea0fb12cb0c1
6
+ metadata.gz: 5a018f96e8929e0a3a7946ad4b25cfdaf042c968cad1e0689d484235ab9aaa3b29b3a17557673e9c8b7ba32226a2746a7b0f1fa5ea1e27700aa96cfa18fa7580
7
+ data.tar.gz: a1dc92d273a4a6a6a870dd050a20fd23e2d06175861a2c094a45c68a80deaee479adb98ab027df1bee7791eb48e03bf520523be999ab49b88b2d4a57e0f29ddd
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GEM
34
34
  language_server-protocol (3.17.0.5)
35
35
  lint_roller (1.1.0)
36
36
  logger (1.7.0)
37
- loog (0.7.2)
37
+ loog (0.8.0)
38
38
  ellipsized
39
39
  logger (~> 1.0)
40
40
  minitest (6.0.1)
@@ -62,7 +62,7 @@ GEM
62
62
  psych (5.3.1)
63
63
  date
64
64
  stringio
65
- qbash (0.7.2)
65
+ qbash (0.8.0)
66
66
  backtrace (> 0)
67
67
  elapsed (> 0)
68
68
  loog (> 0)
@@ -76,7 +76,7 @@ GEM
76
76
  tsort
77
77
  regexp_parser (2.11.3)
78
78
  rexml (3.4.4)
79
- rubocop (1.84.1)
79
+ rubocop (1.84.2)
80
80
  json (~> 2.3)
81
81
  language_server-protocol (~> 3.17.0.2)
82
82
  lint_roller (~> 1.1.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
@@ -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
@@ -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)
@@ -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.7' unless const_defined?(:VERSION)
13
13
  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.7
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