iriq 0.33.0 → 0.35.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.
@@ -13,6 +13,24 @@ module Iriq
13
13
  class Sqlite
14
14
  SCHEMA_VERSION = 4
15
15
 
16
+ # Processes share a corpus's write lock by one rule: a writer waits up
17
+ # to LOCK_WAIT seconds for its turn, and none keeps the lock longer
18
+ # than a turn of work.
19
+ LOCK_WAIT = 10
20
+ LOCK_TURN = 1.0
21
+ # How long a writer that used a whole turn leaves the lock free: many
22
+ # of a waiter's 1ms busy retries, and a fair chance at an older iriq's
23
+ # 100ms ones.
24
+ TURN_PAUSE = 0.02
25
+
26
+ # Every table derived from the observation log.
27
+ VIEW_TABLES = %w[
28
+ host_counts path_length_counts raw_shape_counts fingerprint_counts
29
+ position_stats position_values position_types
30
+ clusters cluster_examples cluster_segments
31
+ cluster_params cluster_param_values cluster_param_types
32
+ ].freeze
33
+
16
34
  SCHEMA = <<~SQL.freeze
17
35
  CREATE TABLE IF NOT EXISTS meta (
18
36
  key TEXT PRIMARY KEY,
@@ -128,6 +146,21 @@ module Iriq
128
146
  def self.open(path, classifier: SegmentClassifier::DEFAULT,
129
147
  max_values_per_position: PositionStats::DEFAULT_MAX_VALUES)
130
148
  new(path: path, classifier: classifier, max_values_per_position: max_values_per_position).tap(&:setup!)
149
+ rescue SQLite3::Exception => e
150
+ raise corpus_error(path, e)
151
+ end
152
+
153
+ # A failure SQLite reports (not a database, can't open, read-only, full
154
+ # disk) reads like every other corpus failure: `corpus PATH: cause`. The
155
+ # gem appends the failing SQL to its message; the cause is the part before.
156
+ def self.corpus_error(path, e)
157
+ # Busy only ever means the wait for another process's lock ran out.
158
+ cause = if e.is_a?(SQLite3::BusyException)
159
+ "another process held the corpus lock for over #{LOCK_WAIT}s"
160
+ else
161
+ e.message.split(":\n", 2).first
162
+ end
163
+ CorpusError.new("corpus #{path}: #{cause}")
131
164
  end
132
165
 
133
166
  def initialize(path:, classifier: SegmentClassifier::DEFAULT,
@@ -136,18 +169,54 @@ module Iriq
136
169
  @classifier = classifier
137
170
  @max_values_per_position = max_values_per_position
138
171
  @db = SQLite3::Database.new(path)
139
- # busy_timeout MUST come first: other PRAGMAs (journal_mode in
140
- # particular) can themselves block on the write lock under
141
- # concurrent open, and without busy_timeout set they fail
142
- # immediately with SQLITE_BUSY.
143
- @db.execute("PRAGMA busy_timeout = 30000")
172
+ # Before any PRAGMA: journal_mode itself can wait on a lock. SQLite's
173
+ # own busy_timeout backs off to 100ms between retries, which would
174
+ # mostly miss a TURN_PAUSE; this one retries every 1ms.
175
+ @db.busy_handler_timeout = LOCK_WAIT * 1000
144
176
  enable_wal!
145
177
  @db.execute("PRAGMA synchronous = NORMAL")
178
+ # Up to 64MB of pages (a ceiling, not an allocation): an ingest that
179
+ # commits a turn at a time re-reads the pages it wrote last turn, and
180
+ # SQLite's default 2MB cache turns that into disk reads.
181
+ @db.execute("PRAGMA cache_size = -64000")
146
182
  @db.execute("PRAGMA foreign_keys = ON")
147
- @in_batch = false
183
+ @in_batch = false
184
+ @in_transaction = false
185
+ # Values tracked per position, remembered so each new value needn't
186
+ # re-count them. Exact only inside a transaction (the write lock is
187
+ # held) and while @counts_version still matches.
188
+ @value_counts = {}
189
+ # PRAGMA data_version when @value_counts was last known exact; it
190
+ # changes only when another connection commits.
191
+ @counts_version = nil
192
+ # When the transaction in progress took the write lock, and until
193
+ # when a connection that used a whole turn leaves the lock free.
194
+ @locked_at = nil
195
+ @next_turn = nil
196
+ # A rebuild is in progress: TEMP tables named like the views shadow
197
+ # them for this connection alone, so every view statement writes the
198
+ # rebuild. Its own transaction touches only those tables; it ends
199
+ # before a log read, so the read sees the latest log, and before the
200
+ # write lock is taken.
201
+ @rebuilding = false
202
+ @rebuild_txn = false
203
+ end
204
+
205
+ # Checked before SCHEMA runs, so iriq never adds tables to a corpus
206
+ # written by a newer iriq.
207
+ def refuse_newer_schema!
208
+ has_meta = @db.get_first_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'")
209
+ return unless has_meta
210
+
211
+ version = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'").to_i
212
+ return if version <= SCHEMA_VERSION
213
+
214
+ @db.close
215
+ raise CorpusError, "corpus #{@path}: schema version #{version} is newer than this iriq supports (#{SCHEMA_VERSION}); upgrade iriq"
148
216
  end
149
217
 
150
218
  def setup!
219
+ refuse_newer_schema!
151
220
  @db.execute_batch(SCHEMA)
152
221
  existing = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
153
222
  if existing.nil?
@@ -170,32 +239,119 @@ module Iriq
170
239
  # no-ops — the outer batch wraps everything in one txn for speed.
171
240
  return yield(self) if @in_batch
172
241
 
173
- @db.transaction
174
- yield self
175
- @db.commit
176
- rescue
177
- @db.rollback rescue nil
178
- raise
242
+ write_transaction { yield self }
179
243
  end
180
244
 
181
245
  # Wrap many observations in a single transaction. Cuts SQLite write
182
- # overhead from O(observations) fsyncs to O(1).
246
+ # overhead from O(observations) fsyncs to O(1). Yields whether another
247
+ # connection may have committed since this one's last transaction.
183
248
  def batch
184
- return yield if @in_batch
249
+ return yield(false) if @in_batch
185
250
 
186
251
  @in_batch = true
187
- @db.transaction
188
252
  begin
189
- yield
190
- @db.commit
191
- rescue
192
- @db.rollback rescue nil
193
- raise
253
+ write_transaction { |changed| yield changed }
194
254
  ensure
195
255
  @in_batch = false
196
256
  end
197
257
  end
198
258
 
259
+ # IMMEDIATE takes the write lock up front. A deferred transaction that
260
+ # reads first can't upgrade once another process commits: SQLite
261
+ # reports busy without consulting busy_timeout.
262
+ private def write_transaction
263
+ if @rebuild_txn
264
+ # A rebuild's writes so far are its own; keep them while waiting.
265
+ @db.commit
266
+ @rebuild_txn = false
267
+ end
268
+ pause = @next_turn && (@next_turn - monotonic_now)
269
+ @next_turn = nil
270
+ sleep(pause) if pause&.positive?
271
+ @db.transaction(:immediate)
272
+ @locked_at = monotonic_now
273
+ @in_transaction = true
274
+ # Under the write lock no one else can commit until we do, so the
275
+ # version read now holds for the whole transaction. An unknown last
276
+ # version (first transaction, or after a rollback) counts as changed.
277
+ version = @db.get_first_value("PRAGMA data_version")
278
+ changed = version != @counts_version
279
+ @value_counts.clear if changed
280
+ @counts_version = version
281
+ result = yield changed
282
+ @db.commit
283
+ result
284
+ rescue => e
285
+ @db.rollback rescue nil
286
+ @value_counts.clear
287
+ @counts_version = nil
288
+ raise corpus_error(e)
289
+ ensure
290
+ # One that used a whole turn leaves the lock free a moment.
291
+ @next_turn = monotonic_now + TURN_PAUSE if @locked_at && monotonic_now - @locked_at >= LOCK_TURN
292
+ @locked_at = nil
293
+ @in_transaction = false
294
+ end
295
+
296
+ # Whether the transaction in progress has held the write lock for a
297
+ # whole turn, so a long-running writer should commit and let others in.
298
+ def turn_over?
299
+ !@locked_at.nil? && monotonic_now - @locked_at >= LOCK_TURN
300
+ end
301
+
302
+ # --- Rebuilding the views ---------------------------------------------
303
+ #
304
+ # Rebuild out of sight: view writes after begin_rebuild go to fresh,
305
+ # empty views only this connection sees, until install_rebuild (inside a
306
+ # transaction) makes them the corpus's views. discard_rebuild drops a
307
+ # rebuild not installed, and also runs after an install whose
308
+ # transaction rolled back, which brings the TEMP tables back. Other
309
+ # connections keep reading and writing the live views meanwhile.
310
+
311
+ def begin_rebuild
312
+ discard_rebuild
313
+ VIEW_TABLES.each do |table|
314
+ # The live table's own definition, so the copy lines up column for
315
+ # column whichever iriq created the corpus.
316
+ sql = @db.get_first_value("SELECT sql FROM main.sqlite_master WHERE type = 'table' AND name = ?", [table])
317
+ @db.execute(sql.sub("CREATE TABLE", "CREATE TEMP TABLE"))
318
+ end
319
+ unless @in_transaction
320
+ @db.transaction
321
+ @rebuild_txn = true
322
+ end
323
+ @value_counts.clear
324
+ @rebuilding = true
325
+ rescue SQLite3::Exception => e
326
+ raise corpus_error(e)
327
+ end
328
+
329
+ def install_rebuild
330
+ VIEW_TABLES.each do |table|
331
+ @db.execute("DELETE FROM main.#{table}")
332
+ @db.execute("INSERT INTO main.#{table} SELECT * FROM temp.#{table}")
333
+ @db.execute("DROP TABLE temp.#{table}")
334
+ end
335
+ @rebuilding = false
336
+ @value_counts.clear
337
+ end
338
+
339
+ def discard_rebuild
340
+ if @rebuild_txn
341
+ @db.rollback rescue nil
342
+ @rebuild_txn = false
343
+ end
344
+ VIEW_TABLES.each { |table| @db.execute("DROP TABLE IF EXISTS temp.#{table}") rescue nil }
345
+ @rebuilding = false
346
+ @value_counts.clear
347
+ end
348
+
349
+ # SQLite failures inside a transaction become CorpusErrors; anything else
350
+ # passes through.
351
+ def corpus_error(e)
352
+ e.is_a?(SQLite3::Exception) ? Sqlite.corpus_error(@path, e) : e
353
+ end
354
+
199
355
  # Saving is automatic — incremental UPSERTs hit disk on commit. flush
200
356
  # makes that explicit; close releases the connection.
201
357
  def flush; end
@@ -261,16 +417,18 @@ module Iriq
261
417
  WHERE host = ? AND scope = ? AND locator = ? AND value = ?
262
418
  SQL
263
419
  if @db.changes.zero?
264
- card = @db.get_first_value(
265
- "SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
266
- [host, scope, locator],
420
+ where = [host, scope, locator]
421
+ card = (counts_exact? && @value_counts[where]) || @db.get_first_value(
422
+ "SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?", where,
267
423
  )
268
424
  if card < @max_values_per_position
269
425
  @db.execute(
270
426
  "INSERT INTO position_values (host, scope, locator, value, count) VALUES (?, ?, ?, ?, 1)",
271
427
  [host, scope, locator, value],
272
428
  )
429
+ card += 1
273
430
  end
431
+ @value_counts[where] = card if counts_exact?
274
432
  end
275
433
  end
276
434
 
@@ -341,8 +499,6 @@ module Iriq
341
499
  end
342
500
  end
343
501
  end
344
-
345
- load_cluster(key)
346
502
  end
347
503
 
348
504
  # Append a canonical IRI to the source-IRI log. Inside the same
@@ -358,6 +514,18 @@ module Iriq
358
514
  end
359
515
  end
360
516
 
517
+ # The observations logged after `mark` (0 for all), in id order;
518
+ # returns the id of the last one (`mark` when there are none).
519
+ def each_observed_iri_since(mark)
520
+ if @rebuild_txn
521
+ @db.commit
522
+ @db.transaction
523
+ end
524
+ rows = @db.execute("SELECT id, canonical FROM observed_iris WHERE id > ? ORDER BY id", [mark])
525
+ rows.each { |_id, canonical| yield canonical }
526
+ rows.empty? ? mark : rows.last[0]
527
+ end
528
+
361
529
  def observed_iri_count
362
530
  @db.get_first_value("SELECT COUNT(*) FROM observed_iris") || 0
363
531
  end
@@ -384,6 +552,7 @@ module Iriq
384
552
  # Drop every materialized view without touching the source-IRI log.
385
553
  # Corpus#reinfer calls this before replaying the log.
386
554
  def clear_materialized_views
555
+ @value_counts.clear
387
556
  @db.execute_batch(<<~SQL)
388
557
  DELETE FROM host_counts;
389
558
  DELETE FROM path_length_counts;
@@ -409,7 +578,7 @@ module Iriq
409
578
 
410
579
  def path_length_counts
411
580
  h = Hash.new(0)
412
- @db.execute("SELECT length, count FROM path_length_counts") { |r| h[r[0]] = r[1] }
581
+ @db.execute("SELECT length, count FROM path_length_counts") { |r| h[r[0]] = coerce_int!(r[1], 1, "count") }
413
582
  h
414
583
  end
415
584
 
@@ -432,20 +601,20 @@ module Iriq
432
601
  return nil if total.nil?
433
602
 
434
603
  stats = PositionStats.new(max_values: @max_values_per_position)
435
- stats.instance_variable_set(:@total, total)
604
+ stats.instance_variable_set(:@total, coerce_int!(total, 0, "total"))
436
605
 
437
606
  vc = Hash.new(0)
438
607
  @db.execute(
439
608
  "SELECT value, count FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
440
609
  [host, scope, locator],
441
- ) { |r| vc[r[0]] = r[1] }
610
+ ) { |r| vc[r[0]] = coerce_int!(r[1], 1, "count") }
442
611
  stats.instance_variable_set(:@value_counts, vc)
443
612
 
444
613
  tc = Hash.new(0)
445
614
  @db.execute(
446
615
  "SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?",
447
616
  [host, scope, locator],
448
- ) { |r| tc[r[0].to_sym] = r[1] }
617
+ ) { |r| tc[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
449
618
  stats.instance_variable_set(:@type_counts, tc)
450
619
 
451
620
  recompute_numeric!(stats)
@@ -479,8 +648,65 @@ module Iriq
479
648
  load_cluster(key)
480
649
  end
481
650
 
651
+ # What Corpus#classify reads: counts, not every value tracked at the
652
+ # position.
653
+ def position_evidence(position, value)
654
+ where = [position.host || "", position.scope.to_s, position.locator]
655
+ total = @db.get_first_value(
656
+ "SELECT total FROM position_stats WHERE host = ? AND scope = ? AND locator = ?", where,
657
+ )
658
+ return nil if total.nil?
659
+
660
+ type_counts = Hash.new(0)
661
+ @db.execute(
662
+ "SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?", where,
663
+ ) { |r| type_counts[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
664
+ value_count = @db.get_first_value(
665
+ "SELECT count FROM position_values WHERE host = ? AND scope = ? AND locator = ? AND value = ?",
666
+ [*where, value],
667
+ )
668
+ PositionEvidence.new(
669
+ total: coerce_int!(total, 0, "total"),
670
+ type_counts: type_counts,
671
+ cardinality: @db.get_first_value(
672
+ "SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?", where,
673
+ ),
674
+ value_count: value_count.nil? ? nil : coerce_int!(value_count, 0, "count"),
675
+ )
676
+ end
677
+
678
+ # One query param's stats — narrower than cluster_for, which also loads
679
+ # the cluster's examples and segment counts.
680
+ def param_stats(key, name)
681
+ total = @db.get_first_value(
682
+ "SELECT total FROM cluster_params WHERE cluster_key = ? AND name = ?", [key, name],
683
+ )
684
+ return nil if total.nil?
685
+
686
+ stats = PositionStats.new(max_values: @max_values_per_position)
687
+ stats.instance_variable_set(:@total, coerce_int!(total, 0, "total"))
688
+ @db.execute(
689
+ "SELECT value, count FROM cluster_param_values WHERE cluster_key = ? AND name = ?", [key, name],
690
+ ) { |r| stats.value_counts[r[0]] = coerce_int!(r[1], 1, "count") }
691
+ @db.execute(
692
+ "SELECT type, count FROM cluster_param_types WHERE cluster_key = ? AND name = ?", [key, name],
693
+ ) { |r| stats.type_counts[r[0].to_sym] = coerce_int!(r[1], 1, "count") }
694
+ recompute_numeric!(stats)
695
+ stats
696
+ end
697
+
482
698
  private
483
699
 
700
+ # Whether remembered value counts can be trusted: under the write lock,
701
+ # or while the views written are a rebuild nothing else writes.
702
+ def counts_exact?
703
+ @in_transaction || @rebuilding
704
+ end
705
+
706
+ def monotonic_now
707
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
708
+ end
709
+
484
710
  # Converting a rollback-mode database to WAL takes an exclusive lock,
485
711
  # and SQLite does NOT consult the busy handler for that lock — so
486
712
  # concurrent first-opens of a fresh corpus can fail with SQLITE_BUSY
@@ -488,11 +714,11 @@ module Iriq
488
714
  # retry briefly — either this connection wins the conversion or
489
715
  # another process already converted the file.
490
716
  def enable_wal!
491
- attempts = 0
717
+ deadline = monotonic_now + LOCK_WAIT
492
718
  begin
493
719
  @db.execute("PRAGMA journal_mode = WAL")
494
720
  rescue SQLite3::BusyException
495
- raise if (attempts += 1) > 100
721
+ raise if monotonic_now > deadline
496
722
 
497
723
  sleep 0.01
498
724
  retry
@@ -508,21 +734,45 @@ module Iriq
508
734
 
509
735
  def rows_to_count_hash(table, key_col)
510
736
  h = Hash.new(0)
511
- @db.execute("SELECT #{key_col}, count FROM #{table}") { |r| h[r[0]] = r[1] }
737
+ @db.execute("SELECT #{key_col}, count FROM #{table}") { |r| h[r[0]] = coerce_int!(r[1], 1, "count") }
512
738
  h
513
739
  end
514
740
 
741
+ # rusqlite's storage class name for a value SQLite handed back untyped
742
+ # for an INTEGER column — matches the words rust/ raises for the same
743
+ # corrupted row so the two runtimes agree.
744
+ SQLITE_TYPE_NAMES = {
745
+ NilClass => "Null",
746
+ Float => "Real",
747
+ String => "Text",
748
+ }.freeze
749
+
750
+ # Every count/total column is declared INTEGER, but SQLite's affinity
751
+ # rules only convert a value on write when that can be done losslessly
752
+ # — a hand-edited (or otherwise corrupted) corpus can still leave a
753
+ # Text or Real value behind. Reads validate the type here instead of
754
+ # crashing downstream on Integer#+ / Array#sum / Integer#times.
755
+ def coerce_int!(value, index, name)
756
+ return value if value.is_a?(Integer)
757
+
758
+ type_name = SQLITE_TYPE_NAMES.fetch(value.class, "Blob")
759
+ raise CorpusError, "corpus #{@path}: Invalid column type #{type_name} at index: #{index}, name: #{name}"
760
+ end
761
+
515
762
  def load_cluster(key)
763
+ # key isn't re-selected — it's already the query parameter (matches
764
+ # the Rust reader's column list, so a corrupted `count` reports the
765
+ # same column index in both runtimes).
516
766
  row = @db.get_first_row(
517
- "SELECT key, host, scheme, shape, count FROM clusters WHERE key = ?", [key],
767
+ "SELECT host, scheme, shape, count FROM clusters WHERE key = ?", [key],
518
768
  )
519
769
  return nil unless row
520
770
 
521
771
  c = Cluster.new(
522
- key: row[0], host: row[1], scheme: row[2], shape: row[3],
772
+ key: key, host: row[0], scheme: row[1], shape: row[2],
523
773
  max_values: @max_values_per_position,
524
774
  )
525
- c.instance_variable_set(:@count, row[4])
775
+ c.instance_variable_set(:@count, coerce_int!(row[3], 3, "count"))
526
776
 
527
777
  examples = []
528
778
  @db.execute(
@@ -535,9 +785,9 @@ module Iriq
535
785
  "SELECT position, value, count FROM cluster_segments WHERE cluster_key = ? ORDER BY position",
536
786
  [key],
537
787
  ) do |r|
538
- pos = r[0]
788
+ pos = coerce_int!(r[0], 0, "position")
539
789
  seg_counts[pos] ||= Hash.new(0)
540
- seg_counts[pos][r[1]] = r[2]
790
+ seg_counts[pos][r[1]] = coerce_int!(r[2], 2, "count")
541
791
  end
542
792
  c.instance_variable_set(:@segment_counts, seg_counts)
543
793
 
@@ -550,20 +800,20 @@ module Iriq
550
800
  # and type counts; only @total needs filling here. The followup
551
801
  # SELECTs below populate value/type rows in place.
552
802
  stats = PositionStats.new(max_values: @max_values_per_position)
553
- stats.instance_variable_set(:@total, r[1])
803
+ stats.instance_variable_set(:@total, coerce_int!(r[1], 1, "total"))
554
804
  params[r[0]] = stats
555
805
  end
556
806
  @db.execute(
557
807
  "SELECT name, value, count FROM cluster_param_values WHERE cluster_key = ?", [key],
558
808
  ) do |r|
559
809
  stats = params[r[0]] or next
560
- stats.value_counts[r[1]] = r[2]
810
+ stats.value_counts[r[1]] = coerce_int!(r[2], 2, "count")
561
811
  end
562
812
  @db.execute(
563
813
  "SELECT name, type, count FROM cluster_param_types WHERE cluster_key = ?", [key],
564
814
  ) do |r|
565
815
  stats = params[r[0]] or next
566
- stats.type_counts[r[1].to_sym] = r[2]
816
+ stats.type_counts[r[1].to_sym] = coerce_int!(r[2], 2, "count")
567
817
  end
568
818
  params.each_value { |stats| recompute_numeric!(stats) }
569
819
  c.instance_variable_set(:@param_stats, params)
@@ -584,7 +834,7 @@ module Iriq
584
834
  sum = 0.0
585
835
  stats.value_counts.each do |value, n|
586
836
  num = Float(value, exception: false)
587
- next unless num
837
+ next unless num&.finite? # same rule as PositionStats#record_numeric
588
838
 
589
839
  count += n
590
840
  min = num if min.nil? || num < min
data/lib/iriq/storage.rb CHANGED
@@ -13,6 +13,10 @@ module Iriq
13
13
  module Storage
14
14
  SQLITE_EXTS = %w[.db .sqlite .sqlite3].freeze
15
15
 
16
+ TEMP_SEQ_LOCK = Mutex.new
17
+ @temp_seq = 0
18
+ def self.next_temp_seq = TEMP_SEQ_LOCK.synchronize { @temp_seq += 1 }
19
+
16
20
  module_function
17
21
 
18
22
  # Opens (or creates) a storage at `path`, picking the backend by extension.
@@ -29,6 +33,20 @@ module Iriq
29
33
  Json.open(path, classifier: classifier, max_values_per_position: max_values_per_position)
30
34
  end
31
35
  end
36
+
37
+ # Replace `path` atomically via a per-write temp file, PATH.<pid>.<n>.tmp
38
+ # (Rust's writer uses the same shape; --reset sweeps exactly it), + rename.
39
+ # A shared PATH.tmp let concurrent writers rename each other's file away
40
+ # (ENOENT). Last writer still wins: a JSON corpus is single-writer.
41
+ def write_atomically(path, contents)
42
+ tmp = "#{path}.#{Process.pid}.#{Storage.next_temp_seq}.tmp"
43
+ File.write(tmp, contents)
44
+ File.rename(tmp, path)
45
+ rescue SystemCallError => e
46
+ raise CorpusError, "corpus #{path}: #{Iriq.os_error_message(e)}"
47
+ ensure
48
+ File.delete(tmp) if tmp && File.exist?(tmp)
49
+ end
32
50
  end
33
51
  end
34
52
 
data/lib/iriq/trace.rb CHANGED
@@ -31,8 +31,9 @@ module Iriq
31
31
  input: iri.canonical,
32
32
  normalized: normalized,
33
33
  scheme: iri.scheme,
34
- host: iri.host,
35
34
  }
35
+ # Fields that don't apply are omitted, not null (matches parse JSON).
36
+ out[:host] = iri.host if iri.host
36
37
  out[:port] = iri.port if iri.port
37
38
 
38
39
  if iri.urn?
@@ -84,25 +85,24 @@ module Iriq
84
85
  payload: { type: entry[:type], variable: entry[:variable], hint: entry[:hint] },
85
86
  )
86
87
 
88
+ # The canonical policy applies even when the value is already canonical
89
+ # (that's what makes the row print the value, not a placeholder); only
90
+ # the note is reserved for an actual rewrite.
87
91
  if entry[:variable]
88
92
  if entry[:type] == :date && (canon = SegmentClassifier.canonical_date(entry[:value]))
89
- if canon != entry[:value]
90
- records << Evidence.segment(
91
- index: idx, value: entry[:value],
92
- source: :policy,
93
- payload: { rule: :canonical_date, before: entry[:value], after: canon },
94
- notes: ["canonical date (#{entry[:value]} → #{canon})"],
95
- )
96
- end
93
+ records << Evidence.segment(
94
+ index: idx, value: entry[:value],
95
+ source: :policy,
96
+ payload: { rule: :canonical_date, before: entry[:value], after: canon },
97
+ notes: canon == entry[:value] ? [] : ["canonical date (#{entry[:value]} → #{canon})"],
98
+ )
97
99
  elsif entry[:type] == :currency && (canon = SegmentClassifier.canonical_currency(entry[:value]))
98
- if canon != entry[:value]
99
- records << Evidence.segment(
100
- index: idx, value: entry[:value],
101
- source: :policy,
102
- payload: { rule: :canonical_currency, before: entry[:value], after: canon },
103
- notes: ["currency upcase (#{entry[:value]} → #{canon})"],
104
- )
105
- end
100
+ records << Evidence.segment(
101
+ index: idx, value: entry[:value],
102
+ source: :policy,
103
+ payload: { rule: :canonical_currency, before: entry[:value], after: canon },
104
+ notes: canon == entry[:value] ? [] : ["currency upcase (#{entry[:value]} → #{canon})"],
105
+ )
106
106
  else
107
107
  extra = placeholder_decoration_evidence(entry, segments, idx, classifier, hints)
108
108
  records.concat(extra)
@@ -162,23 +162,19 @@ module Iriq
162
162
  )
163
163
 
164
164
  if effective == :date && (canon = SegmentClassifier.canonical_date(value))
165
- if canon != value
166
- records << Evidence.segment(
167
- index: name, value: value,
168
- source: :policy,
169
- payload: { rule: :canonical_date, before: value, after: canon },
170
- notes: ["canonical date (#{value} → #{canon})"],
171
- )
172
- end
165
+ records << Evidence.segment(
166
+ index: name, value: value,
167
+ source: :policy,
168
+ payload: { rule: :canonical_date, before: value, after: canon },
169
+ notes: canon == value ? [] : ["canonical date (#{value} → #{canon})"],
170
+ )
173
171
  elsif effective == :currency && (canon = SegmentClassifier.canonical_currency(value))
174
- if canon != value
175
- records << Evidence.segment(
176
- index: name, value: value,
177
- source: :policy,
178
- payload: { rule: :canonical_currency, before: value, after: canon },
179
- notes: ["currency upcase (#{value} → #{canon})"],
180
- )
181
- end
172
+ records << Evidence.segment(
173
+ index: name, value: value,
174
+ source: :policy,
175
+ payload: { rule: :canonical_currency, before: value, after: canon },
176
+ notes: canon == value ? [] : ["currency upcase (#{value} → #{canon})"],
177
+ )
182
178
  elsif effective == :ipv4 || effective == :ipv6
183
179
  records << Evidence.segment(
184
180
  index: name, value: value,
data/lib/iriq/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iriq
2
- VERSION = "0.33.0"
2
+ VERSION = "0.35.0"
3
3
  end
data/lib/iriq.rb CHANGED
@@ -19,6 +19,7 @@ require "iriq/trace"
19
19
  require "iriq/cluster"
20
20
  require "iriq/clusterer"
21
21
  require "iriq/position_stats"
22
+ require "iriq/position_evidence"
22
23
  require "set"
23
24
 
24
25
  require "iriq/observation"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iriq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -110,6 +110,7 @@ files:
110
110
  - lib/iriq/parser.rb
111
111
  - lib/iriq/path_shape.rb
112
112
  - lib/iriq/position.rb
113
+ - lib/iriq/position_evidence.rb
113
114
  - lib/iriq/position_stats.rb
114
115
  - lib/iriq/recognizer.rb
115
116
  - lib/iriq/recognizer_proposal.rb