> ## Documentation Index
> Fetch the complete documentation index at: https://ventstream.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Fan-out & hot endpoints

> Why one change recomputes only the right documents — and how shared lookup nodes are kept from exploding.

When a change arrives, the engine has to answer one question: **which
documents embed this thing, and therefore need recomputing?** Getting
that set right — not too big, not too small — is what makes the engine
both correct and fast.

## The two directions of fan-out

A change can be to the **primary itself** or to **something it embeds**.

* **Primary change** (an `orders` row, a `Product` node): recompute that
  one document. Easy.
* **Embedded change** (a `customer` the order references, a `Region` two
  hops from the product): recompute every primary that embeds it. This
  is the fan-out, and it can touch many documents from one change.

For Postgres, the engine keeps an in-memory reverse index of foreign
keys to answer this. For Neo4j, it runs a Cypher query anchored on the
changed element's ID, walking back to the affected primaries along the
spec's declared paths.

## Bounded by the spec

Fan-out only follows paths the spec declares, up to `fan_out_max_hops`.
A node whose label the spec never traverses produces **zero**
recomputations — the change event is read and discarded. This is what
keeps a single change from rippling across the whole graph.

Changing a shared lookup node must recompute every primary document that embeds
that value. This can be expensive, but it is required for the target documents
to converge.

## The hot-endpoint problem

Now consider a **shared lookup node** — like a `Category` that thousands
of products point at. In the validation graph there was a single shared
status node referenced by \~99,000 documents through one relationship type.

When a single document's edge to that shared node *changes*, the naive
fan-out anchors on the relationship's endpoints — including the shared
node — and asks "which documents reach this node?" The answer is *all
99,000*, even though only one document's edge actually changed.

Without handling, a single edge change triggered a **59,498-document
recompute** in testing. A 1,000-operation burst queued \~5,800 such
cascades — hours of wasted work for changes that should each touch one
document.

## Hot-endpoint detection

At startup, the engine probes each projection path's leaf cardinality.
Any endpoint label below a threshold (default **100**, via
`VS_NEO4J_HOT_NODE_THRESHOLD`) is flagged "hot" and its element IDs are
captured in a small in-memory set.

At event time, relationship events involving a hot endpoint have that
endpoint filtered out of the fan-out anchor set — so only the
**primary-side** key reaches the recompute path:

```
shared-node edge change
  naive anchors:   [edge, primary-eid, shared-node-eid]  → 99,498 recompute
  hot-filtered:    [edge, primary-eid]                    → 1 recompute
```

With hot-endpoint detection, the relationship event keeps only the
primary-side anchor and recomputes the document whose edge changed.

## What is *not* filtered

The filter applies to **relationship** events only. A **node-property
change on a hot node** — say, renaming that shared status node from
"Published" to "Live" — still cascades to all \~99,000 documents, because
the embedded value genuinely changed for all of them. That's correct:

| Event                                                | Recomputes        | Correct because                         |
| ---------------------------------------------------- | ----------------- | --------------------------------------- |
| One document's edge to the shared node added/removed | 1 (that document) | only that document's membership changed |
| The shared node's label property renamed             | \~99,000          | the embedded value changed everywhere   |

The filter removes *spurious* fan-out (an edge change masquerading as a
graph-wide change), never *legitimate* fan-out (a shared value actually
changing).

## Cost

* **Startup:** one count query for each analysable projection-path prefix.
* **Memory:** a set of detected endpoint IDs, keyed by relationship type.
* **Per event:** relationship-type and endpoint membership lookups.

Set `VS_NEO4J_HOT_NODE_THRESHOLD=0` to disable detection entirely (the
old behavior). Raise it if a genuine fan-out target sits just above the
default and you'd rather treat it as hot.
