Skip to content
Oeiuwq Faith Blog OpenSource Porfolio

sini/gen-dispatch

gen-derive: stratified rule dispatch with fixpoint convergence

sini/gen-dispatch.json
{
"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-01T19:28:20Z",
"stargazersCount": 1,
"topics": [],
"updatedAt": "2026-07-01T19:28:39Z",
"url": "https://github.com/sini/gen-dispatch"
}

gen-dispatch — relational rule dispatch over ordered phases

Section titled “gen-dispatch — relational rule dispatch over ordered phases”

CI License: MIT Sponsor

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 phases (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 dispatch, conflict resolution, and rule dedup over a caller-supplied phase order — all rules in phase N complete before phase N+1 begins, with context threaded between phases. Actions are opaque — the vocabulary belongs to the consumer.

gen-dispatch is one dispatch step. 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 phase ordering (turning before/after constraints into a linear order) belongs to gen-graph (phaseOrder). The loop⊥step split is proven byte-identical by an equivalence oracle maintained in gen-resolve, against the retired in-tree fixpoint.

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.

  • [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)
TermDefinitionSource
RuleGuarded transformation unit: condition + action producer + identityEhrig 2006; Forgy 1982
ConditionPredicate determining when a rule firesForgy 1982 (RETE LHS)
ActionOpaque tagged value produced when a rule firesForgy 1982 (RETE RHS)
PhaseNamed dispatch group with DAG orderingArntzenius 2016 (stratification)
MatchTesting a condition against a positionEhrig 2006 (match morphism)
Dispatch stepOne guard→effect pass over ordered phases (the unit a convergence loop iterates)Forgy 1982; Arntzenius 2016
NACNegative application condition — pattern that must NOT matchEhrig 2006

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 phase order, and for each phase: matches conditions against the threaded context, applies conflict resolution (override → priority → specificity), fires the survivors, classifies their actions into phases, then threads the resulting context forward into the next phase. The result is { actions; orderedPhases; context; fired; }.

Three concerns meet at a dispatch step, and gen-dispatch owns exactly one of them:

ConcernOwnerEntry point
One guard→effect pass over an ordered phase listgen-dispatch (this lib)dispatch
Iterating a step to a fixpoint (a circular attribute’s Kleene ascent)gen-resolve / gen-scope.circularpair a step via dispatchStep / dispatchInit
Turning before/after constraints into a linear phase ordergen-graphphaseOrder

Wrapping repeated steps into a convergence loop — extract feedback, widen context, re-dispatch until stable — is a separable concern; gen-dispatch exposes dispatchStep / dispatchInit so a step can be paired with gen-scope.circular’s loop, but does not own the loop itself.

LibraryRole
gen-preludePure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils)
gen-algebraPure primitives (record, search monad, either, intensional identity)
gen-schemaTyped registries (kinds, instances, collections, refs)
gen-aspectsAspect type system (traits, classification, dispatch)
gen-scopeHOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes)
gen-graphAccessor-based graph query combinators (traversal, condensation, phaseOrder)
gen-selectSelector algebra (pattern matching over graph positions)
gen-bindModule binding (inject external args into NixOS modules)
gen-dispatchThis lib — relational rule dispatch STEP (stratified phases, conflict resolution)
gen-resolveDemand-driven RAG evaluator over scope graphs (attribute schedule + convergence loop)
gen-rebuildPure-Nix incremental rebuilder (change propagation, AFFECTED set)
gen-varsPure-Nix vars/secrets (den-agnostic)

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:

flake.nix
{
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 lock
in { /* ... */ }
# or pass an explicit prelude / import the lib directly:
let dispatch = import ./gen-dispatch/lib { prelude = myPrelude; };
in { /* ... */ }

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 mkMatch and CSS-like selectorSpecificity.

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).

The full exported surface is { dispatch, dispatchStep, dispatchInit, mkRule, fromFunction, fromFunctionMatch, mkActions, restrict, override, chain, adapters }, where adapters = { select = { mkMatch, selectorSpecificity }; }.

dispatch {
rules; # [ rule ]
id; # current position
context; # caller-defined context
match; # condition -> id -> ctx -> bool
classify; # action -> phase name
phaseOrder; # [ phaseName ] — pre-ordered (e.g. gen-graph.phaseOrder); dispatch does NOT sort
exclusive ? false; # only highest-priority group fires
fired ? {}; # pre-seeded fired identity set
extract ? (_: {}); # { phase = [action]; } -> ctx delta (per-phase threading; default no-op)
combine ? (ctx: _: ctx); # ctx -> delta -> ctx (default identity = no threading)
}
-> { actions; orderedPhases; context; fired; }

One-shot dispatch. Fires all matching rules in the supplied phaseOrder — lower phases complete before higher phases begin, with context threaded between phases. Ordering is the caller’s concern (gen-graph.phaseOrder builds it from before/after constraints); dispatch just walks the list. orderedPhases in the result is the present-only subsequence of phaseOrder. Validates the single-phase-per-rule constraint.

Dispatch sequence: walk phaseOrder; per phase — select this phase’s rules (an unphased rule under multi-phase dispatch throws) → NAC + condition match against the threaded context → forward-accumulating override suppression (carries to later phases) → priority sort → exclusive filter → fire → classify-validate (single-phase-per-rule + declared-phase consistency) → group → thread context (combine/extract) into the next phase.

dispatchStep / dispatchInit (convergence step)

Section titled “dispatchStep / dispatchInit (convergence step)”

The convergence LOOP is not gen-dispatch’s — it belongs to gen-resolve (gen-scope.circular’s Kleene ascent). gen-dispatch supplies the loop’s STEP: a dispatch pass that threads fired and accumulates actions across passes.

dispatchStep { dispatch } cfg # -> (self: id: prev -> next) # cfg = dispatch args minus context/fired
dispatchInit context # -> { context; fired = {}; accActions = {}; orderedPhases = []; }

The step’s shape (self: id: prev) matches gen-scope.circular’s f: self: id; the threaded value is { context; fired; accActions; orderedPhases }. fired carries once-per-identity dedup across passes; accActions accumulates with the exact fold the retired fixpoint used. Drive convergence with gen-scope.circular { init = dispatchInit ctx; eq; } (dispatchStep { inherit dispatch; } cfg). This composition is proven byte-identical to the old in-tree fixpoint by an equivalence oracle maintained in gen-resolve.

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
phase ? null; # phase name for stratified dispatch, or null (single-phase)
}
-> rule
fromFunction : fn -> rule

Converts 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 context
dispatch.fromFunction ({ host, ... }: [ (fx.spawn { kind = "user"; }) ])
# mkIntensional wrapping adds dedup identity
dispatch.fromFunction (mkIntensional "host-init" {} ({ host, ... }: [ ... ]))
fromFunctionMatch : condition -> id -> ctx -> bool

Default 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 { phaseName = [ "tag" ... ]; ... }
-> { tag = args: { __action = "tag"; } // args; ...; classify = action -> phaseName; }

Generates tagged action constructors and a classify function from a phase declaration. Optional — complex consumers write their own constructors.

Three strategies, applied in order:

StrategyTierMechanism
OverrideCoreRule names identities it replaces via the overrides field
PriorityCoreNumeric priority (higher first), exclusive mode
SpecificityAdapterSelector 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).

# 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 B
dispatch.chain { extract; } ruleA ruleB

Phase ordering is no longer gen-dispatch’s concern. Build the phaseOrder list with gen-graph.phaseOrder over before/after entries and pass it to dispatch:

graph.phaseOrder {
structural = graph.entryAnywhere; # no ordering constraints
resolution = graph.entryAfter [ "structural" ]; # after named phases
collection = graph.entryBefore [ "teardown" ]; # before named phases
} # -> a valid producers-first topo order

Adapter: gen-select bridge (adapters.select)

Section titled “Adapter: gen-select bridge (adapters.select)”
# Bridge gen-select selectors as gen-dispatch conditions
match = dispatch.adapters.select.mkMatch genSelect;
# CSS-like specificity counting for conflict resolution
dispatch.adapters.select.selectorSpecificity selector # -> int

Policy-like rules that enrich context and produce typed actions across stratified phases. Ordering comes from gen-graph.phaseOrder; a single dispatch threads context between phases 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;
# phase ORDERING is gen-graph's job
phaseOrder = 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 — wrap the step with gen-scope.circular:

let
scope = gen-scope.lib;
step = dispatch.dispatchStep { inherit (dispatch) dispatch; } cfg; # self:id:prev -> next
in
(scope.circular {
init = dispatch.dispatchInit { host = { name = "igloo"; }; }; # { context; fired; accActions; ... }
eq = a: b: builtins.attrNames a.context == builtins.attrNames b.context;
} step) {} null # -> { context; fired; accActions; ... }

dispatchStep threads fired (once-per-identity dedup) and accumulates actions across passes exactly as the retired fixpoint did; gen-scope.circular drives the Kleene ascent.

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).

Terminal window
nix flake check ./ci # all suites + the purity check
nix build ./ci#formatter.x86_64-linux # then run ./result/bin/* . to format
nix repl --impure --file ci/repl.nix # all exports in scope for interactive use

There are 55 tests across 10 suites (rule, actions, dispatch-basic, dispatch-phases, dispatch-nac, conflict, compose, adapter-select, integration, purity). Iteration/convergence coverage lives cross-repo now: the loop⊥step equivalence oracle is maintained in gen-resolve, and the gen-scope.circular Kleene ascent is tested in gen-scope.

PaperRelationshipUsed for
Forgy (1982) “RETE”ImplementsCondition-action rule dispatch; rule = condition + action production system
Ehrig et al. (2006) “Fundamentals of Algebraic Graph Transformation”ImplementsGraph rewriting rules, negative application conditions as a first-class nac field
Arntzenius & Krishnaswami (2016) “Datafun”ImplementsStratified phases: rules dispatched in a caller-supplied stratum order — all rules in phase N complete before phase N+1 begins, with context threaded between phases. (The monotone fixpoint reading — iterating dispatch to convergence — moved with the loop to gen-resolve.)
Palmer et al. (2024) “Intensional Functions”ImplementsRule identity via mkIntensional detection (four-predicate check: isAttrs + name/__functor/closure), dedup
Hedin & Magnusson (2003) “JastAdd”Informed byOpen action types with framework-owned dispatch; aspect-oriented modular attribution
Batory (2005) “AHEAD”Informed byFeature composition model inspires the restrict/override/chain rule combinators
Berry & Boudol (1990) “Chemical Abstract Machine”Informed byRules as reactions producing transformations; multiset rewriting as a dispatch metaphor

MIT — see LICENSE.