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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+ require 'plumb/function'
5
+
6
+ module Plumb
7
+ # A reversible, type-aware transform between two representations of a value —
8
+ # an input type and an output type. Neither side need be a "wire" format: a
9
+ # serialized string and a parsed Ruby object, or one in-memory data structure
10
+ # and another, are equally valid. Declared class-based, with the types given
11
+ # as a one-pair Hash literal:
12
+ #
13
+ # DateRange = Types::Range[Types::Date]
14
+ # JSONDateRange = Types::Hash[from: Types::Date, to: Types::Date]
15
+ #
16
+ # class JSONDateRangeEncoder < Plumb::Encoder[JSONDateRange => DateRange]
17
+ # def encode(range) = { from: range.begin, to: range.end }
18
+ # def decode(hash) = hash[:from]..hash[:to]
19
+ # end
20
+ #
21
+ # The DEFAULT direction is the declared one (`input => output`, running
22
+ # #decode) — an encoder composes like a normal Function:
23
+ #
24
+ # JSONDateRange >> JSONDateRangeEncoder >> DateRange # decode
25
+ #
26
+ # Composed next to a type that matches its OUTPUT side instead, it
27
+ # transparently runs the inverse (#encode, input/output swapped):
28
+ #
29
+ # DateRange >> JSONDateRangeEncoder >> JSONDateRange # encode
30
+ #
31
+ # When the context gives no signal (schema literals, `#/`, `.parse`, an
32
+ # opaque/Any neighbour) the default direction is used; `.decoding` /
33
+ # `.encoding` are the explicit escape hatches. Each direction is a plain
34
+ # {Function} whose proc runs the encoder's method — so subtyping, `#>>`
35
+ # composition checks and reductions, visitors and the JSON Schema all work
36
+ # on it with no encoder-specific machinery.
37
+ class Encoder
38
+ # Build the parameterized superclass: `Encoder[Input => Output]`.
39
+ # The pair is a one-pair Hash literal; both sides are wrapped as Plumb
40
+ # types (a raw Hash becomes a HashClass, so
41
+ # `Encoder[{from: Date} => DateRange]` works).
42
+ #
43
+ # @param pair [Hash] a one-pair Hash: input type => output type
44
+ # @return [Class]
45
+ def self.[](pair)
46
+ unless pair.is_a?(::Hash) && pair.size == 1
47
+ raise ArgumentError,
48
+ "#{self}[Input => Output] expects a one-pair Hash (eg. Encoder[Types::String => Types::Date]), " \
49
+ "got #{pair.inspect}"
50
+ end
51
+
52
+ input, output = pair.first
53
+ input = Composable.wrap(input)
54
+ output = Composable.wrap(output)
55
+ # Singleton methods (unlike class ivars) are inherited by the user's
56
+ # subclass, so `class Foo < Encoder[A => B]` gets .input_type/.output_type.
57
+ Class.new(self) do
58
+ define_singleton_method(:input_type) { input }
59
+ define_singleton_method(:output_type) { output }
60
+ end
61
+ end
62
+
63
+ class << self
64
+ def input_type
65
+ raise ArgumentError,
66
+ "#{inspect} declares no types; define it as `class #{inspect} < Plumb::Encoder[Input => Output]`"
67
+ end
68
+
69
+ alias output_type input_type
70
+
71
+ # The input type to rewrite for a particular matched output type.
72
+ # Fixed for a normal encoder — the declared input type, regardless of what
73
+ # matched. A GENERIC encoder (one whose output_type is a container top like
74
+ # Types::Range, matching any Range[member]) overrides this to BUILD its input
75
+ # from the matched node's structure, so `Range[Date]` yields a
76
+ # `from: Date, to: Date` input and `Range[Integer]` a `from: Integer, …` one.
77
+ # The codec then rewrites that member type through itself as usual. Mirrors
78
+ # how the Rewriter reads an Array's element type off the matched node.
79
+ # @param matched_type [Composable] the type this encoder matched
80
+ # @return [Composable]
81
+ def input_type_for(_matched_type) = input_type
82
+
83
+ # The default step: the declared `input_type -> output_type` direction,
84
+ # running #decode. A plain Function — #call validates the input type,
85
+ # runs the encoder's method, and validates the produced value against
86
+ # the output type (a wrong return value becomes an invalid Result, not
87
+ # silent corruption); subtyping identity, accepted type and `#>>` checks
88
+ # all come with Function. Memoized per subclass (steps are frozen).
89
+ # @return [Function]
90
+ def decoding
91
+ @decoding ||= build_step(:decode, input_type, output_type)
92
+ end
93
+
94
+ # The inverse step: `output_type -> input_type`, running #encode.
95
+ # @return [Function]
96
+ def encoding
97
+ @encoding ||= build_step(:encode, output_type, input_type)
98
+ end
99
+
100
+ # A direction step with one or both sides substituted — used by
101
+ # Codec::Rewriter to splice in a rewritten input type or a narrowed
102
+ # output type, keeping each rewritten field a single Function node.
103
+ #
104
+ # The substitutions name the ENCODER's declared sides, not the step's
105
+ # positions, so the caller doesn't have to transpose them per direction:
106
+ # `input_side` always replaces the declared input, `output_side` the
107
+ # declared output, whichever end of the step each lands on.
108
+ # @return [Function]
109
+ def step(direction, input_side: nil, output_side: nil)
110
+ base = direction == :decode ? decoding : encoding
111
+ return base unless input_side || output_side
112
+
113
+ in_t, out_t = direction == :decode ? [input_side, output_side] : [output_side, input_side]
114
+ Function.new(in_t || base.input_type, out_t || base.output_type, base.fn)
115
+ end
116
+
117
+ # Composition-context direction pick when the encoder is the RIGHT
118
+ # operand (see Composable#to_plumb_type). For `left >> Enc`, orient by
119
+ # what `left` produces; for a union/intersection, branches describe the
120
+ # same produced value, so orient by what the sibling produces relative to
121
+ # what each direction produces. Falls back to the default direction —
122
+ # the ordinary composition check raises if it genuinely doesn't fit.
123
+ def to_plumb_type(op:, left:)
124
+ produced = Plumb::Subtyping.resolved_output(Composable.wrap(left))
125
+ # `left >> Enc` feeds `left`'s output into the step; in a union/
126
+ # intersection the branches describe the same produced value.
127
+ pick_direction(produced, op == :>> ? :consumes : :produces)
128
+ end
129
+
130
+ # Left-position composition (`Enc >> X`, `Enc | X`, ...): a Class has no
131
+ # Composable operators, so these singleton versions orient against the
132
+ # right operand and delegate to the oriented step.
133
+ def >>(other) = oriented_against(other, :>>) >> other
134
+ def |(other) = oriented_against(other, :|) | other
135
+ def &(other) = oriented_against(other, :&) & other
136
+
137
+ def /(other) = decoding / other
138
+
139
+ # Direct use runs the default direction — also the Composable.wrap hook
140
+ # for context-free positions (schema literals, `Array[Enc]`, `#/`).
141
+ def to_composable = decoding
142
+ def call(result) = decoding.call(result)
143
+ def resolve(...) = decoding.resolve(...)
144
+ def parse(...) = decoding.parse(...)
145
+
146
+ # Value-level conveniences for each direction.
147
+ def encode(value) = encoding.parse(value)
148
+ def decode(value) = decoding.parse(value)
149
+
150
+ private
151
+
152
+ # Build one direction as a plain Function. The transform proc wraps the
153
+ # encoder's method: exceptions raised inside #encode/#decode become
154
+ # invalid Results.
155
+ def build_step(direction, in_type, out_type)
156
+ if instance_method(direction).owner == Encoder
157
+ raise ArgumentError, "#{inspect} must implement ##{direction} to be used in this direction"
158
+ end
159
+
160
+ run = new.freeze.method(direction)
161
+ encoder_class = self
162
+ step_proc = lambda do |result|
163
+ result.valid!(run.call(result.value))
164
+ rescue StandardError => e
165
+ result.invalid!(errors: "#{encoder_class.inspect}##{direction} failed: #{e.message}")
166
+ end
167
+ Function.new(in_type, out_type, step_proc)
168
+ end
169
+
170
+ # Orientation for the LEFT-position operators above, where the encoder
171
+ # must PRODUCE something the sibling fits: for `>>` the sibling consumes
172
+ # what we produce, so orient by what it ACCEPTS; in a union/intersection
173
+ # both sides describe the same resulting value, so orient by what it
174
+ # produces.
175
+ def oriented_against(other, op)
176
+ other = Composable.wrap(other)
177
+ produced = op == :>> ? Plumb::Subtyping.accepted_type(other) : Plumb::Subtyping.resolved_output(other)
178
+ pick_direction(produced, :produces)
179
+ end
180
+
181
+ # Pick a direction by matching `candidate` against the two declared sides.
182
+ # `orient` says which relation `candidate` stands in to this encoder:
183
+ # :consumes — it is fed INTO the step (a `>>` neighbour's output), so it
184
+ # must match the side the step reads;
185
+ # :produces — it is what the step must yield (a union sibling's value,
186
+ # or a `>>` right side's accepted input).
187
+ # Decode (the declared direction) wins ties and is the fallback: encode is
188
+ # chosen only when it is the unambiguous fit, so an ambiguous, opaque or
189
+ # unmatched candidate keeps the declared direction and the ordinary
190
+ # composition check reports it if it genuinely doesn't fit.
191
+ def pick_direction(candidate, orient)
192
+ decode_side, encode_side = orient == :consumes ? [input_type, output_type] : [output_type, input_type]
193
+ return encoding if Plumb::Subtyping.subtype?(candidate, encode_side) &&
194
+ !Plumb::Subtyping.subtype?(candidate, decode_side)
195
+
196
+ decoding
197
+ end
198
+ end
199
+
200
+ # The value-level contract. These are also the sentinels build_step checks
201
+ # (`instance_method(direction).owner == Encoder`), so using an encoder in a
202
+ # direction it doesn't implement fails at BUILD time with a better message
203
+ # than these — which is why they stay deliberately plain.
204
+ def encode(_value) = raise(NotImplementedError, "#{self.class} must implement #encode(value)")
205
+ def decode(_value) = raise(NotImplementedError, "#{self.class} must implement #decode(value)")
206
+ end
207
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/composable'
4
+ require 'plumb/typed_step'
5
+
6
+ module Plumb
7
+ # A value-converting join, built by `Composable#transform` / `#build`.
8
+ # Unlike `And` (a refinement), a `Function` changes the value: it validates
9
+ # the input (`input_type`), applies `fn`, then declares `output_type`
10
+ # as the produced type. The input constraints do NOT carry through to the
11
+ # output, so for subtyping a `Function` is identified by what it *produces*
12
+ # (its `output_type`). See Plumb::Subtyping.subtype?.
13
+ class Function
14
+ include Composable
15
+ include TypedStep
16
+
17
+ # Build a typed function from an input => output pair and a
18
+ # callable that produces the new value.
19
+ #
20
+ # Plumb::Function[String => Integer] { |result| result.valid(result.value.size) }
21
+ # Plumb::Function[callable, String => Integer]
22
+ #
23
+ # The callable takes and returns a Result (unlike Composable#transform, whose
24
+ # block takes a value). With no types, both ends default to Types::Any and
25
+ # this delegates to .opaque (use that directly if you want an #inspect label).
26
+ # When input and output are the same type and there's nothing to apply, this
27
+ # is just that type, so it's returned as-is.
28
+ #
29
+ # @param args [Array] (in => out), (callable), or (callable, in => out)
30
+ # @yield [Result] Result => Result
31
+ # @return [Composable]
32
+ def self.[](*args, &block)
33
+ input_type = Types::Any
34
+ output_type = Types::Any
35
+ fn = block || NOOP
36
+
37
+ case args
38
+ in []
39
+ # no types, no callable: defaults apply
40
+ in [clb, Hash => inout] if clb.respond_to?(:call)
41
+ raise ArgumentError, 'expected a callable or a block, not both' if block
42
+
43
+ fn = clb
44
+ input_type, output_type = __set_types(inout)
45
+ in [clb] if clb.respond_to?(:call)
46
+ raise ArgumentError, 'expected a callable or a block, not both' if block
47
+
48
+ fn = clb
49
+ in [Hash => inout]
50
+ input_type, output_type = __set_types(inout)
51
+ else
52
+ raise ArgumentError,
53
+ 'expected Plumb::Function[input_type => output_type], Plumb::Function[callable] ' \
54
+ "or Plumb::Function[callable, input_type => output_type]. Got #{args.inspect}"
55
+ end
56
+
57
+ return input_type if input_type == output_type && fn == NOOP
58
+
59
+ if fn == NOOP
60
+ raise ArgumentError,
61
+ "defined a function (#{input_type} -> #{output_type}), but no explicit " \
62
+ "transform block or callable given. \n" \
63
+ "Should be Plumb::Function[#{input_type} => #{output_type}] { |r| r.valid(new_value_here) }"
64
+ end
65
+
66
+ return opaque(fn) if input_type == Types::Any && output_type == Types::Any
67
+
68
+ new(input_type, output_type, fn)
69
+ end
70
+
71
+ # Build an OPAQUE function around a bare `#call(Result) => Result` callable
72
+ # — see #opaque?. This is what `Composable.wrap` produces for anything
73
+ # callable, and the canonical builder for the no-declared-types case:
74
+ # `Function[callable]` is sugar for it and returns the same thing.
75
+ #
76
+ # It exists separately only because it takes an `inspect` label, which `.[]`
77
+ # cannot: declaring any keyword there would make Ruby route the bare hash in
78
+ # `Function[callable, String => Integer]` into keywords ("unknown keyword:
79
+ # String") and break that form.
80
+ #
81
+ # A GuaranteedFunction, because an opaque function's output check IS `Any`,
82
+ # which no value can fail: skipping it is provably redundant rather than a
83
+ # shortcut, which is exactly what that subclass encodes. (The input check is
84
+ # `Any` too and equally redundant, but #call has to run *some* input check
85
+ # for typed functions, so that hop stays.)
86
+ #
87
+ # Takes the callable positionally, or as a block:
88
+ #
89
+ # Plumb::Function.opaque(some_callable)
90
+ # Plumb::Function.opaque(inspect: 'filtered') { |result| result.valid(...) }
91
+ #
92
+ # @param callable [#call, nil] Result => Result
93
+ # @param inspect [String, nil] label to #inspect as, instead of the types
94
+ # @param identity [Object, nil] what the step IS, for #==. An opaque function
95
+ # declares no types, so its callable is all that distinguishes it; pass a
96
+ # deterministic token when the block is built fresh per call (see
97
+ # Composable#invoke).
98
+ # @yield [Result] Result => Result
99
+ # @return [GuaranteedFunction]
100
+ def self.opaque(callable = nil, inspect: nil, identity: nil, &block)
101
+ raise ArgumentError, 'expected a callable or a block, not both' if callable && block
102
+
103
+ fn = callable || block
104
+ raise ArgumentError, 'expected a callable or a block' unless fn.respond_to?(:call)
105
+
106
+ GuaranteedFunction.new(Types::Any, Types::Any, fn, inspect:, identity:, wrapper: true)
107
+ end
108
+
109
+ def self.__set_types(inout)
110
+ return [Types::Any, Types::Any] if inout.empty?
111
+ raise ArgumentError, 'expected single key input_type => output_type' if inout.size > 1
112
+
113
+ input_type = Composable.wrap(inout.keys.first)
114
+ output_type = Composable.wrap(inout.values.first)
115
+ [input_type, output_type]
116
+ end
117
+ private_class_method :__set_types
118
+
119
+ # `fn` is the value-level callable — `#call(Result) => Result`. Exposed so
120
+ # the Decorator can rebuild the node around it, and so an opaque function
121
+ # can be resolved back to the object it wraps (see Plumb::Attributes.struct_class).
122
+ attr_reader :children, :input_type, :output_type, :fn, :identity
123
+
124
+ # @param inspect [String, nil] label to #inspect as, instead of the types
125
+ # @param identity [Object, nil] what this function IS, for `#==` — see #==.
126
+ # Defaults to `fn`, which is correct whenever `fn` is the caller's own
127
+ # callable rather than a wrapper built around it.
128
+ # @param wrapper [Boolean] whether this node merely WRAPS a caller-supplied
129
+ # callable — see #wraps_callable?. Set by .opaque, the only builder that does.
130
+ def initialize(input_type, output_type, fn = Plumb::NOOP, inspect: nil, identity: nil, wrapper: false)
131
+ @input_type = input_type
132
+ @output_type = output_type
133
+ @fn = fn
134
+ @inspect_label = inspect
135
+ @identity = identity || fn
136
+ @wraps_callable = wrapper
137
+ @children = [input_type, output_type].freeze
138
+ freeze
139
+ end
140
+
141
+ # Is this node a plain WRAPPER around a callable the caller handed to Plumb (what
142
+ # `Composable.wrap` / `.opaque` build), rather than a #transform / #build /
143
+ # coercion whose #fn is a lambda Plumb built around a value-level block? It is what
144
+ # singles out a node whose #fn is worth reaching for — a struct class, or a proc a
145
+ # decorator wants to replace. @see Plumb::Attributes.struct_class
146
+ #
147
+ # DECLARED at construction, and not derivable from the types, which say nothing
148
+ # about it: boundary absorption moves a neighbouring type INTO a wrapper's slot, so
149
+ # `Types::Integer >> some_proc` is a wrapper with a typed end. @see #absorb_input
150
+ def wraps_callable? = @wraps_callable
151
+
152
+ # Equal when they declare the same types AND apply the same transformation.
153
+ #
154
+ # The default Composable#== compares #children, which for a Function is only
155
+ # `[input_type, output_type]` — so it called any two `String -> Integer` steps
156
+ # equal regardless of what they do, and any two OPAQUE functions equal regardless
157
+ # of their callable. A reducer reading #== as node identity then dropped one:
158
+ # `wrap(proc_a) & wrap(proc_b)` returned just `proc_a`.
159
+ #
160
+ # Compared on #identity, not #fn, because #transform wraps the caller's callable in
161
+ # a FRESH lambda per call — comparing #fn would make two identically-built
162
+ # transforms unequal, and with them every schema containing one. #identity is the
163
+ # caller's own callable, or a deterministic token the builder chose (see
164
+ # Composable#build), so two anonymous blocks still compare unequal.
165
+ def ==(other)
166
+ other.is_a?(self.class) &&
167
+ other.input_type == input_type &&
168
+ other.output_type == output_type &&
169
+ other.identity == identity
170
+ end
171
+
172
+ # A Function's #children are [input_type, output_type], so rebuilding it around
173
+ # new ones also has to carry the callable, the inspect label and the #==
174
+ # identity — none of which a caller can see. @see Plumb::NodeMapper
175
+ def with_children(children)
176
+ self.class.new(children[0], children[1], @fn,
177
+ inspect: @inspect_label, identity: @identity, wrapper: @wraps_callable)
178
+ end
179
+
180
+ private def _inspect
181
+ @inspect_label || %((#{@input_type.inspect} -> #{@output_type.inspect}))
182
+ end
183
+
184
+ def call(result)
185
+ result.map(@input_type).map(@fn).map(@output_type)
186
+ end
187
+
188
+ # Wraps this function with its output check for fusion.
189
+ # @return [Proc]
190
+ # @see #fuse_with
191
+ protected def fn_with_output_check
192
+ fn = @fn
193
+ out = @output_type
194
+ ->(result) { result.map(fn).map(out) }
195
+ end
196
+
197
+ # Fuses compatible functions while retaining this function's output check;
198
+ # declared subtype compatibility does not prove what the callable returns.
199
+ # Returns nil (no fusion) unless:
200
+ # - both #calls are the standard input->fn->output mapping (see #fusible?);
201
+ # - what self produces is a DIRECT subtype of what other declares as
202
+ # input. check_composable! is looser — it relaxes container fields via
203
+ # accepted_type (the `encode >> decode` case), and there other's input
204
+ # check does real per-field work and must keep running;
205
+ # - the dropped check is value-preserving: a coercing input type changes the
206
+ # value, so it must keep running.
207
+ #
208
+ # @param other [Function]
209
+ # @return [Function, nil]
210
+ def fuse_with(other)
211
+ return nil unless fusable_step? && other.fusable_step?
212
+ return nil unless Plumb::Subtyping.subtype?(@output_type, other.input_type)
213
+ return nil unless Plumb::Subtyping.value_preserving?(other.input_type)
214
+ # Converting output checks must remain explicit between nodes.
215
+ return nil unless !checks_output? || Plumb::Subtyping.value_preserving?(@output_type)
216
+
217
+ fn1 = checks_output? ? fn_with_output_check : @fn
218
+ fn2 = other.fn
219
+ # Rebuild as one of the two standard classes, not `other.class`: a node is
220
+ # fusable because it kept #call, which says nothing about its constructor,
221
+ # and this one takes (input, output, fn). What has to survive is whether the
222
+ # final output check still runs — so ask `other`, which may not be a
223
+ # Function at all (see Implementation::TypeInterface).
224
+ klass = other.checks_output? ? Function : GuaranteedFunction
225
+ klass.new(@input_type, other.output_type, ->(result) { result.map(fn1).map(fn2) },
226
+ identity: [identity, other.identity])
227
+ end
228
+
229
+ # Take `type` as this node's OUTPUT slot, for `self >> type` — so
230
+ # `(Integer -> Any) >> Types::Float` becomes `(Integer -> Float)`, one node
231
+ # running one Float check instead of two nodes running an Any hop and a Float
232
+ # check. @see Composable#absorb_output for the shape; the conditions are here.
233
+ #
234
+ # The conditions are about THE SLOT — what is dropped — not about the neighbour.
235
+ # For `F(B => C) >> D` taking D into the C slot:
236
+ #
237
+ # - C is CHECKED (a plain Function). Its check goes, so C must not TRANSFORM
238
+ # (`value_preserving?(C)`, or the value the node produces changes) and D must
239
+ # reject everything C rejected. `D <= C` states the latter only while D is
240
+ # value-preserving: the subtype relation identifies a converting D by what it
241
+ # PRODUCES, and what matters here is what it ACCEPTS. `Types::Static[10] <=
242
+ # Types::Integer` holds — its value IS an Integer — yet a Static accepts
243
+ # anything, so absorbing it would swallow the dropped Integer check and turn an
244
+ # invalid `'abc'` into a valid `10`.
245
+ # - C is UNCHECKED (a GuaranteedFunction). Nothing is dropped, so there is nothing
246
+ # to prove and D may convert freely — it runs in the slot exactly where the
247
+ # no-op ran. When the guarantee already implies D there is nothing to win, and
248
+ # that redundant gate is reduce_step's to drop, so decline. Otherwise D takes the
249
+ # slot and the node becomes a plain Function, since the check has to run: the
250
+ # guarantee goes with the type that carried it.
251
+ #
252
+ # Validity, value and order are preserved either way. The ERROR TEXT of a value both
253
+ # C and D would reject becomes D's, the same trade Optimizer.reduce_union makes when
254
+ # it absorbs a union branch.
255
+ def absorb_output(type)
256
+ return nil unless absorbable? && !step_neighbour?(type)
257
+
258
+ if checks_output?
259
+ return nil unless Plumb::Subtyping.value_preserving?(@output_type)
260
+ return nil unless Plumb::Subtyping.value_preserving?(type)
261
+ return nil unless Plumb::Subtyping.subtype?(type, @output_type)
262
+
263
+ with_children([@input_type, type])
264
+ else
265
+ return nil if Plumb::Subtyping.subtype?(@output_type, type)
266
+
267
+ Function.new(@input_type, type, @fn, identity: @identity, wrapper: @wraps_callable)
268
+ end
269
+ end
270
+
271
+ # Take `type` as this node's INPUT slot, for `type >> self` — so
272
+ # `Types::Integer >> ->(r) { ... }` becomes `(Integer -> Any)` rather than
273
+ # `And(Integer, (Any -> Any))`. With #absorb_output, that is what collapses
274
+ # `Types::Integer >> ->(r) { r } >> Types::Float` to `(Integer -> Float)`:
275
+ # a little typed pipeline with arbitrary code in the middle.
276
+ #
277
+ # Again the conditions are about THE SLOT. For `A >> F(B => C)` taking A into the B
278
+ # slot, B's check is what goes, so B must not TRANSFORM and A must reject everything
279
+ # B rejected — `value_preserving?(B) && A <= B`. Here `A <= B` says exactly the right
280
+ # thing whatever A is: the relation identifies a converting A by what it PRODUCES,
281
+ # and what B sees IS what A produced. Whether A preserves the value is irrelevant —
282
+ # A runs either way, in the slot or beside it.
283
+ #
284
+ # SCOPE: only an `Any` slot, where both conditions hold trivially and no check is
285
+ # dropped at all, so errors are preserved verbatim too. The general form is the
286
+ # left-hand gate drop Optimizer declines, and costs what that costs; see the note in
287
+ # its header.
288
+ def absorb_input(type)
289
+ return nil unless absorbable? && !step_neighbour?(type)
290
+ return nil unless @input_type.is_a?(AnyClass)
291
+
292
+ with_children([type, @output_type])
293
+ end
294
+
295
+ # May a neighbouring type move into one of this node's slots? Only when #call is
296
+ # the canonical mapping — a replaced #call runs different checks, and nothing can
297
+ # be assumed about the slots (the same question #fusable_step? asks) — and only
298
+ # when this node renders as its TYPES. A LABELLED function (#generate, #invoke,
299
+ # the :default and :rescue policies, a filtered container) inspects as its label
300
+ # instead, so a type absorbed into a slot would vanish from #inspect; for those the
301
+ # label is the more useful reading, and the type runs as its own step.
302
+ private def absorbable? = standard_call? && @inspect_label.nil?
303
+
304
+ # Is `type` itself a TYPED STEP? Then the seam belongs to #fuse_with rather than
305
+ # here: it composes the two fns instead of nesting one node inside the other's slot,
306
+ # and where fusion declines (two opaque wrappers) both nodes stay so that each #fn
307
+ # remains reachable. A ROUTING rule, not a soundness one — which rewrite owns the
308
+ # pair. Everything else is a type, and a type runs as a step whether it sits in a
309
+ # slot or beside one.
310
+ private def step_neighbour?(type) = type.is_a?(Plumb::TypedStep)
311
+
312
+ # DERIVED, not declared: this family's #call is Function's full
313
+ # input -> fn -> output or GuaranteedFunction's input -> fn (whose missing
314
+ # output check is provably redundant, not skipped). Anything else replaced it.
315
+ #
316
+ # Asking the method table rather than trusting a class list makes the answer
317
+ # fail-safe — a new subclass with custom semantics is excluded because it
318
+ # overrode #call, not because someone remembered to exclude it. FilteredHash
319
+ # (per-field validation, neither boundary check) is caught that way, so this
320
+ # file no longer has to know that class exists.
321
+ # @see Plumb::TypedStep#fusable_step?
322
+ def standard_call?
323
+ owner = method(:call).owner
324
+ owner.equal?(Function) || owner.equal?(GuaranteedFunction)
325
+ end
326
+ end
327
+
328
+ # A Function whose output check is known *at build time* to be redundant, so
329
+ # it is simply not run. Two ways to earn that: the proc provably produces
330
+ # output_type (eg. a `:to_i` coercion always yields an Integer), or the
331
+ # output_type is `Any`, which nothing can fail — the opaque case, see
332
+ # Function.opaque. Choosing the class at build time means
333
+ # #call carries no per-call `guaranteed?` branch — the check is simply absent.
334
+ # It inherits Function's node_name (:function) and `is_a?(Function)`, so
335
+ # visitors, JSON-schema, subtyping and the decorator treat it as a Function;
336
+ # only the produced value's output re-validation is dropped. @output_type is
337
+ # still kept as metadata (inference / JSON schema / subtyping).
338
+ class GuaranteedFunction < Function
339
+ def call(result)
340
+ result.map(@input_type).map(@fn)
341
+ end
342
+
343
+ # The whole point of the class: no output check runs, so a seam has none to
344
+ # drop here. @see Plumb::TypedStep#checks_output?
345
+ def checks_output? = false
346
+ end
347
+ end