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
data/conformance.rb
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: false
|
|
3
|
+
|
|
4
|
+
# The Causalontology conformance runner for causalontology-ruby.
|
|
5
|
+
#
|
|
6
|
+
# Runs every vector in conformance/vectors/ against the Ruby binding. An
|
|
7
|
+
# implementation is conformant if and only if it passes every vector; this
|
|
8
|
+
# runner exits nonzero on any failure. It mirrors
|
|
9
|
+
# bindings/python/tests/run_conformance.py exactly.
|
|
10
|
+
#
|
|
11
|
+
# The vectors are frozen at specification 1.0.0: they carry concrete 64-hex
|
|
12
|
+
# identifiers and real Ed25519 keys, which the normalization below simply
|
|
13
|
+
# passes through. Symbolic names still used inside this harness's own
|
|
14
|
+
# behavioral constructions ("occ:A", key "alice") are normalized
|
|
15
|
+
# deterministically - symbolic object ids become scheme:sha256(name), and
|
|
16
|
+
# symbolic key names become real Ed25519 keypairs seeded from
|
|
17
|
+
# sha256("key:" + name).
|
|
18
|
+
|
|
19
|
+
require "json"
|
|
20
|
+
require "digest"
|
|
21
|
+
require_relative "lib/causalontology"
|
|
22
|
+
|
|
23
|
+
ROOT = ENV["CAUSALONTOLOGY_ROOT"] || File.expand_path("../..", __dir__)
|
|
24
|
+
VECDIR = File.join(ROOT, "conformance", "vectors")
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# assertion helper
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
class VectorFailure < StandardError; end
|
|
30
|
+
|
|
31
|
+
def assert(cond, msg = "assertion failed")
|
|
32
|
+
raise VectorFailure, msg.to_s unless cond
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# symbolic-identifier normalization
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
SYM_PREFIX = /\A(occ|cro|cnt|rlz|ast|enr|ret|suc|ed25519):/
|
|
39
|
+
HEX64 = /\A[0-9a-f]{64}\z/
|
|
40
|
+
KEYS = {}
|
|
41
|
+
|
|
42
|
+
# A real, deterministic Ed25519 keypair for a symbolic key name.
|
|
43
|
+
def key(name)
|
|
44
|
+
KEYS[name] ||= begin
|
|
45
|
+
seed = Digest::SHA256.digest("key:" + name)
|
|
46
|
+
Causalontology.keypair_from_seed(seed)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Normalize one symbolic identifier to a well-formed one.
|
|
51
|
+
def sym(s)
|
|
52
|
+
scheme, name = s.split(":", 2)
|
|
53
|
+
if scheme == "ed25519"
|
|
54
|
+
return s if name.match?(HEX64) # frozen: a real key passes through
|
|
55
|
+
return key(name)[1]
|
|
56
|
+
end
|
|
57
|
+
return s if name.match?(HEX64)
|
|
58
|
+
scheme + ":" + Digest::SHA256.hexdigest(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Recursively normalize symbolic identifiers and placeholders.
|
|
62
|
+
def normalize(x)
|
|
63
|
+
case x
|
|
64
|
+
when String
|
|
65
|
+
return "ab" * 64 if x == "<128 hex>"
|
|
66
|
+
return sym(x) if x.match?(SYM_PREFIX)
|
|
67
|
+
x
|
|
68
|
+
when Array
|
|
69
|
+
x.map { |v| normalize(v) }
|
|
70
|
+
when Hash
|
|
71
|
+
x.transform_values { |v| normalize(v) } # preserves key order
|
|
72
|
+
else
|
|
73
|
+
x
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Load vector n's JSON file (for its structured inputs).
|
|
78
|
+
def vec(n)
|
|
79
|
+
hits = Dir.glob(File.join(VECDIR, format("v%02d_*.json", n)))
|
|
80
|
+
assert hits.length == 1, "vector #{n} not found"
|
|
81
|
+
JSON.parse(File.read(hits[0]))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def vec_name(n)
|
|
85
|
+
File.basename(Dir.glob(File.join(VECDIR, format("v%02d_*.json", n)))[0], ".json")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
TS = "2026-07-13T0%d:00:00Z"
|
|
89
|
+
|
|
90
|
+
# Build, timestamp, and sign a provenance record.
|
|
91
|
+
def signed(kind, body, who, ts_i = 0)
|
|
92
|
+
secret, pub = key(who)
|
|
93
|
+
rec = body.dup
|
|
94
|
+
rec["type"] = kind
|
|
95
|
+
rec["timestamp"] = format(TS, ts_i) unless rec.key?("timestamp")
|
|
96
|
+
if kind == "succession"
|
|
97
|
+
rec["predecessor"] = pub unless rec.key?("predecessor")
|
|
98
|
+
else
|
|
99
|
+
rec["source"] = pub
|
|
100
|
+
end
|
|
101
|
+
Causalontology.sign_record(rec, secret, kind)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# internal sanity checks (not conformance vectors)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
def internal_checks
|
|
108
|
+
# RFC 8032, TEST 1 known-answer
|
|
109
|
+
sk = ["9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"].pack("H*")
|
|
110
|
+
pk = Causalontology::Ed25519.secret_to_public(sk)
|
|
111
|
+
assert pk.unpack1("H*") ==
|
|
112
|
+
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
|
113
|
+
pk.unpack1("H*")
|
|
114
|
+
sig = Causalontology::Ed25519.sign(sk, "")
|
|
115
|
+
assert Causalontology::Ed25519.verify(pk, "", sig)
|
|
116
|
+
assert !Causalontology::Ed25519.verify(pk, "x", sig)
|
|
117
|
+
# JCS basics
|
|
118
|
+
assert Causalontology::Jcs.encode({ "b" => 2, "a" => 1 }) == '{"a":1,"b":2}'
|
|
119
|
+
assert Causalontology::Jcs.encode(1.0) == "1"
|
|
120
|
+
assert Causalontology::Jcs.encode(6.000) == "6"
|
|
121
|
+
assert Causalontology::Jcs.encode(0.7) == "0.7"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# the 38 vectors
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
def v01
|
|
128
|
+
inp = normalize(vec(1)["input"])
|
|
129
|
+
ok, why = Causalontology.validate_schema(inp)
|
|
130
|
+
assert ok, why
|
|
131
|
+
ok, why = Causalontology.validate_semantics(inp)
|
|
132
|
+
assert ok, why
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def v02
|
|
136
|
+
inp = normalize(vec(2)["input"])
|
|
137
|
+
ok, _why = Causalontology.validate_schema(inp)
|
|
138
|
+
assert ok
|
|
139
|
+
ok, _why = Causalontology.validate_semantics(inp)
|
|
140
|
+
assert ok
|
|
141
|
+
partial, missing = Causalontology.is_partial(inp)
|
|
142
|
+
assert partial && missing == vec(2)["expect"]["missing"], missing
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def schema_fails(n, must_mention)
|
|
146
|
+
inp = normalize(vec(n)["input"])
|
|
147
|
+
ok, why = Causalontology.validate_schema(inp)
|
|
148
|
+
assert !ok, "expected schema-invalid"
|
|
149
|
+
assert why.any? { |w| w.include?(must_mention) }, why
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def v03; schema_fails(3, "effects"); end
|
|
153
|
+
def v04; schema_fails(4, "causes"); end
|
|
154
|
+
def v05; schema_fails(5, "modality"); end
|
|
155
|
+
def v06; schema_fails(6, "colour"); end
|
|
156
|
+
def v07; schema_fails(7, "causes"); end
|
|
157
|
+
|
|
158
|
+
def v08
|
|
159
|
+
ok, why = Causalontology.validate_schema(normalize(vec(8)["input"]))
|
|
160
|
+
assert ok, why
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def v09; schema_fails(9, "label"); end
|
|
164
|
+
def v10; schema_fails(10, "category"); end
|
|
165
|
+
|
|
166
|
+
def v11
|
|
167
|
+
ok, why = Causalontology.validate_schema(normalize(vec(11)["input"]))
|
|
168
|
+
assert ok, why
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def v12; schema_fails(12, "confidence"); end
|
|
172
|
+
|
|
173
|
+
def v13
|
|
174
|
+
inp = normalize(vec(13)["input"])
|
|
175
|
+
ok, why = Causalontology.validate_schema(inp)
|
|
176
|
+
assert ok, why
|
|
177
|
+
ok, why = Causalontology.validate_semantics(inp)
|
|
178
|
+
assert ok, why
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def semantics_fails(n, must_mention)
|
|
182
|
+
inp = normalize(vec(n)["input"])
|
|
183
|
+
ok, why = Causalontology.validate_semantics(inp)
|
|
184
|
+
assert !ok, "expected semantically-invalid"
|
|
185
|
+
assert why.any? { |w| w.include?(must_mention) }, why
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def v14
|
|
189
|
+
inp = normalize(vec(14)["input"])
|
|
190
|
+
ok, _why = Causalontology.validate_schema(inp)
|
|
191
|
+
assert ok
|
|
192
|
+
semantics_fails(14, "dmin")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def v15; semantics_fails(15, "acyclic"); end
|
|
196
|
+
def v16; semantics_fails(16, "acyclic"); end
|
|
197
|
+
|
|
198
|
+
def v17
|
|
199
|
+
v = vec(17)
|
|
200
|
+
parent = normalize(v["given"]["parent"])
|
|
201
|
+
child = normalize(v["input"])
|
|
202
|
+
ok, reason = Causalontology.refinement_valid(child, parent)
|
|
203
|
+
assert !ok && reason.include?("rival"), reason
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def v18; semantics_fails(18, "not a legal field"); end
|
|
207
|
+
def v19; semantics_fails(19, "language-tagged"); end
|
|
208
|
+
|
|
209
|
+
def v20
|
|
210
|
+
dog = sym("cnt:dog")
|
|
211
|
+
mam = sym("cnt:mammal")
|
|
212
|
+
ani = sym("cnt:animal")
|
|
213
|
+
enrich = lambda do |about, entry, i|
|
|
214
|
+
signed("enrichment",
|
|
215
|
+
{ "about" => about, "field" => "subsumes", "entry" => entry },
|
|
216
|
+
"taxo", i)
|
|
217
|
+
end
|
|
218
|
+
# enforcing tier rejects the cycle-completing write
|
|
219
|
+
s = Causalontology::InMemoryStore.new(enforcing: true)
|
|
220
|
+
s.put_record(enrich.call(dog, mam, 1))
|
|
221
|
+
s.put_record(enrich.call(mam, ani, 2))
|
|
222
|
+
begin
|
|
223
|
+
s.put_record(enrich.call(ani, dog, 3))
|
|
224
|
+
raise VectorFailure, "enforcing store accepted a cycle"
|
|
225
|
+
rescue Causalontology::RejectedWrite => e
|
|
226
|
+
assert e.message.include?("cycle"), e.message
|
|
227
|
+
end
|
|
228
|
+
# decentralized merge: the view breaks the cycle deterministically
|
|
229
|
+
s2 = Causalontology::InMemoryStore.new(enforcing: true)
|
|
230
|
+
s2.put_record(enrich.call(dog, mam, 1))
|
|
231
|
+
s2.put_record(enrich.call(mam, ani, 2))
|
|
232
|
+
bad = enrich.call(ani, dog, 3)
|
|
233
|
+
s2.force_merge_record(bad)
|
|
234
|
+
_active, excluded = s2.active_taxonomy_edges("subsumes")
|
|
235
|
+
assert excluded.length == 1 && excluded[0]["id"] == bad["id"]
|
|
236
|
+
repair = s2.gaps("inconsistent_hierarchy")
|
|
237
|
+
assert repair.any? { |g| g["id"] == bad["id"] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def adm(n)
|
|
241
|
+
g = vec(n)["given"]
|
|
242
|
+
cro = { "causes" => [sym("occ:c")], "effects" => [sym("occ:e")],
|
|
243
|
+
"temporal" => g["temporal"] }
|
|
244
|
+
Causalontology.admissible(cro, g["elapsed_seconds"])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def v21; assert adm(21) == true; end
|
|
248
|
+
def v22; assert adm(22) == false; end
|
|
249
|
+
def v23; assert adm(23) == true; end
|
|
250
|
+
|
|
251
|
+
def v24
|
|
252
|
+
v = vec(24)
|
|
253
|
+
assert Causalontology.identify(normalize(v["inputA"])) ==
|
|
254
|
+
Causalontology.identify(normalize(v["inputB"]))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def v25
|
|
258
|
+
v = vec(25)
|
|
259
|
+
assert Causalontology.identify(normalize(v["inputA"])) ==
|
|
260
|
+
Causalontology.identify(normalize(v["inputB"]))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def v26
|
|
264
|
+
s = Causalontology::InMemoryStore.new
|
|
265
|
+
obj = { "type" => "occurrent", "label" => "press_button",
|
|
266
|
+
"category" => "action" }
|
|
267
|
+
a = s.put(obj.dup)
|
|
268
|
+
b = s.put(obj.dup)
|
|
269
|
+
assert a == b && s.objects.length == 1
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def v27
|
|
273
|
+
s = Causalontology::InMemoryStore.new
|
|
274
|
+
occ = s.put({ "type" => "occurrent", "label" => "press_button",
|
|
275
|
+
"category" => "action" })
|
|
276
|
+
entry = { "lang" => "en", "text" => "press the button" }
|
|
277
|
+
r1 = signed("enrichment", { "about" => occ, "field" => "aliases",
|
|
278
|
+
"entry" => entry }, "alice", 1)
|
|
279
|
+
r2 = signed("enrichment", { "about" => occ, "field" => "aliases",
|
|
280
|
+
"entry" => entry }, "bob", 2)
|
|
281
|
+
assert s.put_record(r1) != s.put_record(r2) # two records
|
|
282
|
+
view = s.get(occ)["enrichments"]["aliases"]
|
|
283
|
+
assert view.length == 1 && view[0]["contributors"].length == 2
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def v28
|
|
287
|
+
s = Causalontology::InMemoryStore.new
|
|
288
|
+
claim = { "type" => "cro", "causes" => [sym("occ:A")],
|
|
289
|
+
"effects" => [sym("occ:B")], "modality" => "sufficient" }
|
|
290
|
+
i1 = s.put(claim.dup)
|
|
291
|
+
i2 = s.put(claim.dup)
|
|
292
|
+
assert i1 == i2 && s.objects.length == 1
|
|
293
|
+
[["lab1", 1], ["lab2", 2]].each do |who, ts|
|
|
294
|
+
s.put_record(signed("assertion",
|
|
295
|
+
{ "about" => i1, "evidence_type" => "observation",
|
|
296
|
+
"strength" => 0.8, "confidence" => 0.8 }, who, ts))
|
|
297
|
+
end
|
|
298
|
+
assert s.assertions_about(i1).length == 2
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def v29
|
|
302
|
+
rec = signed("assertion", { "about" => sym("cro:demo"),
|
|
303
|
+
"evidence_type" => "intervention",
|
|
304
|
+
"strength" => 0.7, "confidence" => 0.9 }, "signer")
|
|
305
|
+
assert Causalontology.verify_record(rec) == true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def v30
|
|
309
|
+
rec = signed("assertion", { "about" => sym("cro:demo"),
|
|
310
|
+
"evidence_type" => "intervention",
|
|
311
|
+
"strength" => 0.7, "confidence" => 0.9 }, "signer")
|
|
312
|
+
tampered = rec.merge("confidence" => 0.1)
|
|
313
|
+
assert Causalontology.verify_record(tampered) == false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def v31
|
|
317
|
+
s = Causalontology::InMemoryStore.new
|
|
318
|
+
x = s.put({ "type" => "cro", "causes" => [sym("occ:A")],
|
|
319
|
+
"effects" => [sym("occ:B")] })
|
|
320
|
+
a = signed("assertion", { "about" => x, "evidence_type" => "observation",
|
|
321
|
+
"confidence" => 0.8 }, "lab1", 1)
|
|
322
|
+
s.put_record(a)
|
|
323
|
+
s.put_record(signed("retraction", { "retracts" => a["id"] }, "lab1", 2))
|
|
324
|
+
assert s.assertions_about(x) == []
|
|
325
|
+
hist = s.assertions_about(x, include_retracted: true)
|
|
326
|
+
assert hist.length == 1 && hist[0]["retracted"] == true
|
|
327
|
+
foreign = signed("retraction", { "retracts" => a["id"] }, "mallory", 3)
|
|
328
|
+
begin
|
|
329
|
+
s.put_record(foreign)
|
|
330
|
+
raise VectorFailure, "foreign retraction accepted"
|
|
331
|
+
rescue Causalontology::RejectedWrite
|
|
332
|
+
# expected: only the source or its lineage may retract
|
|
333
|
+
end
|
|
334
|
+
assert s.assertions_about(x) == [] # still excluded by lab1's own
|
|
335
|
+
assert s.assertions_about(x, include_retracted: true).length == 1
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def v32
|
|
339
|
+
s = Causalontology::InMemoryStore.new
|
|
340
|
+
occ = s.put({ "type" => "occurrent", "label" => "press_button",
|
|
341
|
+
"category" => "action" })
|
|
342
|
+
e = signed("enrichment", { "about" => occ, "field" => "aliases",
|
|
343
|
+
"entry" => { "lang" => "ja", "text" => "botan" } },
|
|
344
|
+
"bob", 1)
|
|
345
|
+
s.put_record(e)
|
|
346
|
+
before = s.get(occ)["enrichments"]["aliases"] || []
|
|
347
|
+
assert before.length == 1
|
|
348
|
+
s.put_record(signed("retraction", { "retracts" => e["id"] }, "bob", 2))
|
|
349
|
+
after = s.get(occ)["enrichments"]["aliases"] || []
|
|
350
|
+
assert after == []
|
|
351
|
+
hist = s.get(occ, view: "history")["enrichments"]["aliases"] || []
|
|
352
|
+
assert hist.length == 1
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def v33
|
|
356
|
+
s = Causalontology::InMemoryStore.new
|
|
357
|
+
k1 = key("K1")[1]
|
|
358
|
+
k2 = key("K2")[1]
|
|
359
|
+
a = signed("assertion", { "about" => sym("cro:claim"),
|
|
360
|
+
"evidence_type" => "observation",
|
|
361
|
+
"confidence" => 0.9 }, "K1", 1)
|
|
362
|
+
s.put_record(a)
|
|
363
|
+
succ = signed("succession", { "successor" => k2 }, "K1", 2)
|
|
364
|
+
s.put_record(succ)
|
|
365
|
+
assert s.lineage(k2).include?(k1) && s.lineage(k1).include?(k2)
|
|
366
|
+
r = signed("retraction", { "retracts" => a["id"] }, "K2", 3)
|
|
367
|
+
s.put_record(r) # successor may retract the predecessor's record
|
|
368
|
+
assert s.assertions_about(sym("cro:claim")) == []
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def v34
|
|
372
|
+
g = normalize(vec(34)["given"])
|
|
373
|
+
assert Causalontology.conflicts(g["A"], g["B"]) == true
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def v35
|
|
377
|
+
g = normalize(vec(35)["given"])
|
|
378
|
+
assert Causalontology.conflicts(g["A"], g["B"]) == false
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def v36
|
|
382
|
+
a = sym("occ:A")
|
|
383
|
+
b = sym("occ:B")
|
|
384
|
+
c = sym("occ:C")
|
|
385
|
+
d = sym("occ:D")
|
|
386
|
+
m1 = { "id" => sym("cro:m1"), "causes" => [a], "effects" => [b] }
|
|
387
|
+
m2 = { "id" => sym("cro:m2"), "causes" => [b], "effects" => [c] }
|
|
388
|
+
m3 = { "id" => sym("cro:m3"), "causes" => [d], "effects" => [c] }
|
|
389
|
+
parent = { "causes" => [a], "effects" => [c],
|
|
390
|
+
"mechanism" => [m1["id"], m2["id"]] }
|
|
391
|
+
assert Causalontology.hierarchy_consistent(
|
|
392
|
+
parent, { m1["id"] => m1, m2["id"] => m2 }) == "consistent"
|
|
393
|
+
parent2 = parent.merge("mechanism" => [m1["id"], m3["id"]])
|
|
394
|
+
assert Causalontology.hierarchy_consistent(
|
|
395
|
+
parent2, { m1["id"] => m1, m3["id"] => m3 }) == "inconsistent"
|
|
396
|
+
assert Causalontology.hierarchy_consistent(
|
|
397
|
+
parent, { m1["id"] => m1 }) == "indeterminate"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def v37
|
|
401
|
+
s = Causalontology::InMemoryStore.new
|
|
402
|
+
occ = s.put({ "type" => "occurrent", "label" => "press_button",
|
|
403
|
+
"category" => "action" })
|
|
404
|
+
s.put_record(signed("enrichment",
|
|
405
|
+
{ "about" => occ, "field" => "aliases",
|
|
406
|
+
"entry" => { "lang" => "en",
|
|
407
|
+
"text" => "Press the Button" } },
|
|
408
|
+
"alice", 1))
|
|
409
|
+
assert s.resolve("Press The Button", "en") == [occ] # alias match
|
|
410
|
+
assert s.resolve("press_button", "en")[0] == occ # label, first
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def v38
|
|
414
|
+
s = Causalontology::InMemoryStore.new
|
|
415
|
+
parent = s.put({ "type" => "cro", "causes" => [sym("occ:A")],
|
|
416
|
+
"effects" => [sym("occ:B")] })
|
|
417
|
+
gaps = s.gaps("missing_field").map { |g| g["id"] }
|
|
418
|
+
assert gaps.include?(parent)
|
|
419
|
+
refinement = s.put({ "type" => "cro", "causes" => [sym("occ:A")],
|
|
420
|
+
"effects" => [sym("occ:B")],
|
|
421
|
+
"temporal" => { "dmin" => 0, "dmax" => 1,
|
|
422
|
+
"unit" => "seconds" },
|
|
423
|
+
"modality" => "sufficient", "refines" => parent })
|
|
424
|
+
gaps = s.gaps("missing_field").map { |g| g["id"] }
|
|
425
|
+
assert !gaps.include?(parent), "the gap did not close"
|
|
426
|
+
assert !gaps.include?(refinement), "the refinement itself must be complete"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
def main
|
|
431
|
+
puts "causalontology-ruby conformance run"
|
|
432
|
+
print "internal checks (RFC 8032 known-answer, RFC 8785 basics) ... "
|
|
433
|
+
internal_checks
|
|
434
|
+
puts "ok"
|
|
435
|
+
failures = 0
|
|
436
|
+
(1..38).each do |n|
|
|
437
|
+
name = vec_name(n)
|
|
438
|
+
begin
|
|
439
|
+
send(format("v%02d", n))
|
|
440
|
+
puts "PASS #{name}"
|
|
441
|
+
rescue StandardError => e
|
|
442
|
+
failures += 1
|
|
443
|
+
puts "FAIL #{name} :: #{e.class}: #{e.message}"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
puts "-" * 60
|
|
447
|
+
puts "#{38 - failures}/38 vectors passed"
|
|
448
|
+
exit 1 if failures > 0
|
|
449
|
+
puts "causalontology-ruby is CONFORMANT to the suite " \
|
|
450
|
+
"(vectors frozen at specification 1.0.0)."
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
main if __FILE__ == $PROGRAM_NAME
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
IDENTITY_FIELDS = {
|
|
18
|
+
"occurrent" => ["label", "category"],
|
|
19
|
+
"cro" => ["causes", "effects", "mechanism", "temporal", "modality",
|
|
20
|
+
"context", "refines"],
|
|
21
|
+
"continuant" => ["label", "category"],
|
|
22
|
+
"realizable" => ["kind", "bearer"],
|
|
23
|
+
"assertion" => ["about", "source", "evidence_type", "evidence", "strength",
|
|
24
|
+
"confidence", "timestamp"],
|
|
25
|
+
"enrichment" => ["about", "field", "entry", "source", "timestamp"],
|
|
26
|
+
"retraction" => ["retracts", "source", "timestamp"],
|
|
27
|
+
"succession" => ["predecessor", "successor", "timestamp"],
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
PREFIX = {
|
|
31
|
+
"occurrent" => "occ", "cro" => "cro", "continuant" => "cnt",
|
|
32
|
+
"realizable" => "rlz", "assertion" => "ast", "enrichment" => "enr",
|
|
33
|
+
"retraction" => "ret", "succession" => "suc",
|
|
34
|
+
}.freeze
|
|
35
|
+
KIND_OF_PREFIX = PREFIX.invert.freeze
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Infer an object's kind from its type field, id prefix, or shape.
|
|
40
|
+
def infer_kind(obj)
|
|
41
|
+
return obj["type"] if obj.key?("type")
|
|
42
|
+
if obj.key?("id") && obj["id"].is_a?(String) && obj["id"].include?(":")
|
|
43
|
+
pre = obj["id"].split(":", 2)[0]
|
|
44
|
+
return KIND_OF_PREFIX[pre] if KIND_OF_PREFIX.key?(pre)
|
|
45
|
+
end
|
|
46
|
+
return "cro" if obj.key?("causes") && obj.key?("effects")
|
|
47
|
+
return "retraction" if obj.key?("retracts")
|
|
48
|
+
return "succession" if obj.key?("predecessor") && obj.key?("successor")
|
|
49
|
+
return "enrichment" if obj.key?("field") && obj.key?("entry")
|
|
50
|
+
return "assertion" if obj.key?("evidence_type") ||
|
|
51
|
+
(obj.key?("about") && obj.key?("confidence"))
|
|
52
|
+
return "realizable" if obj.key?("kind") && obj.key?("bearer")
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"cannot infer kind (occurrents and continuants share a shape); " \
|
|
55
|
+
"pass kind explicitly"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The identity-bearing subset of an object, with type always present.
|
|
59
|
+
# Returns [kind, subset].
|
|
60
|
+
def identity_bearing(obj, kind = nil)
|
|
61
|
+
kind ||= infer_kind(obj)
|
|
62
|
+
unless IDENTITY_FIELDS.key?(kind)
|
|
63
|
+
raise ArgumentError, "unknown kind: #{kind.inspect}"
|
|
64
|
+
end
|
|
65
|
+
out = { "type" => kind }
|
|
66
|
+
IDENTITY_FIELDS[kind].each do |field|
|
|
67
|
+
out[field] = obj[field] if obj.key?(field)
|
|
68
|
+
end
|
|
69
|
+
[kind, out]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The RFC 8785 identity-bearing bytes of an object (a UTF-8 string).
|
|
73
|
+
def canonicalize(obj, kind = nil)
|
|
74
|
+
_, ib = identity_bearing(obj, kind)
|
|
75
|
+
Jcs.encode(ib).encode(Encoding::UTF_8)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The content-addressed identifier: scheme + ":" + SHA-256 hex.
|
|
79
|
+
def identify(obj, kind = nil)
|
|
80
|
+
kind, ib = identity_bearing(obj, kind)
|
|
81
|
+
digest = Digest::SHA256.hexdigest(Jcs.encode(ib).encode(Encoding::UTF_8))
|
|
82
|
+
PREFIX[kind] + ":" + digest
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
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
|