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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +887 -64
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +133 -25
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +886 -0
  22. data/lib/plumb/composable.rb +451 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +339 -32
  32. data/lib/plumb/hash_map.rb +58 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +26 -29
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +28 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +44 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. data/lib/plumb/transform.rb +0 -26
@@ -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::Valid]
37
- # @return [Result::Valid, Result::Invalid]
47
+ # @param result [Result]
48
+ # @return [Result]
38
49
  def call(result)
39
- return result.invalid(errors: 'is not an Enumerable') unless result.value.respond_to?(:each)
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
- result.value.each do |e|
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 [Step] a step that resolves to an Enumerator that filters out invalid elements
72
+ # @return [Composable] a step that resolves to an Enumerator that filters out invalid elements
51
73
  def filtered
52
- self >> Step.new(nil, 'filtered') do |result|
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
@@ -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
- raise ParseError, "key type at :#{@key} #{key_type} must be a Match type" unless key_type.is_a?(MatchClass)
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[key_type.children[0]] = t
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
@@ -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