Skip to main content
Glama
J-X0

mardale-trading-search-ranking-mcp

by J-X0
README.md
# mardale-trading-search-ranking-mcp

Project Fathom: relevance-drift monitoring for search ranking, built for
Mardale Trading's aviation-maintenance document search.

The system answers one operational question: *is our ranking getting worse?*
It ranks candidate documents for a query, scores that ranking against editorial
relevance judgements, and compares the result to an earlier baseline to detect
and classify drift. Every decision -- each ranking and each drift verdict --
is written to a tamper-evident audit trail, because the engagement requires
that every decision carry a reviewable record.

## Why the audit trail is central

The hard constraint on this build is auditability. It is not a logging layer
added at the end; it drives the design:

- `fathomsearch/audit.py` maintains an append-only, hash-chained ledger. Each
  record hashes its own content together with the previous record's hash, so
  any edit, deletion, or reordering is detectable by `AuditLog.verify()`.
- `rank_documents` and `monitor_drift` accept an `AuditLog` and emit a record
  for every decision, including the rationale and the thresholds applied.
- Domain types are frozen dataclasses: once a decision is hashed, the data
  behind it cannot be mutated out from under the record.

## Layout

```
fathomsearch/
  types.py            domain types (Document, Query, Judgment, Ranking, DriftReport, ...)
  audit.py            hash-chained audit ledger
  metrics.py          nDCG / precision / recall against editorial judgements
  ranking.py          provider scores -> ordered ranking (+ audit)
  drift.py            central algorithm: relevance-drift monitoring (+ audit)
  providers/
    base.py           RelevanceProvider interface
    stub.py           deterministic, offline lexical provider (used by tests)
    real.py           HTTP-backed provider (production; optional httpx)
tests/
```

## Install and test

```
make venv
make install
make test
```

`make` targets run through `$(PY)` (default `.venv/bin/python`); override the
interpreter with `make test PY=/path/to/python`.

The test suite runs fully offline with no API key: all model-like behaviour is
behind `RelevanceProvider`, and the tests use `DeterministicStubProvider`.
`tests/test_offline.py` disables socket creation and runs the whole
rank -> evaluate -> drift pipeline to prove nothing reaches the network.

## Quickstart

Sample data lives in `examples/`. From the repository root, score a baseline,
score a degraded corpus (one relevant document removed), then compare them:

```
python -m fathomsearch evaluate \
  --documents examples/documents.json --queries examples/queries.json \
  --judgments examples/judgments.json --label v1 \
  --out v1.eval.json --audit-out v1.audit.jsonl

python -m fathomsearch evaluate \
  --documents examples/documents_degraded.json --queries examples/queries.json \
  --judgments examples/judgments.json --label v2 --out v2.eval.json

python -m fathomsearch drift --baseline v1.eval.json --current v2.eval.json \
  --out drift.json
```

The drift command exits 1 because removing `d1` from the corpus regresses query
`q1`; `drift.json` shows the per-query deltas and `v1.audit.jsonl` holds the
verifiable ledger for the baseline run.

## Using the core

```python
from fathomsearch.types import Query, Document, Judgment, JudgmentSet, Evaluation
from fathomsearch.providers.stub import DeterministicStubProvider
from fathomsearch.ranking import rank_documents
from fathomsearch.metrics import evaluate_ranking
from fathomsearch.drift import monitor_drift
from fathomsearch.audit import AuditLog

audit = AuditLog()
provider = DeterministicStubProvider()

query = Query("q1", "hydraulic leak inspection")
docs = [
    Document("d1", "hydraulic leak inspection checklist"),
    Document("d2", "galley equipment manual"),
]
judgments = JudgmentSet([Judgment("q1", "d1", 3), Judgment("q1", "d2", 0)])

ranking = rank_documents(provider, query, docs, audit=audit)
evaluation = evaluate_ranking(ranking, judgments, k=10)

# Compare two snapshots to detect drift:
baseline = Evaluation("v1", {"q1": evaluation})
current = Evaluation("v2", {"q1": evaluation})
report = monitor_drift(baseline, current, metric="ndcg", audit=audit)

assert audit.verify()          # chain intact
audit.write_jsonl("run.audit.jsonl")
```

## Drift severity bands

`monitor_drift` classifies each query's metric change (current minus baseline)
using `DriftThresholds`:

- `major_regression`: delta <= `major` (default -0.15)
- `minor_regression`: `major` < delta <= `minor` (default -0.05)
- `stable`: within +/- `stable_band` (default 0.02)
- `improved`: delta >= `stable_band`

A query that appears in only one snapshot is a drift signal that cannot be
measured as a delta; it is excluded from the deltas and recorded separately.

## The real provider

`fathomsearch/providers/real.py` posts query/document text to an HTTP scoring
backend. It requires `httpx` (`pip install ".[real]"`) and an API key via
`FATHOM_API_KEY` or the constructor. It never falls back to a heuristic on
failure: a silent fallback would poison drift comparisons. It is not imported
by the test suite.

## Command-line interface

Installed as `fathomsearch` (or run `python -m fathomsearch`). Input files are
JSON:

- documents: `[{"id": "...", "text": "...", "metadata": {}}, ...]`
- queries: `[{"id": "...", "text": "..."}, ...]`
- judgments: `[{"query_id": "...", "doc_id": "...", "grade": 0}, ...]`

Score a run into an evaluation file, capturing the audit ledger:

```
python -m fathomsearch evaluate \
  --documents docs.json --queries queries.json --judgments qrels.json \
  --label v1 --out v1.eval.json --audit-out v1.audit.jsonl
```

Compare two evaluations and classify drift:

```
python -m fathomsearch drift --baseline v1.eval.json --current v2.eval.json \
  --out drift.json --audit-out drift.audit.jsonl
```

Exit codes: `0` no regressions, `1` one or more regressions found (usable as a
CI gate), `2` an expected error (bad input, missing file, bad config).

Start the MCP server (needs the `mcp` package installed):

```
python -m fathomsearch serve
```

## Configuration

Configuration is read from the environment and validated at startup:

| Variable | Default | Meaning |
| --- | --- | --- |
| `FATHOM_PROVIDER` | `stub` | `stub` or `real` |
| `FATHOM_METRIC` | `ndcg` | `ndcg`, `precision`, or `recall` for drift |
| `FATHOM_K` | `10` | cutoff for metrics |
| `FATHOM_MAX_DOCUMENTS` | `10000` | per-query candidate limit (resource guard) |
| `FATHOM_MAX_QUERY_LENGTH` | `2048` | max query characters |
| `FATHOM_LOG_LEVEL` | `INFO` | logging level |
| `FATHOM_LOG_JSON` | `1` | `0` for human-readable logs |
| `FATHOM_AUDIT_PATH` | unset | default audit ledger path |
| `FATHOM_REAL_ENDPOINT` / `FATHOM_REAL_MODEL` / `FATHOM_API_KEY` | unset | real provider settings |

## Error handling and observability

- `fathomsearch/errors.py` defines the failure hierarchy; the CLI turns any
  `FathomError` into exit code 2 with a message, and lets real bugs crash.
- `fathomsearch/loader.py` reports missing files, bad JSON, and wrong-shaped
  records with the file and record index named.
- `fathomsearch/service.py` enforces input validation and resource limits,
  times each operation, and logs structured events.
- `fathomsearch/logging_config.py` emits one JSON object per log line and a
  `timed()` context manager that records duration even when the block raises.
- A failed audit write is surfaced (`persist_audit` returns `False` and logs an
  error) rather than swallowed, because auditability is the binding constraint.

## Architecture

Layered so the audit trail and validation are enforced once, regardless of
entry point:

```
CLI (cli.py)  /  MCP server (server.py)
        \             /
       RankingService (service.py)      validation, limits, timing, audit
        /      |          \
 ranking.py  metrics.py  drift.py       core algorithm
        \      |          /
          providers/ (base, stub, real) + audit.py + types.py
```

`config.py` builds the `Config` and the provider; `loader.py` handles file I/O.
Both entry points call `RankingService`, so an MCP caller and a CLI caller get
identical validation, resource limits, and audit records.

Design decisions with a real alternative are recorded in
[`docs/adr/`](docs/adr/README.md).

## Known limitations

- The stub provider is lexical (term overlap), not a learned relevance model.
  It exists for offline runs and tests; production relevance needs the real
  provider against a scoring backend.
- The audit chain proves internal consistency, not authorship: a party who
  rewrites the whole ledger produces a valid chain. Detecting that requires
  signing or an external anchor, which is out of scope here (see ADR 0001).
- nDCG treats a query with no relevant judgement as a perfect 1.0, which can
  flatter the aggregate; read nDCG alongside precision (see ADR 0002).
- The MCP server (`serve`) requires the `mcp` package, which is not installed by
  the offline test environment. `build_server` is imported lazily and is not
  exercised by the suite.
- Metrics assume graded judgements are complete enough to trust; unjudged
  documents count as non-relevant (the standard pooled-qrels assumption), which
  can understate quality on shallow pools.

## Status

Delivered: the core algorithm, providers, configuration, the CLI and MCP
entry points, input validation, error handling, structured logging, ADRs, and
the test suite. The MCP server transport imports `mcp` lazily, so everything
else runs and tests without it installed.

---

*Mardale Trading is an illustrative client; this repository is a self-directed reference implementation built to work end to end.*