Module: Plumb::Optimizer
- Defined in:
- lib/plumb/optimizer.rb
Overview
THE REWRITE RULES — the optimisation pass over the type AST.
The operators do not build the tree you wrote; they build an equivalent one with
the provably-redundant runtime work removed. Integer[0..100] >> Integer[0..]
validates ::Integer once; Integer | Numeric collapses to Numeric;
String[/a/] | String[/b/] checks String once and branches on the suffixes.
A different kind of thing from Subtyping: the relation ANSWERS questions about types, these REWRITE one AST into another. Keeping them apart makes the dependency one-way (Optimizer -> Subtyping, never back).
Runs EAGERLY at build time, not as a deferred pass, because reductions are
observable through #inspect, #== and the visitors — a type's identity is its
reduced form, and (Integer[0..100] >> Integer[0..]) == Integer[0..100] is a
documented property.
EVERY RULE MUST PRESERVE, for every input: validity, output value, errors, and
execution order. The last is easy to lose and invisible to most tests, since it
only shows up when a step has a side effect — which is why absorption and factoring
are gated on #value_preserving?. spec/invariants_spec.rb asserts all four, order
via probes that log each step as it runs.
THE RULES, in the order tried:
`left >> right` / `left / right` reduce_step, then redundant_refinement?
(#>> only), else Conjunction.build
`left | right` reduce_union, then factor_union,
else Disjunction.build
reduce_step is itself a ladder: intersection distribution, attribute narrowing, step fusion, refinement re-parenting, and finally boundary absorption.
The meet (#&) is NOT here — a greatest lower bound is lattice algebra, not a
rewrite, so it stays in Subtyping.intersect.
DECLINED, not missing: dropping a redundant gate on the LEFT. reduce_step drops one
on the right (f >> Types::String returns f), and the mirror looks equally free —
Types::String >> f runs the String check twice when f declares String as its
input, worth 21% of a resolve, or 64% for Types::UUID::V4 >> a_finder where a
regex ran twice. It was implemented and measured, then backed out: it destroys the
shared prefix factor_union hoists, so (String >> b) | (String >> c) | (String >> d)
goes from one String check to three — 3.7x worse on the rejection path, which is
where an enum-like union spends its time. The two rules want opposite things about
the same node (absorb the gate vs extract it), and at >> time there is no way to
know a | is coming. Factoring wins the bigger case.
#absorb_boundary sits on the other side of that line, and where the check goes is
what divides them: it moves a left-hand type into an Any input slot only, where
there is no gate to drop, so what a union gives up is a step boundary in a chain
whose middle step is an untyped callable. Extending it to a TYPED input slot
(Types::String >> (String -> Integer) -> just the transform) is the declined rule
in another form, and would need factor_union taught to unfold a value-preserving
input slot back into a step before it could pay.
Class Method Summary collapse
-
.absorb_boundary(left, right) ⇒ Object
LAST RUNG.
-
.absorb_branches(branches) ⇒ Object
Drop every branch another one already covers, keeping first-seen order.
-
.absorbs?(wider, narrower) ⇒ Boolean
Does
widercovernarrower, such that droppingnarrowerchanges nothing?. -
.catch_all_preserved?(left, right) ⇒ Boolean
Does
rightkeep and preserveleft's catch-all tail (the keysleftemits beyond its declared ones)? Vacuously true whenlefthas no catch-all. -
.common_step_prefix(sa, sb) ⇒ Object
Length of the longest leading run of steps the two lists agree on AND that is value-preserving — the sound-to-factor shared prefix.
-
.compatible_base?(a, b) ⇒ Boolean
Two same-attribute clauses may merge when their base types are subtype- comparable — so a clause built on
Stringand one built on the accumulatedString.where(size: …)(as chained#whereproduces) still fold together. - .compose_step(left, right) ⇒ Object
-
.factor_union(a, b) ⇒ Object
Distributive factoring — the join-dual of reduce_union's absorption:
(P >> A) | (P >> B)factors toP >> (A | B)when the shared left prefixPis value-preserving, soPis validated once instead of per branch. -
.intersect_attribute_values(a, b) ⇒ Object
Intersect two attribute-constraint values into a single value,
nilto keep the two clauses stacked, orConstraint::EMPTYwhen the overlap is provably empty (the caller turns that into Never). -
.merge_attribute_into(left, avm) ⇒ Object
leftrebuilt withavmmerged into its matching same-attribute clause, or nil whenlefthas none (the caller then stacks). -
.narrow_attribute(left, avm) ⇒ Object
Narrow
leftby attribute constraintavm: intersect it intoleft's existing clause on the same attribute+base type (mirroring how Constraint.narrow intersects Range matchers), or stack it on when there is no such clause. -
.plain_record?(hash) ⇒ Boolean
A record whose only keys are literal names plus an optional
_catch-all (no typed/pattern keys, whose key-keeping we don't reason about here). -
.rebuild(list) ⇒ Object
Re-fold a step list into a type, fusing consecutive Constraint refinements back into a Constraint chain (
[String, /d/] → String[/d/]) and using And at a non-fusable boundary (a transform or an Or suffix). -
.reduce_step(left, right) ⇒ Object
Rung-1 structural reduction of
left >> right— the ladder tried before absorption into a Conjunction: intersection distribution, attribute narrowing, step fusion, refinement re-parenting, boundary absorption. -
.reduce_union(a, b) ⇒ Object
Join-dual of
reduce_step: absorption fora | b. -
.redundant_record_refinement?(left, right) ⇒ Boolean
The record case of
redundant_refinement?. -
.redundant_refinement?(left, right) ⇒ Boolean
In
left >> right, isrighta no-op thatleftalready guarantees? True whenrightpreserves values AND every valueleftproduces already satisfies it (left <= right), sorightcan neither reject nor change them — eg. -
.reparent_refinement(left, right) ⇒ Object
When
rightis a refinement (aConstraintchain) whose ROOT is a base-type (Module) gate thatleft's output already guarantees, that gate is a duplicated runtime check: re-parentright's refinement matchers ontoleftand drop it. -
.rewrite_refinement(left, right) ⇒ Composable
For
left / right— the escape hatch, and the refinement builders (#[], #where, #value) that route through it. -
.rewrite_step(left, right) ⇒ Composable
The full rule set for
left >> right. -
.rewrite_union(left, right) ⇒ Composable
The full rule set for
left | right. -
.same_literal_keys?(left, right) ⇒ Boolean
Do two records declare the same set of literal key names?.
-
.steps(type) ⇒ Object
Flatten a type into its
>>execution steps. -
.union_branches(type) ⇒ Object
ONLY Union, never Or: a choice is left-biased and its branches may convert, so its order is semantic and dropping one is not a type-level decision.
Class Method Details
.absorb_boundary(left, right) ⇒ Object
LAST RUNG. A typed step runs its boundary types as steps, so a plain type next
to one can move into the matching slot: Types::Integer >> a_proc >> Types::Float becomes the single (Integer -> Float) instead of a three-node
chain with an Any hop in the middle. The node owns the conditions (see
Function#absorb_output / #absorb_input) and at most one side can accept, since
each requires the OTHER side to be a value-preserving type — which a typed step
never is, and a seam between two of those is #fuse_with's.
Last in the ladder, so a pair another rule reduces gets that reduction instead:
#[] on a transform re-parents (String.transform(:to_i)[0..10]), and a
redundant gate is dropped rather than absorbed.
175 176 177 |
# File 'lib/plumb/optimizer.rb', line 175 def absorb_boundary(left, right) left.absorb_output(right) || right.absorb_input(left) end |
.absorb_branches(branches) ⇒ Object
Drop every branch another one already covers, keeping first-seen order.
349 350 351 352 353 354 355 356 |
# File 'lib/plumb/optimizer.rb', line 349 def absorb_branches(branches) branches.each_with_object([]) do |candidate, survivors| next if survivors.any? { |s| absorbs?(s, candidate) } survivors.reject! { |s| absorbs?(candidate, s) } survivors << candidate end end |
.absorbs?(wider, narrower) ⇒ Boolean
Does wider cover narrower, such that dropping narrower changes nothing?
Guarded to VALUE-PRESERVING branches: subtype? identifies a Function by its
OUTPUT type, so subtype?(String->Integer, Numeric) holds even though that branch
accepts Strings a bare Numeric rejects, and absorbing would drop a coercion.
Identical branches dedupe regardless — the survivor IS the dropped node.
364 365 366 367 368 369 370 371 372 373 |
# File 'lib/plumb/optimizer.rb', line 364 def absorbs?(wider, narrower) return true if wider == narrower return false unless Subtyping.value_preserving?(wider) && Subtyping.value_preserving?(narrower) # Never absorb across a wrapper: subtype? sees through Policy/Metadata/Node, so # the drop would lose the identity one carries (eg. `Types::Email | # Types::String` must keep both). @see Subtyping.identity_wrapper? return false if Subtyping.identity_wrapper?(wider) || Subtyping.identity_wrapper?(narrower) Subtyping.subtype?(narrower, wider) end |
.catch_all_preserved?(left, right) ⇒ Boolean
Does right keep and preserve left's catch-all tail (the keys left emits
beyond its declared ones)? Vacuously true when left has no catch-all.
303 304 305 306 307 308 309 |
# File 'lib/plumb/optimizer.rb', line 303 def catch_all_preserved?(left, right) lc = left.catch_all_type return true if lc.nil? rc = right.catch_all_type !rc.nil? && Subtyping.value_preserving?(rc) && Subtyping.subtype?(lc, rc) end |
.common_step_prefix(sa, sb) ⇒ Object
Length of the longest leading run of steps the two lists agree on AND that is value-preserving — the sound-to-factor shared prefix.
422 423 424 425 426 427 |
# File 'lib/plumb/optimizer.rb', line 422 def common_step_prefix(sa, sb) max = sa.size < sb.size ? sa.size : sb.size i = 0 i += 1 while i < max && sa[i] == sb[i] && Subtyping.value_preserving?(sa[i]) i end |
.compatible_base?(a, b) ⇒ Boolean
Two same-attribute clauses may merge when their base types are subtype-
comparable — so a clause built on String and one built on the accumulated
String.where(size: …) (as chained #where produces) still fold together.
234 235 236 |
# File 'lib/plumb/optimizer.rb', line 234 def compatible_base?(a, b) a == b || Subtyping.subtype?(a, b) || Subtyping.subtype?(b, a) end |
.compose_step(left, right) ⇒ Object
436 437 438 439 440 441 442 |
# File 'lib/plumb/optimizer.rb', line 436 def compose_step(left, right) if left.is_a?(Constraint) && right.is_a?(Constraint) && right.base.nil? Constraint.narrow(left, right.matcher) else Conjunction.build(left, right) end end |
.factor_union(a, b) ⇒ Object
Distributive factoring — the join-dual of reduce_union's absorption:
(P >> A) | (P >> B) factors to P >> (A | B) when the shared left prefix
P is value-preserving, so P is validated once instead of per branch.
SOUNDNESS. The un-factored union runs P once per branch (the Or re-runs
it in the right branch when the left fails). Factoring runs it once, which
is behaviour-preserving iff P is referentially transparent. A VALUE-
PRESERVING P guarantees this: it never alters the value, so both forms
feed the divergent suffixes the identical input. The guard is per prefix
STEP (below), so the suffixes may be anything (incl. transforms) and a
transform prefix — whose purity is unprovable — halts the shared prefix.
Both branches are flattened to their >> step lists (#steps unwraps already-
factored :refined_union nodes, so a third branch folds into an existing
P >> (…) rather than re-checking P — n-ary folding). The longest common
value-preserving step prefix is pulled out; the divergent tails become the
Or. Runs AFTER reduce_union, so a prefix consuming a whole branch was
already absorbed. The result is decorated :refined_union so visitors fold
the type-less disjunction into P's type spec; runtime is the plain And.
394 395 396 397 398 399 400 401 402 403 |
# File 'lib/plumb/optimizer.rb', line 394 def factor_union(a, b) sa = steps(a) sb = steps(b) k = common_step_prefix(sa, sb) return nil if k.zero? # disjoint prefixes — nothing shared return nil if k == sa.size || k == sb.size # one is a prefix of the other (absorption's job) inner = Disjunction.build(rebuild(sa.drop(k)), rebuild(sb.drop(k))) Conjunction.build(rebuild(sa.take(k)), inner).as_node(:refined_union) end |
.intersect_attribute_values(a, b) ⇒ Object
Intersect two attribute-constraint values into a single value, nil to keep
the two clauses stacked, or Constraint::EMPTY when the overlap is provably
empty (the caller turns that into Never). Raw Ranges/Sets intersect to their
(possibly narrower, possibly empty) overlap via Constraint.merge_matchers; a
Plumb-typed value reduces only by subsumption — keeping the narrower — and
otherwise stays stacked (intersecting two arbitrary Plumb types into one
clause isn't representable).
218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/plumb/optimizer.rb', line 218 def intersect_attribute_values(a, b) return a if a == b if a.is_a?(Composable) || b.is_a?(Composable) return a if Subtyping.value_subtype?(a, b) return b if Subtyping.value_subtype?(b, a) nil else Constraint.merge_matchers(a, b) end end |
.merge_attribute_into(left, avm) ⇒ Object
left rebuilt with avm merged into its matching same-attribute clause,
or nil when left has none (the caller then stacks). Prefers the outermost
(most-recently-added) clause.
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/plumb/optimizer.rb', line 191 def merge_attribute_into(left, avm) case left when AttributeValueMatch return nil unless left.attr_name == avm.attr_name && compatible_base?(left.type, avm.type) merged = intersect_attribute_values(left.value, avm.value) return nil if merged.nil? return Types::Never if merged.equal?(Constraint::EMPTY) # unsatisfiable clause ⇒ bottom AttributeValueMatch.new(left.type, left.attr_name, merged) when Conjunction # A conjunct that folds to Never makes the whole node uninhabitable ⇒ Never. if (right = merge_attribute_into(left.children[1], avm)) right.is_a?(NeverClass) ? right : Conjunction.build(left.children[0], right) elsif (leftc = merge_attribute_into(left.children[0], avm)) leftc.is_a?(NeverClass) ? leftc : Conjunction.build(leftc, left.children[1]) end end end |
.narrow_attribute(left, avm) ⇒ Object
Narrow left by attribute constraint avm: intersect it into left's
existing clause on the same attribute+base type (mirroring how
Constraint.narrow intersects Range matchers), or stack it on when there is
no such clause. Always reduces (never bails) — an AVM is a value-narrowing
refinement, so there is no duplicated type gate to keep it apart.
184 185 186 |
# File 'lib/plumb/optimizer.rb', line 184 def narrow_attribute(left, avm) merge_attribute_into(left, avm) || Conjunction.build(left, avm) end |
.plain_record?(hash) ⇒ Boolean
A record whose only keys are literal names plus an optional _ catch-all
(no typed/pattern keys, whose key-keeping we don't reason about here).
292 |
# File 'lib/plumb/optimizer.rb', line 292 def plain_record?(hash) = hash.matcher_fields.all? { |key, _| key.catch_all? } |
.rebuild(list) ⇒ Object
Re-fold a step list into a type, fusing consecutive Constraint refinements
back into a Constraint chain ([String, /d/] → String[/d/]) and using And at
a non-fusable boundary (a transform or an Or suffix).
432 433 434 |
# File 'lib/plumb/optimizer.rb', line 432 def rebuild(list) list.reduce { |left, step| compose_step(left, step) } end |
.reduce_step(left, right) ⇒ Object
Rung-1 structural reduction of left >> right — the ladder tried before
absorption into a Conjunction: intersection distribution, attribute narrowing,
step fusion, refinement re-parenting, boundary absorption. Each is a method or a
commented block below, in that order. Returns the reduced type, or nil to fall
back to Conjunction.build.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/plumb/optimizer.rb', line 103 def reduce_step(left, right) # A refinement narrows by each conjunct in turn: `left / (b ∧ c)` is # `(left / b) / c`. Being an Intersection IS the condition — it is only # built when both sides preserve the value — so no runtime value-preservation # test is needed here. A composition (And) carries a transform, is a barrier, # and falls through to the Constraint check below, which bails. if right.is_a?(Intersection) l = reduce_step(left, right.children[0]) || Conjunction.build(left, right.children[0]) return reduce_step(l, right.children[1]) || Conjunction.build(l, right.children[1]) end # An attribute constraint intersects into `left`'s clause on the same # attribute — like Constraint.narrow intersects Ranges — or stacks on when # there is none. `String.where(size: 0..40) / .where(size: 10..100)` -> # `.where(size: 10..40)`, one `String` check. return narrow_attribute(left, right) if right.is_a?(AttributeValueMatch) # Two adjacent converting steps whose boundary is provable at build time # fuse into one node: `(A -> B) >> (B -> C)` becomes `(A -> C)` running # both fns, dropping the redundant out/in checks between them. fuse_with # carries its own subtype proof, so it is sound from #>> and #/ alike. if (fused = left.fuse_with(right)) return fused end reparent_refinement(left, right) || absorb_boundary(left, right) end |
.reduce_union(a, b) ⇒ Object
Join-dual of reduce_step: absorption for a | b. If one branch's value set
is contained in another's, the union equals the wider branch
(a ∪ b == b when a <= b), so the narrower is dropped — and duplicate
branches dedupe. Returns the surviving type, or nil when nothing can go.
A JOIN IS N-ARY. A | B | C is stored as a nested pair but means one flat branch
set, so absorption has to see all of it — comparing only the two operands makes
the result depend on the order they were written:
Numeric | String | Integer => Numeric | String (Integer absorbed)
Integer | String | Numeric => (Integer | String) | Numeric
Both accept the same values, but the second keeps Integer even though
Integer <= Numeric, because subtype?(Union(Integer, String), Numeric) requires
EVERY branch to be within Numeric. The redundant branch then costs a failed match
on every value falling through to it.
So flatten, absorb across the set, re-fold. Order among survivors is preserved —
a join is commutative, and keeping it stable avoids churning #inspect and a
JSON Schema's anyOf order.
331 332 333 334 335 336 337 338 339 340 |
# File 'lib/plumb/optimizer.rb', line 331 def reduce_union(a, b) branches = union_branches(a) + union_branches(b) survivors = absorb_branches(branches) return nil if survivors.size == branches.size # nothing to drop — leave the pair alone return survivors.first if survivors.size == 1 survivors.drop(1).reduce(survivors.first) do |acc, branch| factor_union(acc, branch) || Disjunction.build(acc, branch) end end |
.redundant_record_refinement?(left, right) ⇒ Boolean
The record case of redundant_refinement?. A HashClass is NOT value-
preserving in general — a non-inclusive record drops undeclared keys — so
the generic test above never fires for it. left >> right (both records)
still reduces to left when right merely re-validates every value left
produces without dropping or changing anything. Sound sufficient conditions:
- both are plain records (no typed/pattern keys — only literal keys and an
optional `_` catch-all — so key-keeping is decidable);
- the same declared (literal) key set — `right` drops none of `left`'s keys
and requires none `left` lacks;
- `left <= right` — `right`'s fields are supertypes with compatible
optionality, so it rejects nothing `left` emits;
- every `right` field is value-preserving — `right` coerces nothing;
- if `left` carries a catch-all (so it emits arbitrary extra keys), `right`
carries a value-preserving catch-all that covers it — otherwise `right`
would drop or reject those extra keys.
Anything short keeps the And (right might drop keys or convert values —
eg. the front/back coercion Hash[price: Int] >> Hash[price: Int.build(Money)]
must NOT collapse).
280 281 282 283 284 285 286 287 288 |
# File 'lib/plumb/optimizer.rb', line 280 def redundant_record_refinement?(left, right) return false unless left.is_a?(HashClass) && right.is_a?(HashClass) return false unless plain_record?(left) && plain_record?(right) return false unless same_literal_keys?(left, right) return false unless catch_all_preserved?(left, right) return false unless Subtyping.subtype?(left, right) right.literal_fields.all? { |_key, field| Subtyping.value_preserving?(field) } end |
.redundant_refinement?(left, right) ⇒ Boolean
In left >> right, is right a no-op that left already guarantees?
True when right preserves values AND every value left produces already
satisfies it (left <= right), so right can neither reject nor change
them — eg. String.where(size: 3..10) >> String.where(size: 0..) drops the
vacuous size: 0... This is what reduce_step does for a Constraint chain,
generalized to any value-preserving refinement (a where/AVM And, a nested
Or). It tests REAL subsumption via Subtyping.subtype?, not check_composable!'s type-
compat check — a value-narrowing refinement (AVM) opts out of the latter
(its #input_type is Any), so check_composable! can't tell it apart.
247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/plumb/optimizer.rb', line 247 def redundant_refinement?(left, right) # `right` is the dropped side (kept: left). Don't drop it if it carries # wrapper identity Subtyping.subtype? now sees through — unless it equals left, # where left already IS that identity. eg. `String[EMAIL] >> Types::Email` # keeps the And so the :email node (and its JSON-schema format) survives. if Subtyping.value_preserving?(right) && Subtyping.subtype?(left, right) && (left == right || !Subtyping.identity_wrapper?(right)) return true end redundant_record_refinement?(left, right) end |
.reparent_refinement(left, right) ⇒ Object
When right is a refinement (a Constraint chain) whose ROOT is a base-type
(Module) gate that left's output already guarantees, that gate is a duplicated
runtime check: re-parent right's refinement matchers onto left and drop it.
Nil when it declines, which leaves the pair to the absorption rung.
Keyed on the root TYPE only (Subtyping.subtype?(left_output, root)), NOT on matcher
values — so Integer[0..100] >> Integer[-10..110] becomes
Integer[0..100][-10..110] (the -10..110 range is preserved), not the
value-subsumed Integer[0..100] (that would be rung 2).
matchers=[-10..110], root=Constraint(::Integer), left guarantees Integer
=> Constraint(-10..110, base: Integer[0..100]) == Integer[0..100][-10..110]
Degenerate left >> Integer (matchers == []) returns left — a pure
redundant type gate removed.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/plumb/optimizer.rb', line 146 def reparent_refinement(left, right) return nil unless right.is_a?(Constraint) matchers = [] # innermost-first, excludes the root gate node = right while node.is_a?(Constraint) && node.base matchers.unshift(node.matcher) node = node.base end root = node return nil unless root.is_a?(Constraint) && SemanticMatcher.nominal?(root.matcher) return nil unless Subtyping.subtype?(Subtyping.resolved_output(left), root) # Stack right's refinements onto left; Constraint.narrow intersects Ranges # so `Integer[0..100] >> Integer[0..]` collapses to `Integer[0..100]`. matchers.reduce(left) { |acc, m| Constraint.narrow(acc, m) } end |
.rewrite_refinement(left, right) ⇒ Composable
For left / right — the escape hatch, and the refinement builders (#[], #where,
#value) that route through it.
STRUCTURAL REDUCTION ONLY: absorption is deliberately skipped, because #/ exists
to assert a narrowing the checker cannot prove, and dropping it as "already
guaranteed" would discard the cast the caller asked for. reduce_step still removes
a duplicated type gate, which is pure bookkeeping.
83 84 85 |
# File 'lib/plumb/optimizer.rb', line 83 def rewrite_refinement(left, right) reduce_step(left, right) || Conjunction.build(left, right) end |
.rewrite_step(left, right) ⇒ Composable
The full rule set for left >> right. Always returns a node.
67 68 69 70 |
# File 'lib/plumb/optimizer.rb', line 67 def rewrite_step(left, right) reduce_step(left, right) || (redundant_refinement?(left, right) ? left : Conjunction.build(left, right)) end |
.rewrite_union(left, right) ⇒ Composable
The full rule set for left | right. Always returns a node.
92 93 94 95 96 |
# File 'lib/plumb/optimizer.rb', line 92 def rewrite_union(left, right) reduce_union(left, right) || factor_union(left, right) || Disjunction.build(left, right) end |
.same_literal_keys?(left, right) ⇒ Boolean
Do two records declare the same set of literal key names?
295 296 297 298 299 |
# File 'lib/plumb/optimizer.rb', line 295 def same_literal_keys?(left, right) lk = left.literal_fields.keys rk = right.literal_fields.keys lk.size == rk.size && lk.all? { |k| rk.any? { |o| o.eql?(k) } } end |
.steps(type) ⇒ Object
Flatten a type into its >> execution steps. A fused Constraint chain
(String[/d/]) unfolds to its base then a bare matcher refinement; an And
to its two sides; an already-factored :refined_union node is peeled so its
shared prefix re-exposes for n-ary folding. Everything else (root gate,
transform, Or, container) is atomic. Only :refined_union nodes are peeled
— Metadata/Policy/other Nodes carry identity we must not factor away.
411 412 413 414 415 416 417 418 |
# File 'lib/plumb/optimizer.rb', line 411 def steps(type) type = type.type if type.is_a?(Composable::Node) && type.node_name == :refined_union case type when Conjunction then type.children.flat_map { |c| steps(c) } when Constraint then type.base ? steps(type.base) + [Constraint.new(type.matcher)] : [type] else [type] end end |
.union_branches(type) ⇒ Object
ONLY Union, never Or: a choice is left-biased and its branches may convert, so its order is semantic and dropping one is not a type-level decision.
344 345 346 |
# File 'lib/plumb/optimizer.rb', line 344 def union_branches(type) type.is_a?(Union) ? type.children.flat_map { |c| union_branches(c) } : [type] end |