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,455 @@
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. Two
26
+ # occurrent forms added in 2.0.0.
27
+ ENRICHMENT_FIELDS = {
28
+ "aliases" => [["occurrent", "continuant"], "alias"],
29
+ "participants" => [["occurrent"], "continuant"],
30
+ "subsumes" => [["continuant"], "continuant"],
31
+ "part_of" => [["continuant"], "continuant"],
32
+ "realized_in" => [["realizable"], "occurrent"],
33
+ "occurrent_subsumes" => [["occurrent"], "occurrent"],
34
+ "occurrent_part_of" => [["occurrent"], "occurrent"],
35
+ }.freeze
36
+
37
+ CRO_OPTIONAL_FIELDS = ["mechanism", "temporal", "modality", "context"].freeze
38
+
39
+ # Rule 6 (amended): necessary, sufficient, contributory, enabling are
40
+ # mutually compatible; preventive opposes all four.
41
+ POSITIVE = Set.new(["necessary", "sufficient", "contributory",
42
+ "enabling"]).freeze
43
+
44
+ module_function
45
+
46
+ def kind_of_id(identifier)
47
+ Canonical::KIND_OF_PREFIX[identifier.split(":", 2)[0]]
48
+ end
49
+
50
+ # [ok, reasons] - the locally checkable semantic rules.
51
+ def validate_semantics(obj, kind = nil)
52
+ kind ||= Canonical.infer_kind(obj)
53
+ errors = []
54
+
55
+ if kind == "causal_relation_object"
56
+ t = obj["temporal"]
57
+ if !t.nil? && !t["minimum_delay"].nil? && !t["maximum_delay"].nil? && t["minimum_delay"] > t["maximum_delay"]
58
+ errors << "minimum_delay must be <= maximum_delay"
59
+ end
60
+ oid = obj["id"]
61
+ if oid && (obj["mechanism"] || []).include?(oid)
62
+ errors << "mechanism must be acyclic " \
63
+ "(a Causal Relation Object may not contain itself)"
64
+ end
65
+ if oid && obj["refines"] == oid
66
+ errors << "refines must be acyclic"
67
+ end
68
+ # Rule 16, clause 1 (contradictory_skip): a HARD, locally-decidable
69
+ # contradiction between skips:true and a non-empty mechanism.
70
+ if obj["skips"] == true && obj["mechanism"] && !obj["mechanism"].empty?
71
+ errors << "contradictory_skip: skips is true but a mechanism " \
72
+ "is present"
73
+ end
74
+ end
75
+
76
+ if kind == "enrichment"
77
+ field = obj["field"]
78
+ about = obj["about"] || ""
79
+ entry = obj["entry"]
80
+ spec = ENRICHMENT_FIELDS[field]
81
+ if spec
82
+ legal_kinds, shape = spec
83
+ about_kind = kind_of_id(about)
84
+ if about_kind && !legal_kinds.include?(about_kind)
85
+ errors << "#{field} is not a legal field for a #{about_kind} (rule 12)"
86
+ end
87
+ if shape == "alias"
88
+ unless entry.is_a?(Hash) && entry.key?("lang") && entry.key?("text")
89
+ errors << "an aliases entry must be a language-tagged text object"
90
+ end
91
+ else
92
+ unless entry.is_a?(String) && entry.start_with?(shape + ":")
93
+ errors << "a #{field} entry must be a #{shape}: identifier"
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ [errors.empty?, errors]
100
+ end
101
+
102
+ # [partial, missing] - which optional CRO fields are unspecified.
103
+ def is_partial(cro)
104
+ missing = CRO_OPTIONAL_FIELDS.reject { |f| cro.key?(f) }
105
+ [!missing.empty?, missing]
106
+ end
107
+
108
+ # Rule 4: temporal admissibility with the fixed constants.
109
+ def admissible(cro, elapsed_seconds)
110
+ t = cro["temporal"]
111
+ return true if t.nil? # no window imposes no constraint
112
+ unit = UNIT_SECONDS.fetch(t["unit"])
113
+ lo = t["minimum_delay"] * unit
114
+ hi = t["maximum_delay"] * unit
115
+ lo <= elapsed_seconds && elapsed_seconds <= hi
116
+ end
117
+
118
+ def window_overlap(a, b)
119
+ ta = a["temporal"]
120
+ tb = b["temporal"]
121
+ return true if ta.nil? || tb.nil? # either absent counts as overlapping
122
+ ua = UNIT_SECONDS.fetch(ta["unit"])
123
+ ub = UNIT_SECONDS.fetch(tb["unit"])
124
+ lo_a = ta["minimum_delay"] * ua
125
+ hi_a = ta["maximum_delay"] * ua
126
+ lo_b = tb["minimum_delay"] * ub
127
+ hi_b = tb["maximum_delay"] * ub
128
+ lo_a <= hi_b && lo_b <= hi_a
129
+ end
130
+
131
+ def contexts_compatible(a, b)
132
+ ca = a["context"]
133
+ cb = b["context"]
134
+ return true if ca.nil? || ca.empty? || cb.nil? || cb.empty?
135
+ sa = Set.new(ca)
136
+ sb = Set.new(cb)
137
+ sa == sb || sa.subset?(sb) || sb.subset?(sa)
138
+ end
139
+
140
+ # Rule 6: the formal conflict test.
141
+ def conflicts(a, b)
142
+ return false if Set.new(a["causes"]) != Set.new(b["causes"])
143
+ return false if Set.new(a["effects"]) != Set.new(b["effects"])
144
+ return false unless contexts_compatible(a, b)
145
+ return false unless window_overlap(a, b)
146
+ ma = a["modality"]
147
+ mb = b["modality"]
148
+ (ma == "preventive" && POSITIVE.include?(mb)) ||
149
+ (mb == "preventive" && POSITIVE.include?(ma))
150
+ end
151
+
152
+ # Rule 3: [ok, reason] - is child a valid refinement of parent?
153
+ def refinement_valid(child, parent)
154
+ if child["refines"] != parent["id"]
155
+ return [false, "child does not name the parent in refines"]
156
+ end
157
+ if Set.new(child["causes"]) != Set.new(parent["causes"]) ||
158
+ Set.new(child["effects"]) != Set.new(parent["effects"])
159
+ return [false, "a refinement must keep the parent's causes and effects"]
160
+ end
161
+ added = 0
162
+ CRO_OPTIONAL_FIELDS.each do |field|
163
+ if parent.key?(field)
164
+ if child[field] != parent[field]
165
+ return [false, "a refinement may not change a field the " \
166
+ "parent specified; this is a rival claim"]
167
+ end
168
+ elsif child.key?(field)
169
+ added += 1
170
+ end
171
+ end
172
+ if added == 0
173
+ return [false, "a refinement must add at least one unspecified field"]
174
+ end
175
+ [true, "valid refinement"]
176
+ end
177
+
178
+ # =======================================================================
179
+ # 2.0.0 NORMATIVE ALGORITHMS (Section 12)
180
+ # =======================================================================
181
+
182
+ # ALGORITHM A. Every finer occurrent an occurrent resolves to, following
183
+ # Bridges downward, transitively. Includes the starting occurrent
184
+ # (N12.1.1). `bridges` is any iterable of bridge objects. The visited guard
185
+ # (N12.1.2) prevents an infinite loop on malformed cyclic data.
186
+ def bridge_closure(occurrent_id, bridges)
187
+ result = Set.new([occurrent_id])
188
+ frontier = [occurrent_id]
189
+ visited = Set.new
190
+ coarse_index = {}
191
+ bridges.each { |b| (coarse_index[b["coarse"]] ||= []) << b }
192
+ until frontier.empty?
193
+ current = frontier.pop
194
+ next if visited.include?(current)
195
+ visited << current
196
+ (coarse_index[current] || []).each do |b|
197
+ b["fine"].each do |f|
198
+ result << f
199
+ frontier << f
200
+ end
201
+ end
202
+ end
203
+ result
204
+ end
205
+
206
+ def path_exists(edges, src, dst)
207
+ seen = Set.new
208
+ stack = [src]
209
+ until stack.empty?
210
+ node = stack.pop
211
+ return true if node == dst
212
+ next if seen.include?(node)
213
+ seen << node
214
+ stack.concat((edges[node] || []).to_a)
215
+ end
216
+ false
217
+ end
218
+
219
+ # ALGORITHM B (amended Rule 7): "consistent" | "inconsistent" |
220
+ # "indeterminate", ACROSS STRATA via bridged reachability.
221
+ #
222
+ # members: mapping from CRO identifier to CRO object for the mechanism
223
+ # entries. bridges: the store's bridges (empty -> 1.0.0 literal
224
+ # reachability, the degenerate case, N12.2.3).
225
+ def hierarchy_consistent(parent, members, bridges = [])
226
+ mechanism = parent["mechanism"] || []
227
+ return "consistent" if mechanism.empty? # nothing claimed (N12.2.1)
228
+ edges = {}
229
+ mechanism.each do |mid|
230
+ m = members[mid]
231
+ return "indeterminate" if m.nil? # dangling; ignorance, not refutation
232
+ m["causes"].each do |c|
233
+ (edges[c] ||= Set.new).merge(m["effects"])
234
+ end
235
+ end
236
+ b_cause = {}
237
+ parent["causes"].each { |c| b_cause[c] = bridge_closure(c, bridges) }
238
+ b_effect = {}
239
+ parent["effects"].each { |e| b_effect[e] = bridge_closure(e, bridges) }
240
+ parent["causes"].each do |c|
241
+ parent["effects"].each do |e|
242
+ connected = b_cause[c].any? do |cp|
243
+ b_effect[e].any? { |ep| path_exists(edges, cp, ep) }
244
+ end
245
+ return "inconsistent" unless connected
246
+ end
247
+ end
248
+ "consistent"
249
+ end
250
+
251
+ # ALGORITHM C (Rule 15): "intra_stratal" | "adjacent_stratal" |
252
+ # "skipping" | "mixed" | "unclassifiable" | "scheme_mismatch".
253
+ # Derived, never asserted; recompute on ingest (N12.3.1).
254
+ def classify_cro(cro, occ_map, stratum_map)
255
+ stratum_of = lambda { |occ_id| (occ_map[occ_id] || {})["stratum"] }
256
+ cause_strata = cro["causes"].map { |c| stratum_of.call(c) }
257
+ effect_strata = cro["effects"].map { |e| stratum_of.call(e) }
258
+ if (cause_strata + effect_strata).any?(&:nil?)
259
+ return "unclassifiable" # surface unstratified_occurrent (invitation)
260
+ end
261
+ all_strata = Set.new(cause_strata) | Set.new(effect_strata)
262
+ schemes = Set.new(all_strata.map { |s| stratum_map[s]["scheme"] })
263
+ return "scheme_mismatch" if schemes.length > 1 # HARD
264
+ c_ord = cause_strata.map { |s| stratum_map[s]["ordinal"] }
265
+ e_ord = effect_strata.map { |s| stratum_map[s]["ordinal"] }
266
+ if c_ord.max == c_ord.min && c_ord.min == e_ord.max && e_ord.max == e_ord.min
267
+ return "intra_stratal"
268
+ end
269
+ pairs = c_ord.product(e_ord).map { |i, j| (i - j).abs }
270
+ gap = pairs.min
271
+ span = pairs.max
272
+ return "adjacent_stratal" if span == 1
273
+ return "skipping" if gap > 1
274
+ "mixed" # some pairs adjacent, some skipping
275
+ end
276
+
277
+ # True iff causes or effects span more than one distinct stratum
278
+ # (surfaces mixed_stratal_endpoints, an invitation; N12.3.2).
279
+ def endpoints_mixed(cro, occ_map)
280
+ stratum_of = lambda { |occ_id| (occ_map[occ_id] || {})["stratum"] }
281
+ cs = Set.new(cro["causes"].map { |c| stratum_of.call(c) })
282
+ es = Set.new(cro["effects"].map { |e| stratum_of.call(e) })
283
+ return false if cs.include?(nil) || es.include?(nil)
284
+ cs.length > 1 || es.length > 1
285
+ end
286
+
287
+ # ALGORITHM D (Rule 16): the gaps a Causal Relation Object surfaces for the
288
+ # skip decision. THE ASYMMETRY (clause 3) is the whole point of the field
289
+ # and is implemented exactly.
290
+ def skip_gaps(cro, classification)
291
+ gaps = []
292
+ has_mech = !(cro["mechanism"] || []).empty?
293
+ if cro["skips"] == true && has_mech
294
+ gaps << "contradictory_skip" # HARD
295
+ return gaps
296
+ end
297
+ if cro["skips"] == true &&
298
+ !["skipping", "unclassifiable"].include?(classification)
299
+ gaps << "vacuous_skip" # invitation
300
+ end
301
+ if classification == "skipping" && !has_mech
302
+ if cro["skips"] == true
303
+ # NOTHING: absence is a finding
304
+ else
305
+ gaps << "incomplete_mechanism" # invitation
306
+ end
307
+ end
308
+ gaps
309
+ end
310
+
311
+ # ALGORITHM E helper: normalize a delay to seconds by the fixed table.
312
+ def to_seconds(duration, unit)
313
+ return 0 if unit == "instant"
314
+ duration * UNIT_SECONDS.fetch(unit)
315
+ end
316
+
317
+ # ALGORITHM E (Rule 20): does an observed delay fall within a covering
318
+ # law's temporal window? Inclusive at both ends (N12.5.2).
319
+ def delay_within_window(actual_delay, temporal)
320
+ return true if actual_delay.nil? || actual_delay.empty? ||
321
+ temporal.nil? || temporal.empty?
322
+ observed = to_seconds(actual_delay["duration"], actual_delay["unit"])
323
+ lo = to_seconds(temporal["minimum_delay"], temporal["unit"])
324
+ hi = to_seconds(temporal["maximum_delay"], temporal["unit"])
325
+ lo <= observed && observed <= hi
326
+ end
327
+
328
+ # Rule 14 / N3.2.1: Bridge well-formedness. [ok, reason]. All of (a)-(e)
329
+ # of N3.2.1 must hold, else malformed_bridge.
330
+ def bridge_wellformed(bridge, occ_map, stratum_map)
331
+ coarse = occ_map[bridge["coarse"]] || {}
332
+ cs = coarse["stratum"]
333
+ return [false, "malformed_bridge: coarse has no stratum (a)"] if cs.nil?
334
+ fine_strata = bridge["fine"].map { |f| (occ_map[f] || {})["stratum"] }
335
+ if fine_strata.any?(&:nil?)
336
+ return [false, "malformed_bridge: a fine member has no stratum (b)"]
337
+ end
338
+ if Set.new(fine_strata).length != 1
339
+ return [false, "malformed_bridge: fine members span >1 stratum (c)"]
340
+ end
341
+ fs = fine_strata[0]
342
+ if stratum_map[cs]["scheme"] != stratum_map[fs]["scheme"]
343
+ return [false, "malformed_bridge: coarse and fine differ in scheme (d)"]
344
+ end
345
+ unless stratum_map[cs]["ordinal"] > stratum_map[fs]["ordinal"]
346
+ return [false, "malformed_bridge: coarse ordinal not > fine ordinal (e)"]
347
+ end
348
+ [true, "well-formed bridge"]
349
+ end
350
+
351
+ # Rule 17 / N4.2.1-2: Conduit well-formedness. [ok, reason]. N4.2.1 with
352
+ # the transform exception of N4.2.2.
353
+ def conduit_wellformed(conduit, port_map, cro_map = nil)
354
+ frm = port_map[conduit["from"]]
355
+ to = port_map[conduit["to"]]
356
+ if frm.nil? || to.nil?
357
+ return [false, "malformed_conduit: dangling port reference"]
358
+ end
359
+ unless ["out", "bidirectional"].include?(frm["direction"])
360
+ return [false, "malformed_conduit: from port is not out/bidirectional (a)"]
361
+ end
362
+ unless ["in", "bidirectional"].include?(to["direction"])
363
+ return [false, "malformed_conduit: to port is not in/bidirectional (b)"]
364
+ end
365
+ carries = conduit["carries"]
366
+ unless carries.all? { |o| frm["accepts"].include?(o) }
367
+ return [false, "malformed_conduit: carries not accepted by from (c)"]
368
+ end
369
+ transform = conduit["transform"]
370
+ if transform.nil?
371
+ unless carries.all? { |o| to["accepts"].include?(o) }
372
+ return [false, "malformed_conduit: carries not accepted by to (d)"]
373
+ end
374
+ else
375
+ law = (cro_map || {})[transform]
376
+ unless law.nil?
377
+ unless law["effects"].all? { |o| to["accepts"].include?(o) }
378
+ return [false, "malformed_conduit: transform effects not " \
379
+ "accepted by to (d, relaxed per N4.2.2)"]
380
+ end
381
+ end
382
+ end
383
+ [true, "well-formed conduit"]
384
+ end
385
+
386
+ # Rule 19 / N5.3.1-2: the HARD gaps a state assertion surfaces against its
387
+ # quality: value_type_mismatch and/or unit_mismatch.
388
+ def state_gaps(state, quality)
389
+ gaps = []
390
+ dt = quality["datatype"]
391
+ v = state["value"] || {}
392
+ shape = if v.key?("quantity") then "quantity"
393
+ elsif v.key?("categorical") then "categorical"
394
+ elsif v.key?("boolean") then "boolean"
395
+ end
396
+ if shape != dt
397
+ gaps << "value_type_mismatch"
398
+ elsif dt == "quantity" && v["unit"] != quality["unit"]
399
+ gaps << "unit_mismatch"
400
+ end
401
+ gaps
402
+ end
403
+
404
+ # Rule 20: true iff the token claim's cause/effect tokens do not
405
+ # instantiate the covering law's causes/effects (surfaces
406
+ # covering_law_mismatch).
407
+ def covering_law_mismatch(tcc, token_map, law)
408
+ return false if law.nil?
409
+ law_causes = Set.new(law["causes"])
410
+ law_effects = Set.new(law["effects"])
411
+ tcc["causes"].each do |c|
412
+ return true unless law_causes.include?(token_map[c]["instantiates"])
413
+ end
414
+ tcc["effects"].each do |e|
415
+ return true unless law_effects.include?(token_map[e]["instantiates"])
416
+ end
417
+ false
418
+ end
419
+
420
+ # Rule 21: true iff any cause token starts after any effect token (HARD;
421
+ # retrocausal_claim). RFC 3339 UTC "Z" strings compare lexicographically.
422
+ def retrocausal(tcc, token_map)
423
+ tcc["causes"].each do |c|
424
+ cstart = token_map[c]["interval"]["start"]
425
+ tcc["effects"].each do |e|
426
+ estart = token_map[e]["interval"]["start"]
427
+ return true if cstart > estart
428
+ end
429
+ end
430
+ false
431
+ end
432
+
433
+ # Rules 4 / 6.1: true iff a directed graph (node -> iterable of successors)
434
+ # has a cycle. Used for bridge graph, occurrent_subsumes, occurrent_part_of,
435
+ # and token mereology (part_of).
436
+ def has_cycle(edges)
437
+ white = 0
438
+ grey = 1
439
+ black = 2
440
+ state = {}
441
+ visit = nil
442
+ visit = lambda do |node|
443
+ state[node] = grey
444
+ (edges[node] || []).each do |nxt|
445
+ s = state.fetch(nxt, white)
446
+ return true if s == grey
447
+ return true if s == white && visit.call(nxt)
448
+ end
449
+ state[node] = black
450
+ false
451
+ end
452
+ edges.keys.any? { |n| state.fetch(n, white) == white && visit.call(n) }
453
+ end
454
+ end
455
+ 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