Synthetic data only · v3

Verifier hardening audit

Ten banking and payments coding tasks. Each is graded twice: by a naive grader written first, and by a hardened final grader. Every task also ships a reference solution, a structurally different valid alternative, and plausible wrong solutions.

How to review: scan the matrix for red naive “accepted ✗” badges sitting above green final “rejected ✓” badges, then select any cell to see its failing tests and pytest output.

60/60final verdicts as expected
20/30wrong solutions accepted by naive graders
0/30wrong solutions accepted by final graders
0/20valid solutions rejected by final graders

Tasks

NumberAreaTitleSummary
01Payments APIMake payment creation idempotentA payment endpoint must charge a card once per Idempotency-Key, no matter how the client retries.
02InterestAccrue CAD interest at posting boundariesAccrue daily interest on a CAD balance and post it at month ends, using Actual/365 and banker's rounding.
03FX transfersQuote transfers with currency-specific minor unitsQuote a currency transfer with the fee taken in the source currency and each currency's own decimal places.
04ReconciliationReconcile synthetic statements deterministicallyMatch bank statement lines to ledger entries with exact, split, refund and duplicate rules, deterministically.
05LedgerEnforce an atomic double-entry journalRun a double-entry journal where every post balances per currency and is all-or-nothing.
06Card holdsTrack active authorization holds and available balanceCompute available balance as posted funds minus active card holds, with capture, void and expiry.
07StatementsSelect transactions by the account calendar monthSelect a month's transactions using midnight in the account's own time zone, converted to UTC.
08RefundsLimit refunds to the captured amountAllow partial refunds up to the captured amount, which may be less than what was authorized.
09FeesApply a non-marginal tiered fee schedulePrice a payment at one tier selected by an inclusive upper bound, then round and clamp the fee.
10Batch filesValidate an entire SBF-80 payment batchValidate an invented 80-character fixed-width payment file and reject the whole batch on any error.

Hardening matrix

One row per task, one column per solution. Each cell shows what the naive and final graders did with that solution; select it for the failing tests and pytest output.

TaskStarterReferenceAlt validWrong solutions
01_idempotent_paymentsPayments APINaiverejected1/3Finalrejected2/17Naiveaccepted3/3Finalaccepted17/17Naiveaccepted3/3Finalaccepted17/17inmemory_cacheNaiveaccepted3/3Finalrejected15/17caught by hardeningretry_raceNaiveaccepted3/3Finalrejected14/17caught by hardeninguncached_declineNaiveaccepted3/3Finalrejected16/17caught by hardening
02_interest_accrualInterestNaiveaccepted4/4Finalrejected24/33caught by hardeningNaiveaccepted4/4Finalaccepted33/33Naiveaccepted4/4Finalaccepted33/33actual_actualNaiveaccepted4/4Finalrejected29/33caught by hardeningfloatNaiveaccepted4/4Finalrejected28/33caught by hardeninghalf_upNaiveaccepted4/4Finalrejected28/33caught by hardening
03_fx_quotesFX transfersNaiverejected1/4Finalrejected16/22Naiveaccepted4/4Finalaccepted22/22Naiveaccepted4/4Finalaccepted22/22fee_after_conversionNaiveaccepted4/4Finalrejected18/22caught by hardeninground_cross_legNaiveaccepted4/4Finalrejected19/22caught by hardeningtwo_decimalsNaiverejected3/4Finalrejected20/22
04_ledger_reconciliationReconciliationNaiverejected2/3Finalrejected5/18Naiveaccepted3/3Finalaccepted18/18Naiveaccepted3/3Finalaccepted18/18no_consumptionNaiveaccepted3/3Finalrejected14/18caught by hardeningrefund_absNaiveaccepted3/3Finalrejected17/18caught by hardeningsplit_ignoredNaiverejected2/3Finalrejected13/18
05_double_entryLedgerNaiverejected1/3Finalrejected12/19Naiveaccepted3/3Finalaccepted19/19Naiveaccepted3/3Finalaccepted19/19cross_currency_sumNaiveaccepted3/3Finalrejected17/19caught by hardeningdouble_reverseNaiveaccepted3/3Finalrejected16/19caught by hardeningnon_atomicNaiveaccepted3/3Finalrejected14/19caught by hardening
06_available_balanceCard holdsNaiverejected1/3Finalrejected1/12Naiveaccepted3/3Finalaccepted12/12Naiveaccepted3/3Finalaccepted12/12available_ignores_holdsNaiverejected2/3Finalrejected7/12expired_holds_countNaiveaccepted3/3Finalrejected11/12caught by hardeningpartial_capture_keeps_remainderNaiverejected2/3Finalrejected9/12
07_statement_periodsStatementsNaiverejected2/3Finalrejected6/19Naiveaccepted3/3Finalaccepted19/19Naiveaccepted3/3Finalaccepted19/19fixed_offsetNaiveaccepted3/3Finalrejected16/19caught by hardeningoffset_signNaiverejected2/3Finalrejected14/19utc_monthNaiverejected2/3Finalrejected14/19
08_refund_limitsRefundsNaiverejected2/4Finalrejected1/8Naiveaccepted4/4Finalaccepted8/8Naiveaccepted4/4Finalaccepted8/8checks_single_refundNaiverejected3/4Finalrejected4/8counts_rejectedNaiverejected3/4Finalrejected3/8uses_authorizedNaiverejected3/4Finalrejected5/8
09_tiered_feesFeesNaiverejected0/3Finalrejected0/19Naiveaccepted3/3Finalaccepted19/19Naiveaccepted3/3Finalaccepted19/19exclusive_boundaryNaiveaccepted3/3Finalrejected18/19caught by hardeningfloat_bpsNaiveaccepted3/3Finalrejected16/19caught by hardeningmarginalNaiverejected1/3Finalrejected17/19
10_batch_parserBatch filesNaiverejected2/3Finalrejected12/39Naiveaccepted3/3Finalaccepted39/39Naiveaccepted3/3Finalaccepted39/39crlf_slicingNaiveaccepted3/3Finalrejected34/39caught by hardeningignores_hash_totalNaiveaccepted3/3Finalrejected31/39caught by hardeningpartial_acceptNaiveaccepted3/3Finalrejected38/39caught by hardening

What hardening caught

Each row is a wrong solution (or an unmodified starter) that the naive grader accepted, alongside the final-grader tests that reject it. Each one is caught by hardening and logged in findings.md (the finding link in each row goes to its entry). Badge counts are hidden tests passed / total, so 11/12 means 11 of 12 tests passed.

Task · solutionNaiveFinalFinal tests that fail
01_idempotent_payments
wrong_retry_race

finding
Naiveaccepted3/3Finalrejected14/17
  • test_concurrent_duplicates_charge_once
  • test_body_mismatch_while_in_flight
  • test_concurrent_duplicates_across_app_instances
01_idempotent_payments
wrong_uncached_decline

finding
Naiveaccepted3/3Finalrejected16/17
  • test_decline_replayed
01_idempotent_payments
wrong_inmemory_cache

finding
Naiveaccepted3/3Finalrejected15/17
  • test_replay_survives_app_restart
  • test_concurrent_duplicates_across_app_instances
02_interest_accrual
starter

finding
Naiveaccepted4/4Finalrejected24/33
  • test_half_cent_ties_bankers[50-0.0365-1]
  • test_half_cent_ties_bankers[73-0.025-1]
  • test_half_cent_ties_bankers[25-0.0365-2]
  • test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_half_cent_ties_bankers[237250.00-0.0001-29]
  • test_large_principal_exact[simple_monthly]
  • test_large_principal_exact[daily_compound]
  • test_leap_and_partial
  • test_accrual_properties
02_interest_accrual
wrong_float

finding
Naiveaccepted4/4Finalrejected28/33
  • test_half_cent_ties_bankers[50-0.0365-1]
  • test_half_cent_ties_bankers[73-0.025-1]
  • test_half_cent_ties_bankers[25-0.0365-2]
  • test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_half_cent_ties_bankers[237250.00-0.0001-29]
02_interest_accrual
wrong_half_up

finding
Naiveaccepted4/4Finalrejected28/33
  • test_half_cent_ties_bankers[50-0.0365-1]
  • test_half_cent_ties_bankers[73-0.025-1]
  • test_half_cent_ties_bankers[25-0.0365-2]
  • test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_half_cent_ties_bankers[237250.00-0.0001-29]
02_interest_accrual
wrong_actual_actual

finding
Naiveaccepted4/4Finalrejected29/33
  • test_large_principal_exact[simple_monthly]
  • test_large_principal_exact[daily_compound]
  • test_leap_and_partial
  • test_accrual_properties
03_fx_quotes
wrong_round_cross_leg

finding
Naiveaccepted4/4Finalrejected19/22
  • test_cross_has_no_intermediate_rounding
  • test_full_precision_cross_rate[1.23456789-1.34567891]
  • test_full_precision_cross_rate[1.12345678901234567890123456789-1.98765432109876543210987654321]
03_fx_quotes
wrong_fee_after_conversion

finding
Naiveaccepted4/4Finalrejected18/22
  • test_jpy_and_kwd_units
  • test_source_fee_before_conversion
  • test_full_precision_cross_rate[1.23456789-1.34567891]
  • test_exact_scaling_and_monotonicity
04_ledger_reconciliation
wrong_no_consumption

finding
Naiveaccepted3/3Finalrejected14/18
  • test_duplicate_statement_line_left_unmatched
  • test_ordering_and_candidate_rules[r1-statement-order]
  • test_duplicate_signature_includes_date
  • test_ground_truth_transforms
04_ledger_reconciliation
wrong_refund_abs

finding
Naiveaccepted3/3Finalrejected17/18
  • test_refund_kind_and_original_reference
05_double_entry
wrong_cross_currency_sum

finding
Naiveaccepted3/3Finalrejected17/19
  • test_cross_currency_cannot_net
05_double_entry
wrong_non_atomic

finding
Naiveaccepted3/3Finalrejected14/19
  • test_cross_currency_cannot_net
  • test_unknown_account_is_atomic
  • test_rejections_leave_no_trace
  • test_concurrent_reversals_once
05_double_entry
wrong_double_reverse

finding
Naiveaccepted3/3Finalrejected16/19
  • test_reverse_once
  • test_concurrent_reversals_once
06_available_balance
wrong_expired_holds_count

finding
Naiveaccepted3/3Finalrejected11/12
  • test_expiry_exactly_seven_days
07_statement_periods
wrong_fixed_offset

finding
Naiveaccepted3/3Finalrejected16/19
  • test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
  • test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
  • test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
09_tiered_fees
wrong_exclusive_boundary

finding
Naiveaccepted3/3Finalrejected18/19
  • test_inclusive_boundaries
09_tiered_fees
wrong_float_bps

finding
Naiveaccepted3/3Finalrejected16/19
  • test_discovered_float_ties[5000-3-2]
  • test_discovered_float_ties[25000-3-8]
  • test_discovered_float_ties[35000-3-11]
10_batch_parser
wrong_partial_accept

finding
Naiveaccepted3/3Finalrejected38/39
  • test_atomic_batch_rejection
10_batch_parser
wrong_ignores_hash_total

finding
Naiveaccepted3/3Finalrejected31/39
  • test_hash_total
  • test_atomic_batch_rejection
  • test_multiple_errors_and_line_numbers
  • test_field_formats[1-1-13-00000000012X-invalid_detail]
  • test_field_formats[1-13-25-000000000000-invalid_detail]
  • test_field_formats[1-13-25-00000000000X-invalid_detail]
  • test_field_formats[1-25-45- -invalid_detail]
  • test_field_formats[1-45-46-X-invalid_detail]
10_batch_parser
wrong_crlf_slicing

finding
Naiveaccepted3/3Finalrejected34/39
  • test_crlf
  • test_mixed_terminators_and_optional_final[]
  • test_mixed_terminators_and_optional_final[\n]
  • test_mixed_terminators_and_optional_final[\r\n]
  • test_roundtrip_and_single_digit_corruption

Held-out solutions

Independently written solutions the graders were not designed against. Counts are solutions, not tests. Wrong solutions rejected: 46/46 (100.0%). Correct solutions accepted: 6/6 (100.0%). Baseline before hardening (results/final/heldout-baseline.json): wrong rejected 6/44 (13.6%).

TaskWrong rejectedCorrect accepted
01_idempotent_payments5 of 51 of 1
02_interest_accrual5 of 50 of 0
03_fx_quotes4 of 40 of 0
04_ledger_reconciliation7 of 71 of 1
05_double_entry6 of 61 of 1
06_available_balance4 of 41 of 1
07_statement_periods3 of 30 of 0
08_refund_limits1 of 11 of 1
09_tiered_fees2 of 20 of 0
10_batch_parser9 of 91 of 1

Runs by task

Each run lists its failed tests first, then the tail of its pytest output.

01_idempotent_paymentsMake payment creation idempotent

A payment endpoint must charge a card once per Idempotency-Key, no matter how the client retries.

What the hardened grader checks

  • A sequential retry replays the original response without a second charge
  • Ten simultaneous duplicates produce exactly one charge
  • The same key used by two merchants creates two independent payments
  • Reusing a key with a different body is rejected, even while the first request is in flight
  • Declines are replayed, and replays survive an app restart
Ticket (PROBLEM.md)
# Ticket: payment retries must be idempotent

Our synthetic payment service already charges a declared gateway and returns a receipt.
Clients retry requests after losing a response. Implement the following observable contract.

Public interface: `app.main.create_app(database_url: str, gateway: Gateway) -> FastAPI`;
`app.gateway.Gateway.charge(merchant_id, amount_minor, currency, source_token) -> ChargeResult`.
`ChargeResult(charge_id: str, status: "succeeded"|"declined")` is a frozen dataclass.
The starter also exports `app.gateway.FakeGateway` for visible and naive smoke tests;
its `.calls` property is a tuple of charging calls. Hidden final tests inject their own Gateway.
`POST /v1/payments` takes `X-Merchant-Id`, `Idempotency-Key`, and JSON
`{amount_minor, currency, source_token}`. Money is positive integer minor units, never bool.
Currencies supported: CAD/USD/EUR/GBP (2 dp), JPY (0), KWD/BHD (3). No conversion occurs.

- Key required, length 1–255 characters; missing/empty/too long: HTTP 400
  `idempotency_key_required`. Merchant is a nonempty string; missing: 400 `merchant_required`.
- Invalid JSON body/fields: 422 `invalid_payment`. Ignore JSON member ordering. Body equality
  means equality of the three validated fields; no extra fields are accepted.
- Keys are scoped per merchant. Same merchant, key and body replay the original status and JSON
  exactly, without another gateway call. Declines (402 `card_declined`) also replay exactly.
- Same merchant and key with a different body: 422 `idempotency_key_reuse`, no charge,
  whether the first request is complete or in flight.
- Concurrent identical duplicates cause exactly one gateway charge. Each duplicate either gets
  the original response or HTTP 409 `idempotency_request_in_progress`. A later retry must replay.
- This behavior survives creating another app instance against the same database; no expiry.
- A successful receipt is HTTP 201 with `payment_id`, `charge_id`, `status` ("succeeded"),
  `amount_minor`, `currency`, and `merchant_id`. The payment id is nonempty and unique for distinct
  payments. Do not compare an id to any particular UUID format.
- Errors are JSON `{"error":{"code":"<snake_case>","message":"..."}}`; message text is free.
  No gateway exceptions or crash recovery are in scope. A charge returns normally with either status.

Preserve the factory and gateway protocol. Store state in the supplied PostgreSQL database;
the application may create its own schema. The schema must exist when `create_app()` returns;
HTTP requests must work without running application lifespan/startup handlers.
Tests inspect only the declared HTTP surface and
gateway calls. Timestamps in stored receipts use aware UTC; no timestamp field is required in JSON.
Run visible tests from starter: `python -m pytest tests -q` with `DATABASE_URL` set to a disposable
database. No customer data or real payment network is involved.
Reviewer notes (NOTES.md)
# Private draft — task 01 reviewer notes

The reservation implementation returns 409 during a charge; the advisory-lock implementation waits.
Both are valid under the public contract. Wrong variants isolate a realistic persistence,
decline-replay, or check-then-write defect; none change visible tests.

v2 note (see analysis/findings.md F-108): the starter now also lacks the request fingerprint and the
Idempotency-Key length validation (they moved into the reference), raising `ref_diff_lines` from 33 to 41.

## v3 wrong-variant replacement

Per human sign-off, `wrong_uncached_decline` replaces the regressive `wrong_key_scope`.
It extends the starter's PaymentStore with persistent, merchant-scoped successful-response replay
under a transaction lock, but treats a declined charge as retryable and never reserves its key.
The existing `test_decline_replayed` catches the extra gateway call; the starter also fails it.
See analysis/findings.md F-301 for predictions and measured evidence; no regression rationale is needed.

## Blind review
Author self-review, restricted to the ticket and starter (independent review still owed).
Ambiguities found: JSON equality vs member order, key length errors, merchant validation, receipt
fields, and failures during gateway calls. Resolved in PROBLEM.md: validated-field equality,
explicit status codes, free message text, and crash recovery excluded.
Three withheld-output predictions: missing key -> 400/idempotency_key_required; same key on
another merchant -> distinct 201 payment; completed decline retry -> identical 402 without charge.
No unresolved graded ambiguity after the ticket revision. This is not an independent model review.

## Flake measurement

Native Windows / PostgreSQL 16.13, 10 simultaneous callers, 150 ms gateway delay, 25 fresh-key rounds per test.
v2 re-measurement (starter changed, so the 20-repeat check was re-run; commit ce7d671, clean tree):
60 final runs (20 per variant), 0 flips, all 60 verdicts ok. Wall time 149.441 s.
Per-run duration_s in repeat order:

- reference: 13.02, 12.577, 12.788, 12.738, 12.861, 12.731, 12.8, 12.917, 12.473, 12.479, 12.751, 12.489, 12.737, 12.733, 12.883, 12.804, 12.75, 12.489, 12.681, 12.685
- alt_valid: 11.321, 11.304, 11.212, 11.318, 11.523, 11.385, 11.76, 11.727, 11.295, 11.281, 11.534, 11.466, 11.753, 11.659, 11.828, 11.745, 12.634, 12.548, 12.544, 12.524
- wrong_retry_race: 5.362, 5.279, 5.119, 5.252, 4.921, 4.841, 4.741, 4.735, 4.979, 4.991, 5.289, 5.103, 5.531, 5.53, 5.556, 5.549, 5.558, 5.549, 5.345, 5.349

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 2/17 tests passed
Failed tests
  • test_hidden_payments::test_sequential_retry
  • test_hidden_payments::test_concurrent_duplicates_charge_once
  • test_hidden_payments::test_body_mismatch[change0]
  • test_hidden_payments::test_body_mismatch[change1]
  • test_hidden_payments::test_body_mismatch[change2]
  • test_hidden_payments::test_decline_replayed
  • test_hidden_payments::test_body_mismatch_while_in_flight
  • test_hidden_payments::test_replay_survives_app_restart
  • test_hidden_payments::test_required_key[None]
  • test_hidden_payments::test_required_key[]
  • test_hidden_payments::test_required_key[xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]
  • test_hidden_payments::test_valid_key_lengths_and_reordered_body[1]
  • test_hidden_payments::test_valid_key_lengths_and_reordered_body[100]
  • test_hidden_payments::test_valid_key_lengths_and_reordered_body[255]
  • test_hidden_payments::test_concurrent_duplicates_across_app_instances
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
5.19 s
Pytest output (last 40 lines)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

first = <Response [201 Created]>, second = <Response [201 Created]>

    def same(first, second):
        assert first.status_code == second.status_code
>       assert first.json() == second.json()
E       AssertionError: assert {'amount_mino...t_alpha', ...} == {'amount_mino...t_alpha', ...}
E         
E         Omitting 4 identical items, use -vv to show
E         Differing items:
E         {'charge_id': 'fd39eeee-362a-4747-b2eb-1af04a1182c4'} != {'charge_id': 'd266ff39-fc9b-4852-b281-be83c4e93e0e'}
E         {'payment_id': '213ac379-7414-4837-afbb-66a2632bf0b2'} != {'payment_id': '52c0b256-17b2-4fcc-9ab0-b482f0ac9253'}
E         Use -v to get more diff

..\grader\test_hidden_payments.py:35: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_zlv6xmis\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_payments.py::test_sequential_retry - AssertionEr...
FAILED ..\grader\test_hidden_payments.py::test_concurrent_duplicates_charge_once
FAILED ..\grader\test_hidden_payments.py::test_body_mismatch[change0] - asser...
FAILED ..\grader\test_hidden_payments.py::test_body_mismatch[change1] - asser...
FAILED ..\grader\test_hidden_payments.py::test_body_mismatch[change2] - asser...
FAILED ..\grader\test_hidden_payments.py::test_decline_replayed - AssertionEr...
FAILED ..\grader\test_hidden_payments.py::test_body_mismatch_while_in_flight
FAILED ..\grader\test_hidden_payments.py::test_replay_survives_app_restart - ...
FAILED ..\grader\test_hidden_payments.py::test_required_key[None] - assert 20...
FAILED ..\grader\test_hidden_payments.py::test_required_key[] - assert 201 ==...
FAILED ..\grader\test_hidden_payments.py::test_required_key[xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]
FAILED ..\grader\test_hidden_payments.py::test_valid_key_lengths_and_reordered_body[1]
FAILED ..\grader\test_hidden_payments.py::test_valid_key_lengths_and_reordered_body[100]
FAILED ..\grader\test_hidden_payments.py::test_valid_key_lengths_and_reordered_body[255]
FAILED ..\grader\test_hidden_payments.py::test_concurrent_duplicates_across_app_instances
15 failed, 2 passed, 1 warning in 3.97s
Naive grader✓ starter: ok 1/3 tests passed
Failed tests
  • test_hidden::test_retry
  • test_hidden::test_missing_key
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
2.59 s
Pytest output (last 40 lines)
    def test_retry():
        gateway = FakeGateway()
        client = TestClient(create_app(os.environ["DATABASE_URL"], gateway))
        kwargs = dict(headers={"X-Merchant-Id": "merchant_retry", "Idempotency-Key": str(uuid4())},
                      json={"amount_minor": 100, "currency": "CAD", "source_token": "token_retry"})
        first = client.post("/v1/payments", **kwargs)
        second = client.post("/v1/payments", **kwargs)
        assert second.status_code == first.status_code == 201
>       assert second.json() == first.json()
E       AssertionError: assert {'amount_mino...t_retry', ...} == {'amount_mino...t_retry', ...}
E         
E         Omitting 4 identical items, use -vv to show
E         Differing items:
E         {'charge_id': 'd15d03d2-5793-48ab-b775-30c7dc75fd60'} != {'charge_id': 'a4053a58-8ff4-4397-844f-66820ff1fec0'}
E         {'payment_id': 'e4c0decc-d46d-4c6f-9496-22f194756215'} != {'payment_id': '636c5b84-dfc8-45f9-a50f-f08f0bc5296e'}
E         Use -v to get more diff

..\grader\test_hidden.py:24: AssertionError
______________________________ test_missing_key _______________________________

    def test_missing_key():
        client = TestClient(create_app(os.environ["DATABASE_URL"], FakeGateway()))
        response = client.post("/v1/payments", headers={"X-Merchant-Id": "merchant_none"},
                               json={"amount_minor": 100, "currency": "CAD", "source_token": "token"})
>       assert response.status_code == 400
E       assert 201 == 400
E        +  where 201 = <Response [201 Created]>.status_code

..\grader\test_hidden.py:32: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_nw1dyle5\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_retry - AssertionError: assert {'amount...
FAILED ..\grader\test_hidden.py::test_missing_key - assert 201 == 400
2 failed, 1 passed, 1 warning in 0.95s

referencereference

Final grader✓ reference: ok 17/17 tests passed
Failed tests

All 17 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
10.69 s
Pytest output (last 40 lines)
.................                                                        [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_8vn9kc_4\junit.xml -
17 passed, 1 warning in 9.29s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.93 s
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_f77xlw15\junit.xml -
3 passed, 1 warning in 0.64s
Diff against the starter
--- starter/app\contracts.py
+++ reference/app\contracts.py
@@ -1,5 +1,7 @@
 """Request and response construction shared by the HTTP adapter."""
 from dataclasses import dataclass
+import hashlib
+import json
 
 from fastapi.responses import JSONResponse
 
@@ -33,6 +35,10 @@
         return dict(amount_minor=self.amount_minor, currency=self.currency,
                     source_token=self.source_token)
 
+    def fingerprint(self):
+        canonical = json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
+        return hashlib.sha256(canonical.encode()).hexdigest()
+
 
 def error(status, code, message=None):
     return JSONResponse({"error": {"code": code, "message": message or code}}, status_code=status)
@@ -41,6 +47,8 @@
 def headers(merchant, key):
     if not merchant:
         return error(400, "merchant_required")
+    if key is None or not 1 <= len(key) <= 255:
+        return error(400, "idempotency_key_required")
     return None
 
 
--- starter/app\main.py
+++ reference/app\main.py
@@ -1,16 +1,22 @@
+import json
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
+from starlette.concurrency import run_in_threadpool
 from app.contracts import PaymentRequest, error, headers
 from app.storage import PaymentStore
 
 
 def create_app(database_url, gateway):
     store = PaymentStore(database_url)
+    with store.connect() as conn:
+        conn.execute("""CREATE TABLE IF NOT EXISTS idempotency_keys (
+            merchant text, key text, fingerprint text NOT NULL, state text NOT NULL,
+            response_code integer, response_json jsonb, PRIMARY KEY(merchant,key))""")
     app = FastAPI()
+
     @app.post("/v1/payments")
     async def pay(request: Request):
-        merchant = request.headers.get("X-Merchant-Id")
-        key = request.headers.get("Idempotency-Key")
+        merchant, key = request.headers.get("X-Merchant-Id"), request.headers.get("Idempotency-Key")
         invalid = headers(merchant, key)
         if invalid is not None:
             return invalid
@@ -18,12 +24,25 @@
             body = PaymentRequest.parse(await request.json())
         except (ValueError, TypeError):
             return error(422, "invalid_payment")
-        return await __import__("starlette.concurrency", fromlist=["run_in_threadpool"]).run_in_threadpool(
-            process, merchant, key, body)
+        return await run_in_threadpool(process, merchant, key, body)
 
     def process(merchant, key, body):
-
+        scope = merchant
+        with store.connect() as conn:
+            inserted = conn.execute("""INSERT INTO idempotency_keys VALUES (%s,%s,%s,'in_progress',NULL,NULL)
+                ON CONFLICT DO NOTHING RETURNING key""", (scope, key, body.fingerprint())).fetchone()
+            if not inserted:
+                row = conn.execute("""SELECT fingerprint,state,response_code,response_json
+                    FROM idempotency_keys WHERE merchant=%s AND key=%s""", (scope, key)).fetchone()
+                if row[0] != body.fingerprint():
+                    return error(422, "idempotency_key_reuse")
+                if row[1] != "complete":
+                    return error(409, "idempotency_request_in_progress")
+                return JSONResponse(row[3], status_code=row[2])
         charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
-        status, payload = store.save(merchant, body, charge)
-        return JSONResponse(payload, status_code=status)
+        with store.connect() as conn:
+            code, payload = store.save(merchant, body, charge, conn)
+            conn.execute("""UPDATE idempotency_keys SET state='complete',response_code=%s,response_json=%s
+                WHERE merchant=%s AND key=%s""", (code, json.dumps(payload), scope, key))
+        return JSONResponse(payload, status_code=code)
     return app

alt_validalt_valid

Final grader✓ alt_valid: ok 17/17 tests passed
Failed tests

All 17 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
9.56 s
Pytest output (last 40 lines)
.................                                                        [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_vrtv9ow1\junit.xml -
17 passed, 1 warning in 7.91s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.50 s
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_keypjkng\junit.xml -
3 passed, 1 warning in 0.72s
Diff against the starter
--- starter/app\main.py
+++ alt_valid/app\main.py
@@ -1,29 +1,54 @@
+"""Serialize a request identity for the lifetime of its charging transaction."""
+from datetime import datetime, timezone
+import json
+from uuid import uuid4
+import psycopg
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
-from app.contracts import PaymentRequest, error, headers
-from app.storage import PaymentStore
+from starlette.concurrency import run_in_threadpool
+from app.contracts import PaymentRequest, error, headers, receipt
 
 
 def create_app(database_url, gateway):
-    store = PaymentStore(database_url)
-    app = FastAPI()
-    @app.post("/v1/payments")
-    async def pay(request: Request):
-        merchant = request.headers.get("X-Merchant-Id")
-        key = request.headers.get("Idempotency-Key")
-        invalid = headers(merchant, key)
-        if invalid is not None:
-            return invalid
+    with psycopg.connect(database_url) as setup:
+        setup.execute("""CREATE TABLE IF NOT EXISTS payment_receipts (
+            id text PRIMARY KEY, merchant text, request_key text, request_body jsonb,
+            response jsonb, code int, created_at timestamptz,
+            UNIQUE (merchant, request_key))""")
+
+    def serialized_charge(merchant, key, parsed):
+        with psycopg.connect(database_url) as transaction:
+            lock_identity = json.dumps([merchant, key])
+            transaction.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (lock_identity,))
+            previous = transaction.execute("""SELECT request_body,response,code FROM payment_receipts
+                WHERE merchant=%s AND request_key=%s""", (merchant, key)).fetchone()
+            if previous is not None:
+                if previous[0] != parsed.as_dict():
+                    return error(422, "idempotency_key_reuse")
+                return JSONResponse(previous[1], status_code=previous[2])
+            result = gateway.charge(merchant, parsed.amount_minor, parsed.currency, parsed.source_token)
+            identifier = str(uuid4())
+            status, response = receipt(identifier, merchant, parsed, result)
+            transaction.execute("INSERT INTO payment_receipts VALUES (%s,%s,%s,%s,%s,%s,%s)",
+                                (identifier, merchant, key, json.dumps(parsed.as_dict()),
+                                 json.dumps(response), status, datetime.now(timezone.utc)))
+            return JSONResponse(response, status_code=status)
+
+    api = FastAPI()
+
+    @api.post("/v1/payments")
+    async def payment_endpoint(request: Request):
+        merchant_id = request.headers.get("X-Merchant-Id")
+        request_key = request.headers.get("Idempotency-Key")
+        invalid_headers = headers(merchant_id, request_key)
+        if invalid_headers is not None:
+            return invalid_headers
+        if request_key is None or not 1 <= len(request_key) <= 255:
+            return error(400, "idempotency_key_required")
         try:
-            body = PaymentRequest.parse(await request.json())
+            parsed = PaymentRequest.parse(await request.json())
         except (ValueError, TypeError):
             return error(422, "invalid_payment")
-        return await __import__("starlette.concurrency", fromlist=["run_in_threadpool"]).run_in_threadpool(
-            process, merchant, key, body)
+        return await run_in_threadpool(serialized_charge, merchant_id, request_key, parsed)
 
-    def process(merchant, key, body):
-
-        charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
-        status, payload = store.save(merchant, body, charge)
-        return JSONResponse(payload, status_code=status)
-    return app
+    return api

wrong_inmemory_cachewrong

Final grader✓ wrong_inmemory_cache: ok 15/17 tests passed
Failed tests
  • test_hidden_payments::test_replay_survives_app_restart declared must_fail
  • test_hidden_payments::test_concurrent_duplicates_across_app_instances
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
6.12 s
Hardening
caught by hardening
Pytest output (last 40 lines)
                        barrier.wait()
                        return client.post([left, right][index % 2] + "/v1/payments", **args)
    
                    responses = list(pool.map(call, range(8)))
                    successes = [r for r in responses if r.status_code == 201]
                    assert successes, [r.status_code for r in responses]
                    for response in responses:
                        if response.status_code == 409:
                            assert response.json()["error"]["code"] == "idempotency_request_in_progress"
                        else:
>                           same(successes[0], response)

..\grader\test_hidden_payments.py:227: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

first = <Response [201 Created]>, second = <Response [201 Created]>

    def same(first, second):
        assert first.status_code == second.status_code
>       assert first.json() == second.json()
E       AssertionError: assert {'amount_mino...t_alpha', ...} == {'amount_mino...t_alpha', ...}
E         
E         Omitting 4 identical items, use -vv to show
E         Differing items:
E         {'charge_id': '1773f353-f4a5-473a-ac31-f40452de564a'} != {'charge_id': '65240c24-3115-4ca3-9996-5ee9c43c459e'}
E         {'payment_id': 'a5a8de47-5586-4d10-b85f-820ad75ad37b'} != {'payment_id': 'e2fe76b5-469e-41b7-a66c-0279e393216f'}
E         Use -v to get more diff

..\grader\test_hidden_payments.py:35: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_f5aei643\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_payments.py::test_replay_survives_app_restart - ...
FAILED ..\grader\test_hidden_payments.py::test_concurrent_duplicates_across_app_instances
2 failed, 15 passed, 1 warning in 5.35s
Naive grader✗ wrong_inmemory_cache: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.79 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_vgk1gtxd\junit.xml -
3 passed, 1 warning in 0.71s
Diff against the starter
--- starter/app\contracts.py
+++ wrong_inmemory_cache/app\contracts.py
@@ -1,5 +1,7 @@
 """Request and response construction shared by the HTTP adapter."""
 from dataclasses import dataclass
+import hashlib
+import json
 
 from fastapi.responses import JSONResponse
 
@@ -33,6 +35,10 @@
         return dict(amount_minor=self.amount_minor, currency=self.currency,
                     source_token=self.source_token)
 
+    def fingerprint(self):
+        canonical = json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
+        return hashlib.sha256(canonical.encode()).hexdigest()
+
 
 def error(status, code, message=None):
     return JSONResponse({"error": {"code": code, "message": message or code}}, status_code=status)
@@ -41,6 +47,8 @@
 def headers(merchant, key):
     if not merchant:
         return error(400, "merchant_required")
+    if key is None or not 1 <= len(key) <= 255:
+        return error(400, "idempotency_key_required")
     return None
 
 
--- starter/app\main.py
+++ wrong_inmemory_cache/app\main.py
@@ -1,3 +1,5 @@
+from threading import Lock
+
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
 from app.contracts import PaymentRequest, error, headers
@@ -6,6 +8,7 @@
 
 def create_app(database_url, gateway):
     store = PaymentStore(database_url)
+    cache, lock = {}, Lock()
     app = FastAPI()
     @app.post("/v1/payments")
     async def pay(request: Request):
@@ -23,7 +26,15 @@
 
     def process(merchant, key, body):
 
-        charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
-        status, payload = store.save(merchant, body, charge)
-        return JSONResponse(payload, status_code=status)
+        with lock:
+            identity = (merchant, key)
+            if identity in cache:
+                fingerprint, status, payload = cache[identity]
+                if fingerprint != body.fingerprint():
+                    return error(422, "idempotency_key_reuse")
+            else:
+                charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
+                status, payload = store.save(merchant, body, charge)
+                cache[identity] = (body.fingerprint(), status, payload)
+            return JSONResponse(payload, status_code=status)
     return app

wrong_retry_racewrong

Final grader✓ wrong_retry_race: ok 14/17 tests passed
Failed tests
  • test_hidden_payments::test_concurrent_duplicates_charge_once declared must_fail
  • test_hidden_payments::test_body_mismatch_while_in_flight
  • test_hidden_payments::test_concurrent_duplicates_across_app_instances
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
7.19 s
Hardening
caught by hardening
Pytest output (last 40 lines)
                    barrier = threading.Barrier(8, timeout=15)
    
                    def call(index):
                        barrier.wait()
                        return client.post([left, right][index % 2] + "/v1/payments", **args)
    
                    responses = list(pool.map(call, range(8)))
                    successes = [r for r in responses if r.status_code == 201]
                    assert successes, [r.status_code for r in responses]
                    for response in responses:
                        if response.status_code == 409:
                            assert response.json()["error"]["code"] == "idempotency_request_in_progress"
                        else:
>                           same(successes[0], response)

..\grader\test_hidden_payments.py:227: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

first = <Response [201 Created]>
second = <Response [500 Internal Server Error]>

    def same(first, second):
>       assert first.status_code == second.status_code
E       assert 201 == 500
E        +  where 201 = <Response [201 Created]>.status_code
E        +  and   500 = <Response [500 Internal Server Error]>.status_code

..\grader\test_hidden_payments.py:34: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_vx501z65\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_payments.py::test_concurrent_duplicates_charge_once
FAILED ..\grader\test_hidden_payments.py::test_body_mismatch_while_in_flight
FAILED ..\grader\test_hidden_payments.py::test_concurrent_duplicates_across_app_instances
3 failed, 14 passed, 1 warning in 5.59s
Naive grader✗ wrong_retry_race: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.36 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_pn4q7na8\junit.xml -
3 passed, 1 warning in 0.69s
Diff against the starter
--- starter/app\contracts.py
+++ wrong_retry_race/app\contracts.py
@@ -1,5 +1,7 @@
 """Request and response construction shared by the HTTP adapter."""
 from dataclasses import dataclass
+import hashlib
+import json
 
 from fastapi.responses import JSONResponse
 
@@ -33,6 +35,10 @@
         return dict(amount_minor=self.amount_minor, currency=self.currency,
                     source_token=self.source_token)
 
+    def fingerprint(self):
+        canonical = json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
+        return hashlib.sha256(canonical.encode()).hexdigest()
+
 
 def error(status, code, message=None):
     return JSONResponse({"error": {"code": code, "message": message or code}}, status_code=status)
@@ -41,6 +47,8 @@
 def headers(merchant, key):
     if not merchant:
         return error(400, "merchant_required")
+    if key is None or not 1 <= len(key) <= 255:
+        return error(400, "idempotency_key_required")
     return None
 
 
--- starter/app\main.py
+++ wrong_retry_race/app\main.py
@@ -1,16 +1,23 @@
+import json
+import psycopg
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
+from starlette.concurrency import run_in_threadpool
 from app.contracts import PaymentRequest, error, headers
 from app.storage import PaymentStore
 
 
 def create_app(database_url, gateway):
     store = PaymentStore(database_url)
+    with store.connect() as conn:
+        conn.execute("""CREATE TABLE IF NOT EXISTS idempotency_keys (
+            merchant text, key text, fingerprint text NOT NULL, state text NOT NULL,
+            response_code integer, response_json jsonb, PRIMARY KEY(merchant,key))""")
     app = FastAPI()
+
     @app.post("/v1/payments")
     async def pay(request: Request):
-        merchant = request.headers.get("X-Merchant-Id")
-        key = request.headers.get("Idempotency-Key")
+        merchant, key = request.headers.get("X-Merchant-Id"), request.headers.get("Idempotency-Key")
         invalid = headers(merchant, key)
         if invalid is not None:
             return invalid
@@ -18,12 +25,25 @@
             body = PaymentRequest.parse(await request.json())
         except (ValueError, TypeError):
             return error(422, "invalid_payment")
-        return await __import__("starlette.concurrency", fromlist=["run_in_threadpool"]).run_in_threadpool(
-            process, merchant, key, body)
+        return await run_in_threadpool(process, merchant, key, body)
 
     def process(merchant, key, body):
-
+        scope = merchant
+        with store.connect() as conn:
+            row = conn.execute("""SELECT fingerprint,state,response_code,response_json
+                FROM idempotency_keys WHERE merchant=%s AND key=%s""", (scope, key)).fetchone()
+            if row:
+                if row[0] != body.fingerprint():
+                    return error(422, "idempotency_key_reuse")
+                return JSONResponse(row[3], status_code=row[2])
         charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
-        status, payload = store.save(merchant, body, charge)
-        return JSONResponse(payload, status_code=status)
+        with store.connect() as conn:
+            code, payload = store.save(merchant, body, charge, conn)
+            try:
+                conn.execute("""INSERT INTO idempotency_keys VALUES (%s,%s,%s,'complete',%s,%s)""",
+                             (scope, key, body.fingerprint(), code, json.dumps(payload)))
+            except psycopg.errors.UniqueViolation:
+                conn.rollback()
+                return error(500, "payment_conflict")
+        return JSONResponse(payload, status_code=code)
     return app

wrong_uncached_declinewrong

Final grader✓ wrong_uncached_decline: ok 16/17 tests passed
Failed tests
  • test_hidden_payments::test_decline_replayed declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
8.99 s
Hardening
caught by hardening
Pytest output (last 40 lines)
......F..........                                                        [100%]
================================== FAILURES ===================================
____________________________ test_decline_replayed ____________________________

database_url = 'user=bcp_01_idempotent_payments__wrong_uncached_dec_654_u dbname=bcp_01_idempotent_payments__wrong_uncached_dec_654310eedde4 host=localhost port=54329 password=NdI7LWLaxkm4iSTWzV3qS0Ds'

    def test_decline_replayed(database_url):
        gateway = Gateway(declined=True)
        client = TestClient(create_app(database_url, gateway))
        args = request()
        first = client.post("/v1/payments", **args)
        assert first.status_code == 402
        assert first.json()["error"]["code"] == "card_declined"
        same(first, client.post("/v1/payments", **args))
>       assert len(gateway.calls) == 1
E       AssertionError: assert 2 == 1
E        +  where 2 = len([('merchant_alpha', 1250, 'CAD', 'token_synthetic'), ('merchant_alpha', 1250, 'CAD', 'token_synthetic')])
E        +    where [('merchant_alpha', 1250, 'CAD', 'token_synthetic'), ('merchant_alpha', 1250, 'CAD', 'token_synthetic')] = <test_hidden_payments.Gateway object at 0x000001BFBB743790>.calls

..\grader\test_hidden_payments.py:113: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_f59hd8u2\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_payments.py::test_decline_replayed - AssertionEr...
1 failed, 16 passed, 1 warning in 8.33s
Naive grader✗ wrong_uncached_decline: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.43 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_yngzjs1_\junit.xml -
3 passed, 1 warning in 0.58s
Diff against the starter
--- starter/app\main.py
+++ wrong_uncached_decline/app\main.py
@@ -1,12 +1,22 @@
+import json
+
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
+from starlette.concurrency import run_in_threadpool
+
 from app.contracts import PaymentRequest, error, headers
 from app.storage import PaymentStore
 
 
 def create_app(database_url, gateway):
     store = PaymentStore(database_url)
+    with store.connect() as conn:
+        conn.execute("""CREATE TABLE IF NOT EXISTS payment_replays (
+            merchant text, key text, request_json jsonb NOT NULL,
+            response_code integer NOT NULL, response_json jsonb NOT NULL,
+            PRIMARY KEY (merchant, key))""")
     app = FastAPI()
+
     @app.post("/v1/payments")
     async def pay(request: Request):
         merchant = request.headers.get("X-Merchant-Id")
@@ -14,16 +24,31 @@
         invalid = headers(merchant, key)
         if invalid is not None:
             return invalid
+        if key is None or not 1 <= len(key) <= 255:
+            return error(400, "idempotency_key_required")
         try:
             body = PaymentRequest.parse(await request.json())
         except (ValueError, TypeError):
             return error(422, "invalid_payment")
-        return await __import__("starlette.concurrency", fromlist=["run_in_threadpool"]).run_in_threadpool(
-            process, merchant, key, body)
+        return await run_in_threadpool(process, merchant, key, body)
 
     def process(merchant, key, body):
+        with store.connect() as conn:
+            conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s), hashtext(%s))",
+                         (merchant, key))
+            previous = conn.execute("""SELECT request_json, response_code, response_json
+                FROM payment_replays WHERE merchant=%s AND key=%s""",
+                                    (merchant, key)).fetchone()
+            if previous:
+                if previous[0] != body.as_dict():
+                    return error(422, "idempotency_key_reuse")
+                return JSONResponse(previous[2], status_code=previous[1])
+            charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
+            status, payload = store.save(merchant, body, charge, conn)
+            # Only completed payments reserve the key; a decline can be attempted again.
+            if charge.status == "succeeded":
+                conn.execute("INSERT INTO payment_replays VALUES (%s,%s,%s,%s,%s)",
+                             (merchant, key, json.dumps(body.as_dict()), status, json.dumps(payload)))
+            return JSONResponse(payload, status_code=status)
 
-        charge = gateway.charge(merchant, body.amount_minor, body.currency, body.source_token)
-        status, payload = store.save(merchant, body, charge)
-        return JSONResponse(payload, status_code=status)
     return app
02_interest_accrualAccrue CAD interest at posting boundaries

Accrue daily interest on a CAD balance and post it at month ends, using Actual/365 and banker's rounding.

What the hardened grader checks

  • Half-cent ties round to even, not up
  • A 1,000,000,000.00 principal is exact to the cent
  • Small balances still accrue under daily compounding
  • Leap days and partial first and last months
  • Totals reconcile: final balance equals principal plus every posting
Ticket (PROBLEM.md)
# Ticket: accrue interest across partial calendar months

The synthetic CAD account needs correct daily accrual and explicit posting boundaries.
Keep `interest.accrual.accrue(principal: Decimal, annual_rate: Decimal, start: date, end: date,
method: Literal["simple_monthly","daily_compound"]) -> list[Posting]` and
`Posting(posting_date: date, interest: Decimal, balance: Decimal)` (frozen dataclass).

CAD has 2 decimal places. Principal is a nonnegative finite Decimal in whole cents; rate is
a nonnegative finite Decimal (a fraction, e.g. 0.05 means 5%). Invalid values/method or end before
start raise ValueError. These are dates, not timestamps; any datetime instead of date is invalid.

- Day count is Actual/365 Fixed, including leap years. Accrue for every date in `[start,end)`.
- Post on each month end within that period, and also on `end - 1 day` if it is not a month end.
  Return chronological postings. `start == end` returns `[]`.
- `simple_monthly`: interest at a posting equals current posted balance × rate × segment days / 365.
  Add each posting to the balance; thus it compounds at posting boundaries.
- `daily_compound`: each date, accrued += (posted balance + accrued) × rate / 365 with full
  intermediate precision. At posting, round accrued, add to balance, reset accrued to zero;
  discard the unrounded residual.
- Round only the posting to 0.01 using ROUND_HALF_EVEN. Intermediate arithmetic must use
  precision at least 34 or exact arithmetic. Mathematically exact half-cent ties must round
  half-even, without intermediate rounding changing which side of the tie is selected.
  Both money fields must be Decimal with exponent -2,
  even when zero. Return the resulting balance after each posting.

Worked example: principal 1000.00, rate 0.0365 (so 0.0001 per day), start 2026-01-01, end 2026-03-01.
Both methods post on 2026-01-31 (31 days) and 2026-02-28 (28 days). January: 1000.00 x 0.0365 x 31 / 365 =
3.10, balance 1003.10. February: 1003.10 x 0.0365 x 28 / 365 = 2.8087..., posted as 2.81, balance 1005.91.
(With `daily_compound` the unrounded January interest is 3.1048...; both methods round to the same cents here.)

Only the declared interface is graded; internal representation and algorithms are unrestricted.
Run from starter: `python -m pytest tests -q`. All examples and accounts are synthetic.
Reviewer notes (NOTES.md)
# Private draft — interest reviewer notes

Reference accumulates daily in Decimal (34 digits), multiplying the simple segment in one
expression to preserve exact ties. Alternative uses closed-form Fraction compounding by segment.
The floating candidate is financially plausible and converts results back to Decimal; type
checking alone cannot reject it. Actual/Actual is a plausible but incorrect day-count convention here.

## v3 wrong-variant replacement

Per human sign-off, `wrong_actual_actual` replaces the regressive `wrong_round_daily`.
It completes the starter using its calendar segments and Decimal arithmetic, but divides by 366
in leap years rather than using Actual/365 Fixed. Existing leap-year, large-principal and property
tests catch it, and the starter also fails those tests. See analysis/findings.md F-301 for predictions
and measured evidence; no regression rationale is needed.

## Blind review
Author self-review using only ticket and starter; independent review remains owed.
Ambiguities: negative rates/principal, initial cents, datetime vs date, rounding residuals, empty
periods. Resolved explicitly in PROBLEM.md.
Predictions: 50 CAD at 0.0365 for one day -> 0.00 interest; zero days -> []; 1000 CAD at 0.0365
for ten simple days -> 1.00 interest and 1001.00 balance. No unresolved graded ambiguity.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 24/33 tests passed
Failed tests
  • test_hidden_interest::test_half_cent_ties_bankers[50-0.0365-1]
  • test_hidden_interest::test_half_cent_ties_bankers[73-0.025-1]
  • test_hidden_interest::test_half_cent_ties_bankers[25-0.0365-2]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-29]
  • test_hidden_interest::test_large_principal_exact[simple_monthly]
  • test_hidden_interest::test_large_principal_exact[daily_compound]
  • test_hidden_interest::test_leap_and_partial
  • test_hidden_interest::test_accrual_properties
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.00 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..\grader\test_hidden_interest.py:95: in test_accrual_properties
    rows = check(principal, annual_rate, start, end, method)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

principal = Decimal('5573.85'), rate = Decimal('0.1500')
start = datetime.date(2024, 1, 13), end = datetime.date(2024, 2, 17)
method = 'simple_monthly'

    def check(principal, rate, start, end, method):
        rows = accrue(principal, rate, start, end, method)
        for row in rows:
            assert_money(row.interest)
            assert_money(row.balance)
>       assert [(r.posting_date, r.interest, r.balance) for r in rows] == oracle(principal, rate, start, end, method)
E       AssertionError: assert [(datetime.da...l('5617.37'))] == [(datetime.da...l('5654.31'))]
E         
E         Right contains one more item: (datetime.date(2024, 2, 16), Decimal('36.94'), Decimal('5654.31'))
E         Use -v to get more diff
E       Failing test case: test_accrual_properties(
E           cents=557385,
E           rate=1500,
E           offset=12,
E           days=35,
E           method='simple_monthly',
E       )

..\grader\test_hidden_interest.py:34: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run__fa9icp6\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[50-0.0365-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[73-0.025-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[25-0.0365-2]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-29]
FAILED ..\grader\test_hidden_interest.py::test_large_principal_exact[simple_monthly]
FAILED ..\grader\test_hidden_interest.py::test_large_principal_exact[daily_compound]
FAILED ..\grader\test_hidden_interest.py::test_leap_and_partial - AssertionEr...
FAILED ..\grader\test_hidden_interest.py::test_accrual_properties - Assertion...
9 failed, 24 passed in 0.23s
Naive grader✗ starter: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.75 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_5i6d48ew\junit.xml -
4 passed in 0.03s

referencereference

Final grader✓ reference: ok 33/33 tests passed
Failed tests

All 33 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.20 s
Pytest output (last 40 lines)
.................................                                        [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_gngprbsr\junit.xml -
33 passed in 0.48s
Naive grader✓ reference: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.80 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_0d3lpkcz\junit.xml -
4 passed in 0.03s
Diff against the starter
--- starter/interest\accrual.py
+++ reference/interest\accrual.py
@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from datetime import date, timedelta
-from decimal import Decimal
+from decimal import Decimal, localcontext, ROUND_HALF_EVEN
 from interest.calendar import is_month_end, validate
 
 
@@ -13,19 +13,23 @@
 
 def accrue(principal, annual_rate, start, end, method):
     validate(principal, annual_rate, start, end, method)
-    balance, rate, accumulated, days = float(principal), float(annual_rate), 0.0, 0
-    day, result = start, []
-    while day < end:
-        days += 1
-        if method == "daily_compound":
-            accumulated += (balance + accumulated) * rate / 365
-        else:
-            accumulated = balance * rate * days / 365
-        if is_month_end(day):
-            interest = round(accumulated, 2)
-            balance = round(balance + interest, 2)
-            result.append(Posting(day, Decimal(str(interest)).quantize(Decimal("0.01")),
-                                  Decimal(str(balance)).quantize(Decimal("0.01"))))
-            accumulated, days = 0.0, 0
-        day += timedelta(days=1)
-    return result
+    with localcontext() as context:
+        context.prec = 34
+        cent = Decimal("0.01")
+        balance = principal.quantize(cent)
+        accrued = Decimal(0)
+        result = []
+        days = 0
+        day = start
+        while day < end:
+            base = balance + accrued if method == "daily_compound" else balance
+            days += 1
+            accrued = (accrued + base * annual_rate / 365) if method == "daily_compound" else balance * annual_rate * days / 365
+            if is_month_end(day) or day == end - timedelta(days=1):
+                interest = accrued.quantize(cent, rounding=ROUND_HALF_EVEN)
+                balance = (balance + interest).quantize(cent)
+                result.append(Posting(day, interest, balance))
+                accrued = Decimal(0)
+                days = 0
+            day += timedelta(days=1)
+        return result

alt_validalt_valid

Final grader✓ alt_valid: ok 33/33 tests passed
Failed tests

All 33 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.91 s
Pytest output (last 40 lines)
.................................                                        [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_r8vq9kl9\junit.xml -
33 passed in 0.31s
Naive grader✓ alt_valid: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.71 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_9vpw220q\junit.xml -
4 passed in 0.03s
Diff against the starter
--- starter/interest\accrual.py
+++ alt_valid/interest\accrual.py
@@ -1,7 +1,8 @@
 from dataclasses import dataclass
-from datetime import date, timedelta
+from datetime import date
 from decimal import Decimal
-from interest.calendar import is_month_end, validate
+from fractions import Fraction
+from interest.calendar import segments, validate
 
 
 @dataclass(frozen=True)
@@ -11,21 +12,29 @@
     balance: Decimal
 
 
+def cents(value):
+    quotient, remainder = divmod(value.numerator * 100, value.denominator)
+    if 2 * remainder > value.denominator or (2 * remainder == value.denominator and quotient % 2):
+        quotient += 1
+    return quotient
+
+
+def money(n):
+    return Decimal(f"{n // 100}.{n % 100:02d}")
+
+
 def accrue(principal, annual_rate, start, end, method):
     validate(principal, annual_rate, start, end, method)
-    balance, rate, accumulated, days = float(principal), float(annual_rate), 0.0, 0
-    day, result = start, []
-    while day < end:
-        days += 1
-        if method == "daily_compound":
-            accumulated += (balance + accumulated) * rate / 365
+    posted_cents = int(principal * 100)
+    factor = Fraction(annual_rate) / 365
+    postings = []
+    for segment in segments(start, end):
+        capital = Fraction(posted_cents, 100)
+        if method == "simple_monthly":
+            earnings = capital * factor * segment.days
         else:
-            accumulated = balance * rate * days / 365
-        if is_month_end(day):
-            interest = round(accumulated, 2)
-            balance = round(balance + interest, 2)
-            result.append(Posting(day, Decimal(str(interest)).quantize(Decimal("0.01")),
-                                  Decimal(str(balance)).quantize(Decimal("0.01"))))
-            accumulated, days = 0.0, 0
-        day += timedelta(days=1)
-    return result
+            earnings = capital * ((1 + factor) ** segment.days - 1)
+        rounded_cents = cents(earnings)
+        posted_cents += rounded_cents
+        postings.append(Posting(segment.last, money(rounded_cents), money(posted_cents)))
+    return postings

wrong_actual_actualwrong

Final grader✓ wrong_actual_actual: ok 29/33 tests passed
Failed tests
  • test_hidden_interest::test_large_principal_exact[simple_monthly]
  • test_hidden_interest::test_large_principal_exact[daily_compound]
  • test_hidden_interest::test_leap_and_partial declared must_fail
  • test_hidden_interest::test_accrual_properties declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.54 s
Hardening
caught by hardening
Pytest output (last 40 lines)
                   ^^^
           method=st.sampled_from(["simple_monthly", "daily_compound"]))

..\grader\test_hidden_interest.py:89: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\grader\test_hidden_interest.py:95: in test_accrual_properties
    rows = check(principal, annual_rate, start, end, method)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

principal = Decimal('5573.85'), rate = Decimal('0.1500')
start = datetime.date(2024, 1, 13), end = datetime.date(2024, 2, 17)
method = 'simple_monthly'

    def check(principal, rate, start, end, method):
        rows = accrue(principal, rate, start, end, method)
        for row in rows:
            assert_money(row.interest)
            assert_money(row.balance)
>       assert [(r.posting_date, r.interest, r.balance) for r in rows] == oracle(principal, rate, start, end, method)
E       AssertionError: assert [(datetime.da...l('5654.08'))] == [(datetime.da...l('5654.31'))]
E         
E         At index 0 diff: (datetime.date(2024, 1, 31), Decimal('43.40'), Decimal('5617.25')) != (datetime.date(2024, 1, 31), Decimal('43.52'), Decimal('5617.37'))
E         Use -v to get more diff
E       Failing test case: test_accrual_properties(
E           cents=557385,
E           rate=1500,
E           offset=12,
E           days=35,
E           method='simple_monthly',
E       )

..\grader\test_hidden_interest.py:34: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_a95hbscd\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_interest.py::test_large_principal_exact[simple_monthly]
FAILED ..\grader\test_hidden_interest.py::test_large_principal_exact[daily_compound]
FAILED ..\grader\test_hidden_interest.py::test_leap_and_partial - AssertionEr...
FAILED ..\grader\test_hidden_interest.py::test_accrual_properties - Assertion...
4 failed, 29 passed in 0.10s
Naive grader✗ wrong_actual_actual: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.47 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_315f0338\junit.xml -
4 passed in 0.02s
Diff against the starter
--- starter/interest\accrual.py
+++ wrong_actual_actual/interest\accrual.py
@@ -1,7 +1,9 @@
+import calendar
 from dataclasses import dataclass
-from datetime import date, timedelta
-from decimal import Decimal
-from interest.calendar import is_month_end, validate
+from datetime import date
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
+
+from interest.calendar import segments, validate
 
 
 @dataclass(frozen=True)
@@ -13,19 +15,20 @@
 
 def accrue(principal, annual_rate, start, end, method):
     validate(principal, annual_rate, start, end, method)
-    balance, rate, accumulated, days = float(principal), float(annual_rate), 0.0, 0
-    day, result = start, []
-    while day < end:
-        days += 1
-        if method == "daily_compound":
-            accumulated += (balance + accumulated) * rate / 365
-        else:
-            accumulated = balance * rate * days / 365
-        if is_month_end(day):
-            interest = round(accumulated, 2)
-            balance = round(balance + interest, 2)
-            result.append(Posting(day, Decimal(str(interest)).quantize(Decimal("0.01")),
-                                  Decimal(str(balance)).quantize(Decimal("0.01"))))
-            accumulated, days = 0.0, 0
-        day += timedelta(days=1)
+    result = []
+    with localcontext() as context:
+        context.prec = 34
+        cent = Decimal("0.01")
+        balance = principal.quantize(cent)
+        for segment in segments(start, end):
+            year_days = 366 if calendar.isleap(segment.first.year) else 365
+            if method == "simple_monthly":
+                accumulated = balance * annual_rate * segment.days / year_days
+            else:
+                accumulated = Decimal(0)
+                for _ in range(segment.days):
+                    accumulated += (balance + accumulated) * annual_rate / year_days
+            interest = accumulated.quantize(cent, rounding=ROUND_HALF_EVEN)
+            balance = (balance + interest).quantize(cent)
+            result.append(Posting(segment.last, interest, balance))
     return result

wrong_floatwrong

Final grader✓ wrong_float: ok 28/33 tests passed
Failed tests
  • test_hidden_interest::test_half_cent_ties_bankers[50-0.0365-1]
  • test_hidden_interest::test_half_cent_ties_bankers[73-0.025-1]
  • test_hidden_interest::test_half_cent_ties_bankers[25-0.0365-2]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-29]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.78 s
Hardening
caught by hardening
Pytest output (last 40 lines)
______________ test_half_cent_ties_bankers[237250.00-0.0001-29] _______________

principal = '237250.00', rate = '0.0001', days = 29

    @pytest.mark.parametrize("principal,rate,days", [
        ("50", "0.0365", 1), ("73", "0.025", 1), ("25", "0.0365", 2),
        ("237250.00", "0.0001", 1), ("237250.00", "0.0001", 29),
        ("237250.00", "0.0001", 31),
    ])
    def test_half_cent_ties_bankers(principal, rate, days):
        # Search output from tools/find_ties.py, independently cross-checked with Fraction.
>       check(D(principal), D(rate), date(2026, 1, 1), date(2026, 1, 1) + timedelta(days=days), "simple_monthly")

..\grader\test_hidden_interest.py:46: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

principal = Decimal('237250.00'), rate = Decimal('0.0001')
start = datetime.date(2026, 1, 1), end = datetime.date(2026, 1, 30)
method = 'simple_monthly'

    def check(principal, rate, start, end, method):
        rows = accrue(principal, rate, start, end, method)
        for row in rows:
            assert_money(row.interest)
            assert_money(row.balance)
>       assert [(r.posting_date, r.interest, r.balance) for r in rows] == oracle(principal, rate, start, end, method)
E       AssertionError: assert [(datetime.da...'237251.89'))] == [(datetime.da...'237251.88'))]
E         
E         At index 0 diff: (datetime.date(2026, 1, 29), Decimal('1.89'), Decimal('237251.89')) != (datetime.date(2026, 1, 29), Decimal('1.88'), Decimal('237251.88'))
E         Use -v to get more diff

..\grader\test_hidden_interest.py:34: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_r810n6ex\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[50-0.0365-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[73-0.025-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[25-0.0365-2]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-29]
5 failed, 28 passed in 0.31s
Naive grader✗ wrong_float: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.49 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_g_krp4wo\junit.xml -
4 passed in 0.02s
Diff against the starter
--- starter/interest\accrual.py
+++ wrong_float/interest\accrual.py
@@ -21,7 +21,7 @@
             accumulated += (balance + accumulated) * rate / 365
         else:
             accumulated = balance * rate * days / 365
-        if is_month_end(day):
+        if is_month_end(day) or day == end - timedelta(days=1):
             interest = round(accumulated, 2)
             balance = round(balance + interest, 2)
             result.append(Posting(day, Decimal(str(interest)).quantize(Decimal("0.01")),

wrong_half_upwrong

Final grader✓ wrong_half_up: ok 28/33 tests passed
Failed tests
  • test_hidden_interest::test_half_cent_ties_bankers[50-0.0365-1]
  • test_hidden_interest::test_half_cent_ties_bankers[73-0.025-1]
  • test_hidden_interest::test_half_cent_ties_bankers[25-0.0365-2]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-1]
  • test_hidden_interest::test_half_cent_ties_bankers[237250.00-0.0001-29]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.81 s
Hardening
caught by hardening
Pytest output (last 40 lines)
______________ test_half_cent_ties_bankers[237250.00-0.0001-29] _______________

principal = '237250.00', rate = '0.0001', days = 29

    @pytest.mark.parametrize("principal,rate,days", [
        ("50", "0.0365", 1), ("73", "0.025", 1), ("25", "0.0365", 2),
        ("237250.00", "0.0001", 1), ("237250.00", "0.0001", 29),
        ("237250.00", "0.0001", 31),
    ])
    def test_half_cent_ties_bankers(principal, rate, days):
        # Search output from tools/find_ties.py, independently cross-checked with Fraction.
>       check(D(principal), D(rate), date(2026, 1, 1), date(2026, 1, 1) + timedelta(days=days), "simple_monthly")

..\grader\test_hidden_interest.py:46: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

principal = Decimal('237250.00'), rate = Decimal('0.0001')
start = datetime.date(2026, 1, 1), end = datetime.date(2026, 1, 30)
method = 'simple_monthly'

    def check(principal, rate, start, end, method):
        rows = accrue(principal, rate, start, end, method)
        for row in rows:
            assert_money(row.interest)
            assert_money(row.balance)
>       assert [(r.posting_date, r.interest, r.balance) for r in rows] == oracle(principal, rate, start, end, method)
E       AssertionError: assert [(datetime.da...'237251.89'))] == [(datetime.da...'237251.88'))]
E         
E         At index 0 diff: (datetime.date(2026, 1, 29), Decimal('1.89'), Decimal('237251.89')) != (datetime.date(2026, 1, 29), Decimal('1.88'), Decimal('237251.88'))
E         Use -v to get more diff

..\grader\test_hidden_interest.py:34: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_3fbiawui\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[50-0.0365-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[73-0.025-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[25-0.0365-2]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-1]
FAILED ..\grader\test_hidden_interest.py::test_half_cent_ties_bankers[237250.00-0.0001-29]
5 failed, 28 passed in 0.33s
Naive grader✗ wrong_half_up: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.46 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_kg9x67z1\junit.xml -
4 passed in 0.02s
Diff against the starter
--- starter/interest\accrual.py
+++ wrong_half_up/interest\accrual.py
@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from datetime import date, timedelta
-from decimal import Decimal
+from decimal import Decimal, localcontext, ROUND_HALF_UP
 from interest.calendar import is_month_end, validate
 
 
@@ -13,19 +13,23 @@
 
 def accrue(principal, annual_rate, start, end, method):
     validate(principal, annual_rate, start, end, method)
-    balance, rate, accumulated, days = float(principal), float(annual_rate), 0.0, 0
-    day, result = start, []
-    while day < end:
-        days += 1
-        if method == "daily_compound":
-            accumulated += (balance + accumulated) * rate / 365
-        else:
-            accumulated = balance * rate * days / 365
-        if is_month_end(day):
-            interest = round(accumulated, 2)
-            balance = round(balance + interest, 2)
-            result.append(Posting(day, Decimal(str(interest)).quantize(Decimal("0.01")),
-                                  Decimal(str(balance)).quantize(Decimal("0.01"))))
-            accumulated, days = 0.0, 0
-        day += timedelta(days=1)
-    return result
+    with localcontext() as context:
+        context.prec = 34
+        cent = Decimal("0.01")
+        balance = principal.quantize(cent)
+        accrued = Decimal(0)
+        result = []
+        days = 0
+        day = start
+        while day < end:
+            base = balance + accrued if method == "daily_compound" else balance
+            days += 1
+            accrued = (accrued + base * annual_rate / 365) if method == "daily_compound" else balance * annual_rate * days / 365
+            if is_month_end(day) or day == end - timedelta(days=1):
+                interest = accrued.quantize(cent, rounding=ROUND_HALF_UP)
+                balance = (balance + interest).quantize(cent)
+                result.append(Posting(day, interest, balance))
+                accrued = Decimal(0)
+                days = 0
+            day += timedelta(days=1)
+        return result
03_fx_quotesQuote transfers with currency-specific minor units

Quote a currency transfer with the fee taken in the source currency and each currency's own decimal places.

What the hardened grader checks

  • JPY has 0 decimal places and KWD has 3
  • Cross rates via USD are multiplied without rounding the middle leg
  • The fee is deducted in the source currency before conversion
  • Exact half-unit ties round to even
  • Unknown pairs raise RateUnavailable
Ticket (PROBLEM.md)
# Ticket: quote a synthetic currency transfer

`fx.quote.quote_transfer(amount_minor:int,src:str,dst:str,rates:dict[tuple[str,str],Decimal],fee_bps:int)`
returns frozen `Quote(fee_minor:int,converted_minor:int,rate_used:Decimal,path:tuple[str,...])`.
Expose `RateUnavailable(ValueError)` for unavailable currencies/pairs.

Minor units: CAD/USD/EUR/GBP=2, JPY=0, KWD/BHD=3. Rates are positive finite Decimals in
destination major units per source major unit. All money values returned are int minor units.
Amount is nonnegative int, never bool; fee_bps integer in [0,10000]; invalid values -> ValueError.
Inputs use distinct src/dst; an unsupported currency -> RateUnavailable.

1. Fee in source minor units = amount_minor × fee_bps / 10000, rounded HALF_EVEN to integer.
   Deduct this fee BEFORE conversion. Returned fee_minor is this source fee.
2. Use direct `(src,dst)` rate if present, path `(src,dst)`. Otherwise require both `(src,"USD")`
   and `("USD",dst)`, multiply at full precision, path `(src,"USD",dst)`. Do not infer inverses or
   other routes. Missing required rates -> RateUnavailable. Direct rate takes precedence.
3. Convert net source minor units to source major units, apply the full rate, then scale to
   destination minor units. Round ONLY the final destination amount HALF_EVEN to integer.
   Do not round the USD leg of a cross quote. rate_used is the full direct/product Decimal.

Worked examples. (a) Cross quote: `rates = {("GBP","USD"): Decimal("1.25"), ("USD","CAD"): Decimal("1.40")}`,
`quote_transfer(20000, "GBP", "CAD", rates, 50)`: fee = 20000 x 50 / 10000 = 100 (1.00 GBP); net 19900 = 199.00 GBP;
rate_used = 1.25 x 1.40 = 1.7500; 199.00 x 1.7500 = 348.25 CAD, so `Quote(100, 34825, Decimal("1.7500"), ("GBP","USD","CAD"))`.
(b) Zero-decimal currency: `quote_transfer(5000, "CAD", "JPY", {("CAD","JPY"): Decimal("110")}, 0)`: 50.00 CAD x 110 = 5500 JPY,
so `Quote(0, 5500, Decimal("110"), ("CAD","JPY"))`.

Do not mutate rates. Zero amount returns zero amounts while still resolving the declared route.
No actual currency market or customer is represented. Run `python -m pytest tests -q` from starter.
Reviewer notes (NOTES.md)
# Private draft - FX reviewer notes

v2 starter (see analysis/findings.md F-110): the starter quotes direct pairs only, assumes two decimal
places for every currency, computes the fee but does not deduct it before conversion and has no cross
route through USD. Per-currency minor units, the cross rate at full precision, fee-before-conversion and
the precision rules are the ticket. The wrong variants are plausible completions of that starter:
`wrong_two_decimals` adds the cross route and fee deduction but keeps the starter's universal scale;
`wrong_round_cross_leg` rounds the USD leg of a cross quote to cents; `wrong_fee_after_conversion`
converts the gross amount and subtracts a destination-side fee. The exact rational alternative verifies
that the Decimal implementation does not define the answer by itself. Ordinary two-decimal direct
quotes can mask all three errors.

## Blind review
Author self-review: fee denomination, direct-rate precedence, unsupported currencies, inverse
rates, zero amount and acceptable fee range clarified in PROBLEM.md.
Predictions: JPY100 -> USD at0.01 ->100 cents; CAD3 at0.5 with5000bps ->fee2/converted0;
cross CAD1 via0.5 USD and1.5 EUR ->1 EUR cent. No unresolved graded ambiguity.
Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 16/22 tests passed
Failed tests
  • test_hidden_fx::test_jpy_and_kwd_units
  • test_hidden_fx::test_cross_has_no_intermediate_rounding
  • test_hidden_fx::test_source_fee_before_conversion
  • test_hidden_fx::test_full_precision_cross_rate[1.23456789-1.34567891]
  • test_hidden_fx::test_full_precision_cross_rate[1.12345678901234567890123456789-1.98765432109876543210987654321]
  • test_hidden_fx::test_exact_scaling_and_monotonicity
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.69 s
Pytest output (last 40 lines)
..\grader\test_hidden_fx.py:94: in test_exact_scaling_and_monotonicity
    first = check(amount, src, dst, rates, bps)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount = 533000, src = 'JPY', dst = 'KWD'
rates = {('JPY', 'KWD'): Decimal('0.0389')}, bps = 161

    def check(amount, src, dst, rates, bps):
        before = dict(rates)
        result = quote_transfer(amount, src, dst, rates, bps)
        path = (src, dst) if (src, dst) in rates else (src, "USD", dst)
        rate = Fraction(1)
        for pair in zip(path, path[1:]):
            rate *= Fraction(rates[pair])
        fee = nearest(Fraction(amount * bps, 10000))
        converted = nearest(Fraction(amount - fee, 10**UNITS[src]) * rate * 10**UNITS[dst])
        assert type(result.fee_minor) is type(result.converted_minor) is int
        assert result.fee_minor == fee
>       assert result.converted_minor == converted
E       AssertionError: assert 20734 == 20399899
E        +  where 20734 = Quote(fee_minor=8581, converted_minor=20734, rate_used=Decimal('0.0389'), path=('JPY', 'KWD')).converted_minor
E       Failing test case: test_exact_scaling_and_monotonicity(
E           amount=533000,
E           extra=712,
E           units=389,
E           bps=161,
E           pair=('JPY', 'KWD'),
E       )

..\grader\test_hidden_fx.py:26: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_w_t94yoy\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fx.py::test_jpy_and_kwd_units - AssertionError: ...
FAILED ..\grader\test_hidden_fx.py::test_cross_has_no_intermediate_rounding
FAILED ..\grader\test_hidden_fx.py::test_source_fee_before_conversion - Asser...
FAILED ..\grader\test_hidden_fx.py::test_full_precision_cross_rate[1.23456789-1.34567891]
FAILED ..\grader\test_hidden_fx.py::test_full_precision_cross_rate[1.12345678901234567890123456789-1.98765432109876543210987654321]
FAILED ..\grader\test_hidden_fx.py::test_exact_scaling_and_monotonicity - Ass...
6 failed, 16 passed in 0.25s
Naive grader✓ starter: ok 1/4 tests passed
Failed tests
  • test_hidden::test_direct_cad_usd
  • test_hidden::test_ticket_example_cross_quote
  • test_hidden::test_ticket_example_zero_decimal_currency
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.50 s
Pytest output (last 40 lines)
The above exception was the direct cause of the following exception:

    def test_ticket_example_cross_quote():
        rates = {("GBP", "USD"): D("1.25"), ("USD", "CAD"): D("1.40")}
>       result = quote_transfer(20000, "GBP", "CAD", rates, 50)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

..\grader\test_hidden.py:14: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount_minor = 20000, src = 'GBP', dst = 'CAD'
rates = {('GBP', 'USD'): Decimal('1.25'), ('USD', 'CAD'): Decimal('1.40')}
fee_bps = 50

    def quote_transfer(amount_minor, src, dst, rates, fee_bps):
        validate(amount_minor, src, dst, fee_bps)
        try:
            rate = rates[src, dst]
        except KeyError as exc:
>           raise RateUnavailable((src, dst)) from exc
E           fx.models.RateUnavailable: ('GBP', 'CAD')

fx\quote.py:12: RateUnavailable
__________________ test_ticket_example_zero_decimal_currency __________________

    def test_ticket_example_zero_decimal_currency():
        result = quote_transfer(5000, "CAD", "JPY", {("CAD", "JPY"): D("110")}, 0)
>       assert (result.fee_minor, result.converted_minor) == (0, 5500)
E       assert (0, 550000) == (0, 5500)
E         
E         At index 1 diff: 550000 != 5500
E         Use -v to get more diff

..\grader\test_hidden.py:22: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_vd2dl9ix\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_direct_cad_usd - AssertionError: assert...
FAILED ..\grader\test_hidden.py::test_ticket_example_cross_quote - fx.models....
FAILED ..\grader\test_hidden.py::test_ticket_example_zero_decimal_currency - ...
3 failed, 1 passed in 0.05s

referencereference

Final grader✓ reference: ok 22/22 tests passed
Failed tests

All 22 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.76 s
Pytest output (last 40 lines)
......................                                                   [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run__63alop2\junit.xml -
22 passed in 0.29s
Naive grader✓ reference: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.45 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_xesc96rd\junit.xml -
4 passed in 0.01s
Diff against the starter
--- starter/fx\quote.py
+++ reference/fx\quote.py
@@ -1,15 +1,22 @@
-from decimal import Decimal, ROUND_HALF_EVEN
-from fx.models import Quote, RateUnavailable, validate
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
+from fx.models import Quote, RateUnavailable, UNITS, validate
 
 __all__ = ["quote_transfer", "Quote", "RateUnavailable"]
 
 
 def quote_transfer(amount_minor, src, dst, rates, fee_bps):
     validate(amount_minor, src, dst, fee_bps)
-    try:
-        rate = rates[src, dst]
-    except KeyError as exc:
-        raise RateUnavailable((src, dst)) from exc
-    fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    converted = int((Decimal(amount_minor) * rate).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    return Quote(fee, converted, rate, (src, dst))
+    with localcontext() as ctx:
+        ctx.prec = max(50, len(str(amount_minor)) + sum(len(r.as_tuple().digits) for r in rates.values()) + 20)
+        if (src, dst) in rates:
+            rate, path = rates[src, dst], (src, dst)
+        else:
+            try:
+                rate = rates[src, "USD"] * rates["USD", dst]
+            except KeyError as exc:
+                raise RateUnavailable((src, dst)) from exc
+            path = (src, "USD", dst)
+        fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        net = Decimal(amount_minor - fee).scaleb(-UNITS[src])
+        converted = int((net * rate).scaleb(UNITS[dst]).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        return Quote(fee, converted, rate, path)

alt_validalt_valid

Final grader✓ alt_valid: ok 22/22 tests passed
Failed tests

All 22 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.75 s
Pytest output (last 40 lines)
......................                                                   [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_dlunvl9e\junit.xml -
22 passed in 0.31s
Naive grader✓ alt_valid: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.45 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_1r15mjgr\junit.xml -
4 passed in 0.01s
Diff against the starter
--- starter/fx\quote.py
+++ alt_valid/fx\quote.py
@@ -1,15 +1,32 @@
-from decimal import Decimal, ROUND_HALF_EVEN
-from fx.models import Quote, RateUnavailable, validate
+from decimal import Decimal, localcontext
+from fractions import Fraction
+from fx.models import Quote, RateUnavailable, UNITS, validate
 
 __all__ = ["quote_transfer", "Quote", "RateUnavailable"]
 
 
+def nearest_even(value):
+    integer, residue = divmod(value.numerator, value.denominator)
+    doubled = residue * 2
+    return integer + int(doubled > value.denominator or (doubled == value.denominator and integer % 2))
+
+
 def quote_transfer(amount_minor, src, dst, rates, fee_bps):
     validate(amount_minor, src, dst, fee_bps)
-    try:
-        rate = rates[src, dst]
-    except KeyError as exc:
-        raise RateUnavailable((src, dst)) from exc
-    fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    converted = int((Decimal(amount_minor) * rate).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    return Quote(fee, converted, rate, (src, dst))
+    route = (src, dst) if (src, dst) in rates else (src, "USD", dst)
+    factors = []
+    for pair in zip(route, route[1:]):
+        if pair not in rates:
+            raise RateUnavailable(pair)
+        factors.append(rates[pair])
+    with localcontext() as arithmetic:
+        arithmetic.prec = max(34, sum(len(f.as_tuple().digits) for f in factors) + 10)
+        decimal_rate = Decimal(1)
+        for factor in factors:
+            decimal_rate *= factor
+    exact_rate = Fraction(1)
+    for factor in factors:
+        exact_rate *= Fraction(factor)
+    commission = nearest_even(Fraction(amount_minor * fee_bps, 10000))
+    destination = Fraction(amount_minor - commission, 10**UNITS[src]) * exact_rate * 10**UNITS[dst]
+    return Quote(commission, nearest_even(destination), decimal_rate, route)

wrong_fee_after_conversionwrong

Final grader✓ wrong_fee_after_conversion: ok 18/22 tests passed
Failed tests
  • test_hidden_fx::test_jpy_and_kwd_units
  • test_hidden_fx::test_source_fee_before_conversion declared must_fail
  • test_hidden_fx::test_full_precision_cross_rate[1.23456789-1.34567891]
  • test_hidden_fx::test_exact_scaling_and_monotonicity
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.69 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..\grader\test_hidden_fx.py:90: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\grader\test_hidden_fx.py:94: in test_exact_scaling_and_monotonicity
    first = check(amount, src, dst, rates, bps)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount = 533000, src = 'JPY', dst = 'KWD'
rates = {('JPY', 'KWD'): Decimal('0.0389')}, bps = 161

    def check(amount, src, dst, rates, bps):
        before = dict(rates)
        result = quote_transfer(amount, src, dst, rates, bps)
        path = (src, dst) if (src, dst) in rates else (src, "USD", dst)
        rate = Fraction(1)
        for pair in zip(path, path[1:]):
            rate *= Fraction(rates[pair])
        fee = nearest(Fraction(amount * bps, 10000))
        converted = nearest(Fraction(amount - fee, 10**UNITS[src]) * rate * 10**UNITS[dst])
        assert type(result.fee_minor) is type(result.converted_minor) is int
        assert result.fee_minor == fee
>       assert result.converted_minor == converted
E       AssertionError: assert 20399887 == 20399899
E        +  where 20399887 = Quote(fee_minor=8581, converted_minor=20399887, rate_used=Decimal('0.0389'), path=('JPY', 'KWD')).converted_minor
E       Failing test case: test_exact_scaling_and_monotonicity(
E           amount=533000,
E           extra=712,
E           units=389,
E           bps=161,
E           pair=('JPY', 'KWD'),
E       )

..\grader\test_hidden_fx.py:26: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_65odwuby\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fx.py::test_jpy_and_kwd_units - AssertionError: ...
FAILED ..\grader\test_hidden_fx.py::test_source_fee_before_conversion - Asser...
FAILED ..\grader\test_hidden_fx.py::test_full_precision_cross_rate[1.23456789-1.34567891]
FAILED ..\grader\test_hidden_fx.py::test_exact_scaling_and_monotonicity - Ass...
4 failed, 18 passed in 0.24s
Naive grader✗ wrong_fee_after_conversion: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.46 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_nxd5tgg6\junit.xml -
4 passed in 0.01s
Diff against the starter
--- starter/fx\quote.py
+++ wrong_fee_after_conversion/fx\quote.py
@@ -1,15 +1,23 @@
-from decimal import Decimal, ROUND_HALF_EVEN
-from fx.models import Quote, RateUnavailable, validate
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
+from fx.models import Quote, RateUnavailable, UNITS, validate
 
 __all__ = ["quote_transfer", "Quote", "RateUnavailable"]
 
 
 def quote_transfer(amount_minor, src, dst, rates, fee_bps):
     validate(amount_minor, src, dst, fee_bps)
-    try:
-        rate = rates[src, dst]
-    except KeyError as exc:
-        raise RateUnavailable((src, dst)) from exc
-    fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    converted = int((Decimal(amount_minor) * rate).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    return Quote(fee, converted, rate, (src, dst))
+    with localcontext() as ctx:
+        ctx.prec = max(50, len(str(amount_minor)) + sum(len(r.as_tuple().digits) for r in rates.values()) + 20)
+        if (src, dst) in rates:
+            rate, path = rates[src, dst], (src, dst)
+        else:
+            try:
+                rate = rates[src, "USD"] * rates["USD", dst]
+            except KeyError as exc:
+                raise RateUnavailable((src, dst)) from exc
+            path = (src, "USD", dst)
+        fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        net = Decimal(amount_minor).scaleb(-UNITS[src])
+        converted = int((net * rate).scaleb(UNITS[dst]).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        converted -= int((Decimal(converted) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        return Quote(fee, converted, rate, path)

wrong_round_cross_legwrong

Final grader✓ wrong_round_cross_leg: ok 19/22 tests passed
Failed tests
  • test_hidden_fx::test_cross_has_no_intermediate_rounding declared must_fail
  • test_hidden_fx::test_full_precision_cross_rate[1.23456789-1.34567891]
  • test_hidden_fx::test_full_precision_cross_rate[1.12345678901234567890123456789-1.98765432109876543210987654321]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.80 s
Hardening
caught by hardening
Pytest output (last 40 lines)
left = '1.12345678901234567890123456789'
right = '1.98765432109876543210987654321'

    @pytest.mark.parametrize("left,right", [
        ("1.23456789", "1.34567891"),
        ("1.12345678901234567890123456789", "1.98765432109876543210987654321"),
    ])
    def test_full_precision_cross_rate(left, right):
>       check(10**9, "GBP", "CAD", {("GBP", "USD"): D(left), ("USD", "CAD"): D(right)}, 17)

..\grader\test_hidden_fx.py:77: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount = 1000000000, src = 'GBP', dst = 'CAD'
rates = {('GBP', 'USD'): Decimal('1.12345678901234567890123456789'), ('USD', 'CAD'): Decimal('1.98765432109876543210987654321')}
bps = 17

    def check(amount, src, dst, rates, bps):
        before = dict(rates)
        result = quote_transfer(amount, src, dst, rates, bps)
        path = (src, dst) if (src, dst) in rates else (src, "USD", dst)
        rate = Fraction(1)
        for pair in zip(path, path[1:]):
            rate *= Fraction(rates[pair])
        fee = nearest(Fraction(amount * bps, 10000))
        converted = nearest(Fraction(amount - fee, 10**UNITS[src]) * rate * 10**UNITS[dst])
        assert type(result.fee_minor) is type(result.converted_minor) is int
        assert result.fee_minor == fee
>       assert result.converted_minor == converted
E       AssertionError: assert 2229247566 == 2229247567
E        +  where 2229247566 = Quote(fee_minor=1700000, converted_minor=2229247566, rate_used=Decimal('2.2330437412481329062372961438336229233322374638011112635269'), path=('GBP', 'USD', 'CAD')).converted_minor

..\grader\test_hidden_fx.py:26: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_edagtfro\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fx.py::test_cross_has_no_intermediate_rounding
FAILED ..\grader\test_hidden_fx.py::test_full_precision_cross_rate[1.23456789-1.34567891]
FAILED ..\grader\test_hidden_fx.py::test_full_precision_cross_rate[1.12345678901234567890123456789-1.98765432109876543210987654321]
3 failed, 19 passed in 0.35s
Naive grader✗ wrong_round_cross_leg: false_pass 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.45 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_sij0vysq\junit.xml -
4 passed in 0.01s
Diff against the starter
--- starter/fx\quote.py
+++ wrong_round_cross_leg/fx\quote.py
@@ -1,15 +1,27 @@
-from decimal import Decimal, ROUND_HALF_EVEN
-from fx.models import Quote, RateUnavailable, validate
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
+from fx.models import Quote, RateUnavailable, UNITS, validate
 
 __all__ = ["quote_transfer", "Quote", "RateUnavailable"]
 
 
 def quote_transfer(amount_minor, src, dst, rates, fee_bps):
     validate(amount_minor, src, dst, fee_bps)
-    try:
-        rate = rates[src, dst]
-    except KeyError as exc:
-        raise RateUnavailable((src, dst)) from exc
-    fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    converted = int((Decimal(amount_minor) * rate).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    return Quote(fee, converted, rate, (src, dst))
+    with localcontext() as ctx:
+        ctx.prec = max(50, len(str(amount_minor)) + sum(len(r.as_tuple().digits) for r in rates.values()) + 20)
+        if (src, dst) in rates:
+            rate, path = rates[src, dst], (src, dst)
+        else:
+            try:
+                rate = rates[src, "USD"] * rates["USD", dst]
+            except KeyError as exc:
+                raise RateUnavailable((src, dst)) from exc
+            path = (src, "USD", dst)
+        fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        net = Decimal(amount_minor - fee).scaleb(-UNITS[src])
+        if len(path) == 3:
+            net = (net * rates[src, "USD"]).quantize(Decimal(".01"), rounding=ROUND_HALF_EVEN)
+            rate_for_amount = rates["USD", dst]
+        else:
+            rate_for_amount = rate
+        converted = int((net * rate_for_amount).scaleb(UNITS[dst]).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        return Quote(fee, converted, rate, path)

wrong_two_decimalswrong

Final grader✓ wrong_two_decimals: ok 20/22 tests passed
Failed tests
  • test_hidden_fx::test_jpy_and_kwd_units declared must_fail
  • test_hidden_fx::test_exact_scaling_and_monotonicity
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.69 s
Pytest output (last 40 lines)
                   ^^^

..\grader\test_hidden_fx.py:90: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\grader\test_hidden_fx.py:94: in test_exact_scaling_and_monotonicity
    first = check(amount, src, dst, rates, bps)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount = 533000, src = 'JPY', dst = 'KWD'
rates = {('JPY', 'KWD'): Decimal('0.0389')}, bps = 161

    def check(amount, src, dst, rates, bps):
        before = dict(rates)
        result = quote_transfer(amount, src, dst, rates, bps)
        path = (src, dst) if (src, dst) in rates else (src, "USD", dst)
        rate = Fraction(1)
        for pair in zip(path, path[1:]):
            rate *= Fraction(rates[pair])
        fee = nearest(Fraction(amount * bps, 10000))
        converted = nearest(Fraction(amount - fee, 10**UNITS[src]) * rate * 10**UNITS[dst])
        assert type(result.fee_minor) is type(result.converted_minor) is int
        assert result.fee_minor == fee
>       assert result.converted_minor == converted
E       AssertionError: assert 20400 == 20399899
E        +  where 20400 = Quote(fee_minor=8581, converted_minor=20400, rate_used=Decimal('0.0389'), path=('JPY', 'KWD')).converted_minor
E       Failing test case: test_exact_scaling_and_monotonicity(
E           amount=533000,
E           extra=712,
E           units=389,
E           bps=161,
E           pair=('JPY', 'KWD'),
E       )

..\grader\test_hidden_fx.py:26: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_m_xfckpl\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fx.py::test_jpy_and_kwd_units - AssertionError: ...
FAILED ..\grader\test_hidden_fx.py::test_exact_scaling_and_monotonicity - Ass...
2 failed, 20 passed in 0.23s
Naive grader~ wrong_two_decimals: ok_wrong_reason 3/4 tests passed
Failed tests
  • test_hidden::test_ticket_example_zero_decimal_currency
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
0.49 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
..F.                                                                     [100%]
================================== FAILURES ===================================
__________________ test_ticket_example_zero_decimal_currency __________________

    def test_ticket_example_zero_decimal_currency():
        result = quote_transfer(5000, "CAD", "JPY", {("CAD", "JPY"): D("110")}, 0)
>       assert (result.fee_minor, result.converted_minor) == (0, 5500)
E       assert (0, 550000) == (0, 5500)
E         
E         At index 1 diff: 550000 != 5500
E         Use -v to get more diff

..\grader\test_hidden.py:22: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_crk3_cwz\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_example_zero_decimal_currency - ...
1 failed, 3 passed in 0.05s
Diff against the starter
--- starter/fx\quote.py
+++ wrong_two_decimals/fx\quote.py
@@ -1,4 +1,4 @@
-from decimal import Decimal, ROUND_HALF_EVEN
+from decimal import Decimal, ROUND_HALF_EVEN, localcontext
 from fx.models import Quote, RateUnavailable, validate
 
 __all__ = ["quote_transfer", "Quote", "RateUnavailable"]
@@ -6,10 +6,17 @@
 
 def quote_transfer(amount_minor, src, dst, rates, fee_bps):
     validate(amount_minor, src, dst, fee_bps)
-    try:
-        rate = rates[src, dst]
-    except KeyError as exc:
-        raise RateUnavailable((src, dst)) from exc
-    fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    converted = int((Decimal(amount_minor) * rate).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
-    return Quote(fee, converted, rate, (src, dst))
+    with localcontext() as ctx:
+        ctx.prec = max(50, len(str(amount_minor)) + sum(len(r.as_tuple().digits) for r in rates.values()) + 20)
+        if (src, dst) in rates:
+            rate, path = rates[src, dst], (src, dst)
+        else:
+            try:
+                rate = rates[src, "USD"] * rates["USD", dst]
+            except KeyError as exc:
+                raise RateUnavailable((src, dst)) from exc
+            path = (src, "USD", dst)
+        fee = int((Decimal(amount_minor) * fee_bps / 10000).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        net = Decimal(amount_minor - fee).scaleb(-2)
+        converted = int((net * rate).scaleb(2).quantize(Decimal(1), rounding=ROUND_HALF_EVEN))
+        return Quote(fee, converted, rate, path)
04_ledger_reconciliationReconcile synthetic statements deterministically

Match bank statement lines to ledger entries with exact, split, refund and duplicate rules, deterministically.

What the hardened grader checks

  • A ledger entry is matched at most once, so duplicate lines stay unmatched
  • Split payments match only when all lines in the window sum exactly
  • Refunds match refund entries by original reference, never payments
  • Tie-breaks and the three-day window are applied in the stated order
  • Generated statements recover the known ground-truth matching
Ticket (PROBLEM.md)
# Ticket: reconcile a synthetic statement to ledger entries

Keep `recon.engine.reconcile(statement: list[StatementLine], ledger: list[LedgerEntry]) -> ReconResult`.
Public frozen dataclasses (also exported from recon.engine):
- `StatementLine(id: str, value_date: date, amount_minor: int, reference: str)`
- `LedgerEntry(id: str, booking_date: date, amount_minor: int, reference: str,
  kind: "payment"|"refund" = "payment", original_reference: str|None = None)`
- `Match(statement_ids: frozenset[str], ledger_ids: frozenset[str], rule: str)`
- `ReconResult(matches: list[Match], unmatched_statement: dict[str,str], unmatched_ledger: set[str])`

All amounts are integer minor units in one synthetic currency (CAD, 2 dp); statement amounts
are signed, ledger payment/refund amounts positive. Dates are calendar dates. IDs are unique
within each input, strings sort lexicographically. Inputs are valid and must not be mutated.
Apply complete passes in the following order; each id can appear in at most one match:

1. R1: process statements by `(value_date,id)`. Match an unmatched ledger **payment** with equal
   reference and equal signed amount, date difference <=3 calendar days. Choose smallest absolute
   date difference, then earliest booking_date, then smallest id.
2. R2: process remaining ledger payments by `(reference,booking_date,id)`. For each payment,
   collect **all** unmatched statement lines of the same reference within +/-3 days of that payment.
   If at least two lines and their signed sum equals its amount, match the whole group. Do not
   search subsets. Negative lines, if present, contribute their signed amount to this pass.
3. R3: process remaining negative statements by `(value_date,id)`. Match an unmatched ledger
   **refund**, with original_reference equal to the statement reference and positive amount equal
   to abs(statement amount). There is no date restriction in R3. Choose earliest booking_date,
   then smallest id. The refund's own reference need not equal the original reference.
4. R4: each remaining statement identical in `(value_date,amount_minor,reference)` to any matched
   statement gets reason `duplicate`; otherwise `no_match`. Unused ledger ids form unmatched_ledger.

Worked example (CAD minor units; every reference is a synthetic invoice id):

| Statement id | value_date | amount | reference |  | Ledger id | booking_date | amount | reference |
|---|---|---:|---|---|---|---|---:|---|
| s1 | 2026-01-05 | 10000 | INV-1 |  | l1 | 2026-01-03 | 10000 | INV-1 |
| s2 | 2026-01-06 | 4000 | INV-2 |  | l2 | 2026-01-06 | 10000 | INV-2 |
| s3 | 2026-01-07 | 6000 | INV-2 |  |  |  |  |  |

`s1` matches `l1` by R1 (two days apart, inside the window). `s2` and `s3` are the only unmatched
lines for `INV-2` inside the window of `l2` and sum to 10000, so R2 matches both to `l2`.
Nothing is left unmatched.

Use rule strings `R1`, `R2`, `R3`. Match-list order is not graded. Exact membership, reasons,
partitions and no reuse are graded. Empty inputs produce empty collections.
Run from starter: `python -m pytest tests -q`.
Reviewer notes (NOTES.md)
# Private draft — reconciliation reviewer notes

Counts alone hide identity reuse. v2 starter (see analysis/findings.md F-0xx v2 redesign): the
starter implements R1 only, with a same-day window, no ledger consumption and a flat `no_match`
reason; the window, consumption, R2 split, R3 refund and R4 duplicate classification are the
ticket. The wrong variants are plausible completions of that starter: `wrong_no_consumption`
adds the window, R2, R3 and R4 but keeps the starter's non-consuming candidate lookup;
`wrong_split_ignored` adds everything except R2; `wrong_refund_abs` compares absolute amounts in
the exact rule. The alternative uses remaining-record maps and indexed references; the reference
extends the starter's candidate helpers and tracks consumed ids.

## Blind review
Author self-review on ticket plus starter. Ambiguities: R2 traversal with repeated references,
negative R2 amounts, R3 tie breaks/date window, ordering of result lists. Ticket now states all
of these. Predictions: exact amount at +3 days -> R1; duplicate exact statement -> one R1 and
one duplicate; parts 40+60 against 100 -> one R2 with both ids. No unresolved graded ambiguity.
Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 5/18 tests passed
Failed tests
  • test_hidden_recon::test_duplicate_statement_line_left_unmatched
  • test_hidden_recon::test_split_all_lines
  • test_hidden_recon::test_refund_kind_and_original_reference
  • test_hidden_recon::test_tie_breaks_and_window
  • test_hidden_recon::test_ordering_and_candidate_rules[r1-booking-date]
  • test_hidden_recon::test_ordering_and_candidate_rules[r1-payments-only]
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-booking-date]
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-id]
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-signed-lines]
  • test_hidden_recon::test_ordering_and_candidate_rules[r3-booking-date]
  • test_hidden_recon::test_ordering_and_candidate_rules[r3-id]
  • test_hidden_recon::test_ordering_and_candidate_rules[r3-statement-order]
  • test_hidden_recon::test_ground_truth_transforms
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.58 s
Pytest output (last 40 lines)
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount + 1, reference))
                reasons[sid] = "no_match"
                unmatched.add(lid)
            else:
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount, reference))
                truth.add(match([sid], [lid], "R1"))
                if mode == "duplicate":
                    statement.append(S(sid + "z", DAY, amount, reference))
                    reasons[sid + "z"] = "duplicate"
        before = (list(statement), list(ledger))
        result = reconcile(list(reversed(statement)), list(reversed(ledger)))
>       assert pairs(result) == truth
E       AssertionError: assert {(frozenset({...'l0'}), 'R1')} == {(frozenset({...'l0'}), 'R1')}
E         
E         Extra items in the left set:
E         (frozenset({'s0z'}), frozenset({'l0'}), 'R1')
E         Use -v to get more diff
E       Failing test case: test_ground_truth_transforms(
E           cases=[('duplicate', 10, 2)],
E       )

..\grader\test_hidden_recon.py:162: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_wzoi3k0n\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_recon.py::test_duplicate_statement_line_left_unmatched
FAILED ..\grader\test_hidden_recon.py::test_split_all_lines - AssertionError:...
FAILED ..\grader\test_hidden_recon.py::test_refund_kind_and_original_reference
FAILED ..\grader\test_hidden_recon.py::test_tie_breaks_and_window - Assertion...
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r1-booking-date]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r1-payments-only]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-booking-date]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-id]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-signed-lines]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r3-booking-date]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r3-id]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r3-statement-order]
FAILED ..\grader\test_hidden_recon.py::test_ground_truth_transforms - Asserti...
13 failed, 5 passed in 0.14s
Naive grader✓ starter: ok 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.49 s
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

    def test_ticket_worked_example():
        statement = [StatementLine("s1", date(2026, 1, 5), 10000, "INV-1"),
                     StatementLine("s2", date(2026, 1, 6), 4000, "INV-2"),
                     StatementLine("s3", date(2026, 1, 7), 6000, "INV-2")]
        ledger = [LedgerEntry("l1", date(2026, 1, 3), 10000, "INV-1"),
                  LedgerEntry("l2", date(2026, 1, 6), 10000, "INV-2")]
        result = reconcile(statement, ledger)
>       assert len(result.matches) == 2
E       AssertionError: assert 0 == 2
E        +  where 0 = len([])
E        +    where [] = ReconResult(matches=[], unmatched_statement={'s1': 'no_match', 's2': 'no_match', 's3': 'no_match'}, unmatched_ledger={'l1', 'l2'}).matches

..\grader\test_hidden.py:19: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_8jfutfre\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - AssertionError:...
1 failed, 2 passed in 0.05s

referencereference

Final grader✓ reference: ok 18/18 tests passed
Failed tests

All 18 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.77 s
Pytest output (last 40 lines)
..................                                                       [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_0zyy55dh\junit.xml -
18 passed in 0.30s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.46 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_8a7fr7f9\junit.xml -
3 passed in 0.01s
Diff against the starter
--- starter/recon\engine.py
+++ reference/recon\engine.py
@@ -1,22 +1,60 @@
 """Statement-to-ledger matching."""
 from recon.models import StatementLine, LedgerEntry, Match, ReconResult  # noqa: F401
 
+WINDOW_DAYS = 3
 
-def exact_candidates(line, ledger):
-    """Ledger payments that agree with a statement line on reference, amount and booking date."""
+
+def day_gap(entry, line):
+    return abs((entry.booking_date - line.value_date).days)
+
+
+def exact_candidates(line, ledger, used_ledger):
+    """Unused ledger payments that agree on reference and amount within the date window."""
     return [entry for entry in ledger
-            if entry.kind == "payment" and entry.reference == line.reference
-            and entry.amount_minor == line.amount_minor and entry.booking_date == line.value_date]
+            if entry.id not in used_ledger and entry.kind == "payment"
+            and entry.reference == line.reference and entry.amount_minor == line.amount_minor
+            and day_gap(entry, line) <= WINDOW_DAYS]
+
+
+def split_group(entry, ordered, used_statement):
+    """All unused statement lines for the entry's reference inside the window, if they sum to it."""
+    group = [line for line in ordered if line.id not in used_statement
+             and line.reference == entry.reference and day_gap(entry, line) <= WINDOW_DAYS]
+    if len(group) >= 2 and sum(line.amount_minor for line in group) == entry.amount_minor:
+        return group
+    return []
+
+
+def refund_candidates(line, ledger, used_ledger):
+    return [entry for entry in ledger
+            if entry.id not in used_ledger and entry.kind == "refund"
+            and entry.original_reference == line.reference and entry.amount_minor == -line.amount_minor]
 
 
 def reconcile(statement, ledger):
-    matches, matched = [], set()
-    for line in sorted(statement, key=lambda s: (s.value_date, s.id)):
-        candidates = exact_candidates(line, ledger)
+    ordered = sorted(statement, key=lambda s: (s.value_date, s.id))
+    used_statement, used_ledger, matches = set(), set(), []
+
+    def record(lines, entry, rule):
+        used_statement.update(line.id for line in lines)
+        used_ledger.add(entry.id)
+        matches.append(Match(frozenset(line.id for line in lines), frozenset([entry.id]), rule))
+
+    for line in ordered:
+        candidates = exact_candidates(line, ledger, used_ledger)
         if candidates:
-            entry = min(candidates, key=lambda e: e.id)
-            matches.append(Match(frozenset([line.id]), frozenset([entry.id]), "R1"))
-            matched.add(line.id)
-    unmatched = {s.id: "no_match" for s in statement if s.id not in matched}
-    used = {identifier for match in matches for identifier in match.ledger_ids}
-    return ReconResult(matches, unmatched, {e.id for e in ledger} - used)
+            record([line], min(candidates, key=lambda e: (day_gap(e, line), e.booking_date, e.id)), "R1")
+    for entry in sorted(ledger, key=lambda e: (e.reference, e.booking_date, e.id)):
+        if entry.id not in used_ledger and entry.kind == "payment":
+            group = split_group(entry, ordered, used_statement)
+            if group:
+                record(group, entry, "R2")
+    for line in ordered:
+        if line.id not in used_statement and line.amount_minor < 0:
+            candidates = refund_candidates(line, ledger, used_ledger)
+            if candidates:
+                record([line], min(candidates, key=lambda e: (e.booking_date, e.id)), "R3")
+    matched_signatures = {(s.value_date, s.amount_minor, s.reference) for s in statement if s.id in used_statement}
+    unmatched = {line.id: "duplicate" if (line.value_date, line.amount_minor, line.reference) in matched_signatures
+                 else "no_match" for line in ordered if line.id not in used_statement}
+    return ReconResult(matches, unmatched, {e.id for e in ledger} - used_ledger)

alt_validalt_valid

Final grader✓ alt_valid: ok 18/18 tests passed
Failed tests

All 18 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.75 s
Pytest output (last 40 lines)
..................                                                       [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_j4o4d9ff\junit.xml -
18 passed in 0.29s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.44 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_vvl4as1w\junit.xml -
3 passed in 0.01s
Diff against the starter
--- starter/recon\engine.py
+++ alt_valid/recon\engine.py
@@ -1,22 +1,49 @@
-"""Statement-to-ledger matching."""
+from collections import defaultdict
 from recon.models import StatementLine, LedgerEntry, Match, ReconResult  # noqa: F401
 
 
-def exact_candidates(line, ledger):
-    """Ledger payments that agree with a statement line on reference, amount and booking date."""
-    return [entry for entry in ledger
-            if entry.kind == "payment" and entry.reference == line.reference
-            and entry.amount_minor == line.amount_minor and entry.booking_date == line.value_date]
+def reconcile(statement, ledger):
+    pending = {s.id: s for s in statement}
+    remaining = {e.id: e for e in ledger}
+    by_reference = defaultdict(list)
+    for entry in ledger:
+        if entry.kind == "payment":
+            by_reference[entry.reference].append(entry)
+    output = []
+    consumed_signatures = set()
 
+    def take(ids, target, rule):
+        for identifier in ids:
+            line = pending.pop(identifier)
+            consumed_signatures.add((line.value_date, line.amount_minor, line.reference))
+        del remaining[target]
+        output.append(Match(frozenset(ids), frozenset([target]), rule))
 
-def reconcile(statement, ledger):
-    matches, matched = [], set()
-    for line in sorted(statement, key=lambda s: (s.value_date, s.id)):
-        candidates = exact_candidates(line, ledger)
-        if candidates:
-            entry = min(candidates, key=lambda e: e.id)
-            matches.append(Match(frozenset([line.id]), frozenset([entry.id]), "R1"))
-            matched.add(line.id)
-    unmatched = {s.id: "no_match" for s in statement if s.id not in matched}
-    used = {identifier for match in matches for identifier in match.ledger_ids}
-    return ReconResult(matches, unmatched, {e.id for e in ledger} - used)
+    for line in sorted(statement, key=lambda v: (v.value_date, v.id)):
+        possible = sorted((abs((e.booking_date - line.value_date).days), e.booking_date, e.id)
+                          for e in by_reference[line.reference]
+                          if e.id in remaining and e.amount_minor == line.amount_minor
+                          and abs((e.booking_date - line.value_date).days) <= 3)
+        if possible:
+            take([line.id], possible[0][2], "R1")
+    for target in sorted(ledger, key=lambda v: (v.reference, v.booking_date, v.id)):
+        if target.id not in remaining or target.kind != "payment":
+            continue
+        ids = [i for i, s in pending.items() if s.reference == target.reference
+               and abs((s.value_date - target.booking_date).days) <= 3]
+        total = sum(pending[i].amount_minor for i in ids)
+        if len(ids) > 1 and total == target.amount_minor:
+            take(ids, target.id, "R2")
+    for line in sorted(list(pending.values()), key=lambda v: (v.value_date, v.id)):
+        if line.amount_minor >= 0:
+            continue
+        refunds = sorted((e.booking_date, e.id) for e in remaining.values()
+                         if e.kind == "refund" and e.original_reference == line.reference
+                         and e.amount_minor + line.amount_minor == 0)
+        if refunds:
+            take([line.id], refunds[0][1], "R3")
+    reasons = {}
+    for identifier, line in pending.items():
+        signature = line.value_date, line.amount_minor, line.reference
+        reasons[identifier] = "duplicate" if signature in consumed_signatures else "no_match"
+    return ReconResult(output, reasons, set(remaining))

wrong_no_consumptionwrong

Final grader✓ wrong_no_consumption: ok 14/18 tests passed
Failed tests
  • test_hidden_recon::test_duplicate_statement_line_left_unmatched declared must_fail
  • test_hidden_recon::test_ordering_and_candidate_rules[r1-statement-order]
  • test_hidden_recon::test_duplicate_signature_includes_date
  • test_hidden_recon::test_ground_truth_transforms
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.58 s
Hardening
caught by hardening
Pytest output (last 40 lines)
                ledger.append(L(lid, DAY, amount, reference))
                ids = []
                for j in range(parts):
                    identifier = f"{sid}_{j}"
                    value = amount // parts if j < parts - 1 else amount - (amount // parts) * (parts - 1)
                    statement.append(S(identifier, DAY, value, reference))
                    ids.append(identifier)
                truth.add(match(ids, [lid], "R2"))
            elif mode == "noise":
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount + 1, reference))
                reasons[sid] = "no_match"
                unmatched.add(lid)
            else:
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount, reference))
                truth.add(match([sid], [lid], "R1"))
                if mode == "duplicate":
                    statement.append(S(sid + "z", DAY, amount, reference))
                    reasons[sid + "z"] = "duplicate"
        before = (list(statement), list(ledger))
        result = reconcile(list(reversed(statement)), list(reversed(ledger)))
>       assert pairs(result) == truth
E       AssertionError: assert {(frozenset({...'l0'}), 'R1')} == {(frozenset({...'l0'}), 'R1')}
E         
E         Extra items in the left set:
E         (frozenset({'s0z'}), frozenset({'l0'}), 'R1')
E         Use -v to get more diff
E       Failing test case: test_ground_truth_transforms(
E           cases=[('duplicate', 10, 2)],
E       )

..\grader\test_hidden_recon.py:162: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_h8jt4fu1\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_recon.py::test_duplicate_statement_line_left_unmatched
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r1-statement-order]
FAILED ..\grader\test_hidden_recon.py::test_duplicate_signature_includes_date
FAILED ..\grader\test_hidden_recon.py::test_ground_truth_transforms - Asserti...
4 failed, 14 passed in 0.10s
Naive grader✗ wrong_no_consumption: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.46 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_z2n_yp7t\junit.xml -
3 passed in 0.01s
Diff against the starter
--- starter/recon\engine.py
+++ wrong_no_consumption/recon\engine.py
@@ -1,22 +1,60 @@
 """Statement-to-ledger matching."""
 from recon.models import StatementLine, LedgerEntry, Match, ReconResult  # noqa: F401
 
+WINDOW_DAYS = 3
+
+
+def day_gap(entry, line):
+    return abs((entry.booking_date - line.value_date).days)
+
 
 def exact_candidates(line, ledger):
-    """Ledger payments that agree with a statement line on reference, amount and booking date."""
+    """Unused ledger payments that agree on reference and amount within the date window."""
     return [entry for entry in ledger
-            if entry.kind == "payment" and entry.reference == line.reference
-            and entry.amount_minor == line.amount_minor and entry.booking_date == line.value_date]
+            if entry.kind == "payment"
+            and entry.reference == line.reference and entry.amount_minor == line.amount_minor
+            and day_gap(entry, line) <= WINDOW_DAYS]
+
+
+def split_group(entry, ordered, used_statement):
+    """All unused statement lines for the entry's reference inside the window, if they sum to it."""
+    group = [line for line in ordered if line.id not in used_statement
+             and line.reference == entry.reference and day_gap(entry, line) <= WINDOW_DAYS]
+    if len(group) >= 2 and sum(line.amount_minor for line in group) == entry.amount_minor:
+        return group
+    return []
+
+
+def refund_candidates(line, ledger, used_ledger):
+    return [entry for entry in ledger
+            if entry.id not in used_ledger and entry.kind == "refund"
+            and entry.original_reference == line.reference and entry.amount_minor == -line.amount_minor]
 
 
 def reconcile(statement, ledger):
-    matches, matched = [], set()
-    for line in sorted(statement, key=lambda s: (s.value_date, s.id)):
+    ordered = sorted(statement, key=lambda s: (s.value_date, s.id))
+    used_statement, used_ledger, matches = set(), set(), []
+
+    def record(lines, entry, rule):
+        used_statement.update(line.id for line in lines)
+        used_ledger.add(entry.id)
+        matches.append(Match(frozenset(line.id for line in lines), frozenset([entry.id]), rule))
+
+    for line in ordered:
         candidates = exact_candidates(line, ledger)
         if candidates:
-            entry = min(candidates, key=lambda e: e.id)
-            matches.append(Match(frozenset([line.id]), frozenset([entry.id]), "R1"))
-            matched.add(line.id)
-    unmatched = {s.id: "no_match" for s in statement if s.id not in matched}
-    used = {identifier for match in matches for identifier in match.ledger_ids}
-    return ReconResult(matches, unmatched, {e.id for e in ledger} - used)
+            record([line], min(candidates, key=lambda e: (day_gap(e, line), e.booking_date, e.id)), "R1")
+    for entry in sorted(ledger, key=lambda e: (e.reference, e.booking_date, e.id)):
+        if entry.id not in used_ledger and entry.kind == "payment":
+            group = split_group(entry, ordered, used_statement)
+            if group:
+                record(group, entry, "R2")
+    for line in ordered:
+        if line.id not in used_statement and line.amount_minor < 0:
+            candidates = refund_candidates(line, ledger, used_ledger)
+            if candidates:
+                record([line], min(candidates, key=lambda e: (e.booking_date, e.id)), "R3")
+    matched_signatures = {(s.value_date, s.amount_minor, s.reference) for s in statement if s.id in used_statement}
+    unmatched = {line.id: "duplicate" if (line.value_date, line.amount_minor, line.reference) in matched_signatures
+                 else "no_match" for line in ordered if line.id not in used_statement}
+    return ReconResult(matches, unmatched, {e.id for e in ledger} - used_ledger)

wrong_refund_abswrong

Final grader✓ wrong_refund_abs: ok 17/18 tests passed
Failed tests
  • test_hidden_recon::test_refund_kind_and_original_reference declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.80 s
Hardening
caught by hardening
Pytest output (last 40 lines)
...F..............                                                       [100%]
================================== FAILURES ===================================
___________________ test_refund_kind_and_original_reference ___________________

    def test_refund_kind_and_original_reference():
        statement = [S("s", DAY, -100, "original")]
        ledger = [L("payment", DAY, 100, "original"),
                  L("refund", DAY + timedelta(days=30), 100, "other", "refund", "original")]
        result = reconcile(statement, ledger)
>       assert pairs(result) == {match(["s"], ["refund"], "R3")}
E       AssertionError: assert {(frozenset({...ent'}), 'R1')} == {(frozenset({...und'}), 'R3')}
E         
E         Extra items in the left set:
E         (frozenset({'s'}), frozenset({'payment'}), 'R1')
E         Extra items in the right set:
E         (frozenset({'s'}), frozenset({'refund'}), 'R3')
E         Use -v to get more diff

..\grader\test_hidden_recon.py:61: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_tlbsilfh\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_recon.py::test_refund_kind_and_original_reference
1 failed, 17 passed in 0.33s
Naive grader✗ wrong_refund_abs: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.45 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_h3_l9uod\junit.xml -
3 passed in 0.01s
Diff against the starter
--- starter/recon\engine.py
+++ wrong_refund_abs/recon\engine.py
@@ -1,22 +1,60 @@
 """Statement-to-ledger matching."""
 from recon.models import StatementLine, LedgerEntry, Match, ReconResult  # noqa: F401
 
+WINDOW_DAYS = 3
 
-def exact_candidates(line, ledger):
-    """Ledger payments that agree with a statement line on reference, amount and booking date."""
+
+def day_gap(entry, line):
+    return abs((entry.booking_date - line.value_date).days)
+
+
+def exact_candidates(line, ledger, used_ledger):
+    """Unused ledger payments that agree on reference and amount within the date window."""
     return [entry for entry in ledger
-            if entry.kind == "payment" and entry.reference == line.reference
-            and entry.amount_minor == line.amount_minor and entry.booking_date == line.value_date]
+            if entry.id not in used_ledger and entry.kind == "payment"
+            and entry.reference == line.reference and entry.amount_minor == abs(line.amount_minor)
+            and day_gap(entry, line) <= WINDOW_DAYS]
+
+
+def split_group(entry, ordered, used_statement):
+    """All unused statement lines for the entry's reference inside the window, if they sum to it."""
+    group = [line for line in ordered if line.id not in used_statement
+             and line.reference == entry.reference and day_gap(entry, line) <= WINDOW_DAYS]
+    if len(group) >= 2 and sum(line.amount_minor for line in group) == entry.amount_minor:
+        return group
+    return []
+
+
+def refund_candidates(line, ledger, used_ledger):
+    return [entry for entry in ledger
+            if entry.id not in used_ledger and entry.kind == "refund"
+            and entry.original_reference == line.reference and entry.amount_minor == -line.amount_minor]
 
 
 def reconcile(statement, ledger):
-    matches, matched = [], set()
-    for line in sorted(statement, key=lambda s: (s.value_date, s.id)):
-        candidates = exact_candidates(line, ledger)
+    ordered = sorted(statement, key=lambda s: (s.value_date, s.id))
+    used_statement, used_ledger, matches = set(), set(), []
+
+    def record(lines, entry, rule):
+        used_statement.update(line.id for line in lines)
+        used_ledger.add(entry.id)
+        matches.append(Match(frozenset(line.id for line in lines), frozenset([entry.id]), rule))
+
+    for line in ordered:
+        candidates = exact_candidates(line, ledger, used_ledger)
         if candidates:
-            entry = min(candidates, key=lambda e: e.id)
-            matches.append(Match(frozenset([line.id]), frozenset([entry.id]), "R1"))
-            matched.add(line.id)
-    unmatched = {s.id: "no_match" for s in statement if s.id not in matched}
-    used = {identifier for match in matches for identifier in match.ledger_ids}
-    return ReconResult(matches, unmatched, {e.id for e in ledger} - used)
+            record([line], min(candidates, key=lambda e: (day_gap(e, line), e.booking_date, e.id)), "R1")
+    for entry in sorted(ledger, key=lambda e: (e.reference, e.booking_date, e.id)):
+        if entry.id not in used_ledger and entry.kind == "payment":
+            group = split_group(entry, ordered, used_statement)
+            if group:
+                record(group, entry, "R2")
+    for line in ordered:
+        if line.id not in used_statement and line.amount_minor < 0:
+            candidates = refund_candidates(line, ledger, used_ledger)
+            if candidates:
+                record([line], min(candidates, key=lambda e: (e.booking_date, e.id)), "R3")
+    matched_signatures = {(s.value_date, s.amount_minor, s.reference) for s in statement if s.id in used_statement}
+    unmatched = {line.id: "duplicate" if (line.value_date, line.amount_minor, line.reference) in matched_signatures
+                 else "no_match" for line in ordered if line.id not in used_statement}
+    return ReconResult(matches, unmatched, {e.id for e in ledger} - used_ledger)

wrong_split_ignoredwrong

Final grader✓ wrong_split_ignored: ok 13/18 tests passed
Failed tests
  • test_hidden_recon::test_split_all_lines declared must_fail
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-booking-date]
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-id]
  • test_hidden_recon::test_ordering_and_candidate_rules[r2-signed-lines]
  • test_hidden_recon::test_ground_truth_transforms
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.63 s
Pytest output (last 40 lines)
                ids = []
                for j in range(parts):
                    identifier = f"{sid}_{j}"
                    value = amount // parts if j < parts - 1 else amount - (amount // parts) * (parts - 1)
                    statement.append(S(identifier, DAY, value, reference))
                    ids.append(identifier)
                truth.add(match(ids, [lid], "R2"))
            elif mode == "noise":
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount + 1, reference))
                reasons[sid] = "no_match"
                unmatched.add(lid)
            else:
                statement.append(S(sid, DAY, amount, reference))
                ledger.append(L(lid, DAY, amount, reference))
                truth.add(match([sid], [lid], "R1"))
                if mode == "duplicate":
                    statement.append(S(sid + "z", DAY, amount, reference))
                    reasons[sid + "z"] = "duplicate"
        before = (list(statement), list(ledger))
        result = reconcile(list(reversed(statement)), list(reversed(ledger)))
>       assert pairs(result) == truth
E       AssertionError: assert set() == {(frozenset({...'l0'}), 'R2')}
E         
E         Extra items in the right set:
E         (frozenset({'s0_0', 's0_1'}), frozenset({'l0'}), 'R2')
E         Use -v to get more diff
E       Failing test case: test_ground_truth_transforms(
E           cases=[('split', 10, 2)],
E       )

..\grader\test_hidden_recon.py:162: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_pw_6kdtc\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_recon.py::test_split_all_lines - AssertionError:...
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-booking-date]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-id]
FAILED ..\grader\test_hidden_recon.py::test_ordering_and_candidate_rules[r2-signed-lines]
FAILED ..\grader\test_hidden_recon.py::test_ground_truth_transforms - Asserti...
5 failed, 13 passed in 0.17s
Naive grader~ wrong_split_ignored: ok_wrong_reason 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
0.48 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

    def test_ticket_worked_example():
        statement = [StatementLine("s1", date(2026, 1, 5), 10000, "INV-1"),
                     StatementLine("s2", date(2026, 1, 6), 4000, "INV-2"),
                     StatementLine("s3", date(2026, 1, 7), 6000, "INV-2")]
        ledger = [LedgerEntry("l1", date(2026, 1, 3), 10000, "INV-1"),
                  LedgerEntry("l2", date(2026, 1, 6), 10000, "INV-2")]
        result = reconcile(statement, ledger)
>       assert len(result.matches) == 2
E       AssertionError: assert 1 == 2
E        +  where 1 = len([Match(statement_ids=frozenset({'s1'}), ledger_ids=frozenset({'l1'}), rule='R1')])
E        +    where [Match(statement_ids=frozenset({'s1'}), ledger_ids=frozenset({'l1'}), rule='R1')] = ReconResult(matches=[Match(statement_ids=frozenset({'s1'}), ledger_ids=frozenset({'l1'}), rule='R1')], unmatched_statement={'s2': 'no_match', 's3': 'no_match'}, unmatched_ledger={'l2'}).matches

..\grader\test_hidden.py:19: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_4spdb36l\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - AssertionError:...
1 failed, 2 passed in 0.05s
Diff against the starter
--- starter/recon\engine.py
+++ wrong_split_ignored/recon\engine.py
@@ -1,22 +1,46 @@
 """Statement-to-ledger matching."""
 from recon.models import StatementLine, LedgerEntry, Match, ReconResult  # noqa: F401
 
+WINDOW_DAYS = 3
 
-def exact_candidates(line, ledger):
-    """Ledger payments that agree with a statement line on reference, amount and booking date."""
+
+def day_gap(entry, line):
+    return abs((entry.booking_date - line.value_date).days)
+
+
+def exact_candidates(line, ledger, used_ledger):
+    """Unused ledger payments that agree on reference and amount within the date window."""
     return [entry for entry in ledger
-            if entry.kind == "payment" and entry.reference == line.reference
-            and entry.amount_minor == line.amount_minor and entry.booking_date == line.value_date]
+            if entry.id not in used_ledger and entry.kind == "payment"
+            and entry.reference == line.reference and entry.amount_minor == line.amount_minor
+            and day_gap(entry, line) <= WINDOW_DAYS]
+
+
+def refund_candidates(line, ledger, used_ledger):
+    return [entry for entry in ledger
+            if entry.id not in used_ledger and entry.kind == "refund"
+            and entry.original_reference == line.reference and entry.amount_minor == -line.amount_minor]
 
 
 def reconcile(statement, ledger):
-    matches, matched = [], set()
-    for line in sorted(statement, key=lambda s: (s.value_date, s.id)):
-        candidates = exact_candidates(line, ledger)
+    ordered = sorted(statement, key=lambda s: (s.value_date, s.id))
+    used_statement, used_ledger, matches = set(), set(), []
+
+    def record(lines, entry, rule):
+        used_statement.update(line.id for line in lines)
+        used_ledger.add(entry.id)
+        matches.append(Match(frozenset(line.id for line in lines), frozenset([entry.id]), rule))
+
+    for line in ordered:
+        candidates = exact_candidates(line, ledger, used_ledger)
         if candidates:
-            entry = min(candidates, key=lambda e: e.id)
-            matches.append(Match(frozenset([line.id]), frozenset([entry.id]), "R1"))
-            matched.add(line.id)
-    unmatched = {s.id: "no_match" for s in statement if s.id not in matched}
-    used = {identifier for match in matches for identifier in match.ledger_ids}
-    return ReconResult(matches, unmatched, {e.id for e in ledger} - used)
+            record([line], min(candidates, key=lambda e: (day_gap(e, line), e.booking_date, e.id)), "R1")
+    for line in ordered:
+        if line.id not in used_statement and line.amount_minor < 0:
+            candidates = refund_candidates(line, ledger, used_ledger)
+            if candidates:
+                record([line], min(candidates, key=lambda e: (e.booking_date, e.id)), "R3")
+    matched_signatures = {(s.value_date, s.amount_minor, s.reference) for s in statement if s.id in used_statement}
+    unmatched = {line.id: "duplicate" if (line.value_date, line.amount_minor, line.reference) in matched_signatures
+                 else "no_match" for line in ordered if line.id not in used_statement}
+    return ReconResult(matches, unmatched, {e.id for e in ledger} - used_ledger)
05_double_entryEnforce an atomic double-entry journal

Run a double-entry journal where every post balances per currency and is all-or-nothing.

What the hardened grader checks

  • Debits equal credits in each currency; currencies cannot net against each other
  • A post with an unknown account leaves no lines behind
  • A transaction can be reversed once, and a reversal cannot be reversed
  • Every rejected operation leaves the trial balance unchanged
  • A state-machine test over random posts and reversals keeps every currency at zero
Ticket (PROBLEM.md)
# Ticket: enforce the journal invariant

`ledger.service.LedgerService(database_url)` operates a synthetic PostgreSQL ledger.
Expose frozen `Line(account_code: str, side: "debit"|"credit", amount_minor: int)` and:
`create_account(code,currency)`, `post(lines: list[Line],memo: str)->int`,
`reverse(txn_id:int,memo:str)->int`, `balance(account_code)->int`,
`trial_balance()->dict[str,tuple[int,int]]`.

Money uses integer minor units. Currencies: CAD/USD/EUR/GBP=2 dp, JPY=0, KWD/BHD=3;
no exchange or cross-currency netting. Errors derive from LedgerError:
UnbalancedTransaction, InvalidAmount, UnknownAccount, AlreadyReversed, CannotReverseReversal.

- A post has at least two lines (otherwise UnbalancedTransaction). Each amount is a positive
  int, never bool (otherwise InvalidAmount). Side must be debit or credit (LedgerError).
- All accounts must exist (UnknownAccount). For EACH currency independently, sum of debits
  equals sum of credits (otherwise UnbalancedTransaction).
- Posting is atomic. Every rejected operation leaves balances and trial balance unchanged,
  including a missing account after other valid lines. Inputs are not mutated.
- Balance is debits minus credits. trial_balance keys are all currencies of created accounts,
  values are `(total_debits,total_credits)` across all posted lines, including reversal lines.
  A currency without postings has `(0,0)`. balance of an unknown account raises UnknownAccount.
- reverse creates a new transaction, with every side swapped, links it to the original and
  returns its distinct id. An original can be reversed at most once (AlreadyReversed).
  A reversal itself cannot be reversed (CannotReverseReversal). An unknown txn raises LedgerError.
  Reversal counts as real debit/credit turnover; it does not erase original lines.
- State survives constructing a new service. Re-creating an account with the same code and
  currency is idempotent; different currency, empty code or unsupported currency -> LedgerError.
- Validation priority: line count, amount/side, accounts, balancing. On reversing a reversal,
  CannotReverseReversal takes priority. Memo is arbitrary synthetic text.

Worked example (CAD accounts `cash` and `revenue`, synthetic):
`post([Line("cash","debit",5000), Line("revenue","credit",5000)], "sale")` returns id 1;
afterwards `balance("cash") == 5000`, `balance("revenue") == -5000` and `trial_balance()["CAD"] == (5000, 5000)`.
`reverse(1, "undo sale")` returns a different id; both balances are then 0 and the CAD trial balance is
`(10000, 10000)`. Reversing id 1 again raises AlreadyReversed.

The service may create its own tables; tests observe only the declared methods. Run
`python -m pytest tests -q` from starter with DATABASE_URL pointing at a disposable database.
Reviewer notes (NOTES.md)
# Private draft - journal reviewer notes

v2 starter (see analysis/findings.md F-102): the starter posts lines on an autocommit connection
with no balance validation, no transaction wrapper and no reversal (no `reverse` method, no
reversal columns). Per-currency balance validation, atomic posting and the reversal lifecycle are
the ticket. The wrong variants are plausible completions of that starter: `wrong_cross_currency_sum`
validates balance but sums one grand total; `wrong_non_atomic` adds validation and reversal but keeps
the starter's autocommit connection and validates while inserting; `wrong_double_reverse` adds
reversal without the once-only check. The independent valid alternative checks a JSON recordset
using SQL aggregation and reverses by INSERT SELECT; it ignores the starter's schema module.

## Blind review
Author self-review from ticket and starter. Ambiguities: trial_balance key/value meaning,
invalid side/count errors, re-created accounts, unknown transaction errors, and reversal turnover.
All are specified in PROBLEM.md. Predictions: CAD debit100/USD credit100 -> UnbalancedTransaction;
unknown third account -> UnknownAccount with unchanged balances; reverse a reversal ->
CannotReverseReversal, no change. No unresolved graded ambiguity. Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 12/19 tests passed
Failed tests
  • test_hidden_ledger::test_cross_currency_cannot_net
  • test_hidden_ledger::test_unknown_account_is_atomic
  • test_hidden_ledger::test_reverse_once
  • test_hidden_ledger::test_rejections_leave_no_trace
  • test_hidden_ledger::test_post_validation_priority[lines3-UnknownAccount]
  • test_hidden_ledger::test_concurrent_reversals_once
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
9.31 s
Pytest output (last 40 lines)
self = <hypothesis.stateful.JournalMachine.TestCase testMethod=runTest>

    def runTest(self):
>       run_state_machine_as_test(cls, settings=self.settings)

..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:507: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:271: in run_state_machine_as_test
    state_machine_test()
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:122: in run_state_machine
    @given(st.data())
               ^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = JournalMachine({})

    @invariant()
    def balances_match_model(self):
        for currency, codes in self.accounts.items():
            assert sum(self.model[a] for a in codes) == 0, currency
>       assert all(debit == credit for debit, credit in self.service.trial_balance().values())
E       assert False
E        +  where False = all(<generator object JournalMachine.balances_match_model.<locals>.<genexpr> at 0x0000026685877840>)
E       Failing test case: <state machine>
E       state = JournalMachine()
E       state.balances_match_model()
E       state.teardown()

..\grader\test_hidden_ledger.py:222: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_24yu533b\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_ledger.py::test_cross_currency_cannot_net - Fail...
FAILED ..\grader\test_hidden_ledger.py::test_unknown_account_is_atomic - psyc...
FAILED ..\grader\test_hidden_ledger.py::test_reverse_once - AttributeError: '...
FAILED ..\grader\test_hidden_ledger.py::test_rejections_leave_no_trace - Fail...
FAILED ..\grader\test_hidden_ledger.py::test_post_validation_priority[lines3-UnknownAccount]
FAILED ..\grader\test_hidden_ledger.py::test_concurrent_reversals_once - Attr...
FAILED ..\grader\test_hidden_ledger.py::TestJournal::runTest - assert False
7 failed, 12 passed in 8.58s
Naive grader✓ starter: ok 1/3 tests passed
Failed tests
  • test_hidden::test_unbalanced_rejected
  • test_hidden::test_ticket_example_post_then_reverse
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.12 s
Pytest output (last 40 lines)
.FF                                                                      [100%]
================================== FAILURES ===================================
__________________________ test_unbalanced_rejected ___________________________

    def test_unbalanced_rejected():
        s, a, b = accounts()
>       with pytest.raises(UnbalancedTransaction):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'ledger.models.UnbalancedTransaction'>

..\grader\test_hidden.py:23: Failed
____________________ test_ticket_example_post_then_reverse ____________________

    def test_ticket_example_post_then_reverse():
        s, cash, revenue = accounts()
        txn = s.post([Line(cash, "debit", 5000), Line(revenue, "credit", 5000)], "sale")
        assert (s.balance(cash), s.balance(revenue)) == (5000, -5000)
>       reversal = s.reverse(txn, "undo sale")
                   ^^^^^^^^^
E       AttributeError: 'LedgerService' object has no attribute 'reverse'

..\grader\test_hidden.py:31: AttributeError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_gyz7sdfc\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_unbalanced_rejected - Failed: DID NOT R...
FAILED ..\grader\test_hidden.py::test_ticket_example_post_then_reverse - Attr...
2 failed, 1 passed in 0.46s

referencereference

Final grader✓ reference: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
25.48 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_kfud9fmy\junit.xml -
19 passed in 24.12s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.24 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_jfemhk6p\junit.xml -
3 passed in 0.45s
Diff against the starter
--- starter/ledger\schema.py
+++ reference/ledger\schema.py
@@ -2,7 +2,11 @@
 
 SCHEMA = """
 CREATE TABLE IF NOT EXISTS accounts(code text PRIMARY KEY, currency text NOT NULL);
-CREATE TABLE IF NOT EXISTS transactions(id bigserial PRIMARY KEY, memo text);
+CREATE TABLE IF NOT EXISTS transactions(
+    id bigserial PRIMARY KEY,
+    memo text,
+    reversal_of bigint REFERENCES transactions(id),
+    reversed_by bigint);
 CREATE TABLE IF NOT EXISTS lines(
     txn bigint REFERENCES transactions(id),
     code text REFERENCES accounts(code),
--- starter/ledger\service.py
+++ reference/ledger\service.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 import psycopg
 from ledger.models import (Line, LedgerError, UnbalancedTransaction, InvalidAmount, UnknownAccount,
                            AlreadyReversed, CannotReverseReversal, CURRENCIES, validate_lines)  # noqa: F401
@@ -15,7 +16,7 @@
             conn.execute(SCHEMA)
 
     def connect(self):
-        return psycopg.connect(self.url, autocommit=True)
+        return psycopg.connect(self.url)
 
     def create_account(self, code, currency):
         if not code or currency not in CURRENCIES:
@@ -25,14 +26,45 @@
             if conn.execute("SELECT currency FROM accounts WHERE code=%s", (code,)).fetchone()[0] != currency:
                 raise LedgerError("currency mismatch")
 
+    def _validate(self, conn, lines):
+        validate_lines(lines)
+        totals = defaultdict(int)
+        for line in lines:
+            account = conn.execute("SELECT currency FROM accounts WHERE code=%s", (line.account_code,)).fetchone()
+            if account is None:
+                raise UnknownAccount(line.account_code)
+            totals[account[0]] += line.amount_minor * (1 if line.side == "debit" else -1)
+        if any(totals.values()):
+            raise UnbalancedTransaction("per-currency sums must balance")
+
+    def _insert(self, conn, lines, memo, reversal_of=None):
+        txn = conn.execute("INSERT INTO transactions(memo,reversal_of) VALUES (%s,%s) RETURNING id",
+                           (memo, reversal_of)).fetchone()[0]
+        for line in lines:
+            conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
+                         (txn, line.account_code, line.side, line.amount_minor))
+        return txn
+
     def post(self, lines, memo):
-        validate_lines(lines)
         with self.connect() as conn:
-            txn = conn.execute("INSERT INTO transactions(memo) VALUES (%s) RETURNING id", (memo,)).fetchone()[0]
-            for line in lines:
-                conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
-                             (txn, line.account_code, line.side, line.amount_minor))
-            return txn
+            self._validate(conn, lines)
+            return self._insert(conn, lines, memo)
+
+    def reverse(self, txn_id, memo):
+        with self.connect() as conn:
+            row = conn.execute("SELECT reversal_of,reversed_by FROM transactions WHERE id=%s FOR UPDATE",
+                               (txn_id,)).fetchone()
+            if row is None:
+                raise LedgerError("unknown transaction")
+            if row[0] is not None:
+                raise CannotReverseReversal(txn_id)
+            if row[1] is not None:
+                raise AlreadyReversed(txn_id)
+            rows = conn.execute("SELECT code,side,amount FROM lines WHERE txn=%s", (txn_id,)).fetchall()
+            opposite = [Line(code, "credit" if side == "debit" else "debit", amount) for code, side, amount in rows]
+            reversal = self._insert(conn, opposite, memo, txn_id)
+            conn.execute("UPDATE transactions SET reversed_by=%s WHERE id=%s", (reversal, txn_id))
+            return reversal
 
     def balance(self, account_code):
         with self.connect() as conn:

alt_validalt_valid

Final grader✓ alt_valid: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
24.44 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_7domehq8\junit.xml -
19 passed in 23.08s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.58 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_e2n36hoa\junit.xml -
3 passed in 0.74s
Diff against the starter
--- starter/ledger\service.py
+++ alt_valid/ledger\service.py
@@ -1,7 +1,9 @@
+import json
 import psycopg
 from ledger.models import (Line, LedgerError, UnbalancedTransaction, InvalidAmount, UnknownAccount,
                            AlreadyReversed, CannotReverseReversal, CURRENCIES, validate_lines)  # noqa: F401
-from ledger.schema import SCHEMA
+
+RECORDS = "jsonb_to_recordset(%s::jsonb) AS x(account_code text, side text, amount_minor bigint)"
 
 
 __all__ = ["LedgerService", "Line", "LedgerError", "UnbalancedTransaction", "InvalidAmount",
@@ -10,41 +12,72 @@
 
 class LedgerService:
     def __init__(self, database_url):
-        self.url = database_url
-        with self.connect() as conn:
-            conn.execute(SCHEMA)
-
-    def connect(self):
-        return psycopg.connect(self.url, autocommit=True)
+        self.dsn = database_url
+        with psycopg.connect(self.dsn) as c:
+            c.execute("""CREATE TABLE IF NOT EXISTS journal_accounts(name text PRIMARY KEY, denomination text);
+                CREATE TABLE IF NOT EXISTS journal_batches(number bigserial PRIMARY KEY, note text,
+                    undoes bigint, undone_by bigint);
+                CREATE TABLE IF NOT EXISTS journal_lines(batch bigint, account text, direction text, value bigint)""")
 
     def create_account(self, code, currency):
         if not code or currency not in CURRENCIES:
             raise LedgerError("invalid account")
-        with self.connect() as conn:
-            conn.execute("INSERT INTO accounts VALUES (%s,%s) ON CONFLICT DO NOTHING", (code, currency))
-            if conn.execute("SELECT currency FROM accounts WHERE code=%s", (code,)).fetchone()[0] != currency:
-                raise LedgerError("currency mismatch")
+        with psycopg.connect(self.dsn) as c:
+            c.execute("INSERT INTO journal_accounts VALUES (%s,%s) ON CONFLICT DO NOTHING", (code, currency))
+            stored, = c.execute("SELECT denomination FROM journal_accounts WHERE name=%s", (code,)).fetchone()
+            if stored != currency:
+                raise LedgerError("account already has a different denomination")
 
     def post(self, lines, memo):
         validate_lines(lines)
-        with self.connect() as conn:
-            txn = conn.execute("INSERT INTO transactions(memo) VALUES (%s) RETURNING id", (memo,)).fetchone()[0]
-            for line in lines:
-                conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
-                             (txn, line.account_code, line.side, line.amount_minor))
-            return txn
+        payload = json.dumps([line.as_dict() for line in lines])
+        with psycopg.connect(self.dsn) as c:
+            absent = c.execute("SELECT x.account_code FROM " + RECORDS +
+                               " LEFT JOIN journal_accounts a ON a.name=x.account_code WHERE a.name IS NULL",
+                               (payload,)).fetchone()
+            if absent:
+                raise UnknownAccount(absent[0])
+            imbalance = c.execute("SELECT a.denomination FROM " + RECORDS +
+                                  " JOIN journal_accounts a ON a.name=x.account_code GROUP BY a.denomination "
+                                  "HAVING SUM(CASE WHEN x.side='debit' THEN x.amount_minor ELSE -x.amount_minor END)<>0",
+                                  (payload,)).fetchone()
+            if imbalance:
+                raise UnbalancedTransaction("denomination totals differ")
+            number, = c.execute("INSERT INTO journal_batches(note) VALUES (%s) RETURNING number", (memo,)).fetchone()
+            c.execute("INSERT INTO journal_lines SELECT %s,x.account_code,x.side,x.amount_minor FROM " + RECORDS,
+                      (number, payload))
+            return number
+
+    def reverse(self, txn_id, memo):
+        with psycopg.connect(self.dsn) as c:
+            info = c.execute("SELECT undoes,undone_by FROM journal_batches WHERE number=%s FOR UPDATE", (txn_id,)).fetchone()
+            if info is None:
+                raise LedgerError("transaction not found")
+            original, prior = info
+            if original is not None:
+                raise CannotReverseReversal(txn_id)
+            if prior is not None:
+                raise AlreadyReversed(txn_id)
+            number, = c.execute("INSERT INTO journal_batches(note,undoes) VALUES (%s,%s) RETURNING number", (memo, txn_id)).fetchone()
+            c.execute("""INSERT INTO journal_lines SELECT %s,account,
+                CASE direction WHEN 'debit' THEN 'credit' ELSE 'debit' END,value
+                FROM journal_lines WHERE batch=%s""", (number, txn_id))
+            c.execute("UPDATE journal_batches SET undone_by=%s WHERE number=%s", (number, txn_id))
+            return number
 
     def balance(self, account_code):
-        with self.connect() as conn:
-            if conn.execute("SELECT 1 FROM accounts WHERE code=%s", (account_code,)).fetchone() is None:
+        with psycopg.connect(self.dsn) as c:
+            exists = c.execute("SELECT 1 FROM journal_accounts WHERE name=%s", (account_code,)).fetchone()
+            if not exists:
                 raise UnknownAccount(account_code)
-            return int(conn.execute("""SELECT COALESCE(SUM(CASE WHEN side='debit' THEN amount ELSE -amount END),0)
-                FROM lines WHERE code=%s""", (account_code,)).fetchone()[0])
+            value, = c.execute("""SELECT COALESCE(SUM(value * CASE direction WHEN 'debit' THEN 1 ELSE -1 END),0)
+                FROM journal_lines WHERE account=%s""", (account_code,)).fetchone()
+            return int(value)
 
     def trial_balance(self):
-        with self.connect() as conn:
-            rows = conn.execute("""SELECT a.currency,
-                COALESCE(SUM(l.amount) FILTER (WHERE l.side='debit'),0),
-                COALESCE(SUM(l.amount) FILTER (WHERE l.side='credit'),0)
-                FROM accounts a LEFT JOIN lines l ON a.code=l.code GROUP BY a.currency""").fetchall()
-        return {currency: (int(debit), int(credit)) for currency, debit, credit in rows}
+        with psycopg.connect(self.dsn) as c:
+            rows = c.execute("""SELECT denomination,
+                COALESCE(SUM(CASE WHEN direction='debit' THEN value ELSE 0 END),0),
+                COALESCE(SUM(CASE WHEN direction='credit' THEN value ELSE 0 END),0)
+                FROM journal_accounts LEFT JOIN journal_lines ON name=account GROUP BY denomination""").fetchall()
+        return {row[0]: tuple(map(int, row[1:])) for row in rows}

wrong_cross_currency_sumwrong

Final grader✓ wrong_cross_currency_sum: ok 17/19 tests passed
Failed tests
  • test_hidden_ledger::test_cross_currency_cannot_net declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
12.75 s
Hardening
caught by hardening
Pytest output (last 40 lines)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'ledger.models.UnbalancedTransaction'>

..\grader\test_hidden_ledger.py:26: Failed
_____________________________ TestJournal.runTest _____________________________

self = <hypothesis.stateful.JournalMachine.TestCase testMethod=runTest>

    def runTest(self):
>       run_state_machine_as_test(cls, settings=self.settings)

..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:507: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:271: in run_state_machine_as_test
    state_machine_test()
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:122: in run_state_machine
    @given(st.data())
               ^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = JournalMachine({})

    @invariant()
    def balances_match_model(self):
        for currency, codes in self.accounts.items():
            assert sum(self.model[a] for a in codes) == 0, currency
>       assert all(debit == credit for debit, credit in self.service.trial_balance().values())
E       assert False
E        +  where False = all(<generator object JournalMachine.balances_match_model.<locals>.<genexpr> at 0x0000026264B68D60>)
E       Failing test case: <state machine>
E       state = JournalMachine()
E       state.balances_match_model()
E       state.teardown()

..\grader\test_hidden_ledger.py:222: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_qodvmnyo\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_ledger.py::test_cross_currency_cannot_net - Fail...
FAILED ..\grader\test_hidden_ledger.py::TestJournal::runTest - assert False
2 failed, 17 passed in 11.75s
Naive grader✗ wrong_cross_currency_sum: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.83 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_05hj496m\junit.xml -
3 passed in 0.73s
Diff against the starter
--- starter/ledger\schema.py
+++ wrong_cross_currency_sum/ledger\schema.py
@@ -2,7 +2,11 @@
 
 SCHEMA = """
 CREATE TABLE IF NOT EXISTS accounts(code text PRIMARY KEY, currency text NOT NULL);
-CREATE TABLE IF NOT EXISTS transactions(id bigserial PRIMARY KEY, memo text);
+CREATE TABLE IF NOT EXISTS transactions(
+    id bigserial PRIMARY KEY,
+    memo text,
+    reversal_of bigint REFERENCES transactions(id),
+    reversed_by bigint);
 CREATE TABLE IF NOT EXISTS lines(
     txn bigint REFERENCES transactions(id),
     code text REFERENCES accounts(code),
--- starter/ledger\service.py
+++ wrong_cross_currency_sum/ledger\service.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 import psycopg
 from ledger.models import (Line, LedgerError, UnbalancedTransaction, InvalidAmount, UnknownAccount,
                            AlreadyReversed, CannotReverseReversal, CURRENCIES, validate_lines)  # noqa: F401
@@ -15,7 +16,7 @@
             conn.execute(SCHEMA)
 
     def connect(self):
-        return psycopg.connect(self.url, autocommit=True)
+        return psycopg.connect(self.url)
 
     def create_account(self, code, currency):
         if not code or currency not in CURRENCIES:
@@ -25,14 +26,45 @@
             if conn.execute("SELECT currency FROM accounts WHERE code=%s", (code,)).fetchone()[0] != currency:
                 raise LedgerError("currency mismatch")
 
+    def _validate(self, conn, lines):
+        validate_lines(lines)
+        totals = defaultdict(int)
+        for line in lines:
+            account = conn.execute("SELECT currency FROM accounts WHERE code=%s", (line.account_code,)).fetchone()
+            if account is None:
+                raise UnknownAccount(line.account_code)
+            totals["all"] += line.amount_minor * (1 if line.side == "debit" else -1)
+        if any(totals.values()):
+            raise UnbalancedTransaction("per-currency sums must balance")
+
+    def _insert(self, conn, lines, memo, reversal_of=None):
+        txn = conn.execute("INSERT INTO transactions(memo,reversal_of) VALUES (%s,%s) RETURNING id",
+                           (memo, reversal_of)).fetchone()[0]
+        for line in lines:
+            conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
+                         (txn, line.account_code, line.side, line.amount_minor))
+        return txn
+
     def post(self, lines, memo):
-        validate_lines(lines)
         with self.connect() as conn:
-            txn = conn.execute("INSERT INTO transactions(memo) VALUES (%s) RETURNING id", (memo,)).fetchone()[0]
-            for line in lines:
-                conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
-                             (txn, line.account_code, line.side, line.amount_minor))
-            return txn
+            self._validate(conn, lines)
+            return self._insert(conn, lines, memo)
+
+    def reverse(self, txn_id, memo):
+        with self.connect() as conn:
+            row = conn.execute("SELECT reversal_of,reversed_by FROM transactions WHERE id=%s FOR UPDATE",
+                               (txn_id,)).fetchone()
+            if row is None:
+                raise LedgerError("unknown transaction")
+            if row[0] is not None:
+                raise CannotReverseReversal(txn_id)
+            if row[1] is not None:
+                raise AlreadyReversed(txn_id)
+            rows = conn.execute("SELECT code,side,amount FROM lines WHERE txn=%s", (txn_id,)).fetchall()
+            opposite = [Line(code, "credit" if side == "debit" else "debit", amount) for code, side, amount in rows]
+            reversal = self._insert(conn, opposite, memo, txn_id)
+            conn.execute("UPDATE transactions SET reversed_by=%s WHERE id=%s", (reversal, txn_id))
+            return reversal
 
     def balance(self, account_code):
         with self.connect() as conn:

wrong_double_reversewrong

Final grader✓ wrong_double_reverse: ok 16/19 tests passed
Failed tests
  • test_hidden_ledger::test_reverse_once declared must_fail
  • test_hidden_ledger::test_concurrent_reversals_once
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
14.25 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:507: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:271: in run_state_machine_as_test
    state_machine_test()
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:122: in run_state_machine
    @given(st.data())
               ^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = JournalMachine({})

    def teardown(self):
>       assert {a: self.service.balance(a) for a in self.model} == self.model
E       AssertionError: assert {'00e9f220dc1...CAD3': 0, ...} == {'00e9f220dc1...CAD3': 0, ...}
E         
E         Omitting 6 identical items, use -vv to show
E         Differing items:
E         {'00e9f220dc1b4b149b41a859c6e37f54_USD2': 9519} != {'00e9f220dc1b4b149b41a859c6e37f54_USD2': 0}
E         {'00e9f220dc1b4b149b41a859c6e37f54_USD1': -9519} != {'00e9f220dc1b4b149b41a859c6e37f54_USD1': 0}
E         Use -v to get more diff
E       Failing test case: <state machine>
E       state = JournalMachine()
E       state.balances_match_model()
E       state.post_valid(amount=9519, currency='USD', index=1)
E       state.balances_match_model()
E       Draw 1: 82
E       state.reverse(data=data(...))
E       state.balances_match_model()
E       Draw 2: 82
E       state.reverse_again(data=data(...))
E       state.teardown()

..\grader\test_hidden_ledger.py:225: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_mij7c67x\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_ledger.py::test_reverse_once - Failed: DID NOT R...
FAILED ..\grader\test_hidden_ledger.py::test_concurrent_reversals_once - asse...
FAILED ..\grader\test_hidden_ledger.py::TestJournal::runTest - AssertionError...
3 failed, 16 passed in 13.18s
Naive grader✗ wrong_double_reverse: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.80 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_g0vp3js7\junit.xml -
3 passed in 0.75s
Diff against the starter
--- starter/ledger\schema.py
+++ wrong_double_reverse/ledger\schema.py
@@ -2,7 +2,11 @@
 
 SCHEMA = """
 CREATE TABLE IF NOT EXISTS accounts(code text PRIMARY KEY, currency text NOT NULL);
-CREATE TABLE IF NOT EXISTS transactions(id bigserial PRIMARY KEY, memo text);
+CREATE TABLE IF NOT EXISTS transactions(
+    id bigserial PRIMARY KEY,
+    memo text,
+    reversal_of bigint REFERENCES transactions(id),
+    reversed_by bigint);
 CREATE TABLE IF NOT EXISTS lines(
     txn bigint REFERENCES transactions(id),
     code text REFERENCES accounts(code),
--- starter/ledger\service.py
+++ wrong_double_reverse/ledger\service.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 import psycopg
 from ledger.models import (Line, LedgerError, UnbalancedTransaction, InvalidAmount, UnknownAccount,
                            AlreadyReversed, CannotReverseReversal, CURRENCIES, validate_lines)  # noqa: F401
@@ -15,7 +16,7 @@
             conn.execute(SCHEMA)
 
     def connect(self):
-        return psycopg.connect(self.url, autocommit=True)
+        return psycopg.connect(self.url)
 
     def create_account(self, code, currency):
         if not code or currency not in CURRENCIES:
@@ -25,14 +26,43 @@
             if conn.execute("SELECT currency FROM accounts WHERE code=%s", (code,)).fetchone()[0] != currency:
                 raise LedgerError("currency mismatch")
 
+    def _validate(self, conn, lines):
+        validate_lines(lines)
+        totals = defaultdict(int)
+        for line in lines:
+            account = conn.execute("SELECT currency FROM accounts WHERE code=%s", (line.account_code,)).fetchone()
+            if account is None:
+                raise UnknownAccount(line.account_code)
+            totals[account[0]] += line.amount_minor * (1 if line.side == "debit" else -1)
+        if any(totals.values()):
+            raise UnbalancedTransaction("per-currency sums must balance")
+
+    def _insert(self, conn, lines, memo, reversal_of=None):
+        txn = conn.execute("INSERT INTO transactions(memo,reversal_of) VALUES (%s,%s) RETURNING id",
+                           (memo, reversal_of)).fetchone()[0]
+        for line in lines:
+            conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
+                         (txn, line.account_code, line.side, line.amount_minor))
+        return txn
+
     def post(self, lines, memo):
-        validate_lines(lines)
         with self.connect() as conn:
-            txn = conn.execute("INSERT INTO transactions(memo) VALUES (%s) RETURNING id", (memo,)).fetchone()[0]
-            for line in lines:
-                conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
-                             (txn, line.account_code, line.side, line.amount_minor))
-            return txn
+            self._validate(conn, lines)
+            return self._insert(conn, lines, memo)
+
+    def reverse(self, txn_id, memo):
+        with self.connect() as conn:
+            row = conn.execute("SELECT reversal_of,reversed_by FROM transactions WHERE id=%s FOR UPDATE",
+                               (txn_id,)).fetchone()
+            if row is None:
+                raise LedgerError("unknown transaction")
+            if row[0] is not None:
+                raise CannotReverseReversal(txn_id)
+            rows = conn.execute("SELECT code,side,amount FROM lines WHERE txn=%s", (txn_id,)).fetchall()
+            opposite = [Line(code, "credit" if side == "debit" else "debit", amount) for code, side, amount in rows]
+            reversal = self._insert(conn, opposite, memo, txn_id)
+            conn.execute("UPDATE transactions SET reversed_by=%s WHERE id=%s", (reversal, txn_id))
+            return reversal
 
     def balance(self, account_code):
         with self.connect() as conn:

wrong_non_atomicwrong

Final grader✓ wrong_non_atomic: ok 14/19 tests passed
Failed tests
  • test_hidden_ledger::test_cross_currency_cannot_net
  • test_hidden_ledger::test_unknown_account_is_atomic declared must_fail
  • test_hidden_ledger::test_rejections_leave_no_trace
  • test_hidden_ledger::test_concurrent_reversals_once
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
11.51 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..\grader\test_hidden_ledger.py:163: AssertionError
_____________________________ TestJournal.runTest _____________________________

self = <hypothesis.stateful.JournalMachine.TestCase testMethod=runTest>

    def runTest(self):
>       run_state_machine_as_test(cls, settings=self.settings)

..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:507: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:271: in run_state_machine_as_test
    state_machine_test()
..\..\..\..\Programs\Python\Python314\Lib\site-packages\hypothesis\stateful.py:122: in run_state_machine
    @given(st.data())
               ^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = JournalMachine({})

    @invariant()
    def balances_match_model(self):
        for currency, codes in self.accounts.items():
            assert sum(self.model[a] for a in codes) == 0, currency
>       assert all(debit == credit for debit, credit in self.service.trial_balance().values())
E       assert False
E        +  where False = all(<generator object JournalMachine.balances_match_model.<locals>.<genexpr> at 0x00000250A9464660>)
E       Failing test case: <state machine>
E       state = JournalMachine()
E       state.balances_match_model()
E       state.teardown()

..\grader\test_hidden_ledger.py:222: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_y4bqjzmt\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_ledger.py::test_cross_currency_cannot_net - Asse...
FAILED ..\grader\test_hidden_ledger.py::test_unknown_account_is_atomic - Asse...
FAILED ..\grader\test_hidden_ledger.py::test_rejections_leave_no_trace - Asse...
FAILED ..\grader\test_hidden_ledger.py::test_concurrent_reversals_once - asse...
FAILED ..\grader\test_hidden_ledger.py::TestJournal::runTest - assert False
5 failed, 14 passed in 10.56s
Naive grader✗ wrong_non_atomic: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.83 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_1mrlk8eu\junit.xml -
3 passed in 0.73s
Diff against the starter
--- starter/ledger\schema.py
+++ wrong_non_atomic/ledger\schema.py
@@ -2,7 +2,11 @@
 
 SCHEMA = """
 CREATE TABLE IF NOT EXISTS accounts(code text PRIMARY KEY, currency text NOT NULL);
-CREATE TABLE IF NOT EXISTS transactions(id bigserial PRIMARY KEY, memo text);
+CREATE TABLE IF NOT EXISTS transactions(
+    id bigserial PRIMARY KEY,
+    memo text,
+    reversal_of bigint REFERENCES transactions(id),
+    reversed_by bigint);
 CREATE TABLE IF NOT EXISTS lines(
     txn bigint REFERENCES transactions(id),
     code text REFERENCES accounts(code),
--- starter/ledger\service.py
+++ wrong_non_atomic/ledger\service.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 import psycopg
 from ledger.models import (Line, LedgerError, UnbalancedTransaction, InvalidAmount, UnknownAccount,
                            AlreadyReversed, CannotReverseReversal, CURRENCIES, validate_lines)  # noqa: F401
@@ -25,14 +26,46 @@
             if conn.execute("SELECT currency FROM accounts WHERE code=%s", (code,)).fetchone()[0] != currency:
                 raise LedgerError("currency mismatch")
 
+    def _insert(self, conn, lines, memo, reversal_of=None):
+        txn = conn.execute("INSERT INTO transactions(memo,reversal_of) VALUES (%s,%s) RETURNING id",
+                           (memo, reversal_of)).fetchone()[0]
+        for line in lines:
+            conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
+                         (txn, line.account_code, line.side, line.amount_minor))
+        return txn
+
     def post(self, lines, memo):
         validate_lines(lines)
         with self.connect() as conn:
             txn = conn.execute("INSERT INTO transactions(memo) VALUES (%s) RETURNING id", (memo,)).fetchone()[0]
+            totals = defaultdict(int)
             for line in lines:
+                account = conn.execute("SELECT currency FROM accounts WHERE code=%s",
+                                       (line.account_code,)).fetchone()
+                if account is None:
+                    raise UnknownAccount(line.account_code)
+                totals[account[0]] += line.amount_minor * (1 if line.side == "debit" else -1)
                 conn.execute("INSERT INTO lines VALUES (%s,%s,%s,%s)",
                              (txn, line.account_code, line.side, line.amount_minor))
+            if any(totals.values()):
+                raise UnbalancedTransaction("per-currency sums must balance")
             return txn
+
+    def reverse(self, txn_id, memo):
+        with self.connect() as conn:
+            row = conn.execute("SELECT reversal_of,reversed_by FROM transactions WHERE id=%s FOR UPDATE",
+                               (txn_id,)).fetchone()
+            if row is None:
+                raise LedgerError("unknown transaction")
+            if row[0] is not None:
+                raise CannotReverseReversal(txn_id)
+            if row[1] is not None:
+                raise AlreadyReversed(txn_id)
+            rows = conn.execute("SELECT code,side,amount FROM lines WHERE txn=%s", (txn_id,)).fetchall()
+            opposite = [Line(code, "credit" if side == "debit" else "debit", amount) for code, side, amount in rows]
+            reversal = self._insert(conn, opposite, memo, txn_id)
+            conn.execute("UPDATE transactions SET reversed_by=%s WHERE id=%s", (reversal, txn_id))
+            return reversal
 
     def balance(self, account_code):
         with self.connect() as conn:
06_available_balanceTrack active authorization holds and available balance

Compute available balance as posted funds minus active card holds, with capture, void and expiry.

What the hardened grader checks

  • Existing holds reduce what new authorizations can use
  • A partial capture releases the rest of the hold
  • Holds stop counting exactly seven days after creation
  • Approval is allowed exactly at the overdraft limit and declined one unit over
  • A random sequence of operations matches a simple model of the account
Ticket (PROBLEM.md)
# Ticket: available balance must include active holds

`holds.main.create_app(database_path,clock)->FastAPI` uses SQLite. The declared
`holds.clock.Clock` Protocol has `now()->datetime`, always aware (otherwise ValueError).
The factory seeds two synthetic accounts on first use, without resetting existing state:
`acct_standard`: posted10000, overdraft0; `acct_flex`: posted10000, overdraft5000.
All money is integer CAD minor units (2 dp). All amounts must be positive ints, never bool.

- GET /accounts/{id}/balance ->200 `{posted_minor,available_minor}`.
  available = posted minus sum of active, unexpired holds.
- POST /accounts/{id}/authorizations `{amount_minor}` ->201 `{auth_id}` iff amount <=
  available + that account's overdraft limit; otherwise402 `insufficient_funds` with no effect.
- A hold expires exactly 7×24 hours after creation, and stops counting at that instant.
- POST /authorizations/{id}/capture `{amount_minor}`: amount <= held amount; overcapture ->422
  `capture_exceeds_hold` with no effect. Successful capture ->200 `{auth_id,status:"captured"}`,
  deduct amount from posted balance, close the hold and release ALL unused remainder.
- POST /authorizations/{id}/void ->200 `{auth_id,status:"voided"}`, close hold, no posted change.
- Capturing/voiding an expired or already closed hold ->409 `authorization_closed`, no effect.
- Invalid body/amount ->422 `invalid_amount`; unknown account/auth ->404 `not_found`.
  Errors are `{"error":{"code":"...","message":"..."}}`; message text is free.
  For capture, validate body/amount first, then existence, then closed/expired status, then
  overcapture. Thus a positive overcapture on a closed/expired hold returns 409
  `authorization_closed`; an invalid amount on that hold returns 422 `invalid_amount`.
  Void has no JSON body.
- Auth ids are nonempty unique strings; available may be negative within overdraft.
  State survives creating another app on the same file. Operations are atomic and per account.

Worked example (`acct_standard`, CAD minor units): authorize 3000 -> 201 and the balance is
`{posted_minor: 10000, available_minor: 7000}`. Capture 1200 -> 200 and the balance is
`{posted_minor: 8800, available_minor: 8800}`: the 1200 is deducted from posted and the unused 1800 of the
hold is released. A further authorization of 8801 is declined with 402 `insufficient_funds`; 8800 is approved.

Tests use the HTTP interface and the injected clock only. No real accounts, system clock delays
or network payments. Run `python -m pytest tests -q` from starter.
Reviewer notes (NOTES.md)
# Private draft - holds reviewer notes

v2 starter (see analysis/findings.md F-103): authorizations are recorded and approved against posted
funds plus overdraft, and `available_minor` always equals `posted_minor`; there is no holds model, no
capture or void endpoint and no expiry. The wrong variants are plausible completions of that starter:
`wrong_available_ignores_holds` adds capture, void and expiry but keeps the starter's availability;
`wrong_partial_capture_keeps_remainder` adds holds but leaves the unused part of a partially captured
hold active; `wrong_expired_holds_count` adds holds and rejects expired capture/void but still counts
expired active records against availability. The alternative retires expired records at transaction
entry and recomputes availability from the remaining active records.

## Blind review
Author self-review clarified account seeding, exact expiry instant, partial-capture response,
closed/overcapture error priority, persistence and amount validation in the ticket.
Predictions: reserve1000/capture400 ->posted9600/available9600; at7days hold stops counting;
flex15000 authorized then1 declined. No unresolved graded ambiguity. Independent review owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 1/12 tests passed
Failed tests
  • test_hidden_holds::test_existing_holds_reduce_available
  • test_hidden_holds::test_partial_capture_releases_remainder
  • test_hidden_holds::test_expiry_exactly_seven_days
  • test_hidden_holds::test_void_overcapture_and_persistence
  • test_hidden_holds::test_overdraft_boundary
  • test_hidden_holds::test_capture_amount_validation_before_closed_status[active]
  • test_hidden_holds::test_capture_amount_validation_before_closed_status[voided]
  • test_hidden_holds::test_capture_amount_validation_before_closed_status[captured]
  • test_hidden_holds::test_capture_amount_validation_before_closed_status[expired]
  • test_hidden_holds::test_authorization_validation_and_unknown_ids
  • test_hidden_holds::test_random_operation_model
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
2.50 s
Pytest output (last 40 lines)
                        elif operation == "capture" and value > held:
                            assert result.status_code == 422
                        else:
                            assert result.status_code == 200
                            holds[id][2] = True
                            if operation == "capture":
                                posted -= value
>                   assert balance(client, account) == {"posted_minor": posted, "available_minor": posted - active()}
E                   AssertionError: assert {'available_m...minor': 10000} == {'available_m...minor': 10000}
E                     
E                     Omitting 1 identical items, use -vv to show
E                     Differing items:
E                     {'available_minor': 10000} != {'available_minor': 9900}
E                     Use -v to get more diff
E                   Failing explicit example: test_random_operation_model(
E                       flex=False,
E                       operations=[('authorize', 100, 0), ('void', 1, 0), ('capture', 101, 0)],
E                   )

..\grader\test_hidden_holds.py:176: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_txh_vwdr\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_holds.py::test_existing_holds_reduce_available
FAILED ..\grader\test_hidden_holds.py::test_partial_capture_releases_remainder
FAILED ..\grader\test_hidden_holds.py::test_expiry_exactly_seven_days - asser...
FAILED ..\grader\test_hidden_holds.py::test_void_overcapture_and_persistence
FAILED ..\grader\test_hidden_holds.py::test_overdraft_boundary - AssertionErr...
FAILED ..\grader\test_hidden_holds.py::test_capture_amount_validation_before_closed_status[active]
FAILED ..\grader\test_hidden_holds.py::test_capture_amount_validation_before_closed_status[voided]
FAILED ..\grader\test_hidden_holds.py::test_capture_amount_validation_before_closed_status[captured]
FAILED ..\grader\test_hidden_holds.py::test_capture_amount_validation_before_closed_status[expired]
FAILED ..\grader\test_hidden_holds.py::test_authorization_validation_and_unknown_ids
FAILED ..\grader\test_hidden_holds.py::test_random_operation_model - Assertio...
11 failed, 1 passed, 1 warning in 1.63s
Naive grader✓ starter: ok 1/3 tests passed
Failed tests
  • test_hidden::test_full_capture
  • test_hidden::test_ticket_worked_example
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.52 s
Pytest output (last 40 lines)
    def test_full_capture(tmp_path):
        client = TestClient(create_app(tmp_path / "db", Clock()))
        result = client.post("/accounts/acct_standard/authorizations", json={"amount_minor": 1000})
        assert result.status_code == 201
        identifier = result.json()["auth_id"]
>       assert client.post(f"/authorizations/{identifier}/capture", json={"amount_minor": 1000}).status_code == 200
E       AssertionError: assert 404 == 200
E        +  where 404 = <Response [404 Not Found]>.status_code
E        +    where <Response [404 Not Found]> = post('/authorizations/ee848892101f4f99acd963dea2d919ba/capture', json={'amount_minor': 1000})
E        +      where post = <starlette.testclient.TestClient object at 0x00000214E34C96A0>.post

..\grader\test_hidden.py:16: AssertionError
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run__8mddxi2/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        client = TestClient(create_app(tmp_path / "db", Clock()))
        identifier = client.post("/accounts/acct_standard/authorizations", json={"amount_minor": 3000}).json()["auth_id"]
>       assert client.get("/accounts/acct_standard/balance").json() == {"posted_minor": 10000, "available_minor": 7000}
E       AssertionError: assert {'available_m...minor': 10000} == {'available_m...minor': 10000}
E         
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'available_minor': 10000} != {'available_minor': 7000}
E         Use -v to get more diff

..\grader\test_hidden.py:23: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run__8mddxi2\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_full_capture - AssertionError: assert 4...
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - AssertionError:...
2 failed, 1 passed, 1 warning in 0.42s

referencereference

Final grader✓ reference: ok 12/12 tests passed
Failed tests

All 12 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
7.00 s
Pytest output (last 40 lines)
............                                                             [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_bcpsz0w9\junit.xml -
12 passed, 1 warning in 6.36s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.17 s
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_c1_99e37\junit.xml -
3 passed, 1 warning in 0.38s
Diff against the starter
--- starter/holds\main.py
+++ reference/holds\main.py
@@ -3,7 +3,7 @@
 from fastapi.responses import JSONResponse
 from holds.clock import instant
 from holds.errors import error
-from holds.storage import transaction, initialize, account
+from holds.storage import transaction, initialize, account, authorization, active_total, HOLD_SECONDS
 from holds.validation import amount
 
 
@@ -18,7 +18,7 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            return {"posted_minor": row["posted"], "available_minor": row["posted"]}
+            return {"posted_minor": row["posted"], "available_minor": row["posted"] - active_total(db, id, instant(clock))}
 
     @api.post("/accounts/{id}/authorizations")
     async def authorize(id: str, request: Request):
@@ -30,9 +30,34 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            if value > row["posted"] + row["overdraft"]:
+            available = row["posted"] - active_total(db, id, now)
+            if value > available + row["overdraft"]:
                 return error(402, "insufficient_funds")
             identifier = uuid4().hex
             db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", (identifier, id, value, now))
             return JSONResponse({"auth_id": identifier}, status_code=201)
+
+    def close(id, capture=None):
+        with transaction(database_path) as db:
+            auth = authorization(db, id)
+            if auth is None:
+                return error(404, "not_found")
+            if auth["state"] != "active" or instant(clock) >= auth["created"] + HOLD_SECONDS:
+                return error(409, "authorization_closed")
+            if capture is not None:
+                if capture > auth["amount"]:
+                    return error(422, "capture_exceeds_hold")
+                db.execute("UPDATE accounts SET posted=posted-? WHERE id=?", (capture, auth["account"]))
+            state = "captured" if capture is not None else "voided"
+            db.execute("UPDATE authorizations SET state=? WHERE id=?", (state, id))
+            return {"auth_id": id, "status": state}
+
+    @api.post("/authorizations/{id}/capture")
+    async def capture(id: str, request: Request):
+        value = await amount(request)
+        return error(422, "invalid_amount") if value is None else close(id, value)
+
+    @api.post("/authorizations/{id}/void")
+    def void(id: str):
+        return close(id)
     return api
--- starter/holds\storage.py
+++ reference/holds\storage.py
@@ -24,6 +24,15 @@
         seed_accounts(db)
 
 
+HOLD_SECONDS = 7 * 86400
+
+
+def active_total(db, account, now):
+    """Sum of the account's active holds that have not yet expired at `now` (epoch seconds)."""
+    return db.execute("""SELECT COALESCE(SUM(amount),0) FROM authorizations
+        WHERE account=? AND state='active' AND created>?""", (account, now - HOLD_SECONDS)).fetchone()[0]
+
+
 def account(db, identifier):
     return db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
 

alt_validalt_valid

Final grader✓ alt_valid: ok 12/12 tests passed
Failed tests

All 12 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
7.51 s
Pytest output (last 40 lines)
............                                                             [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_v6c5a8xv\junit.xml -
12 passed, 1 warning in 6.24s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.91 s
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_x2y_gfhz\junit.xml -
3 passed, 1 warning in 0.96s
Diff against the starter
--- starter/holds\main.py
+++ alt_valid/holds\main.py
@@ -1,38 +1,86 @@
+from contextlib import contextmanager
 from uuid import uuid4
 from fastapi import FastAPI, Request
 from fastapi.responses import JSONResponse
 from holds.clock import instant
-from holds.errors import error
-from holds.storage import transaction, initialize, account
-from holds.validation import amount
+from holds.storage import initialize, transaction
 
 
 def create_app(database_path, clock):
     initialize(database_path)
     instant(clock)
-    api = FastAPI()
+    application = FastAPI()
 
-    @api.get("/accounts/{id}/balance")
-    def balance(id: str):
-        with transaction(database_path) as db:
-            row = account(db, id)
-            if row is None:
-                return error(404, "not_found")
-            return {"posted_minor": row["posted"], "available_minor": row["posted"]}
+    def failure(status, code):
+        return JSONResponse({"error": {"code": code, "message": code}}, status_code=status)
 
-    @api.post("/accounts/{id}/authorizations")
-    async def authorize(id: str, request: Request):
-        value = await amount(request)
-        if value is None:
-            return error(422, "invalid_amount")
-        now = instant(clock)
-        with transaction(database_path) as db:
-            row = account(db, id)
-            if row is None:
-                return error(404, "not_found")
-            if value > row["posted"] + row["overdraft"]:
-                return error(402, "insufficient_funds")
-            identifier = uuid4().hex
-            db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", (identifier, id, value, now))
-            return JSONResponse({"auth_id": identifier}, status_code=201)
-    return api
+    @contextmanager
+    def current_state():
+        with transaction(database_path) as connection:
+            connection.execute("UPDATE authorizations SET state='expired' WHERE state='active' AND created<=?",
+                               [instant(clock) - 604800])
+            yield connection
+
+    async def number(request):
+        try:
+            payload = await request.json()
+            if isinstance(payload, dict) and set(payload) == {"amount_minor"}:
+                n = payload["amount_minor"]
+                if type(n) is int and n > 0:
+                    return n
+        except ValueError:
+            pass
+        return None
+
+    def funds(db, id):
+        result = db.execute("SELECT posted,overdraft FROM accounts WHERE id=?", [id]).fetchone()
+        if result is None:
+            return None
+        total, = db.execute("SELECT COALESCE(SUM(amount),0) FROM authorizations WHERE account=? AND state='active'", [id]).fetchone()
+        return result[0], result[0] - total, result[1]
+
+    @application.get("/accounts/{id}/balance")
+    def get_balance(id: str):
+        with current_state() as db:
+            totals = funds(db, id)
+            return failure(404, "not_found") if totals is None else {"posted_minor": totals[0], "available_minor": totals[1]}
+
+    @application.post("/accounts/{id}/authorizations")
+    async def reserve(id: str, request: Request):
+        n = await number(request)
+        if n is None:
+            return failure(422, "invalid_amount")
+        with current_state() as db:
+            totals = funds(db, id)
+            if totals is None:
+                return failure(404, "not_found")
+            if n > totals[1] + totals[2]:
+                return failure(402, "insufficient_funds")
+            key = str(uuid4())
+            db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", [key, id, n, instant(clock)])
+            return JSONResponse({"auth_id": key}, status_code=201)
+
+    def mutate(key, action, n=0):
+        with current_state() as db:
+            record = db.execute("SELECT account,amount,state FROM authorizations WHERE id=?", [key]).fetchone()
+            if record is None:
+                return failure(404, "not_found")
+            owner, reserved, status = record
+            if status != "active":
+                return failure(409, "authorization_closed")
+            if action == "captured":
+                if n > reserved:
+                    return failure(422, "capture_exceeds_hold")
+                db.execute("UPDATE accounts SET posted=posted-? WHERE id=?", [n, owner])
+            db.execute("UPDATE authorizations SET state=? WHERE id=?", [action, key])
+            return {"auth_id": key, "status": action}
+
+    @application.post("/authorizations/{id}/capture")
+    async def settle(id: str, request: Request):
+        n = await number(request)
+        return failure(422, "invalid_amount") if n is None else mutate(id, "captured", n)
+
+    @application.post("/authorizations/{id}/void")
+    def release(id: str):
+        return mutate(id, "voided")
+    return application

wrong_available_ignores_holdswrong

Final grader✓ wrong_available_ignores_holds: ok 7/12 tests passed
Failed tests
  • test_hidden_holds::test_existing_holds_reduce_available declared must_fail
  • test_hidden_holds::test_expiry_exactly_seven_days
  • test_hidden_holds::test_void_overcapture_and_persistence
  • test_hidden_holds::test_overdraft_boundary
  • test_hidden_holds::test_random_operation_model
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
3.06 s
Pytest output (last 40 lines)
                        id = list(holds)[index % len(holds)]
                        held, at, closed = holds[id]
                        args = {"json": {"amount_minor": value}} if operation == "capture" else {}
                        result = client.post(f"/authorizations/{id}/{operation}", **args)
                        if closed or clock.at >= at + timedelta(days=7):
                            assert result.status_code == 409
                        elif operation == "capture" and value > held:
                            assert result.status_code == 422
                        else:
                            assert result.status_code == 200
                            holds[id][2] = True
                            if operation == "capture":
                                posted -= value
>                   assert balance(client, account) == {"posted_minor": posted, "available_minor": posted - active()}
E                   AssertionError: assert {'available_m...minor': 10000} == {'available_m...minor': 10000}
E                     
E                     Omitting 1 identical items, use -vv to show
E                     Differing items:
E                     {'available_minor': 10000} != {'available_minor': 9900}
E                     Use -v to get more diff
E                   Failing explicit example: test_random_operation_model(
E                       flex=False,
E                       operations=[('authorize', 100, 0), ('void', 1, 0), ('capture', 101, 0)],
E                   )

..\grader\test_hidden_holds.py:176: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_gbl0mqkj\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_holds.py::test_existing_holds_reduce_available
FAILED ..\grader\test_hidden_holds.py::test_expiry_exactly_seven_days - asser...
FAILED ..\grader\test_hidden_holds.py::test_void_overcapture_and_persistence
FAILED ..\grader\test_hidden_holds.py::test_overdraft_boundary - AssertionErr...
FAILED ..\grader\test_hidden_holds.py::test_random_operation_model - Assertio...
5 failed, 7 passed, 1 warning in 2.10s
Naive grader~ wrong_available_ignores_holds: ok_wrong_reason 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
1.73 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_gi3g8o50/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        client = TestClient(create_app(tmp_path / "db", Clock()))
        identifier = client.post("/accounts/acct_standard/authorizations", json={"amount_minor": 3000}).json()["auth_id"]
>       assert client.get("/accounts/acct_standard/balance").json() == {"posted_minor": 10000, "available_minor": 7000}
E       AssertionError: assert {'available_m...minor': 10000} == {'available_m...minor': 10000}
E         
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'available_minor': 10000} != {'available_minor': 7000}
E         Use -v to get more diff

..\grader\test_hidden.py:23: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_gi3g8o50\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - AssertionError:...
1 failed, 2 passed, 1 warning in 0.82s
Diff against the starter
--- starter/holds\main.py
+++ wrong_available_ignores_holds/holds\main.py
@@ -3,7 +3,7 @@
 from fastapi.responses import JSONResponse
 from holds.clock import instant
 from holds.errors import error
-from holds.storage import transaction, initialize, account
+from holds.storage import transaction, initialize, account, authorization, HOLD_SECONDS
 from holds.validation import amount
 
 
@@ -30,9 +30,34 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            if value > row["posted"] + row["overdraft"]:
+            available = row["posted"]
+            if value > available + row["overdraft"]:
                 return error(402, "insufficient_funds")
             identifier = uuid4().hex
             db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", (identifier, id, value, now))
             return JSONResponse({"auth_id": identifier}, status_code=201)
+
+    def close(id, capture=None):
+        with transaction(database_path) as db:
+            auth = authorization(db, id)
+            if auth is None:
+                return error(404, "not_found")
+            if auth["state"] != "active" or instant(clock) >= auth["created"] + HOLD_SECONDS:
+                return error(409, "authorization_closed")
+            if capture is not None:
+                if capture > auth["amount"]:
+                    return error(422, "capture_exceeds_hold")
+                db.execute("UPDATE accounts SET posted=posted-? WHERE id=?", (capture, auth["account"]))
+            state = "captured" if capture is not None else "voided"
+            db.execute("UPDATE authorizations SET state=? WHERE id=?", (state, id))
+            return {"auth_id": id, "status": state}
+
+    @api.post("/authorizations/{id}/capture")
+    async def capture(id: str, request: Request):
+        value = await amount(request)
+        return error(422, "invalid_amount") if value is None else close(id, value)
+
+    @api.post("/authorizations/{id}/void")
+    def void(id: str):
+        return close(id)
     return api
--- starter/holds\storage.py
+++ wrong_available_ignores_holds/holds\storage.py
@@ -24,6 +24,15 @@
         seed_accounts(db)
 
 
+HOLD_SECONDS = 7 * 86400
+
+
+def active_total(db, account, now):
+    """Sum of the account's active holds that have not yet expired at `now` (epoch seconds)."""
+    return db.execute("""SELECT COALESCE(SUM(amount),0) FROM authorizations
+        WHERE account=? AND state='active' AND created>?""", (account, now - HOLD_SECONDS)).fetchone()[0]
+
+
 def account(db, identifier):
     return db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
 

wrong_expired_holds_countwrong

Final grader✓ wrong_expired_holds_count: ok 11/12 tests passed
Failed tests
  • test_hidden_holds::test_expiry_exactly_seven_days declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
6.96 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..F.........                                                             [100%]
================================== FAILURES ===================================
_______________________ test_expiry_exactly_seven_days ________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_o8dgbwys/pytest-tmp/test_expiry_exactly_seven_days0')

    def test_expiry_exactly_seven_days(tmp_path):
        clock = Clock()
        with TestClient(create_app(tmp_path / "db", clock)) as client:
            id = authorize(client, 1000).json()["auth_id"]
            clock.at += timedelta(days=7) - timedelta(microseconds=1)
            assert balance(client)["available_minor"] == 9000
            clock.at += timedelta(microseconds=1)
>           assert balance(client) == {"posted_minor": 10000, "available_minor": 10000}
E           AssertionError: assert {'available_m...minor': 10000} == {'available_m...minor': 10000}
E             
E             Omitting 1 identical items, use -vv to show
E             Differing items:
E             {'available_minor': 9000} != {'available_minor': 10000}
E             Use -v to get more diff

..\grader\test_hidden_holds.py:56: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_o8dgbwys\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_holds.py::test_expiry_exactly_seven_days - Asser...
1 failed, 11 passed, 1 warning in 6.04s
Naive grader✗ wrong_expired_holds_count: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
1.71 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_e1d247e1\junit.xml -
3 passed, 1 warning in 0.85s
Diff against the starter
--- starter/holds\main.py
+++ wrong_expired_holds_count/holds\main.py
@@ -3,7 +3,7 @@
 from fastapi.responses import JSONResponse
 from holds.clock import instant
 from holds.errors import error
-from holds.storage import transaction, initialize, account
+from holds.storage import transaction, initialize, account, authorization, active_total, HOLD_SECONDS
 from holds.validation import amount
 
 
@@ -18,7 +18,7 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            return {"posted_minor": row["posted"], "available_minor": row["posted"]}
+            return {"posted_minor": row["posted"], "available_minor": row["posted"] - active_total(db, id, instant(clock))}
 
     @api.post("/accounts/{id}/authorizations")
     async def authorize(id: str, request: Request):
@@ -30,9 +30,34 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            if value > row["posted"] + row["overdraft"]:
+            available = row["posted"] - active_total(db, id, now)
+            if value > available + row["overdraft"]:
                 return error(402, "insufficient_funds")
             identifier = uuid4().hex
             db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", (identifier, id, value, now))
             return JSONResponse({"auth_id": identifier}, status_code=201)
+
+    def close(id, capture=None):
+        with transaction(database_path) as db:
+            auth = authorization(db, id)
+            if auth is None:
+                return error(404, "not_found")
+            if auth["state"] != "active" or instant(clock) >= auth["created"] + HOLD_SECONDS:
+                return error(409, "authorization_closed")
+            if capture is not None:
+                if capture > auth["amount"]:
+                    return error(422, "capture_exceeds_hold")
+                db.execute("UPDATE accounts SET posted=posted-? WHERE id=?", (capture, auth["account"]))
+            state = "captured" if capture is not None else "voided"
+            db.execute("UPDATE authorizations SET state=? WHERE id=?", (state, id))
+            return {"auth_id": id, "status": state}
+
+    @api.post("/authorizations/{id}/capture")
+    async def capture(id: str, request: Request):
+        value = await amount(request)
+        return error(422, "invalid_amount") if value is None else close(id, value)
+
+    @api.post("/authorizations/{id}/void")
+    def void(id: str):
+        return close(id)
     return api
--- starter/holds\storage.py
+++ wrong_expired_holds_count/holds\storage.py
@@ -24,6 +24,15 @@
         seed_accounts(db)
 
 
+HOLD_SECONDS = 7 * 86400
+
+
+def active_total(db, account, now):
+    """Sum of the account's active holds that have not yet expired at `now` (epoch seconds)."""
+    return db.execute("""SELECT COALESCE(SUM(amount),0) FROM authorizations
+        WHERE account=? AND state='active'""", (account,)).fetchone()[0]
+
+
 def account(db, identifier):
     return db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
 

wrong_partial_capture_keeps_remainderwrong

Final grader✓ wrong_partial_capture_keeps_remainder: ok 9/12 tests passed
Failed tests
  • test_hidden_holds::test_partial_capture_releases_remainder declared must_fail
  • test_hidden_holds::test_capture_amount_validation_before_closed_status[captured]
  • test_hidden_holds::test_random_operation_model
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
4.51 s
Pytest output (last 40 lines)
                        elif operation == "capture" and value > held:
                            assert result.status_code == 422
                        else:
                            assert result.status_code == 200
                            holds[id][2] = True
                            if operation == "capture":
                                posted -= value
>                   assert balance(client, account) == {"posted_minor": posted, "available_minor": posted - active()}
E                   AssertionError: assert {'available_m..._minor': 9713} == {'available_m..._minor': 9713}
E                     
E                     Omitting 1 identical items, use -vv to show
E                     Differing items:
E                     {'available_minor': 9016} != {'available_minor': 9360}
E                     Use -v to get more diff
E                   Failing test case: test_random_operation_model(
E                       flex=False,
E                       operations=[('void', 11093, 11),
E                        ('authorize', 1303, 12),
E                        ('void', 402, 0),
E                        ('capture', 2133, 34),
E                        ('authorize', 353, 40),
E                        ('authorize', 631, 48),
E                        ('capture', 392, 27),
E                        ('capture', 287, 41),
E                        ('authorize', 66, 9)],
E                   )

..\grader\test_hidden_holds.py:176: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_tlnx_lkl\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_holds.py::test_partial_capture_releases_remainder
FAILED ..\grader\test_hidden_holds.py::test_capture_amount_validation_before_closed_status[captured]
FAILED ..\grader\test_hidden_holds.py::test_random_operation_model - Assertio...
3 failed, 9 passed, 1 warning in 3.64s
Naive grader~ wrong_partial_capture_keeps_remainder: ok_wrong_reason 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
1.71 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_zjxtelld/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        client = TestClient(create_app(tmp_path / "db", Clock()))
        identifier = client.post("/accounts/acct_standard/authorizations", json={"amount_minor": 3000}).json()["auth_id"]
        assert client.get("/accounts/acct_standard/balance").json() == {"posted_minor": 10000, "available_minor": 7000}
        assert client.post(f"/authorizations/{identifier}/capture", json={"amount_minor": 1200}).status_code == 200
>       assert client.get("/accounts/acct_standard/balance").json() == {"posted_minor": 8800, "available_minor": 8800}
E       AssertionError: assert {'available_m..._minor': 8800} == {'available_m..._minor': 8800}
E         
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'available_minor': 7000} != {'available_minor': 8800}
E         Use -v to get more diff

..\grader\test_hidden.py:25: AssertionError
============================== warnings summary ===============================
..\..\..\..\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1
  C:\Users\User\AppData\Local\Programs\Python\Python314\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
    from starlette.testclient import TestClient as TestClient  # noqa

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_zjxtelld\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - AssertionError:...
1 failed, 2 passed, 1 warning in 0.79s
Diff against the starter
--- starter/holds\main.py
+++ wrong_partial_capture_keeps_remainder/holds\main.py
@@ -3,7 +3,7 @@
 from fastapi.responses import JSONResponse
 from holds.clock import instant
 from holds.errors import error
-from holds.storage import transaction, initialize, account
+from holds.storage import transaction, initialize, account, authorization, active_total, HOLD_SECONDS
 from holds.validation import amount
 
 
@@ -18,7 +18,7 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            return {"posted_minor": row["posted"], "available_minor": row["posted"]}
+            return {"posted_minor": row["posted"], "available_minor": row["posted"] - active_total(db, id, instant(clock))}
 
     @api.post("/accounts/{id}/authorizations")
     async def authorize(id: str, request: Request):
@@ -30,9 +30,37 @@
             row = account(db, id)
             if row is None:
                 return error(404, "not_found")
-            if value > row["posted"] + row["overdraft"]:
+            available = row["posted"] - active_total(db, id, now)
+            if value > available + row["overdraft"]:
                 return error(402, "insufficient_funds")
             identifier = uuid4().hex
             db.execute("INSERT INTO authorizations VALUES (?,?,?,?,'active')", (identifier, id, value, now))
             return JSONResponse({"auth_id": identifier}, status_code=201)
+
+    def close(id, capture=None):
+        with transaction(database_path) as db:
+            auth = authorization(db, id)
+            if auth is None:
+                return error(404, "not_found")
+            if auth["state"] != "active" or instant(clock) >= auth["created"] + HOLD_SECONDS:
+                return error(409, "authorization_closed")
+            if capture is not None:
+                if capture > auth["amount"]:
+                    return error(422, "capture_exceeds_hold")
+                db.execute("UPDATE accounts SET posted=posted-? WHERE id=?", (capture, auth["account"]))
+            state = "captured" if capture is not None else "voided"
+            if capture is not None and capture < auth["amount"]:
+                db.execute("UPDATE authorizations SET amount=amount-? WHERE id=?", (capture, id))
+            else:
+                db.execute("UPDATE authorizations SET state=? WHERE id=?", (state, id))
+            return {"auth_id": id, "status": state}
+
+    @api.post("/authorizations/{id}/capture")
+    async def capture(id: str, request: Request):
+        value = await amount(request)
+        return error(422, "invalid_amount") if value is None else close(id, value)
+
+    @api.post("/authorizations/{id}/void")
+    def void(id: str):
+        return close(id)
     return api
--- starter/holds\storage.py
+++ wrong_partial_capture_keeps_remainder/holds\storage.py
@@ -24,6 +24,15 @@
         seed_accounts(db)
 
 
+HOLD_SECONDS = 7 * 86400
+
+
+def active_total(db, account, now):
+    """Sum of the account's active holds that have not yet expired at `now` (epoch seconds)."""
+    return db.execute("""SELECT COALESCE(SUM(amount),0) FROM authorizations
+        WHERE account=? AND state='active' AND created>?""", (account, now - HOLD_SECONDS)).fetchone()[0]
+
+
 def account(db, identifier):
     return db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
 
07_statement_periodsSelect transactions by the account calendar month

Select a month's transactions using midnight in the account's own time zone, converted to UTC.

What the hardened grader checks

  • Local midnight boundaries for Toronto and Kolkata
  • Months that contain a daylight-saving change, including Auckland
  • A transaction at 23:59:59.5 local time still belongs to the month
  • Thirteen consecutive months select every transaction exactly once
  • Naive timestamps, unknown zones and invalid months raise ValueError
Ticket (PROBLEM.md)
# Ticket: monthly statements follow the account timezone

`statements.period.period_bounds(account_tz:str,year:int,month:int)->tuple[datetime,datetime]`
returns aware UTC instants for a half-open interval. `select(txns:list[Txn],account_tz,year,month)`
returns transaction ids sorted by instant, then lexicographic id. Frozen `Txn(id:str,at:datetime)`
is public. IDs are unique and timestamps may have arbitrary aware offsets and microseconds.

The period is the calendar month in the account's IANA timezone: local 00:00 on day 1 inclusive
through local 00:00 on day 1 of the next month exclusive, converted to UTC using zoneinfo.
An unknown time zone name, a non-int (or bool) year or month, a month outside 1-12 or a year outside
1900-2098 raises ValueError from both functions. Daylight saving rules apply independently to both
endpoints. Each input timestamp must be aware with a non-None utcoffset,
otherwise select raises ValueError, even when that transaction would be outside the month.
Do not mutate inputs. Empty input returns []. Money is not part of this interface.

Worked example: for an `America/Toronto` account, January 2026 is the interval
`[2026-01-01T05:00:00Z, 2026-02-01T05:00:00Z)` (Toronto is UTC-5 in January). A transaction at
`2026-02-01T04:30:00Z` is 23:30 on 31 January in Toronto, so it belongs to January; one at
`2026-02-01T05:30:00Z` is 00:30 on 1 February and belongs to February. So selecting January 2026 for
`Txn("a", 2026-02-01T04:30Z)` and `Txn("b", 2026-02-01T05:30Z)` returns `["a"]`.

Run from starter: `python -m pytest tests -q`. The records are entirely synthetic.
Reviewer notes (NOTES.md)
# Private draft - period reviewer notes

v2 starter (see analysis/findings.md F-106): the starter derives every account's month from UTC
(`account_tz` is ignored), sorts equal instants by input order only and has no time-zone, month or
year validation. Time-zone months, the id tie-break and the validation are the ticket. The wrong
variants are plausible completions of that starter: `wrong_utc_month` adds the validation and
tie-break but keeps UTC month boundaries; `wrong_offset_sign` adds each endpoint's offset when
converting local wall time to UTC; `wrong_fixed_offset` uses the zone's standard-time offset all year.
The alternative tests local calendar membership for each timestamp, so a grader requiring the
selector to call period_bounds would be invalid.

## v3 wrong-variant replacement

Per human sign-off, `wrong_offset_sign` replaces the regressive `wrong_inclusive_2359`.
It extends the starter's month bounds with independent endpoint offsets, validation and id ordering,
but translates the offsets in the wrong direction. UTC bounds, exclusive microseconds and adjacent
month partitioning are preserved; existing non-UTC boundary and DST tests catch it, and the starter
also fails them. See analysis/findings.md F-301 for predictions and measured evidence;
no regression rationale is needed.

## Blind review
Author self-review: ordering of equal instants, microsecond precision, invalid timestamps outside
the window and year limits clarified in the ticket. Predictions: Toronto March 2026 starts05:00Z
and ends04:00Z; at end exactly -> excluded; last microsecond before end -> included.
No unresolved graded ambiguity. Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 6/19 tests passed
Failed tests
  • test_hidden_periods::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
  • test_hidden_periods::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
  • test_hidden_periods::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
  • test_hidden_periods::test_naive_and_instant_order
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args0]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args1]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args4]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args5]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args6]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args7]
  • test_hidden_periods::test_invalid_zone_month_or_year_raises[args8]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.35 s
Pytest output (last 40 lines)
args = ('UTC', 2026.0, 1)

    @pytest.mark.parametrize("args", [("Not/AZone", 2026, 1), ("", 2026, 1), ("UTC", 2026, 0), ("UTC", 2026, 13),
                                      ("UTC", 1899, 1), ("UTC", 2099, 1), ("UTC", True, 1), ("UTC", 2026, True),
                                      ("UTC", 2026.0, 1)])
    def test_invalid_zone_month_or_year_raises(args):
        with pytest.raises(ValueError):
>           period_bounds(*args)

..\grader\test_hidden_periods.py:75: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
statements\period.py:14: in period_bounds
    start = month_start(timezone.utc, year, month)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

zone = datetime.timezone.utc, year = 2026.0, month = 1

    def month_start(zone, year, month):
>       return datetime(year, month, 1, tzinfo=zone)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: 'float' object cannot be interpreted as an integer

statements\period.py:6: TypeError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_cmdkp41u\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_naive_and_instant_order - Asser...
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args0]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args1]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args4]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args5]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args6]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args7]
FAILED ..\grader\test_hidden_periods.py::test_invalid_zone_month_or_year_raises[args8]
13 failed, 6 passed in 0.63s
Naive grader✓ starter: ok 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example_toronto_january
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.79 s
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________ test_ticket_worked_example_toronto_january __________________

    def test_ticket_worked_example_toronto_january():
        start, end = period_bounds("America/Toronto", 2026, 1)
>       assert start == datetime(2026, 1, 1, 5, tzinfo=timezone.utc)
E       assert datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) == datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc)
E        +  where datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc) = datetime(2026, 1, 1, 5, tzinfo=datetime.timezone.utc)
E        +    where datetime.timezone.utc = timezone.utc

..\grader\test_hidden.py:12: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_2qfhbcdt\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example_toronto_january
1 failed, 2 passed in 0.10s

referencereference

Final grader✓ reference: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.19 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_re9kpebd\junit.xml -
19 passed in 0.47s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.71 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_gvnhounk\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/statements\period.py
+++ reference/statements\period.py
@@ -1,5 +1,22 @@
 from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 from statements.models import Txn  # noqa: F401
+
+MIN_YEAR, MAX_YEAR = 1900, 2098
+
+
+def load_zone(account_tz):
+    try:
+        return ZoneInfo(account_tz)
+    except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as exc:
+        raise ValueError(f"unknown time zone: {account_tz!r}") from exc
+
+
+def check_month(year, month):
+    if type(year) is not int or type(month) is not int:
+        raise ValueError("integer year and month required")
+    if not MIN_YEAR <= year <= MAX_YEAR or not 1 <= month <= 12:
+        raise ValueError("month out of range")
 
 
 def month_start(zone, year, month):
@@ -11,9 +28,11 @@
 
 
 def period_bounds(account_tz, year, month):
-    start = month_start(timezone.utc, year, month)
-    end = month_start(timezone.utc, year + (month == 12), month % 12 + 1)
-    return start, end
+    check_month(year, month)
+    zone = load_zone(account_tz)
+    start = month_start(zone, year, month)
+    end = month_start(zone, year + (month == 12), month % 12 + 1)
+    return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
 
 
 def select(txns, account_tz, year, month):
@@ -22,4 +41,4 @@
         if txn.at.tzinfo is None or txn.at.utcoffset() is None:
             raise ValueError("aware timestamps required")
     chosen = [t for t in txns if start <= instant(t) < end]
-    return [t.id for t in sorted(chosen, key=instant)]
+    return [t.id for t in sorted(chosen, key=lambda t: (instant(t), t.id))]

alt_validalt_valid

Final grader✓ alt_valid: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.26 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_5w8zbb5u\junit.xml -
19 passed in 0.50s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.76 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_reqviya0\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/statements\period.py
+++ alt_valid/statements\period.py
@@ -1,25 +1,41 @@
-from datetime import datetime, timezone
+import calendar
+from datetime import datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
 from statements.models import Txn  # noqa: F401
 
 
-def month_start(zone, year, month):
-    return datetime(year, month, 1, tzinfo=zone)
+def zone_for(name):
+    try:
+        return ZoneInfo(name)
+    except Exception as exc:
+        raise ValueError(f"cannot use time zone {name!r}") from exc
 
 
-def instant(txn):
-    return txn.at.astimezone(timezone.utc)
+def validated(year, month):
+    for value in (year, month):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError("year and month must be integers")
+    if not (1900 <= year <= 2098 and 1 <= month <= 12):
+        raise ValueError("year or month outside the supported range")
 
 
 def period_bounds(account_tz, year, month):
-    start = month_start(timezone.utc, year, month)
-    end = month_start(timezone.utc, year + (month == 12), month % 12 + 1)
-    return start, end
+    validated(year, month)
+    local_first = datetime.fromisoformat(f"{year:04d}-{month:02d}-01T00:00:00").replace(tzinfo=zone_for(account_tz))
+    local_next = local_first + timedelta(days=calendar.monthrange(year, month)[1])
+    return tuple(point.astimezone(timezone.utc) for point in (local_first, local_next))
 
 
 def select(txns, account_tz, year, month):
-    start, end = period_bounds(account_tz, year, month)
-    for txn in txns:
-        if txn.at.tzinfo is None or txn.at.utcoffset() is None:
-            raise ValueError("aware timestamps required")
-    chosen = [t for t in txns if start <= instant(t) < end]
-    return [t.id for t in sorted(chosen, key=instant)]
+    validated(year, month)
+    timezone_for_account = zone_for(account_tz)
+    keyed = []
+    for item in txns:
+        stamp = item.at
+        if stamp.utcoffset() is None:
+            raise ValueError("transaction timestamp has no offset")
+        local = stamp.astimezone(timezone_for_account)
+        if (local.year, local.month) == (year, month):
+            keyed.append((stamp.astimezone(timezone.utc), item.id))
+    keyed.sort()
+    return [identifier for _, identifier in keyed]

wrong_fixed_offsetwrong

Final grader✓ wrong_fixed_offset: ok 16/19 tests passed
Failed tests
  • test_hidden_periods::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
  • test_hidden_periods::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.35 s
Hardening
caught by hardening
Pytest output (last 40 lines)
    @pytest.mark.parametrize("zone,month,start,end", [
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 11, 1, 5, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 11, 1, 4, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
_ test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00] _

zone = 'Pacific/Auckland', month = 4, start = '2026-03-31T11:00:00+00:00'
end = '2026-04-30T12:00:00+00:00'

    @pytest.mark.parametrize("zone,month,start,end", [
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 3, 31, 12, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 3, 31, 11, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_txppj_j7\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
3 failed, 16 passed in 0.62s
Naive grader✗ wrong_fixed_offset: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.75 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_d1ppp7ar\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/statements\period.py
+++ wrong_fixed_offset/statements\period.py
@@ -1,5 +1,22 @@
 from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 from statements.models import Txn  # noqa: F401
+
+MIN_YEAR, MAX_YEAR = 1900, 2098
+
+
+def load_zone(account_tz):
+    try:
+        return ZoneInfo(account_tz)
+    except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as exc:
+        raise ValueError(f"unknown time zone: {account_tz!r}") from exc
+
+
+def check_month(year, month):
+    if type(year) is not int or type(month) is not int:
+        raise ValueError("integer year and month required")
+    if not MIN_YEAR <= year <= MAX_YEAR or not 1 <= month <= 12:
+        raise ValueError("month out of range")
 
 
 def month_start(zone, year, month):
@@ -11,9 +28,13 @@
 
 
 def period_bounds(account_tz, year, month):
-    start = month_start(timezone.utc, year, month)
-    end = month_start(timezone.utc, year + (month == 12), month % 12 + 1)
-    return start, end
+    check_month(year, month)
+    iana = load_zone(account_tz)
+    january = datetime(year, 1, 1, tzinfo=iana)
+    zone = timezone(january.utcoffset() - january.dst())
+    start = month_start(zone, year, month)
+    end = month_start(zone, year + (month == 12), month % 12 + 1)
+    return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
 
 
 def select(txns, account_tz, year, month):
@@ -22,4 +43,4 @@
         if txn.at.tzinfo is None or txn.at.utcoffset() is None:
             raise ValueError("aware timestamps required")
     chosen = [t for t in txns if start <= instant(t) < end]
-    return [t.id for t in sorted(chosen, key=instant)]
+    return [t.id for t in sorted(chosen, key=lambda t: (instant(t), t.id))]

wrong_offset_signwrong

Final grader✓ wrong_offset_sign: ok 14/19 tests passed
Failed tests
  • test_hidden_periods::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
  • test_hidden_periods::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
  • test_hidden_periods::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.40 s
Pytest output (last 40 lines)
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 10, 31, 20, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 11, 1, 4, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
_ test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00] _

zone = 'Pacific/Auckland', month = 4, start = '2026-03-31T11:00:00+00:00'
end = '2026-04-30T12:00:00+00:00'

    @pytest.mark.parametrize("zone,month,start,end", [
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 4, 1, 13, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 3, 31, 11, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_m9f6qub6\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
5 failed, 14 passed in 0.59s
Naive grader~ wrong_offset_sign: ok_wrong_reason 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example_toronto_january
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
0.89 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________ test_ticket_worked_example_toronto_january __________________

    def test_ticket_worked_example_toronto_january():
        start, end = period_bounds("America/Toronto", 2026, 1)
>       assert start == datetime(2026, 1, 1, 5, tzinfo=timezone.utc)
E       assert datetime.datetime(2025, 12, 31, 19, 0, tzinfo=datetime.timezone.utc) == datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc)
E        +  where datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc) = datetime(2026, 1, 1, 5, tzinfo=datetime.timezone.utc)
E        +    where datetime.timezone.utc = timezone.utc

..\grader\test_hidden.py:12: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_7poipnuc\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example_toronto_january
1 failed, 2 passed in 0.13s
Diff against the starter
--- starter/statements\period.py
+++ wrong_offset_sign/statements\period.py
@@ -1,4 +1,6 @@
 from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
 from statements.models import Txn  # noqa: F401
 
 
@@ -11,9 +13,19 @@
 
 
 def period_bounds(account_tz, year, month):
-    start = month_start(timezone.utc, year, month)
-    end = month_start(timezone.utc, year + (month == 12), month % 12 + 1)
-    return start, end
+    if type(year) is not int or type(month) is not int:
+        raise ValueError("integer year and month required")
+    if not 1900 <= year <= 2098 or not 1 <= month <= 12:
+        raise ValueError("month out of range")
+    try:
+        zone = ZoneInfo(account_tz)
+    except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as exc:
+        raise ValueError(f"unknown time zone: {account_tz!r}") from exc
+    start = month_start(zone, year, month)
+    end = month_start(zone, year + (month == 12), month % 12 + 1)
+    # Translate the local wall-clock boundaries by their respective UTC offsets.
+    return (start.replace(tzinfo=timezone.utc) + start.utcoffset(),
+            end.replace(tzinfo=timezone.utc) + end.utcoffset())
 
 
 def select(txns, account_tz, year, month):
@@ -22,4 +34,4 @@
         if txn.at.tzinfo is None or txn.at.utcoffset() is None:
             raise ValueError("aware timestamps required")
     chosen = [t for t in txns if start <= instant(t) < end]
-    return [t.id for t in sorted(chosen, key=instant)]
+    return [t.id for t in sorted(chosen, key=lambda t: (instant(t), t.id))]

wrong_utc_monthwrong

Final grader✓ wrong_utc_month: ok 14/19 tests passed
Failed tests
  • test_hidden_periods::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
  • test_hidden_periods::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
  • test_hidden_periods::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
  • test_hidden_periods::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.33 s
Pytest output (last 40 lines)
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 11, 1, 0, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 11, 1, 4, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
_ test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00] _

zone = 'Pacific/Auckland', month = 4, start = '2026-03-31T11:00:00+00:00'
end = '2026-04-30T12:00:00+00:00'

    @pytest.mark.parametrize("zone,month,start,end", [
        ("America/Toronto", 3, "2026-03-01T05:00:00+00:00", "2026-04-01T04:00:00+00:00"),
        ("America/Toronto", 11, "2026-11-01T04:00:00+00:00", "2026-12-01T05:00:00+00:00"),
        ("Pacific/Auckland", 4, "2026-03-31T11:00:00+00:00", "2026-04-30T12:00:00+00:00"),
    ])
    def test_dst_months(zone, month, start, end):
        left, right = datetime.fromisoformat(start), datetime.fromisoformat(end)
>       assert period_bounds(zone, 2026, month) == (left, right)
E       assert (datetime.dat...timezone.utc)) == (datetime.dat...timezone.utc))
E         
E         At index 0 diff: datetime.datetime(2026, 4, 1, 0, 0, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 3, 31, 11, 0, tzinfo=datetime.timezone.utc)
E         Use -v to get more diff

..\grader\test_hidden_periods.py:32: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_b26hrslr\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[America/Toronto-2026-1-2026-01-01T05:00:00+00:00-2026-02-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_local_midnight_boundaries[Asia/Kolkata-2026-1-2025-12-31T18:30:00+00:00-2026-01-31T18:30:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-3-2026-03-01T05:00:00+00:00-2026-04-01T04:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[America/Toronto-11-2026-11-01T04:00:00+00:00-2026-12-01T05:00:00+00:00]
FAILED ..\grader\test_hidden_periods.py::test_dst_months[Pacific/Auckland-4-2026-03-31T11:00:00+00:00-2026-04-30T12:00:00+00:00]
5 failed, 14 passed in 0.59s
Naive grader~ wrong_utc_month: ok_wrong_reason 2/3 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example_toronto_january
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
0.87 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
.F.                                                                      [100%]
================================== FAILURES ===================================
_________________ test_ticket_worked_example_toronto_january __________________

    def test_ticket_worked_example_toronto_january():
        start, end = period_bounds("America/Toronto", 2026, 1)
>       assert start == datetime(2026, 1, 1, 5, tzinfo=timezone.utc)
E       assert datetime.datetime(2026, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) == datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc)
E        +  where datetime.datetime(2026, 1, 1, 5, 0, tzinfo=datetime.timezone.utc) = datetime(2026, 1, 1, 5, tzinfo=datetime.timezone.utc)
E        +    where datetime.timezone.utc = timezone.utc

..\grader\test_hidden.py:12: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_xx6rijdg\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example_toronto_january
1 failed, 2 passed in 0.11s
Diff against the starter
--- starter/statements\period.py
+++ wrong_utc_month/statements\period.py
@@ -1,5 +1,22 @@
 from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 from statements.models import Txn  # noqa: F401
+
+MIN_YEAR, MAX_YEAR = 1900, 2098
+
+
+def load_zone(account_tz):
+    try:
+        return ZoneInfo(account_tz)
+    except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as exc:
+        raise ValueError(f"unknown time zone: {account_tz!r}") from exc
+
+
+def check_month(year, month):
+    if type(year) is not int or type(month) is not int:
+        raise ValueError("integer year and month required")
+    if not MIN_YEAR <= year <= MAX_YEAR or not 1 <= month <= 12:
+        raise ValueError("month out of range")
 
 
 def month_start(zone, year, month):
@@ -11,9 +28,11 @@
 
 
 def period_bounds(account_tz, year, month):
+    check_month(year, month)
+    load_zone(account_tz)
     start = month_start(timezone.utc, year, month)
     end = month_start(timezone.utc, year + (month == 12), month % 12 + 1)
-    return start, end
+    return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
 
 
 def select(txns, account_tz, year, month):
@@ -22,4 +41,4 @@
         if txn.at.tzinfo is None or txn.at.utcoffset() is None:
             raise ValueError("aware timestamps required")
     chosen = [t for t in txns if start <= instant(t) < end]
-    return [t.id for t in sorted(chosen, key=instant)]
+    return [t.id for t in sorted(chosen, key=lambda t: (instant(t), t.id))]
08_refund_limitsLimit refunds to the captured amount

Allow partial refunds up to the captured amount, which may be less than what was authorized.

What the hardened grader checks

  • Partial refunds add up and cannot exceed the capture
  • The ceiling is the captured amount, not the authorized amount
  • A rejected refund does not use up the remaining budget
  • Uncaptured and unknown payments are refused
  • Random refund sequences match a simple remaining-budget model
Ticket (PROBLEM.md)
# Ticket: refund only captured funds

`refunds.service.RefundService(db_path)` uses SQLite and exposes `create_payment(authorized_minor)->id`,
`capture(id,amount_minor)`, `refund(id,amount_minor)->refund_id`, `refunded_total(id)->int`.
Exceptions: RefundExceedsCaptured, PaymentNotCaptured, InvalidAmount (all derive from ValueError).

All money is integer minor units in one currency (CAD, 2 dp), never bool. Authorization, capture
and refund amounts must be positive ints, otherwise InvalidAmount. An unknown payment id raises ValueError from capture, refund and refunded_total (after the amount has
been validated).
Each payment may be captured once, at most its authorization; invalid/repeated capture -> InvalidAmount.
Refund before capture -> PaymentNotCaptured. Partial refunds are allowed. The cumulative successful
refund amount may not exceed the captured amount, which may be less than authorized. Exceeding
remaining captured funds -> RefundExceedsCaptured. Rejected attempts must never count toward
refunded_total or reduce the future budget. Validate positive amount before capture status.

Refund ids are distinct positive ints. Capture and successful refunds survive a new service instance
on the same file. Operations are atomic; no rejected operation may alter successful totals.
Worked example: `create_payment(10000)`, `capture(id, 6000)`. `refund(id, 4000)` succeeds and
`refunded_total(id) == 4000`. `refund(id, 2500)` then raises RefundExceedsCaptured, because only 2000 of the
captured 6000 remains, and `refunded_total(id)` is still 4000.

No settlement network or real customer is involved. Preserve the interface and run
`python -m pytest tests -q` from starter.
Reviewer notes (NOTES.md)
# Private draft - refund reviewer notes

v2 starter (see analysis/findings.md F-107): the starter records any refund of a captured payment
with no upper limit, lets a payment be captured repeatedly, runs without an explicit write
transaction and fails with an AttributeError on an unknown payment id. The cumulative cap on captured
funds, capture-once, atomic write transactions and the unknown-id rule are the ticket. The wrong
variants are plausible completions of that starter: `wrong_checks_single_refund` caps each refund at
the captured amount but not the running total; `wrong_uses_authorized` caps the running total at the
authorized amount; `wrong_counts_rejected` caps correctly but adds a rejected attempt to the
refunded counter before raising. The correct counter-based service and the append-only event
alternative share only the public errors and validation.

## Blind review
Author self-review: repeated capture, bool/zero, capture before refund and validation priority
were unspecified in the starter. The ticket now resolves them. Predictions: auth100/capture60/
refund40 then30 -> first accepted, second RefundExceedsCaptured; reject101 then refund100 ->
accepted100; uncaptured refund1 -> PaymentNotCaptured. No unresolved graded ambiguity.
Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 1/8 tests passed
Failed tests
  • test_hidden_refunds::test_cumulative_partials
  • test_hidden_refunds::test_capture_less_than_authorization
  • test_hidden_refunds::test_valid_after_rejection
  • test_hidden_refunds::test_unknown_payment_id_raises_value_error
  • test_hidden_refunds::test_capture_once_and_within_authorization
  • test_hidden_refunds::test_concurrent_refunds_respect_budget
  • test_hidden_refunds::test_refund_sequence_matches_greedy_oracle
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
2.75 s
Pytest output (last 40 lines)
..\grader\test_hidden_refunds.py:118: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

capture = 714, extra = 8317, attempts = [9423, 1453, 200, 14504]

    @settings(max_examples=40)
    @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
           attempts=st.lists(st.integers(1, 100000), max_size=30))
    def test_refund_sequence_matches_greedy_oracle(capture, extra, attempts):
        with workspace() as path:
            s, p = payment(path, capture + extra, capture)
            total, identifiers = 0, set()
            for amount in attempts:
                if amount <= capture - total:
                    identifier = s.refund(p, amount)
                    assert type(identifier) is int and identifier > 0 and identifier not in identifiers
                    identifiers.add(identifier)
                    total += amount
                else:
>                   with pytest.raises(RefundExceedsCaptured):
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E                   Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>
E                   Failing test case: test_refund_sequence_matches_greedy_oracle(
E                       capture=714,
E                       extra=8317,
E                       attempts=[9423, 1453, 200, 14504],
E                   )

..\grader\test_hidden_refunds.py:131: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_1oclmu7v\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_refunds.py::test_cumulative_partials - Failed: D...
FAILED ..\grader\test_hidden_refunds.py::test_capture_less_than_authorization
FAILED ..\grader\test_hidden_refunds.py::test_valid_after_rejection - Failed:...
FAILED ..\grader\test_hidden_refunds.py::test_unknown_payment_id_raises_value_error
FAILED ..\grader\test_hidden_refunds.py::test_capture_once_and_within_authorization
FAILED ..\grader\test_hidden_refunds.py::test_concurrent_refunds_respect_budget
FAILED ..\grader\test_hidden_refunds.py::test_refund_sequence_matches_greedy_oracle
7 failed, 1 passed in 2.06s
Naive grader✓ starter: ok 2/4 tests passed
Failed tests
  • test_hidden::test_over_refund_rejected
  • test_hidden::test_ticket_worked_example
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.29 s
Pytest output (last 40 lines)
.FF.                                                                     [100%]
================================== FAILURES ===================================
__________________________ test_over_refund_rejected __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_cwf3n3g5/pytest-tmp/test_over_refund_rejected0')

    def test_over_refund_rejected(tmp_path):
        s = RefundService(tmp_path / "db")
        p = s.create_payment(100)
        s.capture(p, 100)
>       with pytest.raises(RefundExceedsCaptured):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>

..\grader\test_hidden.py:17: Failed
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_cwf3n3g5/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        s = RefundService(tmp_path / "db")
        p = s.create_payment(10000)
        s.capture(p, 6000)
        assert s.refund(p, 4000) > 0
        assert s.refunded_total(p) == 4000
>       with pytest.raises(RefundExceedsCaptured):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>

..\grader\test_hidden.py:27: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_cwf3n3g5\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_over_refund_rejected - Failed: DID NOT ...
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - Failed: DID NOT...
2 failed, 2 passed in 0.56s

referencereference

Final grader✓ reference: ok 8/8 tests passed
Failed tests

All 8 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
11.70 s
Pytest output (last 40 lines)
........                                                                 [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_1v3skvox\junit.xml -
8 passed in 10.92s
Naive grader✓ reference: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.30 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_bj3rj1um\junit.xml -
4 passed in 0.48s
Diff against the starter
--- starter/refunds\service.py
+++ reference/refunds\service.py
@@ -20,20 +20,22 @@
 
     def capture(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment is None or amount_minor > payment.authorized:
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
+            if payment.captured is not None or amount_minor > payment.authorized:
                 raise InvalidAmount("invalid capture")
             store.set_captured(conn, id, amount_minor)
 
     def refund(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
             if payment.captured is None:
                 raise PaymentNotCaptured(id)
+            if amount_minor > payment.refundable:
+                raise RefundExceedsCaptured(id)
             return store.add_refund(conn, id, amount_minor)
 
     def refunded_total(self, id):
         with self.connect() as conn:
-            return store.get_payment(conn, id).refunded
+            return store.require_payment(conn, id).refunded
--- starter/refunds\store.py
+++ reference/refunds\store.py
@@ -1,4 +1,5 @@
 """SQLite access for payments and refunds. Each call receives the caller's connection."""
+from contextlib import contextmanager
 import sqlite3
 from dataclasses import dataclass
 
@@ -10,9 +11,29 @@
     captured: int | None
     refunded: int
 
+    @property
+    def refundable(self):
+        """Captured funds not yet refunded; zero before capture."""
+        return 0 if self.captured is None else self.captured - self.refunded
+
 
 def connect(path):
     return sqlite3.connect(str(path), timeout=30)
+
+
+@contextmanager
+def immediate(path):
+    """Write transaction that takes the database write lock up front and always closes its connection."""
+    conn = connect(path)
+    try:
+        conn.execute("BEGIN IMMEDIATE")
+        yield conn
+        conn.commit()
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
 
 
 def initialize(conn):
@@ -30,6 +51,13 @@
     return PaymentRow(*row) if row else None
 
 
+def require_payment(conn, payment_id):
+    payment = get_payment(conn, payment_id)
+    if payment is None:
+        raise ValueError(f"unknown payment {payment_id!r}")
+    return payment
+
+
 def set_captured(conn, payment_id, amount_minor):
     conn.execute("UPDATE payments SET captured=? WHERE id=?", (amount_minor, payment_id))
 

alt_validalt_valid

Final grader✓ alt_valid: ok 8/8 tests passed
Failed tests

All 8 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
11.03 s
Pytest output (last 40 lines)
........                                                                 [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_5o9cp5jn\junit.xml -
8 passed in 10.25s
Naive grader✓ alt_valid: ok 4/4 tests passed
Failed tests

All 4 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.04 s
Pytest output (last 40 lines)
....                                                                     [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_nyuc18t1\junit.xml -
4 passed in 0.26s
Diff against the starter
--- starter/refunds\service.py
+++ alt_valid/refunds\service.py
@@ -1,39 +1,80 @@
-from refunds import store
+import json
+import sqlite3
+from contextlib import contextmanager
 from refunds.models import RefundExceedsCaptured, PaymentNotCaptured, InvalidAmount, validate_amount
 
 __all__ = ["RefundService", "RefundExceedsCaptured", "PaymentNotCaptured", "InvalidAmount"]
 
 
+@contextmanager
+def journal(filename):
+    connection = sqlite3.connect(filename)
+    try:
+        with connection:
+            connection.execute("BEGIN IMMEDIATE")
+            yield connection
+    finally:
+        connection.close()
+
+
+def append(connection, tag, owner, payload):
+    inserted = connection.execute("INSERT INTO event_log(tag,owner,payload) VALUES (?,?,?)",
+                                  [tag, owner, json.dumps(payload)])
+    return inserted.lastrowid
+
+
+def known_state(connection, number):
+    state = reconstruct(connection, number)
+    if not state["known"]:
+        raise ValueError(f"unknown payment {number!r}")
+    return state
+
+
+def reconstruct(connection, number):
+    state = {"known": False, "ceiling": 0, "settled": None, "returned": 0}
+    history = connection.execute("SELECT tag,payload FROM event_log WHERE number=? OR owner=? ORDER BY number",
+                                 [number, number])
+    for tag, payload in history:
+        value = json.loads(payload)["minor"]
+        if tag == "approval":
+            state["known"] = True
+            state["ceiling"] = value
+        elif tag == "settlement":
+            state["settled"] = value
+        elif tag == "credit":
+            state["returned"] += value
+    return state
+
+
 class RefundService:
     def __init__(self, db_path):
-        self.path = str(db_path)
-        with self.connect() as conn:
-            store.initialize(conn)
-
-    def connect(self):
-        return store.connect(self.path)
+        self.filename = str(db_path)
+        with journal(self.filename) as connection:
+            connection.execute("CREATE TABLE IF NOT EXISTS event_log(number INTEGER PRIMARY KEY, tag TEXT, owner INTEGER, payload TEXT)")
 
     def create_payment(self, authorized_minor):
         validate_amount(authorized_minor)
-        with self.connect() as conn:
-            return store.insert_payment(conn, authorized_minor)
+        with journal(self.filename) as connection:
+            return append(connection, "approval", None, {"minor": authorized_minor})
 
     def capture(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment is None or amount_minor > payment.authorized:
-                raise InvalidAmount("invalid capture")
-            store.set_captured(conn, id, amount_minor)
+        with journal(self.filename) as connection:
+            state = known_state(connection, id)
+            if state["settled"] is not None or amount_minor > state["ceiling"]:
+                raise InvalidAmount("capture not permitted")
+            append(connection, "settlement", id, {"minor": amount_minor})
 
     def refund(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment.captured is None:
+        with journal(self.filename) as connection:
+            state = known_state(connection, id)
+            if state["settled"] is None:
                 raise PaymentNotCaptured(id)
-            return store.add_refund(conn, id, amount_minor)
+            if state["returned"] + amount_minor > state["settled"]:
+                raise RefundExceedsCaptured(id)
+            return append(connection, "credit", id, {"minor": amount_minor})
 
     def refunded_total(self, id):
-        with self.connect() as conn:
-            return store.get_payment(conn, id).refunded
+        with journal(self.filename) as connection:
+            return known_state(connection, id)["returned"]

wrong_checks_single_refundwrong

Final grader✓ wrong_checks_single_refund: ok 4/8 tests passed
Failed tests
  • test_hidden_refunds::test_cumulative_partials declared must_fail
  • test_hidden_refunds::test_capture_once_and_within_authorization
  • test_hidden_refunds::test_concurrent_refunds_respect_budget
  • test_hidden_refunds::test_refund_sequence_matches_greedy_oracle
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
3.25 s
Pytest output (last 40 lines)
>   @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
                   ^^^
           attempts=st.lists(st.integers(1, 100000), max_size=30))

..\grader\test_hidden_refunds.py:118: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

capture = 627, extra = 0, attempts = [11160, 271, 461, 2299, 65535, 1384, ...]

    @settings(max_examples=40)
    @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
           attempts=st.lists(st.integers(1, 100000), max_size=30))
    def test_refund_sequence_matches_greedy_oracle(capture, extra, attempts):
        with workspace() as path:
            s, p = payment(path, capture + extra, capture)
            total, identifiers = 0, set()
            for amount in attempts:
                if amount <= capture - total:
                    identifier = s.refund(p, amount)
                    assert type(identifier) is int and identifier > 0 and identifier not in identifiers
                    identifiers.add(identifier)
                    total += amount
                else:
>                   with pytest.raises(RefundExceedsCaptured):
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E                   Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>
E                   Failing test case: test_refund_sequence_matches_greedy_oracle(
E                       capture=627,
E                       extra=0,
E                       attempts=[11160, 271, 461, 2299, 65535, 1384, 4586, 279, 14, 22808, 434],
E                   )

..\grader\test_hidden_refunds.py:131: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_17synlj0\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_refunds.py::test_cumulative_partials - Failed: D...
FAILED ..\grader\test_hidden_refunds.py::test_capture_once_and_within_authorization
FAILED ..\grader\test_hidden_refunds.py::test_concurrent_refunds_respect_budget
FAILED ..\grader\test_hidden_refunds.py::test_refund_sequence_matches_greedy_oracle
4 failed, 4 passed in 2.45s
Naive grader~ wrong_checks_single_refund: ok_wrong_reason 3/4 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
1.42 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
..F.                                                                     [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_ef7ocd11/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        s = RefundService(tmp_path / "db")
        p = s.create_payment(10000)
        s.capture(p, 6000)
        assert s.refund(p, 4000) > 0
        assert s.refunded_total(p) == 4000
>       with pytest.raises(RefundExceedsCaptured):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>

..\grader\test_hidden.py:27: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_ef7ocd11\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - Failed: DID NOT...
1 failed, 3 passed in 0.52s
Diff against the starter
--- starter/refunds\service.py
+++ wrong_checks_single_refund/refunds\service.py
@@ -20,20 +20,22 @@
 
     def capture(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment is None or amount_minor > payment.authorized:
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
+            if payment.captured is not None or amount_minor > payment.authorized:
                 raise InvalidAmount("invalid capture")
             store.set_captured(conn, id, amount_minor)
 
     def refund(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
             if payment.captured is None:
                 raise PaymentNotCaptured(id)
+            if amount_minor > payment.captured:
+                raise RefundExceedsCaptured(id)
             return store.add_refund(conn, id, amount_minor)
 
     def refunded_total(self, id):
         with self.connect() as conn:
-            return store.get_payment(conn, id).refunded
+            return store.require_payment(conn, id).refunded
--- starter/refunds\store.py
+++ wrong_checks_single_refund/refunds\store.py
@@ -1,4 +1,5 @@
 """SQLite access for payments and refunds. Each call receives the caller's connection."""
+from contextlib import contextmanager
 import sqlite3
 from dataclasses import dataclass
 
@@ -10,9 +11,29 @@
     captured: int | None
     refunded: int
 
+    @property
+    def refundable(self):
+        """Captured funds not yet refunded; zero before capture."""
+        return 0 if self.captured is None else self.captured - self.refunded
+
 
 def connect(path):
     return sqlite3.connect(str(path), timeout=30)
+
+
+@contextmanager
+def immediate(path):
+    """Write transaction that takes the database write lock up front and always closes its connection."""
+    conn = connect(path)
+    try:
+        conn.execute("BEGIN IMMEDIATE")
+        yield conn
+        conn.commit()
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
 
 
 def initialize(conn):
@@ -30,6 +51,13 @@
     return PaymentRow(*row) if row else None
 
 
+def require_payment(conn, payment_id):
+    payment = get_payment(conn, payment_id)
+    if payment is None:
+        raise ValueError(f"unknown payment {payment_id!r}")
+    return payment
+
+
 def set_captured(conn, payment_id, amount_minor):
     conn.execute("UPDATE payments SET captured=? WHERE id=?", (amount_minor, payment_id))
 

wrong_counts_rejectedwrong

Final grader✓ wrong_counts_rejected: ok 3/8 tests passed
Failed tests
  • test_hidden_refunds::test_cumulative_partials
  • test_hidden_refunds::test_capture_less_than_authorization
  • test_hidden_refunds::test_valid_after_rejection declared must_fail
  • test_hidden_refunds::test_concurrent_refunds_respect_budget
  • test_hidden_refunds::test_refund_sequence_matches_greedy_oracle
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
2.71 s
Pytest output (last 40 lines)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

capture = 714, extra = 8317, attempts = [9423, 1453, 200, 14504]

    @settings(max_examples=40)
    @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
           attempts=st.lists(st.integers(1, 100000), max_size=30))
    def test_refund_sequence_matches_greedy_oracle(capture, extra, attempts):
        with workspace() as path:
            s, p = payment(path, capture + extra, capture)
            total, identifiers = 0, set()
            for amount in attempts:
                if amount <= capture - total:
                    identifier = s.refund(p, amount)
                    assert type(identifier) is int and identifier > 0 and identifier not in identifiers
                    identifiers.add(identifier)
                    total += amount
                else:
                    with pytest.raises(RefundExceedsCaptured):
                        s.refund(p, amount)
                s = RefundService(path)
>               assert s.refunded_total(p) == total <= capture
E               assert 9423 == 0
E                +  where 9423 = refunded_total(1)
E                +    where refunded_total = <refunds.service.RefundService object at 0x0000026EA9F4E6D0>.refunded_total
E               Failing test case: test_refund_sequence_matches_greedy_oracle(
E                   capture=714,
E                   extra=8317,
E                   attempts=[9423, 1453, 200, 14504],
E               )

..\grader\test_hidden_refunds.py:134: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_i4l_i5yv\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_refunds.py::test_cumulative_partials - assert 12...
FAILED ..\grader\test_hidden_refunds.py::test_capture_less_than_authorization
FAILED ..\grader\test_hidden_refunds.py::test_valid_after_rejection - assert ...
FAILED ..\grader\test_hidden_refunds.py::test_concurrent_refunds_respect_budget
FAILED ..\grader\test_hidden_refunds.py::test_refund_sequence_matches_greedy_oracle
5 failed, 3 passed in 1.82s
Naive grader~ wrong_counts_rejected: ok_wrong_reason 3/4 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
1.93 s
Prediction
false_pass (missed)
Pytest output (last 40 lines)
..F.                                                                     [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_symkqrzo/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        s = RefundService(tmp_path / "db")
        p = s.create_payment(10000)
        s.capture(p, 6000)
        assert s.refund(p, 4000) > 0
        assert s.refunded_total(p) == 4000
        with pytest.raises(RefundExceedsCaptured):
            s.refund(p, 2500)
>       assert s.refunded_total(p) == 4000
E       assert 6500 == 4000
E        +  where 6500 = refunded_total(1)
E        +    where refunded_total = <refunds.service.RefundService object at 0x00000182BA376210>.refunded_total

..\grader\test_hidden.py:29: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_symkqrzo\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - assert 6500 == ...
1 failed, 3 passed in 1.10s
Diff against the starter
--- starter/refunds\service.py
+++ wrong_counts_rejected/refunds\service.py
@@ -20,20 +20,24 @@
 
     def capture(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment is None or amount_minor > payment.authorized:
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
+            if payment.captured is not None or amount_minor > payment.authorized:
                 raise InvalidAmount("invalid capture")
             store.set_captured(conn, id, amount_minor)
 
     def refund(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
             if payment.captured is None:
                 raise PaymentNotCaptured(id)
+            if amount_minor > payment.refundable:
+                conn.execute("UPDATE payments SET refunded=refunded+? WHERE id=?", (amount_minor, id))
+                conn.commit()
+                raise RefundExceedsCaptured(id)
             return store.add_refund(conn, id, amount_minor)
 
     def refunded_total(self, id):
         with self.connect() as conn:
-            return store.get_payment(conn, id).refunded
+            return store.require_payment(conn, id).refunded
--- starter/refunds\store.py
+++ wrong_counts_rejected/refunds\store.py
@@ -1,4 +1,5 @@
 """SQLite access for payments and refunds. Each call receives the caller's connection."""
+from contextlib import contextmanager
 import sqlite3
 from dataclasses import dataclass
 
@@ -10,9 +11,29 @@
     captured: int | None
     refunded: int
 
+    @property
+    def refundable(self):
+        """Captured funds not yet refunded; zero before capture."""
+        return 0 if self.captured is None else self.captured - self.refunded
+
 
 def connect(path):
     return sqlite3.connect(str(path), timeout=30)
+
+
+@contextmanager
+def immediate(path):
+    """Write transaction that takes the database write lock up front and always closes its connection."""
+    conn = connect(path)
+    try:
+        conn.execute("BEGIN IMMEDIATE")
+        yield conn
+        conn.commit()
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
 
 
 def initialize(conn):
@@ -30,6 +51,13 @@
     return PaymentRow(*row) if row else None
 
 
+def require_payment(conn, payment_id):
+    payment = get_payment(conn, payment_id)
+    if payment is None:
+        raise ValueError(f"unknown payment {payment_id!r}")
+    return payment
+
+
 def set_captured(conn, payment_id, amount_minor):
     conn.execute("UPDATE payments SET captured=? WHERE id=?", (amount_minor, payment_id))
 

wrong_uses_authorizedwrong

Final grader✓ wrong_uses_authorized: ok 5/8 tests passed
Failed tests
  • test_hidden_refunds::test_capture_less_than_authorization declared must_fail
  • test_hidden_refunds::test_capture_once_and_within_authorization
  • test_hidden_refunds::test_refund_sequence_matches_greedy_oracle
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
5.24 s
Pytest output (last 40 lines)
    @settings(max_examples=40)
>   @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
                   ^^^
           attempts=st.lists(st.integers(1, 100000), max_size=30))

..\grader\test_hidden_refunds.py:118: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

capture = 714, extra = 8317, attempts = [9423, 1453, 200, 14504]

    @settings(max_examples=40)
    @given(capture=st.integers(1, 100000), extra=st.integers(0, 100000),
           attempts=st.lists(st.integers(1, 100000), max_size=30))
    def test_refund_sequence_matches_greedy_oracle(capture, extra, attempts):
        with workspace() as path:
            s, p = payment(path, capture + extra, capture)
            total, identifiers = 0, set()
            for amount in attempts:
                if amount <= capture - total:
                    identifier = s.refund(p, amount)
                    assert type(identifier) is int and identifier > 0 and identifier not in identifiers
                    identifiers.add(identifier)
                    total += amount
                else:
>                   with pytest.raises(RefundExceedsCaptured):
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E                   Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>
E                   Failing test case: test_refund_sequence_matches_greedy_oracle(
E                       capture=714,
E                       extra=8317,
E                       attempts=[9423, 1453, 200, 14504],
E                   )

..\grader\test_hidden_refunds.py:131: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_ipkda2on\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_refunds.py::test_capture_less_than_authorization
FAILED ..\grader\test_hidden_refunds.py::test_capture_once_and_within_authorization
FAILED ..\grader\test_hidden_refunds.py::test_refund_sequence_matches_greedy_oracle
3 failed, 5 passed in 4.40s
Naive grader~ wrong_uses_authorized: ok_wrong_reason 3/4 tests passed
Failed tests
  • test_hidden::test_ticket_worked_example
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
1.40 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
..F.                                                                     [100%]
================================== FAILURES ===================================
_________________________ test_ticket_worked_example __________________________

tmp_path = WindowsPath('C:/Users/User/AppData/Local/Temp/bcp/run_f322lvzm/pytest-tmp/test_ticket_worked_example0')

    def test_ticket_worked_example(tmp_path):
        s = RefundService(tmp_path / "db")
        p = s.create_payment(10000)
        s.capture(p, 6000)
        assert s.refund(p, 4000) > 0
        assert s.refunded_total(p) == 4000
>       with pytest.raises(RefundExceedsCaptured):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'refunds.models.RefundExceedsCaptured'>

..\grader\test_hidden.py:27: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_f322lvzm\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_ticket_worked_example - Failed: DID NOT...
1 failed, 3 passed in 0.47s
Diff against the starter
--- starter/refunds\service.py
+++ wrong_uses_authorized/refunds\service.py
@@ -20,20 +20,22 @@
 
     def capture(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
-            if payment is None or amount_minor > payment.authorized:
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
+            if payment.captured is not None or amount_minor > payment.authorized:
                 raise InvalidAmount("invalid capture")
             store.set_captured(conn, id, amount_minor)
 
     def refund(self, id, amount_minor):
         validate_amount(amount_minor)
-        with self.connect() as conn:
-            payment = store.get_payment(conn, id)
+        with store.immediate(self.path) as conn:
+            payment = store.require_payment(conn, id)
             if payment.captured is None:
                 raise PaymentNotCaptured(id)
+            if amount_minor > payment.authorized - payment.refunded:
+                raise RefundExceedsCaptured(id)
             return store.add_refund(conn, id, amount_minor)
 
     def refunded_total(self, id):
         with self.connect() as conn:
-            return store.get_payment(conn, id).refunded
+            return store.require_payment(conn, id).refunded
--- starter/refunds\store.py
+++ wrong_uses_authorized/refunds\store.py
@@ -1,4 +1,5 @@
 """SQLite access for payments and refunds. Each call receives the caller's connection."""
+from contextlib import contextmanager
 import sqlite3
 from dataclasses import dataclass
 
@@ -10,9 +11,29 @@
     captured: int | None
     refunded: int
 
+    @property
+    def refundable(self):
+        """Captured funds not yet refunded; zero before capture."""
+        return 0 if self.captured is None else self.captured - self.refunded
+
 
 def connect(path):
     return sqlite3.connect(str(path), timeout=30)
+
+
+@contextmanager
+def immediate(path):
+    """Write transaction that takes the database write lock up front and always closes its connection."""
+    conn = connect(path)
+    try:
+        conn.execute("BEGIN IMMEDIATE")
+        yield conn
+        conn.commit()
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
 
 
 def initialize(conn):
@@ -30,6 +51,13 @@
     return PaymentRow(*row) if row else None
 
 
+def require_payment(conn, payment_id):
+    payment = get_payment(conn, payment_id)
+    if payment is None:
+        raise ValueError(f"unknown payment {payment_id!r}")
+    return payment
+
+
 def set_captured(conn, payment_id, amount_minor):
     conn.execute("UPDATE payments SET captured=? WHERE id=?", (amount_minor, payment_id))
 
09_tiered_feesApply a non-marginal tiered fee schedule

Price a payment at one tier selected by an inclusive upper bound, then round and clamp the fee.

What the hardened grader checks

  • Amounts exactly on a tier bound use that tier
  • The whole amount is priced at one rate, not band by band
  • Half-unit results round up, using inputs found by search
  • The fee is clamped to the schedule's minimum and maximum
  • Invalid schedules are rejected for any amount
Ticket (PROBLEM.md)
# Ticket: price the whole payment at its selected tier

`fees.schedule.compute_fee(amount_minor:int,schedule:Schedule)->int` returns integer minor units.
Public frozen dataclasses: `Tier(up_to_minor:int|None,bps:int,fixed_minor:int)` and
`Schedule(tiers:list[Tier],min_fee_minor:int,max_fee_minor:int)`.

Amounts are nonnegative ints, never bool; negative/noninteger -> ValueError. Schedule tiers are
ordered by strictly increasing nonnegative upper bounds, ending with exactly one None (infinity).
Bps/fixed/min/max are nonnegative ints, min <= max. `compute_fee` raises ValueError for an invalid
schedule (checked on every call, whatever the amount).

- Select the FIRST tier where amount <= up_to_minor, or None. Upper bounds are INCLUSIVE.
- Pricing is non-marginal: apply that selected tier's bps to the ENTIRE amount, then add its
  fixed fee. Do not sum bands.
- Raw fee = amount × bps / 10000 + fixed_minor. Round once to an integer using ROUND_HALF_UP,
  then clamp to [min_fee_minor,max_fee_minor]. This deliberately differs from interest's half-even.
- Zero amounts still incur the selected fixed fee, subject to the clamp. Input schedules are not
  mutated. The currency is implicit; all numbers use the same minor unit (e.g. CAD has 2 dp).

Worked example: tiers `[Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)]` with
`min_fee_minor=25`, `max_fee_minor=100`. An amount of 5000 uses the first tier: 5000 x 100 bps = 50, plus 10, so 60.
An amount of 15000 uses the second tier on the whole amount: 75 + 20 = 95. An amount of 100 would cost 11
and is raised to the minimum, 25. An amount of 30000 would cost 75 + 30 = 105 and is capped at 100.

Run from starter: `python -m pytest tests -q`. No real fees, networks or institutions are represented.
Reviewer notes (NOTES.md)
# Private draft - fee reviewer notes

v2 starter (see analysis/findings.md F-104): `compute_fee` prices every amount with the first tier's
rate using truncating integer division and no clamp, no amount validation and no schedule validation.
Tier selection, half-up rounding, the clamp and the validation are the ticket. The wrong variants are
plausible completions of that starter: `wrong_exclusive_boundary` selects tiers with `<`,
`wrong_float_bps` rounds half up using a float percentage, `wrong_marginal` prices bands separately.
Integer numerator rounding is independent of the alternative's Decimal quantization. Float
counterexamples are discovered by tools/find_ties.py, not chosen to fit a test.

## Blind review
Author self-review: upper-bound inclusivity, zero, fixed-fee clamp order and malformed schedules
were ambiguous in the starter; all are explicit in the ticket. Predictions: amount5000/bps3 ->2;
amount10000 uses the first inclusive tier; zero with fixed5/min10 ->10.
No unresolved graded ambiguity. Independent review remains owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 0/19 tests passed
Failed tests
  • test_hidden_fees::test_inclusive_boundaries
  • test_hidden_fees::test_discovered_float_ties[5000-3-2]
  • test_hidden_fees::test_discovered_float_ties[25000-3-8]
  • test_hidden_fees::test_discovered_float_ties[35000-3-11]
  • test_hidden_fees::test_non_marginal
  • test_hidden_fees::test_clamps_and_zero
  • test_hidden_fees::test_fee_properties
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers0-0-100]
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers1-0-100]
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers2-0-100]
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers3-0-100]
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers4-0-100]
  • test_hidden_fees::test_invalid_schedule_raises_for_any_amount[tiers5-20-10]
  • test_hidden_fees::test_schedule_revalidated_after_tier_list_mutation
  • test_hidden_fees::test_invalid_amount_type[True]
  • test_hidden_fees::test_invalid_amount_type[False]
  • test_hidden_fees::test_invalid_amount_type[1.5]
  • test_hidden_fees::test_invalid_amount_type[1]
  • test_hidden_fees::test_invalid_amount_type[None]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.11 s
Pytest output (last 40 lines)
>           compute_fee(amount, Schedule([Tier(None, 1, 0)], 0, 100))

..\grader\test_hidden_fees.py:81: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
fees\schedule.py:10: in compute_fee
    return raw_fee(amount_minor, schedule.tiers[0])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

amount_minor = None, tier = Tier(up_to_minor=None, bps=1, fixed_minor=0)

    def raw_fee(amount_minor, tier):
        """Percentage part plus fixed part, in whole minor units."""
>       return amount_minor * tier.bps // 10000 + tier.fixed_minor
               ^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'

fees\schedule.py:6: TypeError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_87r1fiji\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fees.py::test_inclusive_boundaries - assert 109 ...
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[5000-3-2] - ...
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[25000-3-8]
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[35000-3-11]
FAILED ..\grader\test_hidden_fees.py::test_non_marginal - assert 160 == 95
FAILED ..\grader\test_hidden_fees.py::test_clamps_and_zero - assert 5 == 10
FAILED ..\grader\test_hidden_fees.py::test_fee_properties - assert 54797 == 5...
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers0-0-100]
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers1-0-100]
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers2-0-100]
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers3-0-100]
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers4-0-100]
FAILED ..\grader\test_hidden_fees.py::test_invalid_schedule_raises_for_any_amount[tiers5-20-10]
FAILED ..\grader\test_hidden_fees.py::test_schedule_revalidated_after_tier_list_mutation
FAILED ..\grader\test_hidden_fees.py::test_invalid_amount_type[True] - Failed...
FAILED ..\grader\test_hidden_fees.py::test_invalid_amount_type[False] - Faile...
FAILED ..\grader\test_hidden_fees.py::test_invalid_amount_type[1.5] - Failed:...
FAILED ..\grader\test_hidden_fees.py::test_invalid_amount_type[1] - TypeError...
FAILED ..\grader\test_hidden_fees.py::test_invalid_amount_type[None] - TypeEr...
19 failed in 0.38s
Naive grader✓ starter: ok 0/3 tests passed
Failed tests
  • test_hidden::test_tier_midpoints
  • test_hidden::test_ticket_worked_example_with_clamps
  • test_hidden::test_negative_amount_rejected
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.87 s
Pytest output (last 40 lines)
FFF                                                                      [100%]
================================== FAILURES ===================================
_____________________________ test_tier_midpoints _____________________________

    def test_tier_midpoints():
        schedule = Schedule([Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)], 0, 999999)
        for amount, expected in [(5000, 60), (15000, 95), (30000, 105)]:
>           assert compute_fee(amount, schedule) == expected
E           assert 160 == 95
E            +  where 160 = compute_fee(15000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=0, max_fee_minor=999999))

..\grader\test_hidden.py:8: AssertionError
___________________ test_ticket_worked_example_with_clamps ____________________

    def test_ticket_worked_example_with_clamps():
        schedule = Schedule([Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)], 25, 100)
        for amount, expected in [(5000, 60), (15000, 95), (100, 25), (30000, 100)]:
>           assert compute_fee(amount, schedule) == expected
E           assert 160 == 95
E            +  where 160 = compute_fee(15000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=25, max_fee_minor=100))

..\grader\test_hidden.py:14: AssertionError
________________________ test_negative_amount_rejected ________________________

    def test_negative_amount_rejected():
>       with pytest.raises(ValueError):
             ^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE <class 'ValueError'>

..\grader\test_hidden.py:18: Failed
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_acqc5ve2\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_tier_midpoints - assert 160 == 95
FAILED ..\grader\test_hidden.py::test_ticket_worked_example_with_clamps - ass...
FAILED ..\grader\test_hidden.py::test_negative_amount_rejected - Failed: DID ...
3 failed in 0.11s

referencereference

Final grader✓ reference: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.27 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_q6z26gvo\junit.xml -
19 passed in 0.40s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.76 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_4cgun4xt\junit.xml -
3 passed in 0.02s
Diff against the starter
--- starter/fees\schedule.py
+++ reference/fees\schedule.py
@@ -1,10 +1,37 @@
-from fees.models import Tier, Schedule  # noqa: F401
+from fees.models import Tier, Schedule, nonnegative  # noqa: F401
+
+
+def validate_schedule(schedule):
+    tiers = schedule.tiers
+    if not tiers or tiers[-1].up_to_minor is not None:
+        raise ValueError("unbounded last tier required")
+    if not nonnegative(schedule.min_fee_minor) or not nonnegative(schedule.max_fee_minor):
+        raise ValueError("invalid limits")
+    if schedule.min_fee_minor > schedule.max_fee_minor:
+        raise ValueError("invalid clamp interval")
+    prior = -1
+    for tier in tiers[:-1]:
+        if tier.up_to_minor is None or tier.up_to_minor <= prior:
+            raise ValueError("strictly increasing bounded tiers required")
+        prior = tier.up_to_minor
+
+
+def select_tier(amount_minor, schedule):
+    for tier in schedule.tiers:
+        if tier.up_to_minor is None or amount_minor <= tier.up_to_minor:
+            return tier
+    raise ValueError("unbounded tier required")
 
 
 def raw_fee(amount_minor, tier):
-    """Percentage part plus fixed part, in whole minor units."""
-    return amount_minor * tier.bps // 10000 + tier.fixed_minor
+    """Percentage part plus fixed part, rounded half up to a whole minor unit."""
+    numerator = amount_minor * tier.bps + tier.fixed_minor * 10000
+    return (numerator + 5000) // 10000
 
 
 def compute_fee(amount_minor, schedule):
-    return raw_fee(amount_minor, schedule.tiers[0])
+    if type(amount_minor) is not int or amount_minor < 0:
+        raise ValueError("nonnegative integer amount required")
+    validate_schedule(schedule)
+    fee = raw_fee(amount_minor, select_tier(amount_minor, schedule))
+    return min(schedule.max_fee_minor, max(schedule.min_fee_minor, fee))

alt_validalt_valid

Final grader✓ alt_valid: ok 19/19 tests passed
Failed tests

All 19 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.17 s
Pytest output (last 40 lines)
...................                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_z6muizsh\junit.xml -
19 passed in 0.41s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.71 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_7a6nook1\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/fees\schedule.py
+++ alt_valid/fees\schedule.py
@@ -1,10 +1,29 @@
+from decimal import Decimal, ROUND_HALF_UP, localcontext
 from fees.models import Tier, Schedule  # noqa: F401
 
 
-def raw_fee(amount_minor, tier):
-    """Percentage part plus fixed part, in whole minor units."""
-    return amount_minor * tier.bps // 10000 + tier.fixed_minor
+def check(schedule):
+    bounds = [t.up_to_minor for t in schedule.tiers]
+    if not bounds or bounds[-1] is not None or None in bounds[:-1]:
+        raise ValueError("bounded tiers then one unbounded tier required")
+    bounded = bounds[:-1]
+    if any(later <= earlier for earlier, later in zip(bounded, bounded[1:])):
+        raise ValueError("tier bounds must strictly increase")
+    if not 0 <= schedule.min_fee_minor <= schedule.max_fee_minor:
+        raise ValueError("invalid fee limits")
 
 
 def compute_fee(amount_minor, schedule):
-    return raw_fee(amount_minor, schedule.tiers[0])
+    if not isinstance(amount_minor, int) or isinstance(amount_minor, bool) or amount_minor < 0:
+        raise ValueError("invalid payment amount")
+    check(schedule)
+    selected = next(t for t in schedule.tiers if t.up_to_minor is None or t.up_to_minor >= amount_minor)
+    with localcontext() as ctx:
+        ctx.prec = max(34, len(str(amount_minor)) + len(str(selected.bps)) + 10)
+        raw = Decimal(amount_minor) * Decimal(selected.bps) / Decimal(10000) + selected.fixed_minor
+        fee = int(raw.quantize(Decimal(1), rounding=ROUND_HALF_UP))
+    if fee < schedule.min_fee_minor:
+        return schedule.min_fee_minor
+    if fee > schedule.max_fee_minor:
+        return schedule.max_fee_minor
+    return fee

wrong_exclusive_boundarywrong

Final grader✓ wrong_exclusive_boundary: ok 18/19 tests passed
Failed tests
  • test_hidden_fees::test_inclusive_boundaries declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.21 s
Hardening
caught by hardening
Pytest output (last 40 lines)
F..................                                                      [100%]
================================== FAILURES ===================================
__________________________ test_inclusive_boundaries __________________________

    def test_inclusive_boundaries():
        tiers = [Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)]
        schedule = Schedule(tiers, 0, 10**20)
        for boundary in (10000, 20000):
            for amount in (boundary - 1, boundary, boundary + 1):
                tier = next(t for t in tiers if t.up_to_minor is None or amount <= t.up_to_minor)
>               assert compute_fee(amount, schedule) == expected(amount, tier)
E               assert 70 == 110
E                +  where 70 = compute_fee(10000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=0, max_fee_minor=100000000000000000000))
E                +  and   110 = expected(10000, Tier(up_to_minor=10000, bps=100, fixed_minor=10))

..\grader\test_hidden_fees.py:17: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_yyeh9fsb\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fees.py::test_inclusive_boundaries - assert 70 =...
1 failed, 18 passed in 0.46s
Naive grader✗ wrong_exclusive_boundary: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.77 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_87nh0ep7\junit.xml -
3 passed in 0.02s
Diff against the starter
--- starter/fees\schedule.py
+++ wrong_exclusive_boundary/fees\schedule.py
@@ -1,10 +1,37 @@
-from fees.models import Tier, Schedule  # noqa: F401
+from fees.models import Tier, Schedule, nonnegative  # noqa: F401
+
+
+def validate_schedule(schedule):
+    tiers = schedule.tiers
+    if not tiers or tiers[-1].up_to_minor is not None:
+        raise ValueError("unbounded last tier required")
+    if not nonnegative(schedule.min_fee_minor) or not nonnegative(schedule.max_fee_minor):
+        raise ValueError("invalid limits")
+    if schedule.min_fee_minor > schedule.max_fee_minor:
+        raise ValueError("invalid clamp interval")
+    prior = -1
+    for tier in tiers[:-1]:
+        if tier.up_to_minor is None or tier.up_to_minor <= prior:
+            raise ValueError("strictly increasing bounded tiers required")
+        prior = tier.up_to_minor
+
+
+def select_tier(amount_minor, schedule):
+    for tier in schedule.tiers:
+        if tier.up_to_minor is None or amount_minor < tier.up_to_minor:
+            return tier
+    raise ValueError("unbounded tier required")
 
 
 def raw_fee(amount_minor, tier):
-    """Percentage part plus fixed part, in whole minor units."""
-    return amount_minor * tier.bps // 10000 + tier.fixed_minor
+    """Percentage part plus fixed part, rounded half up to a whole minor unit."""
+    numerator = amount_minor * tier.bps + tier.fixed_minor * 10000
+    return (numerator + 5000) // 10000
 
 
 def compute_fee(amount_minor, schedule):
-    return raw_fee(amount_minor, schedule.tiers[0])
+    if type(amount_minor) is not int or amount_minor < 0:
+        raise ValueError("nonnegative integer amount required")
+    validate_schedule(schedule)
+    fee = raw_fee(amount_minor, select_tier(amount_minor, schedule))
+    return min(schedule.max_fee_minor, max(schedule.min_fee_minor, fee))

wrong_float_bpswrong

Final grader✓ wrong_float_bps: ok 16/19 tests passed
Failed tests
  • test_hidden_fees::test_discovered_float_ties[5000-3-2]
  • test_hidden_fees::test_discovered_float_ties[25000-3-8]
  • test_hidden_fees::test_discovered_float_ties[35000-3-11]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.34 s
Hardening
caught by hardening
Pytest output (last 40 lines)
    def test_discovered_float_ties(amount, bps, want):
        # Deterministic search results, see tools/rounding_cases.json.
>       assert compute_fee(amount, Schedule([Tier(None, bps, 0)], 0, 100000)) == want
E       assert 1 == 2
E        +  where 1 = compute_fee(5000, Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000))
E        +    where Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000) = Schedule([Tier(up_to_minor=None, bps=3, fixed_minor=0)], 0, 100000)

..\grader\test_hidden_fees.py:23: AssertionError
____________________ test_discovered_float_ties[25000-3-8] ____________________

amount = 25000, bps = 3, want = 8

    @pytest.mark.parametrize("amount,bps,want", [(5000, 3, 2), (25000, 3, 8), (35000, 3, 11)])
    def test_discovered_float_ties(amount, bps, want):
        # Deterministic search results, see tools/rounding_cases.json.
>       assert compute_fee(amount, Schedule([Tier(None, bps, 0)], 0, 100000)) == want
E       assert 7 == 8
E        +  where 7 = compute_fee(25000, Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000))
E        +    where Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000) = Schedule([Tier(up_to_minor=None, bps=3, fixed_minor=0)], 0, 100000)

..\grader\test_hidden_fees.py:23: AssertionError
___________________ test_discovered_float_ties[35000-3-11] ____________________

amount = 35000, bps = 3, want = 11

    @pytest.mark.parametrize("amount,bps,want", [(5000, 3, 2), (25000, 3, 8), (35000, 3, 11)])
    def test_discovered_float_ties(amount, bps, want):
        # Deterministic search results, see tools/rounding_cases.json.
>       assert compute_fee(amount, Schedule([Tier(None, bps, 0)], 0, 100000)) == want
E       assert 10 == 11
E        +  where 10 = compute_fee(35000, Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000))
E        +    where Schedule(tiers=[Tier(up_to_minor=None, bps=3, fixed_minor=0)], min_fee_minor=0, max_fee_minor=100000) = Schedule([Tier(up_to_minor=None, bps=3, fixed_minor=0)], 0, 100000)

..\grader\test_hidden_fees.py:23: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_2jl9wv70\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[5000-3-2] - ...
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[25000-3-8]
FAILED ..\grader\test_hidden_fees.py::test_discovered_float_ties[35000-3-11]
3 failed, 16 passed in 0.59s
Naive grader✗ wrong_float_bps: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.79 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_3ea_u_w3\junit.xml -
3 passed in 0.02s
Diff against the starter
--- starter/fees\schedule.py
+++ wrong_float_bps/fees\schedule.py
@@ -1,10 +1,38 @@
-from fees.models import Tier, Schedule  # noqa: F401
+import math
+
+from fees.models import Tier, Schedule, nonnegative  # noqa: F401
+
+
+def validate_schedule(schedule):
+    tiers = schedule.tiers
+    if not tiers or tiers[-1].up_to_minor is not None:
+        raise ValueError("unbounded last tier required")
+    if not nonnegative(schedule.min_fee_minor) or not nonnegative(schedule.max_fee_minor):
+        raise ValueError("invalid limits")
+    if schedule.min_fee_minor > schedule.max_fee_minor:
+        raise ValueError("invalid clamp interval")
+    prior = -1
+    for tier in tiers[:-1]:
+        if tier.up_to_minor is None or tier.up_to_minor <= prior:
+            raise ValueError("strictly increasing bounded tiers required")
+        prior = tier.up_to_minor
+
+
+def select_tier(amount_minor, schedule):
+    for tier in schedule.tiers:
+        if tier.up_to_minor is None or amount_minor <= tier.up_to_minor:
+            return tier
+    raise ValueError("unbounded tier required")
 
 
 def raw_fee(amount_minor, tier):
-    """Percentage part plus fixed part, in whole minor units."""
-    return amount_minor * tier.bps // 10000 + tier.fixed_minor
+    """Percentage part plus fixed part, rounded half up to a whole minor unit."""
+    return math.floor(amount_minor * (tier.bps / 10000) + tier.fixed_minor + .5)
 
 
 def compute_fee(amount_minor, schedule):
-    return raw_fee(amount_minor, schedule.tiers[0])
+    if type(amount_minor) is not int or amount_minor < 0:
+        raise ValueError("nonnegative integer amount required")
+    validate_schedule(schedule)
+    fee = raw_fee(amount_minor, select_tier(amount_minor, schedule))
+    return min(schedule.max_fee_minor, max(schedule.min_fee_minor, fee))

wrong_marginalwrong

Final grader✓ wrong_marginal: ok 17/19 tests passed
Failed tests
  • test_hidden_fees::test_inclusive_boundaries
  • test_hidden_fees::test_non_marginal declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.29 s
Pytest output (last 40 lines)
F...F..............                                                      [100%]
================================== FAILURES ===================================
__________________________ test_inclusive_boundaries __________________________

    def test_inclusive_boundaries():
        tiers = [Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)]
        schedule = Schedule(tiers, 0, 10**20)
        for boundary in (10000, 20000):
            for amount in (boundary - 1, boundary, boundary + 1):
                tier = next(t for t in tiers if t.up_to_minor is None or amount <= t.up_to_minor)
>               assert compute_fee(amount, schedule) == expected(amount, tier)
E               assert 120 == 70
E                +  where 120 = compute_fee(10001, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=0, max_fee_minor=100000000000000000000))
E                +  and   70 = expected(10001, Tier(up_to_minor=20000, bps=50, fixed_minor=20))

..\grader\test_hidden_fees.py:17: AssertionError
______________________________ test_non_marginal ______________________________

    def test_non_marginal():
        schedule = Schedule([Tier(10000, 100, 10), Tier(None, 50, 20)], 0, 999999)
>       assert compute_fee(15000, schedule) == 95
E       assert 145 == 95
E        +  where 145 = compute_fee(15000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=None, bps=50, fixed_minor=20)], min_fee_minor=0, max_fee_minor=999999))

..\grader\test_hidden_fees.py:28: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run__ac0kd72\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_fees.py::test_inclusive_boundaries - assert 120 ...
FAILED ..\grader\test_hidden_fees.py::test_non_marginal - assert 145 == 95
2 failed, 17 passed in 0.55s
Naive grader~ wrong_marginal: ok_wrong_reason 1/3 tests passed
Failed tests
  • test_hidden::test_tier_midpoints
  • test_hidden::test_ticket_worked_example_with_clamps
Verdict
~ ok_wrong_reason rejected for an undeclared reason
Expected
FAIL, observed FAIL
Duration
0.94 s
Prediction
ok_wrong_reason (matched)
Pytest output (last 40 lines)
FF.                                                                      [100%]
================================== FAILURES ===================================
_____________________________ test_tier_midpoints _____________________________

    def test_tier_midpoints():
        schedule = Schedule([Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)], 0, 999999)
        for amount, expected in [(5000, 60), (15000, 95), (30000, 105)]:
>           assert compute_fee(amount, schedule) == expected
E           assert 145 == 95
E            +  where 145 = compute_fee(15000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=0, max_fee_minor=999999))

..\grader\test_hidden.py:8: AssertionError
___________________ test_ticket_worked_example_with_clamps ____________________

    def test_ticket_worked_example_with_clamps():
        schedule = Schedule([Tier(10000, 100, 10), Tier(20000, 50, 20), Tier(None, 25, 30)], 25, 100)
        for amount, expected in [(5000, 60), (15000, 95), (100, 25), (30000, 100)]:
>           assert compute_fee(amount, schedule) == expected
E           assert 100 == 95
E            +  where 100 = compute_fee(15000, Schedule(tiers=[Tier(up_to_minor=10000, bps=100, fixed_minor=10), Tier(up_to_minor=20000, bps=50, fixed_minor=20), Tier(up_to_minor=None, bps=25, fixed_minor=30)], min_fee_minor=25, max_fee_minor=100))

..\grader\test_hidden.py:14: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_848k6tcm\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_tier_midpoints - assert 145 == 95
FAILED ..\grader\test_hidden.py::test_ticket_worked_example_with_clamps - ass...
2 failed, 1 passed in 0.22s
Diff against the starter
--- starter/fees\schedule.py
+++ wrong_marginal/fees\schedule.py
@@ -1,10 +1,37 @@
-from fees.models import Tier, Schedule  # noqa: F401
+from fees.models import Tier, Schedule, nonnegative  # noqa: F401
 
 
-def raw_fee(amount_minor, tier):
-    """Percentage part plus fixed part, in whole minor units."""
-    return amount_minor * tier.bps // 10000 + tier.fixed_minor
+def validate_schedule(schedule):
+    tiers = schedule.tiers
+    if not tiers or tiers[-1].up_to_minor is not None:
+        raise ValueError("unbounded last tier required")
+    if not nonnegative(schedule.min_fee_minor) or not nonnegative(schedule.max_fee_minor):
+        raise ValueError("invalid limits")
+    if schedule.min_fee_minor > schedule.max_fee_minor:
+        raise ValueError("invalid clamp interval")
+    prior = -1
+    for tier in tiers[:-1]:
+        if tier.up_to_minor is None or tier.up_to_minor <= prior:
+            raise ValueError("strictly increasing bounded tiers required")
+        prior = tier.up_to_minor
+
+
+def marginal_fee(amount_minor, schedule):
+    """Charge each band at its own rate; the fixed fee of the band that contains the amount is added."""
+    total, lower = 0, 0
+    for tier in schedule.tiers:
+        upper = amount_minor if tier.up_to_minor is None else min(amount_minor, tier.up_to_minor)
+        total += max(0, upper - lower) * tier.bps
+        if upper == amount_minor:
+            total += tier.fixed_minor * 10000
+            break
+        lower = upper
+    return (total + 5000) // 10000
 
 
 def compute_fee(amount_minor, schedule):
-    return raw_fee(amount_minor, schedule.tiers[0])
+    if type(amount_minor) is not int or amount_minor < 0:
+        raise ValueError("nonnegative integer amount required")
+    validate_schedule(schedule)
+    fee = marginal_fee(amount_minor, schedule)
+    return min(schedule.max_fee_minor, max(schedule.min_fee_minor, fee))
10_batch_parserValidate an entire SBF-80 payment batch

Validate an invented 80-character fixed-width payment file and reject the whole batch on any error.

What the hardened grader checks

  • CRLF and LF files parse identically
  • The trailer's count, control total and account hash total are all checked
  • One bad detail line rejects the entire batch
  • Every error is reported with its line number
  • Rendering and re-parsing a random valid batch round-trips; one changed digit is caught
Ticket (PROBLEM.md)
# Ticket: validate invented SBF-80 batch files atomically

`batch.parser.parse(data:bytes)->BatchResult` returns frozen dataclasses exported from that module:
`Payment(payee_account:str,amount_minor:int,reference:str)`, `BatchError(line_no:int,code:str)`,
`BatchResult(accepted:bool,payments:list[Payment],errors:list[BatchError])`.

SBF-80 is invented for this synthetic task. Each record is exactly 80 printable ASCII characters,
followed by LF or CRLF. A final terminator is optional; mixed LF/CRLF is accepted. Strip the
terminator before checking width and slicing. Interior blank lines are records and invalid.
Bare CR, non-ASCII or control bytes are invalid_encoding. All positions below are 1-based inclusive.

| Record | Columns | Field |
|---|---|---|
| H | 1 | H |
| H | 2–13 | batch id: 12 uppercase ASCII letters/digits |
| H | 14–21 | creation date YYYYMMDD, valid calendar date |
| H | 22–24 | currency CAD, USD, EUR, GBP, JPY, KWD or BHD |
| H | 25–80 | spaces |
| D | 1 | D |
| D | 2–13 | 12 ASCII digits, payee account (retain leading zeros) |
| D | 14–25 | 12 ASCII digits, positive zero-padded integer minor units |
| D | 26–45 | 20 printable chars, nonblank reference, right-space padded |
| D | 46–80 | spaces |
| T | 1 | T |
| T | 2–7 | detail count, 6 ASCII digits |
| T | 8–22 | control total of amounts, 15 ASCII digits |
| T | 23–32 | hash total, 10 ASCII digits |
| T | 33–80 | spaces |

Exactly one header first, then one or more details, then exactly one trailer last. The count is
the number of D records, including malformed D records. Control total and hash total use only
fully valid detail records. Hash = sum(int(last 8 digits of each payee account)) mod 10**10.
Money is in header-currency minor units: CAD/USD/EUR/GBP=2 dp, JPY=0, KWD/BHD=3. Do not convert.
Returned references remove right padding only. Preserve detail order and account strings.

Any error rejects the whole batch: accepted=False and payments=[]. Report all detectable errors,
deduplicated by (line_no,code), sorted by line_no then code. Continue checking other records.
For a wrong-width or invalid-encoding record, report only that local error (no field errors),
but structural/order and trailer comparisons can still be reported. Field errors are one code per
record even if multiple fields are invalid. Trailer totals compare independently when its fields
are valid, even if other records failed.

Codes: invalid_encoding, line_length, record_type, record_order, invalid_header, invalid_detail,
invalid_trailer, count_mismatch, control_total_mismatch, hash_total_mismatch, missing_header,
missing_trailer. H outside first position, T outside last, or D at first/last -> record_order.
Unknown type -> record_type. A missing first H -> missing_header at line1; missing final T ->
missing_trailer at the last line (line1 for empty input). An empty detail section -> count_mismatch
on the trailer. Header/trailer field errors include invalid reserved spaces. No other codes.

Worked example (each record is padded with spaces to 80 characters; trailing spaces are not shown):

```text
HSYNTHBATCH0120260115CAD
D000000012345000000010000INV-2026-001
D000000067890000000002550INV-2026-002
T0000020000000000125500000080235
```

The batch is accepted with two payments: `Payment("000000012345", 10000, "INV-2026-001")` and
`Payment("000000067890", 2550, "INV-2026-002")`. Count 2; control total 10000 + 2550 = 12550; hash total
12345 + 67890 = 80235. Changing the trailer count to `000003` rejects the whole batch with
`BatchError(4, "count_mismatch")` and `payments == []`.

Run from starter: `python -m pytest tests -q`. No real batch format, institution or customer is used.
Reviewer notes (NOTES.md)
# Private draft - batch parser reviewer notes

v2 starter (see analysis/findings.md F-105): the starter interprets detail records only, splits the
file on LF only, never looks at the header or trailer, and returns the good payments even when other
detail lines were malformed. Header/trailer validation, count, control and hash totals, CRLF handling
and whole-batch rejection are the ticket. The wrong variants are plausible completions of that starter:
`wrong_partial_accept` adds everything but keeps accepting the valid details of a batch with
malformed detail records; `wrong_ignores_hash_total` implements count and control totals only;
`wrong_crlf_slicing` completes the validation but keeps the starter's LF-only line splitting, so CRLF
files fail on the CR byte. The alternative accumulates totals in a single streaming pass.
CR at the end does not shift earlier columns; the defect is validating/slicing before line-ending
normalization. Fixtures generate CRLF from bytes.

## Blind review
Author self-review: positions, terminators, malformed-field error granularity, cascading trailer
checks, ordering and line numbers all needed explicit rules. The ticket now contains them.
Predictions: account000000000123 contributes123 to hash; count mismatch -> rejected/no payments;
valid CRLF -> accepted/same records as LF. No unresolved graded ambiguity. Independent review owed.

Self-review confirmation (2026-10-17): re-read only PROBLEM.md and starter files from disk; the three predictions above follow from the ticket. No additional graded ambiguity found. Independent review remains owed.

starterstarter

Final grader✓ starter: ok 12/39 tests passed
Failed tests
  • test_hidden_batch::test_crlf
  • test_hidden_batch::test_hash_total
  • test_hidden_batch::test_count_mismatch
  • test_hidden_batch::test_atomic_batch_rejection
  • test_hidden_batch::test_multiple_errors_and_line_numbers
  • test_hidden_batch::test_invalid_records[<lambda>-invalid_encoding]
  • test_hidden_batch::test_invalid_records[<lambda>-record_type]
  • test_hidden_batch::test_missing_and_order
  • test_hidden_batch::test_field_formats[0-1-13-synthetic001-invalid_header]
  • test_hidden_batch::test_field_formats[0-1-13-SYNTHETIC00!-invalid_header]
  • test_hidden_batch::test_field_formats[0-13-21-20260230-invalid_header]
  • test_hidden_batch::test_field_formats[0-21-24-XYZ-invalid_header]
  • test_hidden_batch::test_field_formats[0-24-25-X-invalid_header]
  • test_hidden_batch::test_field_formats[1-1-13-00000000012X-invalid_detail]
  • test_hidden_batch::test_field_formats[1-13-25-000000000000-invalid_detail]
  • test_hidden_batch::test_field_formats[1-13-25-00000000000X-invalid_detail]
  • test_hidden_batch::test_field_formats[1-25-45- -invalid_detail]
  • test_hidden_batch::test_field_formats[1-45-46-X-invalid_detail]
  • test_hidden_batch::test_field_formats[2-1-7-00000X-invalid_trailer]
  • test_hidden_batch::test_field_formats[2-7-22-00000000000000X-invalid_trailer]
  • test_hidden_batch::test_field_formats[2-22-32-000000000X-invalid_trailer]
  • test_hidden_batch::test_field_formats[2-32-33-X-invalid_trailer]
  • test_hidden_batch::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD \rD000000000123000000000100reference \rT0000010000000000001000000000123 \r-want0]
  • test_hidden_batch::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD \nD000000000123000000000100reference \nT0000010000000000001000000000123 \r-want1]
  • test_hidden_batch::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD \n\nD000000000123000000000100reference \nT0000010000000000001000000000123 -want2]
  • test_hidden_batch::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD \nD000000000123000000000100reference -want3]
  • test_hidden_batch::test_roundtrip_and_single_digit_corruption
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.25 s
Pytest output (last 40 lines)
        corrupted = parse((b"\r\n" if crlf else b"\n").join(rows))
>       assert not corrupted.accepted and corrupted.payments == []
E       AssertionError: assert (not True)
E        +  where True = BatchResult(accepted=True, payments=[Payment(payee_account='000000000000', amount_minor=100000000001, reference='0')], errors=[]).accepted
E       Failing test case: test_roundtrip_and_single_digit_corruption(
E           cases=[(0, 1, '0')],
E           crlf=False,
E       )

..\grader\test_hidden_batch.py:165: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_2cm8_v87\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_batch.py::test_crlf - AssertionError: assert (Fa...
FAILED ..\grader\test_hidden_batch.py::test_hash_total - AssertionError: asse...
FAILED ..\grader\test_hidden_batch.py::test_count_mismatch - AssertionError: ...
FAILED ..\grader\test_hidden_batch.py::test_atomic_batch_rejection - Assertio...
FAILED ..\grader\test_hidden_batch.py::test_multiple_errors_and_line_numbers
FAILED ..\grader\test_hidden_batch.py::test_invalid_records[<lambda>-invalid_encoding]
FAILED ..\grader\test_hidden_batch.py::test_invalid_records[<lambda>-record_type]
FAILED ..\grader\test_hidden_batch.py::test_missing_and_order - AssertionErro...
FAILED ..\grader\test_hidden_batch.py::test_field_formats[0-1-13-synthetic001-invalid_header]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[0-1-13-SYNTHETIC00!-invalid_header]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[0-13-21-20260230-invalid_header]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[0-21-24-XYZ-invalid_header]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[0-24-25-X-invalid_header]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-1-13-00000000012X-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-13-25-000000000000-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-13-25-00000000000X-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-25-45-                    -invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-45-46-X-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[2-1-7-00000X-invalid_trailer]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[2-7-22-00000000000000X-invalid_trailer]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[2-22-32-000000000X-invalid_trailer]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[2-32-33-X-invalid_trailer]
FAILED ..\grader\test_hidden_batch.py::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD                                                        \rD000000000123000000000100reference                                              \rT0000010000000000001000000000123                                                \r-want0]
FAILED ..\grader\test_hidden_batch.py::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD                                                        \nD000000000123000000000100reference                                              \nT0000010000000000001000000000123                                                \r-want1]
FAILED ..\grader\test_hidden_batch.py::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD                                                        \n\nD000000000123000000000100reference                                              \nT0000010000000000001000000000123                                                -want2]
FAILED ..\grader\test_hidden_batch.py::test_terminator_blank_and_missing_trailer_errors[HSYNTHETIC00120260101CAD                                                        \nD000000000123000000000100reference                                              -want3]
FAILED ..\grader\test_hidden_batch.py::test_roundtrip_and_single_digit_corruption
27 failed, 12 passed in 0.53s
Naive grader✓ starter: ok 2/3 tests passed
Failed tests
  • test_hidden::test_count_mismatch
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
0.85 s
Pytest output (last 40 lines)
..F                                                                      [100%]
================================== FAILURES ===================================
_____________________________ test_count_mismatch _____________________________

    def test_count_mismatch():
        lines = render([Payment("000000000123", 100, "reference")]).splitlines()
        lines[-1] = b"T000002" + lines[-1][7:]
        result = parse(b"\n".join(lines) + b"\n")
>       assert not result.accepted
E       AssertionError: assert not True
E        +  where True = BatchResult(accepted=True, payments=[Payment(payee_account='000000000123', amount_minor=100, reference='reference')], errors=[]).accepted

..\grader\test_hidden.py:36: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_8wn4ryum\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden.py::test_count_mismatch - AssertionError: assert...
1 failed, 2 passed in 0.09s

referencereference

Final grader✓ reference: ok 39/39 tests passed
Failed tests

All 39 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.66 s
Pytest output (last 40 lines)
.......................................                                  [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_d530fl7p\junit.xml -
39 passed in 0.86s
Naive grader✓ reference: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.79 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_nr209da3\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/batch\parser.py
+++ reference/batch\parser.py
@@ -3,15 +3,34 @@
 
 
 def parse(data):
-    errors, payments = [], []
-    for line_no, raw in enumerate(physical_lines(data), 1):
-        if raw[:1] != b"D":
+    rows = physical_lines(data)
+    errors, payments, trailers = set(), [], []
+    if not rows or rows[0][:1] != b"H":
+        errors.add((1, "missing_header"))
+    if not rows or rows[-1][:1] != b"T":
+        errors.add((max(1, len(rows)), "missing_trailer"))
+    count = sum(row[:1] == b"D" for row in rows)
+    for line_no, raw in enumerate(rows, 1):
+        kind = raw[:1]
+        if ((kind == b"H" and line_no != 1) or (kind == b"T" and line_no != len(rows))
+                or (kind == b"D" and line_no in {1, len(rows)})):
+            errors.add((line_no, "record_order"))
+        text, local_error = decode(raw)
+        if local_error:
+            errors.add((line_no, local_error))
             continue
-        text, error = decode(raw)
-        if error is None:
-            value, error = fields(text)
-        if error:
-            errors.append(BatchError(line_no, error))
-        else:
+        value, field_error = fields(text)
+        if field_error:
+            errors.add((line_no, field_error))
+        elif text[0] == "D":
             payments.append(value)
-    return BatchResult(not errors, payments, errors)
+        elif text[0] == "T":
+            trailers.append((line_no, value))
+    for line_no, (declared_count, total, hash_total) in trailers:
+        if count == 0 or count != declared_count:
+            errors.add((line_no, "count_mismatch"))
+        if total != sum(p.amount_minor for p in payments):
+            errors.add((line_no, "control_total_mismatch"))
+        if hash_total != sum(int(p.payee_account[-8:]) for p in payments) % 10**10:
+            errors.add((line_no, "hash_total_mismatch"))
+    return BatchResult(not errors, [] if errors else payments, [BatchError(*e) for e in sorted(errors)])
--- starter/batch\records.py
+++ reference/batch\records.py
@@ -1,4 +1,6 @@
 """Fixed-width record decoding shared by offline batch utilities."""
+from datetime import datetime
+import re
 from batch.models import Payment
 
 CURRENCIES = {"CAD", "USD", "EUR", "GBP", "JPY", "KWD", "BHD"}
@@ -21,13 +23,26 @@
 
 
 def fields(text):
-    """Decode the columns of one 80-character record. Only detail records are interpreted so far."""
-    if text[0] == "D":
+    kind = text[0]
+    if kind == "H":
+        valid = bool(re.fullmatch(r"[A-Z0-9]{12}", text[1:13]))
+        try:
+            datetime.strptime(text[13:21], "%Y%m%d")
+            valid = valid and digits(text[13:21])
+        except ValueError:
+            valid = False
+        valid = valid and text[21:24] in CURRENCIES and text[24:] == " " * 56
+        return None, None if valid else "invalid_header"
+    if kind == "D":
         valid = digits(text[1:13]) and digits(text[13:25]) and text[45:] == " " * 35
         if not valid or int(text[13:25]) <= 0 or not text[25:45].strip():
             return None, "invalid_detail"
         return Payment(text[1:13], int(text[13:25]), text[25:45].rstrip(" ")), None
-    return None, None
+    if kind == "T":
+        if not (digits(text[1:7]) and digits(text[7:22]) and digits(text[22:32]) and text[32:] == " " * 48):
+            return None, "invalid_trailer"
+        return (int(text[1:7]), int(text[7:22]), int(text[22:32])), None
+    return None, "record_type"
 
 
 def physical_lines(data):
@@ -36,4 +51,5 @@
     rows = data.split(b"\n")
     if rows[-1] == b"":
         rows.pop()
-    return rows
+    return [row[:-1] if row.endswith(b"\r") and (i < len(rows) - 1 or data.endswith(b"\n")) else row
+            for i, row in enumerate(rows)]

alt_validalt_valid

Final grader✓ alt_valid: ok 39/39 tests passed
Failed tests

All 39 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
1.58 s
Pytest output (last 40 lines)
.......................................                                  [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_3wr1h1dg\junit.xml -
39 passed in 0.85s
Naive grader✓ alt_valid: ok 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✓ ok as expected
Expected
PASS, observed PASS
Duration
0.76 s
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_s4z5mdpc\junit.xml -
3 passed in 0.02s
Diff against the starter
--- starter/batch\parser.py
+++ alt_valid/batch\parser.py
@@ -1,17 +1,44 @@
 from batch.models import BatchResult, Payment, BatchError  # noqa: F401
-from batch.records import decode, fields, physical_lines
+from batch.records import fields, decode, physical_lines
 
 
 def parse(data):
-    errors, payments = [], []
-    for line_no, raw in enumerate(physical_lines(data), 1):
-        if raw[:1] != b"D":
-            continue
-        text, error = decode(raw)
-        if error is None:
-            value, error = fields(text)
-        if error:
-            errors.append(BatchError(line_no, error))
-        else:
-            payments.append(value)
-    return BatchResult(not errors, payments, errors)
+    physical = physical_lines(data)
+    issues = set()
+    details = []
+    number_of_details, amount_sum, account_sum = 0, 0, 0
+    controls = []
+    last_index = len(physical)
+    if not physical or not physical[0].startswith(b"H"):
+        issues.add((1, "missing_header"))
+    if not physical or not physical[-1].startswith(b"T"):
+        issues.add((max(last_index, 1), "missing_trailer"))
+    for index, physical_record in enumerate(physical, start=1):
+        marker = physical_record[:1]
+        if marker == b"D":
+            number_of_details += 1
+        expected_position = {b"H": index == 1, b"T": index == last_index,
+                             b"D": 1 < index < last_index}
+        if marker in expected_position and not expected_position[marker]:
+            issues.add((index, "record_order"))
+        record, issue = decode(physical_record)
+        if issue is None:
+            value, issue = fields(record)
+            if issue is None and marker == b"D":
+                details.append(value)
+                amount_sum += value.amount_minor
+                account_sum = (account_sum + int(value.payee_account[-8:])) % 10**10
+            elif issue is None and marker == b"T":
+                controls.append((index, value))
+        if issue:
+            issues.add((index, issue))
+    for index, declared in controls:
+        observed = number_of_details, amount_sum, account_sum
+        codes = "count_mismatch", "control_total_mismatch", "hash_total_mismatch"
+        for i, code in enumerate(codes):
+            if declared[i] != observed[i] or (i == 0 and number_of_details == 0):
+                issues.add((index, code))
+    ordered_errors = [BatchError(line, code) for line, code in sorted(issues)]
+    if ordered_errors:
+        return BatchResult(False, [], ordered_errors)
+    return BatchResult(True, details, [])
--- starter/batch\records.py
+++ alt_valid/batch\records.py
@@ -1,39 +1,55 @@
-"""Fixed-width record decoding shared by offline batch utilities."""
+"""Pattern-based SBF-80 record decoding (regular expressions instead of column slicing)."""
+from datetime import datetime
+import re
 from batch.models import Payment
 
-CURRENCIES = {"CAD", "USD", "EUR", "GBP", "JPY", "KWD", "BHD"}
+CURRENCY = "CAD|USD|EUR|GBP|JPY|KWD|BHD"
+HEADER = re.compile(rf"H[A-Z0-9]{{12}}([0-9]{{8}})(?:{CURRENCY}) {{56}}")
+DETAIL = re.compile(r"D([0-9]{12})([0-9]{12})([ -~]{20}) {35}")
+TRAILER = re.compile(r"T([0-9]{6})([0-9]{15})([0-9]{10}) {48}")
+PRINTABLE = re.compile(rb"[\x20-\x7e]*")
+LINE_END = re.compile(rb"\r?\n")
 
 
-def digits(text):
-    return bool(text) and all("0" <= char <= "9" for char in text)
+def physical_lines(data):
+    """Split on LF or CRLF terminators; an unterminated final line is kept as it is."""
+    if not data:
+        return []
+    pieces = LINE_END.split(data)
+    if pieces[-1] == b"":
+        pieces.pop()
+    return pieces
 
 
 def decode(raw):
+    if not PRINTABLE.fullmatch(raw):
+        return None, "invalid_encoding"
+    if len(raw) != 80:
+        return None, "line_length"
+    return raw.decode("ascii"), None
+
+
+def header_ok(match):
     try:
-        text = raw.decode("ascii")
-    except UnicodeDecodeError:
-        return None, "invalid_encoding"
-    if any(ord(c) < 32 or ord(c) > 126 for c in text):
-        return None, "invalid_encoding"
-    if len(text) != 80:
-        return None, "line_length"
-    return text, None
+        datetime.strptime(match.group(1), "%Y%m%d")
+    except ValueError:
+        return False
+    return True
 
 
 def fields(text):
-    """Decode the columns of one 80-character record. Only detail records are interpreted so far."""
-    if text[0] == "D":
-        valid = digits(text[1:13]) and digits(text[13:25]) and text[45:] == " " * 35
-        if not valid or int(text[13:25]) <= 0 or not text[25:45].strip():
+    kind = text[0]
+    if kind == "H":
+        match = HEADER.fullmatch(text)
+        return None, None if match and header_ok(match) else "invalid_header"
+    if kind == "D":
+        match = DETAIL.fullmatch(text)
+        if not match or int(match.group(2)) <= 0 or not match.group(3).strip():
             return None, "invalid_detail"
-        return Payment(text[1:13], int(text[13:25]), text[25:45].rstrip(" ")), None
-    return None, None
-
-
-def physical_lines(data):
-    if not data:
-        return []
-    rows = data.split(b"\n")
-    if rows[-1] == b"":
-        rows.pop()
-    return rows
+        return Payment(match.group(1), int(match.group(2)), match.group(3).rstrip(" ")), None
+    if kind == "T":
+        match = TRAILER.fullmatch(text)
+        if not match:
+            return None, "invalid_trailer"
+        return tuple(int(group) for group in match.groups()), None
+    return None, "record_type"

wrong_crlf_slicingwrong

Final grader✓ wrong_crlf_slicing: ok 34/39 tests passed
Failed tests
  • test_hidden_batch::test_crlf declared must_fail
  • test_hidden_batch::test_mixed_terminators_and_optional_final[]
  • test_hidden_batch::test_mixed_terminators_and_optional_final[\n]
  • test_hidden_batch::test_mixed_terminators_and_optional_final[\r\n]
  • test_hidden_batch::test_roundtrip_and_single_digit_corruption
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.37 s
Hardening
caught by hardening
Pytest output (last 40 lines)
..\grader\test_hidden_batch.py:128: AssertionError
_________________ test_roundtrip_and_single_digit_corruption __________________

    @given(cases=st.lists(st.tuples(st.integers(0, 999999999999), st.integers(1, 10**8),
>                                 st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", min_size=1, max_size=20)),
                   ^^^
                         min_size=1, max_size=20), crlf=st.booleans())

..\grader\test_hidden_batch.py:153: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cases = [(3751, 17226536, '7N843RF6XHCYXYHRY'), (7987945, 13174, '67WXNNB'), (7011370, 5552861, 'L')]
crlf = True

    @given(cases=st.lists(st.tuples(st.integers(0, 999999999999), st.integers(1, 10**8),
                                  st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", min_size=1, max_size=20)),
                         min_size=1, max_size=20), crlf=st.booleans())
    def test_roundtrip_and_single_digit_corruption(cases, crlf):
        payments = [Payment(f"{account:012d}", amount, ref) for account, amount, ref in cases]
        data = render(payments, crlf)
        result = parse(data)
>       assert result.accepted and result.payments == payments and result.errors == []
E       AssertionError: assert (False)
E        +  where False = BatchResult(accepted=False, payments=[], errors=[BatchError(line_no=1, code='invalid_encoding'), BatchError(line_no=2,...e='invalid_encoding'), BatchError(line_no=4, code='invalid_encoding'), BatchError(line_no=5, code='invalid_encoding')]).accepted
E       Failing test case: test_roundtrip_and_single_digit_corruption(
E           cases=[(3751, 17226536, '7N843RF6XHCYXYHRY'),
E            (7987945, 13174, '67WXNNB'),
E            (7011370, 5552861, 'L')],
E           crlf=True,
E       )

..\grader\test_hidden_batch.py:159: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_0dlp9wnm\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_batch.py::test_crlf - AssertionError: assert (Fa...
FAILED ..\grader\test_hidden_batch.py::test_mixed_terminators_and_optional_final[]
FAILED ..\grader\test_hidden_batch.py::test_mixed_terminators_and_optional_final[\n]
FAILED ..\grader\test_hidden_batch.py::test_mixed_terminators_and_optional_final[\r\n]
FAILED ..\grader\test_hidden_batch.py::test_roundtrip_and_single_digit_corruption
5 failed, 34 passed in 0.59s
Naive grader✗ wrong_crlf_slicing: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.77 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_k_p08a41\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/batch\parser.py
+++ wrong_crlf_slicing/batch\parser.py
@@ -3,15 +3,34 @@
 
 
 def parse(data):
-    errors, payments = [], []
-    for line_no, raw in enumerate(physical_lines(data), 1):
-        if raw[:1] != b"D":
+    rows = physical_lines(data)
+    errors, payments, trailers = set(), [], []
+    if not rows or rows[0][:1] != b"H":
+        errors.add((1, "missing_header"))
+    if not rows or rows[-1][:1] != b"T":
+        errors.add((max(1, len(rows)), "missing_trailer"))
+    count = sum(row[:1] == b"D" for row in rows)
+    for line_no, raw in enumerate(rows, 1):
+        kind = raw[:1]
+        if ((kind == b"H" and line_no != 1) or (kind == b"T" and line_no != len(rows))
+                or (kind == b"D" and line_no in {1, len(rows)})):
+            errors.add((line_no, "record_order"))
+        text, local_error = decode(raw)
+        if local_error:
+            errors.add((line_no, local_error))
             continue
-        text, error = decode(raw)
-        if error is None:
-            value, error = fields(text)
-        if error:
-            errors.append(BatchError(line_no, error))
-        else:
+        value, field_error = fields(text)
+        if field_error:
+            errors.add((line_no, field_error))
+        elif text[0] == "D":
             payments.append(value)
-    return BatchResult(not errors, payments, errors)
+        elif text[0] == "T":
+            trailers.append((line_no, value))
+    for line_no, (declared_count, total, hash_total) in trailers:
+        if count == 0 or count != declared_count:
+            errors.add((line_no, "count_mismatch"))
+        if total != sum(p.amount_minor for p in payments):
+            errors.add((line_no, "control_total_mismatch"))
+        if hash_total != sum(int(p.payee_account[-8:]) for p in payments) % 10**10:
+            errors.add((line_no, "hash_total_mismatch"))
+    return BatchResult(not errors, [] if errors else payments, [BatchError(*e) for e in sorted(errors)])
--- starter/batch\records.py
+++ wrong_crlf_slicing/batch\records.py
@@ -1,4 +1,6 @@
 """Fixed-width record decoding shared by offline batch utilities."""
+from datetime import datetime
+import re
 from batch.models import Payment
 
 CURRENCIES = {"CAD", "USD", "EUR", "GBP", "JPY", "KWD", "BHD"}
@@ -21,13 +23,26 @@
 
 
 def fields(text):
-    """Decode the columns of one 80-character record. Only detail records are interpreted so far."""
-    if text[0] == "D":
+    kind = text[0]
+    if kind == "H":
+        valid = bool(re.fullmatch(r"[A-Z0-9]{12}", text[1:13]))
+        try:
+            datetime.strptime(text[13:21], "%Y%m%d")
+            valid = valid and digits(text[13:21])
+        except ValueError:
+            valid = False
+        valid = valid and text[21:24] in CURRENCIES and text[24:] == " " * 56
+        return None, None if valid else "invalid_header"
+    if kind == "D":
         valid = digits(text[1:13]) and digits(text[13:25]) and text[45:] == " " * 35
         if not valid or int(text[13:25]) <= 0 or not text[25:45].strip():
             return None, "invalid_detail"
         return Payment(text[1:13], int(text[13:25]), text[25:45].rstrip(" ")), None
-    return None, None
+    if kind == "T":
+        if not (digits(text[1:7]) and digits(text[7:22]) and digits(text[22:32]) and text[32:] == " " * 48):
+            return None, "invalid_trailer"
+        return (int(text[1:7]), int(text[7:22]), int(text[22:32])), None
+    return None, "record_type"
 
 
 def physical_lines(data):

wrong_ignores_hash_totalwrong

Final grader✓ wrong_ignores_hash_total: ok 31/39 tests passed
Failed tests
  • test_hidden_batch::test_hash_total declared must_fail
  • test_hidden_batch::test_atomic_batch_rejection
  • test_hidden_batch::test_multiple_errors_and_line_numbers
  • test_hidden_batch::test_field_formats[1-1-13-00000000012X-invalid_detail]
  • test_hidden_batch::test_field_formats[1-13-25-000000000000-invalid_detail]
  • test_hidden_batch::test_field_formats[1-13-25-00000000000X-invalid_detail]
  • test_hidden_batch::test_field_formats[1-25-45- -invalid_detail]
  • test_hidden_batch::test_field_formats[1-45-46-X-invalid_detail]
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.75 s
Hardening
caught by hardening
Pytest output (last 40 lines)
        (0, 1, 13, b"SYNTHETIC00!", "invalid_header"),
        (0, 13, 21, b"20260230", "invalid_header"),
        (0, 21, 24, b"XYZ", "invalid_header"),
        (0, 24, 25, b"X", "invalid_header"),
        (1, 1, 13, b"00000000012X", "invalid_detail"),
        (1, 13, 25, b"000000000000", "invalid_detail"),
        (1, 13, 25, b"00000000000X", "invalid_detail"),
        (1, 25, 45, b" " * 20, "invalid_detail"),
        (1, 45, 46, b"X", "invalid_detail"),
        (2, 1, 7, b"00000X", "invalid_trailer"),
        (2, 7, 22, b"00000000000000X", "invalid_trailer"),
        (2, 22, 32, b"000000000X", "invalid_trailer"),
        (2, 32, 33, b"X", "invalid_trailer"),
    ])
    def test_field_formats(row, start, end, value, code):
        rows = render(fixture()).splitlines()
        rows[row] = rows[row][:start] + value + rows[row][end:]
        result = parse(b"\n".join(rows))
        expected = [(row + 1, code)]
        if row == 1:
            expected += [(3, "control_total_mismatch"), (3, "hash_total_mismatch")]
        assert not result.accepted and result.payments == []
>       assert errors(result) == expected
E       AssertionError: assert [(2, 'invalid...al_mismatch')] == [(2, 'invalid...al_mismatch')]
E         
E         Right contains one more item: (3, 'hash_total_mismatch')
E         Use -v to get more diff

..\grader\test_hidden_batch.py:112: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_j0wdm76o\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_batch.py::test_hash_total - AssertionError: asse...
FAILED ..\grader\test_hidden_batch.py::test_atomic_batch_rejection - Assertio...
FAILED ..\grader\test_hidden_batch.py::test_multiple_errors_and_line_numbers
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-1-13-00000000012X-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-13-25-000000000000-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-13-25-00000000000X-invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-25-45-                    -invalid_detail]
FAILED ..\grader\test_hidden_batch.py::test_field_formats[1-45-46-X-invalid_detail]
8 failed, 31 passed in 0.99s
Naive grader✗ wrong_ignores_hash_total: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.78 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run__ysrnk2v\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/batch\parser.py
+++ wrong_ignores_hash_total/batch\parser.py
@@ -3,15 +3,32 @@
 
 
 def parse(data):
-    errors, payments = [], []
-    for line_no, raw in enumerate(physical_lines(data), 1):
-        if raw[:1] != b"D":
+    rows = physical_lines(data)
+    errors, payments, trailers = set(), [], []
+    if not rows or rows[0][:1] != b"H":
+        errors.add((1, "missing_header"))
+    if not rows or rows[-1][:1] != b"T":
+        errors.add((max(1, len(rows)), "missing_trailer"))
+    count = sum(row[:1] == b"D" for row in rows)
+    for line_no, raw in enumerate(rows, 1):
+        kind = raw[:1]
+        if ((kind == b"H" and line_no != 1) or (kind == b"T" and line_no != len(rows))
+                or (kind == b"D" and line_no in {1, len(rows)})):
+            errors.add((line_no, "record_order"))
+        text, local_error = decode(raw)
+        if local_error:
+            errors.add((line_no, local_error))
             continue
-        text, error = decode(raw)
-        if error is None:
-            value, error = fields(text)
-        if error:
-            errors.append(BatchError(line_no, error))
-        else:
+        value, field_error = fields(text)
+        if field_error:
+            errors.add((line_no, field_error))
+        elif text[0] == "D":
             payments.append(value)
-    return BatchResult(not errors, payments, errors)
+        elif text[0] == "T":
+            trailers.append((line_no, value))
+    for line_no, (declared_count, total, hash_total) in trailers:
+        if count == 0 or count != declared_count:
+            errors.add((line_no, "count_mismatch"))
+        if total != sum(p.amount_minor for p in payments):
+            errors.add((line_no, "control_total_mismatch"))
+    return BatchResult(not errors, [] if errors else payments, [BatchError(*e) for e in sorted(errors)])
--- starter/batch\records.py
+++ wrong_ignores_hash_total/batch\records.py
@@ -1,4 +1,6 @@
 """Fixed-width record decoding shared by offline batch utilities."""
+from datetime import datetime
+import re
 from batch.models import Payment
 
 CURRENCIES = {"CAD", "USD", "EUR", "GBP", "JPY", "KWD", "BHD"}
@@ -21,13 +23,26 @@
 
 
 def fields(text):
-    """Decode the columns of one 80-character record. Only detail records are interpreted so far."""
-    if text[0] == "D":
+    kind = text[0]
+    if kind == "H":
+        valid = bool(re.fullmatch(r"[A-Z0-9]{12}", text[1:13]))
+        try:
+            datetime.strptime(text[13:21], "%Y%m%d")
+            valid = valid and digits(text[13:21])
+        except ValueError:
+            valid = False
+        valid = valid and text[21:24] in CURRENCIES and text[24:] == " " * 56
+        return None, None if valid else "invalid_header"
+    if kind == "D":
         valid = digits(text[1:13]) and digits(text[13:25]) and text[45:] == " " * 35
         if not valid or int(text[13:25]) <= 0 or not text[25:45].strip():
             return None, "invalid_detail"
         return Payment(text[1:13], int(text[13:25]), text[25:45].rstrip(" ")), None
-    return None, None
+    if kind == "T":
+        if not (digits(text[1:7]) and digits(text[7:22]) and digits(text[22:32]) and text[32:] == " " * 48):
+            return None, "invalid_trailer"
+        return (int(text[1:7]), int(text[7:22]), int(text[22:32])), None
+    return None, "record_type"
 
 
 def physical_lines(data):
@@ -36,4 +51,5 @@
     rows = data.split(b"\n")
     if rows[-1] == b"":
         rows.pop()
-    return rows
+    return [row[:-1] if row.endswith(b"\r") and (i < len(rows) - 1 or data.endswith(b"\n")) else row
+            for i, row in enumerate(rows)]

wrong_partial_acceptwrong

Final grader✓ wrong_partial_accept: ok 38/39 tests passed
Failed tests
  • test_hidden_batch::test_atomic_batch_rejection declared must_fail
Verdict
✓ ok as expected
Expected
FAIL, observed FAIL
Duration
1.71 s
Hardening
caught by hardening
Pytest output (last 40 lines)
...F...................................                                  [100%]
================================== FAILURES ===================================
_________________________ test_atomic_batch_rejection _________________________

    def test_atomic_batch_rejection():
        payments = fixture() + [Payment("000000000456", 200, "second")]
        rows = render(payments).splitlines()
        rows[2] = rows[2][:1] + b"X" + rows[2][2:]
        result = parse(b"\n".join(rows))
>       assert not result.accepted and result.payments == []
E       AssertionError: assert (not True)
E        +  where True = BatchResult(accepted=True, payments=[Payment(payee_account='000000000123', amount_minor=100, reference='reference')], ...lid_detail'), BatchError(line_no=4, code='control_total_mismatch'), BatchError(line_no=4, code='hash_total_mismatch')]).accepted

..\grader\test_hidden_batch.py:53: AssertionError
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_dytqcx6a\junit.xml -
=========================== short test summary info ===========================
FAILED ..\grader\test_hidden_batch.py::test_atomic_batch_rejection - Assertio...
1 failed, 38 passed in 0.97s
Naive grader✗ wrong_partial_accept: false_pass 3/3 tests passed
Failed tests

All 3 hidden tests passed.

Verdict
✗ false_pass false pass
Expected
FAIL, observed PASS
Duration
0.76 s
Prediction
false_pass (matched)
Pytest output (last 40 lines)
...                                                                      [100%]
- generated xml file: C:\Users\User\AppData\Local\Temp\bcp\run_ssxjju5s\junit.xml -
3 passed in 0.03s
Diff against the starter
--- starter/batch\parser.py
+++ wrong_partial_accept/batch\parser.py
@@ -3,15 +3,36 @@
 
 
 def parse(data):
-    errors, payments = [], []
-    for line_no, raw in enumerate(physical_lines(data), 1):
-        if raw[:1] != b"D":
+    rows = physical_lines(data)
+    errors, payments, trailers = set(), [], []
+    if not rows or rows[0][:1] != b"H":
+        errors.add((1, "missing_header"))
+    if not rows or rows[-1][:1] != b"T":
+        errors.add((max(1, len(rows)), "missing_trailer"))
+    count = sum(row[:1] == b"D" for row in rows)
+    for line_no, raw in enumerate(rows, 1):
+        kind = raw[:1]
+        if ((kind == b"H" and line_no != 1) or (kind == b"T" and line_no != len(rows))
+                or (kind == b"D" and line_no in {1, len(rows)})):
+            errors.add((line_no, "record_order"))
+        text, local_error = decode(raw)
+        if local_error:
+            errors.add((line_no, local_error))
             continue
-        text, error = decode(raw)
-        if error is None:
-            value, error = fields(text)
-        if error:
-            errors.append(BatchError(line_no, error))
-        else:
+        value, field_error = fields(text)
+        if field_error:
+            errors.add((line_no, field_error))
+        elif text[0] == "D":
             payments.append(value)
-    return BatchResult(not errors, payments, errors)
+        elif text[0] == "T":
+            trailers.append((line_no, value))
+    for line_no, (declared_count, total, hash_total) in trailers:
+        if count == 0 or count != declared_count:
+            errors.add((line_no, "count_mismatch"))
+        if total != sum(p.amount_minor for p in payments):
+            errors.add((line_no, "control_total_mismatch"))
+        if hash_total != sum(int(p.payee_account[-8:]) for p in payments) % 10**10:
+            errors.add((line_no, "hash_total_mismatch"))
+    if payments and any(code == "invalid_detail" for _, code in errors):
+        return BatchResult(True, payments, [BatchError(*e) for e in sorted(errors)])
+    return BatchResult(not errors, [] if errors else payments, [BatchError(*e) for e in sorted(errors)])
--- starter/batch\records.py
+++ wrong_partial_accept/batch\records.py
@@ -1,4 +1,6 @@
 """Fixed-width record decoding shared by offline batch utilities."""
+from datetime import datetime
+import re
 from batch.models import Payment
 
 CURRENCIES = {"CAD", "USD", "EUR", "GBP", "JPY", "KWD", "BHD"}
@@ -21,13 +23,26 @@
 
 
 def fields(text):
-    """Decode the columns of one 80-character record. Only detail records are interpreted so far."""
-    if text[0] == "D":
+    kind = text[0]
+    if kind == "H":
+        valid = bool(re.fullmatch(r"[A-Z0-9]{12}", text[1:13]))
+        try:
+            datetime.strptime(text[13:21], "%Y%m%d")
+            valid = valid and digits(text[13:21])
+        except ValueError:
+            valid = False
+        valid = valid and text[21:24] in CURRENCIES and text[24:] == " " * 56
+        return None, None if valid else "invalid_header"
+    if kind == "D":
         valid = digits(text[1:13]) and digits(text[13:25]) and text[45:] == " " * 35
         if not valid or int(text[13:25]) <= 0 or not text[25:45].strip():
             return None, "invalid_detail"
         return Payment(text[1:13], int(text[13:25]), text[25:45].rstrip(" ")), None
-    return None, None
+    if kind == "T":
+        if not (digits(text[1:7]) and digits(text[7:22]) and digits(text[22:32]) and text[32:] == " " * 48):
+            return None, "invalid_trailer"
+        return (int(text[1:7]), int(text[7:22]), int(text[22:32])), None
+    return None, "record_type"
 
 
 def physical_lines(data):
@@ -36,4 +51,5 @@
     rows = data.split(b"\n")
     if rows[-1] == b"":
         rows.pop()
-    return rows
+    return [row[:-1] if row.endswith(b"\r") and (i < len(rows) - 1 or data.endswith(b"\n")) else row
+            for i, row in enumerate(rows)]