sini/gen-select
{ "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”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.
Table of Contents
Section titled “Table of Contents”- [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)
Overview
Section titled “Overview”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:
- Constructors — build selector values (
sel.star,sel.attrs,sel.and,sel.within, etc.) - Match engine —
matches selector id ctxevaluates a selector against an accessor-based context - 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.
Gen Ecosystem
Section titled “Gen Ecosystem”| Library | Role |
|---|---|
| gen-prelude | Pure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils) |
| gen-algebra | Pure primitives (record, search monad, either, intensional identity) |
| gen-types | Clean-room MIT structural type checker (leaf/poly checkers; verify: v → null|err) |
| gen-merge | Byte-mode module merge engine (evalModuleTree, byte-identical to nixpkgs lib.evalModules over the priority subset) |
| gen-schema | Typed registries (kinds, instances, collections, refs); re-hosted on gen-merge |
| gen-aspects | Aspect type system (traits, classification, dispatch); re-hosted on gen-merge |
| gen-scope | HOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes) |
| gen-graph | Accessor-based graph query combinators (traversal, condensation, phaseOrder) |
| gen-select | This lib — Selector algebra (pattern matching over graph positions) |
| gen-bind | Module binding (inject external args into NixOS modules) |
| gen-dispatch | Relational rule dispatch STEP (stratified phases, conflict resolution) |
| gen-resolve | Demand-driven RAG evaluator over scope graphs (attribute schedule + convergence loop) |
| gen-rebuild | Pure-Nix incremental rebuilder (change propagation, AFFECTED set) |
| gen-vars | Pure-Nix vars/secrets (den-agnostic) |
| gen-flake | The nixpkgs boundary — compose purely, inject resolved values, build NixOS systems (value-injection) |
Quick Start
Section titled “Quick Start”As a flake input
Section titled “As a flake input”{ 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.
Without flakes
Section titled “Without flakes”let sel = (import ./path/to/gen-select).lib;insel.matches (sel.attrs { role = "backend"; }) "api" myContext# => true if myContext.data "api" has role = "backend"API Reference
Section titled “API Reference”Context shape
Section titled “Context shape”matches takes a context record with five accessor functions:
| Field | Type | Purpose |
|---|---|---|
data | id -> attrset | attribute data for a node |
parent | id -> id | null | immediate parent |
children | id -> [id] | direct children |
ancestors | id -> [id] | ancestor chain (parent to root) |
siblings | id -> [id] | sibling nodes (same parent, excluding self) |
The id is not stored in the context — it is the second argument to matches.
matches
Section titled “matches”matches : selector -> id -> context -> boolEvaluates 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"Constructors
Section titled “Constructors”| Constructor | Signature | Matches when |
|---|---|---|
sel.star | -> selector | always |
sel.attrs a | attrset -> selector | all k:v in a equal in data id; missing key = no match |
sel.entity e | registry-entry -> selector | the node’s projected identity (__identity.id_hash) equals the entry’s id_hash |
sel.kind K | kind-value -> selector | the node’s projected kind (__identity.kind) equals K.kind |
sel.and ss | [selector] -> selector | all match; sel.and [] = true |
sel.any ss | [selector] -> selector | any matches; sel.any [] = false |
sel.not s | selector -> selector | does not match |
sel.has s | selector -> selector | any child matches |
sel.within s | selector -> selector | any ancestor matches |
sel.parentMatches s | selector -> selector | immediate parent matches |
sel.child p c | sel -> sel -> selector | sugar: and [ c (parentMatches p) ] |
sel.descendant a d | sel -> sel -> selector | sugar: and [ d (within a) ] |
sel.when fn | fn -> selector | fn 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).)
Identity-bearing selectors
Section titled “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 lackingid_hash, throws immediately with an identity-law message. Onlyid_hash(identity) andname(display/errors) are stored — never the entry itself, whose methods would make Nix==on selectors throw. Becauseid_hashis 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 state | sel.entity | sel.kind |
|---|---|---|
key absent from data id | throw (identity-blind context) | throw (identity-blind context) |
null (node is not entity-backed) | false | false |
record with kind == null | matches on id_hash | throw (kind-blind projection) |
| record | id_hash equality | kind 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 and identity
Section titled “sel.when and identity”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 myPredisIdentified and selectorEq
Section titled “isIdentified and selectorEq”isIdentified : selector -> boolselectorEq : selector -> selector -> boolisIdentified 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.
Adapters
Section titled “Adapters”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 — gen-scope bridge
Section titled “adapters.scope — gen-scope bridge”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} -> contextBuilds a selector context from gen-scope’s accessor pair. Maps scope accessors to the five context fields:
| Context field | Implementation |
|---|---|
data | id: (project (node id)) // { __identity = …; } |
parent | id: (node id).parent |
children | id: attrNames (get id "children") |
ancestors | walks parent chain, cycle-safe |
siblings | children 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 — gen-graph bridge
Section titled “adapters.graph — gen-graph bridge”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} -> contextBuilds 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 — gen-product bridge
Section titled “adapters.product — gen-product bridge”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.
Demo Templates
Section titled “Demo Templates”CSS Selectors (examples/css-selectors/)
Section titled “CSS Selectors (examples/css-selectors/)”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.
SQL WHERE (examples/sql-where/)
Section titled “SQL WHERE (examples/sql-where/)”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.
Performance
Section titled “Performance”gen-select evaluates selectors lazily through accessor functions. When wired to gen-scope:
- O(1) data access — each
ctx.data idcall hits gen-scope’s memoized evaluation; repeated access for the same node evaluates once - Proportional to selector structure —
matchesonly 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.andstops at the first false;sel.anystops at the first true - Ancestor/child walks are bounded —
withinandhastraverse only the relevant subtree or chain, not the full graph
Memory consumption is proportional to what the selector inspects, not the total graph size.
Testing
Section titled “Testing”# CI test suite (core library)nix flake check ./ci# or, from the ci/ dir with the devshell:cd ci && just ci
# CSS selectors democd examples/css-selectors && just ci
# SQL WHERE democd examples/sql-where && just ciThe 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).
Theoretical Foundations
Section titled “Theoretical Foundations”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).
Implements
Section titled “Implements”| Source | Relationship |
|---|---|
| Palmer, Filardo & Wu (2024) — Intensional Functions | sel.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 — W3C | Structural 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 Resolution | sel.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 identity | sel.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 Graphs | coord/inSlice are the vertex-membership predicate of the sub-product obtained by fixing the given coordinates; product graph vertices are coordinate tuples |
Informed by
Section titled “Informed by”| Source | Relationship |
|---|---|
| Neron, Tolmach, Visser & Wachsmuth (2015) — A Theory of Name Resolution | The 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 Datalog | Monotone pattern matching over lattice-structured data informed the design of composable selector predicates that respect structural ordering |
| Reynolds (1983) — Types, Abstraction, and Parametric Polymorphism | Parametricity constraints on selector generality: selectors operate uniformly over any context satisfying the accessor interface, not over concrete representations |
| Mokhov (2017) — Algebraic Graphs with Class | Algebraic composition of graph predicates (overlay/connect as selector combinators) informed how sel.and/sel.any compose without coupling to graph representation |
| XPath 3.1 — W3C | Axis-based navigation model (ancestor, child, descendant, sibling) informed the context accessor vocabulary and structural combinator naming |