Skip to content
Oeiuwq Faith Blog OpenSource Porfolio

sini/gen-select

Selector algebra for attributed graph positions

sini/gen-select.json
{
"createdAt": "2026-05-26T17:28:22Z",
"defaultBranch": "main",
"description": "Selector algebra for attributed graph positions",
"fullName": "sini/gen-select",
"homepage": null,
"language": "Nix",
"name": "gen-select",
"pushedAt": "2026-07-16T02:56:41Z",
"stargazersCount": 2,
"topics": [],
"updatedAt": "2026-07-16T02:56:45Z",
"url": "https://github.com/sini/gen-select"
}

gen-select — selector algebra for attributed graph positions

Section titled “gen-select — selector algebra for attributed graph positions”

CI License: MIT Sponsor

Pure pattern matching library for Nix. Selectors are { __sel = tag; ... } attrsets matched by matches against an ID-based accessor context. Zero dependencies (Class A pure) — builtins only, no nixpkgs.lib and no gen-algebra; the intensional-equality check is inlined.

  • [Overview]!(#overview)
  • [Gen Ecosystem]!(#gen-ecosystem)
  • [Quick Start]!(#quick-start)
  • [API Reference]!(#api-reference)
  • [Demo Templates]!(#demo-templates)
  • [Performance]!(#performance)
  • [Testing]!(#testing)
  • [Theoretical Foundations]!(#theoretical-foundations)

gen-select provides a compositional selector algebra for querying positions in attributed graphs. Selectors express structural and data predicates — “nodes whose parent has attribute X”, “nodes with a descendant matching Y” — without coupling to any particular graph representation.

The library has three layers:

  1. Constructors — build selector values (sel.star, sel.attrs, sel.and, sel.within, etc.)
  2. Match engine — matches selector id ctx evaluates a selector against an accessor-based context
  3. Adapters — bridge selectors to gen-scope and gen-graph

Selectors are plain attrsets tagged with __sel. No special types, no evaluation order dependencies, no side effects.

LibraryRole
gen-preludePure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils)
gen-algebraPure primitives (record, search monad, either, intensional identity)
gen-typesClean-room MIT structural type checker (leaf/poly checkers; verify: v → null|err)
gen-mergeByte-mode module merge engine (evalModuleTree, byte-identical to nixpkgs lib.evalModules over the priority subset)
gen-schemaTyped registries (kinds, instances, collections, refs); re-hosted on gen-merge
gen-aspectsAspect type system (traits, classification, dispatch); re-hosted on gen-merge
gen-scopeHOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes)
gen-graphAccessor-based graph query combinators (traversal, condensation, phaseOrder)
gen-selectThis lib — Selector algebra (pattern matching over graph positions)
gen-bindModule binding (inject external args into NixOS modules)
gen-dispatchRelational 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)
gen-flakeThe nixpkgs boundary — compose purely, inject resolved values, build NixOS systems (value-injection)
{
inputs.gen-select.url = "github:sini/gen-select";
outputs = { gen-select, ... }:
let
sel = gen-select.lib;
in {
# sel.matches, sel.star, sel.attrs, sel.and, ...
};
}

gen-select declares no flake inputs, so it adds no transitive dependency — not even nixpkgs.

let
sel = (import ./path/to/gen-select).lib;
in
sel.matches (sel.attrs { role = "backend"; }) "api" myContext
# => true if myContext.data "api" has role = "backend"

matches takes a context record with five accessor functions:

FieldTypePurpose
dataid -> attrsetattribute data for a node
parentid -> id | nullimmediate parent
childrenid -> [id]direct children
ancestorsid -> [id]ancestor chain (parent to root)
siblingsid -> [id]sibling nodes (same parent, excluding self)

The id is not stored in the context — it is the second argument to matches.

matches : selector -> id -> context -> bool

Evaluates a selector against the node identified by id in the given context. Dispatches on the __sel tag.

sel.matches (sel.attrs { type = "service"; }) "web" ctx
# => true if (ctx.data "web").type == "service"
ConstructorSignatureMatches when
sel.star-> selectoralways
sel.attrs aattrset -> selectorall k:v in a equal in data id; missing key = no match
sel.entity eregistry-entry -> selectorthe node’s projected identity (__identity.id_hash) equals the entry’s id_hash
sel.kind Kkind-value -> selectorthe node’s projected kind (__identity.kind) equals K.kind
sel.and ss[selector] -> selectorall match; sel.and [] = true
sel.any ss[selector] -> selectorany matches; sel.any [] = false
sel.not sselector -> selectordoes not match
sel.has sselector -> selectorany child matches
sel.within sselector -> selectorany ancestor matches
sel.parentMatches sselector -> selectorimmediate parent matches
sel.child p csel -> sel -> selectorsugar: and [ c (parentMatches p) ]
sel.descendant a dsel -> sel -> selectorsugar: and [ d (within a) ]
sel.when fnfn -> selectorfn id ctx returns true

The distinct __sel tags are: "star", "attrs", "entity", "kind", "and", "any", "not", "has", "within", "parentMatches", "when" (and "coord" from the product adapter).

Note: child and descendant are sugar — they expand at construction time to and compositions and carry no distinct __sel tag at runtime. (sel.entityKind was removed — see [Identity-bearing selectors]!(#identity-bearing-selectors).)

sel.entity and sel.kind match by entity identity and entity kind rather than by attribute values or structural position. They take values carrying identity — a registry entry, a gen-schema kind value — never "kind:name" strings (the identity law: strings are internal keys and display rendering only).

sel.entity den.hosts.axon-01 # => { __sel = "entity"; id_hash = "<sha256>"; name = "axon-01"; }
sel.kind schema.user # => { __sel = "kind"; kind = "user"; }
  • sel.entity <registry-entry> validates its argument structurally at construction (entry ? id_hash); a string, or any value lacking id_hash, throws immediately with an identity-law message. Only id_hash (identity) and name (display/errors) are stored — never the entry itself, whose methods would make Nix == on selectors throw. Because id_hash is content-addressed over the kind plus identity fields, storing it loses no identity information.
  • sel.kind <kind-value> takes a gen-schema kind value and validates it with the same structural guard registries use (? kind && ? options); a string throws. It stores the kind name as its internal key.

Both match against a reserved __identity record the enriched adapters project alongside node data (shape below). The dispatch is loud where silence would hide a bug:

__identity statesel.entitysel.kind
key absent from data idthrow (identity-blind context)throw (identity-blind context)
null (node is not entity-backed)falsefalse
record with kind == nullmatches on id_hashthrow (kind-blind projection)
recordid_hash equalitykind equality

The throws convert a projection gap (the historical silent-never-match failure) into a named configuration error. A node carrying a positional type but no entry does not match sel.kind — positional-type matching remains sel.attrs { type = "…"; }.

sel.entityKind (a former string-based sugar) was removed; for one release it is a throwing stub naming the migration path (sel.kind <kind-value>, or sel.attrs { type = "…"; } for bare positional typing).

The __identity record projected into data id by the enriched adapters:

__identity = null; # node is not entity-backed
__identity = {
id_hash = "<sha256>"; # gen-schema content-addressed identity
kind = "<name>" or null; # positional kind (scope: node.type; registry: normalized kindFor)
entry = <registry-entry>; # the full entry, for sel.when predicates & consumer interrogation
};

sel.when wraps a bare lambda as a selector. By default, two when selectors cannot be compared for equality (lambdas are not comparable in Nix).

For equality support, pass an intensional function — a plain attrset carrying a name, a closure, and a __functor. gen-select is zero-dep, so you construct this record directly (no mkIntensional helper is bundled):

myPred = {
name = "is-backend";
closure = { };
__functor = _: id: ctx: (ctx.data id).role == "backend";
};
sel.when myPred
isIdentified : selector -> bool
selectorEq : selector -> selector -> bool

isIdentified returns true when a when selector wraps an intensional function (has name, __functor, and closure fields).

selectorEq compares two selectors. For when selectors, when both wrap intensional functions it compares them by program point (name equality) — a conservative check inlined from the former gen-algebra.intensionalEq (Palmer §2.3), so gen-select carries no dependency for it; otherwise it returns false. For entity selectors it compares id_hash, and for coord selectors (dim, id_hash) — the display-only name field is excluded, so two entries with equal id_hash but differing display names dedup as equal (raw == would wrongly distinguish them). kind payloads carry no display field, so they fall through to structural equality (==), as do all remaining selector types.

The adapters attrset bridges selectors to concrete graph representations. Each adapter produces (or is fed into) a five-field context; matches never depends on any adapter directly.

adapters.scope.mkContext : {
node,
get,
project ? (n: (n.decls or {}) // { inherit (n) type; }), # projection surfacing node type
entryFor ? (id: (node id).decls.__entry or null), # id -> entry | null
} -> context

Builds a selector context from gen-scope’s accessor pair. Maps scope accessors to the five context fields:

Context fieldImplementation
dataid: (project (node id)) // { __identity = …; }
parentid: (node id).parent
childrenid: attrNames (get id "children")
ancestorswalks parent chain, cycle-safe
siblingschildren of parent, excluding self

The enriched adapter composes a reserved __identity record (record or null) outside the projection and merges it last, so identity/kind selectors work through it and a user decl named __identity can never shadow it. __identity.kind is copied from the positional node type (coherence by construction); entryFor defaults to the decls.__entry registration convention. __identity is always present through this adapter, so entity/kind selectors are never silently inert.

adapters.graph.mkPredicate : selector -> context -> (id -> bool)
adapters.graph.mkSelectPredicate : selector -> context -> (attrset -> bool)

mkPredicate curries matches into a predicate suitable for gen-graph traversal filters (e.g., reachableWhere).

mkSelectPredicate wraps matches for use with graph.select, expecting an attrset with an id field.

adapters.registry — flat node-list bridge

Section titled “adapters.registry — flat node-list bridge”
adapters.registry.mkContext : {
nodes, data, parent,
kind ? null, # the registry's kind VALUE
entryFor ? (id: let d = data id; in if d ? id_hash then d else null), # id -> entry | null
kindFor ? (_: kind), # id -> kindValue | kindName | null
} -> context

Builds a selector context from a flat registry: an explicit nodes list plus data and parent accessors. The adapter derives the remaining three fields from nodes and parent — children and siblings by filtering nodes on parent, and ancestors by walking the parent chain (cycle-safe). Use this when nodes are held as a plain list rather than behind a gen-scope evaluator.

Identity enrichment is symmetric with the scope adapter, with one wrinkle: real gen-schema instances carry no kind field, so kind projection cannot default from the datum. Pass the registry’s kind value (registries are per-kind by construction) — validated with the same guard as sel.kind and normalized to its name — or an explicit per-id kindFor for heterogeneous unions. Omitting both projects __identity.kind = null, and any sel.kind match then throws (loud kind-blind projection) while sel.entity continues to work. The default entryFor suits the common case where data id is the entry.

adapters.product = {
coord : dim-name -> registry-entry -> selector; # coord "host" den.hosts.axon-01
inSlice : { <dim> = registry-entry; … } -> selector; # sugar: and (coord per fixed dimension)
mkContext : {
cellIds, # [ cellId ] — gen-product's pgraph.nodes
coordsFor, # cellId -> coords — gen-product's product.coordsOf
dataFor ? (_: {}), # extra matchable cell data
parent ? (_: null), # product lattices are flat by default
} -> context;
};

Matches cells within a gen-product slice by product coordinates given as registry entries. coord dim e validates the entry like sel.entity and matches a cell whose __coords.${dim}.id_hash equals e.id_hash; a cell lacking the dimension is a legitimate heterogeneous union (false), a coordinate-blind context or a malformed coordinate value throws. inSlice expands at construction to the conjunction of one coord per fixed dimension (inSlice { } is vacuously true). mkContext projects __coords (and __identity = null — cells are not entities) and derives structure from parent when supplied. The adapter consumes gen-product’s accessor shape without importing gen-product.

Maps CSS selector syntax concepts to gen-select combinators. Demonstrates sel.attrs as element/class selectors, sel.descendant and sel.child as CSS combinators, sel.has as :has(), and sel.not as :not(). Tests verify the mapping against a DOM-like tree context.

Maps SQL WHERE clause concepts to gen-select. Demonstrates sel.attrs as column equality, sel.and/sel.any as AND/OR, sel.not as NOT, and sel.when for range predicates and LIKE patterns. Tests verify against a table-like flat context.

gen-select evaluates selectors lazily through accessor functions. When wired to gen-scope:

  • O(1) data access — each ctx.data id call hits gen-scope’s memoized evaluation; repeated access for the same node evaluates once
  • Proportional to selector structure — matches only inspects what the selector asks for; sel.attrs { role = "x"; } touches one field, not the full node
  • No Tier 2 materialization — selectors never enumerate all nodes; the caller decides iteration scope
  • Structural combinators short-circuit — sel.and stops at the first false; sel.any stops at the first true
  • Ancestor/child walks are bounded — within and has traverse only the relevant subtree or chain, not the full graph

Memory consumption is proportional to what the selector inspects, not the total graph size.

Terminal window
# CI test suite (core library)
nix flake check ./ci
# or, from the ci/ dir with the devshell:
cd ci && just ci
# CSS selectors demo
cd examples/css-selectors && just ci
# SQL WHERE demo
cd examples/sql-where && just ci

The core suite is 191 tests across 15 suites, driven by nix-unit. Alongside the original structural suites (constructors, match-basic, match-structural, composition, sugar, when, adapters, adapter-registry, purity) the identity-selector work adds constructors-identity, match-identity, adapter-scope-identity, adapter-registry-identity, adapter-product, and integration-scope. The last is the acceptance test for the identity/kind routing surface: it drives sel.kind/sel.entity through a real gen-scope.eval graph seeded from real gen-schema instances, including the neededBy predicate shape. The purity suite is the Class-A invariant: it scans every lib/**.nix (plus the root flake.nix/default.nix) for forbidden tokens (nixpkgs, lib., evalModules, mkOption, gen-algebra) and fails CI if any dependency tether creeps back in — the identity work stays builtins-only (identity validation is structural, entry ? id_hash, not a gen-schema import).

gen-select draws on both academic research and industrial standards. Each source falls into one of two categories: Implements (the library directly realizes constructs from the source) or Informed by (the source shaped design decisions without direct structural correspondence).

SourceRelationship
Palmer, Filardo & Wu (2024) — Intensional Functionssel.when wraps lambdas as selectors; isIdentified and selectorEq realize intensional identity and equality via program point (name) comparison ONLY — a further conservative approximation (Palmer 2024 Theorem 1 (closure consistency) / §2.3 conservative-equality model)
CSS Selectors Level 4 — W3CStructural selector vocabulary: sel.has as :has(), sel.not as :not(), sel.child and sel.descendant as CSS combinators; §5.1 type (element-name) selector E lifted from element names to schema kinds as sel.kind
Neron, Tolmach, Visser & Wachsmuth (2015) — A Theory of Name Resolutionsel.entity is a declaration-identity predicate: id_hash plays the declaration-position role, so shadowing/homonym nodes (equal names, distinct declarations) never cross-match
gen-schema — mkIdentityModule content-addressed identitysel.entity delegates identity to gen-schema: it performs no hashing, comparing the id_hash gen-schema defines. Equality is exactly gen-schema’s instance-identity relation
Imrich & Klavžar — Handbook of Product Graphscoord/inSlice are the vertex-membership predicate of the sub-product obtained by fixing the given coordinates; product graph vertices are coordinate tuples
SourceRelationship
Neron, Tolmach, Visser & Wachsmuth (2015) — A Theory of Name ResolutionThe five-field accessor context (data, parent, children, ancestors, siblings) models the P-edge (parent/child/ancestor) traversal axes of a scope graph; does NOT implement the resolution calculus (no well-formedness, specificity, shadowing, or import edges)
Arntzenius & Krishnaswami (2016) — Datafun: A Functional DatalogMonotone pattern matching over lattice-structured data informed the design of composable selector predicates that respect structural ordering
Reynolds (1983) — Types, Abstraction, and Parametric PolymorphismParametricity constraints on selector generality: selectors operate uniformly over any context satisfying the accessor interface, not over concrete representations
Mokhov (2017) — Algebraic Graphs with ClassAlgebraic composition of graph predicates (overlay/connect as selector combinators) informed how sel.and/sel.any compose without coupling to graph representation
XPath 3.1 — W3CAxis-based navigation model (ancestor, child, descendant, sibling) informed the context accessor vocabulary and structural combinator naming