Skip to content

fix(determinism): seed the enum member index in sorted order (#1365 AC4) - #1391

Closed
robotrocketscience wants to merge 2 commits into
mainfrom
feat/issue-1365-slot-conflict-annotation
Closed

fix(determinism): seed the enum member index in sorted order (#1365 AC4)#1391
robotrocketscience wants to merge 2 commits into
mainfrom
feat/issue-1365-slot-conflict-annotation

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Part of #1365 (AC4). Does not close it — this is the prerequisite the issue names: "Sort the extracted slot tuples before this becomes load-bearing."

The defect

ENUM_VOCAB's groups are frozensets. Building _ENUM_MEMBER_INDEX by iterating them directly (value_compare.py:141-146) seeds the dict in string-hash order, which PYTHONHASHSEED varies per process. _extract_enums then walks that dict and appends matches in the order it finds them — so the enum half of ValueSlots, not merely the index, came out in a different order in different processes.

Measured before the fix, on "the run is nondeterministic and stochastic and also non-deterministic" (three members of one group match):

5 distinct orderings across 8 hash seeds

group_id was never affected — it is already sorted(group)[0].

Why it has survived

The conflict set is order-independent, so every current consumer is blind to it. It stops being latent the moment #1365's annotation renders which lock a belief conflicts with: lock_conflict_annotations returns the first conflicting lock, and "first" is decided by this order. The agent would be shown a different lock id on different runs against an identical store.

The fix

for member in sorted(group) in the innermost comprehension clause. Root cause, not the symptom — sorting the output tuple would leave the index itself still randomised for any future consumer that walks it.

Verification

  • 12 seeds → 1 ordering (was 5 across 8).
  • Mutation: reverting the sort → 4 orderings across 4 seeds, test red.
  • tests/test_lock_consistency_1175.py, test_slot_conflict.py, test_value_compare.py, test_value_compare_nonfinite_1227.py → 81 passed.

The regression test is subprocess-driven across four PYTHONHASHSEED values, because one interpreter has one hash seed and an in-process assertion structurally cannot observe this defect. It carries an explicit timeout(60) per the convention in pyproject.toml:131-133 — four interpreter starts are not unit-sized work. It asserts the orderings agree rather than pinning one literal, so it keeps its meaning when a vocabulary entry is added, plus a guard that the scenario still matches ≥3 members (otherwise a one-element list satisfies it vacuously).

Overlap

This is also the _ENUM_MEMBER_INDEX item in #1370. Noted on that issue so its owner drops it; the rest of #1370 (spla.eigsh seeding, the retrieval.py:2923 pack loop, _ORDER_BY_BM25) is untouched here.

Rollback

Revert the single commit. No data, schema, config or default changes.

Summary by Sourcery

Tests:

  • Add a subprocess-based regression test that verifies ValueSlots.enum ordering is identical across multiple PYTHONHASHSEED values and continues to exercise a multi-member enum group.

@robotrocketscience robotrocketscience added the author-Setr PR coordination mutex label Aug 6, 2026
@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

Ensures deterministic ordering of enum value extraction across different PYTHONHASHSEED values by sorting enum group members when building the reverse lookup index, and adds a subprocess-based regression test that verifies stability of the ordering across multiple hash seeds.

Sequence diagram for subprocess-based hash seed regression test

sequenceDiagram
  participant Pytest
  participant SubprocessRunner
  participant PythonInterp as PythonInterpreter
  participant ValueCompare as value_compare

  Pytest->>SubprocessRunner: spawn_interpreters(PYTHONHASHSEED values)
  loop for each hash seed
    SubprocessRunner->>PythonInterp: start_python_with_env(PYTHONHASHSEED)
    PythonInterp->>ValueCompare: import value_compare
    ValueCompare-->>PythonInterp: build _ENUM_MEMBER_INDEX with sorted(group)
    PythonInterp->>ValueCompare: _extract_enums(input_text)
    ValueCompare-->>PythonInterp: ValueSlots(enum ordering)
    PythonInterp-->>SubprocessRunner: return enum ordering
  end
  SubprocessRunner-->>Pytest: collected_orderings
  Pytest->>Pytest: assert all_orderings_equal()
Loading

File-Level Changes

Change Details Files
Make enum member index construction deterministic across processes.
  • Change _ENUM_MEMBER_INDEX comprehension to iterate sorted(group) instead of group so dict insertion order is stable and independent of string hash randomization.
  • Retain use of sorted(group)[0] for group_id while ensuring members within each group are processed in a consistent alphabetical order.
  • Document in-line the determinism rationale and the prior nondeterministic behavior tied to frozenset iteration and PYTHONHASHSEED.
src/aelfrice/value_compare.py
Add a regression test that validates enum extraction order is stable across multiple PYTHONHASHSEED values.
  • Introduce a subprocess-driven test that runs extract_values(...).enum under several PYTHONHASHSEED values and asserts all observed orderings are identical rather than pinned to a specific literal.
  • Use a multi-member text scenario to ensure the test exercises the case where multiple enum members from a single group match, avoiding vacuous passes on single-member lists.
  • Apply a per-test timeout consistent with project conventions and enforce per-subprocess timeouts to satisfy the suite’s termination policy and avoid hung child processes consuming the entire test budget.
tests/test_value_compare.py

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

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f043aa0-e172-4d4b-9cdb-72174416dbcc

📥 Commits

Reviewing files that changed from the base of the PR and between 0cf0b35 and 29d7870.

📒 Files selected for processing (2)
  • src/aelfrice/value_compare.py
  • tests/test_value_compare.py

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.

@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 1 issue, and left some high level feedback:

  • The assert only.count("'") >= 6 heuristic in the stability test is a bit brittle; consider emitting a structured format (e.g., JSON) from the subprocess and asserting on the actual list length (≥3 members) instead of inferring it from quote counts.
  • In the subprocess call you fully override env with just PYTHONHASHSEED, PATH, and HOME; if you only need to control PYTHONHASHSEED, it may be safer to start from os.environ.copy() and override that key to avoid surprising issues from missing locale or platform-specific environment variables.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `assert only.count("'") >= 6` heuristic in the stability test is a bit brittle; consider emitting a structured format (e.g., JSON) from the subprocess and asserting on the actual list length (≥3 members) instead of inferring it from quote counts.
- In the subprocess call you fully override `env` with just `PYTHONHASHSEED`, `PATH`, and `HOME`; if you only need to control `PYTHONHASHSEED`, it may be safer to start from `os.environ.copy()` and override that key to avoid surprising issues from missing locale or platform-specific environment variables.

## Individual Comments

### Comment 1
<location path="tests/test_value_compare.py" line_range="332-333" />
<code_context>
+    )
+    # The scenario must actually exercise the multi-member case, or the
+    # assertion above is satisfied by a one-element list.
+    (only,) = orderings
+    assert only.count("'") >= 6, f"scenario stopped matching >=3 members: {only}"
</code_context>
<issue_to_address>
**suggestion (testing):** Use structured parsing instead of quote-counting to assert multi-member matches

This guard currently relies on counting single quotes in the string representation, which is brittle and tied to formatting details (quote style, list formatting, or member names containing quotes). Instead, parse the list and assert on its length directly, e.g.:

```python
import ast

(only,) = orderings
members = ast.literal_eval(only)
assert len(members) >= 3, f"scenario stopped matching >=3 members: {members!r}"
```

This makes the test more robust and clearly checks the number of members rather than their string formatting.

Suggested implementation:

```python
    assert len(orderings) == 1, (
        "enum extraction order varies with PYTHONHASHSEED; "
        f"{len(orderings)} orderings across {len(seeds)} seeds:\n  "
        + "\n  ".join(sorted(orderings))
    )
    # The scenario must actually exercise the multi-member case, or the
    # assertion above is satisfied by a one-element list.
    (only,) = orderings
    members = ast.literal_eval(only)
    assert len(members) >= 3, f"scenario stopped matching >=3 members: {members!r}"

```

You also need to ensure `ast` is imported at the top of `tests/test_value_compare.py`, e.g. add `import ast` alongside the other imports. If the file already has an `ast` import, no further changes are required.
</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 tests/test_value_compare.py Outdated
@robotrocketscience

Copy link
Copy Markdown
Owner Author

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

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — reproduced from scratch, correct. Driving it to merge.

I re-derived the defect and the fix rather than reading the body forward.

The nondeterminism is real, and the magnitude reproduces

Twelve PYTHONHASHSEED values against extract_values(...).enum on your
scenario string:

with the fix     : 1 distinct ordering  / 12 seeds
sort reverted    : 5 distinct orderings / 12 seeds

The five, verbatim:

['deterministic', 'non-deterministic', 'nondeterministic', 'stochastic']
['deterministic', 'non-deterministic', 'stochastic', 'nondeterministic']
['deterministic', 'nondeterministic', 'stochastic', 'non-deterministic']
['deterministic', 'stochastic', 'non-deterministic', 'nondeterministic']
['deterministic', 'stochastic', 'nondeterministic', 'non-deterministic']

Your "5 across 8 seeds" is consistent with this — 5 is the number of orderings
reachable, not a sampling artifact.

group_id is indeed unaffected: sorted(group)[0] was already stable, so the
defect is confined to the member order, exactly as described.

The test does what it claims

Reverting sorted(group)group turns
test_enum_extraction_order_is_stable_across_hash_seeds red. It is not merely
present, it discriminates.

The subprocess-per-seed design is the right call and worth stating plainly:
one interpreter has one hash seed, so an in-process assertion structurally
cannot observe this class of defect — it would pass on the broken code every
time. The timeout(60) is the honest consequence of that, and it uses the
per-test override the way pyproject.toml's comment reserves it rather than
touching the global budget.

Asserting the orderings agree rather than pinning one literal is also right:
it survives a vocabulary addition, and the >= 3 members guard stops a
one-element list from satisfying it vacuously.

Scope

Fixing the index rather than sorting the output is the root-cause fix — sorting
the tuple would leave _ENUM_MEMBER_INDEX randomised for any future consumer
that walks it, which is precisely how this survived to now. Agreed on not
closing #1365: this is the prerequisite the issue names.

Good catch on the #1370 overlap, and on noting it there rather than silently
fixing the same line twice.

State

test_lock_consistency_1175.py, test_slot_conflict.py, test_value_compare.py,
test_value_compare_nonfinite_1227.py: 81 passed. Discretion grep on added
lines clean.

Nothing blocking.

@robotrocketscience
robotrocketscience force-pushed the feat/issue-1365-slot-conflict-annotation branch from 2eee497 to aef4b1e Compare August 6, 2026 04:34
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Overlap notice — this collides with #1393 (§8 of #1370). Both branches fix the same defect: _ENUM_MEMBER_INDEX iterating a frozenset, so extract_values output order varied per process under hash randomisation.

This is content-scoped duplicate work that the claim mutex cannot catch — #1370 bundles the fix as one of four determinism sections (_ORDER_BY_BM25 tiebreak, this, and the ARPACK start vector), so nothing in the issue titles or claims collided.

Proposed resolution: this PR takes the fix, #1393 drops its §8 commit. This one is narrower and easier to review in isolation. #1393's other two commits (store.py tiebreak, graph_spectral.py seeding) are independent and unaffected.

One thing worth porting across if you take it. #1393's test for this is out-of-process: it spawns six children under PYTHONHASHSEED 0-5 and asserts a single distinct rendering, guarded by a companion assertion that the fixture actually hits ≥4 multi-member groups — single-member groups cannot expose the bug, so a fixture without that guard can pass vacuously. An in-process test cannot see this defect at all, because the interpreter's hash seed is fixed for the life of the process. Worth checking whether tests/test_value_compare.py's version distinguishes the fix; if it passes with sorted() reverted, it is not testing this.

Deferring to whoever reviews first. I will drop the commit from #1393 on confirmation.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base 41cb90daa290976f3a5e60aeccfde085163d1d69, current main 0cf0b356e8cca6079ee2d94869157daa8b66d160). Rebase locally (git rebase github/main), force-push, and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 6, 2026
ENUM_VOCAB's groups are frozensets. Iterating them directly to build
_ENUM_MEMBER_INDEX seeds the dict in string-hash order, which
PYTHONHASHSEED varies per process; _extract_enums walks that dict and
appends matches as it finds them, so the enum half of ValueSlots came
out in a different order in different processes. Measured before the
fix: five distinct orderings across eight seeds for one input where
three members of one group match.

Latent for the conflict set, which is order-independent -- which is why
it has survived. Not latent for #1365's rendered annotation, which names
which lock a belief conflicts with and so is decided by that order.

The regression test is subprocess-driven across four seeds, because one
interpreter has one hash seed and an in-process assertion structurally
cannot observe this. Mutation-checked: reverting the sort yields four
orderings across four seeds and the test goes red.
The multi-member guard asserted `only.count("'") >= 6` against the repr
of the child process's output. It works, but it is a proxy for the thing
it means, and it breaks silently on a member name containing an
apostrophe or on any change to list formatting.

`ast.literal_eval` on the printed list gives the members exactly, so the
assertion is `len(members) >= 3` — which is what the comment above it
already said it was checking. Re-verified that the guard still
discriminates: reverting `sorted(group)` still turns it red.

Refs #1365.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-1365-slot-conflict-annotation branch from aef4b1e to 29d7870 Compare August 6, 2026 04:40
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Superseded by #1393, which ships the identical _ENUM_MEMBER_INDEX fix (for member in sorted(group)) together with the rest of #1370.

Independently verified before closing: #1393's tests/test_value_compare_hashseed_1370.py produces 6 distinct renderings across 6 PYTHONHASHSEED values on unfixed value_compare.py and passes on the fixed one — so the fix is covered there by a test that is load-bearing, and broader than the one here (six enum groups vs my one, and it renders the numeric slots too).

Keeping two copies of a one-line change to the same comprehension would only guarantee a merge conflict for whichever landed second.

#1365 AC4 is therefore satisfied by #1393 rather than by this PR. Recorded on #1365, which stays open for AC1-AC3 (the retrieve_with_tiers plumbing, the hook.py render, and the default-off flag) — see my note there on the plumbing collision with #1366 before starting those.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

merge-train: blocked

FF push to main failed:\n\n\nremote: error: GH013: Repository rule violations found for refs/heads/main. remote: Review all repository rules at https://github.com/robotrocketscience/aelfrice/rules?ref=refs%2Fheads%2Fmain remote: remote: - Changes must be made through a pull request. remote: To https://github.com/robotrocketscience/aelfrice ! [remote rejected] 29d787043a60c2a827d88124d81624ee0b22fb6d -> main (push declined due to repository rule violations) error: failed to push some refs to 'https://github.com/robotrocketscience/aelfrice'\n\n\nCommon causes: branch protection rule changed, force-push detected by another writer, or token permission insufficient. Re-add the label after investigating.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 6, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

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

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

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Setr PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant