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.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Canonicalization and content-addressed identity.
4
+ #
5
+ # Implements the identity procedure of spec/identity.md:
6
+ # 1. take the object as JSON,
7
+ # 2. keep only the identity-bearing fields for its kind (with "type" injected),
8
+ # 3. serialize with the JSON Canonicalization Scheme (RFC 8785),
9
+ # 4. hash with SHA-256,
10
+ # 5. identifier = scheme + ":" + lowercase hex digest.
11
+
12
+ require "digest"
13
+ require_relative "jcs"
14
+
15
+ module Causalontology
16
+ module Canonical
17
+ # The identity-bearing fields of each of the seventeen kinds. "type" is
18
+ # always injected, so it is not listed here. Order does not matter (JCS
19
+ # sorts keys).
20
+ IDENTITY_FIELDS = {
21
+ # ---- type tier ----
22
+ "occurrent" => ["label", "category", "stratum"],
23
+ "causal_relation_object" => ["causes", "effects", "mechanism", "temporal",
24
+ "modality", "context", "refines", "skips"],
25
+ "continuant" => ["label", "category"],
26
+ "realizable" => ["kind", "bearer", "label"],
27
+ "stratum" => ["label", "scheme", "ordinal", "unit", "governs"],
28
+ "bridge" => ["coarse", "fine", "relation"],
29
+ "port" => ["bearer", "label", "direction", "accepts", "realizable"],
30
+ "conduit" => ["label", "from", "to", "carries", "transform"],
31
+ "quality" => ["label", "datatype", "unit", "stratum"],
32
+ # ---- token tier ----
33
+ "token_individual" => ["instantiates", "designator", "part_of"],
34
+ "token_occurrence" => ["instantiates", "interval", "participants",
35
+ "locus", "observer"],
36
+ "state_assertion" => ["subject", "quality", "value", "interval"],
37
+ "token_causal_claim" => ["causes", "effects", "covering_law",
38
+ "actual_delay", "counterfactual"],
39
+ # ---- provenance tier ----
40
+ "assertion" => ["about", "source", "evidence_type", "evidence", "strength",
41
+ "confidence", "timestamp", "evidenced_by"],
42
+ "enrichment" => ["about", "field", "entry", "source", "timestamp"],
43
+ "retraction" => ["retracts", "source", "timestamp"],
44
+ "succession" => ["predecessor", "successor", "timestamp"],
45
+ }.freeze
46
+
47
+ # Whole-word re-mint (P7): the scheme IS the type value for every kind.
48
+ PREFIX = IDENTITY_FIELDS.keys.each_with_object({}) { |k, h| h[k] = k }.freeze
49
+ KIND_OF_PREFIX = PREFIX.invert.freeze
50
+
51
+ module_function
52
+
53
+ # Infer an object's kind from its type field, id prefix, or shape.
54
+ def infer_kind(obj)
55
+ return obj["type"] if obj.key?("type")
56
+ if obj.key?("id") && obj["id"].is_a?(String) && obj["id"].include?(":")
57
+ pre = obj["id"].split(":", 2)[0]
58
+ return KIND_OF_PREFIX[pre] if KIND_OF_PREFIX.key?(pre)
59
+ end
60
+ return "bridge" if obj.key?("coarse") && obj.key?("fine")
61
+ return "causal_relation_object" if obj.key?("causes") && obj.key?("effects")
62
+ return "retraction" if obj.key?("retracts")
63
+ return "succession" if obj.key?("predecessor") && obj.key?("successor")
64
+ return "enrichment" if obj.key?("field") && obj.key?("entry")
65
+ return "assertion" if obj.key?("evidence_type") ||
66
+ (obj.key?("about") && obj.key?("confidence"))
67
+ return "realizable" if obj.key?("kind") && obj.key?("bearer")
68
+ raise ArgumentError,
69
+ "cannot infer kind (occurrents and continuants share a shape); " \
70
+ "pass kind explicitly"
71
+ end
72
+
73
+ # The identity-bearing subset of an object, with type always present.
74
+ # Returns [kind, subset].
75
+ def identity_bearing(obj, kind = nil)
76
+ kind ||= infer_kind(obj)
77
+ unless IDENTITY_FIELDS.key?(kind)
78
+ raise ArgumentError, "unknown kind: #{kind.inspect}"
79
+ end
80
+ out = { "type" => kind }
81
+ IDENTITY_FIELDS[kind].each do |field|
82
+ out[field] = obj[field] if obj.key?(field)
83
+ end
84
+ [kind, out]
85
+ end
86
+
87
+ # The RFC 8785 identity-bearing bytes of an object (a UTF-8 string).
88
+ def canonicalize(obj, kind = nil)
89
+ _, ib = identity_bearing(obj, kind)
90
+ Jcs.encode(ib).encode(Encoding::UTF_8)
91
+ end
92
+
93
+ # The content-addressed identifier: scheme + ":" + SHA-256 hex.
94
+ def identify(obj, kind = nil)
95
+ kind, ib = identity_bearing(obj, kind)
96
+ digest = Digest::SHA256.hexdigest(Jcs.encode(ib).encode(Encoding::UTF_8))
97
+ PREFIX[kind] + ":" + digest
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Ed25519 digital signatures (RFC 8032), pure Ruby, standard library only.
4
+ #
5
+ # Slow but correct: intended for the conformance suite and for small tools.
6
+ # Production stores should use an optimized library; the signatures are
7
+ # byte-compatible either way (Ed25519 is deterministic, RFC 8032).
8
+ #
9
+ # Ruby's native bignums make this a direct port of the Python reference:
10
+ # Integer#pow(exp, mod) is Python's three-argument pow, and Ruby's % on a
11
+ # positive modulus is floored (always non-negative), exactly like Python's.
12
+ # All byte strings handled here are forced to ASCII-8BIT (binary), so
13
+ # byteslice/reverse operate on bytes, never on multibyte characters.
14
+
15
+ require "digest"
16
+
17
+ module Causalontology
18
+ module Ed25519
19
+ P = 2**255 - 19
20
+ Q = 2**252 + 27742317777372353535851937790883648493
21
+
22
+ module_function
23
+
24
+ def sha512(s)
25
+ Digest::SHA512.digest(s)
26
+ end
27
+
28
+ def modp_inv(x)
29
+ x.pow(P - 2, P)
30
+ end
31
+
32
+ # A 32-byte little-endian string for a non-negative integer below 2**256.
33
+ def int_to_le32(n)
34
+ hex = n.to_s(16).rjust(64, "0")
35
+ [hex].pack("H*").reverse
36
+ end
37
+
38
+ # The non-negative integer encoded by a little-endian byte string.
39
+ def le_to_int(s)
40
+ s.dup.force_encoding(Encoding::BINARY).reverse.unpack1("H*").to_i(16)
41
+ end
42
+
43
+ # The curve constant d = -121665 / 121666 (mod p).
44
+ CURVE_D = -121665 * modp_inv(121666) % P
45
+ # A square root of -1 (mod p), used in point decompression.
46
+ SQRT_M1 = 2.pow((P - 1) / 4, P)
47
+
48
+ # Points are [x, y, z, t] in extended homogeneous coordinates.
49
+ def point_add(pt, qt)
50
+ a = (pt[1] - pt[0]) * (qt[1] - qt[0]) % P
51
+ b = (pt[1] + pt[0]) * (qt[1] + qt[0]) % P
52
+ c = 2 * pt[3] * qt[3] * CURVE_D % P
53
+ d = 2 * pt[2] * qt[2] % P
54
+ e = b - a
55
+ f = d - c
56
+ g = d + c
57
+ h = b + a
58
+ [e * f % P, g * h % P, f * g % P, e * h % P]
59
+ end
60
+
61
+ def point_mul(s, pt)
62
+ q = [0, 1, 1, 0] # the neutral element
63
+ while s > 0
64
+ q = point_add(q, pt) if s & 1 == 1
65
+ pt = point_add(pt, pt)
66
+ s >>= 1
67
+ end
68
+ q
69
+ end
70
+
71
+ def point_equal(pt, qt)
72
+ return false if (pt[0] * qt[2] - qt[0] * pt[2]) % P != 0
73
+ return false if (pt[1] * qt[2] - qt[1] * pt[2]) % P != 0
74
+ true
75
+ end
76
+
77
+ def recover_x(y, sign)
78
+ return nil if y >= P
79
+ x2 = (y * y - 1) * modp_inv(CURVE_D * y * y + 1) % P
80
+ return (sign == 1 ? nil : 0) if x2 == 0
81
+ x = x2.pow((P + 3) / 8, P)
82
+ x = x * SQRT_M1 % P if (x * x - x2) % P != 0
83
+ return nil if (x * x - x2) % P != 0
84
+ x = P - x if (x & 1) != sign
85
+ x
86
+ end
87
+
88
+ # The base point G.
89
+ BASE_Y = 4 * modp_inv(5) % P
90
+ BASE_X = recover_x(BASE_Y, 0)
91
+ BASE_POINT = [BASE_X, BASE_Y, 1, BASE_X * BASE_Y % P].freeze
92
+
93
+ def point_compress(pt)
94
+ zinv = modp_inv(pt[2])
95
+ x = pt[0] * zinv % P
96
+ y = pt[1] * zinv % P
97
+ int_to_le32(y | ((x & 1) << 255))
98
+ end
99
+
100
+ def point_decompress(s)
101
+ return nil if s.bytesize != 32
102
+ y = le_to_int(s)
103
+ sign = y >> 255
104
+ y &= (1 << 255) - 1
105
+ x = recover_x(y, sign)
106
+ return nil if x.nil?
107
+ [x, y, 1, x * y % P]
108
+ end
109
+
110
+ def secret_expand(secret)
111
+ raise ArgumentError, "secret key must be 32 bytes" if secret.bytesize != 32
112
+ h = sha512(secret)
113
+ a = le_to_int(h.byteslice(0, 32))
114
+ a &= (1 << 254) - 8
115
+ a |= (1 << 254)
116
+ [a, h.byteslice(32, 32)]
117
+ end
118
+
119
+ def sha512_modq(s)
120
+ le_to_int(sha512(s)) % Q
121
+ end
122
+
123
+ # The 32-byte public key for a 32-byte secret key.
124
+ def secret_to_public(secret)
125
+ a, _prefix = secret_expand(secret)
126
+ point_compress(point_mul(a, BASE_POINT))
127
+ end
128
+
129
+ # The 64-byte Ed25519 signature of msg under the 32-byte secret key.
130
+ def sign(secret, msg)
131
+ msg = msg.dup.force_encoding(Encoding::BINARY)
132
+ a, prefix = secret_expand(secret)
133
+ public_key = point_compress(point_mul(a, BASE_POINT))
134
+ r = sha512_modq(prefix + msg)
135
+ rs = point_compress(point_mul(r, BASE_POINT))
136
+ h = sha512_modq(rs + public_key + msg)
137
+ s = (r + h * a) % Q
138
+ rs + int_to_le32(s)
139
+ end
140
+
141
+ # True iff signature is a valid Ed25519 signature of msg under public.
142
+ def verify(public_key, msg, signature)
143
+ msg = msg.dup.force_encoding(Encoding::BINARY)
144
+ return false if public_key.bytesize != 32 || signature.bytesize != 64
145
+ a_point = point_decompress(public_key)
146
+ return false if a_point.nil?
147
+ rs = signature.byteslice(0, 32)
148
+ r_point = point_decompress(rs)
149
+ return false if r_point.nil?
150
+ s = le_to_int(signature.byteslice(32, 32))
151
+ return false if s >= Q
152
+ h = sha512_modq(rs + public_key + msg)
153
+ sb = point_mul(s, BASE_POINT)
154
+ ha = point_mul(h, a_point)
155
+ point_equal(sb, point_add(r_point, ha))
156
+ end
157
+ end
158
+ end
@@ -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,204 @@
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
+ # seventeen Causalontology schemas use: type, const, enum, pattern, required,
7
+ # properties, additionalProperties, items, minItems, minLength, minimum,
8
+ # maximum, oneOf, local $ref (#/$defs/...), and cross-file $ref to a sibling
9
+ # schema (https://causalontology.org/schema/<file>.schema.json#/...). "format"
10
+ # is treated as an annotation, as the 2020-12 draft does by default.
11
+
12
+ require "json"
13
+ require_relative "canonical"
14
+
15
+ module Causalontology
16
+ module Schema
17
+ # kind -> schema file. Three token kinds keep their original 1.0.0-reserved
18
+ # file names (individual/token/state); the id scheme is the whole word.
19
+ SCHEMA_FILES = {
20
+ "occurrent" => "occurrent.schema.json",
21
+ "causal_relation_object" => "causal_relation_object.schema.json",
22
+ "continuant" => "continuant.schema.json",
23
+ "realizable" => "realizable.schema.json",
24
+ "stratum" => "stratum.schema.json",
25
+ "bridge" => "bridge.schema.json",
26
+ "port" => "port.schema.json",
27
+ "conduit" => "conduit.schema.json",
28
+ "quality" => "quality.schema.json",
29
+ "token_individual" => "individual.schema.json",
30
+ "token_occurrence" => "token.schema.json",
31
+ "state_assertion" => "state.schema.json",
32
+ "token_causal_claim" => "token_causal_claim.schema.json",
33
+ "assertion" => "assertion.schema.json",
34
+ "enrichment" => "enrichment.schema.json",
35
+ "retraction" => "retraction.schema.json",
36
+ "succession" => "succession.schema.json",
37
+ }.freeze
38
+
39
+ BASE = "https://causalontology.org/schema/"
40
+
41
+ @cache = {}
42
+
43
+ class << self
44
+ def schema_dir
45
+ env = ENV["CAUSALONTOLOGY_SPEC"]
46
+ return File.join(env, "schema") if env && !env.empty?
47
+ # lib/causalontology -> lib -> ruby -> bindings -> repository root
48
+ File.expand_path("../../../../spec/schema", __dir__)
49
+ end
50
+
51
+ # Load and cache a schema document by its file name.
52
+ def load_file(filename)
53
+ @cache[filename] ||= JSON.parse(
54
+ File.read(File.join(schema_dir, filename)))
55
+ end
56
+
57
+ def load_schema(kind)
58
+ unless SCHEMA_FILES.key?(kind)
59
+ raise ArgumentError, "unknown kind: #{kind.inspect}"
60
+ end
61
+ load_file(SCHEMA_FILES[kind])
62
+ end
63
+
64
+ # Navigate a JSON pointer (slash-separated) within a document.
65
+ def navigate(doc, pointer)
66
+ node = doc
67
+ pointer.split("/").each do |part|
68
+ next if part == ""
69
+ node = node[part]
70
+ end
71
+ node
72
+ end
73
+
74
+ # Resolve local and cross-file $refs to a concrete schema node plus the
75
+ # root document it lives in. Returns [schema, root].
76
+ def resolve_ref(schema, root)
77
+ while schema.is_a?(Hash) && schema.key?("$ref")
78
+ ref = schema["$ref"]
79
+ if ref.start_with?("#/")
80
+ schema = navigate(root, ref[2..])
81
+ elsif ref.start_with?(BASE)
82
+ rest = ref[BASE.length..]
83
+ filename, pointer = rest.split("#/", 2)
84
+ root = load_file(filename)
85
+ schema = pointer ? navigate(root, pointer) : root
86
+ else
87
+ raise ArgumentError, "unsupported $ref: #{ref.inspect}"
88
+ end
89
+ end
90
+ [schema, root]
91
+ end
92
+
93
+ def type_matches?(value, t)
94
+ case t
95
+ when "object" then value.is_a?(Hash)
96
+ when "array" then value.is_a?(Array)
97
+ when "string" then value.is_a?(String)
98
+ when "number" then value.is_a?(Integer) || value.is_a?(Float)
99
+ when "integer" then value.is_a?(Integer)
100
+ when "boolean" then value == true || value == false
101
+ else
102
+ raise ArgumentError, "unknown schema type: #{t.inspect}"
103
+ end
104
+ end
105
+
106
+ def numeric?(value)
107
+ value.is_a?(Integer) || value.is_a?(Float)
108
+ end
109
+
110
+ def check(value, schema, root, path, errors)
111
+ schema, root = resolve_ref(schema, root)
112
+
113
+ if schema.key?("oneOf")
114
+ passing = 0
115
+ schema["oneOf"].each do |sub|
116
+ suberrs = []
117
+ check(value, sub, root, path, suberrs)
118
+ passing += 1 if suberrs.empty?
119
+ end
120
+ if passing != 1
121
+ errors << "#{path}: matches #{passing} of the oneOf branches " \
122
+ "(need exactly 1)"
123
+ end
124
+ return
125
+ end
126
+
127
+ t = schema["type"]
128
+ if !t.nil? && !type_matches?(value, t)
129
+ errors << "#{path}: expected #{t}"
130
+ return
131
+ end
132
+
133
+ if schema.key?("const") && value != schema["const"]
134
+ errors << "#{path}: must equal #{schema["const"].inspect}"
135
+ end
136
+ if schema.key?("enum") && !schema["enum"].include?(value)
137
+ errors << "#{path}: #{value.inspect} not in enumeration"
138
+ end
139
+ if schema.key?("pattern") && value.is_a?(String)
140
+ unless Regexp.new(schema["pattern"]).match?(value)
141
+ errors << "#{path}: #{value.inspect} does not match " \
142
+ "#{schema["pattern"]}"
143
+ end
144
+ end
145
+ if schema.key?("minLength") && value.is_a?(String)
146
+ if value.length < schema["minLength"]
147
+ errors << "#{path}: shorter than minLength"
148
+ end
149
+ end
150
+ if schema.key?("minimum") && numeric?(value)
151
+ if value < schema["minimum"]
152
+ errors << "#{path}: below minimum #{schema["minimum"]}"
153
+ end
154
+ end
155
+ if schema.key?("maximum") && numeric?(value)
156
+ if value > schema["maximum"]
157
+ errors << "#{path}: above maximum #{schema["maximum"]}"
158
+ end
159
+ end
160
+
161
+ if value.is_a?(Array)
162
+ if schema.key?("minItems") && value.length < schema["minItems"]
163
+ errors << "#{path}: fewer than #{schema["minItems"]} items"
164
+ end
165
+ if schema.key?("items")
166
+ value.each_with_index do |item, i|
167
+ check(item, schema["items"], root, "#{path}[#{i}]", errors)
168
+ end
169
+ end
170
+ end
171
+
172
+ if value.is_a?(Hash)
173
+ props = schema["properties"] || {}
174
+ (schema["required"] || []).each do |req|
175
+ unless value.key?(req)
176
+ errors << "#{path}: required property '#{req}' missing"
177
+ end
178
+ end
179
+ if schema["additionalProperties"] == false
180
+ value.each_key do |key|
181
+ unless props.key?(key)
182
+ errors << "#{path}: additional property '#{key}'"
183
+ end
184
+ end
185
+ end
186
+ props.each do |key, sub|
187
+ if value.key?(key)
188
+ check(value[key], sub, root, "#{path}.#{key}", errors)
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ # [ok, reasons] - structural validity against the kind's JSON Schema.
195
+ def validate_schema(obj, kind = nil)
196
+ kind ||= Canonical.infer_kind(obj)
197
+ root = load_schema(kind)
198
+ errors = []
199
+ check(obj, root, root, "$", errors)
200
+ [errors.empty?, errors]
201
+ end
202
+ end
203
+ end
204
+ end