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
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/subtyping'
4
+
5
+ module Plumb
6
+ # THE REWRITE RULES — the optimisation pass over the type AST.
7
+ #
8
+ # The operators do not build the tree you wrote; they build an equivalent one with
9
+ # the provably-redundant runtime work removed. `Integer[0..100] >> Integer[0..]`
10
+ # validates `::Integer` once; `Integer | Numeric` collapses to `Numeric`;
11
+ # `String[/a/] | String[/b/]` checks `String` once and branches on the suffixes.
12
+ #
13
+ # A different kind of thing from {Plumb::Subtyping}: the relation ANSWERS questions
14
+ # about types, these REWRITE one AST into another. Keeping them apart makes the
15
+ # dependency one-way (Optimizer -> Subtyping, never back).
16
+ #
17
+ # Runs EAGERLY at build time, not as a deferred pass, because reductions are
18
+ # observable through `#inspect`, `#==` and the visitors — a type's identity is its
19
+ # reduced form, and `(Integer[0..100] >> Integer[0..]) == Integer[0..100]` is a
20
+ # documented property.
21
+ #
22
+ # EVERY RULE MUST PRESERVE, for every input: validity, output value, errors, and
23
+ # execution order. The last is easy to lose and invisible to most tests, since it
24
+ # only shows up when a step has a side effect — which is why absorption and factoring
25
+ # are gated on `#value_preserving?`. spec/invariants_spec.rb asserts all four, order
26
+ # via probes that log each step as it runs.
27
+ #
28
+ # THE RULES, in the order tried:
29
+ #
30
+ # `left >> right` / `left / right` reduce_step, then redundant_refinement?
31
+ # (#>> only), else Conjunction.build
32
+ # `left | right` reduce_union, then factor_union,
33
+ # else Disjunction.build
34
+ #
35
+ # reduce_step is itself a ladder: intersection distribution, attribute narrowing,
36
+ # step fusion, refinement re-parenting, and finally boundary absorption.
37
+ #
38
+ # The meet (`#&`) is NOT here — a greatest lower bound is lattice algebra, not a
39
+ # rewrite, so it stays in Subtyping.intersect.
40
+ #
41
+ # DECLINED, not missing: dropping a redundant gate on the LEFT. reduce_step drops one
42
+ # on the right (`f >> Types::String` returns `f`), and the mirror looks equally free —
43
+ # `Types::String >> f` runs the String check twice when `f` declares String as its
44
+ # input, worth 21% of a resolve, or 64% for `Types::UUID::V4 >> a_finder` where a
45
+ # regex ran twice. It was implemented and measured, then backed out: it destroys the
46
+ # shared prefix factor_union hoists, so `(String >> b) | (String >> c) | (String >> d)`
47
+ # goes from one String check to three — 3.7x worse on the rejection path, which is
48
+ # where an enum-like union spends its time. The two rules want opposite things about
49
+ # the same node (absorb the gate vs extract it), and at `>>` time there is no way to
50
+ # know a `|` is coming. Factoring wins the bigger case.
51
+ #
52
+ # #absorb_boundary sits on the other side of that line, and where the check goes is
53
+ # what divides them: it moves a left-hand type into an `Any` input slot only, where
54
+ # there is no gate to drop, so what a union gives up is a step boundary in a chain
55
+ # whose middle step is an untyped callable. Extending it to a TYPED input slot
56
+ # (`Types::String >> (String -> Integer)` -> just the transform) is the declined rule
57
+ # in another form, and would need factor_union taught to unfold a value-preserving
58
+ # input slot back into a step before it could pay.
59
+ module Optimizer
60
+ module_function
61
+
62
+ # The full rule set for `left >> right`. Always returns a node.
63
+ #
64
+ # @param left [Composable]
65
+ # @param right [Composable]
66
+ # @return [Composable]
67
+ def rewrite_step(left, right)
68
+ reduce_step(left, right) ||
69
+ (redundant_refinement?(left, right) ? left : Conjunction.build(left, right))
70
+ end
71
+
72
+ # For `left / right` — the escape hatch, and the refinement builders (#[], #where,
73
+ # #value) that route through it.
74
+ #
75
+ # STRUCTURAL REDUCTION ONLY: absorption is deliberately skipped, because `#/` exists
76
+ # to assert a narrowing the checker cannot prove, and dropping it as "already
77
+ # guaranteed" would discard the cast the caller asked for. reduce_step still removes
78
+ # a duplicated type gate, which is pure bookkeeping.
79
+ #
80
+ # @param left [Composable]
81
+ # @param right [Composable]
82
+ # @return [Composable]
83
+ def rewrite_refinement(left, right)
84
+ reduce_step(left, right) || Conjunction.build(left, right)
85
+ end
86
+
87
+ # The full rule set for `left | right`. Always returns a node.
88
+ #
89
+ # @param left [Composable]
90
+ # @param right [Composable]
91
+ # @return [Composable]
92
+ def rewrite_union(left, right)
93
+ reduce_union(left, right) ||
94
+ factor_union(left, right) ||
95
+ Disjunction.build(left, right)
96
+ end
97
+
98
+ # Rung-1 structural reduction of `left >> right` — the ladder tried before
99
+ # absorption into a Conjunction: intersection distribution, attribute narrowing,
100
+ # step fusion, refinement re-parenting, boundary absorption. Each is a method or a
101
+ # commented block below, in that order. Returns the reduced type, or `nil` to fall
102
+ # back to `Conjunction.build`.
103
+ def reduce_step(left, right)
104
+ # A refinement narrows by each conjunct in turn: `left / (b ∧ c)` is
105
+ # `(left / b) / c`. Being an Intersection IS the condition — it is only
106
+ # built when both sides preserve the value — so no runtime value-preservation
107
+ # test is needed here. A composition (And) carries a transform, is a barrier,
108
+ # and falls through to the Constraint check below, which bails.
109
+ if right.is_a?(Intersection)
110
+ l = reduce_step(left, right.children[0]) || Conjunction.build(left, right.children[0])
111
+ return reduce_step(l, right.children[1]) || Conjunction.build(l, right.children[1])
112
+ end
113
+
114
+ # An attribute constraint intersects into `left`'s clause on the same
115
+ # attribute — like Constraint.narrow intersects Ranges — or stacks on when
116
+ # there is none. `String.where(size: 0..40) / .where(size: 10..100)` ->
117
+ # `.where(size: 10..40)`, one `String` check.
118
+ return narrow_attribute(left, right) if right.is_a?(AttributeValueMatch)
119
+
120
+ # Two adjacent converting steps whose boundary is provable at build time
121
+ # fuse into one node: `(A -> B) >> (B -> C)` becomes `(A -> C)` running
122
+ # both fns, dropping the redundant out/in checks between them. fuse_with
123
+ # carries its own subtype proof, so it is sound from #>> and #/ alike.
124
+ if (fused = left.fuse_with(right))
125
+ return fused
126
+ end
127
+
128
+ reparent_refinement(left, right) || absorb_boundary(left, right)
129
+ end
130
+
131
+ # When `right` is a refinement (a `Constraint` chain) whose ROOT is a base-type
132
+ # (Module) gate that `left`'s output already guarantees, that gate is a duplicated
133
+ # runtime check: re-parent `right`'s refinement matchers onto `left` and drop it.
134
+ # Nil when it declines, which leaves the pair to the absorption rung.
135
+ #
136
+ # Keyed on the root TYPE only (`Subtyping.subtype?(left_output, root)`), NOT on matcher
137
+ # values — so `Integer[0..100] >> Integer[-10..110]` becomes
138
+ # `Integer[0..100][-10..110]` (the `-10..110` range is preserved), not the
139
+ # value-subsumed `Integer[0..100]` (that would be rung 2).
140
+ #
141
+ # matchers=[-10..110], root=Constraint(::Integer), left guarantees Integer
142
+ # => Constraint(-10..110, base: Integer[0..100]) == Integer[0..100][-10..110]
143
+ #
144
+ # Degenerate `left >> Integer` (`matchers == []`) returns `left` — a pure
145
+ # redundant type gate removed.
146
+ def reparent_refinement(left, right)
147
+ return nil unless right.is_a?(Constraint)
148
+
149
+ matchers = [] # innermost-first, excludes the root gate
150
+ node = right
151
+ while node.is_a?(Constraint) && node.base
152
+ matchers.unshift(node.matcher)
153
+ node = node.base
154
+ end
155
+ root = node
156
+ return nil unless root.is_a?(Constraint) && SemanticMatcher.nominal?(root.matcher)
157
+ return nil unless Subtyping.subtype?(Subtyping.resolved_output(left), root)
158
+
159
+ # Stack right's refinements onto left; Constraint.narrow intersects Ranges
160
+ # so `Integer[0..100] >> Integer[0..]` collapses to `Integer[0..100]`.
161
+ matchers.reduce(left) { |acc, m| Constraint.narrow(acc, m) }
162
+ end
163
+
164
+ # LAST RUNG. A typed step runs its boundary types as steps, so a plain type next
165
+ # to one can move into the matching slot: `Types::Integer >> a_proc >>
166
+ # Types::Float` becomes the single `(Integer -> Float)` instead of a three-node
167
+ # chain with an `Any` hop in the middle. The node owns the conditions (see
168
+ # Function#absorb_output / #absorb_input) and at most one side can accept, since
169
+ # each requires the OTHER side to be a value-preserving type — which a typed step
170
+ # never is, and a seam between two of those is #fuse_with's.
171
+ #
172
+ # Last in the ladder, so a pair another rule reduces gets that reduction instead:
173
+ # `#[]` on a transform re-parents (`String.transform(:to_i)[0..10]`), and a
174
+ # redundant gate is dropped rather than absorbed.
175
+ def absorb_boundary(left, right)
176
+ left.absorb_output(right) || right.absorb_input(left)
177
+ end
178
+
179
+ # Narrow `left` by attribute constraint `avm`: intersect it into `left`'s
180
+ # existing clause on the same attribute+base type (mirroring how
181
+ # Constraint.narrow intersects Range matchers), or stack it on when there is
182
+ # no such clause. Always reduces (never bails) — an AVM is a value-narrowing
183
+ # refinement, so there is no duplicated type gate to keep it apart.
184
+ def narrow_attribute(left, avm)
185
+ merge_attribute_into(left, avm) || Conjunction.build(left, avm)
186
+ end
187
+
188
+ # `left` rebuilt with `avm` merged into its matching same-attribute clause,
189
+ # or nil when `left` has none (the caller then stacks). Prefers the outermost
190
+ # (most-recently-added) clause.
191
+ def merge_attribute_into(left, avm)
192
+ case left
193
+ when AttributeValueMatch
194
+ return nil unless left.attr_name == avm.attr_name && compatible_base?(left.type, avm.type)
195
+
196
+ merged = intersect_attribute_values(left.value, avm.value)
197
+ return nil if merged.nil?
198
+ return Types::Never if merged.equal?(Constraint::EMPTY) # unsatisfiable clause ⇒ bottom
199
+
200
+ AttributeValueMatch.new(left.type, left.attr_name, merged)
201
+ when Conjunction
202
+ # A conjunct that folds to Never makes the whole node uninhabitable ⇒ Never.
203
+ if (right = merge_attribute_into(left.children[1], avm))
204
+ right.is_a?(NeverClass) ? right : Conjunction.build(left.children[0], right)
205
+ elsif (leftc = merge_attribute_into(left.children[0], avm))
206
+ leftc.is_a?(NeverClass) ? leftc : Conjunction.build(leftc, left.children[1])
207
+ end
208
+ end
209
+ end
210
+
211
+ # Intersect two attribute-constraint values into a single value, `nil` to keep
212
+ # the two clauses stacked, or `Constraint::EMPTY` when the overlap is provably
213
+ # empty (the caller turns that into Never). Raw Ranges/Sets intersect to their
214
+ # (possibly narrower, possibly empty) overlap via Constraint.merge_matchers; a
215
+ # Plumb-typed value reduces only by subsumption — keeping the narrower — and
216
+ # otherwise stays stacked (intersecting two arbitrary Plumb types into one
217
+ # clause isn't representable).
218
+ def intersect_attribute_values(a, b)
219
+ return a if a == b
220
+
221
+ if a.is_a?(Composable) || b.is_a?(Composable)
222
+ return a if Subtyping.value_subtype?(a, b)
223
+ return b if Subtyping.value_subtype?(b, a)
224
+
225
+ nil
226
+ else
227
+ Constraint.merge_matchers(a, b)
228
+ end
229
+ end
230
+
231
+ # Two same-attribute clauses may merge when their base types are subtype-
232
+ # comparable — so a clause built on `String` and one built on the accumulated
233
+ # `String.where(size: …)` (as chained `#where` produces) still fold together.
234
+ def compatible_base?(a, b)
235
+ a == b || Subtyping.subtype?(a, b) || Subtyping.subtype?(b, a)
236
+ end
237
+
238
+ # In `left >> right`, is `right` a no-op that `left` already guarantees?
239
+ # True when `right` preserves values AND every value `left` produces already
240
+ # satisfies it (`left <= right`), so `right` can neither reject nor change
241
+ # them — eg. `String.where(size: 3..10) >> String.where(size: 0..)` drops the
242
+ # vacuous `size: 0..`. This is what reduce_step does for a Constraint chain,
243
+ # generalized to any value-preserving refinement (a `where`/AVM And, a nested
244
+ # Or). It tests REAL subsumption via Subtyping.subtype?, not check_composable!'s type-
245
+ # compat check — a value-narrowing refinement (AVM) opts out of the latter
246
+ # (its #input_type is Any), so check_composable! can't tell it apart.
247
+ def redundant_refinement?(left, right)
248
+ # `right` is the dropped side (kept: left). Don't drop it if it carries
249
+ # wrapper identity Subtyping.subtype? now sees through — unless it equals left,
250
+ # where left already IS that identity. eg. `String[EMAIL] >> Types::Email`
251
+ # keeps the And so the :email node (and its JSON-schema format) survives.
252
+ if Subtyping.value_preserving?(right) && Subtyping.subtype?(left, right) &&
253
+ (left == right || !Subtyping.identity_wrapper?(right))
254
+ return true
255
+ end
256
+
257
+ redundant_record_refinement?(left, right)
258
+ end
259
+
260
+ # The record case of `redundant_refinement?`. A HashClass is NOT value-
261
+ # preserving in general — a non-inclusive record drops undeclared keys — so
262
+ # the generic test above never fires for it. `left >> right` (both records)
263
+ # still reduces to `left` when `right` merely re-validates every value `left`
264
+ # produces without dropping or changing anything. Sound sufficient conditions:
265
+ #
266
+ # - both are plain records (no typed/pattern keys — only literal keys and an
267
+ # optional `_` catch-all — so key-keeping is decidable);
268
+ # - the same declared (literal) key set — `right` drops none of `left`'s keys
269
+ # and requires none `left` lacks;
270
+ # - `left <= right` — `right`'s fields are supertypes with compatible
271
+ # optionality, so it rejects nothing `left` emits;
272
+ # - every `right` field is value-preserving — `right` coerces nothing;
273
+ # - if `left` carries a catch-all (so it emits arbitrary extra keys), `right`
274
+ # carries a value-preserving catch-all that covers it — otherwise `right`
275
+ # would drop or reject those extra keys.
276
+ #
277
+ # Anything short keeps the And (`right` might drop keys or convert values —
278
+ # eg. the front/back coercion `Hash[price: Int] >> Hash[price: Int.build(Money)]`
279
+ # must NOT collapse).
280
+ def redundant_record_refinement?(left, right)
281
+ return false unless left.is_a?(HashClass) && right.is_a?(HashClass)
282
+ return false unless plain_record?(left) && plain_record?(right)
283
+ return false unless same_literal_keys?(left, right)
284
+ return false unless catch_all_preserved?(left, right)
285
+ return false unless Subtyping.subtype?(left, right)
286
+
287
+ right.literal_fields.all? { |_key, field| Subtyping.value_preserving?(field) }
288
+ end
289
+
290
+ # A record whose only keys are literal names plus an optional `_` catch-all
291
+ # (no typed/pattern keys, whose key-keeping we don't reason about here).
292
+ def plain_record?(hash) = hash.matcher_fields.all? { |key, _| key.catch_all? }
293
+
294
+ # Do two records declare the same set of literal key names?
295
+ def same_literal_keys?(left, right)
296
+ lk = left.literal_fields.keys
297
+ rk = right.literal_fields.keys
298
+ lk.size == rk.size && lk.all? { |k| rk.any? { |o| o.eql?(k) } }
299
+ end
300
+
301
+ # Does `right` keep and preserve `left`'s catch-all tail (the keys `left` emits
302
+ # beyond its declared ones)? Vacuously true when `left` has no catch-all.
303
+ def catch_all_preserved?(left, right)
304
+ lc = left.catch_all_type
305
+ return true if lc.nil?
306
+
307
+ rc = right.catch_all_type
308
+ !rc.nil? && Subtyping.value_preserving?(rc) && Subtyping.subtype?(lc, rc)
309
+ end
310
+
311
+ # Join-dual of `reduce_step`: absorption for `a | b`. If one branch's value set
312
+ # is contained in another's, the union equals the wider branch
313
+ # (`a ∪ b == b` when `a <= b`), so the narrower is dropped — and duplicate
314
+ # branches dedupe. Returns the surviving type, or nil when nothing can go.
315
+ #
316
+ # A JOIN IS N-ARY. `A | B | C` is stored as a nested pair but means one flat branch
317
+ # set, so absorption has to see all of it — comparing only the two operands makes
318
+ # the result depend on the order they were written:
319
+ #
320
+ # Numeric | String | Integer => Numeric | String (Integer absorbed)
321
+ # Integer | String | Numeric => (Integer | String) | Numeric
322
+ #
323
+ # Both accept the same values, but the second keeps `Integer` even though
324
+ # `Integer <= Numeric`, because `subtype?(Union(Integer, String), Numeric)` requires
325
+ # EVERY branch to be within Numeric. The redundant branch then costs a failed match
326
+ # on every value falling through to it.
327
+ #
328
+ # So flatten, absorb across the set, re-fold. Order among survivors is preserved —
329
+ # a join is commutative, and keeping it stable avoids churning `#inspect` and a
330
+ # JSON Schema's `anyOf` order.
331
+ def reduce_union(a, b)
332
+ branches = union_branches(a) + union_branches(b)
333
+ survivors = absorb_branches(branches)
334
+ return nil if survivors.size == branches.size # nothing to drop — leave the pair alone
335
+ return survivors.first if survivors.size == 1
336
+
337
+ survivors.drop(1).reduce(survivors.first) do |acc, branch|
338
+ factor_union(acc, branch) || Disjunction.build(acc, branch)
339
+ end
340
+ end
341
+
342
+ # ONLY Union, never Or: a choice is left-biased and its branches may convert, so its
343
+ # order is semantic and dropping one is not a type-level decision.
344
+ def union_branches(type)
345
+ type.is_a?(Union) ? type.children.flat_map { |c| union_branches(c) } : [type]
346
+ end
347
+
348
+ # Drop every branch another one already covers, keeping first-seen order.
349
+ def absorb_branches(branches)
350
+ branches.each_with_object([]) do |candidate, survivors|
351
+ next if survivors.any? { |s| absorbs?(s, candidate) }
352
+
353
+ survivors.reject! { |s| absorbs?(candidate, s) }
354
+ survivors << candidate
355
+ end
356
+ end
357
+
358
+ # Does `wider` cover `narrower`, such that dropping `narrower` changes nothing?
359
+ #
360
+ # Guarded to VALUE-PRESERVING branches: `subtype?` identifies a Function by its
361
+ # OUTPUT type, so `subtype?(String->Integer, Numeric)` holds even though that branch
362
+ # accepts Strings a bare Numeric rejects, and absorbing would drop a coercion.
363
+ # Identical branches dedupe regardless — the survivor IS the dropped node.
364
+ def absorbs?(wider, narrower)
365
+ return true if wider == narrower
366
+ return false unless Subtyping.value_preserving?(wider) && Subtyping.value_preserving?(narrower)
367
+ # Never absorb across a wrapper: subtype? sees through Policy/Metadata/Node, so
368
+ # the drop would lose the identity one carries (eg. `Types::Email |
369
+ # Types::String` must keep both). @see Subtyping.identity_wrapper?
370
+ return false if Subtyping.identity_wrapper?(wider) || Subtyping.identity_wrapper?(narrower)
371
+
372
+ Subtyping.subtype?(narrower, wider)
373
+ end
374
+
375
+ # Distributive factoring — the join-dual of reduce_union's absorption:
376
+ # `(P >> A) | (P >> B)` factors to `P >> (A | B)` when the shared left prefix
377
+ # `P` is value-preserving, so `P` is validated once instead of per branch.
378
+ #
379
+ # SOUNDNESS. The un-factored union runs `P` once per branch (the Or re-runs
380
+ # it in the right branch when the left fails). Factoring runs it once, which
381
+ # is behaviour-preserving iff `P` is referentially transparent. A VALUE-
382
+ # PRESERVING `P` guarantees this: it never alters the value, so both forms
383
+ # feed the divergent suffixes the identical input. The guard is per prefix
384
+ # STEP (below), so the suffixes may be anything (incl. transforms) and a
385
+ # transform prefix — whose purity is unprovable — halts the shared prefix.
386
+ #
387
+ # Both branches are flattened to their `>>` step lists (#steps unwraps already-
388
+ # factored `:refined_union` nodes, so a third branch folds into an existing
389
+ # `P >> (…)` rather than re-checking `P` — n-ary folding). The longest common
390
+ # value-preserving step prefix is pulled out; the divergent tails become the
391
+ # Or. Runs AFTER reduce_union, so a prefix consuming a whole branch was
392
+ # already absorbed. The result is decorated `:refined_union` so visitors fold
393
+ # the type-less disjunction into P's type spec; runtime is the plain And.
394
+ def factor_union(a, b)
395
+ sa = steps(a)
396
+ sb = steps(b)
397
+ k = common_step_prefix(sa, sb)
398
+ return nil if k.zero? # disjoint prefixes — nothing shared
399
+ return nil if k == sa.size || k == sb.size # one is a prefix of the other (absorption's job)
400
+
401
+ inner = Disjunction.build(rebuild(sa.drop(k)), rebuild(sb.drop(k)))
402
+ Conjunction.build(rebuild(sa.take(k)), inner).as_node(:refined_union)
403
+ end
404
+
405
+ # Flatten a type into its `>>` execution steps. A fused Constraint chain
406
+ # (`String[/d/]`) unfolds to its base then a bare matcher refinement; an And
407
+ # to its two sides; an already-factored `:refined_union` node is peeled so its
408
+ # shared prefix re-exposes for n-ary folding. Everything else (root gate,
409
+ # transform, Or, container) is atomic. Only `:refined_union` nodes are peeled
410
+ # — Metadata/Policy/other Nodes carry identity we must not factor away.
411
+ def steps(type)
412
+ type = type.type if type.is_a?(Composable::Node) && type.node_name == :refined_union
413
+ case type
414
+ when Conjunction then type.children.flat_map { |c| steps(c) }
415
+ when Constraint then type.base ? steps(type.base) + [Constraint.new(type.matcher)] : [type]
416
+ else [type]
417
+ end
418
+ end
419
+
420
+ # Length of the longest leading run of steps the two lists agree on AND that
421
+ # is value-preserving — the sound-to-factor shared prefix.
422
+ def common_step_prefix(sa, sb)
423
+ max = sa.size < sb.size ? sa.size : sb.size
424
+ i = 0
425
+ i += 1 while i < max && sa[i] == sb[i] && Subtyping.value_preserving?(sa[i])
426
+ i
427
+ end
428
+
429
+ # Re-fold a step list into a type, fusing consecutive Constraint refinements
430
+ # back into a Constraint chain (`[String, /d/] → String[/d/]`) and using And at
431
+ # a non-fusable boundary (a transform or an Or suffix).
432
+ def rebuild(list)
433
+ list.reduce { |left, step| compose_step(left, step) }
434
+ end
435
+
436
+ def compose_step(left, right)
437
+ if left.is_a?(Constraint) && right.is_a?(Constraint) && right.base.nil?
438
+ Constraint.narrow(left, right.matcher)
439
+ else
440
+ Conjunction.build(left, right)
441
+ end
442
+ end
443
+ end
444
+ end
data/lib/plumb/or.rb CHANGED
@@ -1,40 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'plumb/composable'
4
+ require 'plumb/disjunction'
4
5
 
5
6
  module Plumb
7
+ # LEFT-BIASED CHOICE — try `left`, fall back to `right`, built by
8
+ # `Composable#|` when some branch changes the value. The all-branches-preserving
9
+ # case (a genuine set union) is {Plumb::Union}; see {Plumb::Disjunction}.
10
+ #
11
+ # A choice's ends differ, because a converting branch accepts an input the join
12
+ # of the outputs would reject: `Integer | String.transform(:to_i)` consumes
13
+ # `Integer | String` and produces `Integer | Integer`.
6
14
  class Or
7
15
  include Composable
8
-
9
- attr_reader :children
10
-
11
- def initialize(left, right)
12
- @left = left
13
- @right = right
14
- @children = [left, right].freeze
15
- freeze
16
- end
17
-
18
- private def _inspect
19
- %((#{@left.inspect} | #{@right.inspect}))
16
+ include Disjunction
17
+
18
+ # (A | B).output_type == A.output_type | B.output_type. A converting branch
19
+ # produces something other than what it consumed, so this genuinely has to map
20
+ # over the branches — which is exactly what Union does NOT have to do.
21
+ # #input_type is shared with Union; see Disjunction#input_type.
22
+ def output_type
23
+ l = @left.output_type
24
+ r = @right.output_type
25
+ l.equal?(@left) && r.equal?(@right) ? self : Disjunction.build(l, r)
20
26
  end
21
27
 
22
- def call(result)
23
- left_result = @left.call(result)
24
- return left_result if left_result.valid?
25
-
26
- right_result = @right.call(result)
27
- if right_result.valid?
28
- right_result
29
- else
30
- # Decrease Array allocations slightly
31
- # OR can be really expensive in composite ORed types
32
- left_errors = left_result.errors.is_a?(Array) ? left_result.errors : [left_result.errors]
33
- right_errors = right_result.errors.is_a?(Array) ? right_result.errors.first : right_result.errors
34
- left_errors << right_errors
35
-
36
- right_result.invalid(errors: left_errors)
37
- end
38
- end
28
+ # A disjunction preserves the value only if EVERY branch does — a branch
29
+ # that transforms (a coercion) changes it when taken. Recurses through the
30
+ # cached accessor so shared subtrees are memoized once.
31
+ #
32
+ # Kept computed rather than hardcoded false: Disjunction.build only routes the
33
+ # converting case here, but `Or.new` remains callable directly (the Decorator
34
+ # rebuilds by class), so an Or may still hold two preserving branches.
35
+ def value_preserving? = children.all? { |c| Plumb::Subtyping.value_preserving?(c) }
39
36
  end
40
37
  end
@@ -17,6 +17,11 @@ module Plumb
17
17
  def call(result)
18
18
  @block.call(@step, result)
19
19
  end
20
+
21
+ # Opaque middleware wrapper (its wrapped step may be a plain proc), so its
22
+ # input/output types are unknown — Any (top). Opts out of #>> checks.
23
+ def input_type = Types::Any
24
+ def output_type = Types::Any
20
25
  end
21
26
 
22
27
  class << self
@@ -38,8 +43,7 @@ module Plumb
38
43
  attr_reader :children
39
44
 
40
45
  def initialize(type: Types::Any, freeze_after: true, &setup)
41
- @type = type
42
- @children = [type].freeze
46
+ self.type = type
43
47
  @around_blocks = self.class.around_blocks.dup
44
48
 
45
49
  configure(&setup) if block_given?
@@ -50,17 +54,51 @@ module Plumb
50
54
  @type.call(result)
51
55
  end
52
56
 
57
+ # A Pipeline is a transparent wrapper around its accumulated composition, so
58
+ # it delegates its type-flow to that inner type. When the inner steps are
59
+ # opaque (plain procs/around-wrapped steps) those resolve to Any and the
60
+ # Pipeline still opts out of #>> composition type-checks; when they carry
61
+ # real types, the Pipeline participates accurately.
62
+ def input_type = @type.input_type
63
+ def output_type = @type.output_type
64
+
65
+ # Use the accumulated composition as this pipeline's subtype identity. Merely
66
+ # delegating input/output flow would let composition checks work while leaving
67
+ # the Pipeline unrelated to the same type inside containers.
68
+ # @return [Composable]
69
+ def subtype_identity = @type
70
+
71
+ # @return [Boolean] whether the accumulated composition preserves values
72
+ def value_preserving? = @type.value_preserving?
73
+
74
+ # Add a step. A pipeline is a sequence of validators/coercions that
75
+ # progressively narrows its data, so steps are **non-strict** by default:
76
+ # `#step` chains with `#/`, skipping the composition type-check (a later step
77
+ # may legitimately narrow what an earlier one produced). Use `#step!` when you
78
+ # want the strict `#>>` check — a build-time error if a step could never
79
+ # accept the previous step's output.
80
+ #
81
+ # pl.step(type_or_callable) # non-strict chain (via #/)
82
+ # pl.step!(type_or_callable) # strict #>> chain (type-checked)
83
+ # pl.step(output_type) { |r| ... } # a Function: validates the current
84
+ # # output, runs the block, and declares
85
+ # # `output_type` as the produced type
53
86
  def step(callable = nil, &block)
54
- callable ||= block
55
- unless is_a_step?(callable)
56
- raise ArgumentError,
57
- "#step expects an interface #call(Result) Result, but got #{callable.inspect}"
58
- end
87
+ add_step(callable, strict: false, &block)
88
+ end
59
89
 
60
- callable = prepare_step(callable)
61
- callable = @around_blocks.reverse.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
62
- @type >>= callable
63
- self
90
+ # Strict counterpart of `#step`: chains with `#>>`, so it raises
91
+ # `Plumb::TypeError` at build time if the step can't accept what the pipeline
92
+ # currently produces. See #step.
93
+ #
94
+ # NOTE the `output_type` + block form cannot fail this check: the step's input type
95
+ # is DERIVED from what the pipeline already produces (see #add_step), so the two
96
+ # match by construction and `#step!` behaves as `#step` there. That is not a gap —
97
+ # `pl.step!(::Integer) { … }` after a String pipeline is legitimate, because the
98
+ # block is what does the converting; only the value it RETURNS is checked, against
99
+ # the declared output type.
100
+ def step!(callable = nil, &block)
101
+ add_step(callable, strict: true, &block)
64
102
  end
65
103
 
66
104
  def around(callable = nil, &block)
@@ -70,6 +108,56 @@ module Plumb
70
108
 
71
109
  private
72
110
 
111
+ # #children has to track @type. Composable#== compares children and every visitor
112
+ # walks them, so capturing them once in #initialize — before `configure` had added
113
+ # any steps — meant a Pipeline reported no steps at all: `pl.children` was
114
+ # `[Types::Any]` however many steps it had, any two pipelines with the same
115
+ # starting type compared `==`, and visitors saw an empty composition.
116
+ #
117
+ # Assigned together through here so the two cannot drift, and eagerly rather than
118
+ # lazily because #initialize freezes the Pipeline afterwards.
119
+ private def type=(new_type)
120
+ @type = new_type
121
+ @children = [new_type].freeze
122
+ end
123
+
124
+ # Shared body for #step / #step!. Both forms chain with `#>>` when strict and `#/`
125
+ # otherwise; only what gets chained differs.
126
+ #
127
+ # The `output_type` + block form used to bypass the operators entirely, building
128
+ # `Function.new(@type, out, block)` — using the Function's INPUT slot as the chain
129
+ # link, so the accumulated pipeline became the new step's input check. That runs
130
+ # correctly, but it never reaches #>> / #/ and therefore never reaches the
131
+ # optimiser: each block step nested inside the last, and consecutive ones could not
132
+ # reduce.
133
+ #
134
+ # It is built as an ordinary step and chained like every other one instead. Its
135
+ # declared input is what the pipeline currently PRODUCES — the previous step's
136
+ # output type, or the pipeline's initial type when it is the first step — so the
137
+ # step reports honestly what it accepts, and the boundary between two of them is
138
+ # provable by construction, which is what lets Function#fuse_with collapse
139
+ # consecutive block steps into a single Function rather than nesting them.
140
+ #
141
+ # The block form is deliberately not passed through #prepare_step or the `around`
142
+ # wrappers, which is how it has always behaved.
143
+ def add_step(callable, strict:, &block)
144
+ if !callable.nil? && block
145
+ callable = Function.new(Plumb::Subtyping.resolved_output(@type), Composable.wrap(callable), block)
146
+ else
147
+ callable ||= block
148
+ unless is_a_step?(callable)
149
+ raise ArgumentError,
150
+ "#step expects an interface #call(Result) Result, but got #{callable.inspect}"
151
+ end
152
+
153
+ callable = prepare_step(callable)
154
+ callable = @around_blocks.reverse.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
155
+ end
156
+
157
+ self.type = strict ? (@type >> callable) : (@type / callable)
158
+ self
159
+ end
160
+
73
161
  def configure(&setup)
74
162
  case setup.arity
75
163
  when 1