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.
Tasks
| Number | Area | Title | Summary |
|---|---|---|---|
| 01 | Payments API | Make payment creation idempotent | A payment endpoint must charge a card once per Idempotency-Key, no matter how the client retries. |
| 02 | Interest | Accrue 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. |
| 03 | FX transfers | Quote 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. |
| 04 | Reconciliation | Reconcile synthetic statements deterministically | Match bank statement lines to ledger entries with exact, split, refund and duplicate rules, deterministically. |
| 05 | Ledger | Enforce an atomic double-entry journal | Run a double-entry journal where every post balances per currency and is all-or-nothing. |
| 06 | Card holds | Track active authorization holds and available balance | Compute available balance as posted funds minus active card holds, with capture, void and expiry. |
| 07 | Statements | Select transactions by the account calendar month | Select a month's transactions using midnight in the account's own time zone, converted to UTC. |
| 08 | Refunds | Limit refunds to the captured amount | Allow partial refunds up to the captured amount, which may be less than what was authorized. |
| 09 | Fees | Apply a non-marginal tiered fee schedule | Price a payment at one tier selected by an inclusive upper bound, then round and clamp the fee. |
| 10 | Batch files | Validate an entire SBF-80 payment batch | Validate 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.
- ✓ as expected
- ✗ false pass, false fail or error
- ~ rejected, but not by a declared test
- Badge numbers: hidden tests passed / total (1/12 means 1 of 12 tests passed)
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 · solution | Naive | Final | Final tests that fail |
|---|---|---|---|
01_idempotent_paymentswrong_retry_racefinding | Naive✗accepted3/3 | Final✓rejected14/17 |
|
01_idempotent_paymentswrong_uncached_declinefinding | Naive✗accepted3/3 | Final✓rejected16/17 |
|
01_idempotent_paymentswrong_inmemory_cachefinding | Naive✗accepted3/3 | Final✓rejected15/17 |
|
02_interest_accrualstarterfinding | Naive✗accepted4/4 | Final✓rejected24/33 |
|
02_interest_accrualwrong_floatfinding | Naive✗accepted4/4 | Final✓rejected28/33 |
|
02_interest_accrualwrong_half_upfinding | Naive✗accepted4/4 | Final✓rejected28/33 |
|
02_interest_accrualwrong_actual_actualfinding | Naive✗accepted4/4 | Final✓rejected29/33 |
|
03_fx_quoteswrong_round_cross_legfinding | Naive✗accepted4/4 | Final✓rejected19/22 |
|
03_fx_quoteswrong_fee_after_conversionfinding | Naive✗accepted4/4 | Final✓rejected18/22 |
|
04_ledger_reconciliationwrong_no_consumptionfinding | Naive✗accepted3/3 | Final✓rejected14/18 |
|
04_ledger_reconciliationwrong_refund_absfinding | Naive✗accepted3/3 | Final✓rejected17/18 |
|
05_double_entrywrong_cross_currency_sumfinding | Naive✗accepted3/3 | Final✓rejected17/19 |
|
05_double_entrywrong_non_atomicfinding | Naive✗accepted3/3 | Final✓rejected14/19 |
|
05_double_entrywrong_double_reversefinding | Naive✗accepted3/3 | Final✓rejected16/19 |
|
06_available_balancewrong_expired_holds_countfinding | Naive✗accepted3/3 | Final✓rejected11/12 |
|
07_statement_periodswrong_fixed_offsetfinding | Naive✗accepted3/3 | Final✓rejected16/19 |
|
09_tiered_feeswrong_exclusive_boundaryfinding | Naive✗accepted3/3 | Final✓rejected18/19 |
|
09_tiered_feeswrong_float_bpsfinding | Naive✗accepted3/3 | Final✓rejected16/19 |
|
10_batch_parserwrong_partial_acceptfinding | Naive✗accepted3/3 | Final✓rejected38/39 |
|
10_batch_parserwrong_ignores_hash_totalfinding | Naive✗accepted3/3 | Final✓rejected31/39 |
|
10_batch_parserwrong_crlf_slicingfinding | Naive✗accepted3/3 | Final✓rejected34/39 |
|
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%).
| Task | Wrong rejected | Correct accepted |
|---|---|---|
01_idempotent_payments | 5 of 5 | 1 of 1 |
02_interest_accrual | 5 of 5 | 0 of 0 |
03_fx_quotes | 4 of 4 | 0 of 0 |
04_ledger_reconciliation | 7 of 7 | 1 of 1 |
05_double_entry | 6 of 6 | 1 of 1 |
06_available_balance | 4 of 4 | 1 of 1 |
07_statement_periods | 3 of 3 | 0 of 0 |
08_refund_limits | 1 of 1 | 1 of 1 |
09_tiered_fees | 2 of 2 | 0 of 0 |
10_batch_parser | 9 of 9 | 1 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_retrytest_hidden_payments::test_concurrent_duplicates_charge_oncetest_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_replayedtest_hidden_payments::test_body_mismatch_while_in_flighttest_hidden_payments::test_replay_survives_app_restarttest_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.97sNaive grader✓ starter: ok 1/3 tests passed
Failed tests
test_hidden::test_retrytest_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.95sreferencereference
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.29sNaive 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.64sDiff against the starter
@@ -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 @@ -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.91sNaive 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.72sDiff against the starter
@@ -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_restartdeclared must_failtest_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.35sNaive 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.71sDiff against the starter
@@ -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 @@ -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_oncedeclared must_failtest_hidden_payments::test_body_mismatch_while_in_flighttest_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.59sNaive 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.69sDiff against the starter
@@ -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 @@ -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_replayeddeclared 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.33sNaive 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.58sDiff against the starter
@@ -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_partialtest_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.23sNaive 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
@@ -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
@@ -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_partialdeclared must_failtest_hidden_interest::test_accrual_propertiesdeclared 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.10sNaive 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
@@ -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.31sNaive 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
@@ -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.33sNaive 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
@@ -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_unitstest_hidden_fx::test_cross_has_no_intermediate_roundingtest_hidden_fx::test_source_fee_before_conversiontest_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.25sNaive grader✓ starter: ok 1/4 tests passed
Failed tests
test_hidden::test_direct_cad_usdtest_hidden::test_ticket_example_cross_quotetest_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.05sreferencereference
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
@@ -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
@@ -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_unitstest_hidden_fx::test_source_fee_before_conversiondeclared must_failtest_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.24sNaive 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
@@ -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_roundingdeclared must_failtest_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.35sNaive 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
@@ -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_unitsdeclared must_failtest_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.23sNaive 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.05sDiff against the starter
@@ -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_unmatchedtest_hidden_recon::test_split_all_linestest_hidden_recon::test_refund_kind_and_original_referencetest_hidden_recon::test_tie_breaks_and_windowtest_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.14sNaive 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.05sreferencereference
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
@@ -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
@@ -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_unmatcheddeclared must_failtest_hidden_recon::test_ordering_and_candidate_rules[r1-statement-order]test_hidden_recon::test_duplicate_signature_includes_datetest_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.10sNaive 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
@@ -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_referencedeclared 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.33sNaive 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
@@ -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_linesdeclared must_failtest_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.17sNaive 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.05sDiff against the starter
@@ -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_nettest_hidden_ledger::test_unknown_account_is_atomictest_hidden_ledger::test_reverse_oncetest_hidden_ledger::test_rejections_leave_no_tracetest_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.58sNaive grader✓ starter: ok 1/3 tests passed
Failed tests
test_hidden::test_unbalanced_rejectedtest_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.46sreferencereference
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
@@ -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), @@ -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
@@ -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_netdeclared 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.75sNaive 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
@@ -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), @@ -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_oncedeclared must_failtest_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.18sNaive 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
@@ -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), @@ -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_nettest_hidden_ledger::test_unknown_account_is_atomicdeclared must_failtest_hidden_ledger::test_rejections_leave_no_tracetest_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.56sNaive 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
@@ -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), @@ -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_availabletest_hidden_holds::test_partial_capture_releases_remaindertest_hidden_holds::test_expiry_exactly_seven_daystest_hidden_holds::test_void_overcapture_and_persistencetest_hidden_holds::test_overdraft_boundarytest_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_idstest_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.63sNaive grader✓ starter: ok 1/3 tests passed
Failed tests
test_hidden::test_full_capturetest_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.42sreferencereference
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.36sNaive 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.38sDiff against the starter
@@ -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 @@ -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.24sNaive 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.96sDiff against the starter
@@ -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_availabledeclared must_failtest_hidden_holds::test_expiry_exactly_seven_daystest_hidden_holds::test_void_overcapture_and_persistencetest_hidden_holds::test_overdraft_boundarytest_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.10sNaive 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.82sDiff against the starter
@@ -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 @@ -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_daysdeclared 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.04sNaive 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.85sDiff against the starter
@@ -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 @@ -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_remainderdeclared must_failtest_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.64sNaive 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.79sDiff against the starter
@@ -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 @@ -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_ordertest_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.63sNaive 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.10sreferencereference
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
@@ -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
@@ -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.62sNaive 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
@@ -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.59sNaive 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.13sDiff against the starter
@@ -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.59sNaive 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.11sDiff against the starter
@@ -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_partialstest_hidden_refunds::test_capture_less_than_authorizationtest_hidden_refunds::test_valid_after_rejectiontest_hidden_refunds::test_unknown_payment_id_raises_value_errortest_hidden_refunds::test_capture_once_and_within_authorizationtest_hidden_refunds::test_concurrent_refunds_respect_budgettest_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.06sNaive grader✓ starter: ok 2/4 tests passed
Failed tests
test_hidden::test_over_refund_rejectedtest_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.56sreferencereference
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
@@ -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 @@ -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
@@ -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_partialsdeclared must_failtest_hidden_refunds::test_capture_once_and_within_authorizationtest_hidden_refunds::test_concurrent_refunds_respect_budgettest_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.45sNaive 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.52sDiff against the starter
@@ -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 @@ -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_partialstest_hidden_refunds::test_capture_less_than_authorizationtest_hidden_refunds::test_valid_after_rejectiondeclared must_failtest_hidden_refunds::test_concurrent_refunds_respect_budgettest_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.82sNaive 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.10sDiff against the starter
@@ -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 @@ -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_authorizationdeclared must_failtest_hidden_refunds::test_capture_once_and_within_authorizationtest_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.40sNaive 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.47sDiff against the starter
@@ -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 @@ -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_boundariestest_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_marginaltest_hidden_fees::test_clamps_and_zerotest_hidden_fees::test_fee_propertiestest_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_mutationtest_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.38sNaive grader✓ starter: ok 0/3 tests passed
Failed tests
test_hidden::test_tier_midpointstest_hidden::test_ticket_worked_example_with_clampstest_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.11sreferencereference
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
@@ -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
@@ -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_boundariesdeclared 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.46sNaive 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
@@ -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.59sNaive 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
@@ -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_boundariestest_hidden_fees::test_non_marginaldeclared 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.55sNaive grader~ wrong_marginal: ok_wrong_reason 1/3 tests passed
Failed tests
test_hidden::test_tier_midpointstest_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.22sDiff against the starter
@@ -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_crlftest_hidden_batch::test_hash_totaltest_hidden_batch::test_count_mismatchtest_hidden_batch::test_atomic_batch_rejectiontest_hidden_batch::test_multiple_errors_and_line_numberstest_hidden_batch::test_invalid_records[<lambda>-invalid_encoding]test_hidden_batch::test_invalid_records[<lambda>-record_type]test_hidden_batch::test_missing_and_ordertest_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.09sreferencereference
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
@@ -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)]) @@ -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
@@ -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, []) @@ -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_crlfdeclared must_failtest_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.59sNaive 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
@@ -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)]) @@ -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_totaldeclared must_failtest_hidden_batch::test_atomic_batch_rejectiontest_hidden_batch::test_multiple_errors_and_line_numberstest_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.99sNaive 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
@@ -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)]) @@ -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_rejectiondeclared 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.97sNaive 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
@@ -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)]) @@ -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)]