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