relaton 3.0.0.pre.alpha.18 → 3.0.0.pre.alpha.19

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: 5e4cd375143aef3a8e9dacfec4c86c06937243afe6a28a50d2960926e9c3396c
4
- data.tar.gz: 8f96b7587af7145d05843de68b465263fdd524ad25adfd84fd53970ec97fd4a7
3
+ metadata.gz: eee3114e443e307b96ca18a96dff2a48dcb4d5b5dae424fd2ecb3ca6e57202be
4
+ data.tar.gz: 7208a6c3f058fca1874370a24bfe89d03516d5c4d66477d8c4160c4517717c44
5
5
  SHA512:
6
- metadata.gz: f74dc77c912c9894872cfae0f04883badc152bf4c0f4e4abf4e2871a8352a1445b89cfb8ef0cac7f9270fd297327823a4c703013fb559bb268a39b0319fc120b
7
- data.tar.gz: 9ffb0a15a5cb561f8699ef307c966a470ca96ac2cc97a968161cb7095dedfc5c2f6a5a2db8f59d8e53722776d61f72643ec2b32c8e6ccc50b8e9dd837fdfdbed
6
+ metadata.gz: f8c13c3bbdbe96caa0874d0b0c4671c05fa4c6652070372b0fc55992ac3c87834a6e03bda4a7fe17945d31db33b5f1a5a99d927d8d1d203d8238a7e837c29d77
7
+ data.tar.gz: 3e5e49f9fdd82e5abe32bc4afb35b2baf075bc1b10aee9aad0e663f4361a0486069ff94cf3d4c066d17ccf4f32dacfac5eccad6eb1534008978d39734577dc84
@@ -27,6 +27,18 @@ chains:
27
27
  - 'EN \1'
28
28
  - '\1'
29
29
 
30
+ # Adoption ladders: the reverse edges. A code matching `pattern` is a base
31
+ # document; `forms` are its national/regional adoptions, ordered
32
+ # outermost-last, so the resolver can walk a chain from either end
33
+ # (TODO.sota: adoption chains resolvable end-to-end, bidirectional).
34
+ adoptions:
35
+ - pattern: '^(ISO[ /].+)$'
36
+ forms:
37
+ - 'EN \1'
38
+ - 'BS EN \1'
39
+ - 'DIN EN \1'
40
+ - 'NF EN \1'
41
+
30
42
  # Explicit equivalences: every form in `equivalents` resolves to
31
43
  # `canonical` (and the canonical resolves to itself). Used where no pattern
32
44
  # expresses the relationship (joint publications, historical adoptions).
@@ -36,3 +48,12 @@ equivalences:
36
48
  - EN ISO/IEC 17025
37
49
  - BS EN ISO/IEC 17025
38
50
  - DIN EN ISO/IEC 17025
51
+ # Joint publication: the same document under a different publisher
52
+ # ordering (Law 3.1) — the direction each corpus prints is the data.
53
+ - canonical: ISO/IEC 14515-1
54
+ equivalents:
55
+ - IEC/ISO 14515-1
56
+ # Variant spelling: the prefixed form of a bare reference (Law 3.3).
57
+ - canonical: RFC 7200
58
+ equivalents:
59
+ - IETF RFC 7200
@@ -23,7 +23,11 @@ module Relaton
23
23
  attribute :docnumber, :string
24
24
  attribute :date, Date, collection: true, initialize_empty: true
25
25
  attribute :contributor, Contributor, collection: true, initialize_empty: true
26
- attribute :edition, Edition
26
+ # Lenient ingest: some upstream corpus documents carry more than one
27
+ # <edition> in a bibitem (multi-edition bibdata, relaton-db#149); keep
28
+ # the first instead of failing the whole round-trip with
29
+ # CollectionTrueMissingError (lutaml-model#971, singular_tolerance).
30
+ attribute :edition, Edition, singular_tolerance: :first
27
31
  attribute :version, Version, collection: true, initialize_empty: true
28
32
  attribute :note, Note, collection: true, initialize_empty: true
29
33
  attribute :language, :string, collection: true, initialize_empty: true
@@ -36,6 +36,73 @@ module Relaton
36
36
  # @return [Boolean] whether this run prunes the existing output tree
37
37
  attr_accessor :prune
38
38
 
39
+ # @return [Boolean] whether this run consults and maintains the
40
+ # content-digest ledger (see #unchanged?). Off by default: a flavor
41
+ # turns it on only where its enumeration entry determines the record
42
+ # (the TODO.sota crawl-audit rule).
43
+ attr_accessor :digest_ledger
44
+
45
+ # -- Content-digest ledger -----------------------------------------
46
+ #
47
+ # A per-output sidecar (`.digests.yaml`: key -> digest + file) lets a
48
+ # run skip the fetch+parse+write of a record whose enumeration entry is
49
+ # unchanged, where the entry determines the record. The sidecar is
50
+ # tracked in the data repo so a fresh CI clone still skips. Skipped
51
+ # files are recorded as written, so #prune_unwritten keeps them; the
52
+ # saved ledger keeps only files this run owned, so deleted records
53
+ # drop out with their file.
54
+ #
55
+ # Sound only where the entry bounds the record: a per-item detail
56
+ # fetch whose content the entry does not determine, or relation output
57
+ # discovered across records, must stay outside the skip (TODO.sota,
58
+ # crawl audit 2026-10-23).
59
+
60
+ LEDGER_FILE = ".digests.yaml"
61
+
62
+ # True when the ledger holds `key` with the entry's digest - the
63
+ # record is unchanged and the caller may skip it. Records the
64
+ # ledger's file as written, so prune keeps a skipped record.
65
+ #
66
+ # @param key [String] the record's stable identity (its docid)
67
+ # @param entry [Object] the enumeration entry; digested as JSON
68
+ # @return [Boolean]
69
+ def unchanged?(key, entry)
70
+ return false unless @digest_ledger
71
+
72
+ row = ledger[key]
73
+ return false unless row.is_a?(Hash) && row["digest"] == entry_digest(entry)
74
+
75
+ @written << row["file"] if row["file"]
76
+ warn "Skipping unchanged `#{key}`"
77
+ true
78
+ end
79
+
80
+ # Record the entry's digest for `key`, with the file the record was
81
+ # written to (nil when this run wrote no file for it - the entry
82
+ # drops out of the ledger at save time).
83
+ #
84
+ # @param key [String]
85
+ # @param entry [Object]
86
+ # @param file [String, nil]
87
+ # @return [void]
88
+ def note_digest(key, entry, file: nil)
89
+ return unless @digest_ledger
90
+
91
+ ledger_new[key] = { "digest" => entry_digest(entry), "file" => file }
92
+ end
93
+
94
+ # Persist the ledger: this run's noted entries over the loaded ones,
95
+ # keeping only entries whose file this run recorded as written.
96
+ #
97
+ # @return [void]
98
+ def save_ledger
99
+ kept = ledger.merge(ledger_new).select do |_, row|
100
+ row.is_a?(Hash) && row["file"] && @written.include?(row["file"])
101
+ end
102
+ File.write ledger_path, YAML.dump(kept), encoding: "UTF-8"
103
+ @ledger = kept
104
+ end
105
+
39
106
  # API method for external service
40
107
  #
41
108
  # @param prune [Boolean] delete output files this run did not write. The
@@ -43,7 +110,8 @@ module Relaton
43
110
  # ratio guard stops a partial crawl from wiping the dataset.
44
111
  # @return the value returned by the instance `#fetch`, so callers can act
45
112
  # on the outcome (e.g. relaton-iso returns whether it rebuilt).
46
- def self.fetch(source = nil, output: "data", format: "yaml", prune: false)
113
+ def self.fetch(source = nil, output: "data", format: "yaml", prune: false,
114
+ digest_ledger: false)
47
115
  t1 = Time.now
48
116
  puts "Started at: #{t1}"
49
117
  FileUtils.mkdir_p output
@@ -51,8 +119,10 @@ module Relaton
51
119
  # Only touched in prune mode: the default path stays exactly what it
52
120
  # was, and existing ::fetch callers (and their specs) see no new call.
53
121
  fetcher.prune = true if prune
122
+ fetcher.digest_ledger = digest_ledger
54
123
  result = fetcher.fetch(source)
55
124
  fetcher.prune_unwritten if prune
125
+ fetcher.save_ledger if digest_ledger
56
126
  t2 = Time.now
57
127
  puts "Stopped at: #{t2}"
58
128
  puts "Done in: #{(t2 - t1).round} sec."
@@ -251,6 +321,28 @@ module Relaton
251
321
 
252
322
  private
253
323
 
324
+ def ledger_path
325
+ File.join @output, LEDGER_FILE
326
+ end
327
+
328
+ def ledger
329
+ @ledger ||= if File.exist?(ledger_path)
330
+ YAML.safe_load_file(ledger_path) || {}
331
+ else
332
+ {}
333
+ end
334
+ end
335
+
336
+ def ledger_new
337
+ @ledger_new ||= {}
338
+ end
339
+
340
+ def entry_digest(entry)
341
+ require "digest"
342
+ require "json"
343
+ Digest::SHA256.hexdigest JSON.generate(entry)
344
+ end
345
+
254
346
  # Write, clobbering whatever is there. The caller has already established
255
347
  # that the path is ours to take. Identical content is left untouched:
256
348
  # an incremental crawl (one that keeps the data tree) must not churn
data/lib/relaton/db.rb CHANGED
@@ -158,13 +158,15 @@ module Relaton
158
158
  end
159
159
 
160
160
  # Cross-SDO equivalence as data: on a total miss, walk the equivalence
161
- # tables from relaton/equivalence.rb — a national adoption falls back to
162
- # the EN, the EN to the ISO. Each candidate goes through the full routed
163
- # fetch (cache, co-publishers); an unrouteable candidate is skipped.
161
+ # tables from relaton/equivalence.rb in both directions — a national
162
+ # adoption falls back to the EN, the EN to the ISO, and a base document
163
+ # falls forward to its declared adoptions (.related_for). Each candidate
164
+ # goes through the full routed fetch (cache, co-publishers); an
165
+ # unrouteable candidate is skipped.
164
166
  #
165
167
  # @return [Relaton::Bib::ItemData, nil]
166
168
  def fetch_equivalent(code, year, opts)
167
- Equivalence.equivalents_for(code).each do |candidate|
169
+ Equivalence.related_for(code).each do |candidate|
168
170
  Util.info "Not found; trying equivalent `#{candidate}`", key: code
169
171
  std, pubid = @registry.route(candidate)
170
172
  bib = fetch_routed(candidate, year, opts, std, pubid) and return bib
@@ -84,6 +84,34 @@ module Relaton
84
84
  end
85
85
  out
86
86
  end
87
+
88
+ # Every edge of `code`'s equivalence set: the canonical walk
89
+ # (.equivalents_for — a national adoption falls back to the EN, the EN
90
+ # to the ISO) PLUS the reverse edges, generated from the tables'
91
+ # `adoptions` ladders (the ISO resolves to its EN, BS EN, DIN EN and
92
+ # NF EN adoptions). The code itself is excluded; the walk order is
93
+ # canonical-first, ladder outermost-last.
94
+ #
95
+ # @param [String] code
96
+ # @return [Array<String>]
97
+ def related_for(code)
98
+ out = equivalents_for(code)
99
+ load_seed
100
+ ([code] + out.uniq).each do |candidate|
101
+ tables.each do |table|
102
+ (table["adoptions"] || []).each do |ladder|
103
+ m = Regexp.new(ladder["pattern"]).match(candidate)
104
+ next unless m
105
+
106
+ Array(ladder["forms"]).each do |template|
107
+ form = template.gsub(/\\(\d+)/) { m[Regexp.last_match(1).to_i] }
108
+ out << form unless form == code || out.include?(form)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ out
114
+ end
87
115
  end
88
116
  end
89
117
  end
@@ -46,9 +46,13 @@ module Relaton
46
46
 
47
47
  def fetch_doc(hit)
48
48
  return if hit["type"] == "CC"
49
+ # The list entry carries the whole record payload, so an unchanged
50
+ # entry skips the parse and the write (the digest ledger).
51
+ return if unchanged?(hit["identifier"], hit)
49
52
 
50
53
  bib = Scraper.parse_page hit, @errors
51
- write_document bib
54
+ file = write_document bib
55
+ note_digest(hit["identifier"], hit, file: file) if file
52
56
  true
53
57
  rescue StandardError => e
54
58
  Util.error "Fetching document: #{hit['identifier']}\n" \
@@ -56,17 +60,19 @@ module Relaton
56
60
  false
57
61
  end
58
62
 
63
+ # @return [String, nil] the file written, or nil for a duplicate
59
64
  def write_document(bib) # rubocop:disable Metrics/AbcSize
60
65
  docid = bib.docidentifier[0].content
61
66
  if @docids.include?(docid)
62
67
  @dupids << docid
63
- return
68
+ return nil
64
69
  end
65
70
 
66
71
  @docids << docid
67
72
  file = file_name bib
68
73
  index.add_or_update index_id(docid), file
69
- File.write file, serialize(bib), encoding: "UTF-8"
74
+ force_write file, serialize(bib)
75
+ file
70
76
  end
71
77
 
72
78
  #
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.0.0.pre.alpha.18".freeze
2
+ VERSION = "3.0.0.pre.alpha.19".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.pre.alpha.18
4
+ version: 3.0.0.pre.alpha.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -442,14 +442,14 @@ dependencies:
442
442
  requirements:
443
443
  - - "~>"
444
444
  - !ruby/object:Gem::Version
445
- version: 2.0.0.pre.alpha.28
445
+ version: 2.0.0.pre.alpha.59
446
446
  type: :runtime
447
447
  prerelease: false
448
448
  version_requirements: !ruby/object:Gem::Requirement
449
449
  requirements:
450
450
  - - "~>"
451
451
  - !ruby/object:Gem::Version
452
- version: 2.0.0.pre.alpha.28
452
+ version: 2.0.0.pre.alpha.59
453
453
  - !ruby/object:Gem::Dependency
454
454
  name: yeptris
455
455
  requirement: !ruby/object:Gem::Requirement