# 03 — Custom Signal

Introduces `HybridSearch.create()` — the lower-level API that gives full control over which signals participate in RRF fusion.

## What's new

- **HybridSearch.create()** — direct index construction (vs. `SearchEngine` which manages lifecycle)
- **StaticSignal interface** — compute a named ranking signal from document metadata
- **RecencySignal** — boosts recently-modified tables over stale ones
- **explain()** — per-signal breakdown showing each signal's rank and contribution

## Key concepts

### StaticSignal

A `StaticSignal` is computed once and cached until `invalidateStaticSignals()` is called. It returns a `NamedSignal`: a name plus a list of `(id, score)` entries.

```ts
class RecencySignal implements StaticSignal {
  readonly name = "recency";

  compute(): NamedSignal | null {
    // Score each document: 1.0 (just modified) → 0.0 (maxAge days old)
    // Return null if no entries (signal contributes nothing)
  }
}
```

Register custom signals via `HybridSearch.create()`:

```ts
const search = HybridSearch.create<Table>({
  getId: (doc) => doc.fqn,
  buildDocument: (doc) => `${doc.name} ${doc.comment}`,
  getKeywords,
  embeddingProvider: null,
  staticSignals: [new RecencySignal(() => docs)],
  onError: console.error,
});
```

### How it changes ranking

Both `REVENUE` and `LEGACY_REVENUE` match "revenue" via BM25. The recency signal provides additional signal:

| Document                    | keyword rank | recency rank | RRF fused |
| --------------------------- | ------------ | ------------ | --------- |
| REVENUE (1 day old)         | #1           | #1           | 0.0769    |
| LEGACY_REVENUE (1 year old) | #2           | (absent)     | 0.0370    |

`LEGACY_REVENUE` is beyond the 180-day recency window, so it gets no recency contribution. `REVENUE` ranks in both signals and scores higher via RRF.

### explain() for debugging

`explain()` returns the same results as `search()` plus a per-signal breakdown:

```
0.0769  REVENUE  [keyword:#1, recency:#1]
0.0370  LEGACY_REVENUE  [keyword:#2]
0.0370  ARTIST  [recency:#2]
```

This shows exactly which signals contributed to each document's score — useful for tuning weights and debugging glossary entries.

### Static vs. query signals

This example uses a **static** signal (computed once, reused across queries). [Example 06](../06-custom-pipeline/) introduces **query** signals, which are computed fresh per query and can use the query text or top candidates as input.

## Running

```bash
npx tsx examples/03-custom-signal/main.ts
```

## Expected output

```
Indexed 3 tables

Query: "revenue" (with recency signal)
  0.0769  REVENUE  (modified 1d ago)
  0.0370  LEGACY_REVENUE  (modified 365d ago)
  0.0370  ARTIST  (modified 30d ago)

Signal breakdown (explain):
  0.0769  REVENUE  [keyword:#1, recency:#1]
  0.0370  LEGACY_REVENUE  [keyword:#2]
  0.0370  ARTIST  [recency:#2]
```
