Module: Plumb::Subtyping
- Defined in:
- lib/plumb/subtyping.rb
Overview
The structural subtype/subset relation over types, and the #>>
composition type-check (subsumption) that builds on it. Methods are module
functions: call them as Plumb::Subtyping.foo.
Class Method Summary collapse
-
.accepted_type(type) ⇒ Object
What
typewill accept without rejecting it outright, when it's the consumer of aleft >> typechain. -
.atomic?(type) ⇒ Boolean
Returns whether a node wraps one raw matcher.
-
.atomic_subtype?(left_matcher, right_matcher) ⇒ Boolean
The public Boolean form of the matcher subtype relation.
-
.check_composable!(left, right) ⇒ Object
Validate that two steps can be composed sequentially (
left >> right). -
.container_covariant?(type) ⇒ Boolean
The containers that are covariant in their children — the ones #intersect_containers may meet pairwise.
-
.equivalent?(a, b) ⇒ Boolean
aandbdescribe the same values — mutual subtypes. -
.identity_wrapper?(type) ⇒ Boolean
Does
typecarry identity beyond its value semantics — a policy name, user metadata, or a visitor node_name — i.e. -
.intersect(a, b) ⇒ Object
The meet (greatest lower bound) of two types — the dual of Optimizer.reduce_union's join.
-
.intersect_constraints(a, b) ⇒ Object
Intersect two knowable refinements over the SAME base type (Ranges or Sets), reusing Constraint.merge_matchers.
-
.intersect_containers(a, b) ⇒ Object
Intersect two covariant containers of the same class (Array/Tuple/HashMap) by intersecting their children pairwise —
Array[A] & Array[B]==Array[A & B]. -
.intersect_literals(a, b) ⇒ Object
Two literals meet as their singleton sets: distinct values share no inhabitant, so the meet is provably empty ⇒ Never (
Value['a'] & Value['b']). -
.intersect_union(union, other, union_first: true) ⇒ Composable
Distributes intersection over a union, preserving operand order for runtime compositions and dropping branches that reduce to Never.
-
.literal_value(node) ⇒ Object
The single value a node matches by equality, or
Undefinedfor a node that matches more than one (so a caller can't mistake a membership test for a literal). -
.map_children(type) {|child| ... } ⇒ Composable
Map a container's children through
blkand rebuild it around the results — the shared rule behind every covariant container's #accepted_type and #output_type. - .resolved_input(type, depth = 0) ⇒ Object
-
.resolved_output(type, depth = 0) ⇒ Object
Every node resolves its own #input_type / #output_type (an And does it at construction, an Or maps over its branches), so this is normally a single hop.
-
.stable_domain(type) ⇒ Array<Class>?
Returns the shared accepted/output base classes for a non-converting type.
-
.strict_subtype?(a, b) ⇒ Boolean
ais STRICTLY narrower thanb— a subtype, and not merely equivalent to it. -
.subtype?(a, b) ⇒ Boolean
The structural subtype/subset relation over types.
-
.unwrap_transparent(type) ⇒ Object
Peel transparent wrappers (Policy/Metadata/Node) down to the wrapped type.
-
.value_preserving?(type) ⇒ Boolean
Does
typereturn its input unchanged on success (a coreflexive refinement)? Delegates to the type's polymorphic #value_preserving? hook — so custom types opt in by defining it — and memoizes per frozen node in TypeCache, a pure structural predicate like #accepted_type. -
.value_subtype?(a, b) ⇒ Boolean
Subtype test between two attribute-constraint VALUES (the
valueof awhere(attr: value)clause).
Class Method Details
.accepted_type(type) ⇒ Object
What type will accept without rejecting it outright, when it's the
consumer of a left >> type chain. The type itself answers via its
#accepted_type hook (default: its resolved input; refinements and Hash
override it — see Composable#accepted_type). Transparent wrappers are
peeled first so the wrapped type answers. Memoized per node in TypeCache
(frozen nodes only) — this is the sole consumer of #accepted_type, so
caching here covers every type, eg. HashClass's per-field rebuild.
405 406 407 408 |
# File 'lib/plumb/subtyping.rb', line 405 def accepted_type(type) type = unwrap_transparent(type) TypeCache.fetch(:accepted_type, type) { type.accepted_type } end |
.atomic?(type) ⇒ Boolean
Returns whether a node wraps one raw matcher. StaticClass is excluded because its child is an emitted value, not a matcher.
130 131 132 133 134 135 |
# File 'lib/plumb/subtyping.rb', line 130 def atomic?(type) type.respond_to?(:children) && type.children.size == 1 && !type.children.first.is_a?(Composable) && !type.is_a?(StaticClass) end |
.atomic_subtype?(left_matcher, right_matcher) ⇒ Boolean
The public Boolean form of the matcher subtype relation. Unknown relations conservatively return false: they must not justify removing a runtime check.
139 140 141 |
# File 'lib/plumb/subtyping.rb', line 139 def atomic_subtype?(left_matcher, right_matcher) SemanticMatcher.wrap(left_matcher).subset_of(SemanticMatcher.wrap(right_matcher)).proven? end |
.check_composable!(left, right) ⇒ Object
Validate that two steps can be composed sequentially (left >> right).
Composition is typed by subsumption, like function application in any
statically-typed language: everything left produces must be acceptable
to right, i.e. produced(left) <: accepted(right). Otherwise the chain
would reject some of left's output and we raise.
To narrow a value (where only some of it flows through), use #[] /
#transform(...)[...] — a refinement is a runtime-checked cast, built
directly and not subject to this check.
Permissive only where types are genuinely unknown: when either side reports Any (opaque steps/procs, value-level transforms, narrowing matchers), it opts out.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/plumb/subtyping.rb', line 159 def check_composable!(left, right) produced = resolved_output(left) # opaque left (unknown output) or opaque right (accepts anything) -> opt out return if produced.is_a?(AnyClass) || resolved_input(right).is_a?(AnyClass) accepted = accepted_type(right) return if accepted.is_a?(AnyClass) || subtype?(produced, accepted) # Produced literal-key records are closed because #call drops undeclared keys. return if produced.is_a?(HashClass) && subtype?(produced.closed, accepted) raise Plumb::TypeError, "cannot compose #{left.inspect} >> #{right.inspect}: " \ "#{produced.inspect} (produced) is not a subtype of #{accepted.inspect} (accepted); " \ 'narrow with #[] if this is intentional' end |
.container_covariant?(type) ⇒ Boolean
The containers that are covariant in their children — the ones #intersect_containers may meet pairwise. NOT the same set as the nodes answering #with_children, which is much wider (see Plumb::NodeMapper).
338 339 340 |
# File 'lib/plumb/subtyping.rb', line 338 def container_covariant?(type) type.is_a?(ArrayClass) || type.is_a?(TupleClass) || type.is_a?(HashMap) end |
.equivalent?(a, b) ⇒ Boolean
a and b describe the same values — mutual subtypes.
124 |
# File 'lib/plumb/subtyping.rb', line 124 def equivalent?(a, b) = subtype?(a, b) && subtype?(b, a) |
.identity_wrapper?(type) ⇒ Boolean
Does type carry identity beyond its value semantics — a policy name,
user metadata, or a visitor node_name — i.e. is it (wrapped in) a
transparent Policy/Metadata/Node? subtype? sees THROUGH such wrappers
(Policy(X) <= Y iff X <= Y), which is right for the subsumption relation
but means a reduction that DROPS one in favour of a subtype-equal type
would silently lose that identity. So the reductions (this module's
#intersect, and Optimizer.reduce_union / .redundant_refinement?) refuse to
drop one — except when the two are equal, where the survivor already IS
that identity.
429 430 431 |
# File 'lib/plumb/subtyping.rb', line 429 def identity_wrapper?(type) !unwrap_transparent(type).equal?(type) end |
.intersect(a, b) ⇒ Object
The meet (greatest lower bound) of two types — the dual of
Optimizer.reduce_union's join. This is lattice algebra rather than a
rewrite, which is why it stayed here when the rules moved out. intersect(a, b) returns the narrowed type, or nil to fall back to
Conjunction.build(a, b) (a sound runtime intersection: both sides must pass). It
only produces Types::Never when the intersection is PROVABLY empty
(disjoint ranges/sets/classes); when it can't prove emptiness or a subtype
relation, it declines (nil) so the caller keeps a runtime And.
Consumed by Composable#&.
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/plumb/subtyping.rb', line 202 def intersect(a, b) a = Composable.wrap(a) b = Composable.wrap(b) return a if a.is_a?(NeverClass) # Never & X == Never return b if b.is_a?(NeverClass) return b if a.is_a?(AnyClass) # Any & X == X (top is the meet identity) return a if b.is_a?(AnyClass) return a if a == b # The subsumption drops below are sound only when BOTH sides preserve the # value — the same guard, for the same reason, as Optimizer.reduce_union. # `subtype?` identifies a converting node by its OUTPUT type, so any two # `String -> Integer` transforms are mutual subtypes no matter what they # compute; dropping one would silently discard a conversion the caller asked # for (`to_i & size` returned just `to_i`). Only when neither side alters the # value does `subtype?` describe the accepted input domain, which is what # makes keeping the narrower side behaviour-preserving. # # Also skipped when either side is a transparent wrapper: subtype? sees # through it, but dropping it loses the identity it carries (see # #identity_wrapper?). eg. `Types::Integer & doubler.metadata(...)` keeps both. if value_preserving?(a) && value_preserving?(b) && !identity_wrapper?(a) && !identity_wrapper?(b) return a if subtype?(a, b) # a ⊆ b — meet keeps the narrower a return b if subtype?(b, a) # b ⊆ a — keep the narrower b end # Distribute over unions: (a1 | a2) & b == (a1 & b) | (a2 & b). return intersect_union(a, b, union_first: true) if a.is_a?(Disjunction) return intersect_union(b, a, union_first: false) if b.is_a?(Disjunction) intersect_constraints(a, b) || intersect_literals(a, b) || intersect_containers(a, b) || (disjoint_relation(a, b).proven? ? Types::Never : nil) end |
.intersect_constraints(a, b) ⇒ Object
Intersect two knowable refinements over the SAME base type (Ranges or Sets),
reusing Constraint.merge_matchers. An empty overlap (disjoint Ranges, empty
Set intersection) is provably empty ⇒ Never; a non-empty overlap rebuilds via
Constraint.narrow (Integer[2..] & Integer[0..100] == Integer[2..100]).
Returns nil for anything it can't merge here (different bases, non-Range/Set
matchers), leaving the caller to try other strategies.
265 266 267 268 269 270 271 272 273 274 |
# File 'lib/plumb/subtyping.rb', line 265 def intersect_constraints(a, b) return nil unless a.is_a?(Constraint) && b.is_a?(Constraint) return nil unless a.base && b.base && a.base == b.base merged = Constraint.merge_matchers(a.matcher, b.matcher) return nil if merged.nil? # not the same knowable kind, or an incomputable overlap return Types::Never if merged.equal?(Constraint::EMPTY) # provably empty ⇒ Never Constraint.narrow(a.base, merged) end |
.intersect_containers(a, b) ⇒ Object
Intersect two covariant containers of the same class (Array/Tuple/HashMap)
by intersecting their children pairwise — Array[A] & Array[B] ==
Array[A & B]. Returns nil for non-containers or a class/arity mismatch (the
caller then falls back).
A Never child does NOT necessarily sink the whole container. A Tuple has
fixed arity — every position must be filled — so a Never element makes it
uninhabitable ⇒ Never. A homogeneous container (Array/HashMap) can be
empty, so Array[Never] / HashMap[K, Never] are still inhabited (by [] /
{}) and are kept as-is rather than collapsed.
325 326 327 328 329 330 331 332 333 |
# File 'lib/plumb/subtyping.rb', line 325 def intersect_containers(a, b) return nil unless container_covariant?(a) && a.instance_of?(b.class) return nil if a.children.empty? || a.children.size != b.children.size merged = a.children.zip(b.children).map { |x, y| intersect(x, y) || Conjunction.build(x, y) } return Types::Never if a.is_a?(TupleClass) && merged.any? { |m| m.is_a?(NeverClass) } a.with_children(merged) end |
.intersect_literals(a, b) ⇒ Object
Two literals meet as their singleton sets: distinct values share no
inhabitant, so the meet is provably empty ⇒ Never (Value['a'] & Value['b']). Equal values need no answer here — #intersect's a == b
dedupe already folded them — so this only ever returns Never or nil.
Decided by ==, the same test a literal match itself makes, so
equality that crosses Ruby classes is honoured: Value[5] & Value[5.0] is
NOT disjoint, because 5 == 5.0. That is also why literals can't be told
apart by base type — declaring Value[5]'s base to be Integer would make
it disjoint from Float and wrongly sink Value[5] & Types::Float — and so
why this reasons over values instead of nominal-domain disjointness.
Runs after #intersect_constraints, so two literals over the same base reach here only once Constraint.merge_matchers has declined them (it merges Ranges and Sets, not literals).
291 292 293 294 295 296 297 |
# File 'lib/plumb/subtyping.rb', line 291 def intersect_literals(a, b) av = literal_value(a) bv = literal_value(b) return nil if Undefined.equal?(av) || Undefined.equal?(bv) Relation.from(av == bv).disproven? ? Types::Never : nil end |
.intersect_union(union, other, union_first: true) ⇒ Composable
Distributes intersection over a union, preserving operand order for runtime
compositions and dropping branches that reduce to Never. Order is observable
when a fallback branch converts: Static[5] >> String is not String >> Static[5].
248 249 250 251 252 253 254 255 256 257 |
# File 'lib/plumb/subtyping.rb', line 248 def intersect_union(union, other, union_first: true) parts = union.children.filter_map do |branch| left, right = union_first ? [branch, other] : [other, branch] m = intersect(left, right) || Conjunction.build(left, right) m unless m.is_a?(NeverClass) end return Types::Never if parts.empty? parts.reduce { |acc, p| acc | p } end |
.literal_value(node) ⇒ Object
The single value a node matches by equality, or Undefined for a node that
matches more than one (so a caller can't mistake a membership test for a
literal). Covers both spellings of a literal — Types::Value['a'] and
Types::String['a'] are equally provably disjoint from 'b' — but checks
the two differently: a ValueClass matches by == whatever it holds, so any
value qualifies, while a Constraint matches by ===, so only the kinds
whose #=== IS #== do (see Constraint#literal?). A bare Types::Value
already holds Undefined, which reads as "no literal" here.
307 308 309 310 311 312 313 |
# File 'lib/plumb/subtyping.rb', line 307 def literal_value(node) case node when ValueClass then node.children.first when Constraint then node.literal? ? node.matcher : Undefined else Undefined end end |
.map_children(type) {|child| ... } ⇒ Composable
Map a container's children through blk and rebuild it around the results —
the shared rule behind every covariant container's #accepted_type and
#output_type. Kept as an alias so those call sites read in terms of this
module; the traversal and its identity guard live in ONE place, because every
rewrite pass depends on an untouched subtree coming back equal?.
351 |
# File 'lib/plumb/subtyping.rb', line 351 def map_children(type, &blk) = NodeMapper.map_children(type, &blk) |
.resolved_input(type, depth = 0) ⇒ Object
450 451 452 453 454 455 456 457 458 459 |
# File 'lib/plumb/subtyping.rb', line 450 def resolved_input(type, depth = 0) TypeCache.fetch(:resolved_input, type) do nxt = type.input_type if depth >= 50 || nxt.equal?(type) || nxt == type type else resolved_input(nxt, depth + 1) end end end |
.resolved_output(type, depth = 0) ⇒ Object
Every node resolves its own #input_type / #output_type (an And does it at construction, an Or maps over its branches), so this is normally a single hop. The loop remains for nodes that delegate through a wrapper chain, and bottoms out when a type is its own io type. Memoized per node in TypeCache (frozen nodes only) — worth it for Or, which allocates a fresh Or of its resolved branches on every call.
439 440 441 442 443 444 445 446 447 448 |
# File 'lib/plumb/subtyping.rb', line 439 def resolved_output(type, depth = 0) TypeCache.fetch(:resolved_output, type) do nxt = type.output_type if depth >= 50 || nxt.equal?(type) || nxt == type type else resolved_output(nxt, depth + 1) end end end |
.stable_domain(type) ⇒ Array<Class>?
Returns the shared accepted/output base classes for a non-converting type. Unknown or converting domains return nil; comparing their output classes as input domains could incorrectly reduce an inhabited intersection to Never.
377 378 379 380 381 382 383 384 385 386 |
# File 'lib/plumb/subtyping.rb', line 377 def stable_domain(type) consumes = accepted_type(type) # A converting self-fallback does not expose a reliable input domain. return nil if consumes.equal?(type) && !value_preserving?(type) accepted = Plumb.resolve_base_types(consumes) return nil if accepted.empty? accepted == Plumb.resolve_base_types(resolved_output(type)) ? accepted : nil end |
.strict_subtype?(a, b) ⇒ Boolean
a is STRICTLY narrower than b — a subtype, and not merely equivalent
to it. The named form of Composable#<, usable where the operators are
not (a may be a raw struct Class, where < means Ruby ancestry — see
Plumb::Attributes).
121 |
# File 'lib/plumb/subtyping.rb', line 121 def strict_subtype?(a, b) = subtype?(a, b) && !subtype?(b, a) |
.subtype?(a, b) ⇒ Boolean
The structural subtype/subset relation over types.
subtype?(a, b) answers "is every value described by a also described
by b?", i.e. a <= b. Both a and b are normalized to Composables
(raw Ruby classes/values become a Constraint), so Types::String and
String compare the same way.
This engine knows ONLY the TYPE algebra — meet (Intersection), union (Or), and the top type (AnyClass) — plus one projection: a node that CONVERTS is replaced by what it produces (#subtype_identity, which Function, Implementation and the And composition all implement). So an execution node is never reasoned about structurally; it is reduced to a type first. That separation is what keeps the relation sound: applying the meet rule to a composition would put a converting chain under its own input type.
Everything else (atomic matchers, covariant containers, Hash width/depth, custom types) is decided by the type's own #subtype_of? leaf hook (see Composable). It never calls #<= (which would recurse back here).
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/plumb/subtyping.rb', line 34 def subtype?(a, b) a = Composable.wrap(a) b = Composable.wrap(b) return true if a.equal?(b) || a == b # A Deferred stands in for the type it lazily materializes (via #type). We # unwrap it here so the rest of the algebra never sees a Deferred. Recursive # (self-referential) types would otherwise loop forever, so we break cycles # coinductively: keyed by the identity of the (a, b) PAIR — the same Deferred # may be compared against different RHSs on sibling branches, so a single-node # marker would give false positives. Re-encountering a pair mid-recursion # means we've closed a loop in a self-referential type: assume it holds (the # greatest fixpoint) and let the surrounding structure confirm or refute it. # # `seen` is fiber-local (Thread.current[] is fiber-local in Ruby, not # thread-local): a subtype? query never yields, so its recursion owns the set # for its whole run, and concurrent fibers each get an isolated one. The # ensure-delete unwinds each pair, so the set is empty again between queries. if a.is_a?(Deferred) || b.is_a?(Deferred) seen = (Thread.current[:plumb_subtype_seen] ||= Set.new) key = [a.object_id, b.object_id] return true if seen.include?(key) seen.add(key) begin a = a.type if a.is_a?(Deferred) b = b.type if b.is_a?(Deferred) return subtype?(a, b) ensure seen.delete(key) end end # Transparent wrappers (Policy/Metadata/Node) only re-label the type they # delegate to — for the subtype relation they ARE the wrapped type, just # as they are for #accepted_type. ua = unwrap_transparent(a) ub = unwrap_transparent(b) return subtype?(ua, ub) unless ua.equal?(a) && ub.equal?(b) return true if b.is_a?(AnyClass) # X <= Top return false if a.is_a?(AnyClass) # Top <= X only when X is Top (handled above) # A value-converting type (Function, or any custom type that opts in) is # identified for subtyping by what it *produces*, not what it consumes, so we # reduce `a <= b` to `produced(a) <= b` before consulting the leaf hooks. A # type declares its produced identity via #subtype_identity (default: self). # # The `!equal?` guard is the safety rail: we only reduce when the projection # is a DISTINCT node. This makes the "distinct output type" invariant # structural rather than a convention — a type that projects to itself (the # default, and value-preserving types like FilteredHashMap/Static) simply # doesn't reduce and falls through to its #subtype_of? leaf, instead of # recursing into subtype?(self, b) forever. ai = a.subtype_identity return subtype?(ai, b) unless ai.equal?(a) bi = b.subtype_identity return subtype?(a, bi) unless bi.equal?(b) # Unions # Joins. The rule is the same for a choice and a union: a branch that # converts was already projected onto its output by #subtype_identity, so # by here every branch is a type either way. return a.children.all? { |m| subtype?(m, b) } if a.is_a?(Disjunction) # (A|B) <= C return b.children.any? { |m| subtype?(a, m) } if b.is_a?(Disjunction) # A <= (B|C) # Meets: the longer the Intersection chain, the narrower. This rule applies # ONLY to a genuine intersection, where both sides describe the same value. # A sequential composition (And) is a morphism and was already projected onto # what it produces by the #subtype_identity reduction above — applying the # meet rule to it would make a converting chain a subtype of its own INPUT # type, and so a subtype of two disjoint types at once. return b.children.all? { |bb| subtype?(a, bb) } if b.is_a?(Intersection) # a <= (b1 ∧ b2) return a.children.any? { |aa| subtype?(aa, b) } if a.is_a?(Intersection) # (a1 ∧ a2) <= b # `a` decides via its #subtype_of? leaf; if it can't (it doesn't know about # `b`), `b` may claim `a` via #supertype_of? — the mirror hook for # supertype-driven relations like Interface duck-typing. a.subtype_of?(b) || b.supertype_of?(a) end |
.unwrap_transparent(type) ⇒ Object
Peel transparent wrappers (Policy/Metadata/Node) down to the wrapped type.
411 412 413 414 415 416 417 418 |
# File 'lib/plumb/subtyping.rb', line 411 def unwrap_transparent(type) case type when Policy then unwrap_transparent(type.children.first) when Metadata then unwrap_transparent(type.type) when Composable::Node then unwrap_transparent(type.type) else type end end |
.value_preserving?(type) ⇒ Boolean
Does type return its input unchanged on success (a coreflexive
refinement)? Delegates to the type's polymorphic #value_preserving? hook —
so custom types opt in by defining it — and memoizes per frozen node in
TypeCache, a pure structural predicate like #accepted_type. Functions and
value-building containers (Hash/Array/Tuple/…) change the value and stay
false; refinements and their And/Or compositions are true.
394 395 396 |
# File 'lib/plumb/subtyping.rb', line 394 def value_preserving?(type) TypeCache.fetch(:value_preserving, type) { type.value_preserving? } end |
.value_subtype?(a, b) ⇒ Boolean
Subtype test between two attribute-constraint VALUES (the value of a
where(attr: value) clause). A value is either a full Plumb type — compared
structurally with #subtype? — or a raw ===-matcher (Range/Set/literal/
Regexp/Array/Hash), compared with #atomic_subtype?. The split is load-
bearing: #subtype? wraps its arguments, and Composable.wrap turns a raw
Array into Array[element] (raising on multi-element arrays) and a Hash into
a record type — but an AttributeValueMatch matches its value with plain
===, which is exactly what #atomic_subtype? models. Mirrors how a
Constraint's matcher can be either kind.
185 186 187 188 189 190 191 |
# File 'lib/plumb/subtyping.rb', line 185 def value_subtype?(a, b) if a.is_a?(Composable) || b.is_a?(Composable) subtype?(a, b) else atomic_subtype?(a, b) end end |