# 09 — Graph Signals

Demonstrates the four graph-based ranking signals and how each captures a different structural property of the schema graph.

## What's new

- **AdamicAdarSignal** — nodes whose neighbors are rare/specific rank higher (vs hubs)
- **BetweennessSignal** — "bridge" nodes connecting otherwise separate domains rank higher
- **ColumnDensitySignal** — tables with more columns (richer data) rank higher
- **Comparing signals** — same query, different signal combinations, different rankings

## Key concepts

### Why multiple graph signals?

DegreeSignal (Example 02) rewards highly-connected tables. But "highly connected" isn't always "most relevant":

| Signal         | What it captures     | Favors                             | Example                                                  |
| -------------- | -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Degree         | Connection count     | Hub tables (ACCOUNT, CONTRACT)     | ACCOUNT has 8 FK references                              |
| Adamic-Adar    | Neighbor specificity | Tables with rare, niche neighbors  | RESERVE links to LEDGER (specific) not ACCOUNT (generic) |
| Betweenness    | Bridge position      | Tables connecting separate domains | CONTRACT bridges ARTIST domain to ROYALTY domain         |
| Column density | Schema richness      | Fact tables with many columns      | FACT_REVENUE has 12 columns vs DIM_STATUS has 3          |

Each signal produces a ranked list. RRF fusion combines all four (plus keyword, glossary, etc.) into a single ranking. A table that scores well on multiple signals surfaces above one that scores well on only one.

### When graph signals help

On a 16-table corpus, keyword + glossary is already precise (NDCG ~0.93). Graph signals add value at scale:

- **82-table corpus**: NDCG=0.887, MRR=0.943 with all signals
- **189 golden queries**: structural signals disambiguate between many keyword-matching candidates

The signals are computed once (static) and cached until `invalidateStaticSignals()` is called on refresh.

## Running

```bash
npx tsx examples/09-graph-signals/main.ts
```

## Expected output

```
Indexed 8 tables with FK graph (10 edges)

=== Signal scores (top 5 per signal) ===
  degree:         CONTRACT(1.00) ACCOUNT(0.80) STATEMENT(0.60) LEDGER(0.40) TRACK(0.20)
  adamic_adar:    LEDGER(1.00) STATEMENT(0.85) PAYMENT(0.72) RESERVE(0.68) CONTRACT(0.45)
  betweenness:    CONTRACT(1.00) STATEMENT(0.67) ACCOUNT(0.33) LEDGER(0.12) PAYMENT(0.00)
  column_density: LEDGER(1.00) CONTRACT(0.86) STATEMENT(0.71) ACCOUNT(0.57) TRACK(0.43)

=== "account balance" — keyword + degree only ===
  0.0769  ACCOUNT       [keyword:#1, degree:#2]
  0.0370  LEDGER        [keyword:#2, degree:#4]
  0.0357  CONTRACT      [degree:#1]

=== "account balance" — all graph signals ===
  0.0769  LEDGER        [keyword:#2, degree:#4, adamic_adar:#1, column_density:#1]
  0.0693  ACCOUNT       [keyword:#1, degree:#2, betweenness:#3]
  0.0370  CONTRACT      [degree:#1, betweenness:#1, column_density:#2]

With all signals, LEDGER (the actual balance table) outranks ACCOUNT (a generic hub)
because adamic-adar and column-density reward its specific structure.
```
