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.
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