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 enginematches 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 parentchildren 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 structurematches 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-circuitsel.and stops at the first false; sel.any stops at the first true
  • Ancestor/child walks are boundedwithin 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-schemamkIdentityModule 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žarHandbook 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