Documentation

Existence & Flattening

Matching against related collections with if_exists, if_not_exists, and flatten_last.

if_exists, if_not_exists, and flatten_last cover common relationship shapes without forcing the model to materialize extra facts.

if_exists / if_not_exists

Use existence filters when the left row should survive only if a matching row exists, or only if no matching row exists, in another collection.

type Streams = ConstraintFactory<Schedule, HardSoftScore>;

Streams::new()
    .for_each(Schedule::shifts())
    .if_exists((
        Streams::new().for_each(Schedule::unavailability()),
        equal_bi(
            |shift: &Shift| shift.employee_idx,
            |u: &Unavailability| u.employee_idx,
        ),
    ))
    .penalize(HardSoftScore::ONE_HARD)
    .named("Unavailable employee");

if_exists asks “does this item have at least one matching record over there?” if_not_exists asks the inverse. Multiple right-side matches do not multiply the left row’s score: an existence filter publishes only accepted left rows.

Relational Execution

Both operations use stream::relational::operator::ExistenceNode, a semi/anti join over concrete row producers, and finalize onto constraint::relational::OperatorTerminal, not a separate existence scoring engine. Key choice does not select an engine: the node dispatches its key storage internally — dense tables for usize planning-id keys, hash tables otherwise — so choose usize, optional, string, or compound keys for the relationship you need.

The node answers membership from per-key occurrence counts instead of materializing child rows or pair links. It retains one occurrence count per right-side key, each right row’s stored key contribution, and one bucket of left rows per key; a key whose count crosses the alive/dead threshold publishes or retracts every left row carrying it in one idempotent pass per event. Right-side children never become rows and never receive handles — when the right stream is flattened, a ParentChildKeys adapter reads each parent’s children straight from the solution, and a grouped right stream keys off its aggregated result. Left rows are re-keyed from the live solution on insert, so a changed key moves the row between key domains, and only left rows are published, never scored pair rows.

From v0.19.10 through v0.19.12 the node additionally retained per-pair matching links: v0.19.11 reused retired link-list buffers and event scratch across incremental refreshes, and v0.19.12 updated surviving opposite-side match lists in place rather than removing and reinserting those lists. v0.19.13 moves the per-pair links to the relationship shapes that need them (see below) and keeps the reuse discipline for what the count-backed engine does retain: stored key contributions are recycled and every event reuses its notification and key scratch buffers.

Dense usize count updates keep that bookkeeping inline: DenseKeys::add and take check the table length before calling grow, then mark a key’s first touch directly. A key enters the touched list at most once per event, even when several right-side contributions change its count. A key enters the touched list at most once per event, even when several right-side contributions change its count. Growth is still possible when a key exceeds the current table length; this is not an allocation-free update guarantee. Hash-key storage and the public existence methods are unchanged. See the tagged count-state implementation.

These are internal representation and allocation changes, not a change to membership: semi/anti joins still publish left rows according to whether any matching key is present. The per-event membership pass is idempotent, so a key removed and re-added inside one event settles on its final state; retained right-row key contributions remove exactly the old keys after a value changes; generational handles prevent a reused storage slot from reviving an old relationship; and reset clears retained keys, counts, contributions, and scratch before reinitialization. Relationships that need residual predicates beyond an equality key still run the compiled per-pair path: there JoinerExistenceNode retains matching pair links and evaluates the residual predicate, and complement defaults restore through the plan’s retained equality keys.

flatten_last

Use flatten_last after a join when the last joined item owns a collection and the constraint needs to score individual child elements.

type Streams = ConstraintFactory<Schedule, HardSoftScore>;

Streams::new()
    .for_each(Schedule::shifts())
    .join((
        Streams::new().for_each(Schedule::employees()),
        equal_bi(
            |shift: &Shift| shift.employee_idx,
            |employee: &Employee| Some(employee.id),
        ),
    ))
    .flatten_last(
        |employee: &Employee| employee.available_days.as_slice(),
        |day| *day,
        |shift: &Shift| shift.date(),
    )

The three arguments are:

Argument Purpose
slice extractor returns the nested collection from the joined item
flattened key extracts a key from each nested element
lookup key extracts the matching key from the left-side row

This keyed pair operation emits (left entity, child element) matches, not (left entity, parent entity, child element) triples. Score or filter those matches with two value arguments. Both relationships must hold: the original join links the left entity to the parent, and the lookup key matches the child’s flattened key.

The fluent builder creates a FlattenNode over the parent source and joins it to the left collection using the compound (parent key, child key) relationship. It finalizes onto OperatorTerminal; there is no separate flattened-bi retained scoring engine. The three-argument flatten_last API remains the keyed Bi operation shown above, not a general promise that every arbitrary-depth Chain supports flattening.

The node recycles retired parent-to-child handle vectors and reserves space for replacement children. It still retracts every old child and creates fresh generational child handles when the parent refreshes. Reusing a vector allocation does not retain a stale child, change the compound-key relationship, or introduce a new fluent flattening API.

When To Use Which

Need Use
Keep a row if a related record exists if_exists
Keep a row if a related record is missing if_not_exists
Score individual values inside a joined collection flatten_last
Emit a retained scoring row with a custom shape Projected Scoring Rows

See Also