Skip to content

Fix simulation correctness and diagnostics - #53

Merged
fsargent merged 2 commits into
mainfrom
07-13-harden-simulation-correctness
Jul 13, 2026
Merged

Fix simulation correctness and diagnostics#53
fsargent merged 2 commits into
mainfrom
07-13-harden-simulation-correctness

Conversation

@fsargent

@fsargent fsargent commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

  • fix Schulze strongest paths, normalization edge cases, chooser validation, and the broken VSE helper
  • isolate election metadata, seed Python and NumPy consistently, and support streaming CSV output
  • replace ad hoc diagnostics with trace logging and remove commented-out code
  • add repository guidance, updated documentation, and regression coverage

Test plan

  • uv run python -m pytest (42 passed)
  • trunk check on changed files
  • git diff --check

Behavioral note

The corrected Schulze strongest-path implementation intentionally changes one cycle result that was previously produced by an aliased matrix.

Made with Cursor

Summary by Sourcery

Improve simulation correctness, reproducibility, and diagnostics by fixing Schulze and VSE edge cases, isolating election metadata, adding deterministic seeding and streaming CSV support, and documenting repository usage and regeneration workflows with new regression coverage.

New Features:

  • Add election-scoped metadata via ElectionContext to keep method extra events isolated per run
  • Support streaming CSV batch output without retaining all rows in memory, including recording the output file path
  • Provide deterministic seeding for Python and NumPy random generators via a shared seedRandomGenerators helper
  • Introduce validation and fallback behavior for probabilistic strategy choosers to handle invalid probabilities safely

Bug Fixes:

  • Correct Schulze strongest-path computation to use independent rows and fix cycle resolution results
  • Fix Score and VSE normalization for tied or identical utilities to avoid division by zero and ensure neutral VSE
  • Scope Mav cutoff calculations to the generated ballot function instead of shared class state
  • Ensure Schulze and V321 extraEvents are instance-scoped rather than shared at the class level
  • Prevent SideTally initialization from re-running and avoid implicit state mutations across elections

Enhancements:

  • Refine VSE resultsTable and vseOn to reuse normalized VSE and expose election metadata through the instance context
  • Improve diagnostic logging infrastructure by replacing print-based debug output with a structured TRACE logger and trace helper
  • Update decorators and legacy election code to use trace logging for timing and debug output
  • Tighten ProbChooser behavior to guarantee probability sums and non-negative inputs, and ensure deterministic fallback to the last choice
  • Clarify voter model documentation and comments, including dimensional cluster descriptions

Build:

  • Update Trunk CI workflow to run on pushes and pull requests to main with pinned action SHAs
  • Adjust Trunk runtime configuration to target Python 3.12 instead of an unreleased 3.14 series

CI:

  • Reconfigure the Trunk Check GitHub Actions workflow to run directly on pushes and PRs to main, with explicit permissions

Documentation:

  • Rewrite README.md for current tooling, doctest-based validation, and streaming CSV examples
  • Add AGENTS.md to document project architecture, simulation behavior, reproducibility requirements, and change guidelines

Tests:

  • Add regression tests covering Schulze cycle resolution and instance-scoped metadata scenarios
  • Add tests for VSE normalization with equal utilities and multi-run vseOn behavior
  • Add tests validating Mav election-scoped cutoffs and ProbChooser probability handling and fallback behavior
  • Add tests for deterministic seeding across Python and NumPy random generators
  • Add tests for streaming CsvBatch output and IRV recalculation scenarios
  • Add logging-based tests to ensure diagnostics are emitted via the TRACE logger

Correct election edge cases and isolate per-run state so simulations remain reproducible, then add regression coverage and clearer development guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sourcery-ai

sourcery-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes Schulze strongest-path computation and metadata scoping, hardens VSE normalization and strategy choosers, introduces deterministic seeding and streaming CSV output, replaces ad hoc debugging with structured logging, and adds documentation plus regression coverage.

File-Level Changes

Change Details Files
Fix Schulze strongest-path algorithm and method-specific metadata handling, ensuring correct cycle resolution and per-instance extraEvents.
  • Replace beatStrength matrix initialization with independent row lists to avoid aliasing.
  • Refactor strongest-path propagation loops in Schulze.resolveCycle to operate on the correctly initialized matrix.
  • Update Schulze doctests and expected scenarios to match corrected cycle resolution behavior.
  • Scope Schulze.extraEvents to the method instance via ElectionContext instead of a class-level dict.
  • Adjust scenario classification logic in Schulze.results to use the instance extraEvents property.
methods.py
dataClasses.py
test/test_regressions.py
Make VSE normalization and score ballots robust to equal-utility cases and streamline VSE result aggregation.
  • Add normalized_vse helper that guards against zero best-minus-random denominators using numpy.isclose.
  • Use normalized_vse in Method.vseOn to compute per-run VSE values safely.
  • Update Method.resultsTable to compute util and vse once per row via normalized_vse.
  • Handle zero utility range in Score0to.honBallot by returning uniform topRank ballots.
  • Add regression tests covering identical-utility electorates and vseOn aggregation behavior.
methods.py
dataClasses.py
test/test_regressions.py
Introduce election-scoped Mav cutoffs and honest ballot functions to avoid global state, plus tests for ballot stability.
  • Change Mav.honBallotFor to be an instance method that computes election-specific percentile cuts.
  • Return a closure honBallot that uses precomputed cuts, attaches memoized ballots to voters, and exposes tally keys.
  • Factor cutoff logic into _honBallotWithCuts and reuse it from the base honBallot method.
  • Ensure cuts never exceed voter max utility with a small epsilon adjustment.
  • Add regression test confirming that low-electorate cutoffs are not mutated by later honBallotFor calls.
methods.py
test/test_regressions.py
Centralize seeding for Python and NumPy RNGs and expose deterministic CSV streaming for large simulation batches.
  • Add seedRandomGenerators helper that seeds random and np.random based on a hashed seed.
  • Use seedRandomGenerators in CsvBatch.init instead of directly seeding random.
  • Refactor CsvBatch to support retain_rows=False and a generator-based _generateRows that streams elections.
  • Update saveFile to accept an optional rows iterator, handle empty batches, and record the output filename.
  • Expose seedRandomGenerators from vse.py and use it in recalculate_irv_pages chunk workers.
  • Add regression tests verifying seeding reproducibility and that streaming CsvBatch writes the expected CSV shape.
vse.py
scripts/recalculate_irv_pages.py
test/test_regressions.py
Replace ad hoc print-based debugging with structured logging and a TRACE level, then wire callers through trace/setDebug.
  • Introduce a logging-based debugDump module with a custom TRACE level and logger configuration.
  • Implement trace() to log low-level diagnostics and setDebug() to toggle the logger level while preserving API.
  • Replace direct print debugging in sodaTest, mydecorators.timed, and other modules with trace calls.
  • Update regression tests to assert that trace diagnostics emit through logging at the TRACE level.
debugDump.py
sodaTest.py
mydecorators.py
test/test_regressions.py
Tighten strategy chooser validation and behavior, including probability checks and deterministic fallbacks.
  • Make ProbChooser.validate that it receives at least one choice, with non-negative probabilities summing to 1.0 via math.isclose.
  • Ensure ProbChooser.call falls back to the last choice when cumulative probability undershoots due to FP error.
  • Add tests for invalid probability configurations and the fallback behavior under forced random.random outcomes.
  • Minor cleanup of Chooser.getName and media/model comments for clarity.
stratFunctions.py
test/test_regressions.py
Modernize and clarify documentation, CI configuration, and runtime metadata, without changing core algorithms.
  • Rewrite README.md to describe supported Python, uv-based setup, validation commands, and streaming CSV usage.
  • Add AGENTS.md with project overview, file map, change guidance, and reproducibility notes.
  • Update Trunk GitHub Action to run on push/pull_request, pin action versions, and adjust runtime Python version in .trunk/trunk.yaml.
  • Document IRV page reproduction and regeneration workflow in README and AGENTS.
README.md
AGENTS.md
.github/workflows/trunk.yml
.trunk/trunk.yaml

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 Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc69e8b8-489c-40ba-9b8b-20c475374ba0

📥 Commits

Reviewing files that changed from the base of the PR and between 95cbfef and aa97492.

📒 Files selected for processing (15)
  • .github/workflows/trunk.yml
  • .gitignore
  • .trunk/trunk.yaml
  • AGENTS.md
  • README.md
  • dataClasses.py
  • debugDump.py
  • methods.py
  • mydecorators.py
  • scripts/recalculate_irv_pages.py
  • sodaTest.py
  • stratFunctions.py
  • test/test_regressions.py
  • voterModels.py
  • vse.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 07-13-harden-simulation-correctness

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 4 issues, and left some high level feedback:

  • CsvBatch.init still accepts a force parameter that is never used; either implement the intended behavior around overwriting existing result files or remove the argument to avoid confusion.
  • Method.multiResults now returns a flat list of (result, chooser, tallyItems) without a separate extraEvents element; ensure any remaining callers and its docstring are updated to reflect this new return shape.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- CsvBatch.__init__ still accepts a `force` parameter that is never used; either implement the intended behavior around overwriting existing result files or remove the argument to avoid confusion.
- Method.multiResults now returns a flat list of (result, chooser, tallyItems) without a separate extraEvents element; ensure any remaining callers and its docstring are updated to reflect this new return shape.

## Individual Comments

### Comment 1
<location path="test/test_regressions.py" line_range="50-57" />
<code_context>
+    ))
+
+
+def test_vse_on_returns_every_simulation_run():
+    voters = Electorate([Voter([0, 1]), Voter([0, 1])])
+
+    result = Score().vseOn(voters)
+
+    assert len(result.results) == 4
+    assert all(run.result == [1.0] for run in result.results)
+    assert result.extraEvents == {}
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** This test hardcodes the number of VSE runs (4), which seems like an implementation detail and may be brittle if chooser behavior changes.

The assertion `assert len(result.results) == 4` tightly couples this test to the current `Method.multiResults` implementation and the default chooser configuration. If `vseOn` adds or removes chooser variants, this will fail even when behavior is correct. Instead, assert semantic properties, for example:

- There is at least one run.
- Every run has a VSE of 1.0 for this electorate.
- Optionally, `run.chooser` values are unique or match a documented expected set if that API is intended to be stable.

This keeps the test focused on the normalization behavior without depending on the exact number of runs.
</issue_to_address>

### Comment 2
<location path="test/test_regressions.py" line_range="72-81" />
<code_context>
+    assert low_ballot(Mav, Voter([-2, -1]), SideTally()) == expected
+
+
+@pytest.mark.parametrize(
+    "probabilities",
+    [
+        [],
+        [(-0.1, beHon), (1.1, beStrat)],
+        [(0.25, beHon), (0.25, beStrat)],
+    ],
+)
+def test_prob_chooser_rejects_invalid_probabilities(probabilities):
+    with pytest.raises(ValueError):
+        ProbChooser(probabilities)
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** There’s good coverage of invalid probabilities for ProbChooser, but no positive-path test that the chooser respects probabilities and updates tallies.

The current tests cover invalid configs and the 1.0 edge case, but there’s no test of the main behavior. Please add a positive-path test that:

- Builds a `ProbChooser` with valid probabilities (e.g. `[(0.3, beHon), (0.7, beStrat)]`).
- Invokes it many times using a real `SideTally` and a fixed RNG seed.
- Asserts that both strategies are chosen at least once and that the corresponding tally keys (e.g. `"ProbChooser_beStrat"`) are incremented.

This will verify the probability split and tally integration, not just validation and the fallback path.

Suggested implementation:

```python
from vse import CsvBatch, seedRandomGenerators


def test_prob_chooser_respects_probabilities():
    # Fix RNG seed for reproducibility
    seedRandomGenerators(12345)

    tally = SideTally()
    chooser = ProbChooser([(0.3, beHon), (0.7, beStrat)])

    # Exercise the chooser many times to ensure both strategies are used
    electorate = Electorate([Voter([0])])
    voter = Voter([0])

    for _ in range(500):
        chooser(Mav, electorate, voter, tally)

    # Both strategies should have been selected at least once
    assert tally["ProbChooser_beHon"] > 0
    assert tally["ProbChooser_beStrat"] > 0

```

The exact call signature of `ProbChooser` and the strat functions (`beHon`, `beStrat`) may differ slightly from this guess. You may need to:
1. Adjust the arguments passed to `chooser(...)` to match the expected parameters (e.g., method, electorate, voter, side tally).
2. Confirm the exact tally key names used inside `ProbChooser`. If they differ (e.g., different prefix or naming convention), update `"ProbChooser_beHon"` and `"ProbChooser_beStrat"` to match the actual keys.
3. If `seedRandomGenerators` is not the correct way to seed the RNG used by `ProbChooser` (e.g., it uses `np.random.seed` directly), add or adjust seeding logic so that the test is reproducible.
</issue_to_address>

### Comment 3
<location path="test/test_regressions.py" line_range="100-109" />
<code_context>
+    assert (random.random(), np.random.random()) == first
+
+
+def test_csv_batch_can_stream_without_retaining_rows(tmp_path):
+    output_base = str(tmp_path / "results")
+    batch = CsvBatch(
+        _NumpyModel(),
+        [[Score(), []]],
+        nvot=3,
+        ncand=2,
+        niter=2,
+        baseName=output_base,
+        seed="stream-test",
+        force=True,
+        retain_rows=False,
+    )
+
+    assert batch.rows == []
+    assert Path(batch.output_file).exists()
+    assert len(Path(batch.output_file).read_text().splitlines()) == 10
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** The CsvBatch streaming test relies on a hardcoded line count, which is sensitive to header/metadata formatting changes.

Using `assert len(...splitlines()) == 10` makes the test fragile because it bakes in today’s CSV layout (metadata line + header + fixed number of data rows). Any change in header/metadata or tallies will fail the test even if streaming still works.

Instead, consider parsing the file with `csv.DictReader` and asserting that:
- The number of data rows equals `niter * len(methods)`.
- At least one row contains the expected keys (e.g. `"eid"`, `"util"`, `"vse"`).
- Optionally, the first line starts with `"# {"` to verify metadata output.

This keeps the test focused on validating streaming behavior and CSV structure rather than an exact line count.

Suggested implementation:

```python
    seedRandomGenerators("same-seed")

    assert (random.random(), np.random.random()) == first


def test_csv_batch_can_stream_without_retaining_rows(tmp_path):
    output_base = str(tmp_path / "results")
    methods = [Score()]
    batch = CsvBatch(
        _NumpyModel(),
        [[method, []] for method in methods],
        nvot=3,
        ncand=2,
        niter=2,
        baseName=output_base,
        seed="stream-test",
        force=True,
        retain_rows=False,
    )

    # No rows retained in memory when streaming is enabled
    assert batch.rows == []

    output_path = Path(batch.output_file)
    assert output_path.exists()

    with output_path.open("r", newline="") as f:
        # Verify metadata line is present and correctly formatted
        first_line = f.readline().rstrip("\n")
        assert first_line.startswith("# {")

        # Parse remaining CSV content via DictReader
        reader = csv.DictReader(f)
        rows = list(reader)

    # Number of data rows equals niter * number of methods
    expected_row_count = batch.niter * len(methods)
    assert len(rows) == expected_row_count

    # At least one row must contain the expected keys
    assert rows, "CSV should contain at least one data row"
    sample_row = rows[0]
    for key in ("eid", "util", "vse"):
        assert key in sample_row


import numpy as np

```

```python
import csv
from pathlib import Path

import numpy as np
import pytest

```

If `_NumpyModel` is not already imported in `test/test_regressions.py`, you will need to add an appropriate import for it near the other imports, e.g. `from voterModels import _NumpyModel` or wherever it is defined in your codebase. Ensure that `CsvBatch` exposes `niter` and that the `methods` list length matches the structure expected by `CsvBatch` (here `[[method, []] for method in methods]`), or adjust accordingly if the constructor signature differs.
</issue_to_address>

### Comment 4
<location path="test/test_regressions.py" line_range="92-97" />
<code_context>
+    assert chooser(object, object(), SideTally()) == "strat"
+
+
+def test_seed_random_generators_is_reproducible():
+    seedRandomGenerators("same-seed")
+    first = (random.random(), np.random.random())
+    seedRandomGenerators("same-seed")
+
+    assert (random.random(), np.random.random()) == first
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** The seedRandomGenerators test checks reproducibility for the same seed, but not that different seeds actually produce different sequences.

To better cover this helper and catch cases where the seed is ignored or mishandled, please also add an assertion that different seeds produce different sequences, e.g.:

```python
yieldRandomGenerators("seed-a")
seq_a = (random.random(), np.random.random())
yieldRandomGenerators("seed-b")
seq_b = (random.random(), np.random.random())
assert seq_a != seq_b
```

This complements the existing same-seed check by verifying cross-seed variation as well.

```suggestion
def test_seed_random_generators_is_reproducible():
    # Same seed produces the same sequence
    seedRandomGenerators("same-seed")
    first = (random.random(), np.random.random())
    seedRandomGenerators("same-seed")
    assert (random.random(), np.random.random()) == first

    # Different seeds produce different sequences
    seedRandomGenerators("seed-a")
    seq_a = (random.random(), np.random.random())
    seedRandomGenerators("seed-b")
    seq_b = (random.random(), np.random.random())
    assert seq_a != seq_b
```
</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 test/test_regressions.py
Comment thread test/test_regressions.py
Comment thread test/test_regressions.py
Comment thread test/test_regressions.py
Make regression assertions semantic and document the batch APIs so review feedback protects behavior without coupling tests to formatting details.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fsargent

Copy link
Copy Markdown
Member Author

Addressed the review in aa97492:

  • documented the flat multiResults return shape
  • clarified that force permits provenance collection from a dirty Git tree; it is used and does not control output replacement
  • strengthened the VSE, ProbChooser, RNG, and streaming CSV tests

Validation: 43 tests pass and Trunk reports no issues on the changed files.

@fsargent
fsargent merged commit 40fec52 into main Jul 13, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant