unidpp 0.1.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/lib/unidpp/annexb.rb +296 -0
- data/lib/unidpp/canonical_json.rb +37 -0
- data/lib/unidpp/event.rb +103 -0
- data/lib/unidpp/event_log.rb +225 -0
- data/lib/unidpp/gs1.rb +105 -0
- data/lib/unidpp/identifier.rb +240 -0
- data/lib/unidpp/link.rb +197 -0
- data/lib/unidpp/manifest.rb +328 -0
- data/lib/unidpp/quantity.rb +150 -0
- data/lib/unidpp/registry/applicability.rb +66 -0
- data/lib/unidpp/registry/client.rb +141 -0
- data/lib/unidpp/registry/file_store.rb +121 -0
- data/lib/unidpp/registry/item.rb +132 -0
- data/lib/unidpp/registry/item_version.rb +66 -0
- data/lib/unidpp/registry/register.rb +36 -0
- data/lib/unidpp/registry/store.rb +26 -0
- data/lib/unidpp/registry.rb +21 -0
- data/lib/unidpp/verdict.rb +72 -0
- data/lib/unidpp/version.rb +5 -0
- data/lib/unidpp.rb +31 -0
- metadata +108 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unidpp
|
|
4
|
+
# Subject capability classes (I8: twin testimonial by default, sensorial
|
|
5
|
+
# by exception). A profile's requirements must be satisfiable by the
|
|
6
|
+
# class its subjects belong to — demanding live freshness from an S0
|
|
7
|
+
# product is an unsatisfiable profile.
|
|
8
|
+
#
|
|
9
|
+
# S0 silent testimony-only (no connectivity, no keys)
|
|
10
|
+
# S1 passive-auth NFC chip / PUF / IEC 61406 identity link
|
|
11
|
+
# S2 logged-contact dumps on physical read, no comms
|
|
12
|
+
# S3 connected full edge segments
|
|
13
|
+
module CapabilityClass
|
|
14
|
+
ORDER = %w[S0 S1 S2 S3].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param required [String] profile's minimum required class
|
|
19
|
+
# @param actual [String] the subject's capability class
|
|
20
|
+
# @return [Boolean] whether +actual+ meets or exceeds +required+
|
|
21
|
+
def satisfies?(required, actual)
|
|
22
|
+
ORDER.index(actual) >= ORDER.index(required)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Applicability axis values of a profile: composition is three-axial —
|
|
27
|
+
# jurisdiction x sector x characteristic (I10). Characteristic profiles
|
|
28
|
+
# attach by predicate on twin facts (age, material content, heritage
|
|
29
|
+
# status, market status) rather than by law-of-place or industry.
|
|
30
|
+
class ProfileAxes < Lutaml::Model::Serializable
|
|
31
|
+
attribute :jurisdictions, :string, collection: true, default: -> { [] }
|
|
32
|
+
attribute :sectors, :string, collection: true, default: -> { [] }
|
|
33
|
+
attribute :characteristics, :string, collection: true, default: -> { [] }
|
|
34
|
+
|
|
35
|
+
json do
|
|
36
|
+
map "jurisdictions", to: :jurisdictions, render_nil: false
|
|
37
|
+
map "sectors", to: :sectors, render_nil: false
|
|
38
|
+
map "characteristics", to: :characteristics, render_nil: false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
yaml do
|
|
42
|
+
map "jurisdictions", to: :jurisdictions, render_nil: false
|
|
43
|
+
map "sectors", to: :sectors, render_nil: false
|
|
44
|
+
map "characteristics", to: :characteristics, render_nil: false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty?
|
|
48
|
+
jurisdictions.empty? && sectors.empty? && characteristics.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One predicate of a profile's trigger (I10 / I12). Predicates are
|
|
53
|
+
# evaluated against twin facts by the subject's custodian — locally,
|
|
54
|
+
# without enumeration. Time predicates are clock-fired: an object
|
|
55
|
+
# becoming >100 years old is an applicability event no human declares.
|
|
56
|
+
class TriggerPredicate < Lutaml::Model::Serializable
|
|
57
|
+
OPERATORS = %w[eq neq gt gte lt lte contains exists before after].freeze
|
|
58
|
+
# Fact-path suffixes that make a predicate time-derived (clock-fired).
|
|
59
|
+
TIME_SUBJECT_SUFFIXES = %w[_at _date _years _age].freeze
|
|
60
|
+
|
|
61
|
+
attribute :subject, :string # fact path, e.g. "object.age_years"
|
|
62
|
+
attribute :operator, :string, values: OPERATORS
|
|
63
|
+
attribute :value, :string
|
|
64
|
+
attribute :cites, :string # clause-level provenance URN
|
|
65
|
+
|
|
66
|
+
json do
|
|
67
|
+
map "subject", to: :subject
|
|
68
|
+
map "operator", to: :operator
|
|
69
|
+
map "value", to: :value, render_nil: false
|
|
70
|
+
map "cites", to: :cites, render_nil: false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
yaml do
|
|
74
|
+
map "subject", to: :subject
|
|
75
|
+
map "operator", to: :operator
|
|
76
|
+
map "value", to: :value, render_nil: false
|
|
77
|
+
map "cites", to: :cites, render_nil: false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] whether this predicate is time-derived and can
|
|
81
|
+
# fire on the passage of time alone
|
|
82
|
+
def time_fired?
|
|
83
|
+
time_operator? ||
|
|
84
|
+
TIME_SUBJECT_SUFFIXES.any? { |suffix| subject.to_s.end_with?(suffix) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Evaluates the predicate against a facts document (a Hash with
|
|
88
|
+
# dotted-path-resolvable keys, e.g. {"object" => {"age_years" => 120}}).
|
|
89
|
+
#
|
|
90
|
+
# @param facts [Hash]
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def matches?(facts)
|
|
93
|
+
actual = TriggerPredicate.resolve_path(facts, subject)
|
|
94
|
+
return operator == "exists" ? !actual.nil? : false if actual.nil?
|
|
95
|
+
|
|
96
|
+
case operator
|
|
97
|
+
when "exists" then true
|
|
98
|
+
when "eq" then compare_eq(actual)
|
|
99
|
+
when "neq" then !compare_eq(actual)
|
|
100
|
+
when "contains" then contains_match?(actual)
|
|
101
|
+
when "gt", "gte", "lt", "lte" then numeric_compare(actual)
|
|
102
|
+
when "before", "after" then time_compare(actual)
|
|
103
|
+
else false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String] human-readable summary, for drift/conformance
|
|
108
|
+
# reports
|
|
109
|
+
def description
|
|
110
|
+
"#{subject} #{operator} #{value}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.resolve_path(facts, path)
|
|
114
|
+
path.to_s.split(".").reduce(facts) do |node, key|
|
|
115
|
+
break nil unless node.is_a?(Hash) || node.is_a?(Array)
|
|
116
|
+
|
|
117
|
+
node.is_a?(Array) ? node.map { |e| e[key] if e.is_a?(Hash) }.compact : node[key]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def time_operator?
|
|
124
|
+
%w[before after].include?(operator)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def compare_eq(actual)
|
|
128
|
+
if numeric?(actual) && numeric?(value)
|
|
129
|
+
actual.to_f == value.to_f
|
|
130
|
+
else
|
|
131
|
+
actual.to_s == value.to_s
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# contains-match against array or scalar facts (distinct from the
|
|
136
|
+
# lutaml-generated +contains?+ enum predicate on the operator).
|
|
137
|
+
def contains_match?(actual)
|
|
138
|
+
if actual.is_a?(Array)
|
|
139
|
+
actual.map(&:to_s).include?(value.to_s)
|
|
140
|
+
else
|
|
141
|
+
actual.to_s.include?(value.to_s)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def numeric_compare(actual)
|
|
146
|
+
return false unless numeric?(actual) && numeric?(value)
|
|
147
|
+
|
|
148
|
+
a = actual.to_f
|
|
149
|
+
v = value.to_f
|
|
150
|
+
case operator
|
|
151
|
+
when "gt" then a > v
|
|
152
|
+
when "gte" then a >= v
|
|
153
|
+
when "lt" then a < v
|
|
154
|
+
when "lte" then a <= v
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def time_compare(actual)
|
|
159
|
+
a = to_time(actual)
|
|
160
|
+
v = to_time(value)
|
|
161
|
+
return false if a.nil? || v.nil?
|
|
162
|
+
|
|
163
|
+
operator == "before" ? a < v : a > v
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def numeric?(v)
|
|
167
|
+
Float(v.to_s) != nil
|
|
168
|
+
rescue ArgumentError, TypeError
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def to_time(v)
|
|
173
|
+
return v if v.is_a?(Time) || v.is_a?(DateTime) || v.is_a?(Date)
|
|
174
|
+
|
|
175
|
+
DateTime.parse(v.to_s)
|
|
176
|
+
rescue ArgumentError, TypeError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Binding of a cryptographic suite to a profile (I9/I10: jurisdictional
|
|
182
|
+
# crypto suites are part of the profile, not the platform). The same
|
|
183
|
+
# payload carries multiple signatures; verifiers check only what their
|
|
184
|
+
# profile-scoped acceptance policy requires.
|
|
185
|
+
class CryptoSuiteBinding < Lutaml::Model::Serializable
|
|
186
|
+
USES = %w[sign hash kem transport].freeze
|
|
187
|
+
PHASES = %w[primary composite post-quantum].freeze
|
|
188
|
+
|
|
189
|
+
attribute :suite_id, :string # registry item id (crypto-suite subregister)
|
|
190
|
+
attribute :jurisdiction, :string
|
|
191
|
+
attribute :use, :string, values: USES
|
|
192
|
+
attribute :phase, :string, values: PHASES, default: -> { "primary" }
|
|
193
|
+
|
|
194
|
+
json do
|
|
195
|
+
map "suite_id", to: :suite_id
|
|
196
|
+
map "jurisdiction", to: :jurisdiction
|
|
197
|
+
map "use", to: :use
|
|
198
|
+
map "phase", to: :phase
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
yaml do
|
|
202
|
+
map "suite_id", to: :suite_id
|
|
203
|
+
map "jurisdiction", to: :jurisdiction
|
|
204
|
+
map "use", to: :use
|
|
205
|
+
map "phase", to: :phase
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def post_quantum?
|
|
209
|
+
phase == "post-quantum"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The profile manifest (L2 / I10): a profile is a registered, versioned
|
|
214
|
+
# lens placed on the neutral core. It binds axes (jurisdiction x sector
|
|
215
|
+
# x characteristic), trigger predicates, the minimum subject capability
|
|
216
|
+
# class it can be served for, its crypto-suite bindings, the data points
|
|
217
|
+
# it requires (references into the semantic registry), languages, and
|
|
218
|
+
# its legal effective window. Registered transforms and trust
|
|
219
|
+
# requirements ride the registry items referenced here.
|
|
220
|
+
#
|
|
221
|
+
# (De)serialization is generated by lutaml-model from the attribute +
|
|
222
|
+
# mapping declarations below — no hand-rolled to_h / from_h.
|
|
223
|
+
class ProfileManifest < Lutaml::Model::Serializable
|
|
224
|
+
CAPABILITY_CLASSES = CapabilityClass::ORDER
|
|
225
|
+
|
|
226
|
+
attribute :profile_id, :string # registry item identifier
|
|
227
|
+
attribute :version, :string # registry item version pin
|
|
228
|
+
attribute :register, :string # FERIN register that holds the profile
|
|
229
|
+
attribute :owner, :string # regulator / profile owner
|
|
230
|
+
attribute :legal_basis, :string # citation URI of the applicable law
|
|
231
|
+
attribute :axes, ProfileAxes
|
|
232
|
+
attribute :triggers, TriggerPredicate, collection: true, default: -> { [] }
|
|
233
|
+
attribute :capability_class, :string, values: CAPABILITY_CLASSES,
|
|
234
|
+
default: -> { "S0" }
|
|
235
|
+
attribute :crypto_suites, CryptoSuiteBinding, collection: true,
|
|
236
|
+
default: -> { [] }
|
|
237
|
+
# Data points required by the profile: (register, item, version)
|
|
238
|
+
# references into the semantic registry, as resolvable URIs.
|
|
239
|
+
attribute :data_points, :string, collection: true, default: -> { [] }
|
|
240
|
+
attribute :languages, :string, collection: true, default: -> { [] }
|
|
241
|
+
attribute :effective_from, "Lutaml::Model::Type::DateTime"
|
|
242
|
+
attribute :effective_until, "Lutaml::Model::Type::DateTime"
|
|
243
|
+
# Resolution / traversal exposure (I12): confidential profiles are
|
|
244
|
+
# non-public register items; resolution scope limits the resolver.
|
|
245
|
+
attribute :resolution, :string,
|
|
246
|
+
values: %w[none national restricted public],
|
|
247
|
+
default: -> { "public" }
|
|
248
|
+
attribute :confidential, :boolean, default: -> { false }
|
|
249
|
+
|
|
250
|
+
json do
|
|
251
|
+
map "profile_id", to: :profile_id
|
|
252
|
+
map "version", to: :version
|
|
253
|
+
map "register", to: :register, render_nil: false
|
|
254
|
+
map "owner", to: :owner, render_nil: false
|
|
255
|
+
map "legal_basis", to: :legal_basis, render_nil: false
|
|
256
|
+
map "axes", to: :axes, render_nil: false
|
|
257
|
+
map "triggers", to: :triggers, render_nil: false
|
|
258
|
+
map "capability_class", to: :capability_class
|
|
259
|
+
map "crypto_suites", to: :crypto_suites, render_nil: false
|
|
260
|
+
map "data_points", to: :data_points, render_nil: false
|
|
261
|
+
map "languages", to: :languages, render_nil: false
|
|
262
|
+
map "effective_from", to: :effective_from, render_nil: false
|
|
263
|
+
map "effective_until", to: :effective_until, render_nil: false
|
|
264
|
+
map "resolution", to: :resolution
|
|
265
|
+
map "confidential", to: :confidential
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
yaml do
|
|
269
|
+
map "profile_id", to: :profile_id
|
|
270
|
+
map "version", to: :version
|
|
271
|
+
map "register", to: :register, render_nil: false
|
|
272
|
+
map "owner", to: :owner, render_nil: false
|
|
273
|
+
map "legal_basis", to: :legal_basis, render_nil: false
|
|
274
|
+
map "axes", to: :axes, render_nil: false
|
|
275
|
+
map "triggers", to: :triggers, render_nil: false
|
|
276
|
+
map "capability_class", to: :capability_class
|
|
277
|
+
map "crypto_suites", to: :crypto_suites, render_nil: false
|
|
278
|
+
map "data_points", to: :data_points, render_nil: false
|
|
279
|
+
map "languages", to: :languages, render_nil: false
|
|
280
|
+
map "effective_from", to: :effective_from, render_nil: false
|
|
281
|
+
map "effective_until", to: :effective_until, render_nil: false
|
|
282
|
+
map "resolution", to: :resolution
|
|
283
|
+
map "confidential", to: :confidential
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# A profile with no trigger predicates applies to every subject of
|
|
287
|
+
# its axes; otherwise every predicate must match (conjunction).
|
|
288
|
+
#
|
|
289
|
+
# @param facts [Hash] twin facts document
|
|
290
|
+
# @return [Boolean]
|
|
291
|
+
def applies_to?(facts)
|
|
292
|
+
triggers.all? { |predicate| predicate.matches?(facts) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param subject_capability [String] S0..S3 of the subject
|
|
296
|
+
# @return [Boolean] satisfiability of this profile for that class
|
|
297
|
+
def satisfiable_for?(subject_capability)
|
|
298
|
+
CapabilityClass.satisfies?(capability_class, subject_capability)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Legal effective window at time +t+.
|
|
302
|
+
# @param t [Time, DateTime]
|
|
303
|
+
# @return [Boolean]
|
|
304
|
+
def effective_at?(t)
|
|
305
|
+
t = to_datetime(t)
|
|
306
|
+
(!effective_from || t >= effective_from) &&
|
|
307
|
+
(!effective_until || t < effective_until)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def jurisdiction_profile?
|
|
311
|
+
!axes.jurisdictions.empty?
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def characteristic_profile?
|
|
315
|
+
!axes.characteristics.empty?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def time_triggered?
|
|
319
|
+
triggers.any?(&:time_fired?)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
def to_datetime(t)
|
|
325
|
+
t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Unidpp
|
|
6
|
+
# Quantity = value + registered unit URI (the units chain: ISO 80000 ->
|
|
7
|
+
# UnitsDB/UnitsML -> OpenCDD mirror). Units are *registered URIs*, not
|
|
8
|
+
# bare symbols; unit conversion is a registered transform (deterministic
|
|
9
|
+
# factors from the registry), so this class never converts implicitly —
|
|
10
|
+
# arithmetic requires identical unit URIs and raises on mismatch. The
|
|
11
|
+
# twin stores canonical measured values in canonical units only; no
|
|
12
|
+
# pre-transformed duplicates.
|
|
13
|
+
#
|
|
14
|
+
# Measurement uncertainty is mandatory metadata on measured values
|
|
15
|
+
# (published MTF): it is carried as a standard (k=1) uncertainty with
|
|
16
|
+
# its coverage factor, and conformity decisions near a boundary follow
|
|
17
|
+
# the ILAC-G8/JCGM-106 shared-risk rule (measured values agree when
|
|
18
|
+
# |difference| <= expanded combined uncertainty).
|
|
19
|
+
#
|
|
20
|
+
# (De)serialization is generated by lutaml-model from the attribute +
|
|
21
|
+
# mapping declarations below — no hand-rolled to_h / from_h.
|
|
22
|
+
class Quantity < Lutaml::Model::Serializable
|
|
23
|
+
class UnitMismatchError < Unidpp::Error; end
|
|
24
|
+
|
|
25
|
+
# Standard uncertainty with coverage factor.
|
|
26
|
+
class Uncertainty < Lutaml::Model::Serializable
|
|
27
|
+
attribute :value, :decimal
|
|
28
|
+
attribute :coverage_factor, :decimal, default: -> { BigDecimal("2") }
|
|
29
|
+
|
|
30
|
+
json do
|
|
31
|
+
map "value", to: :value
|
|
32
|
+
map "coverage_factor", to: :coverage_factor
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
yaml do
|
|
36
|
+
map "value", to: :value
|
|
37
|
+
map "coverage_factor", to: :coverage_factor
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [BigDecimal] expanded uncertainty U = k * u
|
|
41
|
+
def expanded
|
|
42
|
+
value * coverage_factor
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attribute :value, :decimal
|
|
47
|
+
# Registered unit URI, e.g. "https://unitsml.org/unit#kWh" or the
|
|
48
|
+
# UnitsDB identifier URI for the unit.
|
|
49
|
+
attribute :unit, :string
|
|
50
|
+
attribute :uncertainty, Uncertainty
|
|
51
|
+
|
|
52
|
+
json do
|
|
53
|
+
map "value", to: :value
|
|
54
|
+
map "unit", to: :unit
|
|
55
|
+
map "uncertainty", to: :uncertainty, render_nil: false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
yaml do
|
|
59
|
+
map "value", to: :value
|
|
60
|
+
map "unit", to: :unit
|
|
61
|
+
map "uncertainty", to: :uncertainty, render_nil: false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# -- strict-unit arithmetic ------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def +(other)
|
|
67
|
+
assert_same_unit(other)
|
|
68
|
+
Quantity.new(value: value + other.value, unit: unit,
|
|
69
|
+
uncertainty: combine_uncertainty(other))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def -(other)
|
|
73
|
+
assert_same_unit(other)
|
|
74
|
+
Quantity.new(value: value - other.value, unit: unit,
|
|
75
|
+
uncertainty: combine_uncertainty(other))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Scalar multiple (unit unchanged); scaling does not apply to
|
|
79
|
+
# uncertainties.
|
|
80
|
+
def *(factor)
|
|
81
|
+
Quantity.new(value: value * factor, unit: unit)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def zero?
|
|
85
|
+
value.zero?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def negative?
|
|
89
|
+
value.negative?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def positive?
|
|
93
|
+
value.positive?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param other [Quantity]
|
|
97
|
+
# @return [Boolean] same registered unit URI
|
|
98
|
+
def compatible_with?(other)
|
|
99
|
+
unit == other.unit
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ILAC-G8 shared-risk conformity decision: two measured values agree
|
|
103
|
+
# when their absolute difference is within the expanded combined
|
|
104
|
+
# uncertainty (U = k * sqrt(u1^2 + u2^2), k = the larger coverage
|
|
105
|
+
# factor). Values without uncertainties agree only on exact equality.
|
|
106
|
+
#
|
|
107
|
+
# @param other [Quantity]
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def agrees_with?(other)
|
|
110
|
+
assert_same_unit(other)
|
|
111
|
+
return value == other.value unless uncertainty && other.uncertainty
|
|
112
|
+
|
|
113
|
+
(value - other.value).abs <= expanded_combined_uncertainty(other)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param other [Quantity]
|
|
117
|
+
# @return [BigDecimal] k * sqrt(u1^2 + u2^2), nil when uncertainless
|
|
118
|
+
def expanded_combined_uncertainty(other)
|
|
119
|
+
return nil unless uncertainty && other.uncertainty
|
|
120
|
+
|
|
121
|
+
k = [uncertainty.coverage_factor,
|
|
122
|
+
other.uncertainty.coverage_factor].max
|
|
123
|
+
sum_of_squares = uncertainty.value * uncertainty.value +
|
|
124
|
+
other.uncertainty.value * other.uncertainty.value
|
|
125
|
+
k * BigDecimal(sum_of_squares.sqrt(10))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def assert_same_unit(other)
|
|
131
|
+
return if compatible_with?(other)
|
|
132
|
+
|
|
133
|
+
raise UnitMismatchError,
|
|
134
|
+
"unit mismatch: #{unit} vs #{other.unit} — convert through a " \
|
|
135
|
+
"registered transform, never implicitly"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def combine_uncertainty(other)
|
|
139
|
+
return nil unless uncertainty && other.uncertainty
|
|
140
|
+
|
|
141
|
+
Uncertainty.new(
|
|
142
|
+
value: BigDecimal(Math.sqrt(
|
|
143
|
+
(uncertainty.value * uncertainty.value +
|
|
144
|
+
other.uncertainty.value * other.uncertainty.value).to_f
|
|
145
|
+
)),
|
|
146
|
+
coverage_factor: uncertainty.coverage_factor
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unidpp
|
|
4
|
+
module Registry
|
|
5
|
+
# Applicability binding (source invariant 9): profile-set growth is
|
|
6
|
+
# dated binding, not new identity. Adding a profile to an existing
|
|
7
|
+
# subject is a registry applicability event with an effective window
|
|
8
|
+
# and a retroactivity flag; manifest history is as-of-reconstructable.
|
|
9
|
+
#
|
|
10
|
+
# Retroactivity semantics: a retroactive binding legally backdates —
|
|
11
|
+
# it applies from its effective_from even though registered later
|
|
12
|
+
# (end-of-waste style re-qualification, corrected as-of state). A
|
|
13
|
+
# non-retroactive binding cannot impose obligations for times before
|
|
14
|
+
# the authority declared it (registered_at).
|
|
15
|
+
class ApplicabilityBinding < Lutaml::Model::Serializable
|
|
16
|
+
attribute :subject, :string # canonical product/type identity
|
|
17
|
+
attribute :profile_item, :string # registry item identifier
|
|
18
|
+
attribute :register, :string # register holding the profile
|
|
19
|
+
attribute :profile_version, :string
|
|
20
|
+
attribute :effective_from, "Lutaml::Model::Type::DateTime"
|
|
21
|
+
attribute :effective_until, "Lutaml::Model::Type::DateTime"
|
|
22
|
+
attribute :registered_at, "Lutaml::Model::Type::DateTime"
|
|
23
|
+
attribute :retroactive, :boolean, default: -> { false }
|
|
24
|
+
|
|
25
|
+
json do
|
|
26
|
+
map "subject", to: :subject
|
|
27
|
+
map "profile_item", to: :profile_item
|
|
28
|
+
map "register", to: :register, render_nil: false
|
|
29
|
+
map "profile_version", to: :profile_version, render_nil: false
|
|
30
|
+
map "effective_from", to: :effective_from, render_nil: false
|
|
31
|
+
map "effective_until", to: :effective_until, render_nil: false
|
|
32
|
+
map "registered_at", to: :registered_at, render_nil: false
|
|
33
|
+
map "retroactive", to: :retroactive
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
yaml do
|
|
37
|
+
map "subject", to: :subject
|
|
38
|
+
map "profile_item", to: :profile_item
|
|
39
|
+
map "register", to: :register, render_nil: false
|
|
40
|
+
map "profile_version", to: :profile_version, render_nil: false
|
|
41
|
+
map "effective_from", to: :effective_from, render_nil: false
|
|
42
|
+
map "effective_until", to: :effective_until, render_nil: false
|
|
43
|
+
map "registered_at", to: :registered_at, render_nil: false
|
|
44
|
+
map "retroactive", to: :retroactive
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Legal as-of semantics.
|
|
48
|
+
#
|
|
49
|
+
# @param t [Time, DateTime]
|
|
50
|
+
# @return [Boolean] the binding applied to the subject at +t+
|
|
51
|
+
def applies_at?(t)
|
|
52
|
+
t = t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
|
|
53
|
+
return false if effective_from && t < effective_from
|
|
54
|
+
return false if effective_until && t >= effective_until
|
|
55
|
+
return false if !retroactive? && registered_at && t < registered_at
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def retroactive?
|
|
62
|
+
retroactive == true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unidpp
|
|
4
|
+
module Registry
|
|
5
|
+
# Registry client: item lookup with version+status, supersession
|
|
6
|
+
# resolution, and applicability as-of queries over any Store. The
|
|
7
|
+
# client is read-only — registration (proposals, public review,
|
|
8
|
+
# control-body decisions) belongs to the register operator's own
|
|
9
|
+
# tooling; consumers resolve references.
|
|
10
|
+
class Client
|
|
11
|
+
class ItemNotFoundError < Unidpp::Error; end
|
|
12
|
+
class NoValidVersionError < Unidpp::Error; end
|
|
13
|
+
|
|
14
|
+
attr_reader :store
|
|
15
|
+
|
|
16
|
+
# @param store [Store]
|
|
17
|
+
def initialize(store:)
|
|
18
|
+
@store = store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Register, nil]
|
|
22
|
+
def register
|
|
23
|
+
store.register
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<Item>]
|
|
27
|
+
def items
|
|
28
|
+
store.items
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param register [String, nil] optional register scope
|
|
32
|
+
# @param identifier [String] item identifier
|
|
33
|
+
# @return [Item, nil]
|
|
34
|
+
def item(identifier, register: nil)
|
|
35
|
+
store.items.find do |i|
|
|
36
|
+
i.identifier == identifier && (register.nil? || i.register == register)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param identifier [String]
|
|
41
|
+
# @return [Item]
|
|
42
|
+
# @raise [ItemNotFoundError]
|
|
43
|
+
def item!(identifier, register: nil)
|
|
44
|
+
scope = register ? " in register #{register.inspect}" : ""
|
|
45
|
+
item(identifier, register: register) ||
|
|
46
|
+
raise(ItemNotFoundError,
|
|
47
|
+
"no registry item #{identifier.inspect}#{scope}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Version-pinned lookup: the exact version when given, otherwise the
|
|
51
|
+
# version currently in force.
|
|
52
|
+
#
|
|
53
|
+
# @param identifier [String]
|
|
54
|
+
# @param version [String, nil]
|
|
55
|
+
# @return [ItemVersion]
|
|
56
|
+
# @raise [ItemNotFoundError, Item::UnknownVersionError]
|
|
57
|
+
def version_of(identifier, version: nil, register: nil)
|
|
58
|
+
found = item!(identifier, register: register)
|
|
59
|
+
version ? found.version!(version) : current_version_of(found)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param item [Item]
|
|
63
|
+
# @return [ItemVersion]
|
|
64
|
+
# @raise [NoValidVersionError] when no version is in force
|
|
65
|
+
def current_version_of(item)
|
|
66
|
+
item.current_version ||
|
|
67
|
+
raise(NoValidVersionError, "#{item.identifier} has no valid version")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Supersession resolution: the terminal version of the chain.
|
|
71
|
+
#
|
|
72
|
+
# @param identifier [String]
|
|
73
|
+
# @return [ItemVersion]
|
|
74
|
+
# @raise [ItemNotFoundError]
|
|
75
|
+
def resolve(identifier, register: nil)
|
|
76
|
+
supersession_chain(identifier, register: register).last
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param identifier [String]
|
|
80
|
+
# @param from [String, nil] chain start version
|
|
81
|
+
# @return [Array<ItemVersion>] chain in supersession order
|
|
82
|
+
# @raise [ItemNotFoundError]
|
|
83
|
+
def supersession_chain(identifier, from: nil, register: nil)
|
|
84
|
+
item!(identifier, register: register).supersession_chain(from: from)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Point-in-time item state: the version in force at +t+.
|
|
88
|
+
#
|
|
89
|
+
# @param identifier [String]
|
|
90
|
+
# @param as_of [Time, DateTime]
|
|
91
|
+
# @return [ItemVersion, nil]
|
|
92
|
+
# @raise [ItemNotFoundError]
|
|
93
|
+
def version_as_of(identifier, as_of:, register: nil)
|
|
94
|
+
item!(identifier, register: register).in_force_at(as_of)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -- applicability --------------------------------------------------
|
|
98
|
+
|
|
99
|
+
# @param subject [String] canonical product/type identity
|
|
100
|
+
# @return [Array<ApplicabilityBinding>]
|
|
101
|
+
def bindings_for(subject)
|
|
102
|
+
store.bindings.select { |b| b.subject == subject }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The legal as-of query: which profiles applied to +subject+ at +t+.
|
|
106
|
+
# Retroactive bindings apply from their effective_from even when
|
|
107
|
+
# registered later; non-retroactive ones only from registered_at.
|
|
108
|
+
#
|
|
109
|
+
# @param subject [String]
|
|
110
|
+
# @param as_of [Time, DateTime]
|
|
111
|
+
# @return [Array<ApplicabilityBinding>]
|
|
112
|
+
def applicable_profiles(subject, as_of:)
|
|
113
|
+
bindings_for(subject).select { |b| b.applies_at?(as_of) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The same query resolved to version-pinned profile items.
|
|
117
|
+
#
|
|
118
|
+
# @param subject [String]
|
|
119
|
+
# @param as_of [Time, DateTime]
|
|
120
|
+
# @return [Array<Item>] profile items applicable at +t+
|
|
121
|
+
def applicable_profile_items(subject, as_of:)
|
|
122
|
+
applicable_profiles(subject, as_of: as_of).map do |binding|
|
|
123
|
+
item(binding.profile_item, register: binding.register)
|
|
124
|
+
end.compact
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The manifests of the profiles applicable at +t+ (nil entries are
|
|
128
|
+
# skipped for bindings whose item carries no embedded manifest).
|
|
129
|
+
#
|
|
130
|
+
# @param subject [String]
|
|
131
|
+
# @param as_of [Time, DateTime]
|
|
132
|
+
# @return [Array<ProfileManifest>]
|
|
133
|
+
def applicable_manifests(subject, as_of:)
|
|
134
|
+
applicable_profiles(subject, as_of: as_of).filter_map do |binding|
|
|
135
|
+
found = item(binding.profile_item, register: binding.register)
|
|
136
|
+
found&.manifest
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|