Class: Labimotion::OntologyStore

Inherits:
Object
  • Object
show all
Defined in:
lib/labimotion/libs/ontology_store.rb

Overview

The stored vocabulary behind an "ontology-select" field: one JSON file per ontology root, holding that root's whole subtree.

Both consumers read this one file. The picker renders it as a tree, the AI auto-fill takes the labels out of it. Before the store they resolved the same subtree independently — the browser walking /children node by node, the server asking for the transitive closure — so the only thing keeping the two lists in agreement was somebody checking.

Nothing is ever written straight onto the live path. A concurrent reader must not see a half-written file, and a failed fetch must not replace a good list with an empty one, so a write takes a lock, validates what came back, writes a sibling temp file and renames it into place.

Defined Under Namespace

Classes: Busy, Error

Constant Summary collapse

ACCESSION =

The last path segment of an IRI is the term's accession (BAO_0000019), which is already filename-safe and, unlike a hash, says what the file is when you list the directory. Anything that is not a clean accession — which no real IRI produces — falls back to a digest rather than being trusted into a path.

/\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
ONTOLOGY =

owl_name is not the IRI's namespace: CHMO serves OBI and BFO terms, so the accession alone is not unique across ontologies and the prefix stays.

/\A[a-z0-9][a-z0-9_-]{0,31}\z/
KEEP_RATIO =

A refresh that comes back with a fraction of what is already stored is a service having a bad day, not an ontology that shrank by half. Keep what is on disk and say so.

0.5
MIN_COMPARABLE =

Below this there is no meaningful proportion to compare against.

8
MIN_REFRESH_SECONDS =

Refresh is a user action on shared state; this is politeness toward the ontology service, not protection of our own data.

60

Class Method Summary collapse

Class Method Details

.dirObject

Read through a method so a spec can stub the constant.



135
136
137
# File 'lib/labimotion/libs/ontology_store.rb', line 135

def dir
  Labimotion::ONTOLOGY_ROOTS_DIR
end

.document(root, synced_by: nil) ⇒ Hash?

The subtree for one parsed root, warming the file when it is missing. Never raises: a root that cannot be stored still answers this request from a live resolve, which is exactly the behaviour it had before.

Parameters:

  • root (Hash)

    { ontology:, iri: } as Labimotion::OntologyTerms.parse returns

Returns:

  • (Hash, nil)

    the stored document, or nil when nothing could be had



55
56
57
58
59
60
61
62
63
64
65
# File 'lib/labimotion/libs/ontology_store.rb', line 55

def document(root, synced_by: nil)
  stored = read(root)
  return stored if stored

  begin
    refresh!(root, synced_by: synced_by, interval: false)
  rescue StandardError => e
    log("could not store #{root[:iri]}: #{e.message}")
    live_document(root)
  end
end

.filename(root) ⇒ Object

Raises:



126
127
128
129
130
131
132
# File 'lib/labimotion/libs/ontology_store.rb', line 126

def filename(root)
  ontology = root[:ontology].to_s
  raise Error, "unusable ontology name #{ontology.inspect}" unless ONTOLOGY.match?(ontology)

  iri = root[:iri].to_s
  "#{ontology}-#{accession(iri) || "x#{Digest::SHA256.hexdigest(iri)[0, 16]}"}.json"
end

.labels(root) ⇒ Object

Just the labels, for the auto-fill prompt.



68
69
70
71
72
# File 'lib/labimotion/libs/ontology_store.rb', line 68

def labels(root)
  doc = document(root)
  terms = doc.is_a?(Hash) ? doc['terms'] : nil
  Array(terms).filter_map { |term| term['label'].to_s.strip.presence }
end

.path_for(root) ⇒ Object

<ontology>-<accession>.json under the roots directory.

Raises:



115
116
117
118
119
120
121
122
123
124
# File 'lib/labimotion/libs/ontology_store.rb', line 115

def path_for(root)
  path = File.join(dir, filename(root))
  # The root comes from a template a designer controls. Taking the segment
  # after the last separator cannot produce a traversal, but this makes it
  # impossible for a later edit to the derivation to reintroduce one.
  raise Error, 'refusing an ontology path outside the store' unless
    File.expand_path(path).start_with?("#{File.expand_path(dir)}#{File::SEPARATOR}")

  path
end

.read(root) ⇒ Object

The stored document, or nil when there is no readable file.



97
98
99
100
101
102
103
104
105
106
# File 'lib/labimotion/libs/ontology_store.rb', line 97

def read(root)
  path = path_for(root)
  return nil unless File.exist?(path)

  doc = JSON.parse(File.read(path))
  doc.is_a?(Hash) && doc['terms'].is_a?(Array) ? doc : nil
rescue StandardError => e
  log("unreadable store for #{root[:iri]}: #{e.message}")
  nil
end

.refresh!(root, synced_by: nil, interval: true) ⇒ Object

Re-fetch and rewrite. Raises Error with a message meant to be shown to whoever asked for it.

Parameters:

  • interval (Boolean) (defaults to: true)

    enforce MIN_REFRESH_SECONDS (user-triggered refresh); false when warming a file that does not exist yet.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/labimotion/libs/ontology_store.rb', line 79

def refresh!(root, synced_by: nil, interval: true)
  existing = read(root)
  guard_interval!(existing) if interval

  with_lock(root) do
    # Re-read inside the lock: whoever held it may have just done the work.
    fresh = read(root)
    next fresh if interval == false && fresh

    terms = Labimotion::OntologyTerms.subtree(root)
    validate!(terms, existing)
    doc = build_document(root, terms, synced_by)
    write_atomically(path_for(root), doc)
    doc
  end
end

.stored?(root) ⇒ Boolean

Returns:

  • (Boolean)


108
109
110
111
112
# File 'lib/labimotion/libs/ontology_store.rb', line 108

def stored?(root)
  File.exist?(path_for(root))
rescue Error
  false
end