Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RFD-0036 — Generic declarations for derive, mutation, and compute

Discussion Opened 2026-05-11

Question

Argon today admits generic type parameters on exactly one declaration form: pub constraint. The other rule-shaped declarations — pub derive, pub mutation, pub compute — are syntactically monomorphic. RFD-0009 committed to generic computes but the grammar surface never landed; generic derives and mutations were never specified.

What is the minimal surface that lifts the existing <T: Bound> pattern to derive, mutation, and compute — without committing to dispatch, typeclass, or coherence machinery?

Context

Three pieces of background.

Today’s generic surface is narrow. The grammar parses <Ax: ordered_axis> on pub constraint (argon/oxc/src/cst/grammar/items.rs:954), and the elaborator monomorphizes the resulting GenericConstraint against the axis database (argon/oxc/src/meta/generics.rs). UFO ships its R-rules as generic constraints — necessary_subsumes_contingent<rigidity> etc. — and they specialize per concrete axis at elaboration time. The machinery exists and works.

Outside pub constraint, every rule-shaped declaration is monomorphic. pub derive name(params) :- body, pub mutation name(params) { … }, pub compute name(params) -> T = expr — no generic parameter slot. Real models duplicate identical bodies across concept-typed parameters: argon/examples/lease-story/packages/cofris/prelude.ar:603 declares pub compute total_asset_value(e: Enterprise) -> Money = 0 and packages/story-lease/src/computes.ar:91 re-declares pub compute total_asset_value(e: TenantLLCBooks) -> Money = …. The pattern repeats for equity_value, total_liability_value, total_expense_value, total_revenue_value. Each is a copy of the same shape parameterized over the enterprise concept.

RFD-0009 promised the compute side but didn’t ship. The committed RFD names compute f<T: HasValue>(x: T) -> Money as the canonical surface for generic computations. The grammar slot never landed. Derives and mutations were never specified at all.

The motivating ask (May 2026 design conversation): declare an abstract rule shape once, let multiple concrete cases fit it. The example:

pub predicate holds<S: Situation>(s: S)

pub derive holds(s: RentDue) :- s.amount > 0
pub derive holds(s: RentPaid) :- exists(p: Payment, p.lease = s.lease)

This RFD addresses the body-of-the-rule half of that ask — generics on rule declarations. The abstract-signature-without-body half (pure schema declarations that constrain conforming concrete rules) is reserved for a follow-up RFD; see Future work below.

Decision

Lift <T: Bound> from pub constraint to pub derive, pub mutation, pub compute. Semantics: monomorphizing expansion at composition time, following the existing GenericConstraint precedent and Rust’s compile-time monomorphization model. No dispatch, no typeclass, no coherence requirement.

1. Surface

pub derive name<T: Bound>(x: T, ...) :- body [=> consequence]

pub mutation name<T: Bound>(x: T, ...) { require? do emit* return? }

pub compute name<T: Bound>(x: T, ...) -> R = expr
pub compute name<T: Bound>(x: T, ...) -> R { body { … } | in { … } out { … } require? ensure? }
pub compute name<T: Bound>(x: T, ...) -> R impl rust("…")

Multiple parameters: <T: Foo, U: Bar>. Unconstrained: <T> (bound is the universal top type ). Parameter names follow the existing pub constraint convention — single-letter or short PascalCase identifiers.

Type parameters scope over the entire declaration. They may appear in parameter types, return types, rule-body atoms, mutation do { let x: T = … } bindings, and compute body { … } expressions.

2. Bound language

For v1, bounds are concept subtypes only. <T: Bound> means: at instantiation time, T is some concrete concept satisfying T <: Bound in the workspace’s concept lattice, where Bound is any concept reachable via the import graph.

The substrate makes no assumption about which metatype Bound’s instances belong to. Foundational-ontology packages classify concepts as kind, role, phase, mixin, etc. according to their own discipline — the substrate doesn’t see those distinctions. A <T: HasAmount> bound where HasAmount is UFO-classified as a mixin works exactly like <T: Lease> where Lease is UFO-classified as a relator. The bound resolves to a concept; the concept lattice supplies subtypes; monomorphization proceeds.

This RFD does not add field-shape bounds (<T: { amount: Money }>), where-clause bounds, or any inline structural specification. A package that wants structural typing declares a concept whose fields name the structural shape, and uses that concept as the bound. UFO’s existing mixin metatype is the prototypical example of how a foundational-ontology package wraps the substrate-level mechanism in its own vocabulary.

Inline field-shape bounds, multi-bound conjunctions, and where-clauses are future work; see below.

3. Monomorphization semantics

Generic rule declarations are stored abstractly in the per-package .oxc cache (RFD-0034) and instantiated at composition (ox build/ox compose, RFD-0034 §3).

For each generic declaration pub <kind> name<T: Bound>(...):

  1. The composer walks the workspace’s concept lattice and finds every concrete concept C such that C <: Bound and C is reachable via the workspace’s import graph.
  2. For each such C, the composer emits a monomorphized declaration pub <kind> name(x: C, ...) with every occurrence of T substituted by C in parameters, return type, and body.
  3. The monomorphized declarations enter the fixpoint engine alongside hand-written declarations. The engine never sees the abstract form.

The walk is transitive. Given pub kind Lease, pub subkind ResidentialLease : Lease, pub subkind CommercialLease : Lease, and pub phase ActiveLease : ResidentialLease, a generic pub derive holds<T: Lease>(l: T) :- l.active produces four monomorphizations: one for Lease, one for ResidentialLease, one for CommercialLease, one for ActiveLease.

Stable IDs include the resolved type (Cargo-style mangled names): stable_id("holds<ResidentialLease>"). Composition signatures (RFD-0030 §2) cover monomorphization sets — adding a new subtype of Bound to the workspace changes the signature and re-monomorphizes.

4. Composition with Datalog alternatives

Argon’s derive semantics is already multi-rule: multiple pub derive rules with the same head form a multi-headed Datalog program in which every body that matches contributes a derivation. Monomorphization preserves this exactly.

Given:

pub derive holds<S: Situation>(s: S) :- s.active

pub derive holds(s: RentDue) :- s.amount > 0

the composer produces one monomorphization of the generic per concrete subtype of Situation, then admits the hand-written holds(s: RentDue) as an additional rule with the same head. The fixpoint engine fires whichever bodies match. The union semantics matches the rest of the language.

If a modeler wants a specific case to override the generic default rather than add to it, they reach for defeasibility — pub derive holds(s: RentDue) [defeats: holds_generic_default] :- s.amount > 0 — using existing machinery (RFD-0005, RFD-0007). The generic surface itself does not introduce dispatch or specialization semantics; it produces additional alternatives.

5. Composition with package boundaries

Per RFD-0034 §3, wiring resolution and monomorphization are workspace-level operations. A generic declaration lives in its declaring package’s .oxc cache abstractly. When the workspace adds a new package that contributes subtypes of Bound, the composer regenerates monomorphizations to cover them.

Consequence: a downstream package that declares pub kind NewSituation : Situation automatically gains a holds(s: NewSituation) :- s.active derivation from any in-scope generic declaration over Situation. This matches the spirit of Datalog open-world composition and avoids the typeclass coherence problem (the new package isn’t picking a specific implementation — every generic just fires for every new subtype).

If a downstream package wants the generic NOT to fire for a particular subtype, the same defeasibility surface in §4 applies, scoped to the downstream package.

6. Generic computes and the FFI surface

For Form 1 (= expr) and Form 2 ({ body { … } | in { … } out { … } require? ensure? }) computes, monomorphization is straightforward — each clause is re-instantiated per concrete T, exactly as for derives.

For Form 3 (impl rust("…")), the FFI bridge must dispatch on concrete T. Two viable approaches:

(a) Generate one bridge function per monomorphization. impl rust("total<ResidentialLease>") resolves to a Rust function total_residential_lease(…) registered in the RustComputeRegistry. The codegen pipeline produces the per-T entry points.

(b) Single bridge function with runtime dispatch. impl rust("total") resolves to a single Rust function that branches on the concrete T at runtime via the schema.

Approach (a) is consistent with Rust’s monomorphization model and avoids runtime dispatch cost. Approach (b) is consistent with the kernel’s existing RustComputeRegistry shape (one named compute, one Rust function). The implementation chooses one (recommended: (a), with a compile-time error if any monomorphization’s bridge function is missing — OE0XXX MissingFFIBridge).

7. Substrate neutrality

Three invariants the implementation must honour:

  1. No metatype assumption. <T: Bound> says T is a subtype of Bound. The substrate does not require T to be classified by any particular metatype (kind, mixin, role, etc.). Foundational-ontology packages may impose metatype constraints on T via their own structural rules; the substrate does not.
  2. No field-shape assumption. The bound is the concept, not a field signature. The substrate does not inspect the bound concept’s fields to derive an implicit structural constraint. Field access on a T-typed binding fails the same way it would on a non-generic Bound-typed binding: the field must be declared on the bound concept itself or on a known subtype.
  3. No new vocabulary. This RFD introduces no new keywords. <T: Bound> reuses existing tokens (<, >, identifier, :). The semantics piggybacks on existing concept-lattice machinery in oxc::meta and the GenericConstraint precedent.

8. Diagnostics

Five diagnostic codes register in oxc-protocol::core::codes:

CodeSeverityTrigger
OE02XX GenericBoundNotConceptError<T: SomeBound> where SomeBound doesn’t resolve to a declared concept.
OE02XX GenericArityMismatchErrorGeneric parameter count differs between declaration and a reference site.
OE02XX MonomorphizationFailedErrorA monomorphization candidate failed type-checking (e.g. field access on T that resolves to a concrete type lacking that field). The diagnostic carries the concrete T that triggered the failure.
OE02XX MissingFFIBridgeErrorA Form-3 compute’s impl rust("…") resolves to a name with no corresponding bridge function for some concrete monomorphization.
OW02XX EmptyMonomorphizationWarningA generic declaration’s Bound has zero concrete subtypes in the workspace. The declaration parses but produces no concrete rules.

Exact code numbers reserved at implementation time per the RFD-0024 namespace.

9. Composition-signature interaction

Monomorphization sets are part of the workspace’s composition signature (RFD-0030 §2). The signature input includes:

  • For each generic declaration: its abstract IR + the set of concrete subtype-of-Bound concepts in scope at compose time
  • For each monomorphization: its concrete IR

A new subtype of any in-scope Bound changes the composition signature and triggers re-monomorphization. The .oxc cache invariant from RFD-0034 §4 (“cache is composition-specific”) holds without modification.

10. CLI surface

No new commands. ox build and ox compose (per RFD-0034 §10) cover monomorphization implicitly. oxc emit core-ir per RFD-0034 §10 shows both abstract generic declarations and their monomorphizations. oxc emit monomorphizations is a new diagnostic emit shape, scoped to this RFD’s introspection needs.

Rationale

Three independent design pressures converge on monomorphization. Rust’s <T: Trait> model (compile-time monomorphization, no runtime dispatch), Argon’s existing pub constraint <Ax: ordered_axis> machinery (axis-database walk + per-axis instantiation), and Lean 4’s elaboration-time type-class resolution all produce concrete declarations at compile time and leave the runtime to consume monomorphic code. Argon already pays the monomorphization cost on the constraint side; lifting it to derives, mutations, and computes reuses the same engine.

Datalog alternatives subsume what typeclasses would otherwise need to do. In a typeclass system, one declares an abstract method, multiple instances supply implementations, and dispatch picks one at the call site. In Argon’s Datalog-alternatives world, multiple rules with the same head all contribute; no dispatch is needed because the semantics is union. Monomorphization produces additional alternatives, not competing implementations. This is what makes Argon’s generic surface simpler than Rust’s or Haskell’s: the language doesn’t have a coherence problem because it doesn’t have a dispatch problem.

Substrate neutrality is preserved by construction. Bounds are concepts; concepts are user-declared via pub metatype-classified declarations; the substrate doesn’t know what kind of concept any particular bound is. UFO packages can call something a mixin and use it as a structural bound; BFO packages can use a continuant similarly; hand-rolled vocabularies can declare anything they want. The substrate provides the mechanism; foundational ontologies supply the vocabulary.

The minimal surface is also the most extensible. Closing on monomorphizing-generics-only leaves abstract signatures (RFD-0037+), inline field-shape bounds, and full traits as independent follow-on RFDs. Each can be evaluated on its own merits without committing to it now. Conversely, shipping a more ambitious surface here would force premature design decisions about coherence, default-method specialization, and dispatch semantics — all of which Argon may never need.

Closing the RFD-0009 promise. RFD-0009 named the compute generics surface as committed but never delivered the grammar. This RFD delivers it and extends to derives + mutations under the same machinery. The “no parametric concepts” position of RFD-0009 holds without amendment.

Consequences

  • Grammar: the existing <Ax: bound> parse logic in argon/oxc/src/cst/grammar/items.rs:954 (currently inside parse_package_constraint) factors into a reusable parse_generic_param_list. parse_derive_decl (rules.rs), parse_mutation (modules.rs), and parse_compute (modules.rs) each gain an optional generic-param-list slot after the keyword + name.

  • AST: DeriveDecl, MutationDecl, ComputeDecl each carry an Option<Vec<GenericTypeParam>>. The shape mirrors ConstraintDecl.

  • IR: CoreDerive, CoreMutation, CoreComputation each grow an optional type_params: Vec<TypeParam> field. Abstract IR is what the .oxc cache stores; the workspace composer produces concrete monomorphizations alongside.

  • Elaborator: a new pass in argon/oxc/src/elaborate/ walks generic declarations, computes their subtype-of-Bound sets, and emits monomorphized declarations. The pass runs after standard concept-lattice elaboration and before lowering. The existing GenericConstraint monomorphization in oxc::meta is the implementation template.

  • Stable IDs: stable_id includes resolved type parameters when present (name<ResolvedT>::field). The FNV-1a content-hash on .oxc cache entries covers the monomorphization set so the lockfile (RFD-0013) tracks changes.

  • Composition signature (RFD-0030 §2): monomorphization sets are signature inputs. Subtype additions invalidate caches.

  • Kernel API (RFD-0023): query name <ResolvedT> ( … ) becomes valid; the kernel resolves generic queries against the monomorphized set. v2 endpoint validators learn the new shape.

  • Documentation: argon/book/src/ch02-05-computations-and-mutations.md and ch02-04-rules-and-reasoning.md each get a “generic declarations” section. The for-agents glossary entry on generics (per the open-questions doc-tracker, #396 D.5) extends to cover rule-side generics.

  • Examples: argon/examples/lease-story/packages/cofris/prelude.ar and packages/story-lease/src/computes.ar can collapse the per-Enterprise-subtype compute duplication into a single generic pub compute total_asset_value<T: Enterprise>(e: T) -> Money declaration. Worth doing in the same PR as a demonstration; otherwise the deduplication is opportunistic.

  • Diagnostic registry: five new codes per §8.

  • Migration: none. Existing monomorphic declarations continue to parse and elaborate unchanged.

Open questions

  1. FFI bridge approach for Form-3 generic computes. §6 lists two options ((a) per-monomorphization bridge fns, (b) single bridge with runtime dispatch). Recommendation: ship (a) by default; allow impl rust("…", dispatch = "runtime") opt-in for cases where the Rust side prefers a single entry point. To be finalized during implementation.

  2. Empty-bound semantics. Should EmptyMonomorphization be a warning or an error? Argument for warning (current §8): the generic might gain monomorphizations later as the workspace grows. Argument for error: zero concrete subtypes likely indicates a misnamed bound. Recommendation: warning, with an #[allow(empty_monomorphization)] style opt-in if a package legitimately ships a generic ahead of any subtypes.

  3. Subtype walk scope on private concepts. Should monomorphization extend over pub-visible subtypes only, or include package-private subtypes? Recommendation: pub-visible only — pub <kind> name<T: Bound> is visible across packages; package-private subtypes within the declaring package should not leak their monomorphizations to consumers. (A package-private generic + package-private subtype would still monomorphize within the package, just not visibly.)

  4. Where-clause refinement on type parameters. <T: Bound where T.foo == bar> is a natural extension once refinement-by-meta-property (per RFD-0016) is fully wired. Out of scope for v1.

Future work

This RFD ships the minimum useful surface. Subsequent RFDs may build on it:

  • Abstract rule signatures (RFD-0037+ candidate). Declarations of the shape pub signature name<T: Bound>(x: T) (no body) — pure schemas that constrain conforming concrete derives/mutations/computes. Henrique’s pub predicate proposal, in substrate-neutral terms.

  • Inline field-shape bounds. <T: { amount: Money }> for ad-hoc structural constraints without declaring a named concept. Useful when the only intent is “T has these fields” and naming a concept would be ceremony.

  • Multi-bound conjunctions. <T: Foo + Bar> for cases where T must specialize multiple bounds simultaneously.

  • Where-clause refinement on type parameters (per Open Question 4 above).

  • Trait/typeclass dispatch. Reserved for when (and if) real workloads need pick-one-implementation semantics that doesn’t compose with Datalog alternatives. Likely a separate pub trait keyword if it ever lands; not in scope for this RFD and not a commitment.

  • Generic relations and metatypes. pub rel name<T>(...) would re-open the parametric-concepts problem RFD-0009 closes. Out of scope; closed by reference to RFD-0002 + RFD-0009.

Relationship to other RFDs

  • RFD-0009 (Generics are functor modules and generic computations). This RFD delivers the compute-generics surface RFD-0009 promised, and extends to derives + mutations under the same machinery. RFD-0009’s “no parametric concepts” position holds.

  • RFD-0034 (Composition pipeline). Monomorphization runs at compose time. Abstract generics live in per-package .oxc caches; concrete monomorphizations land in the workspace .oxbin artifact (RFD-0035).

  • RFD-0035 (Binary artifact + execute layer). .oxbin carries monomorphized declarations in the symbol table + rule sections. The abstract generic form does not appear in .oxbin — it’s a per-package cache concept, materialized away by compose time.

  • RFD-0024 (Diagnostic codes). Five new codes register per §8.

  • RFD-0030 (Composition signature). Monomorphization sets are signature inputs.

  • RFD-0013 (Bivalent lockfile). Lockfile content-hashes include monomorphization sets through the composition signature.

  • RFD-0005 (One grammar, multiple contexts). Generic parameter syntax is identical across derive, mutation, and compute — the existing “context determines semantics, not syntax” discipline applies.

  • RFD-0019 (Patterns). Patterns emit declarations on explicit instantiation; generics monomorphize implicitly by subtype walk. Both mechanisms can coexist; they cover different reuse shapes.

Historical lineage

The two-phase abstract-IR + monomorphize-at-compose pattern is the convergent shape across:

  • Rust (Hume 2019, “A Tour of Metaprogramming Models for Generics”) — <T: Trait> monomorphizes at the mono-collector pass before MIR lowering.
  • Lean 4 (de Moura et al. 2015, “Elaboration in Dependent Type Theory”) — instance synthesis at elaboration; instances fully resolved before code generation.
  • GenericConstraint in oxc::meta::generics.rs (Argon, in-tree) — UFO R-rule schemas parameterize over axes; the elaborator monomorphizes against the axis database. Three years of internal use; the pattern is proven on the constraint side.

The Datalog-alternatives composition (§4) draws on the modular Datalog literature, particularly the perspectival-Datalog work that grounds RFD-0030. Multiple rules with the same head are the union of bodies; monomorphization produces additional union members rather than competing implementations. No coherence machinery needed.

The decision not to ship trait/typeclass dispatch in v1 follows the discipline RFD-0009 set: the simplest mechanism that addresses the immediate need, with extensibility reserved. Jones’s qualified types (Jones 1994, ESOP) provide the formal foundation if dispatch ever lands; the concept-lattice-as-bound mechanism is already a qualified-types restriction (one predicate symbol: subtype-of).