plumb 0.0.18 → 0.2.0.beta.2
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 +4 -4
- data/README.md +887 -64
- data/bench/compare_dry_schema.rb +79 -0
- data/bench/compare_dry_types.rb +37 -0
- data/bench/compare_parametric_schema.rb +2 -80
- data/bench/dry_schema_hash.rb +103 -0
- data/bench/dry_types_hash.rb +125 -0
- data/bench/json_schema_profile.rb +107 -0
- data/bench/plumb_hash.rb +17 -11
- data/bench/results_allocations.rb +137 -0
- data/bench/sample_data.rb +78 -0
- data/examples/command_objects.rb +1 -1
- data/examples/concurrent_downloads.rb +16 -9
- data/examples/event_registry.rb +6 -1
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +63 -6
- data/lib/plumb/any_class.rb +12 -2
- data/lib/plumb/array_class.rb +133 -25
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +59 -19
- data/lib/plumb/codec.rb +886 -0
- data/lib/plumb/composable.rb +451 -39
- data/lib/plumb/conjunction.rb +50 -0
- data/lib/plumb/constraint.rb +234 -0
- data/lib/plumb/covariant_fusion.rb +46 -0
- data/lib/plumb/decorator.rb +12 -22
- data/lib/plumb/deferred.rb +13 -5
- data/lib/plumb/disjunction.rb +112 -0
- data/lib/plumb/encoder.rb +207 -0
- data/lib/plumb/function.rb +347 -0
- data/lib/plumb/hash_class.rb +339 -32
- data/lib/plumb/hash_map.rb +58 -14
- data/lib/plumb/implementation.rb +247 -0
- data/lib/plumb/interface_class.rb +21 -2
- data/lib/plumb/intersection.rb +47 -0
- data/lib/plumb/json_schema_visitor.rb +255 -36
- data/lib/plumb/key.rb +63 -13
- data/lib/plumb/mermaid_visitor.rb +129 -0
- data/lib/plumb/metadata.rb +10 -1
- data/lib/plumb/metadata_visitor.rb +36 -34
- data/lib/plumb/never_class.rb +38 -0
- data/lib/plumb/node_mapper.rb +97 -0
- data/lib/plumb/not.rb +34 -2
- data/lib/plumb/optimizer.rb +444 -0
- data/lib/plumb/or.rb +26 -29
- data/lib/plumb/pipeline.rb +99 -11
- data/lib/plumb/policy.rb +17 -4
- data/lib/plumb/range_class.rb +46 -0
- data/lib/plumb/relation.rb +57 -0
- data/lib/plumb/result.rb +55 -23
- data/lib/plumb/semantic_matcher.rb +393 -0
- data/lib/plumb/static_class.rb +20 -1
- data/lib/plumb/stream_class.rb +28 -6
- data/lib/plumb/subtyping.rb +461 -0
- data/lib/plumb/tagged_hash.rb +45 -4
- data/lib/plumb/tuple_class.rb +21 -4
- data/lib/plumb/type_cache.rb +41 -0
- data/lib/plumb/type_registry.rb +71 -0
- data/lib/plumb/typed_step.rb +67 -0
- data/lib/plumb/types.rb +44 -43
- data/lib/plumb/union.rb +30 -0
- data/lib/plumb/value_class.rb +20 -1
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +20 -4
- data/lib/plumb.rb +90 -3
- metadata +30 -8
- data/lib/plumb/build.rb +0 -22
- data/lib/plumb/match_class.rb +0 -42
- data/lib/plumb/schema.rb +0 -195
- data/lib/plumb/step.rb +0 -27
- data/lib/plumb/transform.rb +0 -26
data/lib/plumb/stream_class.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Plumb
|
|
|
16
16
|
# end
|
|
17
17
|
class StreamClass
|
|
18
18
|
include Composable
|
|
19
|
+
include CovariantFusion
|
|
19
20
|
|
|
20
21
|
attr_reader :children
|
|
21
22
|
|
|
@@ -28,28 +29,49 @@ module Plumb
|
|
|
28
29
|
|
|
29
30
|
# return a new Stream definition.
|
|
30
31
|
# @param element_type [Composable] the type of the elements in the stream
|
|
32
|
+
# @see Plumb::NodeMapper
|
|
33
|
+
def with_children(children) = self[children.first]
|
|
34
|
+
|
|
31
35
|
def [](element_type)
|
|
32
36
|
self.class.new(element_type:)
|
|
33
37
|
end
|
|
34
38
|
|
|
39
|
+
# A Stream consumes any enumerable (it checks `#each` at runtime in #call),
|
|
40
|
+
# not another Stream — so it reports Any as its input and opts out of the
|
|
41
|
+
# #>> composition check. This lets a producer of a raw Enumerator/Array (eg.
|
|
42
|
+
# a CSV enumerator) feed a Stream. Covariant Stream[X] <= Stream[Y]
|
|
43
|
+
# subtyping is unaffected (that goes through #subtype_of? / #children).
|
|
44
|
+
def input_type = Types::Each
|
|
45
|
+
|
|
35
46
|
# The [Step] interface
|
|
36
|
-
# @param result [Result
|
|
37
|
-
# @return [Result
|
|
47
|
+
# @param result [Result]
|
|
48
|
+
# @return [Result]
|
|
38
49
|
def call(result)
|
|
39
|
-
|
|
50
|
+
result = input_type.call(result)
|
|
51
|
+
return result unless result.valid?
|
|
52
|
+
# return result.invalid(errors: 'is not an Enumerable') unless result.value.respond_to?(:each)
|
|
40
53
|
|
|
54
|
+
# Snapshot the source enumerable into a local so the lazy Enumerator closes
|
|
55
|
+
# over IT, not the result cursor. The cursor may be reused/mutated after we
|
|
56
|
+
# return (eg. a Stream nested in an Array, whose cursor is reset per
|
|
57
|
+
# element) — closing over the snapshot keeps each stream bound to its own
|
|
58
|
+
# input regardless.
|
|
59
|
+
source = result.value
|
|
41
60
|
enum = Enumerator.new do |y|
|
|
42
|
-
|
|
61
|
+
source.each do |e|
|
|
43
62
|
y << @element_type.resolve(e)
|
|
44
63
|
end
|
|
45
64
|
end
|
|
46
65
|
|
|
66
|
+
# Copying #valid (not #valid!): the returned result's value IS the lazy
|
|
67
|
+
# enum, so the result must be a fresh object the caller can hold while its
|
|
68
|
+
# own cursor moves on.
|
|
47
69
|
result.valid(enum)
|
|
48
70
|
end
|
|
49
71
|
|
|
50
|
-
# @return [
|
|
72
|
+
# @return [Composable] a step that resolves to an Enumerator that filters out invalid elements
|
|
51
73
|
def filtered
|
|
52
|
-
self >>
|
|
74
|
+
self >> Function.opaque(inspect: 'filtered', identity: [:filtered_stream, self]) do |result|
|
|
53
75
|
set = result.value.lazy.filter_map { |e| e.value if e.valid? }
|
|
54
76
|
result.valid(set)
|
|
55
77
|
end
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'plumb/type_cache'
|
|
4
|
+
|
|
5
|
+
module Plumb
|
|
6
|
+
# The structural subtype/subset relation over types, and the `#>>`
|
|
7
|
+
# composition type-check (subsumption) that builds on it. Methods are module
|
|
8
|
+
# functions: call them as `Plumb::Subtyping.foo`.
|
|
9
|
+
module Subtyping
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The structural subtype/subset relation over types.
|
|
13
|
+
#
|
|
14
|
+
# `subtype?(a, b)` answers "is every value described by `a` also described
|
|
15
|
+
# by `b`?", i.e. `a <= b`. Both `a` and `b` are normalized to Composables
|
|
16
|
+
# (raw Ruby classes/values become a Constraint), so `Types::String` and
|
|
17
|
+
# `String` compare the same way.
|
|
18
|
+
#
|
|
19
|
+
# This engine knows ONLY the TYPE algebra — meet (Intersection), union (Or),
|
|
20
|
+
# and the top type (AnyClass) — plus one projection: a node that CONVERTS is
|
|
21
|
+
# replaced by what it produces (#subtype_identity, which Function,
|
|
22
|
+
# Implementation and the And composition all implement). So an execution node
|
|
23
|
+
# is never reasoned about structurally; it is reduced to a type first. That
|
|
24
|
+
# separation is what keeps the relation sound: applying the meet rule to a
|
|
25
|
+
# composition would put a converting chain under its own input type.
|
|
26
|
+
#
|
|
27
|
+
# Everything else (atomic matchers, covariant containers, Hash width/depth,
|
|
28
|
+
# custom types) is decided by the type's own #subtype_of? leaf hook (see
|
|
29
|
+
# Composable). It never calls #<= (which would recurse back here).
|
|
30
|
+
#
|
|
31
|
+
# @param a [Composable, Class, Object] subtype candidate
|
|
32
|
+
# @param b [Composable, Class, Object] supertype candidate
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def subtype?(a, b)
|
|
35
|
+
a = Composable.wrap(a)
|
|
36
|
+
b = Composable.wrap(b)
|
|
37
|
+
|
|
38
|
+
return true if a.equal?(b) || a == b
|
|
39
|
+
|
|
40
|
+
# A Deferred stands in for the type it lazily materializes (via #type). We
|
|
41
|
+
# unwrap it here so the rest of the algebra never sees a Deferred. Recursive
|
|
42
|
+
# (self-referential) types would otherwise loop forever, so we break cycles
|
|
43
|
+
# coinductively: keyed by the identity of the (a, b) PAIR — the same Deferred
|
|
44
|
+
# may be compared against different RHSs on sibling branches, so a single-node
|
|
45
|
+
# marker would give false positives. Re-encountering a pair mid-recursion
|
|
46
|
+
# means we've closed a loop in a self-referential type: assume it holds (the
|
|
47
|
+
# greatest fixpoint) and let the surrounding structure confirm or refute it.
|
|
48
|
+
#
|
|
49
|
+
# `seen` is fiber-local (Thread.current[] is fiber-local in Ruby, not
|
|
50
|
+
# thread-local): a subtype? query never yields, so its recursion owns the set
|
|
51
|
+
# for its whole run, and concurrent fibers each get an isolated one. The
|
|
52
|
+
# ensure-delete unwinds each pair, so the set is empty again between queries.
|
|
53
|
+
if a.is_a?(Deferred) || b.is_a?(Deferred)
|
|
54
|
+
seen = (Thread.current[:plumb_subtype_seen] ||= Set.new)
|
|
55
|
+
key = [a.object_id, b.object_id]
|
|
56
|
+
return true if seen.include?(key)
|
|
57
|
+
|
|
58
|
+
seen.add(key)
|
|
59
|
+
begin
|
|
60
|
+
a = a.type if a.is_a?(Deferred)
|
|
61
|
+
b = b.type if b.is_a?(Deferred)
|
|
62
|
+
return subtype?(a, b)
|
|
63
|
+
ensure
|
|
64
|
+
seen.delete(key)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Transparent wrappers (Policy/Metadata/Node) only re-label the type they
|
|
69
|
+
# delegate to — for the subtype relation they ARE the wrapped type, just
|
|
70
|
+
# as they are for #accepted_type.
|
|
71
|
+
ua = unwrap_transparent(a)
|
|
72
|
+
ub = unwrap_transparent(b)
|
|
73
|
+
return subtype?(ua, ub) unless ua.equal?(a) && ub.equal?(b)
|
|
74
|
+
|
|
75
|
+
return true if b.is_a?(AnyClass) # X <= Top
|
|
76
|
+
return false if a.is_a?(AnyClass) # Top <= X only when X is Top (handled above)
|
|
77
|
+
|
|
78
|
+
# A value-converting type (Function, or any custom type that opts in) is
|
|
79
|
+
# identified for subtyping by what it *produces*, not what it consumes, so we
|
|
80
|
+
# reduce `a <= b` to `produced(a) <= b` before consulting the leaf hooks. A
|
|
81
|
+
# type declares its produced identity via #subtype_identity (default: self).
|
|
82
|
+
#
|
|
83
|
+
# The `!equal?` guard is the safety rail: we only reduce when the projection
|
|
84
|
+
# is a DISTINCT node. This makes the "distinct output type" invariant
|
|
85
|
+
# structural rather than a convention — a type that projects to itself (the
|
|
86
|
+
# default, and value-preserving types like FilteredHashMap/Static) simply
|
|
87
|
+
# doesn't reduce and falls through to its #subtype_of? leaf, instead of
|
|
88
|
+
# recursing into subtype?(self, b) forever.
|
|
89
|
+
ai = a.subtype_identity
|
|
90
|
+
return subtype?(ai, b) unless ai.equal?(a)
|
|
91
|
+
|
|
92
|
+
bi = b.subtype_identity
|
|
93
|
+
return subtype?(a, bi) unless bi.equal?(b)
|
|
94
|
+
|
|
95
|
+
# Unions
|
|
96
|
+
# Joins. The rule is the same for a choice and a union: a branch that
|
|
97
|
+
# converts was already projected onto its output by #subtype_identity, so
|
|
98
|
+
# by here every branch is a type either way.
|
|
99
|
+
return a.children.all? { |m| subtype?(m, b) } if a.is_a?(Disjunction) # (A|B) <= C
|
|
100
|
+
return b.children.any? { |m| subtype?(a, m) } if b.is_a?(Disjunction) # A <= (B|C)
|
|
101
|
+
|
|
102
|
+
# Meets: the longer the Intersection chain, the narrower. This rule applies
|
|
103
|
+
# ONLY to a genuine intersection, where both sides describe the same value.
|
|
104
|
+
# A sequential composition (And) is a morphism and was already projected onto
|
|
105
|
+
# what it produces by the #subtype_identity reduction above — applying the
|
|
106
|
+
# meet rule to it would make a converting chain a subtype of its own INPUT
|
|
107
|
+
# type, and so a subtype of two disjoint types at once.
|
|
108
|
+
return b.children.all? { |bb| subtype?(a, bb) } if b.is_a?(Intersection) # a <= (b1 ∧ b2)
|
|
109
|
+
return a.children.any? { |aa| subtype?(aa, b) } if a.is_a?(Intersection) # (a1 ∧ a2) <= b
|
|
110
|
+
|
|
111
|
+
# `a` decides via its #subtype_of? leaf; if it can't (it doesn't know about
|
|
112
|
+
# `b`), `b` may claim `a` via #supertype_of? — the mirror hook for
|
|
113
|
+
# supertype-driven relations like Interface duck-typing.
|
|
114
|
+
a.subtype_of?(b) || b.supertype_of?(a)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `a` is STRICTLY narrower than `b` — a subtype, and not merely equivalent
|
|
118
|
+
# to it. The named form of `Composable#<`, usable where the operators are
|
|
119
|
+
# not (`a` may be a raw struct Class, where `<` means Ruby ancestry — see
|
|
120
|
+
# Plumb::Attributes).
|
|
121
|
+
def strict_subtype?(a, b) = subtype?(a, b) && !subtype?(b, a)
|
|
122
|
+
|
|
123
|
+
# `a` and `b` describe the same values — mutual subtypes.
|
|
124
|
+
def equivalent?(a, b) = subtype?(a, b) && subtype?(b, a)
|
|
125
|
+
|
|
126
|
+
# Returns whether a node wraps one raw matcher. StaticClass is excluded because
|
|
127
|
+
# its child is an emitted value, not a matcher.
|
|
128
|
+
# @param type [Object]
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
def atomic?(type)
|
|
131
|
+
type.respond_to?(:children) &&
|
|
132
|
+
type.children.size == 1 &&
|
|
133
|
+
!type.children.first.is_a?(Composable) &&
|
|
134
|
+
!type.is_a?(StaticClass)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The public Boolean form of the matcher subtype relation. Unknown relations
|
|
138
|
+
# conservatively return false: they must not justify removing a runtime check.
|
|
139
|
+
def atomic_subtype?(left_matcher, right_matcher)
|
|
140
|
+
SemanticMatcher.wrap(left_matcher).subset_of(SemanticMatcher.wrap(right_matcher)).proven?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate that two steps can be composed sequentially (`left >> right`).
|
|
144
|
+
# Composition is typed by subsumption, like function application in any
|
|
145
|
+
# statically-typed language: everything `left` produces must be acceptable
|
|
146
|
+
# to `right`, i.e. `produced(left) <: accepted(right)`. Otherwise the chain
|
|
147
|
+
# would reject some of `left`'s output and we raise.
|
|
148
|
+
#
|
|
149
|
+
# To *narrow* a value (where only some of it flows through), use `#[]` /
|
|
150
|
+
# `#transform(...)[...]` — a refinement is a runtime-checked cast, built
|
|
151
|
+
# directly and not subject to this check.
|
|
152
|
+
#
|
|
153
|
+
# Permissive only where types are genuinely unknown: when either side
|
|
154
|
+
# reports Any (opaque steps/procs, value-level transforms, narrowing
|
|
155
|
+
# matchers), it opts out.
|
|
156
|
+
#
|
|
157
|
+
# @raise [Plumb::TypeError] when `left`'s output is not a subtype of what
|
|
158
|
+
# `right` accepts.
|
|
159
|
+
def check_composable!(left, right)
|
|
160
|
+
produced = resolved_output(left)
|
|
161
|
+
# opaque left (unknown output) or opaque right (accepts anything) -> opt out
|
|
162
|
+
return if produced.is_a?(AnyClass) || resolved_input(right).is_a?(AnyClass)
|
|
163
|
+
|
|
164
|
+
accepted = accepted_type(right)
|
|
165
|
+
return if accepted.is_a?(AnyClass) || subtype?(produced, accepted)
|
|
166
|
+
|
|
167
|
+
# Produced literal-key records are closed because #call drops undeclared keys.
|
|
168
|
+
return if produced.is_a?(HashClass) && subtype?(produced.closed, accepted)
|
|
169
|
+
|
|
170
|
+
raise Plumb::TypeError,
|
|
171
|
+
"cannot compose #{left.inspect} >> #{right.inspect}: " \
|
|
172
|
+
"#{produced.inspect} (produced) is not a subtype of #{accepted.inspect} (accepted); " \
|
|
173
|
+
'narrow with #[] if this is intentional'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Subtype test between two attribute-constraint VALUES (the `value` of a
|
|
177
|
+
# `where(attr: value)` clause). A value is either a full Plumb type — compared
|
|
178
|
+
# structurally with #subtype? — or a raw `===`-matcher (Range/Set/literal/
|
|
179
|
+
# Regexp/Array/Hash), compared with #atomic_subtype?. The split is load-
|
|
180
|
+
# bearing: #subtype? wraps its arguments, and Composable.wrap turns a raw
|
|
181
|
+
# Array into `Array[element]` (raising on multi-element arrays) and a Hash into
|
|
182
|
+
# a record type — but an AttributeValueMatch matches its value with plain
|
|
183
|
+
# `===`, which is exactly what #atomic_subtype? models. Mirrors how a
|
|
184
|
+
# Constraint's matcher can be either kind.
|
|
185
|
+
def value_subtype?(a, b)
|
|
186
|
+
if a.is_a?(Composable) || b.is_a?(Composable)
|
|
187
|
+
subtype?(a, b)
|
|
188
|
+
else
|
|
189
|
+
atomic_subtype?(a, b)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The meet (greatest lower bound) of two types — the dual of
|
|
194
|
+
# Optimizer.reduce_union's join. This is lattice algebra rather than a
|
|
195
|
+
# rewrite, which is why it stayed here when the rules moved out. `intersect(a, b)` returns the narrowed type, or nil to fall back to
|
|
196
|
+
# `Conjunction.build(a, b)` (a sound runtime intersection: both sides must pass). It
|
|
197
|
+
# only produces `Types::Never` when the intersection is PROVABLY empty
|
|
198
|
+
# (disjoint ranges/sets/classes); when it can't prove emptiness or a subtype
|
|
199
|
+
# relation, it declines (nil) so the caller keeps a runtime And.
|
|
200
|
+
#
|
|
201
|
+
# Consumed by Composable#&.
|
|
202
|
+
def intersect(a, b)
|
|
203
|
+
a = Composable.wrap(a)
|
|
204
|
+
b = Composable.wrap(b)
|
|
205
|
+
|
|
206
|
+
return a if a.is_a?(NeverClass) # Never & X == Never
|
|
207
|
+
return b if b.is_a?(NeverClass)
|
|
208
|
+
return b if a.is_a?(AnyClass) # Any & X == X (top is the meet identity)
|
|
209
|
+
return a if b.is_a?(AnyClass)
|
|
210
|
+
|
|
211
|
+
return a if a == b
|
|
212
|
+
|
|
213
|
+
# The subsumption drops below are sound only when BOTH sides preserve the
|
|
214
|
+
# value — the same guard, for the same reason, as Optimizer.reduce_union.
|
|
215
|
+
# `subtype?` identifies a converting node by its OUTPUT type, so any two
|
|
216
|
+
# `String -> Integer` transforms are mutual subtypes no matter what they
|
|
217
|
+
# compute; dropping one would silently discard a conversion the caller asked
|
|
218
|
+
# for (`to_i & size` returned just `to_i`). Only when neither side alters the
|
|
219
|
+
# value does `subtype?` describe the accepted input domain, which is what
|
|
220
|
+
# makes keeping the narrower side behaviour-preserving.
|
|
221
|
+
#
|
|
222
|
+
# Also skipped when either side is a transparent wrapper: subtype? sees
|
|
223
|
+
# through it, but dropping it loses the identity it carries (see
|
|
224
|
+
# #identity_wrapper?). eg. `Types::Integer & doubler.metadata(...)` keeps both.
|
|
225
|
+
if value_preserving?(a) && value_preserving?(b) &&
|
|
226
|
+
!identity_wrapper?(a) && !identity_wrapper?(b)
|
|
227
|
+
return a if subtype?(a, b) # a ⊆ b — meet keeps the narrower a
|
|
228
|
+
return b if subtype?(b, a) # b ⊆ a — keep the narrower b
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Distribute over unions: (a1 | a2) & b == (a1 & b) | (a2 & b).
|
|
232
|
+
return intersect_union(a, b, union_first: true) if a.is_a?(Disjunction)
|
|
233
|
+
return intersect_union(b, a, union_first: false) if b.is_a?(Disjunction)
|
|
234
|
+
|
|
235
|
+
intersect_constraints(a, b) ||
|
|
236
|
+
intersect_literals(a, b) ||
|
|
237
|
+
intersect_containers(a, b) ||
|
|
238
|
+
(disjoint_relation(a, b).proven? ? Types::Never : nil)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Distributes intersection over a union, preserving operand order for runtime
|
|
242
|
+
# compositions and dropping branches that reduce to Never. Order is observable
|
|
243
|
+
# when a fallback branch converts: `Static[5] >> String` is not `String >> Static[5]`.
|
|
244
|
+
# @param union [Disjunction]
|
|
245
|
+
# @param other [Composable]
|
|
246
|
+
# @param union_first [Boolean] whether the union was the left operand
|
|
247
|
+
# @return [Composable]
|
|
248
|
+
def intersect_union(union, other, union_first: true)
|
|
249
|
+
parts = union.children.filter_map do |branch|
|
|
250
|
+
left, right = union_first ? [branch, other] : [other, branch]
|
|
251
|
+
m = intersect(left, right) || Conjunction.build(left, right)
|
|
252
|
+
m unless m.is_a?(NeverClass)
|
|
253
|
+
end
|
|
254
|
+
return Types::Never if parts.empty?
|
|
255
|
+
|
|
256
|
+
parts.reduce { |acc, p| acc | p }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Intersect two knowable refinements over the SAME base type (Ranges or Sets),
|
|
260
|
+
# reusing Constraint.merge_matchers. An empty overlap (disjoint Ranges, empty
|
|
261
|
+
# Set intersection) is provably empty ⇒ Never; a non-empty overlap rebuilds via
|
|
262
|
+
# Constraint.narrow (`Integer[2..] & Integer[0..100]` == `Integer[2..100]`).
|
|
263
|
+
# Returns nil for anything it can't merge here (different bases, non-Range/Set
|
|
264
|
+
# matchers), leaving the caller to try other strategies.
|
|
265
|
+
def intersect_constraints(a, b)
|
|
266
|
+
return nil unless a.is_a?(Constraint) && b.is_a?(Constraint)
|
|
267
|
+
return nil unless a.base && b.base && a.base == b.base
|
|
268
|
+
|
|
269
|
+
merged = Constraint.merge_matchers(a.matcher, b.matcher)
|
|
270
|
+
return nil if merged.nil? # not the same knowable kind, or an incomputable overlap
|
|
271
|
+
return Types::Never if merged.equal?(Constraint::EMPTY) # provably empty ⇒ Never
|
|
272
|
+
|
|
273
|
+
Constraint.narrow(a.base, merged)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Two literals meet as their singleton sets: distinct values share no
|
|
277
|
+
# inhabitant, so the meet is provably empty ⇒ Never (`Value['a'] &
|
|
278
|
+
# Value['b']`). Equal values need no answer here — #intersect's `a == b`
|
|
279
|
+
# dedupe already folded them — so this only ever returns Never or nil.
|
|
280
|
+
#
|
|
281
|
+
# Decided by `==`, the same test a literal match itself makes, so
|
|
282
|
+
# equality that crosses Ruby classes is honoured: `Value[5] & Value[5.0]` is
|
|
283
|
+
# NOT disjoint, because `5 == 5.0`. That is also why literals can't be told
|
|
284
|
+
# apart by base type — declaring `Value[5]`'s base to be Integer would make
|
|
285
|
+
# it disjoint from Float and wrongly sink `Value[5] & Types::Float` — and so
|
|
286
|
+
# why this reasons over values instead of nominal-domain disjointness.
|
|
287
|
+
#
|
|
288
|
+
# Runs after #intersect_constraints, so two literals over the same base reach
|
|
289
|
+
# here only once Constraint.merge_matchers has declined them (it merges
|
|
290
|
+
# Ranges and Sets, not literals).
|
|
291
|
+
def intersect_literals(a, b)
|
|
292
|
+
av = literal_value(a)
|
|
293
|
+
bv = literal_value(b)
|
|
294
|
+
return nil if Undefined.equal?(av) || Undefined.equal?(bv)
|
|
295
|
+
|
|
296
|
+
Relation.from(av == bv).disproven? ? Types::Never : nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The single value a node matches by equality, or `Undefined` for a node that
|
|
300
|
+
# matches more than one (so a caller can't mistake a membership test for a
|
|
301
|
+
# literal). Covers both spellings of a literal — `Types::Value['a']` and
|
|
302
|
+
# `Types::String['a']` are equally provably disjoint from `'b'` — but checks
|
|
303
|
+
# the two differently: a ValueClass matches by `==` whatever it holds, so any
|
|
304
|
+
# value qualifies, while a Constraint matches by `===`, so only the kinds
|
|
305
|
+
# whose `#===` IS `#==` do (see Constraint#literal?). A bare `Types::Value`
|
|
306
|
+
# already holds Undefined, which reads as "no literal" here.
|
|
307
|
+
def literal_value(node)
|
|
308
|
+
case node
|
|
309
|
+
when ValueClass then node.children.first
|
|
310
|
+
when Constraint then node.literal? ? node.matcher : Undefined
|
|
311
|
+
else Undefined
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Intersect two covariant containers of the same class (Array/Tuple/HashMap)
|
|
316
|
+
# by intersecting their children pairwise — `Array[A] & Array[B]` ==
|
|
317
|
+
# `Array[A & B]`. Returns nil for non-containers or a class/arity mismatch (the
|
|
318
|
+
# caller then falls back).
|
|
319
|
+
#
|
|
320
|
+
# A `Never` child does NOT necessarily sink the whole container. A Tuple has
|
|
321
|
+
# fixed arity — every position must be filled — so a `Never` element makes it
|
|
322
|
+
# uninhabitable ⇒ `Never`. A homogeneous container (Array/HashMap) can be
|
|
323
|
+
# empty, so `Array[Never]` / `HashMap[K, Never]` are still inhabited (by `[]` /
|
|
324
|
+
# `{}`) and are kept as-is rather than collapsed.
|
|
325
|
+
def intersect_containers(a, b)
|
|
326
|
+
return nil unless container_covariant?(a) && a.instance_of?(b.class)
|
|
327
|
+
return nil if a.children.empty? || a.children.size != b.children.size
|
|
328
|
+
|
|
329
|
+
merged = a.children.zip(b.children).map { |x, y| intersect(x, y) || Conjunction.build(x, y) }
|
|
330
|
+
return Types::Never if a.is_a?(TupleClass) && merged.any? { |m| m.is_a?(NeverClass) }
|
|
331
|
+
|
|
332
|
+
a.with_children(merged)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The containers that are covariant in their children — the ones
|
|
336
|
+
# #intersect_containers may meet pairwise. NOT the same set as the nodes
|
|
337
|
+
# answering #with_children, which is much wider (see Plumb::NodeMapper).
|
|
338
|
+
def container_covariant?(type)
|
|
339
|
+
type.is_a?(ArrayClass) || type.is_a?(TupleClass) || type.is_a?(HashMap)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Map a container's children through `blk` and rebuild it around the results —
|
|
343
|
+
# the shared rule behind every covariant container's #accepted_type and
|
|
344
|
+
# #output_type. Kept as an alias so those call sites read in terms of this
|
|
345
|
+
# module; the traversal and its identity guard live in ONE place, because every
|
|
346
|
+
# rewrite pass depends on an untouched subtree coming back `equal?`.
|
|
347
|
+
#
|
|
348
|
+
# @param type [Composable] a container responding to #with_children
|
|
349
|
+
# @yieldparam child [Composable]
|
|
350
|
+
# @return [Composable] `type` itself, or a rebuilt container
|
|
351
|
+
def map_children(type, &blk) = NodeMapper.map_children(type, &blk)
|
|
352
|
+
|
|
353
|
+
# Attempts to prove that two stable nominal domains are disjoint. Unknown
|
|
354
|
+
# domains preserve the runtime intersection.
|
|
355
|
+
# @return [Relation]
|
|
356
|
+
def disjoint_relation(a, b)
|
|
357
|
+
abt = stable_domain(a)
|
|
358
|
+
bbt = stable_domain(b)
|
|
359
|
+
return Relation::UNKNOWN if abt.nil? || bbt.nil?
|
|
360
|
+
|
|
361
|
+
overlaps = abt.product(bbt).map do |left_domain, right_domain|
|
|
362
|
+
SemanticMatcher.wrap(left_domain).overlap(SemanticMatcher.wrap(right_domain))
|
|
363
|
+
end
|
|
364
|
+
overlap = Relation.any(overlaps)
|
|
365
|
+
return Relation::DISPROVEN if overlap.proven?
|
|
366
|
+
return Relation::PROVEN if overlap.disproven?
|
|
367
|
+
|
|
368
|
+
Relation::UNKNOWN
|
|
369
|
+
end
|
|
370
|
+
private_class_method :disjoint_relation
|
|
371
|
+
|
|
372
|
+
# Returns the shared accepted/output base classes for a non-converting type.
|
|
373
|
+
# Unknown or converting domains return nil; comparing their output classes as
|
|
374
|
+
# input domains could incorrectly reduce an inhabited intersection to Never.
|
|
375
|
+
# @param type [Composable]
|
|
376
|
+
# @return [Array<Class>, nil]
|
|
377
|
+
def stable_domain(type)
|
|
378
|
+
consumes = accepted_type(type)
|
|
379
|
+
# A converting self-fallback does not expose a reliable input domain.
|
|
380
|
+
return nil if consumes.equal?(type) && !value_preserving?(type)
|
|
381
|
+
|
|
382
|
+
accepted = Plumb.resolve_base_types(consumes)
|
|
383
|
+
return nil if accepted.empty?
|
|
384
|
+
|
|
385
|
+
accepted == Plumb.resolve_base_types(resolved_output(type)) ? accepted : nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Does `type` return its input unchanged on success (a coreflexive
|
|
389
|
+
# refinement)? Delegates to the type's polymorphic #value_preserving? hook —
|
|
390
|
+
# so custom types opt in by defining it — and memoizes per frozen node in
|
|
391
|
+
# TypeCache, a pure structural predicate like #accepted_type. Functions and
|
|
392
|
+
# value-building containers (Hash/Array/Tuple/…) change the value and stay
|
|
393
|
+
# false; refinements and their And/Or compositions are true.
|
|
394
|
+
def value_preserving?(type)
|
|
395
|
+
TypeCache.fetch(:value_preserving, type) { type.value_preserving? }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# What `type` will accept without rejecting it outright, when it's the
|
|
399
|
+
# consumer of a `left >> type` chain. The type itself answers via its
|
|
400
|
+
# #accepted_type hook (default: its resolved input; refinements and Hash
|
|
401
|
+
# override it — see Composable#accepted_type). Transparent wrappers are
|
|
402
|
+
# peeled first so the wrapped type answers. Memoized per node in TypeCache
|
|
403
|
+
# (frozen nodes only) — this is the sole consumer of #accepted_type, so
|
|
404
|
+
# caching here covers every type, eg. HashClass's per-field rebuild.
|
|
405
|
+
def accepted_type(type)
|
|
406
|
+
type = unwrap_transparent(type)
|
|
407
|
+
TypeCache.fetch(:accepted_type, type) { type.accepted_type }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Peel transparent wrappers (Policy/Metadata/Node) down to the wrapped type.
|
|
411
|
+
def unwrap_transparent(type)
|
|
412
|
+
case type
|
|
413
|
+
when Policy then unwrap_transparent(type.children.first)
|
|
414
|
+
when Metadata then unwrap_transparent(type.type)
|
|
415
|
+
when Composable::Node then unwrap_transparent(type.type)
|
|
416
|
+
else type
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Does `type` carry identity beyond its value semantics — a policy name,
|
|
421
|
+
# user metadata, or a visitor node_name — i.e. is it (wrapped in) a
|
|
422
|
+
# transparent Policy/Metadata/Node? `subtype?` sees THROUGH such wrappers
|
|
423
|
+
# (Policy(X) <= Y iff X <= Y), which is right for the subsumption relation
|
|
424
|
+
# but means a reduction that DROPS one in favour of a subtype-equal type
|
|
425
|
+
# would silently lose that identity. So the reductions (this module's
|
|
426
|
+
# #intersect, and Optimizer.reduce_union / .redundant_refinement?) refuse to
|
|
427
|
+
# drop one — except when the two are equal, where the survivor already IS
|
|
428
|
+
# that identity.
|
|
429
|
+
def identity_wrapper?(type)
|
|
430
|
+
!unwrap_transparent(type).equal?(type)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Every node resolves its own #input_type / #output_type (an And does it at
|
|
434
|
+
# construction, an Or maps over its branches), so this is normally a single
|
|
435
|
+
# hop. The loop remains for nodes that delegate through a wrapper chain, and
|
|
436
|
+
# bottoms out when a type is its own io type. Memoized per node in TypeCache
|
|
437
|
+
# (frozen nodes only) — worth it for Or, which allocates a fresh Or of its
|
|
438
|
+
# resolved branches on every call.
|
|
439
|
+
def resolved_output(type, depth = 0)
|
|
440
|
+
TypeCache.fetch(:resolved_output, type) do
|
|
441
|
+
nxt = type.output_type
|
|
442
|
+
if depth >= 50 || nxt.equal?(type) || nxt == type
|
|
443
|
+
type
|
|
444
|
+
else
|
|
445
|
+
resolved_output(nxt, depth + 1)
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def resolved_input(type, depth = 0)
|
|
451
|
+
TypeCache.fetch(:resolved_input, type) do
|
|
452
|
+
nxt = type.input_type
|
|
453
|
+
if depth >= 50 || nxt.equal?(type) || nxt == type
|
|
454
|
+
type
|
|
455
|
+
else
|
|
456
|
+
resolved_input(nxt, depth + 1)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
data/lib/plumb/tagged_hash.rb
CHANGED
|
@@ -6,7 +6,10 @@ module Plumb
|
|
|
6
6
|
class TaggedHash
|
|
7
7
|
include Composable
|
|
8
8
|
|
|
9
|
-
attr_reader :key, :children
|
|
9
|
+
attr_reader :key, :children, :hash_type
|
|
10
|
+
|
|
11
|
+
# @see Plumb::NodeMapper
|
|
12
|
+
def with_children(children) = self.class.new(hash_type, key, children)
|
|
10
13
|
|
|
11
14
|
def initialize(hash_type, key, children)
|
|
12
15
|
@hash_type = hash_type
|
|
@@ -21,20 +24,58 @@ module Plumb
|
|
|
21
24
|
# types are assumed to have literal values for the index field :key
|
|
22
25
|
@index = @children.each.with_object({}) do |t, memo|
|
|
23
26
|
key_type = t.at_key(@key)
|
|
24
|
-
|
|
27
|
+
# Accept either bare or base-constrained single-value tags.
|
|
28
|
+
tag = Plumb::Subtyping.literal_value(key_type)
|
|
29
|
+
if Undefined.equal?(tag)
|
|
30
|
+
raise ParseError, "key type at :#{@key} #{key_type} must match a single literal value"
|
|
31
|
+
end
|
|
25
32
|
|
|
26
|
-
memo[
|
|
33
|
+
memo[tag] = t
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
freeze
|
|
30
37
|
end
|
|
31
38
|
|
|
39
|
+
# Equality includes the discriminator and base hash because #children contains
|
|
40
|
+
# only variants; comparing children alone would conflate distinct tagged hashes.
|
|
41
|
+
# @param other [Object]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def ==(other)
|
|
44
|
+
other.instance_of?(self.class) &&
|
|
45
|
+
key.eql?(other.key) &&
|
|
46
|
+
hash_type == other.hash_type &&
|
|
47
|
+
children == other.children
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A tagged hash is a subtype when every selectable variant is a subtype. The
|
|
51
|
+
# base hash only narrows those variants, so ignoring it here is conservative;
|
|
52
|
+
# comparing variants pairwise would be unsound across different discriminators.
|
|
53
|
+
# @param other [Composable]
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def subtype_of?(other)
|
|
56
|
+
return true if self == other
|
|
57
|
+
|
|
58
|
+
@children.all? { |variant| Plumb::Subtyping.subtype?(variant, other) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Relaxes each variant's non-discriminator fields to their accepted types.
|
|
62
|
+
# @return [TaggedHash]
|
|
63
|
+
def accepted_type
|
|
64
|
+
relaxed = @children.map do |child|
|
|
65
|
+
schema = child._schema.each_with_object({}) do |(k, field), h|
|
|
66
|
+
h[k] = k.eql?(@key) ? field : Plumb::Subtyping.accepted_type(field)
|
|
67
|
+
end
|
|
68
|
+
child.class.new(schema:)
|
|
69
|
+
end
|
|
70
|
+
self.class.new(@hash_type, @key, relaxed)
|
|
71
|
+
end
|
|
72
|
+
|
|
32
73
|
def call(result)
|
|
33
74
|
result = @hash_type.call(result)
|
|
34
75
|
return result unless result.valid?
|
|
35
76
|
|
|
36
77
|
child = @index[result.value[@key.to_sym]]
|
|
37
|
-
return result.invalid(errors: "expected :#{@key.to_sym} to be one of #{@index.keys.join(', ')}") unless child
|
|
78
|
+
return result.invalid!(errors: "expected :#{@key.to_sym} to be one of #{@index.keys.join(', ')}") unless child
|
|
38
79
|
|
|
39
80
|
child.call(result)
|
|
40
81
|
end
|
data/lib/plumb/tuple_class.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'plumb/composable'
|
|
|
5
5
|
module Plumb
|
|
6
6
|
class TupleClass
|
|
7
7
|
include Composable
|
|
8
|
+
include CovariantFusion
|
|
8
9
|
|
|
9
10
|
attr_reader :children
|
|
10
11
|
|
|
@@ -19,9 +20,25 @@ module Plumb
|
|
|
19
20
|
|
|
20
21
|
alias [] of
|
|
21
22
|
|
|
23
|
+
# A Tuple validates each position through its type, so it preserves the value
|
|
24
|
+
# only when every position type does (a coercing position would change the
|
|
25
|
+
# tuple).
|
|
26
|
+
def value_preserving? = children.all? { |c| Plumb::Subtyping.value_preserving?(c) }
|
|
27
|
+
|
|
28
|
+
# Rebuild around new children (see Plumb::Subtyping.map_children).
|
|
29
|
+
def with_children(children) = of(*children)
|
|
30
|
+
|
|
31
|
+
# As a consumer, a Tuple accepts each position relaxed to what that
|
|
32
|
+
# position's type accepts (see HashClass#accepted_type).
|
|
33
|
+
def accepted_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.accepted_type(c) }
|
|
34
|
+
|
|
35
|
+
# The value you GET after validating each position: every position type
|
|
36
|
+
# resolved to what it produces (mirror of #accepted_type).
|
|
37
|
+
def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
|
|
38
|
+
|
|
22
39
|
def call(result)
|
|
23
|
-
return result.invalid(errors: 'must be an Array') unless result.value.is_a?(::Array)
|
|
24
|
-
return result.invalid(errors: 'must have the same size') unless result.value.size == @children.size
|
|
40
|
+
return result.invalid!(errors: 'must be an Array') unless result.value.is_a?(::Array)
|
|
41
|
+
return result.invalid!(errors: 'must have the same size') unless result.value.size == @children.size
|
|
25
42
|
|
|
26
43
|
errors = {}
|
|
27
44
|
values = @children.map.with_index do |type, idx|
|
|
@@ -31,9 +48,9 @@ module Plumb
|
|
|
31
48
|
r.value
|
|
32
49
|
end
|
|
33
50
|
|
|
34
|
-
return result.valid(values) unless errors.any?
|
|
51
|
+
return result.valid!(values) unless errors.any?
|
|
35
52
|
|
|
36
|
-
result.invalid(errors:)
|
|
53
|
+
result.invalid!(errors:)
|
|
37
54
|
end
|
|
38
55
|
|
|
39
56
|
private
|