sini/gen-dispatch
{ "createdAt": "2026-05-26T05:18:02Z", "defaultBranch": "main", "description": "gen-derive: stratified rule dispatch with fixpoint convergence", "fullName": "sini/gen-dispatch", "homepage": null, "language": "Nix", "name": "gen-dispatch", "pushedAt": "2026-07-25T00:19:49Z", "stargazersCount": 1, "topics": [], "updatedAt": "2026-07-25T00:19:52Z", "url": "https://github.com/sini/gen-dispatch"}gen-dispatch — relational rule dispatch over ordered groups
Section titled “gen-dispatch — relational rule dispatch over ordered groups”Relational rule dispatch — the guard→effect dispatch step, implemented as a pure Nix library.
gen-dispatch is a production rule system (Forgy, 1982) with stratified groups (Arntzenius & Krishnaswami, 2016) and algebraic graph rewriting vocabulary (Ehrig et al., 2006). Given rules (condition + action producer), a position, and a context, gen-dispatch answers: “which rules fire here, and what actions do they produce?” It owns rule evaluation and conflict resolution over a caller-supplied group order — all rules in group N complete before group N+1 begins, with context threaded between groups. Actions are opaque — the vocabulary belongs to the consumer.
gen-dispatch is one dispatch step — a pure function of (rules, context). Two neighbouring concerns are deliberately not here: the convergence loop that iterates dispatch to a fixpoint (a circular attribute’s Kleene ascent) belongs to gen-resolve / gen-scope.circular, and group ordering (turning before/after constraints into a linear order) belongs to gen-graph. Iterate by threading pure domain state through repeated one-shot dispatch; because a group order is a topological sort and the action set is a function of the converged state, the split holds without any cross-pass bookkeeping inside dispatch.
Dependency class. gen-dispatch is nixpkgs-lib-free Class B: its only dependency is gen-prelude (pure, zero-input) — builtins re-exports plus the vendored imap0/unique. The former nixpkgs.lib and gen-algebra dependencies are gone. The library (lib/) is nixpkgs.lib-free, enforced by ci/tests/purity.nix; nixpkgs is pulled only into ci/ for the test harness. gen-dispatch is generic — it has no knowledge of NixOS, aspects, policies, or system configuration. It provides dispatch machinery; consumers define what to compute.
Table of Contents
Section titled “Table of Contents”- [Terminology]!(#terminology)
- [Overview]!(#overview)
- [Gen Ecosystem]!(#gen-ecosystem)
- [Usage]!(#usage)
- [Two-Tier Architecture]!(#two-tier-architecture)
- [API Reference]!(#api-reference)
- [Usage Example]!(#usage-example)
- [Testing]!(#testing)
- [Theoretical Foundations]!(#theoretical-foundations)
Terminology
Section titled “Terminology”| Term | Definition | Source |
|---|---|---|
| Rule | Guarded transformation unit: condition + action producer + identity | Ehrig 2006; Forgy 1982 |
| Condition | Predicate determining when a rule fires | Forgy 1982 (RETE LHS) |
| Action | Opaque tagged value produced when a rule fires | Forgy 1982 (RETE RHS) |
| Group | Named dispatch stratum with DAG ordering | Arntzenius 2016 (stratification) |
| Match | Testing a condition against a position | Ehrig 2006 (match morphism) |
| Dispatch step | One guard→effect pass over ordered groups (the unit a convergence loop iterates) | Forgy 1982; Arntzenius 2016 |
| NAC | Negative application condition — pattern that must NOT match | Ehrig 2006 |
Overview
Section titled “Overview”The hard part of rule dispatch is the generic guard→effect protocol: rules declare what they need, and the engine handles when and how they fire (match, NAC, priority, override, phase threading). Hand-rolling this caused a class of context-threading regressions in den (PRs 408-437). gen-dispatch extracts the protocol as one dispatch step.
A rule is a guarded action producer (mkRule / fromFunction). A dispatch step (dispatch) walks a caller-supplied group order, and for each group: matches conditions against the threaded context, applies conflict resolution (override → priority → specificity), fires the survivors, classifies their actions into groups, then threads the resulting context forward into the next group. The result is { actions; orderedGroups; context; }.
Three concerns meet at a dispatch step, and gen-dispatch owns exactly one of them:
| Concern | Owner | Entry point |
|---|---|---|
| One guard→effect pass over an ordered group list | gen-dispatch (this lib) | dispatch |
| Iterating a step to a fixpoint (a circular attribute’s Kleene ascent) | gen-resolve / gen-scope.circular | thread domain state through repeated dispatch |
Turning before/after constraints into a linear group order | gen-graph | a topological sort |
Wrapping repeated steps into a convergence loop — extract feedback, widen context, re-dispatch until stable — is a separable concern owned by gen-scope.circular. gen-dispatch stays a pure step: the caller threads the domain state and reads the actions off the converged state. See [Convergence]!(#convergence-the-loop-is-gen-resolves) below.
Gen Ecosystem
Section titled “Gen Ecosystem”| Library | Role |
|---|---|
| gen-prelude | Pure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils) |
| gen-algebra | Pure primitives (record, search monad, either, intensional identity) |
| gen-types | Clean-room MIT structural type checker (leaf/poly checkers; verify: v → null|err) |
| gen-merge | Byte-mode module merge engine (evalModuleTree, byte-identical to nixpkgs lib.evalModules over the priority subset) |
| gen-schema | Typed registries (kinds, instances, collections, refs); re-hosted on gen-merge |
| gen-aspects | Aspect type system (traits, classification, dispatch); re-hosted on gen-merge |
| gen-scope | HOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes) |
| gen-graph | Accessor-based graph query combinators (traversal, condensation, phaseOrder) |
| gen-select | Selector algebra (pattern matching over graph positions) |
| gen-bind | Module binding (inject external args into NixOS modules) |
| gen-dispatch | This lib — Relational rule dispatch STEP (stratified groups, conflict resolution) |
| gen-resolve | Demand-driven RAG evaluator over scope graphs (attribute schedule + convergence loop) |
| gen-rebuild | Pure-Nix incremental rebuilder (change propagation, AFFECTED set) |
| gen-vars | Pure-Nix vars/secrets (den-agnostic) |
| gen-flake | The nixpkgs boundary — compose purely, inject resolved values, build NixOS systems (value-injection) |
As a flake input — gen-dispatch.lib is the value output (no functor call). Class B, so nothing but gen-prelude is pulled transitively; no nixpkgs input is needed:
{ inputs.gen-dispatch.url = "github:sini/gen-dispatch"; outputs = { gen-dispatch, ... }: let dispatch = gen-dispatch.lib; # takes only gen-prelude, transitively in { /* ... */ };}Without flakes — the standalone shim (default.nix) derives gen-prelude from the pinned flake.lock (content-addressed, so it stays pure) and needs no <nixpkgs>:
let dispatch = import ./gen-dispatch; # prelude auto-derived from the lockin { /* ... */ }
# or pass an explicit prelude / import the lib directly:let dispatch = import ./gen-dispatch/lib { prelude = myPrelude; };in { /* ... */ }Two-Tier Architecture
Section titled “Two-Tier Architecture”gen-dispatch splits into two tiers:
- Core tier — depends on gen-prelude only. Conditions are opaque; the caller provides
match : condition -> id -> ctx -> bool. - Adapter tier — imports gen-select. Bridges gen-select selectors into gen-dispatch conditions with
mkMatchand CSS-likeselectorSpecificity.
Consumers without gen-select can use gen-dispatch with custom match functions. Consumers with gen-select get selector pattern matching and CSS-like specificity for conflict resolution. The adapter lives under lib.adapters.select; gen-select is a CI-only input (it is not a runtime dependency of the core surface).
API Reference
Section titled “API Reference”The full exported surface is { dispatch, mkRule, fromFunction, fromFunctionMatch, mkActions, restrict, override, chain, groupOf, producesOf, deriveGroup, adapters }, where adapters = { select = { mkMatch, selectorSpecificity }; }.
dispatch
Section titled “dispatch”dispatch { rules; # [ rule ] id; # current position context; # caller-defined context match; # condition -> id -> ctx -> bool classify; # action -> group name groupOrder; # [ groupName ] — pre-ordered (e.g. a gen-graph topo sort); dispatch does NOT sort exclusive ? false; # only highest-priority group fires extract ? (_: {}); # { group = [action]; } -> ctx delta (per-group threading; default no-op) combine ? (ctx: _: ctx); # ctx -> delta -> ctx (default identity = no threading)}-> { actions; orderedGroups; context; }One-shot dispatch, a pure function of (rules, context). Fires all matching rules in the supplied groupOrder — lower groups complete before higher groups begin, with context threaded between groups. Ordering is the caller’s concern (gen-graph builds it from before/after constraints); dispatch just walks the list. orderedGroups in the result is the present-only subsequence of groupOrder. Validates the single-group-per-rule constraint.
Dispatch sequence: walk groupOrder; per group — select this group’s rules (an ungrouped rule under multi-group dispatch throws) → NAC + condition match against the threaded context → forward-accumulating override suppression (carries to later groups) → priority sort → exclusive filter → fire → classify-validate (single-group-per-rule + declared-group consistency) → group → thread context (combine/extract) into the next group.
Convergence (the loop is gen-resolve’s)
Section titled “Convergence (the loop is gen-resolve’s)”dispatch is a pure step: the same context always yields the same actions, so it owns no iteration. When rules are genuinely cyclic and must iterate to a fixpoint, the LOOP belongs to gen-resolve (gen-scope.circular’s Kleene ascent). The blessed composition threads plain domain state through repeated one-shot dispatch:
# one-shot dispatch as the step: next state = the context dispatch threads outstep = _self: _id: ctx: (dispatch (cfg // { context = ctx; })).context;
# gen-scope.circular iterates the step to a fixpoint over the domain stateconverged = (scope.circular { init = ctx0; eq = stateEq; } step) { } null;
# one post-convergence dispatch reads the actions off the fixpointresult = dispatch (cfg // { context = converged; }); # result.actions, result.orderedGroupsRecomputing at the fixpoint makes the action set a function of the converged state, never the iteration path — a confluence guarantee. That is why dispatch keeps no cross-pass fired set: the “double-emit across passes” problem exists only under an accumulate-across-passes model, and cannot arise when each pass recomputes from scratch and the actions are taken from the fixpoint. (The retired dispatchStep / dispatchInit pair was the byte-identical migration seam off the old in-tree fixpoint; with the recompute pattern blessed, it is gone.)
mkRule
Section titled “mkRule”mkRule { condition; # opaque -- interpreted by match function produce; # id -> ctx -> [ action ] nac ? null; # negative application condition identity ? null; # string for dedup, or null (anonymous) priority ? 0; # higher fires first overrides ? []; # identities of rules this one replaces group ? null; # group name (stratum) for stratified dispatch, or null (single-group) produces ? null; # declared produced-kind family [ tag ], or null (undeclared) -- see Declared Stratum}-> rulefromFunction
Section titled “fromFunction”fromFunction : fn -> ruleConverts a Nix function into a rule using builtins.functionArgs as the condition. Detects mkIntensional-wrapped functions (Palmer 2024) via a four-predicate check (isAttrs + name/__functor/closure) and extracts identity automatically.
# { host, ... } is the condition -- required arg "host" must be in contextdispatch.fromFunction ({ host, ... }: [ (fx.spawn { kind = "user"; }) ])
# mkIntensional wrapping adds dedup identitydispatch.fromFunction (mkIntensional "host-init" {} ({ host, ... }: [ ... ]))fromFunctionMatch
Section titled “fromFunctionMatch”fromFunctionMatch : condition -> id -> ctx -> boolDefault match implementation for fromFunction rules. Checks that all required args (non-optional in functionArgs) are present in context. Handles __restricted conditions from restrict by recursively matching both the original and extra conditions.
mkActions
Section titled “mkActions”mkActions { groupName = [ "tag" ... ]; ... }-> { tag = args: { __action = "tag"; } // args; ...; classify = action -> groupName; groupOfKind = tag -> groupName; }Generates tagged action constructors and a classify function from a group declaration. Optional — complex consumers write their own constructors. classify maps a fired action ({ __action = tag; }) to its group; groupOfKind maps a bare kind tag to its group (the static counterpart used to discharge a rule’s declared produces — see [Declared Stratum]!(#declared-stratum)). Both throw on an unknown tag.
Declared Stratum
Section titled “Declared Stratum”A rule’s dispatch group (stratum) is normally learned by firing it: run produce, classify the actions, read off the group. That fire-and-observe probe is a hazard for a value-conditional rule — you must run its body against a synthetic context and swallow throws just to discover where its output belongs. The declared-stratum vocabulary lets a rule carry its stratum as data instead, mirroring gen-resolve’s per-equation stratum field (an equation names its stratum up front; scheduleWith { strataOrder } reads it — no execution).
A rule DECLARES its produced-kind family via mkRule’s produces field (a list of action tags). deriveGroup then discharges the group by classifying the declared kinds — no produce call:
groupOf : rule -> group | null # read the declared stratum WITHOUT firing (null = undeclared)producesOf : rule -> [ tag ] | null # read the declared produced-kind family WITHOUT firingderiveGroup : (tag -> group) -> rule -> rule # classify `produces`, stamp `group`; validate at definition timederiveGroup classifyKind rule (pass fx.groupOfKind as classifyKind) stamps the rule’s group from its declared kinds and enforces the single-group-per-rule law at definition time:
- declared kinds spanning more than one group abort NAMED (a rule cannot occupy two strata);
- an explicit
groupthat disagrees with the classified stratum aborts NAMED — the declared-stratum / produced-kind conflict (the static analog of dispatch’s runtime “declared group X but produced Y” throw, caught before any body runs); - a rule without
producesis returned unchanged (undeclared = infer as before), so the vocabulary is fully additive.
dispatch honors the declaration: a rule whose produces is set skips the fire-and-classify validation (its stratum is the declaration, trusted like gen-resolve trusts stratum). Undeclared rules keep the classify-validation exactly as before — behavior is byte-identical when nothing declares.
fx = dispatch.mkActions { structural = [ "spawn" ]; resolution = [ "edge" ]; };rule = dispatch.deriveGroup fx.groupOfKind (dispatch.mkRule { condition = { host = false; }; produce = _id: _ctx: [ (fx.edge { }) ]; produces = [ "edge" ]; # declared family identity = "nixos-edges";});dispatch.groupOf rule # => "resolution" (derived, never fired)Conflict Resolution
Section titled “Conflict Resolution”Three strategies, applied in order:
| Strategy | Tier | Mechanism |
|---|---|---|
| Override | Core | Rule names identities it replaces via the overrides field |
| Priority | Core | Numeric priority (higher first), exclusive mode |
| Specificity | Adapter | Selector constraint term count via selectorSpecificity |
Resolution order: override suppression → priority sort → specificity (adapter) → ties fire additively. Equal-priority ties are ordered deterministically by declaration order (a total-order sort, independent of builtins.sort stability or rule-list enumeration order).
Rule Composition
Section titled “Rule Composition”# Narrow a rule's condition (produces a __restricted condition)dispatch.restrict extraCondition rule
# One rule replaces another (sugar over the overrides field)dispatch.override original replacement
# Sequential: A's actions feed as context to Bdispatch.chain { extract; } ruleA ruleBGroup ordering (delegated to gen-graph)
Section titled “Group ordering (delegated to gen-graph)”Group ordering is no longer gen-dispatch’s concern. Build the groupOrder list with gen-graph’s topological sort (phaseOrder) over before/after entries and pass it to dispatch:
graph.phaseOrder { structural = graph.entryAnywhere; # no ordering constraints resolution = graph.entryAfter [ "structural" ]; # after named groups collection = graph.entryBefore [ "teardown" ]; # before named groups} # -> a valid producers-first topo orderAdapter: gen-select bridge (adapters.select)
Section titled “Adapter: gen-select bridge (adapters.select)”# Bridge gen-select selectors as gen-dispatch conditionsmatch = dispatch.adapters.select.mkMatch genSelect;
# CSS-like specificity counting for conflict resolutiondispatch.adapters.select.selectorSpecificity selector # -> intUsage Example
Section titled “Usage Example”Policy-like rules that enrich context and produce typed actions across stratified groups. Ordering comes from gen-graph; a single dispatch threads context between groups in that order:
let dispatch = gen-dispatch.lib; graph = gen-graph.lib;
# Define action vocabulary -- gen-dispatch classifies but doesn't interpret fx = dispatch.mkActions { structural = [ "spawn" "enrich" ]; resolution = [ "edge" ]; };
rules = [ # Function signature IS the condition (canTake pattern) (dispatch.fromFunction ({ host, ... }: [ (fx.enrich { key = "isNixos"; value = true; }) (fx.spawn { kind = "user"; }) ]))
# This rule fires only after enrichment adds "isNixos" to context (dispatch.mkRule { condition = { host = false; isNixos = false; }; produce = _id: _ctx: [ (fx.edge { target = "logging"; }) ]; identity = "nixos-edges"; }) ];
cfg = { inherit rules; id = null; match = dispatch.fromFunctionMatch; classify = fx.classify; # group ORDERING is gen-graph's job groupOrder = graph.phaseOrder { structural = graph.entryAnywhere; resolution = graph.entryAfter [ "structural" ]; }; extract = actions: lib.foldl' (acc: a: if a.__action == "enrich" then acc // { ${a.key} = a.value; } else acc ) {} (actions.structural or []); combine = ctx: ext: ctx // ext; };
# One pass: the enrich→resolution cascade completes because context threads forward. result = dispatch.dispatch (cfg // { context = { host = { name = "igloo"; }; }; });in result.actions # { structural = [ enrich, spawn ]; resolution = [ edge ]; }When you need a convergence loop (genuinely cyclic rules that must iterate to a fixpoint), the LOOP is gen-resolve’s, not gen-dispatch’s — thread the domain state through gen-scope.circular and read the actions off the fixpoint:
let scope = gen-scope.lib; # step: next state = the context this pass threads out (one-shot dispatch) step = _self: _id: ctx: (dispatch.dispatch (cfg // { context = ctx; })).context;
converged = (scope.circular { init = { host = { name = "igloo"; }; }; eq = a: b: builtins.attrNames a == builtins.attrNames b; } step) {} null;in (dispatch.dispatch (cfg // { context = converged; })).actions # actions as a function of the fixpointThe action set is a function of the converged state, not the iteration path (confluence), so a rule cannot double-emit across passes — the accumulate-across-passes bookkeeping the retired fixpoint / dispatchStep needed is unnecessary. gen-scope.circular drives the Kleene ascent.
Testing
Section titled “Testing”Tests use nix-unit; the CI flake (ci/) pins nixpkgs for the harness while the library (../lib) takes only gen-prelude. The library is nixpkgs.lib-free, enforced by the purity suite (ci/tests/purity.nix).
nix flake check ./ci # all suites + the purity checknix build ./ci#formatter.x86_64-linux # then run ./result/bin/* . to formatnix repl --impure --file ci/repl.nix # all exports in scope for interactive useThere are 69 tests across 11 suites (rule, actions, dispatch-basic, dispatch-groups, dispatch-nac, conflict, compose, declared, adapter-select, integration, purity). Iteration/convergence coverage lives cross-repo now: the gen-scope.circular Kleene ascent is tested in gen-scope, and the loop⊥step composition (one-shot dispatch threaded to a fixpoint) is exercised by consumers such as gen-resolve.
Theoretical Foundations
Section titled “Theoretical Foundations”| Paper | Relationship | Used for |
|---|---|---|
| Forgy (1982) “RETE” | Implements | Condition-action rule dispatch; rule = condition + action production system |
| Ehrig et al. (2006) “Fundamentals of Algebraic Graph Transformation” | Implements | Graph rewriting rules, negative application conditions as a first-class nac field |
| Arntzenius & Krishnaswami (2016) “Datafun” | Implements | Stratified groups: rules dispatched in a caller-supplied stratum order — all rules in group N complete before group N+1 begins, with context threaded between groups. A rule’s stratum is a static property (discharged by classifying its declared produced kinds, deriveGroup), not a runtime probe — mirroring gen-resolve’s per-equation stratum. (The monotone fixpoint reading — iterating dispatch to convergence — moved with the loop to gen-resolve.) |
| Palmer et al. (2024) “Intensional Functions” | Implements | Rule identity via mkIntensional detection (four-predicate check: isAttrs + name/__functor/closure), dedup |
| Hedin & Magnusson (2003) “JastAdd” | Informed by | Open action types with framework-owned dispatch; aspect-oriented modular attribution |
| Batory (2005) “AHEAD” | Informed by | Feature composition model inspires the restrict/override/chain rule combinators |
| Berry & Boudol (1990) “Chemical Abstract Machine” | Informed by | Rules as reactions producing transformations; multiset rewriting as a dispatch metaphor |
License
Section titled “License”MIT — see LICENSE.