Skip to content

Commit 19be4ff

Browse files
committed
test: expand coverage to 88% (narrative purge, CR fallback, rate limit, auth); feat: error envelope docs; chore: bump coverage gate to 88%, update status codes
1 parent 3ecef40 commit 19be4ff

10 files changed

Lines changed: 433 additions & 9 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
run: mypy src
4646

4747
- name: Pytest (with coverage gate)
48-
run: pytest -q --maxfail=1 --disable-warnings --cov=neuralcache --cov-report=xml --cov-fail-under=84
48+
run: pytest -q --maxfail=1 --disable-warnings --cov=neuralcache --cov-report=xml --cov-fail-under=88
4949

5050
- name: Upload coverage artifact
5151
uses: actions/upload-artifact@v4

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ All notable changes to this project will be documented in this file. The format
66
### Added
77
- Retention telemetry endpoint `/metrics/retention` exposing sweep counters & timestamps
88
- `mmr_lambda_default` setting (`NEURALCACHE_MMR_LAMBDA_DEFAULT`) and debug field `mmr_lambda_used`
9-
- Expanded test suite (gating modes, CR path fallback, narrative success gate, feedback error envelope, pheromone JSON persistence, similarity utilities, rerank feedback flows, encoder backend fallbacks, SQLite persistence) raising coverage to 84% and CI gate accordingly.
9+
- Expanded test suite (gating modes, CR path fallback, narrative success gate, feedback error envelope, pheromone JSON persistence, similarity utilities, rerank feedback flows, encoder backend fallbacks, SQLite persistence, CR index roundtrip, malformed request envelopes, retention sweeper, pheromone purge JSON path, gating overrides, epsilon override env, narrative resize + skip branches, narrative purge stale path, CR empty candidate fallback, encoder unknown-backend warning path, rate limiting + API token auth envelopes, batch gating debug) raising coverage to 88% and CI gate accordingly.
1010

1111
### Changed
1212
- Migrated startup/shutdown hooks to FastAPI lifespan context (removes deprecation warnings)
1313

1414
### Fixed
15-
- N/A
15+
- Structured validation error handler ensures JSON-serializable envelopes (prevents ValueError detail serialization errors)
1616

1717
### Security
1818
- N/A

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
*Adaptive reranker for Retrieval-Augmented Generation (RAG)*
77

88
[![PyPI](https://img.shields.io/pypi/v/neuralcache.svg)](https://pypi.org/project/neuralcache/)
9-
[![CI](https://github.com/Maverick0351a/neuralcache/actions/workflows/ci.yml/badge.svg)](https://github.com/Maverick0351a/neuralcache/actions/workflows/ci.yml)
109
[![Docker](https://github.com/Maverick0351a/neuralcache/actions/workflows/docker.yml/badge.svg)](https://github.com/Maverick0351a/neuralcache/actions/workflows/docker.yml)
1110
[![CodeQL](https://github.com/Maverick0351a/neuralcache/actions/workflows/codeql.yml/badge.svg)](https://github.com/Maverick0351a/neuralcache/actions/workflows/codeql.yml)
1211
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
1312
[![GitHub stars](https://img.shields.io/github/stars/Maverick0351a/neuralcache?style=social)](https://github.com/Maverick0351a/neuralcache/stargazers)
14-
[![Coverage](https://img.shields.io/badge/coverage-84%25-yellow)](./coverage-policy)
13+
[![Coverage](https://img.shields.io/badge/coverage-88%25-yellow)](./coverage-policy)
1514

1615
NeuralCache is a lightweight reranker for RAG pipelines that *actually remembers what helped*. It blends dense semantic similarity with a narrative memory of past wins and stigmergic pheromones that reward helpful passages while decaying stale ones—then spices in MMR diversity and ε-greedy exploration. The result: more relevant context for your LLM without rebuilding your stack.
1716

@@ -323,7 +322,7 @@ ruff check && mypy && pytest --cov=neuralcache --cov-report=term-missing
323322

324323
- Look for [good first issues](https://github.com/Maverick0351a/neuralcache/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
325324
- Add test coverage for user-visible changes.
326-
- Coverage gate currently enforces >=84%. We'll continue to ratchet this upward as core adaptive components gain additional tests.
325+
- Coverage gate currently enforces >=88%. We'll continue to ratchet this upward as core adaptive components gain additional tests (latest uplift added narrative purge stale, CR empty candidate fallback, encoder unknown-backend warning, rate limiting & API auth envelopes, batch gating debug, plus prior CR persistence, malformed envelopes, retention sweeper, pheromone purge, gating overrides, epsilon override, and narrative resize/skip branches).
327326
- PRs with docs, demos, and eval improvements are extra appreciated.
328327

329328
Optionally, join the discussion in **#neuralcache** on Discord (coming soon—watch this space).
@@ -551,7 +550,7 @@ If NeuralCache saves you time, consider starring the repo or sharing a demo with
551550
552551
### Debug envelope fields
553552
554-
Each `/rerank` response may include a `debug` object (structure stable across patch releases):
553+
Each `/rerank` response may include a `debug` object (structure stable across patch releases). For standardized error envelope format see `docs/ERROR_ENVELOPES.md`.
555554

556555
| Field | Description |
557556
|-------|-------------|

docs/ERROR_ENVELOPES.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# API Error Envelopes
2+
3+
NeuralCache returns all non-2xx responses in a structured JSON envelope so callers can implement
4+
reliable, forward-compatible error handling. The schema is intentionally small:
5+
6+
```jsonc
7+
{
8+
"error": {
9+
"code": "STRING", // Stable machine-readable code
10+
"message": "STRING", // Human readable summary
11+
"detail": { ... } // Optional structured context (may be null)
12+
}
13+
}
14+
```
15+
16+
## Codes
17+
18+
| Code | Typical HTTP | Meaning | Notes |
19+
|------|--------------|---------|-------|
20+
| `BAD_REQUEST` | 400 | Input failed domain validation *after* basic JSON / pydantic parsing | e.g. `top_k` exceeds configured limit |
21+
| `UNAUTHORIZED` | 401 | API token missing/invalid | Only emitted when `NEURALCACHE_API_TOKENS` configured |
22+
| `NOT_FOUND` | 404 | Resource / endpoint disabled | `/metrics` when metrics disabled; feedback docs missing |
23+
| `ENTITY_TOO_LARGE` | 413 | Document text length exceeded configured limit | Uses new `HTTP_413_CONTENT_TOO_LARGE` constant |
24+
| `VALIDATION_ERROR` | 422 | Request failed pydantic model validation | `detail` contains a sanitized list of validation errors |
25+
| `RATE_LIMITED` | 429 | Per-minute request ceiling reached | Driven by `NEURALCACHE_RATE_LIMIT_PER_MINUTE` |
26+
| `INTERNAL_ERROR` | 500 | Unexpected server error | Stack trace logged server-side |
27+
28+
Codes are stable within minor versions. New codes may be added; existing codes will not change
29+
semantic meaning without a major version bump.
30+
31+
## Validation error detail shape
32+
33+
`VALIDATION_ERROR` responses include `detail` as a list of objects derived from FastAPI / pydantic
34+
validation error entries. All nested non-JSON-serializable values are coerced to strings to guarantee
35+
safe serialization (e.g., raw `ValueError` instances).
36+
37+
Example:
38+
39+
```json
40+
{
41+
"error": {
42+
"code": "VALIDATION_ERROR",
43+
"message": "Validation failed",
44+
"detail": [
45+
{"type": "missing", "loc": ["body", "query"], "msg": "Field required"}
46+
]
47+
}
48+
}
49+
```
50+
51+
## Client handling recommendations
52+
53+
1. Inspect `error.code` first; prefer code over status text matching.
54+
2. Treat unknown codes as retriable only if status is >=500.
55+
3. For `VALIDATION_ERROR`, surface a user-friendly list derived from `detail` entries. Avoid depending
56+
on internal keys not documented here.
57+
4. For `RATE_LIMITED`, implement exponential backoff capped by a sane upper bound.
58+
5. For `INTERNAL_ERROR`, consider capturing the original request payload (scrub PII) for diagnostics.
59+
60+
## Versioning
61+
62+
The presence and structure of this envelope is guaranteed within a major version. Additional top-level
63+
fields (e.g., `meta`) may appear in the future; clients should ignore unknown keys.
64+
65+
## Related files
66+
67+
- Implementation: `src/neuralcache/api/server.py` (exception handlers)
68+
- Types: `src/neuralcache/types.py` (`ErrorInfo`, `ErrorResponse`)
69+
- Tests exercising envelopes: `tests/test_api_malformed_payloads.py`, `tests/test_api_schema.py`

src/neuralcache/api/server.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Response, status, Request
1010
from contextlib import asynccontextmanager
1111
from fastapi.responses import JSONResponse
12+
from fastapi.exceptions import RequestValidationError
1213

1314
from ..config import Settings
1415
from ..metrics import latest_metrics, metrics_enabled, observe_rerank, record_feedback
@@ -402,8 +403,8 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
402403
status.HTTP_400_BAD_REQUEST: "BAD_REQUEST",
403404
status.HTTP_401_UNAUTHORIZED: "UNAUTHORIZED",
404405
status.HTTP_404_NOT_FOUND: "NOT_FOUND",
405-
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "ENTITY_TOO_LARGE",
406-
status.HTTP_422_UNPROCESSABLE_ENTITY: "VALIDATION_ERROR",
406+
status.HTTP_413_CONTENT_TOO_LARGE: "ENTITY_TOO_LARGE",
407+
status.HTTP_422_UNPROCESSABLE_CONTENT: "VALIDATION_ERROR",
407408
status.HTTP_429_TOO_MANY_REQUESTS: "RATE_LIMITED",
408409
status.HTTP_500_INTERNAL_SERVER_ERROR: "INTERNAL_ERROR",
409410
}
@@ -412,6 +413,29 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
412413
return JSONResponse(status_code=exc.status_code, content=body.model_dump())
413414

414415

416+
@app.exception_handler(RequestValidationError)
417+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: # type: ignore[override]
418+
def _sanitize(val): # recursively coerce non-JSON-safe objects to strings
419+
if isinstance(val, (str, int, float, type(None))):
420+
return val
421+
if isinstance(val, list):
422+
return [_sanitize(v) for v in val]
423+
if isinstance(val, dict):
424+
return {str(k): _sanitize(v) for k, v in val.items()}
425+
return str(val)
426+
427+
raw_errors = exc.errors()
428+
serializable = [_sanitize(e) for e in raw_errors]
429+
body = ErrorResponse(
430+
error=ErrorInfo(
431+
code="VALIDATION_ERROR", message="Validation failed", detail=serializable
432+
)
433+
)
434+
return JSONResponse(
435+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content=body.model_dump()
436+
)
437+
438+
415439
@app.exception_handler(Exception)
416440
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: # pragma: no cover
417441
body = ErrorResponse(error=ErrorInfo(code="INTERNAL_ERROR", message="Unhandled error", detail=None))

tests/test_additional_coverage.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import os
2+
import time
3+
import json
4+
from pathlib import Path
5+
import numpy as np
6+
import pytest
7+
from fastapi.testclient import TestClient
8+
9+
from neuralcache.api.server import app, reranker, settings
10+
from neuralcache.narrative import NarrativeTracker
11+
from neuralcache.types import Document, RerankRequest
12+
from neuralcache.rerank import Reranker
13+
from neuralcache.config import Settings
14+
15+
16+
def test_narrative_purge_if_stale_json(tmp_path):
17+
"""Purge uses internal _updated_ts, so set that stale and confirm JSON file removal."""
18+
narr = NarrativeTracker(dim=4, alpha=0.5, success_gate=0.0, backend="json", path="n.json", storage_dir=str(tmp_path))
19+
emb = np.ones((4,), dtype=np.float32)
20+
narr.update(emb, success=1.0)
21+
file_path = Path(tmp_path)/"n.json"
22+
assert file_path.exists()
23+
# Make internal timestamp stale
24+
narr._updated_ts = time.time() - 10_000 # type: ignore[attr-defined]
25+
narr.purge_if_stale(retention_seconds=3600)
26+
assert not file_path.exists(), "Narrative JSON should be deleted when stale"
27+
assert np.allclose(narr.v, np.zeros_like(narr.v))
28+
29+
30+
def test_cr_empty_candidates_fallback(monkeypatch):
31+
# Force CR on but monkeypatch hierarchical_candidates to return empty
32+
s = Settings()
33+
s.cr.on = True
34+
rr = Reranker(settings=s)
35+
docs = [Document(id=str(i), text=f"text {i}") for i in range(5)]
36+
q = rr.encode_query("query")
37+
import neuralcache.cr.search as crsearch
38+
monkeypatch.setattr(crsearch, "hierarchical_candidates", lambda **kwargs: [])
39+
scored = rr.score(q, docs, query_text="query")
40+
ids = {d.id for d in scored}
41+
assert ids == {d.id for d in docs}, "Fallback should revert to all documents when CR yields none"
42+
43+
44+
def test_encoder_unknown_backend_warning(caplog):
45+
caplog.set_level("WARNING")
46+
from neuralcache.encoder import create_encoder
47+
enc = create_encoder("unknown-backend-xyz", dim=8)
48+
assert enc.encode("hello").shape[0] == 8
49+
assert any("Unknown embedding backend" in rec.message for rec in caplog.records)
50+
51+
52+
def test_rate_limit_enforced(monkeypatch):
53+
client = TestClient(app)
54+
# Patch settings directly for test isolation
55+
orig = settings.rate_limit_per_minute
56+
settings.rate_limit_per_minute = 2
57+
try:
58+
payload = {"query":"q", "documents":[{"id":"a","text":"A"},{"id":"b","text":"B"}], "top_k":1}
59+
# First two should pass
60+
for _ in range(2):
61+
r = client.post("/rerank", json=payload)
62+
assert r.status_code == 200
63+
# Third should rate limit
64+
r3 = client.post("/rerank", json=payload)
65+
assert r3.status_code == 429
66+
body = r3.json()
67+
assert body["error"]["code"] == "RATE_LIMITED"
68+
finally:
69+
settings.rate_limit_per_minute = orig
70+
71+
72+
def test_api_key_authentication(monkeypatch):
73+
client = TestClient(app)
74+
# Inject an API token
75+
orig_tokens = list(settings.api_tokens)
76+
settings.api_tokens = ["secret123"]
77+
try:
78+
payload = {"query":"q", "documents":[{"id":"a","text":"A"}], "top_k":1}
79+
# Missing token -> 401
80+
r = client.post("/rerank", json=payload)
81+
assert r.status_code == 401
82+
assert r.json()["error"]["code"] == "UNAUTHORIZED"
83+
# Provide token via x-api-key
84+
r2 = client.post("/rerank", json=payload, headers={"x-api-key":"secret123"})
85+
assert r2.status_code == 200
86+
finally:
87+
settings.api_tokens = orig_tokens
88+
89+
90+
def test_batch_rerank_gating_debug():
91+
client = TestClient(app)
92+
# Use small document lists for clarity
93+
batch = [
94+
{"query":"q1","documents":[{"id":"a","text":"A"},{"id":"b","text":"B"}],"top_k":1},
95+
{"query":"q2","documents":[{"id":"c","text":"C"},{"id":"d","text":"D"}],"top_k":1}
96+
]
97+
# Override gating to 'on' via query param use_cr left default
98+
resp = client.post("/rerank/batch?use_cr=false", json=batch)
99+
assert resp.status_code == 200
100+
data = resp.json()
101+
assert isinstance(data, list) and len(data) == 2
102+
# Ensure debug present and has gating keys
103+
for item in data:
104+
debug = item.get("debug")
105+
assert debug is not None
106+
gating_dbg = debug.get("gating")
107+
assert gating_dbg is not None
108+
assert set(["mode","uncertainty","use_gating","candidate_count","effective_candidate_count","total_candidates"]).issubset(gating_dbg.keys())
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import json
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
6+
from neuralcache.api.server import app
7+
8+
client = TestClient(app)
9+
10+
11+
def test_rerank_missing_required_field():
12+
# 'query' is required; omit it
13+
payload = {"documents": [{"id": "a", "text": "hello"}], "top_k": 1}
14+
resp = client.post("/rerank", json=payload)
15+
assert resp.status_code == 422 # validation error from pydantic
16+
body = resp.json()
17+
assert body["error"]["code"] == "VALIDATION_ERROR"
18+
assert isinstance(body["error"]["detail"], list)
19+
20+
21+
def test_rerank_invalid_json():
22+
resp = client.post("/rerank", data="{not json", headers={"Content-Type": "application/json"})
23+
# FastAPI surfaces this as 422 validation
24+
assert resp.status_code == 422
25+
body = resp.json()
26+
assert body["error"]["code"] == "VALIDATION_ERROR"
27+
28+
29+
def test_rerank_top_k_exceeds_limit():
30+
payload = {
31+
"query": "test",
32+
"documents": [{"id": "a", "text": "hi"}],
33+
"top_k": 10_000,
34+
}
35+
resp = client.post("/rerank", json=payload)
36+
assert resp.status_code == 400
37+
body = resp.json()
38+
assert body["error"]["code"] == "BAD_REQUEST"
39+
assert "top_k exceeds" in body["error"]["message"].lower()
40+
41+
42+
def test_rerank_document_too_long():
43+
long_text = "x" * 9000 # exceeds default max_text_length=8192
44+
payload = {"query": "q", "documents": [{"id": "a", "text": long_text}], "top_k": 1}
45+
resp = client.post("/rerank", json=payload)
46+
# The validation of doc length occurs inside handler -> 413 mapped to ENTITY_TOO_LARGE
47+
assert resp.status_code == 413 or resp.status_code == 422
48+
body = resp.json()
49+
# Accept either direct validation or our explicit size check
50+
assert body["error"]["code"] in {"ENTITY_TOO_LARGE", "VALIDATION_ERROR"}
51+
52+
53+
def test_feedback_unknown_ids_structure():
54+
payload = {"query": "q", "selected_ids": ["zzz"], "success": 1.0}
55+
resp = client.post("/feedback", json=payload)
56+
assert resp.status_code == 404
57+
body = resp.json()
58+
assert body["error"]["code"] == "NOT_FOUND"
59+
assert "unknown" in body["error"]["message"].lower()

tests/test_cr_index_roundtrip.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import json
2+
from pathlib import Path
3+
4+
import numpy as np
5+
6+
from neuralcache.cr.index import build_cr_index, save_cr_index, load_cr_index
7+
8+
9+
def test_cr_index_build_save_load_roundtrip(tmp_path: Path):
10+
# small deterministic embedding matrix
11+
rng = np.random.default_rng(0)
12+
embeddings = rng.normal(size=(20, 32)).astype(np.float32)
13+
14+
idx = build_cr_index(embeddings, d1=16, d2=8, k2=5, k1_per_bucket=4, seed=123)
15+
npz_path = tmp_path / "cr_index.npz"
16+
meta_path = tmp_path / "cr_index.meta.json"
17+
save_cr_index(idx, str(npz_path), str(meta_path))
18+
19+
# Basic meta file sanity
20+
meta = json.loads(meta_path.read_text())
21+
assert meta["doc_count"] == 20
22+
assert meta["d0"] == 32
23+
24+
loaded = load_cr_index(str(npz_path), str(meta_path))
25+
# Compare core structural pieces
26+
assert loaded.meta.doc_count == idx.meta.doc_count
27+
assert loaded.coarse_centroids.shape == idx.coarse_centroids.shape
28+
assert len(loaded.coarse_buckets) == len(idx.coarse_buckets)
29+
# Ensure at least one bucket non-empty
30+
assert any(len(b) > 0 for b in loaded.coarse_buckets)
31+
# Dimension of projections should not exceed original dimensionality
32+
assert loaded.proj1_components.shape[1] <= embeddings.shape[1]
33+
assert loaded.proj2_components.shape[1] <= loaded.proj1_components.shape[1]

0 commit comments

Comments
 (0)