Skip to content

Flow Contribution Merger (slice 3)

Status: v0.1.0 slice 3 normative. Pins the merge policy that combines FlowContribution bundles from built-in narrowing rules, RBS::Extended annotations, and plugins into a single MergeResult. Binding design surface: ADR-2 § “Plugin Contribution Merging”.

Multiple flow contributions can target the same call: a built-in narrowing rule and a plugin-provided fact may apply at the same site, two plugins may both register for the same receiver family, and RBS::Extended annotations may add their own facts. ADR-2 forbids first-wins / last-wins behaviour: contributions must merge deterministically, and contradictions between contributions must surface as diagnostics, not silent overrides.

Slice 3 ships the standalone merger. Slice 4 routes built-in narrowing through it; slice 5 wires plugin diagnostic provenance through the result; slice 6 uses the merger’s {provenances, conflicts} to attribute plugin-side cache entries.

Every namespace below is locked by spec/rigor/public_api_drift_spec.rb.

Bundle → element list flattening. Walks every non-empty slot and produces one or more {Element} value objects keyed by (target, edge, kind):

slotedgekindtarget
return_typenormalreturn_type:return
truthy_factstruthytruthy_factper-fact
falsey_factsfalseyfalsey_factper-fact
post_return_factspost_returnpost_return_factper-fact
mutationsnormalmutationper-mutation
invalidationsnormalinvalidationper-fact
exceptionalexceptionalexception:raise
role_conformancenormalroleper-role

Per-fact targets come from the payload’s #target accessor when present (typed-fact carriers, mutation effects); otherwise the payload itself becomes the merge key. The flattening is mechanical, deterministic, and round-trippable — feeding the result back through Merger.merge produces an equivalent bundle.

Frozen Data.define(:target, :edge, :kind, :payload, :provenance) value object. Constructor validates edge and kind against the ELEMENT_VALID_EDGES / ELEMENT_VALID_KINDS enums. #merge_key returns the [target, edge, kind] tuple the merger groups by.

Frozen Data.define(:target, :edge, :kind, :reason, :provenances, :message). reason is one of the slice-3 enum:

  • :return_type_collapse — same-tier return types whose intersection is empty.
  • :exceptional_disagreement — same-tier non-nil exceptional effects that disagree.
  • :lower_tier_contradiction — a lower-tier contribution would weaken or contradict a higher-tier proof.

provenances carries every contributing {FlowContribution::Provenance} (typically two — the higher-tier and the contradicting one). #to_h renders the conflict for diagnostic / formatter output.

#to_diagnostic(path:, line:, column:, severity: :error) (slice 5-C) converts the conflict into a Rigor::Analysis::Diagnostic with source_family: :contribution_merge and a kebab-cased rule derived from the conflict reason (return_type_collapsereturn-type-collapse). The qualified rule renders as [contribution_merge.return-type-collapse] in the standard rigor check text stream once the slice-4 wiring emits a conflict.

Frozen value object with the eight content slots from FlowContribution (return_type, truthy_facts, falsey_facts, post_return_facts, mutations, invalidations, exceptional, role_conformance), plus provenances (ordered list of every contributing provenance) and conflicts (collected Conflict rows). #conflict? and #empty? are predicates; #to_h renders the result for diagnostics.

Stateless module-level entry. Two surface methods:

  • Merger.merge(contributions) — folds an array of bundles through the merge policy and returns a MergeResult.
  • Merger.tier_for(provenance) — exposes the tier mapping the merger uses internally (useful for diagnostic formatters).

Canonical slot payload for the four edge-aware fact slots (truthy_facts, falsey_facts, post_return_facts plus the equivalent under future role / mutation Fact-shaped variants). Pinned by ADR-7 § “Slice 4-A”; unifies four parallel contribution carriers into a single comparable shape so the merger’s deduplication / intersection rules operate over a homogeneous payload type.

FieldPurpose
target_kind:parameter or :self. Future kinds (:local, :ivar, :result) attach without changing the merger.
target_nameSymbol — declared parameter name, or the literal :self. Non-nil so #target is well-defined.
typeRigor::Type::* — the type the target is narrowed toward (or away from when negative is true).
negativetrue for the ~T form (predicate-if-true x is ~Integer); false for the plain positive form.

#target returns :self for self-targeted facts, and [:parameter, name] otherwise. That value lands on Element#target and is the merge bucket key — two facts that narrow the same parameter from different contribution sources group together regardless of source family.

The four parallel carriers translate to / from Fact:

  • Rigor::RbsExtended::PredicateEffect#to_fact — class-name effects lift to Nominal[<class>]-typed facts; refinement-form effects pass their refinement_type through directly. The edge field doesn’t survive — the slot the resulting fact lands in (truthy_facts / falsey_facts) encodes that.
  • Rigor::RbsExtended::AssertEffect#to_fact — same shape; the condition field (:always / :if_truthy_return / :if_falsey_return) routes the slot at the read_flow_contribution boundary (:alwayspost_return_facts, :if_truthy_returntruthy_facts, :if_falsey_returnfalsey_facts) and does not surface on the Fact itself.
  • Built-in narrowing facts — slice 4 implementer adds the translation when wiring Inference::Narrowing through the merger.
  • Plugin contributions — slice 5’s emission protocol returns FlowContribution bundles whose truthy_facts / falsey_facts slots are already Fact arrays.
TierSource familyNotes
0:builtinCore Ruby semantics + accepted RBS contracts. Authoritative.
1:rbs_extendedRBS::Extended directive bundles (v0.0.9 group D reference impl).
1:generatedGenerated signatures / metadata.
2:plugin, plugin.<id>Plugin contributions.
3anything elseUnknown — reported but treated as the lowest tier.

Within a tier, contributions merge in deterministic order: provenance-supplied plugin_id alphabetical (nil plugin ids sort first to keep :rbs_extended / :generated pre-plugin contributions stable), then by their original input position as the final tie-break.

  • :return_type. Intersect via Rigor::Type::Combinator.intersection. The merger detects collapse via mutual accepts trinaries: when neither side accepts the other (a.accepts(b).no? && b.accepts(a).no?), the value domains are disjoint and the intersection is empty. Collapse at the same tier raises :return_type_collapse; collapse triggered by a lower tier raises :lower_tier_contradiction. The result keeps the higher-tier value for the slot.
  • :truthy_fact / :falsey_fact / :post_return_fact. Edge-local. Plugin true-edge facts do NOT imply the false-edge complement. Same-tier and cross-tier facts accumulate while deduping by payload equality.
  • :mutation / :invalidation / :role. Union; dedupe by equality.
  • :exception. Single-valued. Equal exceptional effects collapse silently; non-equal effects raise either :exceptional_disagreement (same tier) or :lower_tier_contradiction (lower tier challenges higher).
  • Wire built-in narrowing through the merger. Slice 4’s job.
  • Diagnose conflicts as :contribution_merge Diagnostic rows. Slice 5 routes plugin diagnostic provenance through the formatter; slice 4 surfaces conflicts during analysis.
  • Compose Cache::Descriptor rows from the merge result. Slice 6 picks that up alongside plugin-side cache producers.
  • Detect richer return-type-collapse cases. The slice-3 heuristic uses the accepts trinary; non-nominal carriers (Tuple intersection that collapses, structural intersection with a refined predicate that excludes every constant) fall through as non-collapsing for now. Slice 4 will exercise the full carrier matrix and fold any missed cases back into the merger.

The flattening is implemented to be invertible:

contribution = Rigor::FlowContribution.new(...)
elements = contribution.to_element_list
merged = Rigor::FlowContribution::Merger.merge([contribution])
# merged carries the same slots as `contribution`, plus the
# provenance and an empty conflict list.

Slice 4 will exercise this round-trip alongside the analyzer’s existing narrowing call sites.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.