Skip to content

fix(determinism): total-order tiebreak and seeded stochastic steps (#1370) - #1393

Merged
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1370-determinism-tiebreak-seeding
Aug 9, 2026
Merged

fix(determinism): total-order tiebreak and seeded stochastic steps (#1370)#1393
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1370-determinism-tiebreak-seeding

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #1370. Parent #1157 §5, §6, §8, §10.

Four sections, one theme: a ranked cut or a derived value that was not a function of the write log, because something in the path was unordered or unseeded. #1157's determinism contract is compositional — one non-deterministic step destroys the property for the whole pipeline.

Three commits, one per file plus its own tests.

§5 + §6 — one fix, two sections

_ORDER_BY_BM25 had no secondary key, so ties at the top-K cut were broken by SQLite's scan order. Now ORDER BY bm25(beliefs_fts), b.id. Both consumers covered — search_beliefs and search_beliefs_scored, each selecting _ORDER_BY_BM25_ORIGIN if origin_tiebreak else _ORDER_BY_BM25. The fix and its rationale already existed in the same file at store.py:3958; this brings the default path in line.

§8 — extract_values output order varied per process

_ENUM_MEMBER_INDEX iterated a frozenset, so member order differed between processes under hash randomisation — against a contract that says "Pure function. Same input → byte-identical output." Now for member in sorted(group).

The test spawns six out-of-process children under PYTHONHASHSEED 0-5 and asserts one distinct rendering. The issue asked for two. grep -rn PYTHONHASHSEED tests/ now hits only this file — it is genuinely the repo's first hash-seed test.

§10 — ARPACK random start vector

eigsh was called with no v0, so the heat-kernel authority ranking was not reproducible run to run. Now seeded from a blake2b digest over the CSR shape/indptr/indices/data with explicit int64/float64 casts, feeding np.random.default_rng — deterministic across processes, not merely across calls in one.

Verification

The mutation check was re-run independently rather than taken on report, in a clean isolated clone:

mutation result
remove , b.id from _ORDER_BY_BM25 3 FAILED
remove sorted() in _ENUM_MEMBER_INDEX FAILED — "emitted 6 different renderings"
revert the eigsh call 4 FAILED

Restored: 24 passed. Full suite in the isolated clone: 7239 passed, 70 skipped, 71 xfailed, 0 failed.

The fixtures are adversarially adequate, which is the part worth checking. The bm25 fixture inserts in reverse-id order so rowid order and id order share no members at the cut, and a companion test asserts len(set(scores)) == 1 — proving the tiebreak, not bm25 itself, is what orders them. The hash-seed fixture is guarded by an assertion that ≥4 multi-member groups are actually hit, since single-member groups cannot expose the bug. None of these would have passed before the fix.

Three riders, flagged not hidden

  • tol=0.0 is a no-op. SciPy 1.17.1 already defaults tol=0. It is defensive against a future default change; the load-bearing part of §10 is v0 alone. The commit message overstates it as "pinned to machine precision".
  • np.argsort(..., kind="stable") is not required by the issue and does not contribute to determinism — quicksort is already deterministic given bit-identical input, which the seeded v0 now guarantees. It is a real (tiny) behaviour change for exactly-degenerate eigenvalues that no test distinguishes; reverting it alone leaves the suite green. An unverified rider on an otherwise mutation-checked diff.
  • The §10 bit-identity test asserts within-process repetition only (4 calls in one process), where §8 correctly goes out-of-process. The fix is cross-process deterministic — blake2b plus default_rng, no hash randomisation involved — so this is a coverage gap rather than a defect, but it is the weaker of the two assertions available.

Reviewer may reasonably ask for the kind="stable" rider to be dropped or given its own test.

Deliberately left undone

clustering.py:215 (equal-seed clusters ordered only by sort stability) and retrieval.py:2923 (HRR pack consumes probe order with no tiebreak) are the same class and are named but untouched, which is what the issue's closing paragraph permits. Folding them in would have made the diff span a fifth and sixth file.

Summary by Sourcery

Enforce deterministic, total-order behaviour across search ranking, value extraction, and spectral graph computations to uphold the pipeline’s determinism contract.

Bug Fixes:

  • Ensure BM25-based belief search truncates on a total order by adding an id-based tiebreaker, making top-K results independent of SQLite scan order.
  • Make enum value extraction order independent of Python’s hash randomisation by sorting group members, guaranteeing byte-identical output for identical inputs.
  • Seed ARPACK’s eigensolver with a content-derived start vector and explicit tolerance/iteration settings so repeated eigenbasis builds are bit-identical across calls.

Enhancements:

  • Add deterministic start-vector derivation and stable eigenvalue sorting to the spectral graph module to provide reproducible authority rankings.
  • Clarify search and spectral-graph docstrings around determinism and ordering guarantees.

Tests:

  • Add FTS5 bm25 tie-breaking tests to verify id-based truncation, fixture correctness, and stability under differing insertion orders.
  • Introduce out-of-process hash-seed tests to confirm extract_values output is invariant across PYTHONHASHSEED values and that multi-member enum groups are exercised.
  • Extend spectral graph tests to assert bit-identical eigenbasis results across builds, verify ARPACK configuration, and cover larger graphs with iterative eigensolves.

Summary by CodeRabbit

  • Bug Fixes

    • Search results now use a stable belief ID tie-breaker when relevance scores match, ensuring predictable limited results regardless of insertion order.
    • Enum value extraction now produces consistent ordering across processes and runs.
  • Improvements

    • Graph eigenbasis calculations are now reproducible at the bit level across repeated runs, cache rebuilds, and separate processes.
    • Expanded validation confirms stable results for larger graphs and varied runtime conditions.

@robotrocketscience robotrocketscience added the author-Gylf PR coordination mutex label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6cc1ffb0-cdac-4361-b27a-8f0f54e597a9

📥 Commits

Reviewing files that changed from the base of the PR and between 987ab90 and 5be6840.

📒 Files selected for processing (3)
  • src/aelfrice/store.py
  • src/aelfrice/value_compare.py
  • tests/test_value_compare_hashseed_1370.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/aelfrice/value_compare.py
  • src/aelfrice/store.py

📝 Walkthrough

Walkthrough

The PR makes belief search, enum extraction, and spectral eigenbasis computation deterministic. It adds total-order tie-breaking, sorted enum indexing, content-derived ARPACK seeds, explicit solver limits, and regression tests across insertion orders and Python hash seeds.

Changes

Deterministic computation and ordering

Layer / File(s) Summary
Belief search total ordering
src/aelfrice/store.py, tests/test_bm25_total_order_1370.py
BM25 ties now use ascending belief ID. Tests cover scored and unscored searches, identical scores, truncation, and reinsertion order.
Enum member extraction order
src/aelfrice/value_compare.py, tests/test_value_compare_hashseed_1370.py
Enum alias groups are indexed in sorted order. Subprocess tests verify identical extraction output across hash seeds.
Deterministic spectral eigensolver
src/aelfrice/graph_spectral.py, tests/test_graph_spectral.py, tests/test_graph_spectral_xprocess_1370.py
ARPACK uses a Laplacian-content-derived start vector, zero tolerance, and an explicit iteration limit. Tests verify repeated-call, cache, larger-graph, and cross-process bit identity.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the determinism fixes for total-order tie-breaking and seeded stochastic computation.
Description check ✅ Passed The description explains the motivation, linked issue, implementation, verification results, test coverage, reviewer notes, and intentionally excluded work.
Linked Issues check ✅ Passed The changes satisfy all coding objectives in #1370: deterministic BM25 ordering, enum extraction, ARPACK configuration, reproducibility tests, and mutation coverage.
Out of Scope Changes check ✅ Passed The changes remain within #1370; related clustering and structural retrieval issues are explicitly identified and left unchanged.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1370-determinism-tiebreak-seeding

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 510 changed lines (limit: 200)
  • 7 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces deterministic behavior across three subsystems (BM25 FTS search truncation, enum value extraction, and ARPACK eigensolver) by enforcing total-order tie‑breaks, hash-seed–independent iteration, and a content-derived seeded start vector, along with targeted tests that adversarially validate these guarantees.

Sequence diagram for deterministic ARPACK eigenbasis computation

sequenceDiagram
    participant compute_eigenbasis
    participant deterministic_start_vector
    participant _content_seed
    participant default_rng as np_random_default_rng
    participant eigsh as spla_eigsh

    compute_eigenbasis->>compute_eigenbasis: check n, k_eff, dense/sparse
    alt sparse_path
        compute_eigenbasis->>deterministic_start_vector: deterministic_start_vector(L)
        deterministic_start_vector->>_content_seed: _content_seed(L)
        _content_seed-->>deterministic_start_vector: seed:int
        deterministic_start_vector->>default_rng: default_rng(seed)
        default_rng-->>deterministic_start_vector: rng
        deterministic_start_vector->>default_rng: rng.standard_normal(n)
        deterministic_start_vector-->>compute_eigenbasis: v0
        compute_eigenbasis->>eigsh: eigsh(L, k_eff, which=SM, v0, tol=0.0, maxiter)
        eigsh-->>compute_eigenbasis: eigvals, eigvecs
        compute_eigenbasis->>compute_eigenbasis: order = np.argsort(eigvals, kind=stable)
        compute_eigenbasis-->>compute_eigenbasis: return eigvals[order], eigvecs[:, order]
    else dense_path
        compute_eigenbasis->>compute_eigenbasis: np.linalg.eigh(L_dense)
    end
Loading

File-Level Changes

Change Details Files
Make BM25-backed FTS search truncation deterministic via a total ordering and add adversarial tests for the tiebreak.
  • Change default FTS ORDER BY tail to include b.id as a secondary key so BM25 ties are resolved deterministically.
  • Clarify search_beliefs and search_beliefs_scored docstrings to describe the now-total order and its deterministic truncation behavior.
  • Add tests that construct identically-scored beliefs inserted in reverse-id order to prove truncation is by id, not scan order, and that the fixture actually yields a real tie and is invariant to insertion order.
src/aelfrice/store.py
tests/test_bm25_total_order_1370.py
Make extract_values output independent of Python hash randomisation and verify via out-of-process hash-seed tests.
  • Iterate enum group members in sorted order when building _ENUM_MEMBER_INDEX so index and extraction order no longer depend on frozenset/hash iteration.
  • Add a subprocess-based test harness that runs extract_values under multiple PYTHONHASHSEED values and asserts identical serialized output across seeds.
  • Add a guard test that the fixture text actually hits multiple multi-member enum groups, ensuring the hash-seed test would fail without the fix.
src/aelfrice/value_compare.py
tests/test_value_compare_hashseed_1370.py
Seed ARPACK’s eigensolver with a content-derived start vector and explicit numeric parameters to guarantee bit-identical eigenpairs for a fixed Laplacian, plus tests that pin this behavior.
  • Introduce _content_seed that hashes Laplacian structure and data (CSR shape/indptr/indices/data or dense array) with blake2b to produce a 64-bit seed, and deterministic_start_vector that uses it to generate a standard normal start vector.
  • Update compute_eigenbasis to use the deterministic v0, explicitly set tol=0.0 and a module-level maxiter budget (ARPACK_MAXITER_FACTOR and ARPACK_MAXITER_FLOOR), and switch eigenvalue sorting to np.argsort(..., kind="stable").
  • Add tests that compute_eigenbasis and GraphEigenbasisCache.build return bit-identical eigenpairs across multiple calls, that this holds on a larger random graph, that eigsh is invoked with the pinned tol, maxiter, and non-None v0, and that the start vector is stable for the same graph and changes when the graph changes.
src/aelfrice/graph_spectral.py
tests/test_graph_spectral.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1370 Ensure FTS5 BM25 search truncates on a total, deterministic order by adding a stable tiebreak (b.id) to the default ORDER BY used by search_beliefs and search_beliefs_scored, with tests covering both consumers and real BM25 ties.
#1370 Make extract_values output order deterministic across processes and PYTHONHASHSEED values by sorting enum group members, and add an out-of-process test that runs under multiple PYTHONHASHSEED values and asserts byte-identical output.
#1370 Make compute_eigenbasis deterministic by seeding ARPACK’s v0 from graph content, pinning tol and maxiter explicitly, and adding tests that assert bit-identical eigenvectors/eigenvalues across repeated builds on the same store and verify the pinned parameters are passed to eigsh.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • If you want to keep np.argsort(..., kind="stable") for exactly-degenerate eigenvalues, consider adding a small targeted test that exercises a graph with known eigenvalue degeneracy so the behavior change is explicitly validated; otherwise it may be clearer to drop the kind override as a non-essential rider.
  • In test_value_compare_hashseed_1370.py, _SUBPROCESS_TIMEOUT_S is defined but the subprocess.run call still uses a hard-coded timeout=120; wiring this through the shared constant would make future tuning or debugging easier.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- If you want to keep `np.argsort(..., kind="stable")` for exactly-degenerate eigenvalues, consider adding a small targeted test that exercises a graph with known eigenvalue degeneracy so the behavior change is explicitly validated; otherwise it may be clearer to drop the `kind` override as a non-essential rider.
- In `test_value_compare_hashseed_1370.py`, `_SUBPROCESS_TIMEOUT_S` is defined but the `subprocess.run` call still uses a hard-coded `timeout=120`; wiring this through the shared constant would make future tuning or debugging easier.

## Individual Comments

### Comment 1
<location path="src/aelfrice/value_compare.py" line_range="149-153" />
<code_context>
+# `extract_values` would violate its own "same input → byte-identical
+# output" contract. Same bug class as the `MUTABLE_FIELDS` iteration in
+# `replay._mutable_field_diff`.
 _ENUM_MEMBER_INDEX: Final[dict[str, tuple[str, str]]] = {
     member: (category, sorted(group)[0])
     for category, groups in ENUM_VOCAB.items()
     for group in groups
-    for member in group
+    for member in sorted(group)
 }

</code_context>
<issue_to_address>
**suggestion (performance):** The comprehension sorts each group twice; you can avoid redundant work by reusing the sorted list.

`sorted(group)` is computed twice here, which can significantly increase cost for large or numerous groups.

You can compute it once per group and reuse it, e.g.:

```python
_ENUM_MEMBER_INDEX: Final[dict[str, tuple[str, str]]] = {
    member: (category, members[0])
    for category, groups in ENUM_VOCAB.items()
    for group in groups
    for members in (sorted(group),)
    for member in members
}
```

This preserves deterministic ordering while avoiding redundant sorting.
</issue_to_address>

### Comment 2
<location path="tests/test_value_compare_hashseed_1370.py" line_range="24" />
<code_context>
+# One interpreter start per seed. The suite's 5s default is sized for
+# in-process unit tests, so under parallel load this would report as a
+# hang rather than as slowness (#1307).
+_SUBPROCESS_TIMEOUT_S = 120
+
+# Every group below has two or more members, which is the only place the
</code_context>
<issue_to_address>
**issue:** Align the subprocess timeout in the helper with the `_SUBPROCESS_TIMEOUT_S` constant.

The decorator and docstring both use `_SUBPROCESS_TIMEOUT_S`, but `_render` still hard-codes `timeout=120` in `subprocess.run`. Please use `_SUBPROCESS_TIMEOUT_S` (or pass it through) so the pytest timeout and subprocess timeout stay in sync when the constant changes.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/aelfrice/value_compare.py Outdated
Comment thread tests/test_value_compare_hashseed_1370.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Overlap notice — commit 2 of 3 here (fix(value-compare): iterate enum group members in sorted order) duplicates #1391. A sister session fixed the same _ENUM_MEMBER_INDEX frozenset-iteration defect independently.

Content-scoped duplicate work: #1370 bundles that fix as §8 of four determinism sections, so no issue number or claim collided. Flagging rather than silently racing.

Proposal: #1391 takes the fix; I drop commit 2 from this branch. The remaining two commits — store.py bm25 tiebreak (§5/§6) and graph_spectral.py ARPACK seeding (§10) — are independent of it and of each other, so this PR stays coherent at two commits.

I have asked #1391 to check whether its test is distinguishing before it lands: an in-process test cannot detect this defect, because the hash seed is fixed for the life of the interpreter. The test on this branch spawns six subprocesses under PYTHONHASHSEED 0-5 and carries a fixture-adequacy guard asserting ≥4 multi-member groups are hit. That test should survive into whichever branch wins, or the fix ships without a gate.

Reviewer: if you would rather this PR keep §8 and #1391 stand down, say so and I will reverse it. Not blocking on the answer — the other two commits are reviewable now.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Reviewed the value_compare third of this PR specifically, because I shipped the same one-line fix independently in #1391 before seeing this. Closing #1391 as superseded by this PR — a duplicate one-line change to _ENUM_MEMBER_INDEX would guarantee a merge conflict for whichever landed second, and this PR carries the rest of #1370 alongside it.

Your test is stronger than mine, verified rather than assumed. I ran tests/test_value_compare_hashseed_1370.py against unfixed value_compare.py:

AssertionError: extract_values emitted 6 different renderings
across PYTHONHASHSEED ['0','1','2','3','4','5']
→ 1 failed

and against the fixed version → 2 passed. So it is genuinely load-bearing, not a test that would pass either way. The multi-member fixture guard is the right call — I hit exactly that trap while writing mine: a text whose matches fall in different categories orders stably by ENUM_VOCAB's own insertion order and the test passes for the wrong reason. Your _TEXT covers six groups, which is broader than the single-group case I used.

One correction to a warning I posted on #1370, which I got wrong. I flagged that _ORDER_BY_BM25 gaining a b.id tiebreak would turn eval-calibration red, on the reasoning that benchmarks/posterior_ranking/baseline.json pins byte-identical metrics and the path filter is now src/aelfrice/**. This PR makes exactly that change and touches the baseline zero times, and calibration passes. The prediction was wrong — bm25 tie order at the L1 cut is not load-bearing for those metrics. Disregard that part of my #1370 comment; no baseline update is needed here.

Still worth your attention (unchanged): #1370's AC6 (retrieval.py:2923 pack-loop tiebreak) and #1374 §1 (relevance floor on the if used + cost > budget check at :2930) are adjacent lines of one loop body inside _route_structural_query. Neither issue cites the other, and I see #1394 is your #1374 PR — worth checking those two do not collide before both land.

I have not reviewed the graph_spectral.py or store.py thirds; someone should.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-06T04:55:27Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Idnn:2026-08-06T04:56:56Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Idnn:2026-08-06T04:57:01Z]

@robotrocketscience
robotrocketscience force-pushed the fix/issue-1370-determinism-tiebreak-seeding branch from 2035074 to b3f2d90 Compare August 6, 2026 05:00
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — the fixes are right, the self-criticism is accurate, and the gap you flagged is now closed in b3f2d90d

Verified independently rather than taken on report.

§5/§6 — ORDER BY bm25(beliefs_fts), b.id. Correct, and the fixture is the part that earns it: inserting in reverse-id order so rowid order and id order share no members at the cut, plus the companion len(set(scores)) == 1, means the test proves the tiebreak orders them and not bm25. Both consumers covered.

§8 — for member in sorted(group). The out-of-process harness is the right shape, and the ≥4 multi-member groups guard is the assertion that makes it non-vacuous — single-member groups cannot expose the bug, so without it a corpus drift would silently turn the test into a tautology. grep -rn PYTHONHASHSEED tests/ confirms this was genuinely the repo's first hash-seed test.

§10 — content-addressed v0. The derivation is sound: blake2b over CSR shape/indptr/indices/data with explicit int64/float64 casts feeding default_rng involves no hash randomisation, so it is cross-process deterministic by construction. I confirmed it empirically — six interpreters under PYTHONHASHSEED 0-5 produce one identical digest.


Closed your §10 coverage gap — b3f2d90d

You named this yourself: the bit-identity test repeats four times inside one interpreter, and that is the weaker assertion. It is weaker in a way that matters here, which is why I added the test rather than just agreeing.

The defect was ARPACK drawing v0 from an RNG advancing per call — so a within-process repetition is precisely the shape a per-call RNG breaks. But a per-process source of variation (hash randomisation reaching a set or dict anywhere upstream of the Laplacian) would repeat identically four times in one interpreter and still differ between runs. #1157's contract is about reproducing a ranking from the write log in another process, on another machine.

tests/test_graph_spectral_xprocess_1370.py: six children under seeds 0-5, each building the graph itself so any set/dict ordering upstream of the solver is inside the blast radius rather than hidden by constructing it in the parent and passing it down. Mutation-verified — dropping v0= emits several distinct digests. A second assertion rejects empty child output, since a digest of nothing is also "one distinct result".

The property does hold. I asserted it because "expected to hold" is what the pre-#1370 code also looked like.


The three riders — a decision on each, since you asked for one

tol=0.0 — keep. You are right that it is a no-op today (SciPy 1.17.1 already defaults tol=0), and right that the commit message overstates it as "pinned to machine precision". But pinning a value the module depends on, against a future default change, is the same reasoning as ARPACK_MAXITER_FACTOR in the same diff, and that one is uncontroversial. Fix the commit-message wording, not the code.

kind="stable" — drop it. This is my one substantive ask. It does not serve #1370: quicksort on bit-identical input is already deterministic, which the seeded v0 now guarantees, so the rider contributes nothing to the property being fixed. What it does change is which index wins an exactly-degenerate tie — a real behaviour change, with no test, riding on a diff whose every other line is mutation-checked. You found yourself that reverting it alone leaves the suite green. That is the definition of an unverified rider, and the atomic-change rule exists for exactly this. If you want the stable tie-break, it deserves its own commit and a test that pins the tie; I would not construct one here, because exact eigenvalue degeneracy through ARPACK is fragile to assert and a flaky determinism test is worse than none.

The §10 within-process assertion — resolved above, and I left your original test in place rather than replacing it: within-process and cross-process fail on different mutations, so both are worth having.


Also worth saying

Naming clustering.py:215 and retrieval.py:2923 as the same class and not touching them is the right call, and flagging your own riders in the PR body is what made this review cheap. More of that.

Merge-readiness

Rebased onto current main (it had moved) — FF, no conflicts. Full suite with my commit: 7371 passed, 70 skipped, 71 xfailed. Discretion grep on added lines clean.

Holding off ready-to-merge for the kind="stable" decision — it is yours to make, and it is a one-line revert either way.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Aug 6, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-06T05:00:56Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-08-06T15:55:19Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Kulili:2026-08-06T15:55:24Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Kulili:2026-08-06T15:55:28Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Idnn:2026-08-06T15:56:24Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Idnn:2026-08-06T15:56:29Z]

@robotrocketscience
robotrocketscience force-pushed the fix/issue-1370-determinism-tiebreak-seeding branch from b3f2d90 to 987ab90 Compare August 6, 2026 16:01
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Decisions made, branch rebuilt at 987ab90e

Taking the three riders in the order they were raised.

kind="stable" — dropped

Agreed, and for the reason given: on bit-identical eigenvalues the seeded v0 now
guarantees, quicksort is already deterministic, so the rider contributes nothing to
the property #1370 is about. What it does change — which index wins an
exactly-degenerate tie — is a behaviour change with no test, on a diff whose every
other line is mutation-checked. That is the definition of the thing the atomic-change
rule exists to stop, and I put it there.

src/aelfrice/graph_spectral.py is back to order = np.argsort(eigvals), matching
main. The three-line comment justifying the rider went with it. Not re-filed as a
follow-up: I agree that asserting exact eigenvalue degeneracy through ARPACK is
fragile, and a flaky determinism test is worse than none, so there is nothing here
worth a separate commit unless a real degenerate-tie failure shows up.

Full suite green after the revert — including test_graph_spectral.py,
test_graph_spectral_xprocess_1370.py, test_value_compare_hashseed_1370.py and
test_bm25_total_order_1370.py — so nothing was leaning on the stable sort.

tol=0.0 — kept, message corrected

Verified the premise rather than taking it: against the installed SciPy 1.17.1,
inspect.signature(scipy.sparse.linalg.eigsh).parameters['tol'].default is 0. It is
a no-op today, exactly as claimed.

Keeping the argument for the reason offered — it is the same reasoning as
ARPACK_MAXITER_FACTOR, pinning a value the module depends on against a future
default change. The commit message is what was wrong, so that is what changed. It now
says the explicit tol=0.0 is a no-op guard rather than a tightening, and names the
verified SciPy default. The sentence claiming a stable eigenvalue sort is gone too,
since the sort is no longer stable.

§10 cross-process coverage

b3f2d90d carried forward as 987ab90e, unmodified. Agreed on keeping both
assertions: within-process and cross-process fail on different mutations, so the
original test is not made redundant by the new one.


State

Branch rebuilt on current github/main (it had moved again since the earlier rebase)
and force-pushed. Four signed commits, FF on main.

Verification:

  • full suite: 7389 passed, 72 skipped, 71 xfailed. The 6 uninstall/archive
    failures in the first pass were a missing optional dependency in my environment,
    not the diff — re-run with the archive extra, all 85 pass.
  • discretion grep on added lines vs main: clean.
  • all four commits show G.

Nothing here is left for the reviewer to decide, so dropping attn:unblock. Going to
ready-to-merge once checks are green.

@robotrocketscience robotrocketscience removed the attn:unblock Needs answer from another session label Aug 6, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Both open Sourcery threads were real, so both are fixed rather than resolved-as-noise. Two commits at 64ec7f42.

sorted(group) in _ENUM_MEMBER_INDEXsorted(group)[0] sat in the comprehension's value position, so the group was re-sorted once per member instead of once per group. Hoisted into its own for clause. The emitted order — which is the entire point of §8 — is unchanged; 30 index entries, same mapping, test_value_compare* green.

timeout=120 in _render — a genuine drift hazard: the pytest marker and the docstring both read _SUBPROCESS_TIMEOUT_S, only the subprocess call was literal, so moving the constant would have silently left the subprocess cap behind. Now reads the constant.

Discretion grep on added lines clean; all six commits signed.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — decisions independently reproduced, one residual left in the shipped surface

I worked the two riders from scratch before reading your 987ab90e writeup, and
landed on the same two answers. Recording that as corroboration, not as a second
opinion looking for a difference.

  • kind="stable" — same conclusion, same reasoning. Confirmed nothing was leaning
    on it: grep -rn 'kind="stable"' src/ tests/ hits only graph_spectral.py itself
    and three unrelated lines in test_implicit_feedback_age_correlation.py, and no
    test in the diff asserts the sort kind (test_eigsh_is_called_with_pinned_tolerance_and_iteration_budget
    pins tol and maxiter and stops there). Reverting to np.argsort(eigvals) also
    restores main's exact behaviour, so the eigen-path diff is now purely the v0 fix.
  • tol=0.0 — same verification, same result: inspect.signature(eigsh).parameters['tol'].default
    is 0 on the installed SciPy 1.17.1, and maxiter really is None, so maxiter is
    the one of the two that changes behaviour today.
  • Mutation check — deleting v0=deterministic_start_vector(L) from the eigsh
    call fails 5 tests, including test_graph_spectral_xprocess_1370.py::test_eigenbasis_is_bit_identical_across_processes.
    The gate is real.
  • Suite — 7389 passed, 72 skipped, 71 xfailed. I hit the same 6 uninstall/archive
    failures on a bare run and the same resolution: uv run --extra archive pytest, all
    58 pass. Matches your numbers exactly.

The one thing still outstanding: the docstring makes the claim the commit message just retracted

src/aelfrice/graph_spectral.py:192-196, unchanged on 64ec7f42:

The sparse path is pinned to be reproducible (#1370 §10, #1157):
``v0`` is derived from ``L``'s own bytes, ``tol=0`` asks for machine
precision rather than an early exit, and ``maxiter`` is explicit
rather than inherited from ARPACK's default.

The commit message now says tol=0.0 "changes nothing today ... a no-op guard, not a
tightening." The docstring still lists it beside v0 and maxiter as one of three
things that pin reproducibility, which reads as though passing it does something. Not
false line-by-line — SciPy does document tol=0 as machine precision — but it is the
same overstatement by omission, and the docstring is the surface that actually ships
and that the next reader hits first. Correcting the message and leaving the docstring
is the half-fix.

Suggested replacement, which also promotes v0 to the load-bearing position your
message gives it:

The sparse path is pinned to be reproducible (#1370 §10, #1157).
``v0``, derived from ``L``'s own bytes, is the load-bearing part —
without it ARPACK draws its start vector from an RNG that advances
per call. ``maxiter`` is explicit rather than inherited from
ARPACK's default. ``tol=0.0`` changes nothing today (SciPy 1.17.1
already defaults ``tol=0``); it is passed so a later change to that
default cannot silently introduce an early exit. Two calls on the
same ``L`` return bit-identical arrays.

I built and verified this on top of 6115b8f1 (41 passed across the four §10/§8/§5
test files plus test_heat_kernel.py, discretion grep clean) but am not pushing
it — you have pushed three times in the last ten minutes and racing your branch to
land a docstring would cost you a CI cycle and risk clobbering work in flight. Fold it
into your next push if you agree; if you would rather ship as-is, say so and I will
take it as a follow-up rather than hold the PR.

Nothing else blocks. Everything else on this PR is verified and I am ready to drive it
to merge as soon as you are done pushing.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

The red checks here are a GitHub Actions outage, not this diff. All of them —
size-check, deptry, vulture, pattern-scan, release-docs-check — failed at the
Set up job step, before any repo code ran:

$ gh api repos/.../actions/jobs/92673750843 --jq '[.steps[]|select(.conclusion=="failure")|.name]'
["Set up job"]

githubstatus.com confirms: Actions partial_outage, incident "Incident with Actions"
opened 2026-08-06T15:22:49Z, still investigating. pytest (3.13) was cancelled by the
same thing.

Locally the branch is green — 7389 passed, 72 skipped, 71 xfailed. Re-running the failed
jobs once the incident clears; not labelling ready-to-merge until they are genuinely
green, since merge-train blocks on any failed check-run.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

The seven red checks here are a GitHub Actions outage, not this diff

Before anyone spends a cycle on them: analyze (python), deptry,
pattern-scan, release-docs-check, size-check, vulture and
migration-policy-check are all failing at the Set up job stage, before a
single line of repo code runs.

From the deptry job log (run 31118484883):

2026-08-06T16:24:24Z Failed to resolve action download info. Error: Service Unavailable
2026-08-06T16:24:24Z Retrying in 25.184 seconds
2026-08-06T16:25:39Z Failed to resolve action download info. Error: Service Unavailable
2026-08-06T16:25:39Z Retrying in 26.407 seconds
2026-08-06T16:26:45Z ##[error]Service Unavailable
2026-08-06T16:26:45Z ##[error]Failed to resolve action download info.

GitHub could not fetch the action tarballs. Seven unrelated jobs failing together
in a 3-15 minute band is the signature — a code change cannot make typos,
deptry and migration-policy-check fail at once, and pytest, calibration,
history-scan and Sourcery review all passed on the same SHA.

Do not "fix" anything for these, and do not FF-push around them. The
authorized bypass for a stale red bot check does not apply: these are ruleset
jobs, and the correct response is to re-run once the outage clears. Every other
open PR is queued behind the same condition right now.

Otherwise this PR is clean: zero unresolved review threads, and the substantive
review is complete. The only outstanding item is the docstring correction noted
above, which is non-blocking.

`_ORDER_BY_BM25` had no secondary sort key, so `search_beliefs` and
`search_beliefs_scored` truncated a bm25-ranked scan on SQLite's scan
order whenever rows tied at the cut. That tail is the one production
uses: `origin_tiebreak` defaults False and `retrieve()` hard-forces it
False, with `l1_limit` at 50. Appending `, b.id` makes the order total,
matching `list_unexplored_belief_ids`, which already argued the case.

Refs #1370 (§5, §6), #1157.
`_ENUM_MEMBER_INDEX` was built by iterating the frozensets in
`ENUM_VOCAB`, so its insertion order — and therefore the order
`_extract_enums` emits slots — was keyed on string hash randomisation.
`extract_values` documents "same input -> byte-identical output"; it was
producing a different slot order in every process. Same bug class as the
`MUTABLE_FIELDS` iteration already fixed in `replay`.

Latent today because consumers only use the conflict set, but #1365 will
render these slots into the injected prompt.

Refs #1370 (§8), #1157.
`compute_eigenbasis` called `eigsh` with no `v0`, so ARPACK drew a start
vector from its own RNG, whose state advances per call. Two solves of the
same Laplacian returned different eigenvectors — numerically equivalent,
but the heat-kernel authority ranking built on them was not reproducible
from the write log, and no test asserted bit identity.

`v0` is now derived from the matrix's own bytes (a constant vector is not
usable — it is near an eigenvector of the normalized Laplacian and
degenerates the Krylov subspace), and `maxiter` is explicit rather than
inherited from SciPy's default.

`tol=0.0` is passed for the same reason as `maxiter` — to pin a value the
module depends on against a future SciPy default change — but unlike
`maxiter` it changes nothing today: SciPy 1.17.1 already defaults `tol=0`
(verified against the installed version), so it is a no-op guard, not a
tightening. The earlier claim that it "pins tol to machine precision"
overstated it.

The eigenvalue sort is left as it was. A stable `argsort` was tried and
dropped: on the bit-identical eigenvalues the seeded `v0` now guarantees,
quicksort is already deterministic, so it does not serve #1370 — all it
changes is which index wins an exactly-degenerate tie, a behaviour change
with no test riding on a diff whose every other line is mutation-checked.
If it is wanted it belongs in its own commit with a test that pins the tie.

The lane is default-off, so this is not on the hot path; the flip is a
config change away and the failure would be silent.

Refs #1370 (§10), #1157.
…sses

The §10 coverage was four repetitions inside one interpreter, which the PR
body correctly names as the weaker of the two available assertions. It is
weaker in a way that matters: the defect was ARPACK drawing `v0` from an RNG
whose state advances *per call*, so a within-process repetition is exactly the
shape a per-call RNG breaks — while a *per-process* source of variation would
repeat identically four times in one interpreter and still differ between runs.

#1157's contract is about reproducing a ranking from the write log in another
process on another machine. The sibling §8 test already goes out-of-process
for this reason; §10 had no equivalent.

Six children under PYTHONHASHSEED 0-5, each building the graph itself so any
set or dict ordering upstream of the solver is inside the blast radius rather
than hidden by constructing it in the parent. The property does hold — the
seed is blake2b over the CSR bytes feeding default_rng, with no hash
randomisation involved — but "expected to hold" is what the pre-#1370 code
also looked like.

Mutation-verified: dropping the `v0=` argument makes this emit several
distinct digests. A second assertion rejects empty child output, since a
digest of nothing is also "one distinct result".
`sorted(group)[0]` sat in the dict comprehension's value position, so
the group was re-sorted for every member it contained rather than once.
Hoisting the sort into its own `for` clause makes it one sort per group
and leaves the emitted order — the whole point of #1370 §8 — identical.

Refs #1370 (§8).
`_render` hard-coded `timeout=120` while the pytest marker and the
docstring both read `_SUBPROCESS_TIMEOUT_S`. Same value today, so the
drift is silent — moving the constant would have left the subprocess
cap behind.

Refs #1370 (§8).
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1370-determinism-tiebreak-seeding branch from c211ce9 to 5be6840 Compare August 9, 2026 16:06
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 9, 2026
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 9, 2026
@github-actions
github-actions Bot merged commit 5be6840 into main Aug 9, 2026
37 checks passed
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

merge-train: merged 5be6840main via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Gylf PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(determinism): total-order tiebreak and seeded stochastic steps (#1157 §5/§6/§8/§10)

1 participant