causalontology 1.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 +453 -0
- data/lib/causalontology/canonical.rb +85 -0
- data/lib/causalontology/ed25519.rb +158 -0
- data/lib/causalontology/jcs.rb +95 -0
- data/lib/causalontology/schema.rb +169 -0
- data/lib/causalontology/semantics.rb +204 -0
- data/lib/causalontology/signing.rb +74 -0
- data/lib/causalontology/store.rb +355 -0
- data/lib/causalontology.rb +93 -0
- metadata +62 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# RFC 8785 (JSON Canonicalization Scheme) serialization.
|
|
4
|
+
#
|
|
5
|
+
# Sorted keys (code-point order), minimal string escaping, ECMAScript-style
|
|
6
|
+
# canonical numbers (1.0 -> "1", 0.7 stays "0.7", exponent "1e-7" not
|
|
7
|
+
# "1e-07"). The number serialization implements the RFC 8785 rules for the
|
|
8
|
+
# value ranges Causalontology uses (integers, integer-valued floats, and
|
|
9
|
+
# short decimals); full ECMAScript exponent formatting for extreme
|
|
10
|
+
# magnitudes is pinned at the 1.0.0 conformance freeze.
|
|
11
|
+
#
|
|
12
|
+
# Ruby specifics relied on here: JSON.parse keeps the integer-versus-decimal
|
|
13
|
+
# source distinction ("1" -> Integer, "1.0" -> Float), and Float#to_s prints
|
|
14
|
+
# the shortest round-trip decimal with the same decimal/exponent thresholds
|
|
15
|
+
# as Python's repr (1e16 and 1e-4), so only the exponent spelling needs
|
|
16
|
+
# normalizing ("1.0e-07" -> "1e-7").
|
|
17
|
+
|
|
18
|
+
module Causalontology
|
|
19
|
+
module Jcs
|
|
20
|
+
# The two-character escapes of RFC 8785 section 3.2.2.2.
|
|
21
|
+
ESCAPES = {
|
|
22
|
+
'"' => '\"',
|
|
23
|
+
"\\" => "\\\\",
|
|
24
|
+
"\b" => "\\b",
|
|
25
|
+
"\t" => "\\t",
|
|
26
|
+
"\n" => "\\n",
|
|
27
|
+
"\f" => "\\f",
|
|
28
|
+
"\r" => "\\r",
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# A JSON string literal with minimal escaping: the seven two-character
|
|
34
|
+
# escapes, \u00xx for remaining control characters, everything else
|
|
35
|
+
# (including multibyte text) passed through as UTF-8.
|
|
36
|
+
def string(s)
|
|
37
|
+
parts = +'"'
|
|
38
|
+
s.each_char do |ch|
|
|
39
|
+
if ESCAPES.key?(ch)
|
|
40
|
+
parts << ESCAPES[ch]
|
|
41
|
+
elsif ch.ord < 0x20
|
|
42
|
+
parts << format("\\u%04x", ch.ord)
|
|
43
|
+
else
|
|
44
|
+
parts << ch
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
parts << '"'
|
|
48
|
+
parts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A canonical JSON number: integers verbatim; integer-valued floats
|
|
52
|
+
# below 1e21 printed as integers; other floats in shortest round-trip
|
|
53
|
+
# form with the exponent normalized to ES6 style ("1e-7", "1e+21").
|
|
54
|
+
def number(n)
|
|
55
|
+
return n.to_s if n.is_a?(Integer)
|
|
56
|
+
raise ArgumentError, "NaN and Infinity are not permitted (RFC 8785)" unless n.finite?
|
|
57
|
+
return "0" if n == 0
|
|
58
|
+
return n.to_i.to_s if n == n.truncate && n.abs < 1e21
|
|
59
|
+
r = n.to_s # shortest round-trip decimal
|
|
60
|
+
if r.include?("e")
|
|
61
|
+
mant, exp = r.split("e")
|
|
62
|
+
mant = mant.sub(/\.0\z/, "") # Ruby prints "1.0e-07"; ES6 prints "1e-7"
|
|
63
|
+
sign = exp.start_with?("-") ? "-" : "+"
|
|
64
|
+
digits = exp.sub(/\A[+-]/, "").sub(/\A0+/, "")
|
|
65
|
+
digits = "0" if digits.empty?
|
|
66
|
+
r = mant + "e" + sign + digits
|
|
67
|
+
end
|
|
68
|
+
r
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The canonical serialization of a parsed JSON value (nil, true/false,
|
|
72
|
+
# Integer, Float, String, Array, or Hash with String keys).
|
|
73
|
+
def encode(value)
|
|
74
|
+
case value
|
|
75
|
+
when nil
|
|
76
|
+
"null"
|
|
77
|
+
when true
|
|
78
|
+
"true"
|
|
79
|
+
when false
|
|
80
|
+
"false"
|
|
81
|
+
when Integer, Float
|
|
82
|
+
number(value)
|
|
83
|
+
when String
|
|
84
|
+
string(value)
|
|
85
|
+
when Array
|
|
86
|
+
"[" + value.map { |v| encode(v) }.join(",") + "]"
|
|
87
|
+
when Hash
|
|
88
|
+
items = value.keys.sort_by(&:codepoints)
|
|
89
|
+
"{" + items.map { |k| string(k) + ":" + encode(value[k]) }.join(",") + "}"
|
|
90
|
+
else
|
|
91
|
+
raise TypeError, "cannot canonicalize #{value.class}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
# Schema validation against spec/schema/*.schema.json.
|
|
4
|
+
#
|
|
5
|
+
# A deliberately small interpreter for exactly the JSON Schema keywords the
|
|
6
|
+
# eight Causalontology schemas use: type, const, enum, pattern, required,
|
|
7
|
+
# properties, additionalProperties, items, minItems, minLength, minimum,
|
|
8
|
+
# maximum, oneOf, and local $ref (#/$defs/...). "format" is treated as an
|
|
9
|
+
# annotation, as the 2020-12 draft does by default.
|
|
10
|
+
|
|
11
|
+
require "json"
|
|
12
|
+
require_relative "canonical"
|
|
13
|
+
|
|
14
|
+
module Causalontology
|
|
15
|
+
module Schema
|
|
16
|
+
SCHEMA_FILES = {
|
|
17
|
+
"cro" => "cro.schema.json",
|
|
18
|
+
"occurrent" => "occurrent.schema.json",
|
|
19
|
+
"continuant" => "continuant.schema.json",
|
|
20
|
+
"realizable" => "realizable.schema.json",
|
|
21
|
+
"assertion" => "assertion.schema.json",
|
|
22
|
+
"enrichment" => "enrichment.schema.json",
|
|
23
|
+
"retraction" => "retraction.schema.json",
|
|
24
|
+
"succession" => "succession.schema.json",
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
@cache = {}
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def schema_dir
|
|
31
|
+
env = ENV["CAUSALONTOLOGY_SPEC"]
|
|
32
|
+
return File.join(env, "schema") if env && !env.empty?
|
|
33
|
+
# lib/causalontology -> lib -> ruby -> bindings -> repository root
|
|
34
|
+
File.expand_path("../../../../spec/schema", __dir__)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_schema(kind)
|
|
38
|
+
unless SCHEMA_FILES.key?(kind)
|
|
39
|
+
raise ArgumentError, "unknown kind: #{kind.inspect}"
|
|
40
|
+
end
|
|
41
|
+
@cache[kind] ||= JSON.parse(
|
|
42
|
+
File.read(File.join(schema_dir, SCHEMA_FILES[kind])))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Follow local $ref chains (#/$defs/...) to the referenced subschema.
|
|
46
|
+
def resolve_ref(schema, root)
|
|
47
|
+
while schema.key?("$ref")
|
|
48
|
+
ref = schema["$ref"]
|
|
49
|
+
unless ref.start_with?("#/")
|
|
50
|
+
raise ArgumentError, "only local $ref supported: #{ref.inspect}"
|
|
51
|
+
end
|
|
52
|
+
node = root
|
|
53
|
+
ref[2..].split("/").each { |part| node = node[part] }
|
|
54
|
+
schema = node
|
|
55
|
+
end
|
|
56
|
+
schema
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def type_matches?(value, t)
|
|
60
|
+
case t
|
|
61
|
+
when "object" then value.is_a?(Hash)
|
|
62
|
+
when "array" then value.is_a?(Array)
|
|
63
|
+
when "string" then value.is_a?(String)
|
|
64
|
+
when "number" then value.is_a?(Integer) || value.is_a?(Float)
|
|
65
|
+
when "boolean" then value == true || value == false
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "unknown schema type: #{t.inspect}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def numeric?(value)
|
|
72
|
+
value.is_a?(Integer) || value.is_a?(Float)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def check(value, schema, root, path, errors)
|
|
76
|
+
schema = resolve_ref(schema, root)
|
|
77
|
+
|
|
78
|
+
if schema.key?("oneOf")
|
|
79
|
+
passing = 0
|
|
80
|
+
schema["oneOf"].each do |sub|
|
|
81
|
+
suberrs = []
|
|
82
|
+
check(value, sub, root, path, suberrs)
|
|
83
|
+
passing += 1 if suberrs.empty?
|
|
84
|
+
end
|
|
85
|
+
if passing != 1
|
|
86
|
+
errors << "#{path}: matches #{passing} of the oneOf branches " \
|
|
87
|
+
"(need exactly 1)"
|
|
88
|
+
end
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
t = schema["type"]
|
|
93
|
+
if !t.nil? && !type_matches?(value, t)
|
|
94
|
+
errors << "#{path}: expected #{t}"
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if schema.key?("const") && value != schema["const"]
|
|
99
|
+
errors << "#{path}: must equal #{schema["const"].inspect}"
|
|
100
|
+
end
|
|
101
|
+
if schema.key?("enum") && !schema["enum"].include?(value)
|
|
102
|
+
errors << "#{path}: #{value.inspect} not in enumeration"
|
|
103
|
+
end
|
|
104
|
+
if schema.key?("pattern") && value.is_a?(String)
|
|
105
|
+
unless Regexp.new(schema["pattern"]).match?(value)
|
|
106
|
+
errors << "#{path}: #{value.inspect} does not match " \
|
|
107
|
+
"#{schema["pattern"]}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
if schema.key?("minLength") && value.is_a?(String)
|
|
111
|
+
if value.length < schema["minLength"]
|
|
112
|
+
errors << "#{path}: shorter than minLength"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
if schema.key?("minimum") && numeric?(value)
|
|
116
|
+
if value < schema["minimum"]
|
|
117
|
+
errors << "#{path}: below minimum #{schema["minimum"]}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
if schema.key?("maximum") && numeric?(value)
|
|
121
|
+
if value > schema["maximum"]
|
|
122
|
+
errors << "#{path}: above maximum #{schema["maximum"]}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if value.is_a?(Array)
|
|
127
|
+
if schema.key?("minItems") && value.length < schema["minItems"]
|
|
128
|
+
errors << "#{path}: fewer than #{schema["minItems"]} items"
|
|
129
|
+
end
|
|
130
|
+
if schema.key?("items")
|
|
131
|
+
value.each_with_index do |item, i|
|
|
132
|
+
check(item, schema["items"], root, "#{path}[#{i}]", errors)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if value.is_a?(Hash)
|
|
138
|
+
props = schema["properties"] || {}
|
|
139
|
+
(schema["required"] || []).each do |req|
|
|
140
|
+
unless value.key?(req)
|
|
141
|
+
errors << "#{path}: required property '#{req}' missing"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
if schema["additionalProperties"] == false
|
|
145
|
+
value.each_key do |key|
|
|
146
|
+
unless props.key?(key)
|
|
147
|
+
errors << "#{path}: additional property '#{key}'"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
props.each do |key, sub|
|
|
152
|
+
if value.key?(key)
|
|
153
|
+
check(value[key], sub, root, "#{path}.#{key}", errors)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# [ok, reasons] - structural validity against the kind's JSON Schema.
|
|
160
|
+
def validate_schema(obj, kind = nil)
|
|
161
|
+
kind ||= Canonical.infer_kind(obj)
|
|
162
|
+
root = load_schema(kind)
|
|
163
|
+
errors = []
|
|
164
|
+
check(obj, root, root, "$", errors)
|
|
165
|
+
[errors.empty?, errors]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
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.
|
|
26
|
+
ENRICHMENT_FIELDS = {
|
|
27
|
+
"aliases" => [["occurrent", "continuant"], "alias"],
|
|
28
|
+
"participants" => [["occurrent"], "cnt"],
|
|
29
|
+
"subsumes" => [["continuant"], "cnt"],
|
|
30
|
+
"part_of" => [["continuant"], "cnt"],
|
|
31
|
+
"realized_in" => [["realizable"], "occ"],
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
CRO_OPTIONAL_FIELDS = ["mechanism", "temporal", "modality", "context"].freeze
|
|
35
|
+
|
|
36
|
+
# The positive modalities of the formal conflict test (rule 6).
|
|
37
|
+
POSITIVE = Set.new(["necessary", "sufficient", "contributory"]).freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def kind_of_id(identifier)
|
|
42
|
+
Canonical::KIND_OF_PREFIX[identifier.split(":", 2)[0]]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# [ok, reasons] - the locally checkable semantic rules.
|
|
46
|
+
def validate_semantics(obj, kind = nil)
|
|
47
|
+
kind ||= Canonical.infer_kind(obj)
|
|
48
|
+
errors = []
|
|
49
|
+
|
|
50
|
+
if kind == "cro"
|
|
51
|
+
t = obj["temporal"]
|
|
52
|
+
if !t.nil? && !t["dmin"].nil? && !t["dmax"].nil? && t["dmin"] > t["dmax"]
|
|
53
|
+
errors << "dmin must be <= dmax"
|
|
54
|
+
end
|
|
55
|
+
oid = obj["id"]
|
|
56
|
+
if oid && (obj["mechanism"] || []).include?(oid)
|
|
57
|
+
errors << "mechanism must be acyclic " \
|
|
58
|
+
"(a Causal Relation Object may not contain itself)"
|
|
59
|
+
end
|
|
60
|
+
if oid && obj["refines"] == oid
|
|
61
|
+
errors << "refines must be acyclic"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if kind == "enrichment"
|
|
66
|
+
field = obj["field"]
|
|
67
|
+
about = obj["about"] || ""
|
|
68
|
+
entry = obj["entry"]
|
|
69
|
+
spec = ENRICHMENT_FIELDS[field]
|
|
70
|
+
if spec
|
|
71
|
+
legal_kinds, shape = spec
|
|
72
|
+
about_kind = kind_of_id(about)
|
|
73
|
+
if about_kind && !legal_kinds.include?(about_kind)
|
|
74
|
+
errors << "#{field} is not a legal field for a #{about_kind} (rule 12)"
|
|
75
|
+
end
|
|
76
|
+
if shape == "alias"
|
|
77
|
+
unless entry.is_a?(Hash) && entry.key?("lang") && entry.key?("text")
|
|
78
|
+
errors << "an aliases entry must be a language-tagged text object"
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
unless entry.is_a?(String) && entry.start_with?(shape + ":")
|
|
82
|
+
errors << "a #{field} entry must be a #{shape}: identifier"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
[errors.empty?, errors]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# [partial, missing] - which optional CRO fields are unspecified.
|
|
92
|
+
def is_partial(cro)
|
|
93
|
+
missing = CRO_OPTIONAL_FIELDS.reject { |f| cro.key?(f) }
|
|
94
|
+
[!missing.empty?, missing]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Rule 4: temporal admissibility with the fixed constants.
|
|
98
|
+
def admissible(cro, elapsed_seconds)
|
|
99
|
+
t = cro["temporal"]
|
|
100
|
+
return true if t.nil? # no window imposes no constraint
|
|
101
|
+
unit = UNIT_SECONDS.fetch(t["unit"])
|
|
102
|
+
lo = t["dmin"] * unit
|
|
103
|
+
hi = t["dmax"] * unit
|
|
104
|
+
lo <= elapsed_seconds && elapsed_seconds <= hi
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def window_overlap(a, b)
|
|
108
|
+
ta = a["temporal"]
|
|
109
|
+
tb = b["temporal"]
|
|
110
|
+
return true if ta.nil? || tb.nil? # either absent counts as overlapping
|
|
111
|
+
ua = UNIT_SECONDS.fetch(ta["unit"])
|
|
112
|
+
ub = UNIT_SECONDS.fetch(tb["unit"])
|
|
113
|
+
lo_a = ta["dmin"] * ua
|
|
114
|
+
hi_a = ta["dmax"] * ua
|
|
115
|
+
lo_b = tb["dmin"] * ub
|
|
116
|
+
hi_b = tb["dmax"] * ub
|
|
117
|
+
lo_a <= hi_b && lo_b <= hi_a
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def contexts_compatible(a, b)
|
|
121
|
+
ca = a["context"]
|
|
122
|
+
cb = b["context"]
|
|
123
|
+
return true if ca.nil? || ca.empty? || cb.nil? || cb.empty?
|
|
124
|
+
sa = Set.new(ca)
|
|
125
|
+
sb = Set.new(cb)
|
|
126
|
+
sa == sb || sa.subset?(sb) || sb.subset?(sa)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Rule 6: the formal conflict test.
|
|
130
|
+
def conflicts(a, b)
|
|
131
|
+
return false if Set.new(a["causes"]) != Set.new(b["causes"])
|
|
132
|
+
return false if Set.new(a["effects"]) != Set.new(b["effects"])
|
|
133
|
+
return false unless contexts_compatible(a, b)
|
|
134
|
+
return false unless window_overlap(a, b)
|
|
135
|
+
ma = a["modality"]
|
|
136
|
+
mb = b["modality"]
|
|
137
|
+
(ma == "preventive" && POSITIVE.include?(mb)) ||
|
|
138
|
+
(mb == "preventive" && POSITIVE.include?(ma))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Rule 3: [ok, reason] - is child a valid refinement of parent?
|
|
142
|
+
def refinement_valid(child, parent)
|
|
143
|
+
if child["refines"] != parent["id"]
|
|
144
|
+
return [false, "child does not name the parent in refines"]
|
|
145
|
+
end
|
|
146
|
+
if Set.new(child["causes"]) != Set.new(parent["causes"]) ||
|
|
147
|
+
Set.new(child["effects"]) != Set.new(parent["effects"])
|
|
148
|
+
return [false, "a refinement must keep the parent's causes and effects"]
|
|
149
|
+
end
|
|
150
|
+
added = 0
|
|
151
|
+
CRO_OPTIONAL_FIELDS.each do |field|
|
|
152
|
+
if parent.key?(field)
|
|
153
|
+
if child[field] != parent[field]
|
|
154
|
+
return [false, "a refinement may not change a field the " \
|
|
155
|
+
"parent specified; this is a rival claim"]
|
|
156
|
+
end
|
|
157
|
+
elsif child.key?(field)
|
|
158
|
+
added += 1
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
if added == 0
|
|
162
|
+
return [false, "a refinement must add at least one unspecified field"]
|
|
163
|
+
end
|
|
164
|
+
[true, "valid refinement"]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Rule 7: "consistent" | "inconsistent" | "indeterminate".
|
|
168
|
+
#
|
|
169
|
+
# members: a mapping from CRO identifier to CRO object for the parent's
|
|
170
|
+
# mechanism entries (the store's view of them).
|
|
171
|
+
def hierarchy_consistent(parent, members)
|
|
172
|
+
mechanism = parent["mechanism"] || []
|
|
173
|
+
return "consistent" if mechanism.empty? # nothing claimed, nothing to check
|
|
174
|
+
edges = {}
|
|
175
|
+
mechanism.each do |mid|
|
|
176
|
+
m = members[mid]
|
|
177
|
+
return "indeterminate" if m.nil? # a dangling_reference gap, not a failure
|
|
178
|
+
m["causes"].each do |c|
|
|
179
|
+
(edges[c] ||= Set.new).merge(m["effects"])
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
reachable = lambda do |src, dst|
|
|
184
|
+
seen = Set.new
|
|
185
|
+
stack = [src]
|
|
186
|
+
until stack.empty?
|
|
187
|
+
node = stack.pop
|
|
188
|
+
return true if node == dst
|
|
189
|
+
next if seen.include?(node)
|
|
190
|
+
seen << node
|
|
191
|
+
stack.concat((edges[node] || []).to_a)
|
|
192
|
+
end
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
parent["causes"].each do |c|
|
|
197
|
+
parent["effects"].each do |e|
|
|
198
|
+
return "inconsistent" unless reachable.call(c, e)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
"consistent"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
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
|