Skip to content
Oeiuwq Faith Blog OpenSource Porfolio

sini/gen-derive

gen-derive: stratified rule dispatch with fixpoint convergence

sini/gen-derive.json
{
"createdAt": "2026-05-26T05:18:02Z",
"defaultBranch": "main",
"description": "gen-derive: stratified rule dispatch with fixpoint convergence",
"fullName": "sini/gen-derive",
"homepage": null,
"language": "Nix",
"name": "gen-derive",
"pushedAt": "2026-05-31T18:34:40Z",
"stargazersCount": 1,
"topics": [],
"updatedAt": "2026-05-31T18:34:44Z",
"url": "https://github.com/sini/gen-derive"
}

CI License: MIT Sponsor

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]!(#core-insight)
  • [Terminology]!(#terminology)
  • [Gen Ecosystem]!(#gen-ecosystem)
  • [Usage]!(#usage)
  • [Example]!(#example)
  • [Two-Tier Architecture]!(#two-tier-architecture)
  • [API Reference]!(#api-reference)
  • [Testing]!(#testing)
  • [Theoretical Foundations]!(#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.

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)
FixpointConvergent dispatch loop with monotone feedbackArntzenius 2016; Radul 2009
NACNegative application condition — pattern that must NOT matchEhrig 2006
LibraryRole
gen-algebraPure primitives (search, record, identity)
gen-schemaTyped registries (kinds, instances, collections, refs)
gen-aspectsAspect types (traits, classification, dispatch)
gen-graphGraph queries (combinators, traversals, fixpoint)
gen-scopeScope graphs (construction, evaluation, resolution)
gen-selectSelector algebra (pattern matching over graph positions)
gen-bindModule binding (inject args into NixOS modules)
gen-deriveRule 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 mkMatch and selectorSpecificity.

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)
}
-> rule
fromFunction : fn -> rule

Converts 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 -> 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 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 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
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 ruleB
derive.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 # -> int
Terminal window
cd ci
just ci # run all 55 tests
just ci dispatch-basic # run one suite
just ci fixpoint.test-converge-two-iterations # specific test

Requires nix-unit. 55 tests across 11 suites.

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 first-class nac field
Arntzenius & Krishnaswami (2016) “Datafun”ImplementsMonotonic 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”ImplementsRule identity via mkIntensional detection (three-field check: name, __functor, closure), dedup
Radul & Sussman (2009) “Art of the Propagator”Informed byMonotonic convergence model; quiescence as stability criterion for fixpoint loop
Hedin & Magnusson (2003) “JastAdd”Informed byOpen action types with framework-owned dispatch; aspect-oriented modular attribution
Batory (2005) “AHEAD”Informed byFeature composition model inspires restrict/override/chain rule combinators
Berry & Boudol (1990) “Chemical Abstract Machine”Informed byRules as reactions producing transformations; multiset rewriting as dispatch metaphor