docs(evals): /search rerank is deterministic across restart — root-cause SME #117 reorder - #203
Conversation
…use the SME #117 reorder SME #117 found a daemon restart reordered 49% of /search top-5 on identical-looking data, +32% context. A deterministic reranker reloading the same model shouldn't reorder. This probe answers it. Finding: the rerank stage IS deterministic across restart (proven, code + empirical). FlashRank's ONNX pairwise path is a pure function of (query, passage, weights); the final sort is stable Timsort; rerank_hits reconstructs by original index with no dict-order/float-tie dependence. The probe confirms: same input reranked 5× in-process AND across two fresh process loads (restart sim) → byte-identical order + scores (<1e-9). So the #117 reorder was NOT rerank nondeterminism — it was a candidate-set change upstream: the 2026-05-29 DB rebackfill re-merged checkpoint drawers into the searchable collection (862 confirmed on the live palace), changing the vector ANN neighbours fed to the (deterministic) reranker. Ruled out: #202 (age-fused path, not used by plain /search), the kind filter (both runs used kind=all), and any haystack change (lme drawers filed 05-25). A one-time data migration, NOT an ongoing regression. Latent risk flagged (not changed here — operator call): flashrank is pinned >=0.2.10 (a floor) and PALACE_RERANK_MODEL is unset; a fresh deploy could pull a newer flashrank with different weights and reorder for real. Recommend an exact pin + explicit model env in the systemd unit. - docs/evals/2026-05-30-retrieval-determinism.md — full finding - tests/test_rerank.py::TestRerankDeterminism — pins the guarantee - scripts/evals/rerank_determinism_probe.py — standalone reproducer All read-only against prod (one /search count query + process metadata); no prod restart, no writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request investigates and resolves concerns regarding non-deterministic search results following daemon restarts. By conducting a thorough empirical and code-level analysis, it confirms that the reranking pipeline is deterministic and attributes previous anomalies to upstream data changes. The changes are strictly additive, providing documentation and automated verification tools to prevent and diagnose future regressions. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a detailed analysis document, a standalone probe script, and unit tests to verify and guarantee the determinism of the FlashRank rerank stage across daemon restarts. The feedback focuses on improving the robustness of the probe script, specifically by making the subprocess stdout parsing more resilient to diagnostic logs or warnings, and ensuring that the PALACE_RERANK_MAX_LENGTH environment variable is read consistently across both the main and worker process configurations.
| def _fresh_process_run() -> list[dict]: | ||
| """Re-exec this script in --worker mode and parse its reranked output.""" | ||
| proc = subprocess.run( | ||
| [sys.executable, str(Path(__file__).resolve()), "--worker"], | ||
| capture_output=True, text=True, timeout=300, | ||
| ) | ||
| if proc.returncode != 0: | ||
| raise RuntimeError(f"worker failed rc={proc.returncode}: {proc.stderr[-500:]}") | ||
| return json.loads(proc.stdout.strip().splitlines()[-1]) |
There was a problem hiding this comment.
Parsing the last line of stdout directly using splitlines()[-1] is fragile. If the subprocess prints any warnings, deprecation notices, or diagnostic messages (which ONNX Runtime and ML libraries frequently do to stdout), or if stdout is empty, this will raise an IndexError or JSONDecodeError.
Instead, iterate through the lines in reverse order to find the line containing the JSON array (which starts with [ and ends with ]).
| def _fresh_process_run() -> list[dict]: | |
| """Re-exec this script in --worker mode and parse its reranked output.""" | |
| proc = subprocess.run( | |
| [sys.executable, str(Path(__file__).resolve()), "--worker"], | |
| capture_output=True, text=True, timeout=300, | |
| ) | |
| if proc.returncode != 0: | |
| raise RuntimeError(f"worker failed rc={proc.returncode}: {proc.stderr[-500:]}") | |
| return json.loads(proc.stdout.strip().splitlines()[-1]) | |
| def _fresh_process_run() -> list[dict]: | |
| """Re-exec this script in --worker mode and parse its reranked output.""" | |
| proc = subprocess.run( | |
| [sys.executable, str(Path(__file__).resolve()), "--worker"], | |
| capture_output=True, text=True, timeout=300, | |
| ) | |
| if proc.returncode != 0: | |
| raise RuntimeError(f"worker failed rc={proc.returncode}: {proc.stderr[-500:]}") | |
| for line in reversed(proc.stdout.splitlines()): | |
| cleaned = line.strip() | |
| if cleaned.startswith("[") and cleaned.endswith("]"): | |
| return json.loads(cleaned) | |
| raise RuntimeError(f"No JSON array found in worker stdout. stdout: {proc.stdout[-500:]}") |
| ranker = Ranker( | ||
| model_name=os.getenv("PALACE_RERANK_MODEL", "ms-marco-TinyBERT-L-2-v2"), | ||
| max_length=512, | ||
| ) |
There was a problem hiding this comment.
The max_length parameter is hardcoded to 512 here, but in _rerank_once() (line 68) it is dynamically read from the PALACE_RERANK_MAX_LENGTH environment variable. If an operator runs this probe with a custom PALACE_RERANK_MAX_LENGTH set, the in-process repeat check and the fresh-process reload check will run with different configurations, potentially leading to inconsistent results or false failures.
We should read the environment variable consistently in both places.
| ranker = Ranker( | |
| model_name=os.getenv("PALACE_RERANK_MODEL", "ms-marco-TinyBERT-L-2-v2"), | |
| max_length=512, | |
| ) | |
| ranker = Ranker( | |
| model_name=os.getenv("PALACE_RERANK_MODEL", "ms-marco-TinyBERT-L-2-v2"), | |
| max_length=int(os.getenv("PALACE_RERANK_MAX_LENGTH", "512")), | |
| ) |
…eployed ranking (#204) The determinism probe (#203) proved the /search rerank stage is deterministic for a FIXED model, but flagged a latent risk: requirements.txt pinned `flashrank>=0.2.10` (a floor) and PALACE_RERANK_MODEL was unset, so a fresh deploy could pull a newer flashrank with different bundled ONNX weights / tokenizer — which WOULD silently reorder /search top-5 across that deploy. Freeze the model: - requirements.txt: flashrank>=0.2.10 → flashrank==0.2.10 (the currently deployed version on familiar — no behavior change, just frozen). - palace-daemon.service: add Environment=PALACE_RERANK_MODEL=ms-marco-TinyBERT-L-2-v2 (the code default, now explicit so deployed ranking is byte-stable). - tests/test_rerank.py::TestRerankPinHardening: assert the exact pin + the systemd model env stay in place, so a future floor-pin regression fails CI. No production code touched; deployed behavior unchanged (pins to what's already running). See docs/evals/2026-05-30-retrieval-determinism.md. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
SME's deployed-E2E ladder (
multipass-structural-memory-eval#117) found that re-querying the samelme_*wings viaGET /searchbefore vs after the 2026-05-30 daemon restart reordered 49% of top-5 results on identical-looking data (+32% context). A deterministic reranker reloading the same model shouldn't reorder — this probe nails the mechanism.Finding: the rerank stage IS deterministic across restart
Code (
rerank.py,flashrank/Ranker.py): FlashRank's ONNX pairwise path is a pure function of(query, passage, weights)— no sampling/dropout/RNG at inference; the final ordering is Python's stable Timsort;rerank_hitsreconstructs by original index with no dict-iteration or float-tie dependence.Empirical (
scripts/evals/rerank_determinism_probe.py, pinned astests/test_rerank.py::TestRerankDeterminism):Root cause of the #117 reorder: a candidate-set change, not the reranker
The restart picked up #199 + #202, but neither caused the plain-
/searchreorder: #202 only touches the/search/age-fusedhandler (not used by plain/search), and #199'skindfilter was inert because both runs usedkind=all.The real driver is the data event #199's commit message documents: the 2026-05-29 DB rebackfill re-merged checkpoint drawers into
mempalace_drawers(862 confirmed on the live palace). Adding ~862 vectors to the searchable collection changes the ANN neighbours returned for a fixed query at a fixedlimit, which feeds the deterministic reranker a different candidate pool → a different reordered top-5. That's the #117 signature exactly.Ruled out (each checked): rerank nondeterminism (proven deterministic), #202 (wrong code path), the
kindfilter (kind=all), and any change to thelme_*haystack (filed 2026-05-25, fully populated). → A one-time data migration, not an ongoing regression.Is deployed retrieval deterministic across restarts? Yes, conditionally
For a fixed collection + fixed reranker model, deployed
/searchreproduces the same top-5 across restarts. The #117 reorder was the one-time rebackfill.Latent risk flagged (operator call, not changed here):
requirements.txtpinsflashrank>=0.2.10(a floor) andPALACE_RERANK_MODELis unset. A fresh deploy could pull a newer flashrank with different bundled weights/tokenizer — which would reorder for real. Recommend an exact pin + explicitPALACE_RERANK_MODELin the systemdEnvironment=.Files
docs/evals/2026-05-30-retrieval-determinism.md— full finding + timelinetests/test_rerank.py::TestRerankDeterminism— pins the guarantee (2 tests; skip if flashrank absent)scripts/evals/rerank_determinism_probe.py— standalone reproducerTests
tests/test_rerank.py17/17 pass (incl. the 2 new determinism tests);test_rerank* + test_search_rerank_endpoint26/26 pass. Additive only — no production code touched. All probing was read-only against prod (one/searchcount query + process metadata); no prod restart, no writes.🤖 Generated with Claude Code