causalontology 2.0.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.
- checksums.yaml +7 -0
- data/LICENSE +203 -0
- data/README.md +73 -0
- data/conformance.rb +1179 -0
- data/lib/causalontology/canonical.rb +100 -0
- data/lib/causalontology/ed25519.rb +158 -0
- data/lib/causalontology/jcs.rb +95 -0
- data/lib/causalontology/schema.rb +204 -0
- data/lib/causalontology/semantics.rb +455 -0
- data/lib/causalontology/signing.rb +74 -0
- data/lib/causalontology/store.rb +358 -0
- data/lib/causalontology.rb +143 -0
- metadata +63 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# The semantic rules beyond the schemas (spec/semantics.md).
|
|
4
|
+
#
|
|
5
|
+
# Local rules are checked here; store-context rules (materialized acyclicity,
|
|
6
|
+
# retraction lineage) live in store.rb where the context exists.
|
|
7
|
+
|
|
8
|
+
require "set"
|
|
9
|
+
require_relative "canonical"
|
|
10
|
+
|
|
11
|
+
module Causalontology
|
|
12
|
+
module Semantics
|
|
13
|
+
# Rule 4: the fixed unit-conversion constants (average Gregorian values).
|
|
14
|
+
UNIT_SECONDS = {
|
|
15
|
+
"instant" => 0,
|
|
16
|
+
"seconds" => 1,
|
|
17
|
+
"minutes" => 60,
|
|
18
|
+
"hours" => 3600,
|
|
19
|
+
"days" => 86400,
|
|
20
|
+
"weeks" => 604800,
|
|
21
|
+
"months" => 2629746,
|
|
22
|
+
"years" => 31556952,
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Rule 12: enrichment field-to-kind validity and entry shapes. Two
|
|
26
|
+
# occurrent forms added in 2.0.0.
|
|
27
|
+
ENRICHMENT_FIELDS = {
|
|
28
|
+
"aliases" => [["occurrent", "continuant"], "alias"],
|
|
29
|
+
"participants" => [["occurrent"], "continuant"],
|
|
30
|
+
"subsumes" => [["continuant"], "continuant"],
|
|
31
|
+
"part_of" => [["continuant"], "continuant"],
|
|
32
|
+
"realized_in" => [["realizable"], "occurrent"],
|
|
33
|
+
"occurrent_subsumes" => [["occurrent"], "occurrent"],
|
|
34
|
+
"occurrent_part_of" => [["occurrent"], "occurrent"],
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
CRO_OPTIONAL_FIELDS = ["mechanism", "temporal", "modality", "context"].freeze
|
|
38
|
+
|
|
39
|
+
# Rule 6 (amended): necessary, sufficient, contributory, enabling are
|
|
40
|
+
# mutually compatible; preventive opposes all four.
|
|
41
|
+
POSITIVE = Set.new(["necessary", "sufficient", "contributory",
|
|
42
|
+
"enabling"]).freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def kind_of_id(identifier)
|
|
47
|
+
Canonical::KIND_OF_PREFIX[identifier.split(":", 2)[0]]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [ok, reasons] - the locally checkable semantic rules.
|
|
51
|
+
def validate_semantics(obj, kind = nil)
|
|
52
|
+
kind ||= Canonical.infer_kind(obj)
|
|
53
|
+
errors = []
|
|
54
|
+
|
|
55
|
+
if kind == "causal_relation_object"
|
|
56
|
+
t = obj["temporal"]
|
|
57
|
+
if !t.nil? && !t["minimum_delay"].nil? && !t["maximum_delay"].nil? && t["minimum_delay"] > t["maximum_delay"]
|
|
58
|
+
errors << "minimum_delay must be <= maximum_delay"
|
|
59
|
+
end
|
|
60
|
+
oid = obj["id"]
|
|
61
|
+
if oid && (obj["mechanism"] || []).include?(oid)
|
|
62
|
+
errors << "mechanism must be acyclic " \
|
|
63
|
+
"(a Causal Relation Object may not contain itself)"
|
|
64
|
+
end
|
|
65
|
+
if oid && obj["refines"] == oid
|
|
66
|
+
errors << "refines must be acyclic"
|
|
67
|
+
end
|
|
68
|
+
# Rule 16, clause 1 (contradictory_skip): a HARD, locally-decidable
|
|
69
|
+
# contradiction between skips:true and a non-empty mechanism.
|
|
70
|
+
if obj["skips"] == true && obj["mechanism"] && !obj["mechanism"].empty?
|
|
71
|
+
errors << "contradictory_skip: skips is true but a mechanism " \
|
|
72
|
+
"is present"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if kind == "enrichment"
|
|
77
|
+
field = obj["field"]
|
|
78
|
+
about = obj["about"] || ""
|
|
79
|
+
entry = obj["entry"]
|
|
80
|
+
spec = ENRICHMENT_FIELDS[field]
|
|
81
|
+
if spec
|
|
82
|
+
legal_kinds, shape = spec
|
|
83
|
+
about_kind = kind_of_id(about)
|
|
84
|
+
if about_kind && !legal_kinds.include?(about_kind)
|
|
85
|
+
errors << "#{field} is not a legal field for a #{about_kind} (rule 12)"
|
|
86
|
+
end
|
|
87
|
+
if shape == "alias"
|
|
88
|
+
unless entry.is_a?(Hash) && entry.key?("lang") && entry.key?("text")
|
|
89
|
+
errors << "an aliases entry must be a language-tagged text object"
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
unless entry.is_a?(String) && entry.start_with?(shape + ":")
|
|
93
|
+
errors << "a #{field} entry must be a #{shape}: identifier"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
[errors.empty?, errors]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# [partial, missing] - which optional CRO fields are unspecified.
|
|
103
|
+
def is_partial(cro)
|
|
104
|
+
missing = CRO_OPTIONAL_FIELDS.reject { |f| cro.key?(f) }
|
|
105
|
+
[!missing.empty?, missing]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Rule 4: temporal admissibility with the fixed constants.
|
|
109
|
+
def admissible(cro, elapsed_seconds)
|
|
110
|
+
t = cro["temporal"]
|
|
111
|
+
return true if t.nil? # no window imposes no constraint
|
|
112
|
+
unit = UNIT_SECONDS.fetch(t["unit"])
|
|
113
|
+
lo = t["minimum_delay"] * unit
|
|
114
|
+
hi = t["maximum_delay"] * unit
|
|
115
|
+
lo <= elapsed_seconds && elapsed_seconds <= hi
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def window_overlap(a, b)
|
|
119
|
+
ta = a["temporal"]
|
|
120
|
+
tb = b["temporal"]
|
|
121
|
+
return true if ta.nil? || tb.nil? # either absent counts as overlapping
|
|
122
|
+
ua = UNIT_SECONDS.fetch(ta["unit"])
|
|
123
|
+
ub = UNIT_SECONDS.fetch(tb["unit"])
|
|
124
|
+
lo_a = ta["minimum_delay"] * ua
|
|
125
|
+
hi_a = ta["maximum_delay"] * ua
|
|
126
|
+
lo_b = tb["minimum_delay"] * ub
|
|
127
|
+
hi_b = tb["maximum_delay"] * ub
|
|
128
|
+
lo_a <= hi_b && lo_b <= hi_a
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def contexts_compatible(a, b)
|
|
132
|
+
ca = a["context"]
|
|
133
|
+
cb = b["context"]
|
|
134
|
+
return true if ca.nil? || ca.empty? || cb.nil? || cb.empty?
|
|
135
|
+
sa = Set.new(ca)
|
|
136
|
+
sb = Set.new(cb)
|
|
137
|
+
sa == sb || sa.subset?(sb) || sb.subset?(sa)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Rule 6: the formal conflict test.
|
|
141
|
+
def conflicts(a, b)
|
|
142
|
+
return false if Set.new(a["causes"]) != Set.new(b["causes"])
|
|
143
|
+
return false if Set.new(a["effects"]) != Set.new(b["effects"])
|
|
144
|
+
return false unless contexts_compatible(a, b)
|
|
145
|
+
return false unless window_overlap(a, b)
|
|
146
|
+
ma = a["modality"]
|
|
147
|
+
mb = b["modality"]
|
|
148
|
+
(ma == "preventive" && POSITIVE.include?(mb)) ||
|
|
149
|
+
(mb == "preventive" && POSITIVE.include?(ma))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Rule 3: [ok, reason] - is child a valid refinement of parent?
|
|
153
|
+
def refinement_valid(child, parent)
|
|
154
|
+
if child["refines"] != parent["id"]
|
|
155
|
+
return [false, "child does not name the parent in refines"]
|
|
156
|
+
end
|
|
157
|
+
if Set.new(child["causes"]) != Set.new(parent["causes"]) ||
|
|
158
|
+
Set.new(child["effects"]) != Set.new(parent["effects"])
|
|
159
|
+
return [false, "a refinement must keep the parent's causes and effects"]
|
|
160
|
+
end
|
|
161
|
+
added = 0
|
|
162
|
+
CRO_OPTIONAL_FIELDS.each do |field|
|
|
163
|
+
if parent.key?(field)
|
|
164
|
+
if child[field] != parent[field]
|
|
165
|
+
return [false, "a refinement may not change a field the " \
|
|
166
|
+
"parent specified; this is a rival claim"]
|
|
167
|
+
end
|
|
168
|
+
elsif child.key?(field)
|
|
169
|
+
added += 1
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
if added == 0
|
|
173
|
+
return [false, "a refinement must add at least one unspecified field"]
|
|
174
|
+
end
|
|
175
|
+
[true, "valid refinement"]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# =======================================================================
|
|
179
|
+
# 2.0.0 NORMATIVE ALGORITHMS (Section 12)
|
|
180
|
+
# =======================================================================
|
|
181
|
+
|
|
182
|
+
# ALGORITHM A. Every finer occurrent an occurrent resolves to, following
|
|
183
|
+
# Bridges downward, transitively. Includes the starting occurrent
|
|
184
|
+
# (N12.1.1). `bridges` is any iterable of bridge objects. The visited guard
|
|
185
|
+
# (N12.1.2) prevents an infinite loop on malformed cyclic data.
|
|
186
|
+
def bridge_closure(occurrent_id, bridges)
|
|
187
|
+
result = Set.new([occurrent_id])
|
|
188
|
+
frontier = [occurrent_id]
|
|
189
|
+
visited = Set.new
|
|
190
|
+
coarse_index = {}
|
|
191
|
+
bridges.each { |b| (coarse_index[b["coarse"]] ||= []) << b }
|
|
192
|
+
until frontier.empty?
|
|
193
|
+
current = frontier.pop
|
|
194
|
+
next if visited.include?(current)
|
|
195
|
+
visited << current
|
|
196
|
+
(coarse_index[current] || []).each do |b|
|
|
197
|
+
b["fine"].each do |f|
|
|
198
|
+
result << f
|
|
199
|
+
frontier << f
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
result
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def path_exists(edges, src, dst)
|
|
207
|
+
seen = Set.new
|
|
208
|
+
stack = [src]
|
|
209
|
+
until stack.empty?
|
|
210
|
+
node = stack.pop
|
|
211
|
+
return true if node == dst
|
|
212
|
+
next if seen.include?(node)
|
|
213
|
+
seen << node
|
|
214
|
+
stack.concat((edges[node] || []).to_a)
|
|
215
|
+
end
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# ALGORITHM B (amended Rule 7): "consistent" | "inconsistent" |
|
|
220
|
+
# "indeterminate", ACROSS STRATA via bridged reachability.
|
|
221
|
+
#
|
|
222
|
+
# members: mapping from CRO identifier to CRO object for the mechanism
|
|
223
|
+
# entries. bridges: the store's bridges (empty -> 1.0.0 literal
|
|
224
|
+
# reachability, the degenerate case, N12.2.3).
|
|
225
|
+
def hierarchy_consistent(parent, members, bridges = [])
|
|
226
|
+
mechanism = parent["mechanism"] || []
|
|
227
|
+
return "consistent" if mechanism.empty? # nothing claimed (N12.2.1)
|
|
228
|
+
edges = {}
|
|
229
|
+
mechanism.each do |mid|
|
|
230
|
+
m = members[mid]
|
|
231
|
+
return "indeterminate" if m.nil? # dangling; ignorance, not refutation
|
|
232
|
+
m["causes"].each do |c|
|
|
233
|
+
(edges[c] ||= Set.new).merge(m["effects"])
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
b_cause = {}
|
|
237
|
+
parent["causes"].each { |c| b_cause[c] = bridge_closure(c, bridges) }
|
|
238
|
+
b_effect = {}
|
|
239
|
+
parent["effects"].each { |e| b_effect[e] = bridge_closure(e, bridges) }
|
|
240
|
+
parent["causes"].each do |c|
|
|
241
|
+
parent["effects"].each do |e|
|
|
242
|
+
connected = b_cause[c].any? do |cp|
|
|
243
|
+
b_effect[e].any? { |ep| path_exists(edges, cp, ep) }
|
|
244
|
+
end
|
|
245
|
+
return "inconsistent" unless connected
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
"consistent"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ALGORITHM C (Rule 15): "intra_stratal" | "adjacent_stratal" |
|
|
252
|
+
# "skipping" | "mixed" | "unclassifiable" | "scheme_mismatch".
|
|
253
|
+
# Derived, never asserted; recompute on ingest (N12.3.1).
|
|
254
|
+
def classify_cro(cro, occ_map, stratum_map)
|
|
255
|
+
stratum_of = lambda { |occ_id| (occ_map[occ_id] || {})["stratum"] }
|
|
256
|
+
cause_strata = cro["causes"].map { |c| stratum_of.call(c) }
|
|
257
|
+
effect_strata = cro["effects"].map { |e| stratum_of.call(e) }
|
|
258
|
+
if (cause_strata + effect_strata).any?(&:nil?)
|
|
259
|
+
return "unclassifiable" # surface unstratified_occurrent (invitation)
|
|
260
|
+
end
|
|
261
|
+
all_strata = Set.new(cause_strata) | Set.new(effect_strata)
|
|
262
|
+
schemes = Set.new(all_strata.map { |s| stratum_map[s]["scheme"] })
|
|
263
|
+
return "scheme_mismatch" if schemes.length > 1 # HARD
|
|
264
|
+
c_ord = cause_strata.map { |s| stratum_map[s]["ordinal"] }
|
|
265
|
+
e_ord = effect_strata.map { |s| stratum_map[s]["ordinal"] }
|
|
266
|
+
if c_ord.max == c_ord.min && c_ord.min == e_ord.max && e_ord.max == e_ord.min
|
|
267
|
+
return "intra_stratal"
|
|
268
|
+
end
|
|
269
|
+
pairs = c_ord.product(e_ord).map { |i, j| (i - j).abs }
|
|
270
|
+
gap = pairs.min
|
|
271
|
+
span = pairs.max
|
|
272
|
+
return "adjacent_stratal" if span == 1
|
|
273
|
+
return "skipping" if gap > 1
|
|
274
|
+
"mixed" # some pairs adjacent, some skipping
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# True iff causes or effects span more than one distinct stratum
|
|
278
|
+
# (surfaces mixed_stratal_endpoints, an invitation; N12.3.2).
|
|
279
|
+
def endpoints_mixed(cro, occ_map)
|
|
280
|
+
stratum_of = lambda { |occ_id| (occ_map[occ_id] || {})["stratum"] }
|
|
281
|
+
cs = Set.new(cro["causes"].map { |c| stratum_of.call(c) })
|
|
282
|
+
es = Set.new(cro["effects"].map { |e| stratum_of.call(e) })
|
|
283
|
+
return false if cs.include?(nil) || es.include?(nil)
|
|
284
|
+
cs.length > 1 || es.length > 1
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ALGORITHM D (Rule 16): the gaps a Causal Relation Object surfaces for the
|
|
288
|
+
# skip decision. THE ASYMMETRY (clause 3) is the whole point of the field
|
|
289
|
+
# and is implemented exactly.
|
|
290
|
+
def skip_gaps(cro, classification)
|
|
291
|
+
gaps = []
|
|
292
|
+
has_mech = !(cro["mechanism"] || []).empty?
|
|
293
|
+
if cro["skips"] == true && has_mech
|
|
294
|
+
gaps << "contradictory_skip" # HARD
|
|
295
|
+
return gaps
|
|
296
|
+
end
|
|
297
|
+
if cro["skips"] == true &&
|
|
298
|
+
!["skipping", "unclassifiable"].include?(classification)
|
|
299
|
+
gaps << "vacuous_skip" # invitation
|
|
300
|
+
end
|
|
301
|
+
if classification == "skipping" && !has_mech
|
|
302
|
+
if cro["skips"] == true
|
|
303
|
+
# NOTHING: absence is a finding
|
|
304
|
+
else
|
|
305
|
+
gaps << "incomplete_mechanism" # invitation
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
gaps
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# ALGORITHM E helper: normalize a delay to seconds by the fixed table.
|
|
312
|
+
def to_seconds(duration, unit)
|
|
313
|
+
return 0 if unit == "instant"
|
|
314
|
+
duration * UNIT_SECONDS.fetch(unit)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# ALGORITHM E (Rule 20): does an observed delay fall within a covering
|
|
318
|
+
# law's temporal window? Inclusive at both ends (N12.5.2).
|
|
319
|
+
def delay_within_window(actual_delay, temporal)
|
|
320
|
+
return true if actual_delay.nil? || actual_delay.empty? ||
|
|
321
|
+
temporal.nil? || temporal.empty?
|
|
322
|
+
observed = to_seconds(actual_delay["duration"], actual_delay["unit"])
|
|
323
|
+
lo = to_seconds(temporal["minimum_delay"], temporal["unit"])
|
|
324
|
+
hi = to_seconds(temporal["maximum_delay"], temporal["unit"])
|
|
325
|
+
lo <= observed && observed <= hi
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Rule 14 / N3.2.1: Bridge well-formedness. [ok, reason]. All of (a)-(e)
|
|
329
|
+
# of N3.2.1 must hold, else malformed_bridge.
|
|
330
|
+
def bridge_wellformed(bridge, occ_map, stratum_map)
|
|
331
|
+
coarse = occ_map[bridge["coarse"]] || {}
|
|
332
|
+
cs = coarse["stratum"]
|
|
333
|
+
return [false, "malformed_bridge: coarse has no stratum (a)"] if cs.nil?
|
|
334
|
+
fine_strata = bridge["fine"].map { |f| (occ_map[f] || {})["stratum"] }
|
|
335
|
+
if fine_strata.any?(&:nil?)
|
|
336
|
+
return [false, "malformed_bridge: a fine member has no stratum (b)"]
|
|
337
|
+
end
|
|
338
|
+
if Set.new(fine_strata).length != 1
|
|
339
|
+
return [false, "malformed_bridge: fine members span >1 stratum (c)"]
|
|
340
|
+
end
|
|
341
|
+
fs = fine_strata[0]
|
|
342
|
+
if stratum_map[cs]["scheme"] != stratum_map[fs]["scheme"]
|
|
343
|
+
return [false, "malformed_bridge: coarse and fine differ in scheme (d)"]
|
|
344
|
+
end
|
|
345
|
+
unless stratum_map[cs]["ordinal"] > stratum_map[fs]["ordinal"]
|
|
346
|
+
return [false, "malformed_bridge: coarse ordinal not > fine ordinal (e)"]
|
|
347
|
+
end
|
|
348
|
+
[true, "well-formed bridge"]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Rule 17 / N4.2.1-2: Conduit well-formedness. [ok, reason]. N4.2.1 with
|
|
352
|
+
# the transform exception of N4.2.2.
|
|
353
|
+
def conduit_wellformed(conduit, port_map, cro_map = nil)
|
|
354
|
+
frm = port_map[conduit["from"]]
|
|
355
|
+
to = port_map[conduit["to"]]
|
|
356
|
+
if frm.nil? || to.nil?
|
|
357
|
+
return [false, "malformed_conduit: dangling port reference"]
|
|
358
|
+
end
|
|
359
|
+
unless ["out", "bidirectional"].include?(frm["direction"])
|
|
360
|
+
return [false, "malformed_conduit: from port is not out/bidirectional (a)"]
|
|
361
|
+
end
|
|
362
|
+
unless ["in", "bidirectional"].include?(to["direction"])
|
|
363
|
+
return [false, "malformed_conduit: to port is not in/bidirectional (b)"]
|
|
364
|
+
end
|
|
365
|
+
carries = conduit["carries"]
|
|
366
|
+
unless carries.all? { |o| frm["accepts"].include?(o) }
|
|
367
|
+
return [false, "malformed_conduit: carries not accepted by from (c)"]
|
|
368
|
+
end
|
|
369
|
+
transform = conduit["transform"]
|
|
370
|
+
if transform.nil?
|
|
371
|
+
unless carries.all? { |o| to["accepts"].include?(o) }
|
|
372
|
+
return [false, "malformed_conduit: carries not accepted by to (d)"]
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
law = (cro_map || {})[transform]
|
|
376
|
+
unless law.nil?
|
|
377
|
+
unless law["effects"].all? { |o| to["accepts"].include?(o) }
|
|
378
|
+
return [false, "malformed_conduit: transform effects not " \
|
|
379
|
+
"accepted by to (d, relaxed per N4.2.2)"]
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
[true, "well-formed conduit"]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Rule 19 / N5.3.1-2: the HARD gaps a state assertion surfaces against its
|
|
387
|
+
# quality: value_type_mismatch and/or unit_mismatch.
|
|
388
|
+
def state_gaps(state, quality)
|
|
389
|
+
gaps = []
|
|
390
|
+
dt = quality["datatype"]
|
|
391
|
+
v = state["value"] || {}
|
|
392
|
+
shape = if v.key?("quantity") then "quantity"
|
|
393
|
+
elsif v.key?("categorical") then "categorical"
|
|
394
|
+
elsif v.key?("boolean") then "boolean"
|
|
395
|
+
end
|
|
396
|
+
if shape != dt
|
|
397
|
+
gaps << "value_type_mismatch"
|
|
398
|
+
elsif dt == "quantity" && v["unit"] != quality["unit"]
|
|
399
|
+
gaps << "unit_mismatch"
|
|
400
|
+
end
|
|
401
|
+
gaps
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Rule 20: true iff the token claim's cause/effect tokens do not
|
|
405
|
+
# instantiate the covering law's causes/effects (surfaces
|
|
406
|
+
# covering_law_mismatch).
|
|
407
|
+
def covering_law_mismatch(tcc, token_map, law)
|
|
408
|
+
return false if law.nil?
|
|
409
|
+
law_causes = Set.new(law["causes"])
|
|
410
|
+
law_effects = Set.new(law["effects"])
|
|
411
|
+
tcc["causes"].each do |c|
|
|
412
|
+
return true unless law_causes.include?(token_map[c]["instantiates"])
|
|
413
|
+
end
|
|
414
|
+
tcc["effects"].each do |e|
|
|
415
|
+
return true unless law_effects.include?(token_map[e]["instantiates"])
|
|
416
|
+
end
|
|
417
|
+
false
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Rule 21: true iff any cause token starts after any effect token (HARD;
|
|
421
|
+
# retrocausal_claim). RFC 3339 UTC "Z" strings compare lexicographically.
|
|
422
|
+
def retrocausal(tcc, token_map)
|
|
423
|
+
tcc["causes"].each do |c|
|
|
424
|
+
cstart = token_map[c]["interval"]["start"]
|
|
425
|
+
tcc["effects"].each do |e|
|
|
426
|
+
estart = token_map[e]["interval"]["start"]
|
|
427
|
+
return true if cstart > estart
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
false
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Rules 4 / 6.1: true iff a directed graph (node -> iterable of successors)
|
|
434
|
+
# has a cycle. Used for bridge graph, occurrent_subsumes, occurrent_part_of,
|
|
435
|
+
# and token mereology (part_of).
|
|
436
|
+
def has_cycle(edges)
|
|
437
|
+
white = 0
|
|
438
|
+
grey = 1
|
|
439
|
+
black = 2
|
|
440
|
+
state = {}
|
|
441
|
+
visit = nil
|
|
442
|
+
visit = lambda do |node|
|
|
443
|
+
state[node] = grey
|
|
444
|
+
(edges[node] || []).each do |nxt|
|
|
445
|
+
s = state.fetch(nxt, white)
|
|
446
|
+
return true if s == grey
|
|
447
|
+
return true if s == white && visit.call(nxt)
|
|
448
|
+
end
|
|
449
|
+
state[node] = black
|
|
450
|
+
false
|
|
451
|
+
end
|
|
452
|
+
edges.keys.any? { |n| state.fetch(n, white) == white && visit.call(n) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# Record-level signing and verification (spec/provenance.md).
|
|
4
|
+
#
|
|
5
|
+
# The signature is computed over the record's canonical identity-bearing bytes
|
|
6
|
+
# (the RFC 8785 form with id and signature removed - exactly the bytes that are
|
|
7
|
+
# hashed for the record's identifier), so verification needs nothing but the
|
|
8
|
+
# record itself. Ed25519 is deterministic (RFC 8032): re-signing the same record
|
|
9
|
+
# with the same key yields the same signature, so re-submission is idempotent.
|
|
10
|
+
|
|
11
|
+
require_relative "ed25519"
|
|
12
|
+
require_relative "canonical"
|
|
13
|
+
|
|
14
|
+
module Causalontology
|
|
15
|
+
module Signing
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Hex encoding of a binary string.
|
|
19
|
+
def bin_to_hex(s)
|
|
20
|
+
s.unpack1("H*")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Binary decoding of a hex string, or nil when the text is not clean hex.
|
|
24
|
+
def hex_to_bin(hex)
|
|
25
|
+
return nil unless hex.is_a?(String)
|
|
26
|
+
return nil unless hex.length.even? && hex.match?(/\A\h*\z/)
|
|
27
|
+
[hex].pack("H*")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# [secret, "ed25519:<hex>"] from a 32-byte seed.
|
|
31
|
+
def keypair_from_seed(seed32)
|
|
32
|
+
public_key = Ed25519.secret_to_public(seed32)
|
|
33
|
+
[seed32, "ed25519:" + bin_to_hex(public_key)]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return the record completed with its id and Ed25519 signature.
|
|
37
|
+
def sign_record(record, secret, kind = nil)
|
|
38
|
+
kind ||= Canonical.infer_kind(record)
|
|
39
|
+
body = record.dup
|
|
40
|
+
body.delete("signature")
|
|
41
|
+
message = Canonical.canonicalize(body, kind)
|
|
42
|
+
signature = bin_to_hex(Ed25519.sign(secret, message))
|
|
43
|
+
out = body.dup
|
|
44
|
+
out["id"] = Canonical.identify(body, kind)
|
|
45
|
+
out["signature"] = signature
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The hex of the key the record must verify against: a succession is
|
|
50
|
+
# signed by the predecessor key; every other record by its source.
|
|
51
|
+
def signer_key_hex(record, kind)
|
|
52
|
+
field = kind == "succession" ? "predecessor" : "source"
|
|
53
|
+
value = record[field] || ""
|
|
54
|
+
return nil unless value.is_a?(String) && value.start_with?("ed25519:")
|
|
55
|
+
value.split(":", 2)[1]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True iff the record's signature verifies against its own key field.
|
|
59
|
+
def verify_record(record, kind = nil)
|
|
60
|
+
kind ||= Canonical.infer_kind(record)
|
|
61
|
+
sig_hex = record["signature"]
|
|
62
|
+
key_hex = signer_key_hex(record, kind)
|
|
63
|
+
return false if sig_hex.nil? || sig_hex.empty?
|
|
64
|
+
return false if key_hex.nil? || key_hex.empty?
|
|
65
|
+
public_key = hex_to_bin(key_hex)
|
|
66
|
+
signature = hex_to_bin(sig_hex)
|
|
67
|
+
return false if public_key.nil? || signature.nil?
|
|
68
|
+
body = record.dup
|
|
69
|
+
body.delete("signature")
|
|
70
|
+
message = Canonical.canonicalize(body, kind)
|
|
71
|
+
Ed25519.verify(public_key, message, signature)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|