myxce/src/docs/resreq-ast-cse-design.md
Rongsong Shen c2ad96a5e0 docs: incorporate review feedback for resreq-ast-cse-design
- Rename grammar rules: or_expr→select_or, and_expr→select_and,
  not_expr→select_not, atom_expr→select_primary
- Eliminate SEL_BOOL_LITERAL: parse true/false directly as
  SEL_TRUE/SEL_FALSE; boolean resources used standalone (gpu,
  not gpu==true)
- Remove 'true'|'false' from value production in grammar
- Use LSF-style unquoted strings in examples (type==linux,
  not type=="linux")
- Document n-ary vs binary tree decision in §6.2
- Add §8.5 Evaluation Memoization: per-host select_eval_ctx_t
  with node_id-indexed bool_bits bitmap, num_vals, str_vals
  arrays; memoized eval_select with cache check; session lifecycle
  (pre-session capacity, per-host alloc/discard, post-session
  GC + node ID recompaction)
- Add node_id field to xce_select_node_t for eval context indexing
- Clarify §9 two-level sharing: select CSE is global, resreq
  sharing is scoped to (user, queue, project)
- Clarify §10 select vs rusage boundary: select evaluates against
  host snapshot, rusage checks host.available (total - booked)
2026-04-22 02:25:30 +00:00

58 KiB
Raw Blame History

XCE Resource Requirement AST & CSE Design

Status: Draft Last Updated: 2026-04-21 Target: src/lib/ (C shared library), src/include/resreq.h


Table of Contents

  1. Background
  2. Full Resreq AST Goals
  3. Select Expression AST
  4. Resource Types
  5. CSE via Hash-Consing
  6. Commutativity Canonicalization
  7. Parse-Time Type Validation
  8. Evaluation in Host Context
  9. Job-Level Sharing
  10. Scheduling Session Semantics
  11. Implementation Plan
  12. Appendix: Data Structures

1. Background

The existing resreq parser has a 3-level hierarchy:

xce_resreq_t                      (alter / compound / primary)
  └─ xce_primary_resreq_t
       ├─ xce_select_t            ← EMPTY STUB  (struct { })
       ├─ xlist_t rusages         ← fully parsed (rusage.peg)
       ├─ xce_span_t              ← EMPTY STUB
       ├─ xce_same_t              ← EMPTY STUB
       └─ xce_affinity_t          ← EMPTY STUB

section.peg splits the raw resreq string into clause sub-strings (selectString, rusageString, spanString, …). rusage.peg fully parses the rusage clause. The select clause is extracted but never parsed — EvalSelectForHost in the Go backend is a no-CGO stub that always returns errNoCGO.

This document describes the design to:

  • Fully parse the select clause into a boolean expression AST.
  • Apply Common Subexpression Elimination (CSE) at the AST node level so identical sub-expressions across all jobs share a single allocation. In production HPC workloads, many jobs share identical or overlapping select expressions (e.g., type==x86_64 && gpu). CSE at the AST node level allows these jobs to share interned subtrees, yielding significant memory savings and reducing allocation pressure during scheduling — validated by experience with IBM LSF's select clause implementation. A direct example: when a user submits a large job array, all elements of the array share the same resource requirement string, so CSE allows a single interned AST to represent thousands of jobs. In a busy EDA cluster (10,000 nodes, 10M jobs, 1M/hour throughput), the number of unique EDA tool configurations is limited (< 100), so many jobs share the same select expression. Node-level CSE provides structural sharing even when full resreq strings differ (e.g., same select[...] but different rusage[...]), which string-level interning alone cannot achieve.
  • Evaluate that AST efficiently in the context of a host's live resource values.
  • Run a mark-and-sweep GC between scheduling sessions to recycle interned nodes that are no longer reachable from any live job (§5.6).

2. Full Resreq AST Goals

All clauses in xce_primary_resreq_t will eventually be filled. This document focuses on select as the first clause.

  • span controls task span across nodes (e.g., for MPI jobs).
  • affinity controls hardware topology allocation (e.g., for OpenMP jobs).
  • same constrains co-allocation of tasks on the same node.

These are advanced features; their design and implementation are separate future work. The CSE and intern table technique described here is applicable to them but should not be considered final for those use cases.

The resreq string grammar (outer structure, already implemented):

resreq      ::= alter_resreq | compound_resreq | primary_resreq
alter_resreq    ::= '{' compound '}' ( '||' '{' compound '}' )*
compound_resreq ::= n '*' '{' primary '}' ( '+' n '*' '{' primary '}' )*
primary_resreq  ::= select[...] rusage[...] span[...] same[...] affinity[...]

3. Select Expression AST

3.1 Grammar (new lib/xce_select.peg)

select_expr   ::= select_or EOF
select_or      ::= select_and ( '||' select_and )*
select_and     ::= select_not ( '&&' select_not )*
select_not     ::= '!' select_primary | select_primary
select_primary ::= '(' select_or ')' | comparison | resname
comparison     ::= resname cmp_op value
cmp_op         ::= '>=' | '<=' | '!=' | '==' | '>' | '<'
resname        ::= [a-zA-Z_][a-zA-Z_0-9]*
value          ::= string_literal | number | ANY
ANY            ::= 'any'
number         ::= [0-9]+ ('.' [0-9]*)?
string_literal ::= [a-zA-Z_][a-zA-Z_0-9]*   (unquoted word, excluding 'any')

LSF-compatible disambiguation: resname and string_literal share the same lexical pattern. The parser resolves the ambiguity using the resource manager: if a bare word matches a known resource name, it is parsed as SEL_RESNAME; otherwise it is treated as SEL_STR (a string literal). This is compatible with IBM LSF's select clause semantics. For example, type==linux — type resolves to SEL_RESNAME (known resource), linux resolves to SEL_STR (not a known resource name). String values in LSF select expressions are unquoted words.

any keyword: The special keyword any matches any value. It is only valid as the right-hand side of == or != (e.g., type == any, model == any). At parse time, any produces a SEL_ANY leaf node. The const-fold pass (§6.3) reduces resname == any to SEL_TRUE, so eval_select never encounters SEL_ANY at runtime.

Note on != any: resname != any is a parse error. However, !(resname == any) const-folds to !SEL_TRUE which reduces to SEL_FALSE — these are semantically equivalent. This inconsistency exists because the parse-time rejection of != any provides a clearer error message for users, while the const-fold path handles the negated form implicitly. If this inconsistency proves confusing in practice, a future change can allow resname != any at parse time and const-fold it to SEL_FALSE.

Boolean keywords true and false: These are parsed directly as SEL_TRUE and SEL_FALSE singleton nodes — there is no intermediate node type. They are not part of the value production (the RHS of comparisons) because boolean resources should be used as standalone operands, not compared to true/false. The idiomatic form is:

  • gpu (standalone boolean resource — the resource's value IS the boolean)
  • Not gpu == true — though the parser accepts this, const-folding reduces bool_res == SEL_TRUE → SEL_RESNAME(bool_res) and bool_res == SEL_FALSE → SEL_NOT(SEL_RESNAME(bool_res))

Since const-folding always eliminates these comparisons, a dedicated SEL_BOOL_LITERAL type would never survive to runtime. Parsing true → SEL_TRUE and false → SEL_FALSE directly avoids an intermediate type. true and false are reserved keywords excluded from the string_literal pattern.

String literals in comparisons: Unquoted words that are not known resource names and not reserved keywords (any, true, false) are treated as string literals. For example, type == x86_64 — type resolves to SEL_RESNAME (known resource), x86_64 resolves to SEL_STR (not a known resource name). String values in comparisons do not need quotes — LSF select expressions use unquoted words.

3.2 Node Types

typedef enum {
    SEL_AND,            /* &&  — commutative */
    SEL_OR,             /* ||  — commutative */
    SEL_NOT,            /* !   — unary       */
    SEL_GT,             /* >   — numeric only */
    SEL_LT,             /* <   — numeric only */
    SEL_GE,             /* >=  — numeric only */
    SEL_LE,             /* <=  — numeric only */
    SEL_EQ,             /* ==  — commutative, all types */
    SEL_NE,             /* !=  — commutative, all types */
    SEL_RESNAME,        /* leaf: resource variable  */
    SEL_NUM,            /* leaf: numeric literal    */
    SEL_STR,            /* leaf: string literal     */
    SEL_ANY,            /* leaf: any keyword (const-folded to SEL_TRUE) */
    SEL_TRUE,           /* leaf: constant true  — also produced by keyword `true` */
    SEL_FALSE,          /* leaf: constant false — also produced by keyword `false` */
} sel_node_type_t;

3.3 Node Structure

struct xce_select_node_ {
    sel_node_type_t type;
    uint32_t        hashcode;   /* cached at construction       */
    uint32_t        node_id;    /* dense index into eval context arrays (§8.5) */
    unsigned        gc_mark;    /* set during mark-and-sweep GC (§5.5) */
    union {
        struct {
            xce_select_node_t *left, *right;
        }                    binop;    /* AND / OR / GT / LT / GE / LE / EQ / NE */
        xce_select_node_t*   operand;  /* NOT                                     */
        struct {
            int        res_idx;        /* resolved at parse time via resmgr        */
            res_type_t res_type;       /* stored for eval dispatch, not in CSE key */
        }                    resname;
        double               numval;   /* SEL_NUM                                  */
        char*                strval;   /* SEL_STR — interned via makeString()      */
    } u;
};

struct xce_resreq_select_ becomes:

struct xce_resreq_select_ {
    xce_select_node_t* root;    /* reference to interned node tree */
};

4. Resource Types

Resources have exactly one of three types, known from the resource definition:

typedef enum {
    RES_TYPE_NUMERIC = 0,   /* float  — supports all comparison operators */
    RES_TYPE_STRING,        /* char*  — supports == != only               */
    RES_TYPE_BOOL,          /* int    — standalone boolean use            */
} res_type_t;

Operator validity by resource type:

Operator Numeric String Boolean
> < >= <= ✓ parse error parse error
== != ✓ ✓ ✓
standalone (no op) ✓ (non-zero) parse error ✓

The res_type field is stored in the SEL_RESNAME node for eval-time dispatch. It is not part of the CSE hash key — res_idx alone identifies the resource uniquely, and a resource has exactly one type.

Note: res_type_t maps directly to the existing XCE_VAL_* constants in src/include/xce/types.h (XCE_VAL_STRING=0, XCE_VAL_NUMERIC=1, XCE_VAL_BOOLEAN=2). The numeric values differ in order, so an explicit mapping is required:

/* res_type_t → existing valtype mapping */
static inline int res_type_to_valtype(res_type_t t) {
    static const int map[] = {
        [RES_TYPE_NUMERIC] = XCE_VAL_NUMERIC,   /* 0 → 1 */
        [RES_TYPE_STRING]  = XCE_VAL_STRING,    /* 1 → 0 */
        [RES_TYPE_BOOL]    = XCE_VAL_BOOLEAN,  /* 2 → 2 */
    };
    return map[t];
}

/* existing valtype → res_type_t inverse mapping */
static inline res_type_t valtype_to_res_type(int v) {
    switch (v) {
    case XCE_VAL_NUMERIC: return RES_TYPE_NUMERIC;
    case XCE_VAL_STRING:  return RES_TYPE_STRING;
    case XCE_VAL_BOOLEAN: return RES_TYPE_BOOL;
    default:              return RES_TYPE_NUMERIC; /* safe default */
    }
}

The PEG action for resname resolution must look up the resource in the resmgr by name, retrieve its valtype (using XCE_VAL_*), and map it to res_type_t via the inverse mapping before storing it in the SEL_RESNAME node.

String resources in host values: xvec_idval_t stores only numeric resource values (float pairs). String host resources (e.g., type, arch) are resolved differently — the host's string properties are looked up by res_idx through a separate (res_idx → const char*) table, not through xvec_idval_t. Boolean resources are stored as nonzero/zero float values in xvec_idval_t. See §8 for the accessor functions.


5. CSE via Hash-Consing

5.1 Principle

Every xce_select_node_t is interned in a global table keyed by its logical content. Two nodes with the same type and same operands always return the same pointer. This is hash-consing, used in compilers and functional language runtimes.

The packcc PEG parser is bottom-up: leaves are reduced before their parents. By the time a parent node is constructed, all its children are already interned canonical pointers. Therefore:

  • Equality of a composite node = pointer equality of its children.
  • No recursive comparison is ever needed.
  • Hash and equality are both O(1) regardless of expression depth.

5.2 CSE Key

typedef struct {
    sel_node_type_t type;
    uintptr_t       op1;   /* left child ptr, res_idx, or numval bits  */
    uintptr_t       op2;   /* right child ptr, or 0 for unary / leaf   */
} sel_node_key_t;

Key encoding per node type:

Node type op1 op2
SEL_AND SEL_OR (uintptr_t)left (uintptr_t)right
SEL_GT SEL_LT SEL_GE SEL_LE (uintptr_t)left (uintptr_t)right
SEL_EQ SEL_NE (uintptr_t)min(left,right) (uintptr_t)max(left,right)
SEL_NOT (uintptr_t)operand 0
SEL_RESNAME (uintptr_t)res_idx 0
SEL_NUM bit-cast of double 0
SEL_STR (uintptr_t)xstring_ptr 0
SEL_ANY 0 0
SEL_TRUE 0 0
SEL_FALSE 0 0

SEL_NUM uses the exact bit-pattern of the double value reinterpreted as uintptr_t (via memcpy or union cast). Two numeric literals are equivalent for CSE if their bit-patterns are identical; 0.0 and -0.0 are distinct CSE keys (they differ in the sign bit). NaN payloads are also compared by bit-pattern.

SEL_STR uses the interned xstring pointer — since makeString() already canonicalizes strings (libxce's string de-dup mechanism maps identical strings to the same pointer), pointer equality IS string equality. SEL_ANY, SEL_TRUE, and SEL_FALSE are singletons — all instances share the same interned pointer.

5.3 Intern Table

static struct {
    mylock_t lock;
    var      table;     /* kvtab keyed by sel_node_key_t */
} sel_node_table;

Modelled on xstring_table in lib/xstring.c.

5.4 makeSelectNode

xce_select_node_t* makeSelectNode(sel_node_type_t type,
                                   uintptr_t op1, uintptr_t op2)
{
    /* 1. canonicalize commutative operators (see §6) */
    switch (type) {
    case SEL_AND: case SEL_OR: case SEL_EQ: case SEL_NE:
        if (op1 > op2) { uintptr_t t = op1; op1 = op2; op2 = t; }
        break;
    default:
        break;
    }

    /* 2. build lookup key */
    sel_node_key_t key = { type, op1, op2 };

    /* 3. lookup */
    xce_select_node_t* node = NULL;
    mylock_lock(&sel_node_table.lock);
    int rc = key_find(sel_node_table.table, &key, (void**)&node);
    if (rc > 0) { mylock_unlock(&sel_node_table.lock); return node; }
    mylock_unlock(&sel_node_table.lock);
    if (rc < 0) return NULL;   /* key_find error (not ENOENT) */

    /* 4. alloc and fill */
    node = calloc(1, sizeof(*node));
    if (node == NULL) { errno = ENOMEM; return NULL; }
    node->type     = type;
    node->hashcode = sel_key_hash(&key);
    node->node_id  = sel_node_next_id++;   /* dense index for eval context */
    /* fill u.* from op1/op2 by type … */

    /* 5. insert — re-check under lock for TOCTOU safety */
    mylock_lock(&sel_node_table.lock);
    xce_select_node_t* existing = NULL;
    int rc2 = key_find(sel_node_table.table, &key, (void**)&existing);
    if (rc2 > 0) {
        /* another thread inserted first — use theirs, free ours */
        mylock_unlock(&sel_node_table.lock);
        free(node);
        return existing;
    }
    int rc3 = key_add(sel_node_table.table, &key, node);
    if (rc3 < 0) {
        /* key_add failed — if EEXIST from a race, re-find and return existing */
        if (rc3 == -EEXIST) {
            xce_select_node_t* raced = NULL;
            int rc4 = key_find(sel_node_table.table, &key, (void**)&raced);
            mylock_unlock(&sel_node_table.lock);
            free(node);
            return (rc4 > 0) ? raced : NULL;   /* return existing or NULL on double-failure */
        }
        mylock_unlock(&sel_node_table.lock);
        free(node);
        return NULL;   /* ENOMEM or other error */
    }
    mylock_unlock(&sel_node_table.lock);

    return node;
}

TOCTOU note: The existing makeString() in xstring.c uses the same lock/find/unlock/alloc/lock/add pattern without a re-check. The select intern table adds the re-check to prevent node leaks under concurrent construction. An alternative is to hold the lock through the entire sequence (simpler but longer critical section).

kill_primary_resreq() in resreq.c must unlink the select root pointer (primary->select->root = NULL) before freeing the select struct. Node reclamation is handled by mark-and-sweep GC (§5.5) — no explicit deref function is needed.

5.5 Mark-and-Sweep GC Between Scheduling Sessions

Node reclamation is handled by a mark-and-sweep GC that runs between scheduling sessions, when no evaluation is in progress. There is no derefSelectNode function — when a job completes, its resreq entry is simply unlinked from the job (see §5.4); the next GC sweep reclaims unreachable nodes.

gc_sweep_select_nodes()
  1. Mark pass:
     a. Clear the GC mark flag on every node in sel_node_table.
     b. For each live job's resreq, visit the AST tree starting from
        the root node and set the GC mark flag on every reachable node.
        (If a node is referenced by multiple AST trees, it is marked
        only once — the flag is boolean.)
  2. Sweep pass (two-phase to avoid modifying the hash table during iteration):
     a. Iterate sel_node_table using the kvtab iterator interface
        (`iter_make`/`iter_next`).  Collect keys of unmarked nodes into a
        temporary array.
     b. For each collected key: remove the node from the intern table
        (`key_del`), then free the node.
     c. Clear all GC mark flags on remaining (reachable) nodes.

This approach is simple and correct:

  • A node is reachable if and only if it is visited from at least one live job's resreq root. No reference provenance tracking is needed — the mark flag is a single boolean per node.
  • The sweep runs only between sessions (no concurrent eval), so no read-after-free risk exists.
  • SEL_STR nodes' xstring references are handled by the same mark pass — if the SEL_STR node is reachable, the string stays interned.

The scheduler is responsible for calling gc_sweep_select_nodes() between scheduling sessions, typically after all dispatch/defer decisions for the current batch of jobs have been made. Job cleanup (primary->select->root = NULL) also happens between sessions — there is no mid-session job deletion. When a job completes, its entry is marked for removal; the select root pointer is unlinked and the job struct freed at the session boundary, before GC runs. This eliminates the risk of shared sub-nodes becoming unreachable mid-session.

After GC, node IDs are re-compacted to 0..N-1 (§8.5.4) so that the evaluation context arrays remain dense. This is done by a linear scan of the intern table that reassigns node_id sequentially.

The function is not thread-safe with respect to concurrent eval_select calls, so the scheduler must ensure no evaluations are in progress when GC runs.

Like IBM LSF, the XCE scheduler operates in sessions — each session evaluates all pending jobs against candidate hosts, dispatches what it can, then ends. Pre/post-session work (such as GC) is a natural fit for this architecture.

Pointer-based canonicalization note: Sorting by uintptr_t produces a deterministic total order within a single process lifetime. Across process restarts, pointer addresses change (ASLR, heap layout), so the canonical form may differ, but this is immaterial — the intern table is per-process and never persisted. The guarantee is: within a session, A && B and B && A produce the same intern table key.


6. Commutativity Canonicalization

For commutative operators (&&, ||, ==, !=), operand order in the source string is arbitrary. A && B and B && A express the same constraint but would produce different intern table keys without canonicalization.

Strategy: before building the CSE key, sort op1 and op2 by their uintptr_t value. Since both children are already canonical interned pointers, their addresses are stable. Sorting by address gives a deterministic total order with no semantic knowledge required:

if (op1 > op2) { uintptr_t t = op1; op1 = op2; op2 = t; }

After this swap, A && B and B && A produce the same (SEL_AND, min, max) key and hit the same intern table entry.

Non-commutative operators (>, <, >=, <=, !) are left unchanged.

6.2 Associativity Flattening

The binary AST naturally produces (A && B) && C and A && (B && C) as structurally different trees with different CSE keys, even though they are semantically identical. A flattening transform normalizes all same-operator chains to left-associative form:

flatten(AND, (AND, A, B), C) → (AND, (AND, A, B), C)   /* already left-assoc */
flatten(AND, A, (AND, B, C)) → (AND, (AND, A, B), C)   /* re-associate right to left */

After flattening, (A && B) && C and A && (B && C) both produce the same left-associative tree, maximizing CSE sharing. The same transform applies to SEL_OR.

Why binary trees, not n-ary nodes? An alternative design uses n-ary nodes like (AND, A, B, C) instead of binary (AND, (AND, A, B), C). N-ary nodes make operand reordering trivial (sort the children array) and eliminate the need for flattening. However, they break the fixed-size sel_node_key_t — the CSE key becomes variable-length, requiring a hash over N sorted child pointers per lookup. Binary trees preserve the O(1) fixed-size key: (SEL_AND, left_ptr, right_ptr). For typical HPC workloads where &&/|| chains are 2–5 operands, binary trees with flattening + commutativity sort give canonical form with zero per-lookup cost beyond two pointer compares. N-ary representation is noted as a potential future enhancement if operand reordering within long chains provides measurable CSE gains beyond what binary canonicalization achieves.

6.3 Const-Fold Transform

A const-fold pass simplifies the AST by evaluating expressions that are statically determined at parse time:

  • resname == any → SEL_TRUE (always matches, regardless of the resource value). The any keyword (§3.1) is only valid on the right-hand side of == or !=. For !=, resname != any is a parse error (nothing would not match any).
  • Identity laws:SEL_TRUE && X → X, X && SEL_TRUE → X; SEL_FALSE || X → X, X || SEL_FALSE → X
  • Annihilation laws: SEL_FALSE && X → SEL_FALSE, X && SEL_FALSE → SEL_FALSE; SEL_TRUE || X → SEL_TRUE, X || SEL_TRUE → SEL_TRUE
  • Complement laws: !SEL_TRUE → SEL_FALSE, !SEL_FALSE → SEL_TRUE
  • Boolean equality: bool_res == SEL_TRUE → bool_res (since x == true is just x); bool_res == SEL_FALSE → SEL_NOT(bool_res) (since x == false is !x); bool_res != SEL_TRUE → SEL_NOT(bool_res); bool_res != SEL_FALSE → bool_res. These apply only when bool_res is a SEL_RESNAME with res_type == RES_TYPE_BOOL.

The const-fold pass runs after construction and flattening, replacing subtrees with constant leaf nodes. Since all nodes are interned, SEL_TRUE and SEL_FALSE are singletons — every const-fold reduces to the same pointer.

/* SEL_FALSE also added to enum and intern table as a singleton */

6.4 Transform Pipeline

AST construction follows a fixed pipeline after the PEG parser produces the raw tree:

1. Parse (PEG → raw binary AST, each PEG action calls `makeSelectNode`
   which performs commutativity canonicalization as part of CSE key
   construction)
2. Post-parse tree walk: associativity flattening (§6.2)
3. Post-parse tree walk: const-fold (§6.3)
4. Bottom-up re-intern via `makeSelectNode` for any modified subtrees

Note: commutativity canonicalization (operand swap) happens inside makeSelectNode as part of the CSE key lookup — it is not a separate pipeline step. The PEG action for each binary operator calls makeSelectNode, which sorts op1/op2 for commutative operators before building the key. Flattening and const-fold are post-parse tree walks that traverse the raw AST and call makeSelectNode to construct new normalized subtrees. Since makeSelectNode already handles interning and canonicalization, these walks naturally produce the maximally shared tree.


7. Parse-Time Type Validation

The resmgr is passed into the select parse context so resource names can be resolved to (res_idx, res_type) at parse time:

typedef struct {
    const char*         input;
    int                 pos;
    int                 err_pos;
    int                 has_error;
    char*               errmsg;
    var                 resmgr;           /* name → (res_idx, res_type) */
} select_context_t;

PEG actions validate types before calling makeSelectNode:

/* action for '>' '<' '>=' '<=' — numeric operands only */
if (!operand_is_numeric(lnode) || !operand_is_numeric(rnode)) {
    auxil->has_error = 1;
    $$ = NULL;
} else {
    $$ = (char*)makeSelectNode(SEL_GT, (uintptr_t)lnode, (uintptr_t)rnode);
}

EQ/NE type coercion: For == and !=, operand types must be compatible. When a resource name (SEL_RESNAME) is compared to a literal, the literal is coerced to the resource's type:

  • type == linux — type is SEL_RESNAME(RES_TYPE_STRING), linux is SEL_STR (not a known resource name) — types match.
  • type == 5 — type is RES_TYPE_STRING, 5 is SEL_NUM — the numeric literal is converted to SEL_STR ("5") at parse time so that eval sees two string operands.
  • mem > 100 — mem is RES_TYPE_NUMERIC, 100 is SEL_NUM — types match.
  • gpu — gpu is RES_TYPE_BOOL, used as a standalone boolean operand (idiomatic LSF form). gpu == true is also accepted but const-folds to just gpu; gpu == false const-folds to !gpu.

This coercion guarantees that at eval time, both operands of EQ/NE always have matching types — the switch (lv.type) dispatch in eval_select never reads the wrong union member.

/* action for '==' '!=' — coerce literal to resource type */
if (lnode->type == SEL_RESNAME && rnode->type == SEL_NUM
    && lnode->u.resname.res_type == RES_TYPE_STRING) {
    /* convert numeric literal to string: 5 → "5", preserving full double precision */
    char buf[64];
    snprintf(buf, sizeof(buf), "%.17g", rnode->u.numval);
    rnode = makeSelectNode(SEL_STR, (uintptr_t)makeString(buf), 0);
}
/* symmetric: coerce if right is RESNAME and left is numeric literal */
if (rnode->type == SEL_RESNAME && lnode->type == SEL_NUM
    && rnode->u.resname.res_type == RES_TYPE_STRING) {
    char buf[64];
    snprintf(buf, sizeof(buf), "%.17g", lnode->u.numval);
    lnode = makeSelectNode(SEL_STR, (uintptr_t)makeString(buf), 0);
}
$$ = (char*)makeSelectNode(SEL_EQ, (uintptr_t)lnode, (uintptr_t)rnode);

Where operand_is_numeric(node) returns true if:

  • node->type == SEL_NUM, or
  • node->type == SEL_RESNAME && node->u.resname.res_type == RES_TYPE_NUMERIC

See §12 for the formal declaration.

Unknown resource names fail the parse (rejected at job submission time).

resmgr=NULL fallback: When resmgr is NULL (as called by the existing parse_resreq_primary entry point), the parser cannot disambiguate resname from string_literal. In this mode, all bare words are treated as SEL_STR and no type validation is performed — the caller must validate type compatibility before evaluation. The new entry point parse_resreq_primary_with_resmgr should be preferred whenever the resource manager is available.

The resmgr (resource manager) is simply an array of resource definitions — each entry maps a resource name to its type and other metadata. The API server has access to this information via the cluster configuration and can pass it to the parser at job submission time.


8. Evaluation in Host Context

8.1 Typed Value

typedef struct {
    res_type_t type;
    union {
        double      num;
        const char* str;
        int         bval;
    } u;
} sel_val_t;

8.2 Leaf Resolution

static sel_val_t get_leaf_value(const xce_select_node_t* node,
                                 select_eval_ctx_t*       ctx)
{
    switch (node->type) {
    case SEL_RESNAME:
        return host_resval_typed(ctx->host,
                                 node->u.resname.res_idx,
                                 node->u.resname.res_type);
    case SEL_NUM:
        return (sel_val_t){ RES_TYPE_NUMERIC, .u.num = node->u.numval };
    case SEL_STR:
        return (sel_val_t){ RES_TYPE_STRING,  .u.str = node->u.strval };
    default:
        return (sel_val_t){ RES_TYPE_BOOL,    .u.bval = 0 };
    }
}

8.3 eval_select

eval_select is a post-order traversal of the AST tree with memoization via a per-host evaluation context (§8.5). The outer eval_select checks the cache before recursing; eval_select_inner contains the actual recursive logic. If anything goes wrong during the visit, -1 is returned immediately and propagated up — &&, ||, and ! never silently swallow errors.

Host evaluation context: xvec_idval_t stores per-job resource allocation data, not host resource values. The select evaluator uses a per-host select_eval_ctx_t (§8.5) that provides resource values by res_idx through its numvals and strvals members, and caches evaluation results by node_id so each node is evaluated at most once per host across all jobs.

/* Inner eval — called by memoized eval_select (§8.5.2) on cache miss.
 * Returns: 1 = host matches, 0 = host does not match, -1 = error */
static int eval_select_inner(const xce_select_node_t* node,
                              select_eval_ctx_t* ctx)
{
    int lv, rv;
    const host_eval_ctx_t* host = ctx->host;  /* host snapshot in ctx */

    switch (node->type) {
    /* logical — explicit error propagation, not C boolean operators */
    case SEL_AND:
        lv = eval_select(node->u.binop.left,  ctx);
        if (lv < 0) return -1;
        if (lv == 0) return 0;
        rv = eval_select(node->u.binop.right, ctx);
        if (rv < 0) return -1;
        return rv;

    case SEL_OR:
        lv = eval_select(node->u.binop.left,  ctx);
        if (lv < 0) return -1;
        if (lv == 1) return 1;
        rv = eval_select(node->u.binop.right, ctx);
        if (rv < 0) return -1;
        return rv;

    case SEL_NOT:
        lv = eval_select(node->u.operand, ctx);
        if (lv < 0) return -1;
        return !lv;

    /* SEL_TRUE: const-folded from expressions like `type == any` */
    case SEL_TRUE:
        return 1;

    /* SEL_FALSE: const-folded from expressions like `!(type == any)` */
    case SEL_FALSE:
        return 0;

    /* standalone boolean use of a RESNAME node */
    case SEL_RESNAME:
        switch (node->u.resname.res_type) {
        case RES_TYPE_BOOL:    return host_get_bool (host, node->u.resname.res_idx);
        case RES_TYPE_NUMERIC: return host_get_float(host, node->u.resname.res_idx) != 0.0f;
        default:               return -1;  /* string standalone rejected at parse time */
        }

    /* numeric comparisons — operand types validated at parse time */
    case SEL_GT: case SEL_LT: case SEL_GE: case SEL_LE: {
        double l = get_leaf_value(node->u.binop.left,  ctx).u.num;
        double r = get_leaf_value(node->u.binop.right, ctx).u.num;
        if (node->type == SEL_GT) return l >  r;
        if (node->type == SEL_LT) return l <  r;
        if (node->type == SEL_GE) return l >= r;
                                   return l <= r;
    }

    /* type-dispatched equality — type guaranteed compatible at parse time */
    case SEL_EQ: case SEL_NE: {
        sel_val_t l = get_leaf_value(node->u.binop.left,  ctx);
        sel_val_t r = get_leaf_value(node->u.binop.right, ctx);
        int eq;
        switch (l.type) {
        case RES_TYPE_NUMERIC: eq = l.u.num  == r.u.num;            break;
        case RES_TYPE_STRING:  eq = xce_str_match(l.u.str, r.u.str); break;
        case RES_TYPE_BOOL:    eq = l.u.bval == r.u.bval;           break;
        default:               return -1;
        }
        return (node->type == SEL_EQ) ? eq : !eq;
    }

    default: return -1;
    }
}

Key design decisions:

  • Error propagation: AND/OR/NOT use explicit error checks instead of C's &&/|| operators. C's -1 is truthy, so && would swallow errors. The explicit if (lv < 0) return -1 pattern propagates errors correctly.
  • SEL_TRUE: A constant-true leaf node produced by const-folding (e.g., type == any reduces to SEL_TRUE). See §6.3.
  • xce_str_match: == on strings dispatches to xce_str_match rather than raw strcmp, allowing pattern matching extensions (e.g., wildcard support) without changing the eval structure. For the initial implementation, xce_str_match is strcmp semantics.
  • Type compatibility for EQ/NE: Parse-time validation (§7) ensures both operands of EQ/NE are type-compatible: either both numeric, both string, or a RESNAME paired with a literal whose type matches the resource's declared type. See §7 for type coercion rules.
  • Runtime type safety: In debug builds, eval_select should include assert() checks on operand types for SEL_GT/SEL_LT/SEL_GE/SEL_LE branches to verify both operands are RES_TYPE_NUMERIC. In production builds, these branches return -1 on type mismatch as a defensive fallback, since parse-time validation should prevent such cases from reaching eval.

8.4 Thread Safety

  • The intern table is protected by sel_node_table.lock for all insert/remove operations.
  • Once a node is in the table, its content is read-only. Concurrent eval_select calls on the same node require no locking.
  • The Go CGO bridge must call eval_select from a single, pinned goroutine (the scheduler goroutine). eval_select accesses interned nodes without holding references — if a concurrent goroutine triggered GC sweep while another was evaluating, a use-after-free would occur. The mark-and-sweep GC (§5.5) runs only between sessions when the scheduler goroutine is not evaluating, so single-goroutine eval guarantees safety.
  • Runtime enforcement: The Go layer should include a runtime assertion that panics if EvalSelectForHost is called from a goroutine other than the one that initialized the C state. This catches misuse at development time rather than producing silent data corruption in production. If concurrent evaluation is needed in the future, an eval-time reference count (increment before eval, decrement after) must be added to the C API.

8.5 Evaluation Memoization

Many jobs share sub-expressions via CSE (§5). Without memoization, evaluating type==linux for job1 and job2 against the same host computes the same node twice. A per-host evaluation context stores cached results indexed by node_id, so each node is evaluated at most once per host across all jobs.

Design principle: The interned AST nodes are read-only — they carry no mutable eval state. All ephemeral evaluation state lives in a separate select_eval_ctx_t that is allocated per host, used across all jobs for that host within a session, and discarded when scheduling for that host completes.

8.5.1 Evaluation Context

typedef struct {
    const host_eval_ctx_t* host;    /* host resource snapshot for this evaluation */

    /* Boolean result: 2 bits per node
     *   00 = not evaluated
     *   01 = match (true)
     *   10 = no-match (false)
     *   11 = error
     */
    uint8_t*  bool_bits;        /* bitmap, 2 bits per node */
    uint32_t  bool_bits_cap;    /* capacity in nodes (not bytes) */

    /* Numeric leaf cache: only populated for SEL_RESNAME(numeric) and SEL_NUM.
     * Indexed by node_id. Unused entries are 0.0 (which is a valid numeric
     * value, but leaf nodes are always evaluated before use, so the slot
     * is always written before it is read). */
    double*   num_vals;
    uint32_t  num_vals_cap;

    /* String leaf cache: only populated for SEL_RESNAME(string) and SEL_STR.
     * Indexed by node_id. Unused entries are NULL. */
    const char** str_vals;
    uint32_t  str_vals_cap;
} select_eval_ctx_t;

Each interned node carries a node_id — a dense 0-based index assigned at intern time. The evaluation context arrays are sized to max(node_id) + 1.

#define EVAL_NONE    0    /* not yet evaluated */
#define EVAL_TRUE    1    /* node evaluates to true  */
#define EVAL_FALSE   2    /* node evaluates to false */
#define EVAL_ERROR   3    /* node evaluates to error  */

static inline int eval_get_bool(const select_eval_ctx_t* ctx, uint32_t node_id)
{
    uint32_t bit_off = node_id * 2;
    return (ctx->bool_bits[bit_off / 8] >> (bit_off % 8)) & 0x3;
}

static inline void eval_set_bool(select_eval_ctx_t* ctx, uint32_t node_id, int val)
{
    uint32_t bit_off = node_id * 2;
    uint32_t byte = bit_off / 8;
    uint32_t shift = bit_off % 8;
    ctx->bool_bits[byte] = (ctx->bool_bits[byte] & ~(0x3 << shift))
                         | ((val & 0x3) << shift);
}

8.5.2 Memoized eval_select

int eval_select(const xce_select_node_t* node, select_eval_ctx_t* ctx)
{
    /* check cache — O(1) bitmap lookup */
    int cached = eval_get_bool(ctx, node->node_id);
    if (cached != EVAL_NONE)
        return (cached == EVAL_TRUE) ? 1 :
               (cached == EVAL_FALSE) ? 0 : -1;

    int result = eval_select_inner(node, ctx);   /* recursive eval */

    /* write cache */
    eval_set_bool(ctx, node->node_id,
        result == 1  ? EVAL_TRUE  :
        result == 0  ? EVAL_FALSE : EVAL_ERROR);

    return result;
}

eval_select_inner contains the recursive evaluation logic (§8.3) but without the cache check — it is called only on cache miss. Leaf nodes (SEL_RESNAME, SEL_NUM, SEL_STR, SEL_TRUE, SEL_FALSE) write their results to the num_vals and str_vals arrays as well, so comparison operators can read cached leaf values without re-reading host data.

8.5.3 Session Lifecycle

┌─ Pre-session ──────────────────────────────────────────────────┐
│ 1. Check max_node_id against eval context capacity             │
│ 2. If needed: grow bool_bits / num_vals / str_vals arrays      │
│ 3. If many nodes GC'd and eval context is oversized by 2x:    │
│    shrink arrays (optional, avoids unbounded growth)           │
└────────────────────────────────────────────────────────────────┘
  ↓
┌─ Scheduling session ─────────────────────────────────────────────┐
│ for each host:                                                   │
│   ctx = select_eval_ctx_for_host(host)  /* allocate or reuse */   │
│   memset(ctx->bool_bits, 0, bitmap_bytes(max_node_id + 1))      │
│   /* num_vals and str_vals: no need to clear —                  │
│      leaf resolution overwrites per-host before comparators    │
│      read them. Only bool_bits needs clearing because its      │
│      "not evaluated" state (EVAL_NONE) is represented by 0. */ │
│   for each pending_job:                                          │
│     rc = eval_select(job->select_root, ctx)                      │
│     if rc < 0: log error, skip host                              │
│     if rc == 0: skip host (select filter failed)                 │
│     if rc == 1: proceed to rusage check                          │
│   select_eval_ctx_discard(ctx)  /* free per-host context */      │
└──────────────────────────────────────────────────────────────────┘
  ↓
┌─ Post-session ──────────────────────────────────────────────────┐
│ 1. Unlink completed jobs (primary->select->root = NULL)          │
│ 2. Run gc_sweep_select_nodes()                                   │
│ 3. Re-allocate node IDs: linear scan of intern table,            │
│    assign 0..N-1 (dense after GC)                                │
│ 4. Adjust eval context for next session capacity check           │
└─────────────────────────────────────────────────────────────────┘

Per-host allocation and discard: Each host gets its own select_eval_ctx_t that is allocated when the scheduler begins evaluating jobs against that host, shared across all jobs for that host within the session, and freed when the host's evaluation is complete. This means the context does not persist between hosts — the next host gets a fresh (or reused) context with bool_bits cleared.

8.5.4 Node ID Re-compaction After GC

After GC removes unreachable nodes, the remaining nodes have sparse IDs. Compacting to 0..N-1 keeps the eval context arrays dense:

/* Run after gc_sweep_select_nodes() */
static void recompact_node_ids(void)
{
    uint32_t next_id = 0;
    kvtab_iter_t* iter = iter_make(sel_node_table.table);
    for (void* val; iter_next(iter, NULL, &val); ) {
        xce_select_node_t* node = (xce_select_node_t*)val;
        node->node_id = next_id++;
    }
    iter_free(iter);
    sel_node_next_id = next_id;   /* reset counter for new allocations */
}

8.5.5 Memory Cost

For a cluster with 10,000 unique select nodes:

Field Per-node cost Total 10K nodes
bool_bits (2 bits/node) 0.25 B 2.5 KB
num_vals (float64) 8 B 80 KB
str_vals (char*) 8 B 80 KB
Total per host ~163 KB

By separating the typed arrays instead of using a sel_val_t union (24 bytes/node) for every node, the per-host cost drops from ~240 KB to ~163 KB — a 32% reduction. The savings is larger in practice because 80%+ of nodes are boolean-type (AND, OR, NOT, comparisons), which use only 2 bits instead of 24 bytes.

8.5.6 Thread Safety for Memoization

The select_eval_ctx_t is per-host and per-session. It is not shared between hosts or between sessions. Since the scheduler goroutine processes one host at a time (§8.4), no concurrent access to the same context occurs. The bool_bits bitmap is cleared per host with memset, which is safe because no other goroutine accesses it concurrently.


9. Job-Level Sharing

There are two levels of sharing in the resreq system:

9.1 Select Sharing is Global (Node-Level CSE)

Select expressions are purely host-filtering — type==linux means the same thing regardless of which user, queue, or project submitted the job. Node-level CSE (§5) makes this sharing global: all jobs that reference type==linux share the same interned SEL_EQ subtree, regardless of their user, queue, or project. This is one of the key benefits of CSE at the AST node level rather than string-level interning.

9.2 Resreq Sharing is Scoped to (user, queue, project)

Two jobs share the same xce_resreq_t* pointer if and only if they were submitted with the same user, same queue, same project, and the same resreq string. The scoping is necessary because rusage clauses involve per-user and per-queue allocation constraints — the same rusage string may result in different allocation behavior for different users or queues.

The sharing mechanism is a second intern table at the resreq level, modelled on xstring.c:

makeResreq(canonical_resreq_str)
  → lookup in resreq_intern_table
  → cache hit: addref, return existing xce_resreq_t*
  → cache miss: parse_resreq() → intern each select node (CSE) → insert → return

9.3 Composability of the Two Levels

Because makeSelectNode interns individual AST nodes, jobs that share only a subset of their resreq (e.g., same select[...] but different rusage[...]) still share the common select subtree. The sharing is structural, not just per-string. The two levels compose:

  • Node-level CSE (§5): global — type==linux is one interned subtree used by all jobs.
  • Resreq-level sharing (§9.2): scoped to (user, queue, project, resreq_string) — because rusage clauses involve allocation constraints that differ per user/queue/project.

The resreq intern table key is the canonical resreq string (raw, after whitespace normalization). A future enhancement could normalize clause order (e.g., always select[...] before rusage[...]) to improve hit rates.


10. Scheduling Session Semantics

Within a single scheduling session:

for each candidate_host:
    ctx = select_eval_ctx_for_host(host)        /* allocate per-host eval context */
    memset(ctx->bool_bits, 0, ...)              /* clear memoization cache */
    for each pending_job:
        1. rc = eval_select(job.resreq.primary.select, ctx)
              → 1 (true):  proceed to rusage check
              → 0 (false):  skip host (select filter failed)
              → -1 (error): log error, skip host — do NOT silently treat as false
        2. can_satisfy_rusage(job.resreq.primary.rusages, host.available)
              → false: skip host (insufficient resources)
        3. allocate(job, host) → mark job as dispatched, update host.available
    select_eval_ctx_discard(ctx)                /* free per-host context */
    if no_host_matched_for_job:
        job remains unsatisfied, re-evaluated next session

Error handling: eval_select returns -1 for error (§8.3). The scheduler must treat -1 distinctly from 0 (no match): log the error and skip the host rather than silently treating the error as "no match." The updated eval_select (§8.3) propagates -1 through AND/OR/NOT explicitly — C's boolean operators are not used, so -1 is never silently coerced to truthy.

Key properties:

  • select evaluates against a host snapshot: eval_select uses host_eval_ctx_t, which contains the host's static and semi-static properties (type, architecture, boolean features, current load). These do not change during a session, so eval_select on the same (select_node, host) pair always returns the same result within a session. The host_eval_ctx_t does not contain allocation or booking data — it is a pure resource snapshot.
  • rusage allocation uses snapshot + booked: can_satisfy_rusage checks host.available = host.total - host.booked, where host.booked accumulates as jobs are dispatched within the session. This is a separate mechanism from select evaluation — the select filter runs first, and only hosts that pass the select filter are considered for rusage allocation.
  • rusage is stateful: as jobs are dispatched, host.available shrinks. Later jobs in the same session see reduced available resources.
  • unsatisfied jobs persist: a job that cannot be placed stays in the queue and is re-evaluated in the next scheduling session.

The separation of concerns is deliberate: select filtering is a pure boolean check against host properties (no side effects, fully memoizable per host), while rusage allocation is a resource consumption check that mutates the host's available resources as dispatch decisions are made.


11. Implementation Plan

C Library (Phases 1–5)

Phase 1: TDD test cases (write first)

  • lib/testsuite/test_select.c
    • Parse: valid expressions, unknown resource names (expect error), type mismatches (numeric op on string resource, expect error).
    • CSE: A && B and B && A produce the same xce_select_node_t*.
    • CSE: two calls to makeSelectNode with same args return same pointer (same interned node).
    • Eval: numeric >, <, ==; string ==; bool standalone; !, &&, ||; short-circuit evaluation.

Tests are written as skeletons/stubs in Phase 1. They compile but fail until Phase 4 provides the implementation. The TDD contract is: test signatures and assertions are defined before implementation code.

Phase 2: Header definitions

  • include/resreq.h
    • Add res_type_t, sel_node_type_t, xce_select_node_t typedef + struct.
    • Fill struct xce_resreq_select_ with xce_select_node_t* root.
    • Declare makeSelectNode, eval_select, eval_select_inner, select_eval_ctx_for_host, select_eval_ctx_discard, sel_node_table_init, sel_node_table_fini, gc_sweep_select_nodes, gc_mark_tree, recompact_node_ids.
    • Add res_type_to_valtype / valtype_to_res_type mapping helpers.
    • Add parse_resreq_primary_with_resmgr(primary, resreqString, errmsg, resmgr) as a new entry point. The existing parse_resreq_primary calls it with resmgr=NULL (no previous release, so backward compatibility is not a concern — the existing function can be updated directly if preferred).

Phase 3: PEG grammar

  • lib/xce_select.peg
    • Grammar as in §3.1 with packcc preamble (§12).
    • select_context_t auxil with resmgr pointer.
    • Each action calls makeSelectNode with canonicalized operands.
    • Type validation in comparison actions.

Phase 4: Implementation

  • lib/resreq.c
    • Add #include "kv.h" (required for kv_key_ops_t; not transitively included via kvmgr.h).
    • sel_node_table static with mylock_t.
    • sel_key_ops_t for sel_node_key_t (hash, cmp, assign, destroy).
    • sel_node_table_init(int nslots) / sel_node_table_fini().
    • makeSelectNode (§5.4) — with key_add return code check for TOCTOU safety.
    • sel_key_of, sel_key_hash helpers.
    • eval_select / eval_select_inner (§8.3/§8.5.2) — memoized evaluation with per-host select_eval_ctx_t, explicit error propagation.
    • select_eval_ctx_for_host, select_eval_ctx_discard — per-host context allocation/discard (§8.5.3).
    • host_resval_typed, host_get_bool, host_get_float, host_get_string accessors.
    • Mark-and-sweep GC: gc_sweep_select_nodes(), gc_mark_tree().
    • Node ID assignment: node_id field assigned by makeSelectNode from a monotonically increasing counter (sel_node_next_id++).
    • recompact_node_ids() — reassign dense 0..N-1 IDs after GC (§8.5.4).
    • AST transform pipeline: flatten, const-fold (§6.2–6.4).
    • xce_str_match wrapper (initially strcmp semantics).
    • Update kill_primary_resreq to unlink select root (primary->select->root = NULL) before freeing the select struct. GC between sessions reclaims unreachable nodes.
    • Update parse_resreq_primary to call parse_select with resmgr.

Phase 5: Build wiring

  • lib/meson.build — add xce_select.peg to the source list alongside xce_resreq.peg, section.peg, rusage.peg.

Go Integration (Phase 6)

Phase 6: Go bridge

  • backend/clib.go — replace EvalSelectForHost stub with real CGO call. Must marshal a host_eval_ctx_t from Go to C, populating both the numeric resource vector (numvals) and the string resource table (strvals, indexed by res_idx). The Go bridge then calls select_eval_ctx_for_host() to create the per-host memoization context, evaluates each job against it via eval_select(), and calls select_eval_ctx_discard() when the host's evaluation is complete. The strvals array maps res_idx to interned string pointers obtained via makeString() for CSE compatibility.
  • controllers/hosts.go — wire FilterHostByResReqSelect into preCheckHostRequest.
  • GC trigger: Add CGO calls invoked by the scheduler goroutine at the end of each scheduling session, after all dispatch/defer decisions and job cleanup (unlinking select roots) are complete:
    1. C.gc_sweep_select_nodes() — reclaim unreachable nodes.
    2. C.recompact_node_ids() — reassign dense 0..N-1 IDs after GC (§8.5.4). This must be a well-defined session boundary — no eval_select calls may be in progress during the sweep or recompaction. Before the next session, check whether the eval context arrays need to grow or shrink to accommodate the new max_node_id (§8.5.3).
  • CGO concurrency: FilterHostByResReqSelect must be called from a single, pinned scheduler goroutine (§8.4). This is a hard invariant: eval_select accesses interned nodes without holding references, so concurrent evaluation while GC sweep runs would cause use-after-free. Add a runtime assertion in the Go layer that panics if EvalSelectForHost is called from a goroutine other than the one that initialized the C state. If concurrent scheduling is needed in the future, eval-time reference counting (increment before eval, decrement after) must be added to the C API.

12. Appendix: Data Structures

Complete xce_select_node_t

typedef enum { RES_TYPE_NUMERIC = 0, RES_TYPE_STRING, RES_TYPE_BOOL } res_type_t;

typedef enum {
    SEL_AND, SEL_OR, SEL_NOT,
    SEL_GT, SEL_LT, SEL_GE, SEL_LE, SEL_EQ, SEL_NE,
    SEL_RESNAME, SEL_NUM, SEL_STR, SEL_ANY, SEL_TRUE, SEL_FALSE,
} sel_node_type_t;

typedef struct xce_select_node_ xce_select_node_t;

struct xce_select_node_ {
    sel_node_type_t type;
    uint32_t        hashcode;
    uint32_t        node_id;    /* dense index into eval context arrays (§8.5) */
    unsigned        gc_mark;   /* set during mark-and-sweep GC (§5.5) */
    union {
        struct { xce_select_node_t *left, *right; } binop;
        xce_select_node_t*                           operand;
        struct { int res_idx; res_type_t res_type; } resname;
        double                                       numval;
        char*                                        strval;
    } u;
};

CSE Key

typedef struct {
    sel_node_type_t type;
    uintptr_t       op1;
    uintptr_t       op2;
} sel_node_key_t;

Typed Eval Value

typedef struct {
    res_type_t type;
    union { double num; const char* str; int bval; } u;
} sel_val_t;

Host Evaluation Context

typedef struct {
    const xvec_idval_t*  numvals;    /* numeric resource values by res_idx */
    const char**         strvals;    /* string resource values by res_idx */
} host_eval_ctx_t;

Select Evaluation Context (per-host, §8.5)

typedef struct {
    const host_eval_ctx_t* host;     /* host resource snapshot */
    uint8_t*   bool_bits;           /* bitmap, 2 bits per node (eval result cache) */
    uint32_t   bool_bits_cap;       /* capacity in nodes */
    double*    num_vals;            /* numeric leaf cache by node_id */
    uint32_t   num_vals_cap;
    const char** str_vals;          /* string leaf cache by node_id */
    uint32_t   str_vals_cap;
} select_eval_ctx_t;

/* Allocate eval context for a host (shared across all jobs for that host) */
select_eval_ctx_t* select_eval_ctx_for_host(const host_eval_ctx_t* host);

/* Free per-host eval context after host scheduling is complete */
void select_eval_ctx_discard(select_eval_ctx_t* ctx);

Select Parse Context

typedef struct {
    const char* input;
    int         pos;
    int         err_pos;
    int         has_error;
    char*       errmsg;
    var         resmgr;    /* name → (res_idx, res_type) */
} select_context_t;

Helper: operand_is_numeric

static int operand_is_numeric(const xce_select_node_t* node)
{
    return node->type == SEL_NUM
        || (node->type == SEL_RESNAME
            && node->u.resname.res_type == RES_TYPE_NUMERIC);
}

CSE Key Operations

The sel_node_table uses kvtab_type keyed by sel_node_key_t. A custom kv_key_ops_t is required since sel_node_key_t is a struct with potential padding between type and op1 — sel_key_hash and sel_key_cmp must operate on the three fields individually, not by memcpy-ing the entire struct (which would hash padding bytes):

static kv_key_ops_t sel_node_key_ops = {
    .hash   = sel_key_hash,
    .cmp    = sel_key_cmp,
    .assign = sel_key_assign,   /* malloc + memcpy */
    .destroy = sel_key_destroy, /* free */
};

These must be defined in resreq.c alongside the intern table.

Host Resource Value Accessors

eval_select calls accessor functions that use the host_eval_ctx_t context (not xvec_idval_t directly). xvec_idval_t stores per-job resource allocation data; host resource values are retrieved through the evaluation context's numvals and strvals members:

/* Retrieve typed value from host evaluation context */
static sel_val_t host_resval_typed(const host_eval_ctx_t* host,
                                    int res_idx, res_type_t res_type);

/* Boolean resource: nonzero → true */
static int host_get_bool(const host_eval_ctx_t* host, int res_idx);

/* Numeric resource: return float value */
static double host_get_float(const host_eval_ctx_t* host, int res_idx);

/* String resource: return interned string pointer */
static const char* host_get_string(const host_eval_ctx_t* host,
                                    int res_idx);

String resource storage: xvec_idval_t stores per-job resource allocation data (not host values). The host_eval_ctx_t bundles host resource data: numvals provides numeric and boolean resource values indexed by res_idx, and strvals provides string resource values indexed by res_idx. When host data is loaded into the scheduler, both the numeric vector and the string mapping are populated from the cluster configuration. libxce already employs a de-duplication mechanism that maps identical strings to the same pointer (via makeString), so host_get_string returns an interned pointer — xce_str_match (which starts as strcmp) can be replaced with pointer equality when both operands are known-interned.

PEG Grammar Preamble

The existing PEG parsers use %value "char *". The select parser produces xce_select_node_t* values. The packcc preamble:

%prefix "xce_select"
%value   "char *"                /* packcc returns char*; actions cast */
%auxil   "select_context_t *"

PEG actions cast node pointers through char* return values:

$$ = (char*)makeSelectNode(SEL_AND, (uintptr_t)lnode, (uintptr_t)rnode);

The parse_select function unwraps the final return:

xce_select_node_t* root = (xce_select_node_t*)xce_select_parse(ctx, input, ctx);