Loadbear
6 ideasabout

path_laplacian_retrieval_lab.py

scripts/path_laplacian_retrieval_lab.py

#!/usr/bin/env python3
"""A deterministic retrieval experiment for the Loadbear path-Laplacian series.

The graph is intentionally synthetic.  It has a planted relevant chain and a
nearby, lexical-looking but irrelevant support cluster.  That lets us ask a
useful question with a known answer: as indirect paths get stronger, do we find
the distant relevant nodes before we start spending context on the wrong topic?

The paper's operator is used for transport:

    L_hat(alpha) = sum_d d**(-alpha) L^(d)
    dx/dt = -L_hat(alpha) x

The small query prior below is *not* from the paper.  It stands in for whatever
lexical/semantic scorer a real retrieval system already has.  Keeping it
separate from transport is the point of the experiment.

Run:
    python3 scripts/path_laplacian_retrieval_lab.py
    python3 scripts/path_laplacian_retrieval_lab.py --json
"""

from __future__ import annotations

import argparse
import json
from dataclasses import asdict, dataclass
from math import inf


@dataclass(frozen=True)
class Node:
    label: str
    relevant: bool
    tokens: int
    query_prior: float


@dataclass(frozen=True)
class Result:
    alpha: float
    selected: tuple[str, ...]
    recall: float
    leakage: int
    tokens: int


# The first four candidates are the answer-bearing chain.  The last four make
# up a second topic cluster reached through one sparse bridge.  "renewal FAQ"
# is a deliberate lexical decoy: it has a decent query prior, but is not part
# of the answer to an approval-follow-up query.
NODES = (
    Node("customer request", False, 24, 1.00),  # query anchor; never selected
    Node("pricing owner", True, 30, 0.58),
    Node("approval policy", True, 42, 0.94),
    Node("follow-up task", True, 34, 0.82),
    Node("contract exception", True, 46, 0.66),
    Node("support bridge", False, 28, 0.14),
    Node("renewal FAQ", False, 38, 0.72),
    Node("incident timeline", False, 44, 0.31),
    Node("on-call rota", False, 30, 0.17),
)

# A relevant chain 0--1--2--3--4, followed by a sparse bridge 2--5 into the
# unrelated support cluster 5--6--7--8.
EDGES = ((0, 1), (1, 2), (2, 3), (3, 4), (2, 5), (5, 6), (6, 7), (7, 8))
QUERY_ANCHOR = 0
# The four answer-bearing nodes occupy exactly 152 tokens.  A lexical decoy
# costs 38 tokens, so it can crowd out the fourth answer-bearing node when the
# ranking leaks into the support cluster.
TOKEN_BUDGET = 152
TRANSPORT_WEIGHT = 0.82
DIFFUSION_TIME = 0.90
EULER_STEP = 0.01


def shortest_distances() -> list[list[float]]:
    """Floyd-Warshall is plenty for this nine-node fixture."""
    size = len(NODES)
    distances = [[0.0 if row == column else inf for column in range(size)] for row in range(size)]
    for left, right in EDGES:
        distances[left][right] = distances[right][left] = 1.0
    for middle in range(size):
        for left in range(size):
            for right in range(size):
                distances[left][right] = min(distances[left][right], distances[left][middle] + distances[middle][right])
    return distances


DISTANCES = shortest_distances()


def path_laplacian(alpha: float) -> list[list[float]]:
    """Build L_hat from shortest-path layers, not from invented graph edges."""
    size = len(NODES)
    laplacian = [[0.0 for _ in range(size)] for _ in range(size)]
    for left in range(size):
        for right in range(left + 1, size):
            distance = DISTANCES[left][right]
            if distance == inf:
                continue
            weight = distance ** (-alpha)
            laplacian[left][left] += weight
            laplacian[right][right] += weight
            laplacian[left][right] -= weight
            laplacian[right][left] -= weight
    return laplacian


def matvec(matrix: list[list[float]], vector: list[float]) -> list[float]:
    return [sum(entry * vector[index] for index, entry in enumerate(row)) for row in matrix]


def diffuse(alpha: float) -> list[float]:
    """Explicit Euler approximation of x' = -L_hat x from the query anchor."""
    laplacian = path_laplacian(alpha)
    values = [0.0 for _ in NODES]
    values[QUERY_ANCHOR] = 1.0
    for _ in range(round(DIFFUSION_TIME / EULER_STEP)):
        disagreement = matvec(laplacian, values)
        values = [value - EULER_STEP * delta for value, delta in zip(values, disagreement)]
    return values


def rank(alpha: float) -> list[tuple[float, int]]:
    """Combine fixed query evidence with transport, then rank candidates.

    The combination is deliberately ordinary weighted addition.  It is here so
    the lab tests a *retrieval policy*, rather than quietly treating diffusion
    time as a relevance label.
    """
    transported = diffuse(alpha)
    transport_max = max(transported[index] for index in range(len(NODES)) if index != QUERY_ANCHOR)
    scored = []
    for index, node in enumerate(NODES):
        if index == QUERY_ANCHOR:
            continue
        transport = transported[index] / transport_max if transport_max else 0.0
        score = TRANSPORT_WEIGHT * transport + (1.0 - TRANSPORT_WEIGHT) * node.query_prior
        scored.append((score, index))
    return sorted(scored, reverse=True)


def select(alpha: float) -> Result:
    """Greedily pack ranked context until the fixed token budget is exhausted."""
    selected: list[str] = []
    spent = 0
    relevant = 0
    leakage = 0
    for _, index in rank(alpha):
        node = NODES[index]
        if spent + node.tokens > TOKEN_BUDGET:
            continue
        selected.append(node.label)
        spent += node.tokens
        if node.relevant:
            relevant += 1
        else:
            leakage += 1
    total_relevant = sum(node.relevant for node in NODES)
    return Result(alpha, tuple(selected), relevant / total_relevant, leakage, spent)


def run() -> list[Result]:
    return [select(alpha) for alpha in (0.0, 0.5, 1.0, 2.0, 3.0, 4.0, 8.0)]


def verify(results: list[Result]) -> None:
    """Keep the deliberately planted tradeoff from silently drifting away."""
    by_alpha = {result.alpha: result for result in results}
    for alpha in (1.0, 2.0, 3.0):
        assert by_alpha[alpha].recall == 1.0 and by_alpha[alpha].leakage == 0
    for alpha in (0.0, 4.0, 8.0):
        assert by_alpha[alpha].recall == 0.75 and by_alpha[alpha].leakage == 1


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", action="store_true", help="emit machine-readable results")
    parser.add_argument("--verify", action="store_true", help="assert the fixture's intended tradeoff")
    args = parser.parse_args()
    results = run()
    if args.verify:
        verify(results)
    if args.json:
        print(json.dumps([asdict(result) for result in results], indent=2))
        return

    print("alpha  recall  leak  tokens  selected")
    for result in results:
        print(
            f"{result.alpha:>4.1f}   {result.recall:>4.2f}    {result.leakage:>1}    "
            f"{result.tokens:>3}   {', '.join(result.selected)}"
        )


if __name__ == "__main__":
    main()