sini/gen-derive
{ "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"}gen-derive
Section titled “gen-derive”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.
Table of Contents
Section titled “Table of Contents”- [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)
Core Insight
Section titled “Core Insight”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.
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) |
| 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 |
Gen Ecosystem
Section titled “Gen Ecosystem”| 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) |
{ 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 { /* ... */ }Example
Section titled “Example”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 ]; }}Two-Tier Architecture
Section titled “Two-Tier Architecture”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.
API Reference
Section titled “API Reference”dispatch
Section titled “dispatch”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
Section titled “fixpoint”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
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 phase ? null; # phase name for stratified dispatch, or null (single-phase)}-> 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 three-field check (name, __functor, closure) and extracts identity automatically.
# { host, ... } is the condition -- required arg "host" must be in contextderive.fromFunction ({ host, ... }: [ (fx.spawn { kind = "user"; }) ])
# mkIntensional wrapping adds dedup identityderive.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 original and extra conditions.
mkActions
Section titled “mkActions”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.
Conflict Resolution
Section titled “Conflict Resolution”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).
Rule Composition
Section titled “Rule Composition”# Narrow a rule's conditionderive.restrict extraCondition rule
# One rule replaces another (sugar over overrides field)derive.override original replacement
# Sequential: A's actions feed as context to Bderive.chain { extract; } ruleA ruleBPhase DAG
Section titled “Phase DAG”derive.entryAnywhere {} # no ordering constraintsderive.entryAfter [ "structural" ] {} # fires after named phasesderive.entryBefore [ "collection" ] {} # fires before named phasesderive.entryBetween [ "c" ] [ "a" ] {} # between two setsderive.topoSort phases # -> { result = [ { name; data; } ... ]; }Adapter: gen-select Bridge
Section titled “Adapter: gen-select Bridge”# Bridge gen-select selectors as gen-derive conditionsmatch = derive.adapters.select.mkMatch genSelect;
# CSS-like specificity counting for conflict resolutionderive.adapters.select.selectorSpecificity selector # -> intTesting
Section titled “Testing”cd cijust ci # run all 55 testsjust ci dispatch-basic # run one suitejust ci fixpoint.test-converge-two-iterations # specific testRequires nix-unit. 55 tests across 11 suites.
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 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 |