Class: Causalontology::InMemoryStore

Inherits:
Object
  • Object
show all
Defined in:
lib/causalontology/store.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(enforcing: true) ⇒ InMemoryStore

Returns a new instance of InMemoryStore.



30
31
32
33
34
35
# File 'lib/causalontology/store.rb', line 30

def initialize(enforcing: true)
  @enforcing = enforcing
  @objects = {}    # id -> content object
  @records = {}    # id -> provenance record
  @quarantine = {} # id -> record (unsigned / unverifiable)
end

Instance Attribute Details

#enforcingObject (readonly)

Returns the value of attribute enforcing.



28
29
30
# File 'lib/causalontology/store.rb', line 28

def enforcing
  @enforcing
end

#objectsObject (readonly)

Returns the value of attribute objects.



28
29
30
# File 'lib/causalontology/store.rb', line 28

def objects
  @objects
end

#quarantineObject (readonly)

Returns the value of attribute quarantine.



28
29
30
# File 'lib/causalontology/store.rb', line 28

def quarantine
  @quarantine
end

#recordsObject (readonly)

Returns the value of attribute records.



28
29
30
# File 'lib/causalontology/store.rb', line 28

def records
  @records
end

Class Method Details

.canon_label(text) ⇒ Object

-------------------------------------------------------------- resolve



250
251
252
# File 'lib/causalontology/store.rb', line 250

def self.canon_label(text)
  text.strip.downcase.split.join("_")
end

.find_cycle_records(recs) ⇒ Object

The records forming one directed cycle among the given enrichment records, or an empty array when the graph they draw is acyclic.



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/causalontology/store.rb', line 185

def self.find_cycle_records(recs)
  edges = {}
  recs.each { |r| (edges[r["about"]] ||= []) << [r["entry"], r] }
  state = Hash.new(0)
  cycle = []

  dfs = nil
  dfs = lambda do |node, path_records|
    state[node] = 1
    (edges[node] || []).each do |nxt, rec|
      if state[nxt] == 1
        cycle.concat(path_records + [rec])
        return true
      end
      if state[nxt] == 0
        return true if dfs.call(nxt, path_records + [rec])
      end
    end
    state[node] = 2
    false
  end

  edges.keys.each do |start|
    return cycle if state[start] == 0 && dfs.call(start, [])
  end
  []
end

.norm_alias(text) ⇒ Object



254
255
256
# File 'lib/causalontology/store.rb', line 254

def self.norm_alias(text)
  text.split.join(" ").downcase(:fold)
end

Instance Method Details

#active_taxonomy_edges(field) ⇒ Object

[edges, excluded] for subsumes/part_of after rule 13 cycle-breaking.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/causalontology/store.rb', line 164

def active_taxonomy_edges(field)
  retracted = retracted_ids
  recs = records_of("enrichment").select do |r|
    r["field"] == field && !retracted.include?(r["id"])
  end
  active = recs.dup
  excluded = []
  loop do
    cyc = self.class.find_cycle_records(active)
    break if cyc.empty?
    # exclude the cycle-completing record with the LATEST timestamp,
    # ties broken by lexicographic record identifier (deterministic)
    loser = cyc.max_by { |r| [r["timestamp"], r["id"]] }
    active.delete_at(active.index(loser))
    excluded << loser
  end
  [active, excluded]
end

#assertions_about(identifier, include_retracted: false) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/causalontology/store.rb', line 136

def assertions_about(identifier, include_retracted: false)
  retracted = retracted_ids
  out = []
  records_of("assertion").each do |r|
    next unless r["about"] == identifier
    if retracted.include?(r["id"])
      out << r.merge("retracted" => true) if include_retracted
      next
    end
    out << r
  end
  out
end

#enrichments_about(identifier, include_retracted: false) ⇒ Object



150
151
152
153
154
155
156
157
158
159
# File 'lib/causalontology/store.rb', line 150

def enrichments_about(identifier, include_retracted: false)
  retracted = retracted_ids
  out = []
  records_of("enrichment").each do |r|
    next unless r["about"] == identifier
    next if retracted.include?(r["id"]) && !include_retracted
    out << r
  end
  out
end

#force_merge_record(record, kind = nil) ⇒ Object

Simulate a decentralized replica merge (no enforcement gate).



92
93
94
# File 'lib/causalontology/store.rb', line 92

def force_merge_record(record, kind = nil)
  put_record(record, kind, force: true)
end

#gaps(kind = nil) ⇒ Object

The stigmergy read. Gap kinds per spec/store.md.



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/causalontology/store.rb', line 288

def gaps(kind = nil)
  out = []
  refined = Set.new
  @objects.each_value do |obj|
    next unless obj["type"] == "cro" && obj["refines"]
    parent = @objects[obj["refines"]]
    next if parent.nil?
    ok, _reason = Semantics.refinement_valid(obj, parent)
    refined << parent["id"] if ok
  end
  @objects.each do |oid, obj|
    next unless obj["type"] == "cro"
    # missing_field: lacking the temporal window or the modality -
    # mechanism and context may legitimately stay unspecified forever
    # (empty_mechanism is its own kind; absent context = context-free).
    if (!obj.key?("temporal") || !obj.key?("modality")) &&
       !refined.include?(oid)
      out << { "id" => oid, "kind" => "missing_field",
               "missing" => Semantics.is_partial(obj)[1] }
    end
    if !obj.key?("mechanism") || obj["mechanism"] == []
      unless refined.include?(oid)
        out << { "id" => oid, "kind" => "empty_mechanism" }
      end
    end
  end
  ["subsumes", "part_of"].each do |field|
    _, excluded = active_taxonomy_edges(field)
    excluded.each do |rec|
      out << { "id" => rec["id"], "kind" => "inconsistent_hierarchy",
               "note" => "excluded by the deterministic " \
                         "cycle-breaking view rule" }
    end
  end
  # dangling_reference: a reference to an object absent from the store -
  # the red link that says "this page is wanted".
  @objects.each do |oid, obj|
    refs = []
    if obj["type"] == "cro"
      refs = (obj["causes"] || []).to_a +
             (obj["effects"] || []).to_a +
             (obj["context"] || []).to_a +
             (obj["mechanism"] || []).to_a
      refs << obj["refines"] if obj["refines"]
    elsif obj["type"] == "realizable"
      refs = [obj["bearer"]]
    end
    refs.each do |ref|
      if ref && !@objects.key?(ref)
        out << { "id" => oid, "kind" => "dangling_reference", "ref" => ref }
      end
    end
  end
  # conflict: pairs of claims satisfying the formal test (rule 6).
  cros = @objects.values.select { |o| o["type"] == "cro" }
  (0...cros.length).each do |i|
    ((i + 1)...cros.length).each do |j|
      if Semantics.conflicts(cros[i], cros[j])
        out << { "kind" => "conflict",
                 "a" => cros[i]["id"], "b" => cros[j]["id"] }
      end
    end
  end
  out = out.select { |g| g["kind"] == kind } unless kind.nil?
  out
end

#get(identifier, view: "default") ⇒ Object

The object with its materialized enrichment sets and contributors.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/causalontology/store.rb', line 222

def get(identifier, view: "default")
  obj = @objects[identifier]
  return nil if obj.nil?
  include_retracted = (view == "history")
  excluded_ids = Set.new
  ["subsumes", "part_of"].each do |field|
    _, excluded = active_taxonomy_edges(field)
    excluded.each { |r| excluded_ids << r["id"] }
  end
  fields = {}
  enrichments_about(identifier, include_retracted: include_retracted).each do |rec|
    next if excluded_ids.include?(rec["id"]) && view != "history"
    entry = rec["entry"]
    entry_key = [rec["field"],
                 entry.is_a?(Hash) ? entry.to_a.sort : entry]
    slot = (fields[rec["field"]] ||= {})
    bucket = (slot[entry_key] ||= { "entry" => entry, "contributors" => [] })
    bucket["contributors"] << {
      "source" => rec["source"], "timestamp" => rec["timestamp"]
    }
  end
  enrichments = fields.transform_values(&:values)
  return { "object" => obj } if view == "raw"
  { "object" => obj, "enrichments" => enrichments }
end

#lineage(key) ⇒ Object

The succession chain closure containing key (includes key).



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/causalontology/store.rb', line 115

def lineage(key)
  succ = {}
  pred = {}
  records_of("succession").each do |s|
    succ[s["predecessor"]] = s["successor"]
    pred[s["successor"]] = s["predecessor"]
  end
  chain = Set.new([key])
  cursor = key
  while pred.key?(cursor)
    cursor = pred[cursor]
    chain << cursor
  end
  cursor = key
  while succ.key?(cursor)
    cursor = succ[cursor]
    chain << cursor
  end
  chain
end

#put(obj, kind = nil) ⇒ Object

Write a content object; idempotent; returns the identifier.

Raises:



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/causalontology/store.rb', line 40

def put(obj, kind = nil)
  kind ||= Canonical.infer_kind(obj)
  unless CONTENT_KINDS.include?(kind)
    raise ArgumentError, "put() takes content objects; use put_record()"
  end
  obj = obj.dup
  obj["type"] = kind unless obj.key?("type")
  obj["id"] = Canonical.identify(obj, kind) unless obj.key?("id")
  return obj["id"] if @objects.key?(obj["id"]) # immutable: identical identity is a no-op
  ok, why = Schema.validate_schema(obj, kind)
  raise RejectedWrite, why.join("; ") unless ok
  ok, why = Semantics.validate_semantics(obj, kind)
  raise RejectedWrite, why.join("; ") unless ok
  @objects[obj["id"]] = obj
  obj["id"]
end

#put_record(record, kind = nil, force: false) ⇒ Object

Write a signed provenance record; returns the identifier.

Raises:



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/causalontology/store.rb', line 58

def put_record(record, kind = nil, force: false)
  kind ||= Canonical.infer_kind(record)
  unless RECORD_KINDS.include?(kind)
    raise ArgumentError, "put_record() takes provenance records"
  end
  record = record.dup
  record["type"] = kind unless record.key?("type")
  rid = record["id"]
  rid = Canonical.identify(record, kind) if rid.nil? || rid.empty?
  record["id"] = rid
  return rid if @records.key?(rid) # add-only and idempotent
  unless Signing.verify_record(record, kind)
    @quarantine[rid] = record
    raise RejectedWrite, "unsigned or unverifiable record: quarantined"
  end
  ok, why = Semantics.validate_semantics(record, kind)
  raise RejectedWrite, why.join("; ") unless ok
  if kind == "retraction" && !retraction_source_ok(record)
    raise RejectedWrite,
          "a retraction is valid only from the retracted record's " \
          "source or its succession lineage"
  end
  if kind == "enrichment" && @enforcing && !force
    if ["subsumes", "part_of"].include?(record["field"]) && would_cycle(record)
      raise RejectedWrite,
            "would create a cycle in the materialized " \
            "#{record["field"]} graph"
    end
  end
  @records[rid] = record
  rid
end

#records_of(kind) ⇒ Object

----------------------------------------------------- record queries



98
99
100
# File 'lib/causalontology/store.rb', line 98

def records_of(kind)
  @records.values.select { |r| r["type"] == kind }
end

#resolve(text, lang = nil) ⇒ Object

The conformance minimum: exact label, then alias, then nothing.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/causalontology/store.rb', line 259

def resolve(text, lang = nil)
  label_hits = []
  alias_hits = []
  wanted_label = self.class.canon_label(text)
  wanted_alias = self.class.norm_alias(text)
  retracted = retracted_ids
  @objects.each do |oid, obj|
    next unless ["occurrent", "continuant"].include?(obj["type"])
    if obj["label"] == wanted_label
      label_hits << oid
      next
    end
    records_of("enrichment").each do |rec|
      next unless rec["about"] == oid && rec["field"] == "aliases"
      next if retracted.include?(rec["id"])
      entry = rec["entry"]
      next if !lang.nil? && entry["lang"] != lang
      if self.class.norm_alias(entry["text"] || "") == wanted_alias
        alias_hits << oid
        break
      end
    end
  end
  label_hits + alias_hits
end

#retracted_idsObject



102
103
104
105
106
# File 'lib/causalontology/store.rb', line 102

def retracted_ids
  out = Set.new
  records_of("retraction").each { |r| out << r["retracts"] }
  out
end

#retraction_source_ok(retraction) ⇒ Object



108
109
110
111
112
# File 'lib/causalontology/store.rb', line 108

def retraction_source_ok(retraction)
  target = @records[retraction["retracts"]]
  return true if target.nil? # open world: the target may arrive later
  lineage(target["source"]).include?(retraction["source"])
end

#would_cycle(record) ⇒ Object



213
214
215
216
217
218
219
# File 'lib/causalontology/store.rb', line 213

def would_cycle(record)
  retracted = retracted_ids
  recs = records_of("enrichment").select do |r|
    r["field"] == record["field"] && !retracted.include?(r["id"])
  end
  !self.class.find_cycle_records(recs + [record]).empty?
end