Stratified rule dispatch engine with fixpoint convergence, implemented as a pure Nix library.
gen-derive 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-derive answers: "which rules fire here, and what actions do they produce?" It owns dispatch, fixpoint convergence, conflict resolution, and rule dedup. Dispatch processes phases in topological 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-derive is generic. It has no knowledge of NixOS, aspects, policies, or system configuration. It provides dispatch machinery; consumers define what to compute.
- Core Insight
- Terminology
- Gen Ecosystem
- Usage
- Example
- Two-Tier Architecture
- API Reference
- Testing
- Theoretical Foundations
The hard part of rule dispatch is the convergence loop: dispatch rules, extract feedback, widen context, re-dispatch until stable. Hand-rolling this loop caused PRs 408-437 in den (all context-threading regressions). gen-derive extracts the generic protocol -- rules declare what they need, gen-derive handles when and how they fire.
| 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) |
| Phase | Named dispatch group with DAG ordering | Arntzenius 2016 (stratification) |
| Match | Testing a condition against a position | Ehrig 2006 (match morphism) |
| Fixpoint | Convergent dispatch loop with monotone feedback | Arntzenius 2016; Radul 2009 |
| NAC | Negative application condition -- pattern that must NOT match | Ehrig 2006 |
| Library | Role |
|---|---|
| gen-algebra | Pure primitives (search, record, identity) |
| gen-schema | Typed registries (kinds, instances, collections, refs) |
| gen-aspects | Aspect types (traits, classification, dispatch) |
| gen-graph | Graph queries (combinators, traversals, fixpoint) |
| gen-scope | Scope graphs (construction, evaluation, resolution) |
| gen-select | Selector algebra (pattern matching over graph positions) |
| gen-bind | Module binding (inject args into NixOS modules) |
| gen-derive | Rule dispatch (stratified phases, fixpoint, conflict resolution) |
# flake.nix
{
inputs.gen-derive.url = "github:sini/gen-derive";
inputs.gen-algebra.url = "github:sini/gen-algebra";
outputs = { gen-derive, gen-algebra, nixpkgs, ... }:
let derive = gen-derive.lib;
in { /* ... */ };
}
# Or without flakes:
let derive = import ./gen-derive { inherit lib; gen-algebra = import ./gen-algebra {}; };
in { /* ... */ }Policy-like rules that enrich context and produce typed actions across stratified phases:
let
derive = import ./gen-derive { inherit lib; gen-algebra = import ./gen-algebra {}; };
# Define action vocabulary -- gen-derive classifies but doesn't interpret
fx = derive.mkActions {
structural = [ "spawn" "enrich" ];
resolution = [ "edge" ];
};
rules = [
# Function signature IS the condition (canTake pattern)
(derive.fromFunction ({ host, ... }: [
(fx.enrich { key = "isNixos"; value = true; })
(fx.spawn { kind = "user"; })
]))
# This rule fires only after enrichment adds "isNixos" to context
(derive.mkRule {
condition = { host = false; isNixos = false; };
produce = _id: _ctx: [ (fx.edge { target = "logging"; }) ];
identity = "nixos-edges";
})
];
result = derive.fixpoint {
inherit rules;
context = { host = { name = "igloo"; }; };
match = derive.fromFunctionMatch;
classify = fx.classify;
phases = {
structural = derive.entryAnywhere {};
resolution = derive.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;
eq = a: b: builtins.attrNames a == builtins.attrNames b;
};
in {
result.iterations # 2 (enrichment converges on second pass)
result.actions # { structural = [ enrich, spawn ]; resolution = [ edge ]; }
}gen-derive follows gen-algebra's pure/lib two-tier model:
- Core tier -- depends on gen-algebra pure tier only. Conditions are opaque; caller provides
match : condition -> id -> ctx -> bool. - Adapter tier -- imports gen-select. Bridges gen-select selectors into gen-derive conditions with
mkMatchandselectorSpecificity.
Consumers without gen-select can use gen-derive with custom match functions. Consumers with gen-select get selector pattern matching and CSS-like specificity for conflict resolution.
dispatch {
rules; # [ rule ]
id; # current position
context; # caller-defined context
match; # condition -> id -> ctx -> bool
classify; # action -> phase name
phases; # DAG of phase entries
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 topological phase order — lower phases complete before higher phases begin, with context threaded between phases. Validates single-phase-per-rule constraint.
Dispatch sequence: walk topoSort(phases); 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.
fixpoint {
rules; context; match; classify; phases;
extract; # actions -> attrset (feedback from actions)
combine; # old ctx -> extracted -> new ctx
eq; # old ctx -> new ctx -> bool (stability check)
id ? null;
exclusive ? false;
maxIter ? 100;
}
-> { actions; orderedPhases; context; iterations; fired; }Convergent dispatch loop. Intra-pass phase threading lives in dispatch (passed extract/combine); fixpoint runs stratified passes and converges when the threaded context stabilizes (eq ctx dispatch.context), accumulating actions in orderedPhases order. Identified rules fire at most once across iterations (dedup via fired set); anonymous rules re-fire each iteration.
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)
}
-> rulefromFunction : fn -> ruleConverts a Nix function into a rule using builtins.functionArgs as the condition. Detects mkIntensional-wrapped functions (Palmer 2024) via three-field check (name, __functor, closure) and extracts identity automatically.
# { host, ... } is the condition -- required arg "host" must be in context
derive.fromFunction ({ host, ... }: [ (fx.spawn { kind = "user"; }) ])
# mkIntensional wrapping adds dedup identity
derive.fromFunction (mkIntensional "host-init" {} ({ host, ... }: [ ... ]))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 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:
| Strategy | Tier | Mechanism |
|---|---|---|
| Override | Core | Rule names identities it replaces via 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).
# Narrow a rule's condition
derive.restrict extraCondition rule
# One rule replaces another (sugar over overrides field)
derive.override original replacement
# Sequential: A's actions feed as context to B
derive.chain { extract; } ruleA ruleBderive.entryAnywhere {} # no ordering constraints
derive.entryAfter [ "structural" ] {} # fires after named phases
derive.entryBefore [ "collection" ] {} # fires before named phases
derive.entryBetween [ "c" ] [ "a" ] {} # between two sets
derive.topoSort phases # -> { result = [ { name; data; } ... ]; }# Bridge gen-select selectors as gen-derive conditions
match = derive.adapters.select.mkMatch genSelect;
# CSS-like specificity counting for conflict resolution
derive.adapters.select.selectorSpecificity selector # -> intcd ci
just ci # run all 55 tests
just ci dispatch-basic # run one suite
just ci fixpoint.test-converge-two-iterations # specific testRequires nix-unit. 55 tests across 11 suites.
| 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 first-class nac field |
| Arntzenius & Krishnaswami (2016) "Datafun" | Implements | Monotonic fixpoint with convergence check; stratified phases dispatched in topological order — all rules in phase N complete before phase N+1 begins, with context threaded between phases |
| Palmer et al. (2024) "Intensional Functions" | Implements | Rule identity via mkIntensional detection (three-field check: name, __functor, closure), dedup |
| Radul & Sussman (2009) "Art of the Propagator" | Informed by | Monotonic convergence model; quiescence as stability criterion for fixpoint loop |
| 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 restrict/override/chain rule combinators |
| Berry & Boudol (1990) "Chemical Abstract Machine" | Informed by | Rules as reactions producing transformations; multiset rewriting as dispatch metaphor |