{"module": "invoice", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.017304399999999998, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0185064, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0165819, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.05308050000000001, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0562785, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0222841, "parse_ok": true, "findings": [{"title": "Unit price and tax_rate are not validated (negative, NaN, Infinity)", "severity": "medium", "detail": "Decimal(unit_price) accepts negative values, 'NaN', 'Infinity' and other special strings. A negative price reduces the subtotal and can produce negative totals. NaN/Infinity propagate and make _round raise decimal.InvalidOperation, which is not the documented ValueError. The same applies to tax_rate (negative or >1 is accepted silently). Validate that prices are finite and non-negative, and that tax_rate is finite and non-negative."}, {"title": "Malformed unit_price raises decimal.InvalidOperation instead of ValueError", "severity": "medium", "detail": "A string like 'abc' or '' (or None) causes Decimal() to raise InvalidOperation (or TypeError), not the ValueError the docstring promises for invalid input. Callers catching ValueError will miss it. Wrap the conversion and re-raise as ValueError including the item name."}, {"title": "Subtotal rounded only after summation; line totals are not rounded", "severity": "low", "detail": "The spec says subtotal is the sum of line totals, but line totals are left unrounded (e.g. a price with 3 decimals like '0.005' times a quantity). Rounding once at the end can differ by a cent from the sum of per-line rounded amounts that would appear on the printed invoice. Clarify the requirement and round each line if invoices display line totals. Also, prices with more than 2 decimals are accepted without complaint."}, {"title": "Total is not explicitly rounded/quantized", "severity": "low", "detail": "total = subtotal - discount + tax is exact because each operand has 2 decimals, so it is fine today, but FLAT5 returns Decimal('5.00') or the unquantized subtotal, and the result relies on all operands being already quantized. A final _round(total) would make the invariant explicit and robust to future changes."}, {"title": "Unknown discount codes are silently ignored", "severity": "low", "detail": "A typo such as 'save10' or ' SAVE10' yields no discount with no signal to the caller. This matches the stated rule, but it is easy to miss in practice; consider logging or returning whether a code was applied. Matching is also case-sensitive and does not strip whitespace."}, {"title": "Item shape is not validated", "severity": "low", "detail": "Items that are not 3-tuples raise an unpacking ValueError/TypeError with an unhelpful message, and error messages for invalid quantity use the name only (not the offending value). Validation and error messages could be clearer."}, {"title": "Magic numbers and string discount codes hard-coded", "severity": "low", "detail": "Discount rates, the flat amount, and codes are embedded in the function body, and tax_rate is a string default. A lookup table or constants would make rules easier to maintain and test."}], "review_text": "", "fix_cost": 0.0291641, "fix_ok": true, "step_changed": 65, "code_after": "import logging\nfrom decimal import Decimal, InvalidOperation, ROUND_HALF_UP\n\nlogger = logging.getLogger(__name__)\n\nCENT = Decimal(\"0.01\")\n\nPERCENT_DISCOUNTS = {\n    \"SAVE10\": Decimal(\"0.10\"),\n}\nFLAT_DISCOUNTS = {\n    \"FLAT5\": Decimal(\"5.00\"),\n}\nDEFAULT_TAX_RATE = \"0.10\"\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value, label):\n    \"\"\"Convert value to a finite, non-negative Decimal or raise ValueError.\"\"\"\n    if isinstance(value, bool) or not isinstance(value, (str, int, Decimal)):\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    try:\n        result = Decimal(value)\n    except (InvalidOperation, ValueError, TypeError):\n        raise ValueError(f\"invalid {label}: {value!r}\") from None\n    if not result.is_finite() or result < 0:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    return result\n\n\ndef invoice_total(items, discount_code=None, tax_rate=DEFAULT_TAX_RATE):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n      (unknown codes are logged as a warning)\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError\n    - each item must be a (name, unit_price, quantity) 3-tuple, otherwise ValueError\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    rate = _to_decimal(tax_rate, \"tax_rate\")\n\n    subtotal = Decimal(\"0.00\")\n    for item in items:\n        try:\n            name, unit_price, quantity = item\n        except (TypeError, ValueError):\n            raise ValueError(\n                f\"invalid item, expected (name, unit_price, quantity): {item!r}\"\n            ) from None\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}: {quantity!r}\")\n        price = _to_decimal(unit_price, f\"unit_price for {name}\")\n        subtotal += _round(price * quantity)\n    subtotal = _round(subtotal)\n\n    if discount_code in PERCENT_DISCOUNTS:\n        discount = _round(subtotal * PERCENT_DISCOUNTS[discount_code])\n    elif discount_code in FLAT_DISCOUNTS:\n        discount = min(FLAT_DISCOUNTS[discount_code], subtotal)\n    else:\n        discount = Decimal(\"0.00\")\n        if discount_code is not None:\n            logger.warning(\"unknown discount code ignored: %r\", discount_code)\n    discount = _round(discount)\n\n    tax = _round((subtotal - discount) * rate)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 83, "cum_changed": 65, "review_cost": 0.0393096, "parse_ok": true, "findings": [{"title": "Very large or extreme-exponent values raise decimal.InvalidOperation instead of ValueError", "severity": "medium", "detail": "_to_decimal only checks is_finite() and >= 0. A value like \"1E30\" or \"1E999999999\" passes validation, but `_round` then calls quantize() on a result with more digits than the default context precision of 28. That raises decimal.InvalidOperation (or Overflow), which is not a ValueError and breaks the documented contract. Add an upper bound or an exponent/digit check in _to_decimal, or catch decimal.DecimalException around the arithmetic and re-raise ValueError."}, {"title": "Arithmetic relies on the global decimal context, so inputs with many digits are silently rounded twice", "severity": "medium", "detail": "`price * quantity` and `(subtotal - discount) * rate` are computed under the ambient context (28-digit precision, ROUND_HALF_EVEN). If a price or rate has more than 28 significant digits, or the quantity is huge, the product is rounded by the context before `_round` applies half-up. This is double rounding and can give a different cent than the spec. Any other code that changes the thread's decimal context also changes these results. Use a local context with sufficient precision (`decimal.localcontext()`) or limit input precision."}, {"title": "Unhashable discount_code raises TypeError", "severity": "low", "detail": "`discount_code in PERCENT_DISCOUNTS` hashes the value. A list or dict code raises TypeError rather than being treated as an unknown code (warning, no discount) or a ValueError. Check `isinstance(discount_code, str)` first."}, {"title": "Negative zero is accepted and can produce \"-0.00\" outputs", "severity": "low", "detail": "Decimal(\"-0\") is not < 0, so it passes validation. A tax_rate of \"-0\" can yield tax == Decimal('-0.00'), which displays as a negative zero and compares oddly in serialization. Normalize with `result = result + 0` or `abs(result)` when result is zero."}, {"title": "Decimal parsing is more permissive than intended", "severity": "low", "detail": "Decimal(str) accepts surrounding whitespace, underscores (\"1_0\"), and exponent notation (\"1e2\"). Callers may expect strict plain-decimal strings like \"19.99\". Floats are rejected, which matches the spec, but the error message does not explain why. Consider a regex check if strictness matters."}, {"title": "Item shape validation is loose", "severity": "low", "detail": "Any 3-element iterable unpacks successfully, including a 3-key dict (its keys become name, unit_price, quantity) or a 3-character string. The spec says each item must be a 3-tuple. Check `isinstance(item, tuple) and len(item) == 3`."}, {"title": "Discount codes are case- and whitespace-sensitive", "severity": "low", "detail": "\"save10\" or \" SAVE10\" is treated as unknown and silently gives no discount, with only a warning logged. This may surprise users. Either document the behavior or normalize the code."}, {"title": "Redundant rounding and mutable module-level tables", "severity": "low", "detail": "`discount = _round(discount)` is redundant, since both branches already produce 2-decimal values (and `min` of already-quantized values). PERCENT_DISCOUNTS and FLAT_DISCOUNTS are mutable module-level dicts that can be altered at runtime. Consider types.MappingProxyType. DEFAULT_TAX_RATE is a string constant that is re-parsed on every call. This is minor."}], "review_text": "", "fix_cost": 0.05435, "fix_ok": true, "step_changed": 118, "code_after": "import logging\nimport re\nfrom decimal import (\n    Decimal,\n    DecimalException,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom types import MappingProxyType\n\nlogger = logging.getLogger(__name__)\n\nCENT = Decimal(\"0.01\")\n\nPERCENT_DISCOUNTS = MappingProxyType({\n    \"SAVE10\": Decimal(\"0.10\"),\n})\nFLAT_DISCOUNTS = MappingProxyType({\n    \"FLAT5\": Decimal(\"5.00\"),\n})\nDEFAULT_TAX_RATE = \"0.10\"\n\n# Input limits keep every calculation exact under the local context below.\nMAX_VALUE = Decimal(\"1E15\")\nMAX_DIGITS = 40\nMAX_SCALE = 40\nMAX_QUANTITY = 10 ** 9\n_CONTEXT_PRECISION = 120\n\n_PLAIN_DECIMAL = re.compile(r\"[0-9]+(?:\\.[0-9]+)?\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value, label):\n    \"\"\"Convert value to a finite, non-negative, bounded Decimal or raise ValueError.\n\n    Accepts plain decimal strings like \"19.99\", ints and Decimals. Floats are\n    rejected because they cannot represent most decimal amounts exactly.\n    \"\"\"\n    if isinstance(value, bool) or not isinstance(value, (str, int, Decimal)):\n        raise ValueError(\n            f\"invalid {label}: {value!r} (expected a plain decimal string, int or Decimal; \"\n            \"floats are not accepted because they are inexact)\"\n        )\n    if isinstance(value, str) and not _PLAIN_DECIMAL.fullmatch(value):\n        raise ValueError(f\"invalid {label}: {value!r} (expected a plain decimal like '19.99')\")\n    try:\n        result = Decimal(value)\n    except (InvalidOperation, ValueError, TypeError):\n        raise ValueError(f\"invalid {label}: {value!r}\") from None\n    if not result.is_finite() or result < 0:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if result > MAX_VALUE:\n        raise ValueError(f\"invalid {label}: {value!r} (too large)\")\n    sign, digits, exponent = result.as_tuple()\n    if len(digits) > MAX_DIGITS or exponent < -MAX_SCALE:\n        raise ValueError(f\"invalid {label}: {value!r} (too much precision)\")\n    if result == 0:\n        return Decimal(0)  # normalize negative zero\n    return result\n\n\ndef invoice_total(items, discount_code=None, tax_rate=DEFAULT_TAX_RATE):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n      (unknown codes are logged as a warning). String codes are matched after\n      stripping surrounding whitespace and upper-casing, so \" save10\" is SAVE10.\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be plain decimal strings, ints or Decimals that are\n      finite, non-negative and within the supported size, otherwise ValueError\n    - each item must be a (name, unit_price, quantity) 3-tuple, otherwise ValueError\n    - quantity must be a positive int (at most 10**9), otherwise ValueError;\n      empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        # A private context avoids the ambient one and prevents double rounding.\n        with localcontext() as ctx:\n            ctx.prec = _CONTEXT_PRECISION\n            ctx.rounding = ROUND_HALF_UP\n\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n\n            subtotal = Decimal(\"0.00\")\n            for item in items:\n                if not isinstance(item, tuple) or len(item) != 3:\n                    raise ValueError(\n                        f\"invalid item, expected (name, unit_price, quantity) tuple: {item!r}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(f\"invalid quantity for {name}: {quantity!r}\")\n                price = _to_decimal(unit_price, f\"unit_price for {name}\")\n                subtotal += _round(price * quantity)\n            subtotal = _round(subtotal)\n\n            code = discount_code.strip().upper() if isinstance(discount_code, str) else None\n            if code in PERCENT_DISCOUNTS:\n                discount = _round(subtotal * PERCENT_DISCOUNTS[code])\n            elif code in FLAT_DISCOUNTS:\n                discount = min(FLAT_DISCOUNTS[code], subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n                if discount_code is not None:\n                    logger.warning(\"unknown discount code ignored: %r\", discount_code)\n\n            tax = _round((subtotal - discount) * rate)\n            total = _round(subtotal - discount + tax)\n    except DecimalException as exc:\n        raise ValueError(f\"invoice calculation failed: {exc}\") from None\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 17, "tests_failed": 0, "code_lines": 131, "cum_changed": 123, "review_cost": 0.040795399999999996, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0794535, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0253505, "parse_ok": true, "findings": [{"title": "Per-key state is never evicted, so memory grows without bound across keys", "severity": "high", "detail": "Pruning only happens inside allow() for the key being queried. A key that is seen once and never again keeps its timestamps in `_hits[key]` forever. `_last_seen[key]` is never removed either, and `self._hits[key]` on the defaultdict creates an entry for every key, even when the request is denied. With many distinct keys (per-IP or per-user), both dicts leak. This violates the requirement that timestamps that can no longer matter must not accumulate forever. A fix is to drop a key's entries once its deque is empty or its newest hit has expired. That needs periodic or amortized cleanup, for example a sweep every N calls or a heap or ordered structure keyed by expiry. Keep in mind that `_last_seen` is needed for the backwards-time check, so it needs a retention policy too."}, {"title": "Invalid or non-finite `now` values corrupt state and can disable the monotonic check", "severity": "low", "detail": "`_last_seen[key]` is written before `now` is used in arithmetic. If `now` is None or a non-numeric type, `now - self.window` raises TypeError after the state has already been modified. A None stored in `_last_seen` is then treated as 'never seen'. If `now` is NaN, `now < last` is always False, so NaN gets stored. Every later comparison against it is also False, which silently disables the backwards-time check for that key. Validate `now` (numeric, finite) first, and update `_last_seen` only after the checks succeed."}, {"title": "bool is accepted as a valid limit or window_seconds", "severity": "low", "detail": "`isinstance(True, int)` is True, so `SlidingWindowLimiter(True, True)` passes validation and behaves as limit=1 and window=1. The spec asks for positive ints, and a bool is almost certainly a caller bug. Reject bool explicitly."}, {"title": "Not thread-safe", "severity": "low", "detail": "allow() does a read-modify-write on shared dicts and deques with no lock. Concurrent callers on the same key can both pass the `len(hits) >= limit` check and exceed the limit, or race on `_last_seen`. This matters if the limiter is shared across threads. It should at least be documented, or guarded with a lock."}], "review_text": "", "fix_cost": 0.035775299999999996, "fix_ok": true, "step_changed": 74, "code_after": "import math\nimport threading\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite int or float (not bool), otherwise TypeError or\n      ValueError; invalid values never modify state.\n    - Per-key state is swept periodically (amortized O(1) per call). A key\n      whose latest `now` is at least window_seconds older than the `now` of a\n      sweeping call is forgotten, so the backwards-time check is only\n      guaranteed while the key is retained.\n    - Thread-safe: allow() is guarded by an internal lock.\n    \"\"\"\n\n    _MIN_SWEEP_INTERVAL = 64\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._lock = threading.Lock()\n        self._calls_since_sweep = 0\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, (int, float)):\n            raise TypeError(\"now must be a finite number\")\n        if not math.isfinite(now):\n            raise ValueError(\"now must be finite\")\n\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            cutoff = now - self.window\n            hits = self._hits.get(key)\n            if hits is not None:\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n\n            if hits is not None and len(hits) >= self.limit:\n                allowed = False\n            else:\n                if hits is None:\n                    hits = self._hits[key] = _deque()\n                hits.append(now)\n                allowed = True\n\n            self._last_seen[key] = now\n            self._maybe_sweep(cutoff)\n            return allowed\n\n    def _maybe_sweep(self, cutoff):\n        self._calls_since_sweep += 1\n        if self._calls_since_sweep < max(self._MIN_SWEEP_INTERVAL, len(self._last_seen)):\n            return\n        self._calls_since_sweep = 0\n        for key in [k for k, seen in self._last_seen.items() if seen <= cutoff]:\n            del self._last_seen[key]\n            self._hits.pop(key, None)\n\n\nfrom collections import deque as _deque\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 87, "cum_changed": 74, "review_cost": 0.0351579, "parse_ok": true, "findings": [{"title": "Sweep discards state that can still matter for valid calls, so the limit can be exceeded", "severity": "medium", "detail": "Time is only required to be monotonic per key, but `_maybe_sweep` uses the cutoff from whichever key triggered the sweep. A key is deleted when `seen <= now_sweeper - window`. Another key may then legitimately call with an earlier `now` (its own last seen time <= that `now` < `now_sweeper`), and its dropped hits would still fall inside its window. Example: limit=1, window=10. `allow('A', 0)` is True. A sweeping call on key B at t=10 deletes A. `allow('A', 5)` then returns True, though it should be False because the request at t=0 lies in (-5, 5]. The class notes only mention the backwards-time check being unreliable, not that the limit itself is silently violated. Fix by sweeping against a conservative bound, such as the minimum `now` any key could still use. Alternatively, track a global high-water mark and reject or clamp calls earlier than it. Or document and enforce a globally monotonic `now`."}, {"title": "Very large ints raise OverflowError instead of the documented TypeError/ValueError", "severity": "low", "detail": "`math.isfinite(10**400)` raises OverflowError because it converts the int to float. The docs say invalid `now` values raise TypeError or ValueError. The exception is still raised before any state change, but it is the wrong type. Handle ints separately (skip the isfinite check for `int`) or catch OverflowError and re-raise it as ValueError."}, {"title": "`deque` is imported at the bottom of the module under a private alias", "severity": "low", "detail": "`from collections import deque as _deque` sits after the class. It works because the name is only resolved at call time, but it is unconventional and fragile, and it hurts readability and tooling. Import `deque` at the top with the other imports."}], "review_text": "", "fix_cost": 0.0360086, "fix_ok": true, "step_changed": 24, "code_after": "import math\nimport threading\nfrom collections import deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent in their counts\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - `now` must also not be earlier than the latest `now` seen across all keys\n      (time is globally monotonic), otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite int or float (not bool), otherwise TypeError or\n      ValueError; invalid values never modify state.\n    - Per-key state is swept periodically (amortized O(1) per call). Because\n      `now` is globally monotonic, a key whose latest `now` is at least\n      window_seconds older than the global latest `now` can never have a hit\n      that counts again, so forgetting it does not change any result.\n    - Thread-safe: allow() is guarded by an internal lock.\n    \"\"\"\n\n    _MIN_SWEEP_INTERVAL = 64\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._lock = threading.Lock()\n        self._calls_since_sweep = 0\n        self._high_water = None\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, (int, float)):\n            raise TypeError(\"now must be a finite number\")\n        if isinstance(now, float) and not math.isfinite(now):\n            raise ValueError(\"now must be finite\")\n\n        with self._lock:\n            if self._high_water is not None and now < self._high_water:\n                raise ValueError(\"time went backwards\")\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            cutoff = now - self.window\n            hits = self._hits.get(key)\n            if hits is not None:\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n\n            if hits is not None and len(hits) >= self.limit:\n                allowed = False\n            else:\n                if hits is None:\n                    hits = self._hits[key] = deque()\n                hits.append(now)\n                allowed = True\n\n            self._last_seen[key] = now\n            self._high_water = now\n            self._maybe_sweep(cutoff)\n            return allowed\n\n    def _maybe_sweep(self, cutoff):\n        self._calls_since_sweep += 1\n        if self._calls_since_sweep < max(self._MIN_SWEEP_INTERVAL, len(self._last_seen)):\n            return\n        self._calls_since_sweep = 0\n        for key in [k for k, seen in self._last_seen.items() if seen <= cutoff]:\n            del self._last_seen[key]\n            self._hits.pop(key, None)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 0, "round": 2, "tests_passed": 14, "tests_failed": 1, "code_lines": 91, "cum_changed": 80, "review_cost": 0.0295091, "parse_ok": true, "findings": [{"title": "Float precision loss makes the window comparison unreliable for large timestamps", "severity": "low", "detail": "cutoff = now - window is computed in floating point. When now is large (for example 1e17 with window 1), now - window == now, so hits recorded at the current instant satisfy hits[0] <= cutoff and are evicted. The limiter then allows more than `limit` requests in the window, which breaks the half-open window rule. _maybe_sweep has the same flaw: it can delete the key that was just updated, because its seen == cutoff. This only matters for extreme float magnitudes, so it is an edge case rather than a normal-use bug. A fix would be to compare `now - hit >= window` instead of precomputing a cutoff, or to document the limitation."}], "review_text": "", "fix_cost": 0.0293053, "fix_ok": true, "step_changed": 12, "code_after": "import math\nimport threading\nfrom collections import deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent in their counts\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - `now` must also not be earlier than the latest `now` seen across all keys\n      (time is globally monotonic), otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite int or float (not bool), otherwise TypeError or\n      ValueError; invalid values never modify state.\n    - Per-key state is swept periodically (amortized O(1) per call). Because\n      `now` is globally monotonic, a key whose latest `now` is at least\n      window_seconds older than the global latest `now` can never have a hit\n      that counts again, so forgetting it does not change any result.\n    - Expiry is decided by comparing `now - hit >= window` directly rather than\n      against a precomputed `now - window` cutoff, so large float magnitudes\n      cannot cause a current hit to be evicted by rounding.\n    - Thread-safe: allow() is guarded by an internal lock.\n    \"\"\"\n\n    _MIN_SWEEP_INTERVAL = 64\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._lock = threading.Lock()\n        self._calls_since_sweep = 0\n        self._high_water = None\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, (int, float)):\n            raise TypeError(\"now must be a finite number\")\n        if isinstance(now, float) and not math.isfinite(now):\n            raise ValueError(\"now must be finite\")\n\n        with self._lock:\n            if self._high_water is not None and now < self._high_water:\n                raise ValueError(\"time went backwards\")\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            hits = self._hits.get(key)\n            if hits is not None:\n                while hits and now - hits[0] >= self.window:\n                    hits.popleft()\n\n            if hits is not None and len(hits) >= self.limit:\n                allowed = False\n            else:\n                if hits is None:\n                    hits = self._hits[key] = deque()\n                hits.append(now)\n                allowed = True\n\n            self._last_seen[key] = now\n            self._high_water = now\n            self._maybe_sweep(now)\n            return allowed\n\n    def _maybe_sweep(self, now):\n        self._calls_since_sweep += 1\n        if self._calls_since_sweep < max(self._MIN_SWEEP_INTERVAL, len(self._last_seen)):\n            return\n        self._calls_since_sweep = 0\n        for key in [k for k, seen in self._last_seen.items() if now - seen >= self.window]:\n            del self._last_seen[key]\n            self._hits.pop(key, None)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 0, "round": 3, "tests_passed": 14, "tests_failed": 1, "code_lines": 93, "cum_changed": 82, "review_cost": 0.032439, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.015958299999999998, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0155083, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0156863, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0894702, "parse_ok": true, "findings": [{"title": "Unit price not validated as positive", "severity": "medium", "detail": "The code doesn't validate that `unit_price` is a positive number. Negative or zero prices would result in negative line totals and an incorrect subtotal. This is likely a mistake and should be prevented with validation."}, {"title": "Tax rate not validated as non-negative", "severity": "medium", "detail": "The code doesn't validate that `tax_rate` is non-negative. A negative tax rate would produce negative tax, which is clearly incorrect for an invoice. Consider adding validation."}, {"title": "FLAT5 discount not explicitly rounded per documented algorithm", "severity": "low", "detail": "The FLAT5 discount calculation uses `min(Decimal(\"5.00\"), subtotal)` without explicitly calling `_round()`, whereas SAVE10 does call `_round()`. The docstring states 'discount ... are each rounded to 2 decimals'. While the result is correct in practice (since subtotal is pre-rounded), the inconsistency violates the documented algorithm and could cause confusion or future bugs if code is modified. Wrap the min() call with `_round()` for consistency."}], "review_text": "", "fix_cost": 0.0241477, "fix_ok": true, "step_changed": 12, "code_after": "from decimal import Decimal, ROUND_HALF_UP\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        unit_price_decimal = Decimal(unit_price)\n        if unit_price_decimal <= 0:\n            raise ValueError(f\"invalid unit_price for {name}\")\n        subtotal += unit_price_decimal * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax_rate_decimal = Decimal(tax_rate)\n    if tax_rate_decimal < 0:\n        raise ValueError(\"tax_rate must be non-negative\")\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = subtotal - discount + tax\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 50, "cum_changed": 12, "review_cost": 0.0777422, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0249351, "parse_ok": true, "findings": [{"title": "Line totals are summed unrounded; rounding only happens on the aggregate", "severity": "medium", "detail": "Per-line totals (unit_price * quantity) are not rounded, and unit_price strings with more than 2 decimals (e.g. \"0.005\") are accepted silently. The subtotal is rounded once at the end, which can differ from the sum of the rounded lines an invoice would display. The spec says 'subtotal = sum of line totals' and only the subtotal is rounded, so this matches the spec literally, but prices with sub-cent precision should be validated or rounded per line to avoid invoices that don't add up."}, {"title": "No validation of unit_price (negative, zero, NaN, Infinity, malformed)", "severity": "medium", "detail": "Decimal(unit_price) accepts negative values, 'NaN', 'Infinity' and 'sNaN'. Negative prices can produce a negative subtotal, making the total negative, and with FLAT5 min(5.00, subtotal) yields a negative discount that increases the total. NaN/Infinity cause InvalidOperation or nonsensical results in quantize. A malformed string raises decimal.InvalidOperation rather than the documented ValueError (InvalidOperation is not a ValueError subclass), and a float or None price raises TypeError or imprecise results."}, {"title": "tax_rate is not validated", "severity": "medium", "detail": "Decimal(tax_rate) accepts negative rates, NaN/Infinity, and malformed strings (raising InvalidOperation instead of ValueError). A float tax_rate would also be converted with binary error or raise, depending on how it is passed (Decimal(0.1) gives a long inexact value). The rate should be validated as finite and non-negative, and ideally constructed via str()."}, {"title": "Discount codes are case- and whitespace-sensitive and unknown codes are silently ignored", "severity": "low", "detail": "'save10' or ' SAVE10' gives no discount with no feedback. The spec says unknown codes mean no discount, so this is by design, but a typo silently costs the customer the discount; consider normalizing or reporting unrecognized codes."}, {"title": "Malformed item tuples and non-string names produce unhelpful errors", "severity": "low", "detail": "Items that are not 3-tuples raise a bare unpacking ValueError/TypeError with no context. A non-iterable `items` that is truthy raises TypeError. Generators are consumed once and a generator is always truthy, so an empty generator bypasses the empty check and yields a zero invoice instead of ValueError."}, {"title": "Return values are not explicitly quantized in the total, and discount in FLAT5 branch may carry unrounded scale", "severity": "low", "detail": "total = subtotal - discount + tax is exact because all three are 2-decimal, so the value is fine, but discount for FLAT5 is min(Decimal('5.00'), subtotal), which is fine only because subtotal was already rounded. If the code order changes this will silently break; applying _round to discount in all branches would make the invariant explicit."}, {"title": "Missing tests and tax_rate default as a string is an unusual API", "severity": "low", "detail": "Money inputs are strings for prices and tax_rate but quantity is an int, with no type hints or tests documenting edge cases (rounding half-up boundaries, FLAT5 greater than subtotal, SAVE10 rounding). Adding type hints and a unit test suite would make the contract clearer."}], "review_text": "", "fix_cost": 0.0368253, "fix_ok": true, "step_changed": 80, "code_after": "from decimal import Decimal, InvalidOperation, ROUND_HALF_UP\nfrom typing import Any, Iterable, Optional\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert str/int/Decimal/float to a finite Decimal, or raise ValueError.\"\"\"\n    if isinstance(value, bool) or value is None:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if isinstance(value, Decimal):\n        result = value\n    elif isinstance(value, (str, int, float)):\n        try:\n            result = Decimal(str(value).strip())\n        except InvalidOperation:\n            raise ValueError(f\"invalid {label}: {value!r}\") from None\n    else:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if not result.is_finite():\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple],\n    discount_code: Optional[str] = None,\n    tax_rate: Any = \"0.10\",\n) -> dict:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of the rounded line totals\n    - discount codes (surrounding whitespace and case are ignored):\n      \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price must be a finite, non-negative number and tax_rate a finite,\n      non-negative number, otherwise ValueError\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError;\n      each item must be a 3-tuple, otherwise ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        item_list = list(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    rate = _to_decimal(tax_rate, \"tax_rate\")\n    if rate < 0:\n        raise ValueError(f\"invalid tax_rate: {tax_rate!r}\")\n\n    subtotal = Decimal(\"0.00\")\n    for index, item in enumerate(item_list):\n        if not isinstance(item, (tuple, list)) or len(item) != 3:\n            raise ValueError(\n                f\"item {index} must be a (name, unit_price, quantity) tuple: {item!r}\"\n            )\n        name, unit_price, quantity = item\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        price = _to_decimal(unit_price, f\"unit_price for {name}\")\n        if price < 0:\n            raise ValueError(f\"invalid unit_price for {name}: {unit_price!r}\")\n        subtotal += _round(price * quantity)\n    subtotal = _round(subtotal)\n\n    code = discount_code.strip().upper() if isinstance(discount_code, str) else None\n    if code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = _round(Decimal(\"0\"))\n\n    tax = _round((subtotal - discount) * rate)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 88, "cum_changed": 80, "review_cost": 0.0366367, "parse_ok": true, "findings": [{"title": "Very large values leak decimal.InvalidOperation instead of ValueError", "severity": "medium", "detail": "`_to_decimal` accepts any finite Decimal, including huge ones such as \"1e30\" or Decimal(\"1E+100\"). `_round` then calls `quantize`, which raises `decimal.InvalidOperation` when the result needs more digits than the context precision (28 by default). This happens for a large unit_price, a very large quantity, or a large tax_rate. `InvalidOperation` is not a `ValueError`, so callers who rely on the documented ValueError contract will miss it. Where `price * quantity` exceeds the precision but `quantize` happens not to fail, the product is silently rounded to 28 significant digits first. Add an explicit magnitude bound, or compute in a local context with enough precision and trap errors, converting them to `ValueError`."}, {"title": "Negative zero tax_rate or unit_price produces a '-0.00' result", "severity": "low", "detail": "`Decimal(\"-0\") < 0` is False, so a tax_rate of \"-0\" passes validation. `(subtotal - discount) * Decimal(\"-0\")` then gives a negative-zero tax of `Decimal('-0.00')`, which is returned and printed as \"-0.00\". The same can happen for a unit_price of \"-0\". Normalize zeros, for example by adding `Decimal(0)` or checking `is_signed()`."}, {"title": "Lists accepted as items although a 3-tuple is required", "severity": "low", "detail": "The docstring says each item must be a 3-tuple, otherwise ValueError. The code accepts `list` items too (`isinstance(item, (tuple, list))`). This is a small deviation from the spec: a 3-element list passes where the contract says it should be rejected."}, {"title": "Quantity error message omits the offending value; name is unvalidated", "severity": "low", "detail": "`invalid quantity for {name}` does not include the bad quantity, unlike the other messages, which use `!r`. The `name` is never validated and is interpolated with `str`, so an object with a failing `__str__` or a misleading name can make error reporting confusing. Use `{name!r}` and include the quantity."}, {"title": "Lenient numeric parsing accepts unexpected string formats", "severity": "low", "detail": "`Decimal(str(value).strip())` accepts forms like \"1_000\", \"1e2\", \"+5\", and non-ASCII digits. Floats are also accepted and converted via `str`, which can hide binary-representation issues. This is probably acceptable, but the spec says only that unit_price is a string like \"19.99\". Stricter parsing, or rejecting floats for money, would be safer."}], "review_text": "", "fix_cost": 0.05353499999999999, "fix_ok": true, "step_changed": 105, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, Optional\n\nCENT = Decimal(\"0.01\")\n\n_MAX_ADJUSTED = 15  # values must be below 10**16\n_MAX_DIGITS = 100\n_MIN_EXPONENT = -1000\n_MAX_QUANTITY = 10**12\n_NUMBER_RE = re.compile(r\"(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\\Z\")\n\n\ndef _make_context() -> Context:\n    # Precision is large enough that every bounded computation below is exact.\n    return Context(prec=400, rounding=ROUND_HALF_UP, traps=[InvalidOperation])\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert a str/int/Decimal to a finite, bounded Decimal, or raise ValueError.\n\n    Strings must be plain ASCII decimal numbers (no sign, exponent or separators).\n    Floats and bools are rejected.\n    \"\"\"\n    if isinstance(value, bool) or value is None:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if isinstance(value, Decimal):\n        result = value\n    elif isinstance(value, int):\n        result = Decimal(value)\n    elif isinstance(value, str):\n        text = value.strip()\n        if not _NUMBER_RE.match(text):\n            raise ValueError(f\"invalid {label}: {value!r}\")\n        try:\n            result = Decimal(text)\n        except InvalidOperation:\n            raise ValueError(f\"invalid {label}: {value!r}\") from None\n    else:\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if not result.is_finite():\n        raise ValueError(f\"invalid {label}: {value!r}\")\n    if result.is_zero():\n        return Decimal(0)  # normalize negative zero\n    sign, digits, exponent = result.as_tuple()\n    if (\n        result.adjusted() > _MAX_ADJUSTED\n        or len(digits) > _MAX_DIGITS\n        or exponent < _MIN_EXPONENT\n    ):\n        raise ValueError(f\"{label} out of supported range: {value!r}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple],\n    discount_code: Optional[str] = None,\n    tax_rate: Any = \"0.10\",\n) -> dict:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of the rounded line totals\n    - discount codes (surrounding whitespace and case are ignored):\n      \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price must be a finite, non-negative number and tax_rate a finite,\n      non-negative number, otherwise ValueError\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError;\n      each item must be a 3-tuple, otherwise ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        item_list = list(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_make_context()):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n            if rate < 0:\n                raise ValueError(f\"invalid tax_rate: {tax_rate!r}\")\n\n            subtotal = Decimal(\"0.00\")\n            for index, item in enumerate(item_list):\n                if not isinstance(item, tuple) or len(item) != 3:\n                    raise ValueError(\n                        f\"item {index} must be a (name, unit_price, quantity) tuple: {item!r}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > _MAX_QUANTITY\n                ):\n                    raise ValueError(f\"invalid quantity for {name!r}: {quantity!r}\")\n                price = _to_decimal(unit_price, f\"unit_price for {name!r}\")\n                if price < 0:\n                    raise ValueError(f\"invalid unit_price for {name!r}: {unit_price!r}\")\n                subtotal += _round(price * quantity)\n            subtotal = _round(subtotal)\n\n            code = discount_code.strip().upper() if isinstance(discount_code, str) else None\n            if code == \"SAVE10\":\n                discount = _round(subtotal * Decimal(\"0.10\"))\n            elif code == \"FLAT5\":\n                discount = _round(min(Decimal(\"5.00\"), subtotal))\n            else:\n                discount = _round(Decimal(\"0\"))\n\n            tax = _round((subtotal - discount) * rate)\n            total = _round(subtotal - discount + tax)\n    except ArithmeticError as exc:\n        raise ValueError(f\"invoice amounts out of supported range: {exc}\") from None\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 2, "tests_passed": 17, "tests_failed": 0, "code_lines": 133, "cum_changed": 135, "review_cost": 0.0639641, "parse_ok": true, "findings": [{"title": "Discount code matching uses Unicode-aware str.upper()", "severity": "medium", "detail": "`discount_code.strip().upper()` applies full Unicode case mapping, not just ASCII case-insensitivity. For example, 'ſave10' (long s) upper-cases to 'SAVE10' and 'ﬂat5' (ligature) upper-cases to 'FLAT5'. Both would silently grant a discount. The spec says only that case and surrounding whitespace are ignored. Matching should be restricted to ASCII (e.g. check `isascii()` first, or compare against `casefold()`ed constants after an ASCII check)."}, {"title": "TypeError from inside a generator is misreported as 'items must be an iterable'", "severity": "low", "detail": "`list(items)` is wrapped in `except TypeError`. If `items` is a generator whose body raises TypeError, that error is masked with a misleading message and its traceback is dropped by `from None`. A non-iterable should be detected with `iter(items)` alone, and errors raised while consuming the iterable should propagate unchanged."}, {"title": "Undocumented limits that raise ValueError", "severity": "low", "detail": "Quantity above 10**12, more than 100 digits, exponent below -1000 and values of 10**16 or more are rejected. The docstring only says 'positive int' for quantity and describes unit_price and tax_rate as 'finite, non-negative'. Callers with legitimate large quantities get an error that contradicts the documented contract. The limits should be documented or the messages should make clear they are limits."}, {"title": "String input is parsed without a length cap and error messages embed the full repr", "severity": "low", "detail": "`_NUMBER_RE.match` and `Decimal(text)` run on arbitrarily long strings before the digit-count bound is checked. Every ValueError also interpolates `{value!r}`, so a multi-megabyte input produces a multi-megabyte exception message. Checking `len(text)` up front, or truncating the repr, would bound the work and the message size."}, {"title": "Whitespace handling is looser than the documented 'plain ASCII' format", "severity": "low", "detail": "`value.strip()` removes all Unicode whitespace (NBSP, ideographic space, etc.), so ' 19.99' with such characters is accepted even though the docstring says strings must be plain ASCII decimal numbers. This is harmless but inconsistent. It could be tightened to `strip(' \\t\\n\\r')` or the docstring could be updated."}, {"title": "No upper bound on tax_rate", "severity": "low", "detail": "Any non-negative value below 10**16 is accepted, so a caller who passes a percentage ('10' instead of '0.10') gets a 1000% tax with no error. This is a design choice, but a sanity cap (e.g. rate <= 1) or an explicit docstring note would catch a common misuse."}, {"title": "Minor redundancy and unused data", "severity": "low", "detail": "`subtotal = _round(subtotal)` is a no-op because every addend is already rounded to cents. `_round(Decimal('0'))` for the no-discount case is just a roundabout way to write `Decimal('0.00')`. The item `name` is never validated and is only used in error messages, so non-string names pass silently and may make messages noisy."}], "review_text": "", "fix_cost": 0.05734149999999999, "fix_ok": true, "step_changed": 88, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, Optional\n\nCENT = Decimal(\"0.01\")\n\n_MAX_ADJUSTED = 15  # values must be below 10**16\n_MAX_DIGITS = 100\n_MIN_EXPONENT = -1000\n_MAX_QUANTITY = 10**12\n_MAX_TEXT_LEN = 256  # longest accepted numeric string, including whitespace\n_MAX_REPR_LEN = 60\n_MAX_TAX_RATE = Decimal(1)\n_ASCII_WHITESPACE = \" \\t\\n\\r\"\n_NUMBER_RE = re.compile(r\"(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\\Z\")\n\n\ndef _make_context() -> Context:\n    # Precision is large enough that every bounded computation below is exact.\n    return Context(prec=400, rounding=ROUND_HALF_UP, traps=[InvalidOperation])\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _repr(value: Any) -> str:\n    \"\"\"repr() truncated so error messages stay small.\"\"\"\n    try:\n        text = repr(value)\n    except Exception:\n        text = f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert a str/int/Decimal to a finite, bounded Decimal, or raise ValueError.\n\n    Strings must be plain ASCII decimal numbers (no sign, exponent or separators),\n    optionally surrounded by ASCII whitespace, and at most 256 characters long.\n    Floats and bools are rejected.\n    \"\"\"\n    if isinstance(value, bool) or value is None:\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if isinstance(value, Decimal):\n        result = value\n    elif isinstance(value, int):\n        result = Decimal(value)\n    elif isinstance(value, str):\n        if len(value) > _MAX_TEXT_LEN:\n            raise ValueError(\n                f\"invalid {label}: string longer than {_MAX_TEXT_LEN} characters\"\n            )\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _NUMBER_RE.match(text):\n            raise ValueError(f\"invalid {label}: {_repr(value)}\")\n        try:\n            result = Decimal(text)\n        except InvalidOperation:\n            raise ValueError(f\"invalid {label}: {_repr(value)}\") from None\n    else:\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if not result.is_finite():\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if result.is_zero():\n        return Decimal(0)  # normalize negative zero\n    sign, digits, exponent = result.as_tuple()\n    if (\n        result.adjusted() > _MAX_ADJUSTED\n        or len(digits) > _MAX_DIGITS\n        or exponent < _MIN_EXPONENT\n    ):\n        raise ValueError(\n            f\"{label} out of supported range (must be below 10**16, at most \"\n            f\"{_MAX_DIGITS} digits, exponent >= {_MIN_EXPONENT}): {_repr(value)}\"\n        )\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple],\n    discount_code: Optional[str] = None,\n    tax_rate: Any = \"0.10\",\n) -> dict:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of the rounded line totals\n    - discount codes (surrounding whitespace and ASCII case are ignored; codes\n      containing non-ASCII characters never match):\n      \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price must be a finite, non-negative number and tax_rate a finite\n      number between 0 and 1 inclusive (a fraction, e.g. \"0.10\" not \"10\"),\n      otherwise ValueError\n    - quantity must be a positive int no greater than 10**12, otherwise ValueError;\n      empty items -> ValueError; each item must be a 3-tuple, otherwise ValueError\n    - supported numeric limits (ValueError beyond them): unit_price and tax_rate\n      must be below 10**16, have at most 100 digits and an exponent of at least\n      -1000; numeric strings must be plain ASCII decimals (no sign, exponent or\n      separators), optionally surrounded by ASCII whitespace, and at most 256\n      characters long\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        iterator = iter(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    item_list = list(iterator)\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_make_context()):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n            if rate < 0 or rate > _MAX_TAX_RATE:\n                raise ValueError(\n                    f\"invalid tax_rate (must be between 0 and 1): {_repr(tax_rate)}\"\n                )\n\n            subtotal = Decimal(\"0.00\")\n            for index, item in enumerate(item_list):\n                if not isinstance(item, tuple) or len(item) != 3:\n                    raise ValueError(\n                        f\"item {index} must be a (name, unit_price, quantity) tuple: \"\n                        f\"{_repr(item)}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > _MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {_repr(name)} (must be an int from 1 \"\n                        f\"to {_MAX_QUANTITY}): {_repr(quantity)}\"\n                    )\n                price = _to_decimal(unit_price, f\"unit_price for {_repr(name)}\")\n                if price < 0:\n                    raise ValueError(\n                        f\"invalid unit_price for {_repr(name)}: {_repr(unit_price)}\"\n                    )\n                subtotal += _round(price * quantity)\n\n            code = discount_code.strip() if isinstance(discount_code, str) else None\n            if code is not None and code.isascii():\n                code = code.upper()\n            else:\n                code = None\n            if code == \"SAVE10\":\n                discount = _round(subtotal * Decimal(\"0.10\"))\n            elif code == \"FLAT5\":\n                discount = _round(min(Decimal(\"5.00\"), subtotal))\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = _round(subtotal - discount + tax)\n    except ArithmeticError as exc:\n        raise ValueError(f\"invoice amounts out of supported range: {exc}\") from None\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 3, "tests_passed": 17, "tests_failed": 0, "code_lines": 175, "cum_changed": 177, "review_cost": 0.0568431, "parse_ok": true, "findings": [{"title": "Discount code whitespace stripping is Unicode-aware, contradicting the \"non-ASCII never matches\" rule", "severity": "medium", "detail": "`discount_code.strip()` with no argument removes all Unicode whitespace (e.g. NBSP \\u00a0, \\u2003, \\u3000). The ASCII check (`code.isascii()`) runs only after stripping, so \"\\u00a0SAVE10\" becomes \"SAVE10\" and receives the discount. The docstring says codes containing non-ASCII characters never match. The numeric parser handles this correctly with `strip(_ASCII_WHITESPACE)`, so the two paths are inconsistent. Fix: run `isascii()` on the raw string first, and strip with an explicit ASCII whitespace set. `str.strip()` also removes \\x0b, \\x0c and \\x1c-\\x1f, which are ASCII but not in `_ASCII_WHITESPACE`; decide whether those should count as whitespace."}, {"title": "Docstring understates accepted input types", "severity": "low", "detail": "The docstring says unit_price is a string like \"19.99\", but `_to_decimal` also accepts int and Decimal and rejects float and bool. Similarly, `items` is documented as a list but any iterable is accepted, and it is fully materialized with `list()` with no size cap. The documented contract should state these behaviors, or the code should enforce a limit on item count."}, {"title": "Negative unit prices outside the supported range report a misleading error", "severity": "low", "detail": "`_to_decimal` applies the magnitude and precision bounds before the sign check in `invoice_total`. A value such as Decimal('-1E+20') therefore raises \"out of supported range\" rather than \"invalid unit_price (must be non-negative)\". Both are ValueError, so behavior is correct, but the diagnostic is inconsistent."}, {"title": "Fixed-precision exactness guarantee is implicit and fragile", "severity": "low", "detail": "Correctness depends on `prec=400` being enough for every intermediate value. This is true today, since a product has at most ~113 digits and rounded amounts are ~30 digits. Nothing asserts or tests it, and `Inexact`/`Rounded` are not trapped. If the limits (`_MAX_DIGITS`, `_MAX_QUANTITY`) are raised later, results could silently round at the wrong step. A comment tying `prec` to those constants, or a derived value, would make this safer."}], "review_text": "", "fix_cost": 0.0726716, "fix_ok": true, "step_changed": 60, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, Optional\n\nCENT = Decimal(\"0.01\")\n\n_MAX_ADJUSTED = 15  # values must be below 10**16\n_MAX_DIGITS = 100\n_MIN_EXPONENT = -1000\n_MAX_QUANTITY = 10**12\n_MAX_TEXT_LEN = 256  # longest accepted numeric string, including whitespace\n_MAX_REPR_LEN = 60\n_MAX_TAX_RATE = Decimal(1)\n# Only these four characters count as whitespace, for numbers and discount codes\n# alike. \\x0b, \\x0c and \\x1c-\\x1f are deliberately not stripped.\n_ASCII_WHITESPACE = \" \\t\\n\\r\"\n_NUMBER_RE = re.compile(r\"(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\\Z\")\n\n# Working precision, derived from the limits above so that every intermediate\n# value is exact:\n#   - a line product has at most _MAX_DIGITS + len(str(_MAX_QUANTITY)) digits;\n#   - a rounded line amount has at most (_MAX_ADJUSTED + 1) + quantity digits + 2\n#     cent digits; 20 extra digits of headroom cover summing any realistic\n#     number of lines;\n#   - (subtotal - discount) * tax_rate has at most amount digits + _MAX_DIGITS.\n_QUANTITY_DIGITS = len(str(_MAX_QUANTITY))\n_AMOUNT_DIGITS = (_MAX_ADJUSTED + 1) + _QUANTITY_DIGITS + 2 + 20\n_PRECISION = max(\n    _MAX_DIGITS + _QUANTITY_DIGITS,\n    _AMOUNT_DIGITS + _MAX_DIGITS,\n) + 10\n\n\ndef _make_context() -> Context:\n    return Context(prec=_PRECISION, rounding=ROUND_HALF_UP, traps=[InvalidOperation])\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _repr(value: Any) -> str:\n    \"\"\"repr() truncated so error messages stay small.\"\"\"\n    try:\n        text = repr(value)\n    except Exception:\n        text = f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _to_decimal(value: Any, label: str, negative_hint: Optional[str] = None) -> Decimal:\n    \"\"\"Convert a str/int/Decimal to a finite, bounded Decimal, or raise ValueError.\n\n    Strings must be plain ASCII decimal numbers (no sign, exponent or separators),\n    optionally surrounded by ASCII whitespace, and at most 256 characters long.\n    Floats and bools are rejected. If negative_hint is given, negative values are\n    rejected with that hint before the range checks are applied.\n    \"\"\"\n    if isinstance(value, bool) or value is None:\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if isinstance(value, Decimal):\n        result = value\n    elif isinstance(value, int):\n        result = Decimal(value)\n    elif isinstance(value, str):\n        if len(value) > _MAX_TEXT_LEN:\n            raise ValueError(\n                f\"invalid {label}: string longer than {_MAX_TEXT_LEN} characters\"\n            )\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _NUMBER_RE.match(text):\n            raise ValueError(f\"invalid {label}: {_repr(value)}\")\n        try:\n            result = Decimal(text)\n        except InvalidOperation:\n            raise ValueError(f\"invalid {label}: {_repr(value)}\") from None\n    else:\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if not result.is_finite():\n        raise ValueError(f\"invalid {label}: {_repr(value)}\")\n    if result.is_zero():\n        return Decimal(0)  # normalize negative zero\n    if negative_hint is not None and result.is_signed():\n        raise ValueError(f\"invalid {label} ({negative_hint}): {_repr(value)}\")\n    sign, digits, exponent = result.as_tuple()\n    if (\n        result.adjusted() > _MAX_ADJUSTED\n        or len(digits) > _MAX_DIGITS\n        or exponent < _MIN_EXPONENT\n    ):\n        raise ValueError(\n            f\"{label} out of supported range (must be below 10**16, at most \"\n            f\"{_MAX_DIGITS} digits, exponent >= {_MIN_EXPONENT}): {_repr(value)}\"\n        )\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple],\n    discount_code: Optional[str] = None,\n    tax_rate: Any = \"0.10\",\n) -> dict:\n    \"\"\"Compute an invoice.\n\n    items: any iterable of (name, unit_price, quantity); it is fully materialized\n    into a list, and no cap is placed on the number of items.\n    unit_price may be a numeric string like \"19.99\", an int, or a Decimal\n    (floats and bools are rejected). tax_rate accepts the same types.\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of the rounded line totals\n    - discount codes (only the ASCII characters space, tab, newline and carriage\n      return are stripped from the ends, and ASCII case is ignored; codes\n      containing any non-ASCII character, including non-ASCII whitespace, never\n      match): \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price must be a finite, non-negative number and tax_rate a finite\n      number between 0 and 1 inclusive (a fraction, e.g. \"0.10\" not \"10\"),\n      otherwise ValueError\n    - quantity must be a positive int no greater than 10**12, otherwise ValueError;\n      empty items -> ValueError; each item must be a 3-tuple, otherwise ValueError\n    - supported numeric limits (ValueError beyond them): unit_price and tax_rate\n      must be below 10**16, have at most 100 digits and an exponent of at least\n      -1000; numeric strings must be plain ASCII decimals (no sign, exponent or\n      separators), optionally surrounded by ASCII whitespace, and at most 256\n      characters long\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        iterator = iter(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    item_list = list(iterator)\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_make_context()):\n            rate = _to_decimal(tax_rate, \"tax_rate\", \"must be between 0 and 1\")\n            if rate > _MAX_TAX_RATE:\n                raise ValueError(\n                    f\"invalid tax_rate (must be between 0 and 1): {_repr(tax_rate)}\"\n                )\n\n            subtotal = Decimal(\"0.00\")\n            for index, item in enumerate(item_list):\n                if not isinstance(item, tuple) or len(item) != 3:\n                    raise ValueError(\n                        f\"item {index} must be a (name, unit_price, quantity) tuple: \"\n                        f\"{_repr(item)}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > _MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {_repr(name)} (must be an int from 1 \"\n                        f\"to {_MAX_QUANTITY}): {_repr(quantity)}\"\n                    )\n                price = _to_decimal(\n                    unit_price,\n                    f\"unit_price for {_repr(name)}\",\n                    \"must be non-negative\",\n                )\n                subtotal += _round(price * quantity)\n\n            code = None\n            if isinstance(discount_code, str) and discount_code.isascii():\n                code = discount_code.strip(_ASCII_WHITESPACE).upper()\n            if code == \"SAVE10\":\n                discount = _round(subtotal * Decimal(\"0.10\"))\n            elif code == \"FLAT5\":\n                discount = _round(min(Decimal(\"5.00\"), subtotal))\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = _round(subtotal - discount + tax)\n    except ArithmeticError as exc:\n        raise ValueError(f\"invoice amounts out of supported range: {exc}\") from None\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 4, "tests_passed": 17, "tests_failed": 0, "code_lines": 195, "cum_changed": 199, "review_cost": 0.06045199999999999, "parse_ok": true, "findings": [{"title": "repr(name) is computed eagerly for every item, even on success", "severity": "low", "detail": "The label f\"unit_price for {_repr(name)}\" is built on every loop iteration before _to_decimal is called, although it is only needed in an error message. repr() on a large or custom name object costs O(len) time and memory (the truncation happens after the full repr is built). It can also run arbitrary __repr__ code. With very many items this adds avoidable overhead. Pass the name lazily, or build the label only when raising."}, {"title": "Decimal context traps only InvalidOperation, so other signals fail silently", "severity": "low", "detail": "Context(traps=[InvalidOperation]) leaves Overflow, Underflow and DivisionByZero untrapped, and Inexact/Rounded are untrapped too. The documented limits make Overflow and Underflow unreachable today. The 20 digits of headroom for summing lines is also an assumption, so an extreme line count would round the subtotal silently instead of raising. The 'every intermediate value is exact' guarantee is therefore not enforced. Trapping Inexact on the non-rounding operations, or at least Overflow, would turn a future change in the limits into an error instead of wrong money."}, {"title": "Errors raised while iterating `items` are not normalized", "severity": "low", "detail": "Only iter(items) is wrapped for TypeError. list(iterator) can still raise arbitrary exceptions from a generator or custom iterator, and these escape unconverted even though the contract promises ValueError for bad input. Those cases may be intentionally propagated, but it is inconsistent with the documented error contract."}, {"title": "Item name is never validated, and invalid discount code types are silently ignored", "severity": "low", "detail": "name can be any object, and the only thing done with it is repr for error text. A non-str discount_code (e.g. bytes or an int) silently means no discount rather than raising, which can hide caller bugs such as a mistyped code that was never applied."}, {"title": "`raise ... from None` discards the original ArithmeticError chain", "severity": "low", "detail": "Only str(exc) is kept, which makes diagnosing the failing operation harder. It also hides the traceback for decimal errors. Using `from exc` would preserve the cause at no cost."}, {"title": "Redundant final rounding of total", "severity": "low", "detail": "subtotal, discount and tax are already quantized to cents, so subtotal - discount + tax is exact at two decimals. The extra _round(...) is a no-op and could mask a future precision bug."}], "review_text": "", "fix_cost": 0.08075929999999999, "fix_ok": true, "step_changed": 102, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    DivisionByZero,\n    InvalidOperation,\n    Overflow,\n    ROUND_HALF_UP,\n    Underflow,\n    localcontext,\n)\nfrom typing import Any, Callable, Iterable, Optional, Union\n\nCENT = Decimal(\"0.01\")\n\n_MAX_ADJUSTED = 15  # values must be below 10**16\n_MAX_DIGITS = 100\n_MIN_EXPONENT = -1000\n_MAX_QUANTITY = 10**12\n_MAX_TEXT_LEN = 256  # longest accepted numeric string, including whitespace\n_MAX_REPR_LEN = 60\n_MAX_TAX_RATE = Decimal(1)\n# Only these four characters count as whitespace, for numbers and discount codes\n# alike. \\x0b, \\x0c and \\x1c-\\x1f are deliberately not stripped.\n_ASCII_WHITESPACE = \" \\t\\n\\r\"\n_NUMBER_RE = re.compile(r\"(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\\Z\")\n\n# Working precision, derived from the limits above so that every intermediate\n# value is exact:\n#   - a line product has at most _MAX_DIGITS + len(str(_MAX_QUANTITY)) digits;\n#   - a rounded line amount has at most (_MAX_ADJUSTED + 1) + quantity digits + 2\n#     cent digits; 20 extra digits of headroom cover summing any realistic\n#     number of lines;\n#   - (subtotal - discount) * tax_rate has at most amount digits + _MAX_DIGITS.\n# Inexact is trapped in the working context, so a violation of these bounds\n# raises instead of silently rounding.\n_QUANTITY_DIGITS = len(str(_MAX_QUANTITY))\n_AMOUNT_DIGITS = (_MAX_ADJUSTED + 1) + _QUANTITY_DIGITS + 2 + 20\n_PRECISION = max(\n    _MAX_DIGITS + _QUANTITY_DIGITS,\n    _AMOUNT_DIGITS + _MAX_DIGITS,\n) + 10\n\n_TRAPS = [InvalidOperation, Overflow, Underflow, DivisionByZero]\n\n# Used only by _round, where rounding to cents is intended and so Inexact must\n# not be trapped.\n_ROUND_CONTEXT = Context(prec=_PRECISION, rounding=ROUND_HALF_UP, traps=_TRAPS)\n\n\ndef _make_context() -> Context:\n    from decimal import Inexact\n\n    return Context(\n        prec=_PRECISION, rounding=ROUND_HALF_UP, traps=_TRAPS + [Inexact]\n    )\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP, context=_ROUND_CONTEXT)\n\n\ndef _repr(value: Any) -> str:\n    \"\"\"repr() truncated so error messages stay small.\"\"\"\n    try:\n        text = repr(value)\n    except Exception:\n        text = f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _resolve_label(label: Union[str, Callable[[], str]]) -> str:\n    return label() if callable(label) else label\n\n\ndef _to_decimal(\n    value: Any,\n    label: Union[str, Callable[[], str]],\n    negative_hint: Optional[str] = None,\n) -> Decimal:\n    \"\"\"Convert a str/int/Decimal to a finite, bounded Decimal, or raise ValueError.\n\n    Strings must be plain ASCII decimal numbers (no sign, exponent or separators),\n    optionally surrounded by ASCII whitespace, and at most 256 characters long.\n    Floats and bools are rejected. If negative_hint is given, negative values are\n    rejected with that hint before the range checks are applied. label may be a\n    callable, in which case it is only invoked when an error message is built.\n    \"\"\"\n    if isinstance(value, bool) or value is None:\n        raise ValueError(f\"invalid {_resolve_label(label)}: {_repr(value)}\")\n    if isinstance(value, Decimal):\n        result = value\n    elif isinstance(value, int):\n        result = Decimal(value)\n    elif isinstance(value, str):\n        if len(value) > _MAX_TEXT_LEN:\n            raise ValueError(\n                f\"invalid {_resolve_label(label)}: string longer than \"\n                f\"{_MAX_TEXT_LEN} characters\"\n            )\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _NUMBER_RE.match(text):\n            raise ValueError(f\"invalid {_resolve_label(label)}: {_repr(value)}\")\n        try:\n            result = Decimal(text)\n        except InvalidOperation as exc:\n            raise ValueError(\n                f\"invalid {_resolve_label(label)}: {_repr(value)}\"\n            ) from exc\n    else:\n        raise ValueError(f\"invalid {_resolve_label(label)}: {_repr(value)}\")\n    if not result.is_finite():\n        raise ValueError(f\"invalid {_resolve_label(label)}: {_repr(value)}\")\n    if result.is_zero():\n        return Decimal(0)  # normalize negative zero\n    if negative_hint is not None and result.is_signed():\n        raise ValueError(\n            f\"invalid {_resolve_label(label)} ({negative_hint}): {_repr(value)}\"\n        )\n    sign, digits, exponent = result.as_tuple()\n    if (\n        result.adjusted() > _MAX_ADJUSTED\n        or len(digits) > _MAX_DIGITS\n        or exponent < _MIN_EXPONENT\n    ):\n        raise ValueError(\n            f\"{_resolve_label(label)} out of supported range (must be below \"\n            f\"10**16, at most {_MAX_DIGITS} digits, exponent >= \"\n            f\"{_MIN_EXPONENT}): {_repr(value)}\"\n        )\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple],\n    discount_code: Optional[str] = None,\n    tax_rate: Any = \"0.10\",\n) -> dict:\n    \"\"\"Compute an invoice.\n\n    items: any iterable of (name, unit_price, quantity); it is fully materialized\n    into a list, and no cap is placed on the number of items. Any exception\n    raised while iterating it is reported as ValueError.\n    name must be a str, otherwise ValueError.\n    unit_price may be a numeric string like \"19.99\", an int, or a Decimal\n    (floats and bools are rejected). tax_rate accepts the same types.\n    Rules:\n    - line total = unit_price * quantity, rounded to 2 decimals (half up)\n    - subtotal = sum of the rounded line totals\n    - discount_code must be a str or None, otherwise ValueError; codes (only the\n      ASCII characters space, tab, newline and carriage return are stripped from\n      the ends, and ASCII case is ignored; codes containing any non-ASCII\n      character, including non-ASCII whitespace, never match): \"SAVE10\" = 10%\n      off the subtotal; \"FLAT5\" = 5.00 off the subtotal (the discount never\n      exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - unit_price must be a finite, non-negative number and tax_rate a finite\n      number between 0 and 1 inclusive (a fraction, e.g. \"0.10\" not \"10\"),\n      otherwise ValueError\n    - quantity must be a positive int no greater than 10**12, otherwise ValueError;\n      empty items -> ValueError; each item must be a 3-tuple, otherwise ValueError\n    - supported numeric limits (ValueError beyond them): unit_price and tax_rate\n      must be below 10**16, have at most 100 digits and an exponent of at least\n      -1000; numeric strings must be plain ASCII decimals (no sign, exponent or\n      separators), optionally surrounded by ASCII whitespace, and at most 256\n      characters long\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if discount_code is not None and not isinstance(discount_code, str):\n        raise ValueError(\n            f\"discount_code must be a str or None: {_repr(discount_code)}\"\n        )\n    try:\n        iterator = iter(items)\n    except TypeError as exc:\n        raise ValueError(\n            \"items must be an iterable of (name, unit_price, quantity)\"\n        ) from exc\n    try:\n        item_list = list(iterator)\n    except Exception as exc:\n        raise ValueError(f\"could not read items: {_repr(exc)}\") from exc\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_make_context()):\n            rate = _to_decimal(tax_rate, \"tax_rate\", \"must be between 0 and 1\")\n            if rate > _MAX_TAX_RATE:\n                raise ValueError(\n                    f\"invalid tax_rate (must be between 0 and 1): {_repr(tax_rate)}\"\n                )\n\n            subtotal = Decimal(\"0.00\")\n            for index, item in enumerate(item_list):\n                if not isinstance(item, tuple) or len(item) != 3:\n                    raise ValueError(\n                        f\"item {index} must be a (name, unit_price, quantity) tuple: \"\n                        f\"{_repr(item)}\"\n                    )\n                name, unit_price, quantity = item\n                if not isinstance(name, str):\n                    raise ValueError(\n                        f\"item {index} name must be a str: {_repr(name)}\"\n                    )\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > _MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {_repr(name)} (must be an int from 1 \"\n                        f\"to {_MAX_QUANTITY}): {_repr(quantity)}\"\n                    )\n                price = _to_decimal(\n                    unit_price,\n                    lambda name=name: f\"unit_price for {_repr(name)}\",\n                    \"must be non-negative\",\n                )\n                subtotal += _round(price * quantity)\n\n            code = None\n            if discount_code is not None and discount_code.isascii():\n                code = discount_code.strip(_ASCII_WHITESPACE).upper()\n            if code == \"SAVE10\":\n                discount = _round(subtotal * Decimal(\"0.10\"))\n            elif code == \"FLAT5\":\n                discount = _round(min(Decimal(\"5.00\"), subtotal))\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except ArithmeticError as exc:\n        raise ValueError(f\"invoice amounts out of supported range: {exc}\") from exc\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 0, "round": 5, "tests_passed": 17, "tests_failed": 0, "code_lines": 241, "cum_changed": 259, "review_cost": 0.05776269999999999, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0239141, "parse_ok": true, "findings": [{"title": "Invalid unit_price / tax_rate raise decimal.InvalidOperation instead of a clear error; NaN/Infinity accepted", "severity": "medium", "detail": "Decimal(unit_price) on a malformed string raises decimal.InvalidOperation (not ValueError as the rest of the API does). Worse, strings like 'NaN', 'Infinity' or '-5' parse successfully: a NaN price poisons the result (and quantize on NaN/Infinity can raise), and negative prices can produce a negative subtotal/total or let the discount exceed the subtotal. Likewise tax_rate is not validated (negative, NaN, or >1 values are accepted). Validate that prices are finite and non-negative and that tax_rate is finite and non-negative, raising ValueError."}, {"title": "Non-string unit_price (float) silently introduces binary floating-point error", "severity": "medium", "detail": "The docstring says unit_price is a string, but nothing enforces it. Decimal(19.99) yields 19.989999999999998436..., which is then summed and rounded, potentially giving off-by-a-cent results. Likewise a float tax_rate like 0.1 is converted exactly from the binary value. Reject non-str/Decimal/int inputs or convert via str()."}, {"title": "FLAT5 discount can exceed subtotal when subtotal is negative, and discount is not clamped generally", "severity": "low", "detail": "min(Decimal('5.00'), subtotal) returns the negative subtotal if prices are negative, producing a negative discount (which increases the total). This follows from the missing price validation; clamping to max(0, ...) or rejecting negatives fixes it."}, {"title": "Malformed item tuples and unhelpful error handling", "severity": "low", "detail": "Items that are not 3-tuples raise a raw unpacking ValueError/TypeError with a confusing message. The quantity error message also does not include the offending value, and the empty-items check `not items` fails for generators/iterators in odd ways (a generator is truthy even when empty, and is then consumed). Consider materializing items with list() and validating structure."}, {"title": "Discount code matching is exact and case/whitespace-sensitive", "severity": "low", "detail": "'save10' or ' SAVE10' silently yields no discount. This matches the stated spec ('any other code = no discount'), but is a likely usability trap; consider normalizing or at least documenting. Magic numbers (0.10, 5.00) are also hard-coded rather than defined as constants."}, {"title": "Decimal context precision may cause implicit rounding or InvalidOperation on huge values", "severity": "low", "detail": "Arithmetic and quantize use the global decimal context (default 28 digits). Very large prices/quantities can make quantize raise InvalidOperation, and a modified global context could change results. Use a local context or bound input sizes."}, {"title": "No tests or type hints; return dict is loosely typed", "severity": "low", "detail": "The function has no annotations and returns an untyped dict; a dataclass/TypedDict would document the contract. Rounding rules (subtotal rounded after summing, not per line) are only documented in the docstring and untested."}], "review_text": "", "fix_cost": 0.0456229, "fix_ok": true, "step_changed": 113, "code_after": "from decimal import (\n    Context,\n    Decimal,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, TypedDict\n\nCENT = Decimal(\"0.01\")\nSAVE10_RATE = Decimal(\"0.10\")\nFLAT5_AMOUNT = Decimal(\"5.00\")\n\nMAX_DIGITS = 30\nMAX_ADJUSTED = 30\nMAX_QUANTITY = 10**9\n\n# Local context so results never depend on the global decimal context.\n_CONTEXT = Context(prec=100, rounding=ROUND_HALF_UP)\n\n\nclass InvoiceTotal(TypedDict):\n    subtotal: Decimal\n    discount: Decimal\n    tax: Decimal\n    total: Decimal\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert a str/Decimal/int to a finite, non-negative Decimal or raise ValueError.\"\"\"\n    if isinstance(value, bool) or not isinstance(value, (str, Decimal, int)):\n        raise ValueError(\n            f\"{label} must be a str, Decimal or int, got {type(value).__name__}\"\n        )\n    try:\n        result = Decimal(value.strip() if isinstance(value, str) else value)\n    except InvalidOperation:\n        raise ValueError(f\"invalid {label}: {value!r}\") from None\n    if not result.is_finite():\n        raise ValueError(f\"{label} must be finite: {value!r}\")\n    if result < 0:\n        raise ValueError(f\"{label} must not be negative: {value!r}\")\n    if len(result.as_tuple().digits) > MAX_DIGITS or result.adjusted() > MAX_ADJUSTED:\n        raise ValueError(f\"{label} is out of range: {value!r}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple[str, Any, int]],\n    discount_code: str | None = None,\n    tax_rate: Any = \"0.10\",\n) -> InvoiceTotal:\n    \"\"\"Compute an invoice.\n\n    items: iterable of (name, unit_price, quantity). unit_price is a string like\n    \"19.99\" (Decimal and int are also accepted; float is rejected to avoid binary\n    rounding error).\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes (exact, case-sensitive match): \"SAVE10\" = 10% off the\n      subtotal; \"FLAT5\" = 5.00 off the subtotal (the discount never exceeds the\n      subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      the subtotal is rounded once after summing, not per line;\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        items = list(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_CONTEXT):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n\n            subtotal = Decimal(\"0\")\n            for item in items:\n                if not isinstance(item, (tuple, list)) or len(item) != 3:\n                    raise ValueError(\n                        f\"each item must be (name, unit_price, quantity), got {item!r}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(f\"invalid quantity for {name}: {quantity!r}\")\n                price = _to_decimal(unit_price, f\"unit_price for {name}\")\n                subtotal += price * quantity\n            subtotal = _round(subtotal)\n\n            if discount_code == \"SAVE10\":\n                discount = _round(subtotal * SAVE10_RATE)\n            elif discount_code == \"FLAT5\":\n                discount = min(FLAT5_AMOUNT, subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except InvalidOperation:\n        raise ValueError(\"invoice values are out of range\") from None\n\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 117, "cum_changed": 113, "review_cost": 0.0476272, "parse_ok": true, "findings": [{"title": "Negative zero is accepted and can produce a '-0.00' tax", "severity": "low", "detail": "`_to_decimal` checks `result < 0`, which is False for `Decimal('-0')`. A `tax_rate` of `'-0'` therefore passes validation, and `(subtotal - discount) * rate` gives `Decimal('-0.00')`. The returned `tax` then prints as '-0.00', although the docstring says inputs must be non-negative. Normalize with `result = +result` or `abs(result)`, or reject `result.is_signed()`."}, {"title": "`except TypeError` around `list(items)` can mask unrelated errors", "severity": "low", "detail": "If `items` is a generator, a `TypeError` raised inside it during iteration is reported as 'items must be an iterable...', which hides the real bug. Check iterability with `iter(items)` in its own try block, then call `list()` outside it."}, {"title": "Undocumented input limits (MAX_QUANTITY, MAX_DIGITS, MAX_ADJUSTED)", "severity": "low", "detail": "The docstring says a quantity must be a positive int, and a price and rate must be finite and non-negative. In fact quantities above 10**9 and values with more than 30 digits or an adjusted exponent above 30 also raise ValueError. This includes `tax_rate`. Callers cannot learn these limits from the contract. The default `tax_rate='0.10'` is not described either."}, {"title": "Decimal string parsing is more permissive than intended", "severity": "low", "detail": "`Decimal(str)` accepts underscores ('1_000.50'), non-ASCII Unicode digits, and exponent notation ('1e2'). These pass validation, which may be unwanted for price input. If strict input is intended, validate the string with a regex before converting."}], "review_text": "", "fix_cost": 0.04645150000000001, "fix_ok": true, "step_changed": 35, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    InvalidOperation,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, TypedDict\n\nCENT = Decimal(\"0.01\")\nSAVE10_RATE = Decimal(\"0.10\")\nFLAT5_AMOUNT = Decimal(\"5.00\")\n\nMAX_DIGITS = 30\nMAX_ADJUSTED = 30\nMAX_QUANTITY = 10**9\n\n# Local context so results never depend on the global decimal context.\n_CONTEXT = Context(prec=100, rounding=ROUND_HALF_UP)\n\n# Plain ASCII decimal notation only: no underscores, exponents, or non-ASCII digits.\n_DECIMAL_RE = re.compile(r\"[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\")\n\n\nclass InvoiceTotal(TypedDict):\n    subtotal: Decimal\n    discount: Decimal\n    tax: Decimal\n    total: Decimal\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert a str/Decimal/int to a finite, non-negative Decimal or raise ValueError.\n\n    Strings must use plain ASCII decimal notation (e.g. \"19.99\"); exponent\n    notation, underscores and non-ASCII digits are rejected.\n    \"\"\"\n    if isinstance(value, bool) or not isinstance(value, (str, Decimal, int)):\n        raise ValueError(\n            f\"{label} must be a str, Decimal or int, got {type(value).__name__}\"\n        )\n    if isinstance(value, str):\n        text = value.strip()\n        if not _DECIMAL_RE.fullmatch(text):\n            raise ValueError(f\"invalid {label}: {value!r}\")\n        value = text\n    try:\n        result = Decimal(value)\n    except InvalidOperation:\n        raise ValueError(f\"invalid {label}: {value!r}\") from None\n    if not result.is_finite():\n        raise ValueError(f\"{label} must be finite: {value!r}\")\n    if result < 0:\n        raise ValueError(f\"{label} must not be negative: {value!r}\")\n    if result.is_signed():\n        # Negative zero: normalize so no '-0.00' values leak into results.\n        result = result.copy_abs()\n    if len(result.as_tuple().digits) > MAX_DIGITS or result.adjusted() > MAX_ADJUSTED:\n        raise ValueError(f\"{label} is out of range: {value!r}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple[str, Any, int]],\n    discount_code: str | None = None,\n    tax_rate: Any = \"0.10\",\n) -> InvoiceTotal:\n    \"\"\"Compute an invoice.\n\n    items: iterable of (name, unit_price, quantity). unit_price is a string like\n    \"19.99\" (Decimal and int are also accepted; float is rejected to avoid binary\n    rounding error). Strings must be plain ASCII decimals: exponent notation\n    (\"1e2\"), underscores and non-ASCII digits are rejected.\n    tax_rate: same accepted types as unit_price; defaults to \"0.10\" (10%).\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes (exact, case-sensitive match): \"SAVE10\" = 10% off the\n      subtotal; \"FLAT5\" = 5.00 off the subtotal (the discount never exceeds the\n      subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      the subtotal is rounded once after summing, not per line;\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError;\n      negative zero is treated as zero\n    - unit_price and tax_rate are also limited to at most 30 significant digits\n      and a magnitude below 10**31 (adjusted exponent <= 30), otherwise ValueError\n    - quantity must be a positive int no greater than 10**9, otherwise ValueError;\n      empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        iterator = iter(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    items = list(iterator)\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_CONTEXT):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n\n            subtotal = Decimal(\"0\")\n            for item in items:\n                if not isinstance(item, (tuple, list)) or len(item) != 3:\n                    raise ValueError(\n                        f\"each item must be (name, unit_price, quantity), got {item!r}\"\n                    )\n                name, unit_price, quantity = item\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(f\"invalid quantity for {name}: {quantity!r}\")\n                price = _to_decimal(unit_price, f\"unit_price for {name}\")\n                subtotal += price * quantity\n            subtotal = _round(subtotal)\n\n            if discount_code == \"SAVE10\":\n                discount = _round(subtotal * SAVE10_RATE)\n            elif discount_code == \"FLAT5\":\n                discount = min(FLAT5_AMOUNT, subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except InvalidOperation:\n        raise ValueError(\"invoice values are out of range\") from None\n\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 17, "tests_failed": 0, "code_lines": 140, "cum_changed": 138, "review_cost": 0.0636959, "parse_ok": true, "findings": [{"title": "Digit-limit check counts trailing zeros, contradicting the documented 'significant digits' / adjusted-exponent rule", "severity": "low", "detail": "`len(result.as_tuple().digits) > MAX_DIGITS` counts stored digits, including trailing zeros, not significant digits. The docstring promises a limit of at most 30 significant digits and a magnitude below 10**31 (adjusted <= 30). Inputs such as \"1000000000000000000000000000000\" or `10**30` (31 stored digits, adjusted == 30) or \"1.00000000000000000000000000000\" are rejected as out of range. The equivalent `Decimal('1E+30')` is accepted, so behaviour depends on representation. Fix: normalize before counting (with care for exponent), or reword the docs to say 'at most 30 stored digits'."}, {"title": "Unbounded cost for huge int / extreme-exponent Decimal inputs before range check", "severity": "low", "detail": "`Decimal(value)` on a very large `int` (e.g. `10**1_000_000`) is converted in full before the digit/magnitude limits are checked. Quadratic-time conversion, or Python's int limits, can make this slow or raise a non-ValueError. Checking `value.bit_length()` (or comparing against a bound) before conversion would keep the 'raises ValueError when out of range' contract cheap and reliable. Decimals with extreme negative exponents (e.g. `1E-1000000`) also pass the range check, since only the upper bound is enforced."}, {"title": "String normalization is looser than documented", "severity": "low", "detail": "`value.strip()` removes all Unicode whitespace (and newlines) before the ASCII-only regex runs. Strings such as \" 19.99\\n\" or ones padded with \\u2003 are accepted, although the docstring says only plain ASCII decimal notation is allowed and never mentions whitespace tolerance. Either document the tolerance or strip only ASCII whitespace."}, {"title": "Local context is not fully independent of global state", "severity": "low", "detail": "`Context(prec=100, rounding=...)` takes traps, Emin and Emax from `decimal.DefaultContext`, which can be mutated globally. The comment says results never depend on the global decimal context, which is not strictly true. Pass `traps=[InvalidOperation, DivisionByZero, Overflow]`, `Emax` and `Emin` explicitly. Also, only `InvalidOperation` is converted to ValueError, so another trapped signal would escape as a `decimal.DecimalException`."}, {"title": "Item name is unvalidated and formatted into error messages", "severity": "low", "detail": "`name` is never type-checked, yet it is interpolated with `{name}` into error messages. An object whose `__str__` or `__repr__` raises would replace the intended ValueError with a different exception, and very long or odd names can produce huge messages. The type hint says `str`, but nothing enforces it."}, {"title": "Parameter reassigned and error behaviour for non-iterable items mid-iteration", "severity": "low", "detail": "`items` is rebound from the caller's iterable to a list, which hides the original type from readers and type checkers. Exceptions raised while iterating a generator (other than the `iter()` TypeError) propagate unchanged rather than as ValueError, so the error contract is inconsistent."}], "review_text": "", "fix_cost": 0.0849555, "fix_ok": true, "step_changed": 121, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    DecimalException,\n    DivisionByZero,\n    InvalidOperation,\n    Overflow,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom typing import Any, Iterable, TypedDict\n\nCENT = Decimal(\"0.01\")\nSAVE10_RATE = Decimal(\"0.10\")\nFLAT5_AMOUNT = Decimal(\"5.00\")\n\nMAX_DIGITS = 30\nMAX_ADJUSTED = 30\nMAX_DECIMAL_PLACES = 60\nMAX_QUANTITY = 10**9\n\n# Every int below 10**(MAX_ADJUSTED + 1) fits in this many bits.\n_MAX_INT_BITS = (10 ** (MAX_ADJUSTED + 1)).bit_length()\n\n_ASCII_WHITESPACE = \" \\t\\n\\r\\f\\v\"\n_MAX_REPR_LEN = 60\n\n# Local context, fully specified so results never depend on the global decimal context.\n_CONTEXT = Context(\n    prec=100,\n    rounding=ROUND_HALF_UP,\n    Emin=-999999,\n    Emax=999999,\n    capitals=1,\n    clamp=0,\n    flags=[],\n    traps=[InvalidOperation, DivisionByZero, Overflow],\n)\n\n# Plain ASCII decimal notation only: no underscores, exponents, or non-ASCII digits.\n_DECIMAL_RE = re.compile(r\"[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\")\n\n\nclass InvoiceTotal(TypedDict):\n    subtotal: Decimal\n    discount: Decimal\n    tax: Decimal\n    total: Decimal\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _fmt(value: Any) -> str:\n    \"\"\"Return a short, safe description of value for error messages.\"\"\"\n    if isinstance(value, int):\n        if value.bit_length() > 128:\n            return f\"<int of {value.bit_length()} bits>\"\n        text = int.__repr__(value)\n    elif isinstance(value, str):\n        text = str.__repr__(value)\n    elif isinstance(value, Decimal):\n        text = Decimal.__repr__(value)\n    else:\n        return f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _to_decimal(value: Any, label: str) -> Decimal:\n    \"\"\"Convert a str/Decimal/int to a finite, non-negative Decimal or raise ValueError.\n\n    Strings must use plain ASCII decimal notation (e.g. \"19.99\"), optionally\n    surrounded by ASCII whitespace; exponent notation, underscores and\n    non-ASCII digits (including non-ASCII whitespace) are rejected.\n    \"\"\"\n    if isinstance(value, bool) or not isinstance(value, (str, Decimal, int)):\n        raise ValueError(\n            f\"{label} must be a str, Decimal or int, got {type(value).__name__}\"\n        )\n    if isinstance(value, str):\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _DECIMAL_RE.fullmatch(text):\n            raise ValueError(f\"invalid {label}: {_fmt(value)}\")\n        value = text\n    elif isinstance(value, int):\n        # Cheap range check before the (potentially expensive) conversion.\n        if value < 0:\n            raise ValueError(f\"{label} must not be negative: {_fmt(value)}\")\n        if value.bit_length() > _MAX_INT_BITS:\n            raise ValueError(f\"{label} is out of range: {_fmt(value)}\")\n    try:\n        result = Decimal(value)\n    except InvalidOperation:\n        raise ValueError(f\"invalid {label}: {_fmt(value)}\") from None\n    if not result.is_finite():\n        raise ValueError(f\"{label} must be finite: {_fmt(value)}\")\n    if result < 0:\n        raise ValueError(f\"{label} must not be negative: {_fmt(value)}\")\n    if result.is_zero():\n        # Covers negative zero and zeros with arbitrary exponents.\n        return Decimal(0)\n    if result.adjusted() > MAX_ADJUSTED:\n        raise ValueError(f\"{label} is out of range: {_fmt(value)}\")\n    _, digits, exponent = result.as_tuple()\n    trailing = 0\n    for digit in reversed(digits):\n        if digit != 0:\n            break\n        trailing += 1\n    significant = len(digits) - trailing\n    if significant > MAX_DIGITS or exponent + trailing < -MAX_DECIMAL_PLACES:\n        raise ValueError(f\"{label} is out of range: {_fmt(value)}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple[str, Any, int]],\n    discount_code: str | None = None,\n    tax_rate: Any = \"0.10\",\n) -> InvoiceTotal:\n    \"\"\"Compute an invoice.\n\n    items: iterable of (name, unit_price, quantity). name must be a str.\n    unit_price is a string like \"19.99\" (Decimal and int are also accepted;\n    float is rejected to avoid binary rounding error). Strings must be plain\n    ASCII decimals, optionally surrounded by ASCII whitespace: exponent\n    notation (\"1e2\"), underscores and non-ASCII digits are rejected.\n    tax_rate: same accepted types as unit_price; defaults to \"0.10\" (10%).\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes (exact, case-sensitive match): \"SAVE10\" = 10% off the\n      subtotal; \"FLAT5\" = 5.00 off the subtotal (the discount never exceeds the\n      subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      the subtotal is rounded once after summing, not per line;\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError;\n      negative zero is treated as zero\n    - unit_price and tax_rate are also limited to at most 30 significant digits\n      (trailing zeros are not counted), a magnitude below 10**31 (adjusted\n      exponent <= 30) and at most 60 decimal places (ignoring trailing zeros),\n      otherwise ValueError\n    - quantity must be a positive int no greater than 10**9, otherwise ValueError;\n      empty items -> ValueError\n    - an items iterable that raises while being consumed -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    try:\n        iterator = iter(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    try:\n        item_list = list(iterator)\n    except Exception as exc:\n        raise ValueError(\n            f\"could not read items: {type(exc).__name__}\"\n        ) from None\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n\n    try:\n        with localcontext(_CONTEXT):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n\n            subtotal = Decimal(\"0\")\n            for item in item_list:\n                if not isinstance(item, (tuple, list)) or len(item) != 3:\n                    raise ValueError(\n                        \"each item must be (name, unit_price, quantity), \"\n                        f\"got {type(item).__name__}\"\n                    )\n                name, unit_price, quantity = item\n                if not isinstance(name, str):\n                    raise ValueError(\n                        f\"item name must be a str, got {type(name).__name__}\"\n                    )\n                label_name = _fmt(name)\n                if (\n                    not isinstance(quantity, int)\n                    or isinstance(quantity, bool)\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {label_name}: {_fmt(quantity)}\"\n                    )\n                price = _to_decimal(unit_price, f\"unit_price for {label_name}\")\n                subtotal += price * quantity\n            subtotal = _round(subtotal)\n\n            if discount_code == \"SAVE10\":\n                discount = _round(subtotal * SAVE10_RATE)\n            elif discount_code == \"FLAT5\":\n                discount = min(FLAT5_AMOUNT, subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except DecimalException:\n        raise ValueError(\"invoice values are out of range\") from None\n\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 17, "tests_failed": 0, "code_lines": 209, "cum_changed": 217, "review_cost": 0.06719739999999999, "parse_ok": true, "findings": [{"title": "Context precision is not guaranteed for unbounded item counts", "severity": "low", "detail": "Per-line validation bounds each price to adjusted <= 30 and 60 decimal places, so a line total spans at most 100 digits (10^39 down to 10^-60). That exactly matches prec=100. Summing many large lines can carry into a 101st digit, and the context does not trap Inexact or Rounded, so the subtotal is rounded silently. The effect is far below one cent and practically unreachable, but it contradicts the stated design that results never depend on context limits. Raising prec (for example to 150), or trapping Inexact on the summation, would remove the edge case."}, {"title": "_fmt builds the full repr of an arbitrarily large string before truncating", "severity": "low", "detail": "str.__repr__(value) is computed over the whole string and only then cut to 60 characters. A multi-megabyte item name or price string costs O(n) time and memory on every call. Because label_name = _fmt(name) runs for every item, even valid ones, and the f-string for the price label is also built per item, this adds avoidable overhead and a cheap amplification vector. Slice the string first (value[:_MAX_REPR_LEN + 1]) and build labels lazily, only on error."}, {"title": "No bound on the number of items or the size of input strings", "severity": "low", "detail": "The iterable is fully materialized with list(iterator), and price strings are regex-matched and converted to Decimal before any length check. Untrusted input can therefore consume large amounts of memory or CPU. The numeric limits (digits, exponent) are enforced only after parsing. Add a maximum item count and a maximum string length (for example, reject text longer than about 100 characters before the regex)."}, {"title": "Errors from iter(items) other than TypeError are not wrapped", "severity": "low", "detail": "The docstring promises ValueError for an iterable that fails while being consumed. Only list(iterator) is protected by a broad except. A custom __iter__ that raises something other than TypeError escapes unwrapped, so the contract is applied inconsistently."}, {"title": "Type hints and docstring disagree with accepted input", "severity": "low", "detail": "The items parameter is typed as tuple[str, Any, int], but lists of length 3 are also accepted. The quantity annotation is int, yet int subclasses such as IntEnum pass the isinstance check. discount_code is typed str | None, but any non-str value silently means no discount. A typo or wrong type in a discount code therefore produces no error and no discount."}, {"title": "tax_rate has no upper bound and its unit is ambiguous", "severity": "low", "detail": "tax_rate is only limited by the generic magnitude check (below 10^31). A caller who passes 10 or 8.5 meaning a percentage gets a 1000% or 850% tax with no error, while the default is the fraction '0.10'. A sanity cap (for example, rate <= 1) or an explicit documented unit would prevent silent misuse."}], "review_text": "", "fix_cost": 0.079867, "fix_ok": true, "step_changed": 110, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    DecimalException,\n    DivisionByZero,\n    InvalidOperation,\n    Overflow,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom itertools import islice\nfrom typing import Any, Iterable, TypedDict\n\nCENT = Decimal(\"0.01\")\nSAVE10_RATE = Decimal(\"0.10\")\nFLAT5_AMOUNT = Decimal(\"5.00\")\nMAX_TAX_RATE = Decimal(\"1\")\n\nMAX_DIGITS = 30\nMAX_ADJUSTED = 30\nMAX_DECIMAL_PLACES = 60\nMAX_QUANTITY = 10**9\nMAX_ITEMS = 100_000\nMAX_TEXT_LEN = 100\nMAX_NAME_LEN = 1000\n\n# Every int below 10**(MAX_ADJUSTED + 1) fits in this many bits.\n_MAX_INT_BITS = (10 ** (MAX_ADJUSTED + 1)).bit_length()\n\n_ASCII_WHITESPACE = \" \\t\\n\\r\\f\\v\"\n_MAX_REPR_LEN = 60\n\n# Local context, fully specified so results never depend on the global decimal context.\n# A line total spans at most 100 digits and at most MAX_ITEMS lines are summed,\n# so prec=150 leaves ample headroom: the summation is always exact.\n_CONTEXT = Context(\n    prec=150,\n    rounding=ROUND_HALF_UP,\n    Emin=-999999,\n    Emax=999999,\n    capitals=1,\n    clamp=0,\n    flags=[],\n    traps=[InvalidOperation, DivisionByZero, Overflow],\n)\n\n# Plain ASCII decimal notation only: no underscores, exponents, or non-ASCII digits.\n_DECIMAL_RE = re.compile(r\"[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\")\n\n\nclass InvoiceTotal(TypedDict):\n    subtotal: Decimal\n    discount: Decimal\n    tax: Decimal\n    total: Decimal\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _fmt(value: Any) -> str:\n    \"\"\"Return a short, safe description of value for error messages.\"\"\"\n    if isinstance(value, int):\n        if value.bit_length() > 128:\n            return f\"<int of {value.bit_length()} bits>\"\n        text = int.__repr__(value)\n    elif isinstance(value, str):\n        # Slice before repr so huge strings are never fully processed.\n        text = str.__repr__(str.__getitem__(value, slice(0, _MAX_REPR_LEN + 1)))\n    elif isinstance(value, Decimal):\n        text = Decimal.__repr__(value)\n    else:\n        return f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _to_decimal(value: Any, label: str, item_name: str | None = None) -> Decimal:\n    \"\"\"Convert a str/Decimal/int to a finite, non-negative Decimal or raise ValueError.\n\n    Strings must use plain ASCII decimal notation (e.g. \"19.99\"), optionally\n    surrounded by ASCII whitespace, and be at most MAX_TEXT_LEN characters long;\n    exponent notation, underscores and non-ASCII digits (including non-ASCII\n    whitespace) are rejected.\n\n    If item_name is given, error messages read \"<label> for <item name>\"; the\n    description is only built when an error occurs.\n    \"\"\"\n\n    def describe() -> str:\n        if item_name is None:\n            return label\n        return f\"{label} for {_fmt(item_name)}\"\n\n    if isinstance(value, bool) or not isinstance(value, (str, Decimal, int)):\n        raise ValueError(\n            f\"{describe()} must be a str, Decimal or int, got {type(value).__name__}\"\n        )\n    if isinstance(value, str):\n        if len(value) > MAX_TEXT_LEN:\n            raise ValueError(f\"{describe()} is too long: {_fmt(value)}\")\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _DECIMAL_RE.fullmatch(text):\n            raise ValueError(f\"invalid {describe()}: {_fmt(value)}\")\n        value = text\n    elif isinstance(value, int):\n        # Cheap range check before the (potentially expensive) conversion.\n        if value < 0:\n            raise ValueError(f\"{describe()} must not be negative: {_fmt(value)}\")\n        if value.bit_length() > _MAX_INT_BITS:\n            raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    try:\n        result = Decimal(value)\n    except InvalidOperation:\n        raise ValueError(f\"invalid {describe()}: {_fmt(value)}\") from None\n    if not result.is_finite():\n        raise ValueError(f\"{describe()} must be finite: {_fmt(value)}\")\n    if result < 0:\n        raise ValueError(f\"{describe()} must not be negative: {_fmt(value)}\")\n    if result.is_zero():\n        # Covers negative zero and zeros with arbitrary exponents.\n        return Decimal(0)\n    if result.adjusted() > MAX_ADJUSTED:\n        raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    _, digits, exponent = result.as_tuple()\n    trailing = 0\n    for digit in reversed(digits):\n        if digit != 0:\n            break\n        trailing += 1\n    significant = len(digits) - trailing\n    if significant > MAX_DIGITS or exponent + trailing < -MAX_DECIMAL_PLACES:\n        raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple[str, Any, int] | list[Any]],\n    discount_code: str | None = None,\n    tax_rate: Any = \"0.10\",\n) -> InvoiceTotal:\n    \"\"\"Compute an invoice.\n\n    items: iterable of (name, unit_price, quantity); each item is a tuple or a\n    list of length 3. name must be a str of at most 1000 characters.\n    unit_price is a string like \"19.99\" (Decimal and int are also accepted;\n    float is rejected to avoid binary rounding error). Strings must be at most\n    100 characters and plain ASCII decimals, optionally surrounded by ASCII\n    whitespace: exponent notation (\"1e2\"), underscores and non-ASCII digits are\n    rejected.\n    tax_rate: same accepted types as unit_price, expressed as a fraction (not a\n    percentage) between 0 and 1 inclusive; defaults to \"0.10\" (10%). Values\n    above 1 -> ValueError.\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - at most 100000 items are accepted, otherwise ValueError\n    - discount_code must be a str or None, otherwise ValueError. Codes (exact,\n      case-sensitive match): \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off\n      the subtotal (the discount never exceeds the subtotal); any other str or\n      None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      the subtotal is rounded once after summing, not per line;\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError;\n      negative zero is treated as zero\n    - unit_price and tax_rate are also limited to at most 30 significant digits\n      (trailing zeros are not counted), a magnitude below 10**31 (adjusted\n      exponent <= 30) and at most 60 decimal places (ignoring trailing zeros),\n      otherwise ValueError\n    - quantity must be a positive int (exactly int, not bool or another int\n      subclass) no greater than 10**9, otherwise ValueError;\n      empty items -> ValueError\n    - an items iterable that raises while being created or consumed -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if discount_code is not None and not isinstance(discount_code, str):\n        raise ValueError(\n            f\"discount_code must be a str or None, got {type(discount_code).__name__}\"\n        )\n    try:\n        iterator = iter(items)\n    except TypeError:\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    except Exception as exc:\n        raise ValueError(\n            f\"could not read items: {type(exc).__name__}\"\n        ) from None\n    try:\n        item_list = list(islice(iterator, MAX_ITEMS + 1))\n    except Exception as exc:\n        raise ValueError(\n            f\"could not read items: {type(exc).__name__}\"\n        ) from None\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n    if len(item_list) > MAX_ITEMS:\n        raise ValueError(f\"too many items (maximum {MAX_ITEMS})\")\n\n    try:\n        with localcontext(_CONTEXT):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n            if rate > MAX_TAX_RATE:\n                raise ValueError(\n                    \"tax_rate must be a fraction between 0 and 1, \"\n                    f\"got {_fmt(tax_rate)}\"\n                )\n\n            subtotal = Decimal(\"0\")\n            for item in item_list:\n                if not isinstance(item, (tuple, list)) or len(item) != 3:\n                    raise ValueError(\n                        \"each item must be (name, unit_price, quantity), \"\n                        f\"got {type(item).__name__}\"\n                    )\n                name, unit_price, quantity = item\n                if not isinstance(name, str):\n                    raise ValueError(\n                        f\"item name must be a str, got {type(name).__name__}\"\n                    )\n                if len(name) > MAX_NAME_LEN:\n                    raise ValueError(\n                        f\"item name is too long (maximum {MAX_NAME_LEN} characters)\"\n                    )\n                if (\n                    type(quantity) is not int\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {_fmt(name)}: {_fmt(quantity)}\"\n                    )\n                price = _to_decimal(unit_price, \"unit_price\", name)\n                subtotal += price * quantity\n            subtotal = _round(subtotal)\n\n            if discount_code == \"SAVE10\":\n                discount = _round(subtotal * SAVE10_RATE)\n            elif discount_code == \"FLAT5\":\n                discount = min(FLAT5_AMOUNT, subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except DecimalException:\n        raise ValueError(\"invoice values are out of range\") from None\n\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 4, "tests_passed": 17, "tests_failed": 0, "code_lines": 253, "cum_changed": 263, "review_cost": 0.061147200000000006, "parse_ok": true, "findings": [{"title": "Unbounded work on Decimal inputs with very many digits", "severity": "low", "detail": "`_to_decimal` bounds str length (MAX_TEXT_LEN) and int bit length, but a Decimal argument has no length bound. A Decimal such as `Decimal('0.' + '1'*10_000_000)` passes the `adjusted() > MAX_ADJUSTED` check, because its adjusted exponent is -1. The code then calls `as_tuple()` and walks the whole digit tuple before rejecting it for too many significant digits. This costs time and memory proportional to the input size, which is inconsistent with the care taken for str and int. Check `len(result.as_tuple().digits)` or the `_int` digit count cheaply first, or reject Decimals with more than MAX_DIGITS + MAX_DECIMAL_PLACES digits up front."}, {"title": "TypeError raised while creating the iterator is reported as 'not an iterable'", "severity": "low", "detail": "`except TypeError` around `iter(items)` assumes any TypeError means items is not iterable. A custom `__iter__` that raises TypeError internally gets the misleading message 'items must be an iterable...'. It is still a ValueError, so the contract holds, but the diagnostic is wrong. Use a separate check, or report the exception type as the generic handler does."}, {"title": "MAX_TEXT_LEN is applied before whitespace stripping", "severity": "low", "detail": "A valid number padded with ASCII whitespace can be rejected as 'too long' even though the stripped text is within the limit. The docstring says strings may be 'optionally surrounded by ASCII whitespace' and at most 100 characters, so the behaviour is defensible but ambiguous. Either document that padding counts toward the limit or strip first."}], "review_text": "", "fix_cost": 0.09511830000000002, "fix_ok": true, "step_changed": 35, "code_after": "import re\nfrom decimal import (\n    Context,\n    Decimal,\n    DecimalException,\n    DivisionByZero,\n    InvalidOperation,\n    Overflow,\n    ROUND_HALF_UP,\n    localcontext,\n)\nfrom itertools import islice\nfrom typing import Any, Iterable, TypedDict\n\nCENT = Decimal(\"0.01\")\nSAVE10_RATE = Decimal(\"0.10\")\nFLAT5_AMOUNT = Decimal(\"5.00\")\nMAX_TAX_RATE = Decimal(\"1\")\n\nMAX_DIGITS = 30\nMAX_ADJUSTED = 30\nMAX_DECIMAL_PLACES = 60\nMAX_QUANTITY = 10**9\nMAX_ITEMS = 100_000\nMAX_TEXT_LEN = 100\nMAX_NAME_LEN = 1000\n\n# Every int below 10**(MAX_ADJUSTED + 1) fits in this many bits.\n_MAX_INT_BITS = (10 ** (MAX_ADJUSTED + 1)).bit_length()\n\n_ASCII_WHITESPACE = \" \\t\\n\\r\\f\\v\"\n_MAX_REPR_LEN = 60\n\n# Local context, fully specified so results never depend on the global decimal context.\n# A line total spans at most 100 digits and at most MAX_ITEMS lines are summed,\n# so prec=150 leaves ample headroom: the summation is always exact.\n_CONTEXT = Context(\n    prec=150,\n    rounding=ROUND_HALF_UP,\n    Emin=-999999,\n    Emax=999999,\n    capitals=1,\n    clamp=0,\n    flags=[],\n    traps=[InvalidOperation, DivisionByZero, Overflow],\n)\n\n# Quantum used to cheaply bound the digit count of Decimal inputs.\n_PLACES_QUANTUM = Decimal(1).scaleb(-MAX_DECIMAL_PLACES)\n\n# Plain ASCII decimal notation only: no underscores, exponents, or non-ASCII digits.\n_DECIMAL_RE = re.compile(r\"[+-]?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)\")\n\n\nclass InvoiceTotal(TypedDict):\n    subtotal: Decimal\n    discount: Decimal\n    tax: Decimal\n    total: Decimal\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef _fmt(value: Any) -> str:\n    \"\"\"Return a short, safe description of value for error messages.\"\"\"\n    if isinstance(value, int):\n        if value.bit_length() > 128:\n            return f\"<int of {value.bit_length()} bits>\"\n        text = int.__repr__(value)\n    elif isinstance(value, str):\n        # Slice before repr so huge strings are never fully processed.\n        text = str.__repr__(str.__getitem__(value, slice(0, _MAX_REPR_LEN + 1)))\n    elif isinstance(value, Decimal):\n        text = Decimal.__repr__(value)\n    else:\n        return f\"<{type(value).__name__}>\"\n    if len(text) > _MAX_REPR_LEN:\n        text = text[:_MAX_REPR_LEN] + \"...\"\n    return text\n\n\ndef _to_decimal(value: Any, label: str, item_name: str | None = None) -> Decimal:\n    \"\"\"Convert a str/Decimal/int to a finite, non-negative Decimal or raise ValueError.\n\n    Strings must use plain ASCII decimal notation (e.g. \"19.99\"), optionally\n    surrounded by ASCII whitespace, and be at most MAX_TEXT_LEN characters long\n    including any surrounding whitespace; exponent notation, underscores and\n    non-ASCII digits (including non-ASCII whitespace) are rejected.\n\n    If item_name is given, error messages read \"<label> for <item name>\"; the\n    description is only built when an error occurs.\n    \"\"\"\n\n    def describe() -> str:\n        if item_name is None:\n            return label\n        return f\"{label} for {_fmt(item_name)}\"\n\n    if isinstance(value, bool) or not isinstance(value, (str, Decimal, int)):\n        raise ValueError(\n            f\"{describe()} must be a str, Decimal or int, got {type(value).__name__}\"\n        )\n    is_decimal = False\n    if isinstance(value, str):\n        if len(value) > MAX_TEXT_LEN:\n            raise ValueError(f\"{describe()} is too long: {_fmt(value)}\")\n        text = value.strip(_ASCII_WHITESPACE)\n        if not _DECIMAL_RE.fullmatch(text):\n            raise ValueError(f\"invalid {describe()}: {_fmt(value)}\")\n        value = text\n    elif isinstance(value, int):\n        # Cheap range check before the (potentially expensive) conversion.\n        if value < 0:\n            raise ValueError(f\"{describe()} must not be negative: {_fmt(value)}\")\n        if value.bit_length() > _MAX_INT_BITS:\n            raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    else:\n        is_decimal = True\n    try:\n        result = Decimal(value)\n    except InvalidOperation:\n        raise ValueError(f\"invalid {describe()}: {_fmt(value)}\") from None\n    if not result.is_finite():\n        raise ValueError(f\"{describe()} must be finite: {_fmt(value)}\")\n    if result < 0:\n        raise ValueError(f\"{describe()} must not be negative: {_fmt(value)}\")\n    if result.is_zero():\n        # Covers negative zero and zeros with arbitrary exponents.\n        return Decimal(0)\n    if result.adjusted() > MAX_ADJUSTED:\n        raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    if is_decimal:\n        # A Decimal may carry arbitrarily many digits. Quantizing to the allowed\n        # number of places (at most MAX_ADJUSTED + 1 + MAX_DECIMAL_PLACES digits)\n        # is done in C without building a digit tuple; if it is not exact, the\n        # value has too many decimal places. Continue with the bounded value.\n        quantized = result.quantize(_PLACES_QUANTUM, context=_CONTEXT)\n        if quantized != result:\n            raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n        result = quantized\n    _, digits, exponent = result.as_tuple()\n    trailing = 0\n    for digit in reversed(digits):\n        if digit != 0:\n            break\n        trailing += 1\n    significant = len(digits) - trailing\n    if significant > MAX_DIGITS or exponent + trailing < -MAX_DECIMAL_PLACES:\n        raise ValueError(f\"{describe()} is out of range: {_fmt(value)}\")\n    return result\n\n\ndef invoice_total(\n    items: Iterable[tuple[str, Any, int] | list[Any]],\n    discount_code: str | None = None,\n    tax_rate: Any = \"0.10\",\n) -> InvoiceTotal:\n    \"\"\"Compute an invoice.\n\n    items: iterable of (name, unit_price, quantity); each item is a tuple or a\n    list of length 3. name must be a str of at most 1000 characters.\n    unit_price is a string like \"19.99\" (Decimal and int are also accepted;\n    float is rejected to avoid binary rounding error). Strings must be at most\n    100 characters (surrounding whitespace counts toward the limit) and plain\n    ASCII decimals, optionally surrounded by ASCII whitespace: exponent\n    notation (\"1e2\"), underscores and non-ASCII digits are rejected.\n    tax_rate: same accepted types as unit_price, expressed as a fraction (not a\n    percentage) between 0 and 1 inclusive; defaults to \"0.10\" (10%). Values\n    above 1 -> ValueError.\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - at most 100000 items are accepted, otherwise ValueError\n    - discount_code must be a str or None, otherwise ValueError. Codes (exact,\n      case-sensitive match): \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off\n      the subtotal (the discount never exceeds the subtotal); any other str or\n      None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      the subtotal is rounded once after summing, not per line;\n      total = subtotal - discount + tax\n    - unit_price and tax_rate must be finite and non-negative, otherwise ValueError;\n      negative zero is treated as zero\n    - unit_price and tax_rate are also limited to at most 30 significant digits\n      (trailing zeros are not counted), a magnitude below 10**31 (adjusted\n      exponent <= 30) and at most 60 decimal places (ignoring trailing zeros),\n      otherwise ValueError\n    - quantity must be a positive int (exactly int, not bool or another int\n      subclass) no greater than 10**9, otherwise ValueError;\n      empty items -> ValueError\n    - an items iterable that raises while being created or consumed -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if discount_code is not None and not isinstance(discount_code, str):\n        raise ValueError(\n            f\"discount_code must be a str or None, got {type(discount_code).__name__}\"\n        )\n    try:\n        iterator = iter(items)\n    except TypeError as exc:\n        item_type = type(items)\n        if hasattr(item_type, \"__iter__\") or hasattr(item_type, \"__getitem__\"):\n            # The object claims to be iterable; the TypeError came from inside.\n            raise ValueError(\n                f\"could not read items: {type(exc).__name__}\"\n            ) from None\n        raise ValueError(\"items must be an iterable of (name, unit_price, quantity)\") from None\n    except Exception as exc:\n        raise ValueError(\n            f\"could not read items: {type(exc).__name__}\"\n        ) from None\n    try:\n        item_list = list(islice(iterator, MAX_ITEMS + 1))\n    except Exception as exc:\n        raise ValueError(\n            f\"could not read items: {type(exc).__name__}\"\n        ) from None\n    if not item_list:\n        raise ValueError(\"items must not be empty\")\n    if len(item_list) > MAX_ITEMS:\n        raise ValueError(f\"too many items (maximum {MAX_ITEMS})\")\n\n    try:\n        with localcontext(_CONTEXT):\n            rate = _to_decimal(tax_rate, \"tax_rate\")\n            if rate > MAX_TAX_RATE:\n                raise ValueError(\n                    \"tax_rate must be a fraction between 0 and 1, \"\n                    f\"got {_fmt(tax_rate)}\"\n                )\n\n            subtotal = Decimal(\"0\")\n            for item in item_list:\n                if not isinstance(item, (tuple, list)) or len(item) != 3:\n                    raise ValueError(\n                        \"each item must be (name, unit_price, quantity), \"\n                        f\"got {type(item).__name__}\"\n                    )\n                name, unit_price, quantity = item\n                if not isinstance(name, str):\n                    raise ValueError(\n                        f\"item name must be a str, got {type(name).__name__}\"\n                    )\n                if len(name) > MAX_NAME_LEN:\n                    raise ValueError(\n                        f\"item name is too long (maximum {MAX_NAME_LEN} characters)\"\n                    )\n                if (\n                    type(quantity) is not int\n                    or quantity <= 0\n                    or quantity > MAX_QUANTITY\n                ):\n                    raise ValueError(\n                        f\"invalid quantity for {_fmt(name)}: {_fmt(quantity)}\"\n                    )\n                price = _to_decimal(unit_price, \"unit_price\", name)\n                subtotal += price * quantity\n            subtotal = _round(subtotal)\n\n            if discount_code == \"SAVE10\":\n                discount = _round(subtotal * SAVE10_RATE)\n            elif discount_code == \"FLAT5\":\n                discount = min(FLAT5_AMOUNT, subtotal)\n            else:\n                discount = Decimal(\"0.00\")\n\n            tax = _round((subtotal - discount) * rate)\n            total = subtotal - discount + tax\n    except DecimalException:\n        raise ValueError(\"invoice values are out of range\") from None\n\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "sonnet", "prompt": "open", "rep": 1, "round": 5, "tests_passed": 17, "tests_failed": 0, "code_lines": 274, "cum_changed": 284, "review_cost": 0.0808509, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0506674, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0405894, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0256665, "parse_ok": true, "findings": [{"title": "Per-key state is never evicted, so memory grows without bound", "severity": "high", "detail": "Timestamps are only pruned inside allow() for the same key. A key that is never seen again keeps its deque of expired timestamps forever. Even for keys that are pruned, the empty deque and the `_last_seen` entry are never deleted. With many distinct keys (user IDs, IPs), `_hits` and `_last_seen` grow forever. This violates the requirement that timestamps that can no longer matter must not accumulate. Fix: delete the key from `_hits` when its deque empties, and add a periodic sweep or a purge method that drops keys whose newest hit is outside the window. `_last_seen` needs similar handling, but it must still enforce monotonic time. It can be dropped once it can no longer affect results, or be bounded another way."}, {"title": "State is mutated before the input is validated", "severity": "medium", "detail": "`_last_seen[key] = now` runs before `now` is used in arithmetic. If `now` is a non-numeric value such as None or a str, `now - self.window` raises TypeError after `_last_seen` has already been overwritten with the bad value. Later calls for that key then fail on the `now < last` comparison, or silently misbehave. A NaN `now` is worse: every `now < last` comparison is False, so the monotonic check is disabled for that key. Validate `now` (a finite real number) up front and update `_last_seen` only after the call succeeds."}, {"title": "allow() creates state for keys on every call, including rejected or invalid ones", "severity": "low", "detail": "`self._hits[key]` on a defaultdict inserts an empty deque, and `_last_seen` is written even when the request is rejected. This adds to the memory growth above, and an attacker who can choose keys can inflate the dicts cheaply. Use `.get()` and create entries only when a hit is recorded."}, {"title": "bool is accepted for limit and window_seconds", "severity": "low", "detail": "`isinstance(True, int)` is True, so `SlidingWindowLimiter(True, True)` passes validation as limit=1, window=1. The spec says positive ints, so bools should be rejected with an explicit `isinstance(x, bool)` check."}, {"title": "Not thread-safe", "severity": "low", "detail": "The read-modify-write sequence on `_last_seen` and the deque (prune, check length, append) is not atomic. Concurrent callers on the same key can exceed `limit` or trigger spurious 'time went backwards' errors. Add a lock if the limiter is shared across threads, or document that it is single-threaded."}], "review_text": "", "fix_cost": 0.040380900000000004, "fix_ok": true, "step_changed": 88, "code_after": "import math\nimport threading\nfrom collections import deque\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - Instances are thread-safe; all state changes happen under a lock.\n    - Idle keys are evicted by a periodic sweep (and by purge()). Eviction is\n      measured against the highest `now` seen across all keys, so callers are\n      assumed to share one clock. Once a key has been idle for a full window\n      relative to that clock, its monotonic-time record is forgotten too.\n    \"\"\"\n\n    _SWEEP_EVERY = 1024\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._max_now = None\n        self._calls_since_sweep = 0\n        self._lock = threading.Lock()\n\n    @staticmethod\n    def _check_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real) or not math.isfinite(now):\n            raise ValueError(\"now must be a finite real number\")\n\n    def _purge_locked(self):\n        if self._max_now is None:\n            return\n        horizon = self._max_now - self.window\n        for key in [k for k, h in self._hits.items() if not h or h[-1] <= horizon]:\n            del self._hits[key]\n        for key in [k for k, t in self._last_seen.items() if t <= horizon]:\n            del self._last_seen[key]\n        self._calls_since_sweep = 0\n\n    def purge(self):\n        \"\"\"Drop state for keys that can no longer affect results.\"\"\"\n        with self._lock:\n            self._purge_locked()\n\n    def allow(self, key, now):\n        self._check_now(now)\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            hits = self._hits.get(key)\n            if hits is not None:\n                cutoff = now - self.window\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n                if not hits:\n                    del self._hits[key]\n                    hits = None\n\n            if hits is not None and len(hits) >= self.limit:\n                allowed = False\n            else:\n                if hits is None:\n                    hits = self._hits[key] = deque()\n                hits.append(now)\n                allowed = True\n\n            self._last_seen[key] = now\n            if self._max_now is None or now > self._max_now:\n                self._max_now = now\n\n            self._calls_since_sweep += 1\n            if self._calls_since_sweep >= self._SWEEP_EVERY:\n                self._purge_locked()\n            return allowed\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 101, "cum_changed": 88, "review_cost": 0.038381399999999996, "parse_ok": true, "findings": [{"title": "Purge uses the global max `now`, so it can drop hits that still count for a key", "severity": "high", "detail": "The spec only requires `now` to be monotonic per key, so keys may be on different clocks. `_purge_locked` computes `horizon = self._max_now - self.window` from the highest `now` seen on any key and deletes every key whose last hit is <= horizon. Example: window=50, key A has a hit at 100, and key B is called at 200. A sweep or `purge()` then deletes A's hits. A later `allow('A', 120)` is valid for A, since 120 >= 100, and the hit at 100 lies in (70, 120] and should count. It is gone, so the limiter allows requests it should reject. The docstring's 'callers share one clock' caveat contradicts the stated rule that keys are independent. It also lets one caller with a far-future timestamp reset every other key's limits, which is a rate-limit bypass."}, {"title": "Forgetting `_last_seen` lets backwards time be accepted, violating the ValueError rule", "severity": "medium", "detail": "Once a key's `_last_seen` entry is purged, `allow(key, earlier_now)` no longer raises 'time went backwards'. The spec says `now` must never be earlier than the latest `now` already seen for that key. The code instead accepts the stale timestamp, records it, and corrupts the key's ordered deque, so `hits[0]` and `hits[-1]` stop reflecting the time order. Because eviction uses the global max, the behavior depends on unrelated keys' traffic and is non-deterministic from the caller's view."}, {"title": "Huge int `now` raises OverflowError instead of ValueError", "severity": "low", "detail": "`_check_now` calls `math.isfinite(now)` on any `Real`. For an int too large for a float (e.g. `10**400`) this raises OverflowError, not the documented ValueError. Other exotic `Real` types, such as a Fraction with a huge numerator, can fail the same way. Wrap the call or check the type more narrowly."}, {"title": "Full O(n) sweep runs under the lock on the request path", "severity": "low", "detail": "Every 1024th call scans all keys and builds list comprehensions while holding the single global lock. With many keys this causes periodic latency spikes and blocks all threads. A single lock also serializes all keys. Incremental eviction or a background sweep would avoid this."}, {"title": "Mixed numeric types and float timestamps can give inconsistent window edges", "severity": "low", "detail": "`Real` admits int, float, Fraction and numpy scalars. Comparisons like `hits[0] <= now - window` mix them. Float rounding can then misclassify a hit exactly `window_seconds` old, which breaks the half-open window rule. The code does not normalize or document this, and there are no tests for the boundary."}], "review_text": "", "fix_cost": 0.0434584, "fix_ok": true, "step_changed": 64, "code_after": "import threading\nfrom collections import deque\nfrom fractions import Fraction\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - Each key has its own clock. No key's state is ever evicted based on\n      another key's timestamps, so one key's traffic can never affect another\n      key's limits or its monotonic-time check.\n    - Timestamps are normalized to exact Fractions, so window edges are exact\n      for int, float, Fraction and other Real inputs (a float is compared by\n      its exact binary value).\n    - Per-key memory is bounded: expired hits are dropped whenever that key is\n      accessed, and at most `limit` hits are stored per key. A key's last\n      timestamp is retained so that backwards time is always rejected; it\n      cannot be safely forgotten without a clock for that key.\n    - Instances are thread-safe; every operation is O(limit) or better and\n      runs under a single lock, with no full sweeps on the request path.\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._lock = threading.Lock()\n\n    @staticmethod\n    def _normalize_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            try:\n                return Fraction(now)\n            except TypeError:\n                return Fraction(float(now))\n        except (ValueError, OverflowError, TypeError, ZeroDivisionError):\n            raise ValueError(\"now must be a finite real number\") from None\n\n    def purge(self):\n        \"\"\"Drop empty per-key hit records.\n\n        Keys are on independent clocks, so no non-empty state can be proven\n        irrelevant here; expired hits are removed when their key is next used.\n        \"\"\"\n        with self._lock:\n            for key in [k for k, h in self._hits.items() if not h]:\n                del self._hits[key]\n\n    def allow(self, key, now):\n        now = self._normalize_now(now)\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            hits = self._hits.get(key)\n            if hits is not None:\n                cutoff = now - self.window\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n\n            if hits is not None and len(hits) >= self.limit:\n                allowed = False\n            else:\n                if hits is None:\n                    hits = self._hits[key] = deque()\n                hits.append(now)\n                allowed = True\n\n            self._last_seen[key] = now\n            return allowed\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 15, "tests_failed": 0, "code_lines": 97, "cum_changed": 84, "review_cost": 0.0330403, "parse_ok": true, "findings": [{"title": "Per-key state is never evicted, so memory grows with the number of distinct keys", "severity": "high", "detail": "The spec says timestamps that can no longer matter must not accumulate forever. Here `_hits` and `_last_seen` gain an entry for every key ever seen and nothing removes them. Idle keys keep up to `limit` expired Fractions in `_hits`, which `allow` only drops when that same key is used again. `_last_seen` keeps one Fraction per key permanently. With high-cardinality keys (IPs, user IDs, request-scoped IDs) this is an unbounded leak. The docstring's claim that per-key memory is bounded hides that total memory is not. The expired `_hits` entries are plainly removable. `_last_seen` has to be kept for the monotonic check, but only until the key's clock could matter, and the class gives no bounded way to handle that."}, {"title": "purge() is effectively a no-op", "severity": "medium", "detail": "`purge` only deletes keys whose deque is empty. `allow` never leaves a deque empty: it either appends the new hit or leaves the deque at `len >= limit`, which is at least 1. So no empty deque ever exists and `purge` removes nothing. It also does not touch `_last_seen` and does not drop expired hits from idle keys. Callers who run it to bound memory get no effect. At minimum it should take a `now` per key (or a reference time) and drop expired hits and the entries that become empty."}, {"title": "Rejected requests advance the key's last-seen clock", "severity": "low", "detail": "`_last_seen[key] = now` is set even when the request is denied. This matches the literal wording 'latest now already seen', but it means a denied call with a far-future timestamp permanently blocks later calls with earlier timestamps for that key. The behavior is undocumented for the denied case and may surprise callers."}, {"title": "Input validation order and unhashable keys", "severity": "low", "detail": "`now` is validated before the lock, but `key` is never validated. An unhashable key raises a raw TypeError from the dict lookup rather than a clear error. It is raised before any state changes, so nothing is corrupted, but the failure is unclear."}, {"title": "Fraction timestamps and a single global lock add overhead", "severity": "low", "detail": "Every call does Fraction construction and arithmetic, and each stored hit is a Fraction object. That costs noticeably more CPU and memory than floats or ints. All keys also share one lock, so unrelated keys contend with each other under concurrency. Per-key locks or sharding would avoid this, but it is a performance concern rather than a correctness one."}], "review_text": "", "fix_cost": 0.0475632, "fix_ok": true, "step_changed": 78, "code_after": "import threading\nfrom collections import deque\nfrom fractions import Fraction\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - `key` must be hashable, otherwise TypeError.\n    - Each key has its own clock. No key's state is ever evicted based on\n      another key's timestamps, so one key's traffic can never affect another\n      key's limits or its monotonic-time check.\n    - The key's clock advances on every call, including denied ones: a denied\n      call with a far-future `now` still counts as \"seen\", and later calls for\n      that key with earlier timestamps raise ValueError.\n    - Timestamps are normalized to exact Fractions, so window edges are exact\n      for int, float, Fraction and other Real inputs (a float is compared by\n      its exact binary value).\n    - Per-key memory is bounded by `limit` hits plus one clock value, and\n      expired hits are dropped whenever that key is accessed. The number of\n      keys is NOT bounded automatically: a key's clock cannot be forgotten\n      without a reference time that no later call will go below. Callers with\n      many distinct keys should call purge(now) periodically, promising that\n      no later call (for any key) will use a timestamp earlier than `now`.\n      That removes every key whose state can no longer matter.\n    - Instances are thread-safe; allow is O(limit) or better and runs under a\n      single lock, with no full sweeps on the request path.\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        # key -> [deque of hit timestamps, latest now seen for the key]\n        self._state = {}\n        self._lock = threading.Lock()\n\n    @staticmethod\n    def _normalize_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            try:\n                return Fraction(now)\n            except TypeError:\n                return Fraction(float(now))\n        except (ValueError, OverflowError, TypeError, ZeroDivisionError):\n            raise ValueError(\"now must be a finite real number\") from None\n\n    def purge(self, now=None):\n        \"\"\"Drop state that can no longer affect any result.\n\n        Without `now`, expired hits are dropped relative to each key's own\n        clock (always safe); every key's clock is kept.\n\n        With `now`, the caller promises that no later call for any key will\n        use a timestamp earlier than `now`. Hits expired as of `now` are\n        dropped, and keys whose clock is not ahead of `now` and that have no\n        remaining hits are removed entirely.\n        \"\"\"\n        if now is not None:\n            now = self._normalize_now(now)\n        with self._lock:\n            for key in list(self._state):\n                hits, last = self._state[key]\n                ref = last if now is None else max(now, last) if False else (\n                    last if now is None else now\n                )\n                cutoff = ref - self.window\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n                if now is not None and not hits and last <= now:\n                    del self._state[key]\n\n    def allow(self, key, now):\n        now = self._normalize_now(now)\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n        with self._lock:\n            entry = self._state.get(key)\n            if entry is None:\n                hits = deque()\n            else:\n                hits, last = entry\n                if now < last:\n                    raise ValueError(\"time went backwards\")\n\n            cutoff = now - self.window\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            if len(hits) >= self.limit:\n                allowed = False\n            else:\n                hits.append(now)\n                allowed = True\n\n            self._state[key] = [hits, now]\n            return allowed\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 3, "tests_passed": 14, "tests_failed": 1, "code_lines": 123, "cum_changed": 112, "review_cost": 0.046008799999999996, "parse_ok": true, "findings": [{"title": "Convoluted dead expression computing `ref` in purge()", "severity": "medium", "detail": "`ref = last if now is None else max(now, last) if False else (last if now is None else now)` contains a branch guarded by `if False` and tests `now is None` twice. It reduces to `ref = last if now is None else now`. This looks like a leftover from an abandoned `max(now, last)` design. It is hard to read, and it hides whether the author intended `now` or `max(now, last)` as the reference. Both are safe here, but the intent should be explicit. Replace it with the simple conditional."}, {"title": "purge() without `now` is effectively a no-op", "severity": "low", "detail": "allow() already drops expired hits relative to the key's clock on every access. The last access also leaves the clock at `last`, so a no-arg purge() finds nothing to remove. It still takes the global lock and sweeps every key. It also never removes any key, so it does not address the unbounded key growth the docstring warns about. Either remove this mode or document that it does nothing useful."}, {"title": "purge(now) holds the global lock for a full O(keys) sweep", "severity": "low", "detail": "With many distinct keys, which is the case purge exists for, the sweep blocks every allow() call for its whole duration. This is a latency spike on the request path whenever purge is called. Consider chunked sweeps or releasing the lock periodically. At minimum, document the cost."}, {"title": "Unbounded growth in the number of keys unless the caller cooperates", "severity": "low", "detail": "Memory is bounded per key but not overall. Without periodic purge(now) calls with a safe reference time, state for every key ever seen is retained forever, which conflicts with the rule that timestamps that can no longer matter must not accumulate forever. This is documented and is a deliberate trade-off. It is still a leak risk for callers who do not read the notes, for example with attacker-controlled keys."}, {"title": "Key hashing and equality run user code under the lock; validation order is inconsistent", "severity": "low", "detail": "`hash(key)` is checked outside the lock, then dict get/set hashes it again and calls `__eq__` while the lock is held. A key type with a slow or misbehaving `__hash__`/`__eq__` can stall or deadlock other callers, for example if it re-enters the limiter. Also, `now` is validated before `key`, so a call with both invalid arguments raises ValueError rather than TypeError. That ordering is not specified."}, {"title": "Exact Fraction arithmetic adds per-call overhead", "severity": "low", "detail": "Every call builds a Fraction and does Fraction comparisons and subtractions. This is much slower than float or int arithmetic. Floats with large exponents or tiny values produce big numerators and denominators. This is correct and meets the stated exactness requirement, but it is a throughput cost on a hot path."}], "review_text": "", "fix_cost": 0.04524039999999999, "fix_ok": true, "step_changed": 63, "code_after": "import threading\nfrom collections import deque\nfrom fractions import Fraction\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `key` must be hashable, otherwise TypeError. The key is validated before\n      `now`, so a call with both arguments invalid raises TypeError.\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - Each key has its own clock. No key's state is ever evicted based on\n      another key's timestamps, so one key's traffic can never affect another\n      key's limits or its monotonic-time check.\n    - The key's clock advances on every call, including denied ones: a denied\n      call with a far-future `now` still counts as \"seen\", and later calls for\n      that key with earlier timestamps raise ValueError.\n    - Timestamps are normalized to exact Fractions, so window edges are exact\n      for int, float, Fraction and other Real inputs (a float is compared by\n      its exact binary value). This costs more per call than float or int\n      arithmetic, and floats with large exponents or tiny values produce large\n      numerators and denominators. Exactness is deliberately preferred over\n      raw throughput.\n    - Per-key memory is bounded by `limit` hits plus one clock value, and\n      expired hits are dropped whenever that key is accessed. The number of\n      keys is NOT bounded automatically: a key's clock cannot be forgotten\n      without a reference time that no later call will go below. Callers with\n      many distinct keys, and especially callers whose keys are influenced by\n      untrusted input, MUST call purge(now) periodically, promising that no\n      later call (for any key) will use a timestamp earlier than `now`.\n      That removes every key whose state can no longer matter. Without such\n      calls, state for every key ever seen is retained forever.\n    - Keys are hashed and compared while the internal lock is held, so key\n      types with slow or misbehaving __hash__/__eq__ (or ones that re-enter the\n      limiter) can stall other callers. Use plain immutable keys.\n    - Instances are thread-safe; allow is O(limit) or better and runs under a\n      single lock. purge(now) sweeps in chunks and releases the lock between\n      chunks, so it does not block allow() for the whole sweep.\n    \"\"\"\n\n    _PURGE_CHUNK = 1024\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        # key -> [deque of hit timestamps, latest now seen for the key]\n        self._state = {}\n        self._lock = threading.Lock()\n\n    @staticmethod\n    def _normalize_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            try:\n                return Fraction(now)\n            except TypeError:\n                return Fraction(float(now))\n        except (ValueError, OverflowError, TypeError, ZeroDivisionError):\n            raise ValueError(\"now must be a finite real number\") from None\n\n    def purge(self, now=None):\n        \"\"\"Drop state that can no longer affect any result.\n\n        Without `now` this does nothing: allow() already drops expired hits\n        for a key on every access, and no key can be removed without a\n        reference time. It is kept only for compatibility.\n\n        With `now`, the caller promises that no later call for any key will\n        use a timestamp earlier than `now`. Hits expired as of `now` are\n        dropped, and keys whose clock is not ahead of `now` and that have no\n        remaining hits are removed entirely. The sweep is O(keys) but runs in\n        chunks, releasing the lock between chunks so allow() calls can\n        interleave.\n        \"\"\"\n        if now is None:\n            return\n        now = self._normalize_now(now)\n        cutoff = now - self.window\n        with self._lock:\n            keys = list(self._state)\n        for start in range(0, len(keys), self._PURGE_CHUNK):\n            with self._lock:\n                for key in keys[start:start + self._PURGE_CHUNK]:\n                    entry = self._state.get(key)\n                    if entry is None:\n                        continue\n                    hits, last = entry\n                    while hits and hits[0] <= cutoff:\n                        hits.popleft()\n                    if not hits and last <= now:\n                        del self._state[key]\n\n    def allow(self, key, now):\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n        now = self._normalize_now(now)\n        with self._lock:\n            entry = self._state.get(key)\n            if entry is None:\n                hits = deque()\n            else:\n                hits, last = entry\n                if now < last:\n                    raise ValueError(\"time went backwards\")\n\n            cutoff = now - self.window\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            if len(hits) >= self.limit:\n                allowed = False\n            else:\n                hits.append(now)\n                allowed = True\n\n            self._state[key] = [hits, now]\n            return allowed\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 4, "tests_passed": 14, "tests_failed": 1, "code_lines": 142, "cum_changed": 131, "review_cost": 0.039438600000000004, "parse_ok": true, "findings": [{"title": "Key set is unbounded unless callers cooperate with purge(now)", "severity": "low", "detail": "Per-key state is retained forever unless the caller periodically calls purge(now) with a promise about future timestamps. A caller with untrusted keys who never purges, or who cannot make that promise, has an unbounded memory leak. The docstring documents this and it follows from the per-key monotonic clock rule, so it is a design limitation, not a bug. It conflicts somewhat with the spec line 'timestamps that can no longer matter must not accumulate forever', which holds per key but not across keys."}, {"title": "purge() snapshots all keys while holding the lock", "severity": "low", "detail": "`keys = list(self._state)` runs under the lock and is O(keys). The docstring says the sweep releases the lock between chunks, but this initial copy blocks allow() for the whole copy. For millions of keys this is a noticeable pause, though much shorter than a full sweep."}, {"title": "Exact Fraction arithmetic lets extreme floats inflate cost", "severity": "low", "detail": "Floats such as 1e308 or 5e-324 become Fractions with huge numerators or denominators. If `now` is influenced by untrusted input, every comparison and subtraction on such values becomes expensive while the lock is held. The docstring accepts this tradeoff, but the cost is paid under the global lock, so one slow call stalls all keys."}, {"title": "purge(now=None) is a silent no-op", "severity": "low", "detail": "Calling purge() with no argument does nothing, which can mislead callers into thinking they have bounded memory. Raising an error or removing the default would be safer than keeping it 'for compatibility'."}, {"title": "Single global lock serializes all keys", "severity": "low", "detail": "Keys are independent, but all allow() calls contend on one lock, and key hash/eq runs under it. This limits throughput under heavy multithreaded load. Sharding the lock by key hash would help, but the current behavior matches the documented contract."}], "review_text": "", "fix_cost": 0.0591064, "fix_ok": true, "step_changed": 85, "code_after": "import threading\nfrom collections import deque\nfrom fractions import Fraction\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `key` must be hashable, otherwise TypeError. The key is validated before\n      `now`, so a call with both arguments invalid raises TypeError.\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - Each key has its own clock. No key's state is ever evicted based on\n      another key's timestamps, so one key's traffic can never affect another\n      key's limits or its monotonic-time check.\n    - The key's clock advances on every call, including denied ones: a denied\n      call with a far-future `now` still counts as \"seen\", and later calls for\n      that key with earlier timestamps raise ValueError.\n    - Timestamps are normalized to exact Fractions, so window edges are exact\n      for int, float, Fraction and other Real inputs (a float is compared by\n      its exact binary value). This costs more per call than float or int\n      arithmetic, and floats with large exponents or tiny values produce large\n      numerators and denominators. Exactness is deliberately preferred over\n      raw throughput. Normalization and the window cutoff are computed before\n      any lock is taken, but comparisons against stored values still happen\n      under a lock, so extreme values only stall keys in the same shard.\n    - Per-key memory is bounded by `limit` hits plus one clock value, and\n      expired hits are dropped whenever that key is accessed. The number of\n      keys is NOT bounded automatically: a key's clock cannot be forgotten\n      without a reference time that no later call will go below. This is an\n      inherent consequence of the per-key monotonic clock rule. Callers with\n      many distinct keys, and especially callers whose keys are influenced by\n      untrusted input, MUST call purge(now) periodically, promising that no\n      later call (for any key) will use a timestamp earlier than `now`.\n      That removes every key whose state can no longer matter. Without such\n      calls, state for every key ever seen is retained forever.\n    - Keys are hashed outside the lock, but are still compared (and rehashed\n      by the dict) while an internal lock is held, so key types with slow or\n      misbehaving __hash__/__eq__ (or ones that re-enter the limiter) can\n      stall other callers. Use plain immutable keys.\n    - Instances are thread-safe. State is split into independent shards chosen\n      by key hash, each with its own lock, so keys in different shards do not\n      contend. allow is O(limit) or better. purge(now) works shard by shard,\n      sweeping in chunks and releasing the lock between chunks, so it never\n      blocks allow() for more than a small part of the sweep.\n    \"\"\"\n\n    _PURGE_CHUNK = 1024\n    _SHARDS = 64\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        # per shard: key -> [deque of hit timestamps, latest now seen for the key]\n        self._shards = [({}, threading.Lock()) for _ in range(self._SHARDS)]\n\n    @staticmethod\n    def _normalize_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            try:\n                return Fraction(now)\n            except TypeError:\n                return Fraction(float(now))\n        except (ValueError, OverflowError, TypeError, ZeroDivisionError):\n            raise ValueError(\"now must be a finite real number\") from None\n\n    def purge(self, now=None):\n        \"\"\"Drop state that can no longer affect any result.\n\n        `now` is required; calling purge() without it raises ValueError,\n        because no key can be removed without a reference time.\n\n        The caller promises that no later call for any key will use a\n        timestamp earlier than `now`. Hits expired as of `now` are dropped,\n        and keys whose clock is not ahead of `now` and that have no remaining\n        hits are removed entirely. The sweep is O(keys) but runs shard by\n        shard and in chunks, releasing the lock between chunks so allow()\n        calls can interleave.\n        \"\"\"\n        if now is None:\n            raise ValueError(\"purge requires a reference time `now`\")\n        now = self._normalize_now(now)\n        cutoff = now - self.window\n        for state, lock in self._shards:\n            with lock:\n                keys = list(state)\n            for start in range(0, len(keys), self._PURGE_CHUNK):\n                with lock:\n                    for key in keys[start:start + self._PURGE_CHUNK]:\n                        entry = state.get(key)\n                        if entry is None:\n                            continue\n                        hits, last = entry\n                        while hits and hits[0] <= cutoff:\n                            hits.popleft()\n                        if not hits and last <= now:\n                            del state[key]\n\n    def allow(self, key, now):\n        try:\n            h = hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n        now = self._normalize_now(now)\n        cutoff = now - self.window\n        state, lock = self._shards[h % self._SHARDS]\n        with lock:\n            entry = state.get(key)\n            if entry is None:\n                hits = deque()\n            else:\n                hits, last = entry\n                if now < last:\n                    raise ValueError(\"time went backwards\")\n\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            if len(hits) >= self.limit:\n                allowed = False\n            else:\n                hits.append(now)\n                allowed = True\n\n            state[key] = [hits, now]\n            return allowed\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 5, "tests_passed": 14, "tests_failed": 1, "code_lines": 149, "cum_changed": 138, "review_cost": 0.0430087, "parse_ok": true, "findings": [{"title": "purge() snapshots each shard's keys while holding the shard lock, contradicting the 'small part of the sweep' claim", "severity": "low", "detail": "`with lock: keys = list(state)` is O(keys in shard) and runs under the lock, and it also allocates a full key list per shard. With millions of keys this stalls allow() for that shard for a time proportional to shard size. Only the later per-chunk sweep is bounded. The docstring's guarantee that purge never blocks allow() for more than a small part of the sweep is therefore not strictly true. Iterating in bounded batches (e.g. with itertools.islice and a resumable cursor) or documenting the cost would fix this."}, {"title": "A denied call with a far-future `now` permanently advances the key's clock", "severity": "low", "detail": "The state update `state[key] = [hits, now]` runs even when the request is denied, so one call with a huge timestamp makes every later realistic call for that key raise ValueError. The docstring documents this, but it is a footgun if `now` can be influenced by untrusted input, because it lets a caller lock out a key. Consider not advancing the clock on denied calls, or at least rejecting the call, if the spec allows it."}, {"title": "purge() has a misleading signature: `now=None` is accepted but always an error", "severity": "low", "detail": "`now` is required, yet the signature gives it a default of None only to raise ValueError at runtime. A required positional parameter would be clearer and would produce a normal TypeError for a missing argument instead of a custom ValueError."}, {"title": "Unbounded key growth is left to the caller, and purge() is O(total keys)", "severity": "low", "detail": "State for every key ever seen is retained until the caller calls purge(). Keys whose clock is ahead of the purge time, or that still have live hits, survive. Memory is therefore unbounded under many distinct or attacker-controlled keys unless the caller schedules purge correctly. This is documented as inherent, but nothing in the class helps, such as an optional auto-purge hook or a count of tracked keys for monitoring."}], "review_text": "", "fix_cost": 0.0646858, "fix_ok": true, "step_changed": 80, "code_after": "import threading\nfrom collections import deque\nfrom fractions import Fraction\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Notes:\n    - `key` must be hashable, otherwise TypeError. The key is validated before\n      `now`, so a call with both arguments invalid raises TypeError.\n    - `now` must be a finite real number (bool, NaN and infinities are rejected).\n    - Each key has its own clock. No key's state is ever evicted based on\n      another key's timestamps, so one key's traffic can never affect another\n      key's limits or its monotonic-time check.\n    - The key's clock advances only on allowed calls (the ones that record a\n      hit). A denied call is still checked against the key's clock, but it\n      changes no state, so a denied call with a far-future `now` cannot lock\n      out the key or expire its recorded hits. An allowed call with a\n      far-future `now` does advance the clock, since it records a request.\n    - Timestamps are normalized to exact Fractions, so window edges are exact\n      for int, float, Fraction and other Real inputs (a float is compared by\n      its exact binary value). This costs more per call than float or int\n      arithmetic, and floats with large exponents or tiny values produce large\n      numerators and denominators. Exactness is deliberately preferred over\n      raw throughput. Normalization and the window cutoff are computed before\n      any lock is taken, but comparisons against stored values still happen\n      under a lock, so extreme values only stall keys in the same shard.\n    - Per-key memory is bounded by `limit` hits plus one clock value, and\n      expired hits are dropped whenever an allowed call for that key is made.\n      The number of keys is NOT bounded automatically: a key's clock cannot be\n      forgotten without a reference time that no later call will go below.\n      This is an inherent consequence of the per-key monotonic clock rule.\n      Callers with many distinct keys, and especially callers whose keys are\n      influenced by untrusted input, MUST call purge(now) periodically,\n      promising that no later call (for any key) will use a timestamp earlier\n      than `now`. That removes every key whose state can no longer matter.\n      Without such calls, state for every key ever seen is retained forever.\n      tracked_keys() reports the current number of keys, for monitoring.\n    - Keys are hashed outside the lock, but are still compared (and rehashed\n      by the dict) while an internal lock is held, so key types with slow or\n      misbehaving __hash__/__eq__ (or ones that re-enter the limiter) can\n      stall other callers. Use plain immutable keys.\n    - Instances are thread-safe. State is split into independent shards chosen\n      by key hash, each with its own lock, so keys in different shards do not\n      contend. allow is O(limit) or better. purge(now) works shard by shard.\n      Per shard it takes one key snapshot under the shard lock, which is\n      O(keys in that shard) and allocates a list of that size, so allow() on\n      that shard can stall for that long. The per-key sweep that follows runs\n      in chunks with the lock released between chunks.\n    \"\"\"\n\n    _PURGE_CHUNK = 1024\n    _SHARDS = 64\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        # per shard: key -> [deque of hit timestamps, latest now seen for the key]\n        self._shards = [({}, threading.Lock()) for _ in range(self._SHARDS)]\n\n    @staticmethod\n    def _normalize_now(now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            try:\n                return Fraction(now)\n            except TypeError:\n                return Fraction(float(now))\n        except (ValueError, OverflowError, TypeError, ZeroDivisionError):\n            raise ValueError(\"now must be a finite real number\") from None\n\n    def tracked_keys(self):\n        \"\"\"Return the number of keys currently tracked (for monitoring).\"\"\"\n        total = 0\n        for state, lock in self._shards:\n            with lock:\n                total += len(state)\n        return total\n\n    def purge(self, now):\n        \"\"\"Drop state that can no longer affect any result.\n\n        The caller promises that no later call for any key will use a\n        timestamp earlier than `now`. Hits expired as of `now` are dropped,\n        and keys whose clock is not ahead of `now` and that have no remaining\n        hits are removed entirely. The sweep is O(keys) and runs shard by\n        shard. Each shard's key snapshot is taken under its lock (O(keys in\n        the shard)); the sweep itself is chunked, releasing the lock between\n        chunks so allow() calls can interleave.\n        \"\"\"\n        now = self._normalize_now(now)\n        cutoff = now - self.window\n        for state, lock in self._shards:\n            with lock:\n                keys = list(state)\n            for start in range(0, len(keys), self._PURGE_CHUNK):\n                with lock:\n                    for key in keys[start:start + self._PURGE_CHUNK]:\n                        entry = state.get(key)\n                        if entry is None:\n                            continue\n                        hits, last = entry\n                        while hits and hits[0] <= cutoff:\n                            hits.popleft()\n                        if not hits and last <= now:\n                            del state[key]\n\n    def allow(self, key, now):\n        try:\n            h = hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n        now = self._normalize_now(now)\n        cutoff = now - self.window\n        state, lock = self._shards[h % self._SHARDS]\n        with lock:\n            entry = state.get(key)\n            if entry is None:\n                hits = deque()\n            else:\n                hits, last = entry\n                if now < last:\n                    raise ValueError(\"time went backwards\")\n\n            # Count expired hits without mutating, so a denied call changes\n            # nothing.\n            expired = 0\n            for t in hits:\n                if t > cutoff:\n                    break\n                expired += 1\n\n            if len(hits) - expired >= self.limit:\n                return False\n\n            for _ in range(expired):\n                hits.popleft()\n            hits.append(now)\n            state[key] = [hits, now]\n            return True\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 2, "round": 6, "tests_passed": 14, "tests_failed": 1, "code_lines": 163, "cum_changed": 152, "review_cost": 0.060992399999999995, "parse_ok": true, "findings": [{"title": "Monotonic-time check ignores denied calls, contradicting the stated rule", "severity": "medium", "detail": "The rules say `now` must not be earlier than the latest `now` already seen for that key, otherwise ValueError. The clock (`last`) is only updated on allowed calls. A denied call at now=10 followed by a call at now=5 therefore raises no error, although 10 was already seen for that key. The Notes describe this as deliberate, but it still departs from the Rules, and a spec-driven test would flag it. Either fix the rule text or advance the clock on denied calls too. Advancing it only monotonically would reintroduce the far-future lockout the design avoids, so this is a real tradeoff."}, {"title": "An allowed call with a far-future `now` permanently disrupts that key", "severity": "medium", "detail": "A single allowed call with a huge `now` sets the key's clock and records a hit at that time. Every later realistic timestamp then raises ValueError('time went backwards'), so the key is effectively dead until purge() or a restart. If `now` comes from untrusted input, this is a per-key denial of service. The docstring acknowledges the behavior but gives no guard, such as a maximum forward jump."}, {"title": "Unbounded key growth unless the caller runs purge()", "severity": "medium", "detail": "The state of every key ever seen is retained forever unless the caller calls purge(). The class does not do this itself, so with untrusted or high-cardinality keys it is a memory-exhaustion vector. This is documented, but there is no optional automatic or opportunistic cleanup, such as a max-keys cap or a purge hook driven by the caller's own clock."}, {"title": "purge() snapshots a whole shard under its lock", "severity": "low", "detail": "`keys = list(state)` runs under the shard lock and costs O(keys in shard) in time and memory. With millions of keys this stalls allow() on that shard and allocates a large list, which partly defeats the chunking that follows. Iterating over a bounded slice, or tracking a cursor, would avoid the long pause."}, {"title": "Shard selection uses low bits of hash(key), so skew is possible", "severity": "low", "detail": "`h % 64` uses the low bits of the hash. Integer keys hash to themselves, so keys that are multiples of 64 or share a stride all land in one shard. That removes the parallelism sharding is meant to provide. Mixing the hash first would spread them evenly."}, {"title": "Exact Fraction arithmetic is costly and can be abused", "severity": "low", "detail": "Floats with extreme exponents, such as 1e-300 or 1e300, yield huge numerators or denominators. Normalization happens outside the lock, but the comparisons and the stored Fractions still cost time and memory under the shard lock. A caller who controls `now` can slow every key in that shard. The docstring accepts this tradeoff."}, {"title": "Keys that are not equal to themselves (NaN) leak state", "severity": "low", "detail": "A float('nan') key hashes fine, but distinct NaN objects never compare equal. Each new NaN object creates a new entry that no later call can find, so entries accumulate without bound. If purge() removes them at all, only the expiry logic does, since nothing can look the key up again. Rejecting keys with `key != key` would close this."}, {"title": "Non-reentrant locks held during dict operations on user keys", "severity": "low", "detail": "Key __eq__ and __hash__ run inside state.get and the dict assignment while the shard lock is held. A key whose methods block, or that re-enters the limiter, can deadlock or stall the shard. The class documents this, but it could be avoided by restricting keys to plain immutable types."}], "review_text": "", "stopped": "round_cap"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.014881499999999999, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged", "severity": "high", "detail": "The merge condition `start < merged[-1][1]` only merges strictly overlapping ranges. The spec says ranges whose end equals the next start must be merged, so e.g. [(1,2),(2,3)] is returned unmerged instead of [(1,3)]. The condition should be `start <= merged[-1][1]`."}, {"title": "Adjacent integer ranges are not merged (possible spec ambiguity)", "severity": "low", "detail": "For inclusive integer ranges, (1,2) and (3,4) are contiguous with no gap, but are kept separate. The stated rule only requires merging when end equals start, so this is consistent with the spec, but it should be documented or confirmed that adjacency (end+1 == start) is intentionally not merged."}, {"title": "No validation of input element shape or type", "severity": "low", "detail": "Elements that are not 2-tuples of ints (e.g. wrong length, None, floats, strings) produce an unhelpful unpack error or a TypeError from comparison rather than a clear error. The docstring promises ints only, so this is minor, but bools and floats are silently accepted."}], "review_text": "", "fix_cost": 0.0167933, "fix_ok": true, "step_changed": 13, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - ranges that are merely adjacent (end + 1 == next start) are NOT merged\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    - an element that is not a 2-tuple/list of ints (bools excluded) -> TypeError\n    \"\"\"\n    for item in ranges:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise TypeError(f\"invalid range element: {item!r}\")\n        for value in item:\n            if not isinstance(value, int) or isinstance(value, bool):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(tuple(r) for r in ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 32, "cum_changed": 13, "review_cost": 0.016114999999999997, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0698132, "parse_ok": true, "findings": [{"title": "Missing input type validation for unit_price and tax_rate", "severity": "medium", "detail": "The docstring specifies unit_price should be a string (e.g., '19.99') and tax_rate defaults to a string, but the code does not validate these types. If floats are passed (e.g., 19.99 instead of '19.99'), Decimal() will preserve the float's binary representation, causing precision loss. Decimal(19.99) produces '19.989999999999998436805384398460000514984130859375' instead of '19.99', which could lead to incorrect invoice calculations."}, {"title": "Missing type hints", "severity": "low", "detail": "The function lacks type hints for parameters and return value. Type hints would improve code clarity and enable static type checking to catch type-related issues."}], "review_text": "", "fix_cost": 0.0326723, "fix_ok": true, "step_changed": 10, "code_after": "from __future__ import annotations\nfrom decimal import Decimal, ROUND_HALF_UP\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items: list[tuple[str, str, int]], discount_code: str | None = None, tax_rate: str = \"0.10\") -> dict[str, Decimal]:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(unit_price, str):\n            raise ValueError(f\"unit_price for {name} must be a string\")\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += Decimal(unit_price) * quantity\n    subtotal = _round(subtotal)\n\n    if not isinstance(tax_rate, str):\n        raise ValueError(\"tax_rate must be a string\")\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = min(Decimal(\"5.00\"), subtotal)\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * Decimal(tax_rate))\n    total = subtotal - discount + tax\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 50, "cum_changed": 10, "review_cost": 0.0786957, "parse_ok": true, "findings": [{"title": "FLAT5 discount not explicitly rounded", "severity": "medium", "detail": "The docstring states 'subtotal, discount and tax are each rounded to 2 decimals (half up)', but the FLAT5 discount is not explicitly rounded via _round(). The SAVE10 case correctly uses _round(), but FLAT5 uses min(Decimal('5.00'), subtotal) without rounding. For consistency with the contract and the SAVE10 implementation, this should be: discount = _round(min(Decimal('5.00'), subtotal))"}, {"title": "Invalid unit_price format not validated", "severity": "medium", "detail": "The code validates that unit_price is a string but does not validate that it's a valid Decimal string. If unit_price='abc' is passed, the code raises decimal.InvalidOperation (ConversionSyntax) instead of a ValueError. This is inconsistent with the function's error handling strategy. The validation should catch invalid Decimal strings and raise ValueError with a descriptive message, similar to how quantity validation works."}, {"title": "Total value not rounded", "severity": "low", "detail": "While the docstring specifies that 'subtotal, discount and tax are each rounded to 2 decimals', the total is calculated as `subtotal - discount + tax` without explicit rounding. Although Decimal arithmetic should preserve 2-decimal precision, it would be more explicit and robust to round the total: total = _round(subtotal - discount + tax)"}], "review_text": "", "fix_cost": 0.024779600000000002, "fix_ok": true, "step_changed": 12, "code_after": "from __future__ import annotations\nfrom decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items: list[tuple[str, str, int]], discount_code: str | None = None, tax_rate: str = \"0.10\") -> dict[str, Decimal]:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(unit_price, str):\n            raise ValueError(f\"unit_price for {name} must be a string\")\n        try:\n            unit_price_decimal = Decimal(unit_price)\n        except InvalidOperation:\n            raise ValueError(f\"unit_price for {name} must be a valid Decimal string\")\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += unit_price_decimal * quantity\n    subtotal = _round(subtotal)\n\n    if not isinstance(tax_rate, str):\n        raise ValueError(\"tax_rate must be a string\")\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * Decimal(tax_rate))\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 17, "tests_failed": 0, "code_lines": 54, "cum_changed": 22, "review_cost": 0.0371306, "parse_ok": true, "findings": [{"title": "Uncaught InvalidOperation for tax_rate", "severity": "high", "detail": "The function validates that tax_rate is a string but doesn't catch InvalidOperation when converting it to Decimal on line `Decimal(tax_rate)`. This is inconsistent with unit_price handling (lines 32-34), where InvalidOperation is caught and converted to ValueError. If an invalid tax_rate string like 'not_a_number' is passed, the function will crash with InvalidOperation instead of raising a descriptive ValueError."}, {"title": "Negative unit prices are accepted", "severity": "medium", "detail": "The code validates that unit_price is a valid Decimal string but doesn't check if it's non-negative. In an invoicing context, negative prices are likely unintended and could produce confusing results. The validation of quantity being strictly positive suggests the intent is for normal invoices with all positive values."}, {"title": "No validation for tax_rate reasonableness", "severity": "low", "detail": "The function accepts any valid Decimal for tax_rate without checking for reasonable bounds. Values like '-0.50' or '999999.99' would be accepted without error, even though they represent nonsensical tax rates (negative tax or 99,999,900% tax). While this might be intentional flexibility, it should at least validate that tax_rate is non-negative."}], "review_text": "", "fix_cost": 0.0258629, "fix_ok": true, "step_changed": 10, "code_after": "from __future__ import annotations\nfrom decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items: list[tuple[str, str, int]], discount_code: str | None = None, tax_rate: str = \"0.10\") -> dict[str, Decimal]:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(unit_price, str):\n            raise ValueError(f\"unit_price for {name} must be a string\")\n        try:\n            unit_price_decimal = Decimal(unit_price)\n        except InvalidOperation:\n            raise ValueError(f\"unit_price for {name} must be a valid Decimal string\")\n        if unit_price_decimal < 0:\n            raise ValueError(f\"unit_price for {name} must be non-negative\")\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += unit_price_decimal * quantity\n    subtotal = _round(subtotal)\n\n    if not isinstance(tax_rate, str):\n        raise ValueError(\"tax_rate must be a string\")\n    try:\n        tax_rate_decimal = Decimal(tax_rate)\n    except InvalidOperation:\n        raise ValueError(\"tax_rate must be a valid Decimal string\")\n    if tax_rate_decimal < 0:\n        raise ValueError(\"tax_rate must be non-negative\")\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 17, "tests_failed": 0, "code_lines": 62, "cum_changed": 32, "review_cost": 0.08142859999999999, "parse_ok": true, "findings": [{"title": "Subtotal rounded before discount calculation", "severity": "high", "detail": "The `subtotal` is rounded to 2 decimals on line 30 before the discount is calculated. According to the spec rules, the discount should be applied to the unrounded subtotal, and then subtotal should be rounded after the discount is calculated. This can produce incorrect discount amounts. Example: subtotal 3.345 (unrounded) should give 10% discount of 0.33 (from 3.345 * 0.10 = 0.3345 → 0.33), but the code gives 0.34 (from rounded 3.35 * 0.10 = 0.335 → 0.34)."}], "review_text": "", "fix_cost": 0.026424600000000003, "fix_ok": true, "step_changed": 2, "code_after": "from __future__ import annotations\nfrom decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value: Decimal) -> Decimal:\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items: list[tuple[str, str, int]], discount_code: str | None = None, tax_rate: str = \"0.10\") -> dict[str, Decimal]:\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(unit_price, str):\n            raise ValueError(f\"unit_price for {name} must be a string\")\n        try:\n            unit_price_decimal = Decimal(unit_price)\n        except InvalidOperation:\n            raise ValueError(f\"unit_price for {name} must be a valid Decimal string\")\n        if unit_price_decimal < 0:\n            raise ValueError(f\"unit_price for {name} must be non-negative\")\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += unit_price_decimal * quantity\n\n    if not isinstance(tax_rate, str):\n        raise ValueError(\"tax_rate must be a string\")\n    try:\n        tax_rate_decimal = Decimal(tax_rate)\n    except InvalidOperation:\n        raise ValueError(\"tax_rate must be a valid Decimal string\")\n    if tax_rate_decimal < 0:\n        raise ValueError(\"tax_rate must be non-negative\")\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    subtotal = _round(subtotal)\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 1, "round": 4, "tests_passed": 17, "tests_failed": 0, "code_lines": 62, "cum_changed": 34, "review_cost": 0.0918326, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0231675, "parse_ok": true, "findings": [{"title": "Per-key state is never evicted, so idle keys accumulate forever", "severity": "medium", "detail": "Timestamps are pruned only when allow() is called again for the same key. A key that is never seen again keeps up to `limit` stale timestamps in `_hits` indefinitely. Its `_last_seen` entry also stays forever. With many distinct keys (per-IP or per-user, for example) memory grows without bound, which violates the rule that timestamps that can no longer matter must not accumulate forever. Fix: when a key's deque is empty after pruning, or when the window has fully expired, delete the key from both dicts, and/or add a periodic sweep of idle keys. Note that dropping `_last_seen` for expired keys weakens the monotonic-time check for them, so decide that trade-off deliberately."}, {"title": "NaN `now` disables the backwards-time check", "severity": "low", "detail": "`now` is not validated. If NaN is passed, `now < last` is False and `_last_seen[key]` becomes NaN. Every later comparison against it is False, so the ValueError for time going backwards can never fire again for that key. The deque pruning also misbehaves. Validate that `now` is a finite real number before using it."}, {"title": "bool values are accepted as limit/window_seconds", "severity": "low", "detail": "`isinstance(True, int)` is True, so `SlidingWindowLimiter(True, True)` passes validation and behaves as limit=1, window=1. The spec requires positive ints, so bools should be rejected explicitly (`isinstance(x, bool)`)."}, {"title": "Not thread-safe", "severity": "low", "detail": "The read-modify-write sequence on `_last_seen` and `_hits` is unguarded. Concurrent calls for the same key can exceed `limit` or raise spurious 'time went backwards' errors. Rate limiters are often shared across threads, so a lock should be added, or the single-threaded assumption documented."}], "review_text": "", "fix_cost": 0.0363611, "fix_ok": true, "step_changed": 67, "code_after": "import math\nimport threading\nfrom collections import defaultdict, deque\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must be a finite real number, otherwise ValueError\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Eviction trade-off: idle keys are swept periodically, using the largest\n    `now` seen across all keys as the clock. A key whose newest request is a\n    full window older than that clock is dropped, including its last-seen\n    time, so the monotonic-time check is forgotten for evicted keys. Callers\n    are expected to share one clock across keys.\n\n    The limiter is thread-safe; all state changes happen under a lock.\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n        self._clock = None\n        self._last_sweep = None\n        self._lock = threading.Lock()\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, Real) or not math.isfinite(now):\n            raise ValueError(\"now must be a finite real number\")\n\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n\n            if self._clock is None or now > self._clock:\n                self._clock = now\n            self._sweep()\n\n            self._last_seen[key] = now\n\n            hits = self._hits[key]\n            while hits and hits[0] <= now - self.window:\n                hits.popleft()\n\n            if len(hits) >= self.limit:\n                return False\n            hits.append(now)\n            return True\n\n    def _sweep(self):\n        # Run at most once per window of clock advance to keep cost amortized.\n        if self._last_sweep is not None and self._clock - self._last_sweep < self.window:\n            return\n        self._last_sweep = self._clock\n        cutoff = self._clock - self.window\n        stale = [\n            k for k, hits in self._hits.items() if not hits or hits[-1] <= cutoff\n        ]\n        for k in stale:\n            del self._hits[k]\n            self._last_seen.pop(k, None)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 84, "cum_changed": 67, "review_cost": 0.040227799999999994, "parse_ok": true, "findings": [{"title": "Global-clock eviction can discard hits that still count, so a key can exceed its limit", "severity": "high", "detail": "_sweep() drops any key whose newest hit is <= (global clock - window). The per-key rule only requires `now` to be >= that key's own last-seen time, not the global clock. So a key can legitimately send a `now` below the global clock, and its old hits are still inside the window for that `now`. Example: limit=1, window=10. Key A is hit at t=0, key B is hit at t=100 and triggers a sweep that evicts A, then A is hit at t=5. The first hit at t=0 should still count, but A was wiped, so the second request is wrongly allowed. A single caller with a far-future or bad timestamp advances the shared clock permanently and wipes every other key's history, which bypasses rate limiting for all of them. The docstring's 'callers share one clock' caveat only covers the forgotten monotonic check, not this violation of the core limit guarantee. Evicting only when it is provably safe would avoid it, for example by tracking per-key state and sweeping against a clock that cannot get ahead of the keys."}, {"title": "Evicted keys lose the monotonic-time check", "severity": "low", "detail": "Eviction also deletes `_last_seen`, so a 'time went backwards' request for an evicted key is accepted instead of raising ValueError. This contradicts the stated rule that `now` must not be earlier than the latest `now` seen for that key. The docstring documents it as a trade-off, but it is still a spec deviation and it compounds the issue above."}, {"title": "Huge int `now` raises OverflowError instead of ValueError", "severity": "low", "detail": "`math.isfinite(10**400)` raises OverflowError because the int cannot be converted to float. The spec says any invalid `now` must raise ValueError. The check should catch OverflowError, or handle ints separately, since a huge int is not meaningfully usable here."}, {"title": "Full O(n) sweep runs under the lock", "severity": "low", "detail": "Each sweep scans every key while holding the single lock. Once per window it blocks all callers for time proportional to the number of tracked keys, which causes latency spikes with many keys. The cost is amortized but not bounded per call."}], "review_text": "", "fix_cost": 0.041272699999999996, "fix_ok": true, "step_changed": 58, "code_after": "import math\nimport threading\nfrom collections import deque\nfrom numbers import Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must be a finite real number that can be converted to a float,\n      otherwise ValueError\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    State is strictly per key: no clock is shared between keys, so one key's\n    timestamps can never cause another key's history to be discarded. Each\n    key's hits are pruned against that key's own `now` on every call, which is\n    the only point at which it is provably safe (later calls for the key must\n    use a `now` at least as large). Memory is therefore bounded by one deque of\n    at most `limit` timestamps plus one last-seen value per key. The last-seen\n    value is kept so the monotonic-time check is never forgotten.\n\n    The limiter is thread-safe; all state changes happen under a lock. Each\n    call does O(1) amortized work and never scans other keys.\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, int)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = {}\n        self._last_seen = {}\n        self._lock = threading.Lock()\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            finite = math.isfinite(now)\n        except OverflowError:\n            finite = False\n        if not finite:\n            raise ValueError(\"now must be a finite real number\")\n\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n            self._last_seen[key] = now\n\n            hits = self._hits.get(key)\n            if hits is None:\n                hits = self._hits[key] = deque()\n\n            cutoff = now - self.window\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            if len(hits) >= self.limit:\n                return False\n            hits.append(now)\n            return True\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 15, "tests_failed": 0, "code_lines": 78, "cum_changed": 65, "review_cost": 0.030894500000000005, "parse_ok": true, "findings": [{"title": "Per-key state is never evicted, so memory grows without bound across keys", "severity": "high", "detail": "`_hits` and `_last_seen` gain an entry for every key ever seen and nothing removes it. Pruning only happens when the same key is called again, so a key used once keeps its deque (up to `limit` timestamps) and its last-seen value forever. With many distinct keys (per-IP or per-user ids, for example) memory grows linearly and without limit. This violates the requirement that timestamps that can no longer matter must not accumulate forever. The docstring's 'bounded per key' claim hides that the total is unbounded. Fixing it needs a sweep, an LRU or TTL cap, or amortized cleanup of idle keys. Dropping `_last_seen` entries weakens the monotonic-time check, so that trade-off has to be made explicitly, for example by evicting only keys whose hits have all expired and documenting the relaxed guarantee."}, {"title": "Expired timestamps stay in an idle key's deque until that key is called again", "severity": "medium", "detail": "Entries older than the window are removed only inside `allow` for the same key. A key that stops receiving traffic keeps up to `limit` stale timestamps indefinitely, even though they can no longer affect any decision. There is no background or amortized cleanup path."}, {"title": "Single global lock serializes all keys", "severity": "low", "detail": "Keys are independent, but every call takes one `threading.Lock`, so unrelated keys contend. Per-key locks or sharding would scale better. This is correct but a throughput limit under concurrency."}, {"title": "Unhashable keys raise an unspecified TypeError, and the key type is not documented", "severity": "low", "detail": "A list or dict as `key` fails at `self._last_seen.get(key)` with TypeError, which the spec does not mention. Because validation of `now` happens first and the key is used afterwards, the error behavior is inconsistent. The accepted key types should be documented, or the error handled deliberately."}, {"title": "Integer validation rejects int-like types such as numpy integers", "severity": "low", "detail": "`isinstance(limit, int)` rejects `numpy.int64` and other integral types that implement `numbers.Integral`. This matches the literal spec ('positive ints') but is stricter than may be intended. Checking `numbers.Integral` would be more permissive."}], "review_text": "", "fix_cost": 0.0445277, "fix_ok": true, "step_changed": 79, "code_after": "import math\nimport threading\nfrom collections import deque\nfrom numbers import Integral, Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive integers (any\n      `numbers.Integral`, e.g. numpy integers; bool is rejected), otherwise\n      ValueError\n    - `key` must be hashable, otherwise TypeError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must be a finite real number that can be converted to a float,\n      otherwise ValueError\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Each key's hits are pruned against that key's own `now` on every call, so\n    a key holds at most `limit` timestamps plus one last-seen value.\n\n    Idle-key eviction: the limiter tracks the largest `now` seen on any key\n    (its sweep clock). Periodically, after a number of calls proportional to\n    the number of tracked keys (so the cost is O(1) amortized per call), it\n    removes every key whose last-seen time is at least `window_seconds` older\n    than the sweep clock. All hits of such a key have expired, so evicting it\n    cannot change any allow/deny decision for callers whose time is consistent\n    with the sweep clock. Total memory is thus bounded by the keys active\n    within roughly one window plus a bounded number of not-yet-swept idle keys.\n\n    Relaxed guarantees (explicit trade-off): once a key is evicted its\n    last-seen value is forgotten, so the monotonic-time check for that key is\n    no longer enforced against its old history. A later call with a `now`\n    earlier than the evicted last-seen time is accepted and starts fresh\n    history. Likewise, a caller whose `now` jumps far ahead on one key\n    advances the sweep clock and can cause other idle keys to be evicted.\n    The monotonic check is guaranteed for any key that has been seen within\n    `window_seconds` of the largest `now` seen overall.\n\n    The limiter is thread-safe; all state changes happen under one lock, so\n    calls on different keys are serialized (each call is short).\n    \"\"\"\n\n    _MIN_SWEEP_INTERVAL = 1024\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, Integral) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, Integral)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = int(limit)\n        self.window = int(window_seconds)\n        self._hits = {}\n        self._last_seen = {}\n        self._clock = None\n        self._calls_since_sweep = 0\n        self._lock = threading.Lock()\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            finite = math.isfinite(now)\n        except OverflowError:\n            finite = False\n        if not finite:\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n            self._last_seen[key] = now\n            if self._clock is None or now > self._clock:\n                self._clock = now\n\n            hits = self._hits.get(key)\n            if hits is None:\n                hits = self._hits[key] = deque()\n\n            cutoff = now - self.window\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            allowed = len(hits) < self.limit\n            if allowed:\n                hits.append(now)\n\n            self._calls_since_sweep += 1\n            if self._calls_since_sweep >= max(\n                self._MIN_SWEEP_INTERVAL, len(self._last_seen)\n            ):\n                self._sweep()\n            return allowed\n\n    def _sweep(self):\n        \"\"\"Evict keys idle for a full window. Caller must hold the lock.\"\"\"\n        self._calls_since_sweep = 0\n        cutoff = self._clock - self.window\n        stale = [k for k, seen in self._last_seen.items() if seen <= cutoff]\n        for k in stale:\n            del self._last_seen[k]\n            self._hits.pop(k, None)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 15, "tests_failed": 0, "code_lines": 119, "cum_changed": 112, "review_cost": 0.044429899999999994, "parse_ok": true, "findings": [{"title": "Current key can be evicted in the same call that just recorded its hit", "severity": "medium", "detail": "allow() appends `now` to the key's hits and then may run _sweep(). The sweep cutoff is based on the global clock. If this caller's `now` is at least `window` behind the clock (late or skewed caller, or another key jumped ahead), the key's last_seen is stale, so the key and the hit just granted are deleted immediately. A later call with the same or similar `now` then starts with empty history and is allowed again, so the key can exceed `limit` in its window. Whether this happens depends on where the call falls in the sweep cycle, so enforcement is nondeterministic. The docstring only describes the relaxed guarantee for evicted idle keys, not for the key being served. The sweep should skip the key being processed, or the call should be handled so its hit is never dropped."}, {"title": "A single far-future timestamp on one key poisons the global sweep clock", "severity": "medium", "detail": "`_clock` is the maximum `now` seen on any key. One call with a huge but finite `now` (a bug or an untrusted caller) advances it. The next sweep then evicts every other key, wiping their history and their monotonic-time protection. Other tenants' limits are reset, so an attacker or a buggy client can effectively disable rate limiting for everyone. The behavior is documented, but it is a real robustness and security weakness. Consider a bounded clock advance, per-key eviction only, or eviction driven by a trusted time source."}, {"title": "Full O(n) sweep runs under the global lock", "severity": "low", "detail": "_sweep scans every tracked key while holding the single lock, so all callers on all keys stall during it. The amortized cost is O(1), but with millions of keys the worst-case latency of one call becomes very large. Incremental or chunked eviction would avoid these spikes."}, {"title": "`now` is validated as convertible to float but stored and compared as the original object", "severity": "low", "detail": "The spec says `now` must be convertible to a float, but the raw object (int, Fraction, numpy scalar, etc.) is stored and used in arithmetic and comparisons. Mixed types can give subtly different results than float semantics. Examples are float rounding in `now - window` versus exact big-int or Fraction arithmetic, and numpy scalars retained in state. Normalizing with `now = float(now)` after validation would make behavior uniform. It would also catch Real objects whose __float__ raises something other than OverflowError, which currently escapes as an unexpected exception type."}, {"title": "Hash-equal but semantically different keys, and NaN keys, share or fragment state", "severity": "low", "detail": "Keys such as 1, 1.0 and True collide in the dict and share one limit. A NaN key never equals itself, so separate NaN objects each create a new entry that is only reclaimed by the sweep. This is minor, but it is undocumented and a possible source of surprising limit sharing."}, {"title": "Denied requests still create state and advance sweep/clock bookkeeping", "severity": "low", "detail": "Every call, allowed or denied, updates last_seen, the global clock and the sweep counter. A flood of distinct new keys therefore grows `_last_seen` and `_hits` until the next sweep. Between sweeps the table can roughly double (interval = max(1024, len)), so memory is bounded only loosely."}], "review_text": "", "fix_cost": 0.056074099999999995, "fix_ok": true, "step_changed": 67, "code_after": "import math\nimport threading\nfrom collections import OrderedDict, deque\nfrom numbers import Integral, Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive integers (any\n      `numbers.Integral`, e.g. numpy integers; bool is rejected), otherwise\n      ValueError\n    - `key` must be hashable, otherwise TypeError\n    - keys are compared with normal dict semantics: keys that are equal and\n      hash equal (e.g. 1, 1.0 and True) share one limit, and a NaN key is not\n      equal to other NaN objects, so distinct NaN objects are distinct keys\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must be a finite real number that can be converted to a float,\n      otherwise ValueError; it is normalized with float() before use\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Each key's hits are pruned against that key's own `now` on every call, so\n    a key holds at most `limit` timestamps plus one last-seen value.\n\n    Idle-key eviction: the limiter keeps a sweep clock that follows the\n    largest `now` seen, but advances by at most `window_seconds` per call, so\n    a single far-future timestamp cannot instantly evict every other key.\n    On every call it examines at most a few of the least recently used keys\n    and removes those whose last-seen time is at least `window_seconds` older\n    than the sweep clock (constant work per call, no full scans). All hits of\n    such a key have expired, so evicting it cannot change any allow/deny\n    decision for callers whose time is consistent with the sweep clock. The\n    key being served by the current call is never evicted by that call.\n\n    Relaxed guarantees (explicit trade-off): once a key is evicted its\n    last-seen value is forgotten, so the monotonic-time check for that key is\n    no longer enforced against its old history. A later call with a `now`\n    earlier than the evicted last-seen time is accepted and starts fresh\n    history. A caller that repeatedly submits far-future timestamps can still\n    advance the sweep clock by one window per call and so gradually cause\n    idle keys to be evicted; use trusted timestamps if that matters.\n    The monotonic check is guaranteed for any key that has been seen within\n    `window_seconds` of the sweep clock.\n\n    The limiter is thread-safe; all state changes happen under one lock, so\n    calls on different keys are serialized (each call is short).\n    \"\"\"\n\n    _EVICTIONS_PER_CALL = 4\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, Integral) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, Integral)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = int(limit)\n        self.window = int(window_seconds)\n        self._hits = {}\n        # Ordered from least to most recently used key.\n        self._last_seen = OrderedDict()\n        self._clock = None\n        self._lock = threading.Lock()\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            now = float(now)\n        except (OverflowError, TypeError, ValueError, ArithmeticError):\n            raise ValueError(\"now must be a finite real number\") from None\n        if not math.isfinite(now):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n\n        with self._lock:\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n            self._last_seen[key] = now\n            self._last_seen.move_to_end(key)\n            if self._clock is None:\n                self._clock = now\n            elif now > self._clock:\n                self._clock = min(now, self._clock + self.window)\n\n            hits = self._hits.get(key)\n            if hits is None:\n                hits = self._hits[key] = deque()\n\n            cutoff = now - self.window\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n\n            allowed = len(hits) < self.limit\n            if allowed:\n                hits.append(now)\n\n            self._evict_idle(key)\n            return allowed\n\n    def _evict_idle(self, current):\n        \"\"\"Evict a few idle keys, never `current`. Caller must hold the lock.\"\"\"\n        cutoff = self._clock - self.window\n        for _ in range(self._EVICTIONS_PER_CALL):\n            k = next(iter(self._last_seen))\n            if k is current or k == current or self._last_seen[k] > cutoff:\n                break\n            del self._last_seen[k]\n            self._hits.pop(k, None)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 4, "tests_passed": 15, "tests_failed": 0, "code_lines": 124, "cum_changed": 119, "review_cost": 0.052180899999999995, "parse_ok": true, "findings": [{"title": "First timestamp seeds the sweep clock uncapped, so one far-future call can evict every other key and break limits", "severity": "high", "detail": "`_clock` is set directly to the first `now` seen. Only later advances are capped at `window_seconds` per call. If the first call (or a call on a fresh limiter) uses a large timestamp such as 1e12, every key served afterwards with a normal `now` has `last_seen <= clock - window`. Such a key is evicted as soon as it reaches the LRU head during another key's call. Its hit history is dropped, and the next request starts with an empty deque, so the key can exceed `limit` within one window. This contradicts the docstring's claim that a single far-future timestamp cannot instantly evict other keys. The clock also never decreases, so the damage is permanent for the limiter's lifetime. Fix: initialize the clock conservatively, for example by capping the first step or deriving the clock from keys' own times, or never evict a key that still has unexpired hits."}, {"title": "LRU order is call order, not last-seen-time order, so eviction can stall and idle keys can accumulate", "severity": "medium", "detail": "`_evict_idle` looks only at the head of the `OrderedDict` and stops at the first key whose `last_seen > cutoff`. Different keys may be called with non-monotonic `now` values across keys, which is allowed because only per-key monotonicity is enforced. So the head can have a newer timestamp than keys behind it. For example, key A at now=1000 is followed by key B at now=10. A blocks the sweep and B stays even though it is long idle. Memory is bounded only once the head itself becomes idle. A single key with a far-future `now` at the head can hold up eviction of everything behind it for a long time. The 'must not accumulate forever' guarantee is therefore weaker than documented. Fix: order by last-seen time, for example with a heap or by skipping non-idle heads."}, {"title": "Sweep clock lags behind real time after a legitimate time jump, delaying cleanup", "severity": "low", "detail": "Because the clock advances by at most one window per call, a legitimate gap of many windows (an hour of idleness with a 60s window) leaves the clock far behind `now`. Idle keys then are not considered idle for many subsequent calls, and their stale timestamps (up to `limit` each) stay in memory. This is bounded but is a real retention cost, and it trades against the far-future protection."}, {"title": "Denied requests still mutate state (last-seen and clock), contradicting 'records nothing'", "severity": "low", "detail": "When `allow` returns False, `_last_seen[key]`, the LRU position, and `_clock` have already been updated. The documented contract says a denied call records nothing. Observable effect: after a denied call at now=100, a later call at now=90 raises ValueError, whereas with truly nothing recorded it would not. It also lets denied traffic with far-future times advance the sweep clock. Decide whether this is intended and either document it or defer the updates until after the decision."}, {"title": "Redundant `k == current` can invoke arbitrary user `__eq__` and raise or misbehave after state was mutated", "severity": "low", "detail": "`k is current or k == current` runs a user-defined `__eq__` on every eviction check. A key type with an `__eq__` that raises or returns a non-bool would propagate an exception after `_last_seen`, `_clock` and `_hits` have been partly updated, and after the hit may already have been appended. This leaves the call half-applied. Dict lookups already used equality to find the entry, so the identity check, or comparing against the stored key object, is sufficient."}, {"title": "Keys with a far-future `now` become effectively unusable and linger", "severity": "low", "detail": "A key served with a far-future `now` makes every later normal-time call for that key raise 'time went backwards'. That key is also never idle relative to the capped clock, so it stays in memory. This is partly documented as a trade-off, but it allows an untrusted caller to poison specific keys (a denial of service for that key) with one request."}], "review_text": "", "fix_cost": 0.07848659999999999, "fix_ok": true, "step_changed": 111, "code_after": "import heapq\nimport itertools\nimport math\nimport threading\nfrom collections import deque\nfrom numbers import Integral, Real\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive integers (any\n      `numbers.Integral`, e.g. numpy integers; bool is rejected), otherwise\n      ValueError\n    - `key` must be hashable, otherwise TypeError\n    - keys are compared with normal dict semantics: keys that are equal and\n      hash equal (e.g. 1, 1.0 and True) share one limit, and a NaN key is not\n      equal to other NaN objects, so distinct NaN objects are distinct keys\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n      (a denied call changes no last-seen time, no sweep clock and no\n      ordering; it only discards hits that had already expired)\n    - keys are independent\n    - `now` must be a finite real number that can be converted to a float,\n      otherwise ValueError; it is normalized with float() before use\n    - `now` must not be earlier than the latest `now` already recorded\n      (allowed) for that key, otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Each key's hits are pruned against that key's own `now` on every call, so\n    a key holds at most `limit` timestamps plus one last-seen value.\n\n    Idle-key eviction: keys are kept in a min-heap ordered by last-seen time\n    (stale heap entries are skipped lazily and the heap is compacted when it\n    grows), so the idlest key is always examined first regardless of the order\n    in which keys were called. The limiter keeps a sweep clock that follows\n    the largest recorded `now`, but advances by at most `window_seconds` per\n    recorded call. Eviction uses the reference time min(now, sweep clock), so\n    a far-future timestamp, whether it seeded the clock or arrives later,\n    never makes keys with ordinary timestamps look idle. On every call at most\n    a few keys whose last-seen time is at least `window_seconds` older than the\n    reference time are removed (constant amortized work, no full scans). All\n    hits of such a key have expired, so evicting it cannot change any\n    allow/deny decision for callers whose time is consistent with the\n    reference time. The reference time is always strictly earlier than the\n    current call's `now`, so the key being served is never evicted.\n\n    Relaxed guarantees (explicit trade-offs): once a key is evicted its\n    last-seen value is forgotten, so the monotonic-time check for that key is\n    no longer enforced against its old history. A later call with a `now`\n    earlier than the evicted last-seen time is accepted and starts fresh\n    history. After a legitimate jump of many windows the sweep clock catches\n    up by one window per recorded call, so cleanup of idle keys is delayed\n    accordingly (bounded retention). A key that was served with a far-future\n    `now` rejects later ordinary timestamps as \"time went backwards\" and\n    lingers until the sweep clock reaches it; the limiter cannot tell a bad\n    timestamp from a good one, so use trusted timestamps if that matters.\n    The monotonic check is guaranteed for any key that has been seen within\n    `window_seconds` of the reference time.\n\n    The limiter is thread-safe; all state changes happen under one lock, so\n    calls on different keys are serialized (each call is short).\n    \"\"\"\n\n    _EVICTIONS_PER_CALL = 4\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, Integral) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, Integral)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = int(limit)\n        self.window = int(window_seconds)\n        self._hits = {}\n        # key -> time of the latest recorded (allowed) request.\n        self._last_seen = {}\n        # Min-heap of (last_seen, seq, key); entries that no longer match\n        # _last_seen are stale and skipped lazily. seq avoids comparing keys.\n        self._heap = []\n        self._seq = itertools.count()\n        self._clock = None\n        self._lock = threading.Lock()\n\n    def allow(self, key, now):\n        if isinstance(now, bool) or not isinstance(now, Real):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            now = float(now)\n        except (OverflowError, TypeError, ValueError, ArithmeticError):\n            raise ValueError(\"now must be a finite real number\") from None\n        if not math.isfinite(now):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n\n        with self._lock:\n            # All lookups that may run user __eq__/__hash__ happen before any\n            # state is mutated.\n            last = self._last_seen.get(key)\n            if last is not None and now < last:\n                raise ValueError(\"time went backwards\")\n            hits = self._hits.get(key)\n\n            cutoff = now - self.window\n            if hits is not None:\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n\n            allowed = hits is None or len(hits) < self.limit\n            if allowed:\n                if hits is None:\n                    hits = self._hits[key] = deque()\n                hits.append(now)\n                self._last_seen[key] = now\n                heapq.heappush(self._heap, (now, next(self._seq), key))\n                if self._clock is None:\n                    self._clock = now\n                elif now > self._clock:\n                    self._clock = min(now, self._clock + self.window)\n\n            self._evict_idle(now)\n            return allowed\n\n    def _evict_idle(self, now):\n        \"\"\"Evict a few idle keys. Caller must hold the lock.\"\"\"\n        if self._clock is None:\n            return\n        # Strictly earlier than `now`, so the current key is never idle.\n        cutoff = min(now, self._clock) - self.window\n        heap = self._heap\n        last_seen = self._last_seen\n        evicted = 0\n        while heap and evicted < self._EVICTIONS_PER_CALL:\n            ts, _, k = heap[0]\n            if last_seen.get(k) != ts:\n                heapq.heappop(heap)  # stale entry\n                continue\n            if ts > cutoff:\n                break\n            heapq.heappop(heap)\n            del last_seen[k]\n            self._hits.pop(k, None)\n            evicted += 1\n\n        if len(heap) > 2 * len(last_seen) + 32:\n            self._heap = [(t, next(self._seq), k) for k, t in last_seen.items()]\n            heapq.heapify(self._heap)\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 5, "tests_passed": 15, "tests_failed": 0, "code_lines": 157, "cum_changed": 154, "review_cost": 0.07495059999999999, "parse_ok": true, "findings": [{"title": "Float precision at large timestamps breaks window and eviction invariants", "severity": "medium", "detail": "`now` is converted to float, and `now - window` is computed in float. When `now` is large relative to float spacing (about 1e16 or more with a 1s window, or precision loss on ints above 2**53), `now - window` can round to `now`. Then `hits[0] <= cutoff` expires hits made at the same instant, so the limiter never denies. The sweep clock also stops advancing because `clock + window == clock`. The documented guarantee that the reference time is strictly earlier than `now` fails, and `_evict_idle` can evict the key that was just recorded (its `ts <= cutoff`), dropping its history and last-seen value. This is an extreme edge case. Rejecting or clamping timestamps where `now - window >= now` would close it."}, {"title": "State can become inconsistent if a user key's __eq__/__hash__ raises mid-update", "severity": "low", "detail": "The comment says all user-code lookups happen before mutation, but that is not true. Expired hits are popped before the allow decision. `self._hits[key] = deque()` and `hits.append(now)` run before `self._last_seen[key] = now`, and `_evict_idle` calls `last_seen.get(k)`, `del last_seen[k]` and `self._hits.pop(k)` on keys with arbitrary `__eq__`/`__hash__`. If any of these raises, `_hits` and `_last_seen` can disagree. In `_evict_idle`, a heap entry can be popped while the key stays in `last_seen`, so that key is never evicted. A request can also be recorded while the caller gets an exception."}, {"title": "Eviction runs on denied calls, contradicting the documented denied-call contract", "severity": "low", "detail": "The docstring says a denied call 'changes no last-seen time, no sweep clock and no ordering; it only discards hits that had already expired'. But `allow` calls `_evict_idle(now)` on every call, including denied ones. That removes other keys' `_last_seen`/`_hits` entries and may rebuild the heap. This is probably harmless, but either the code or the docs should change."}, {"title": "Non-reentrant lock can deadlock if a key's __hash__/__eq__ calls back into the limiter", "severity": "low", "detail": "A plain `threading.Lock` is held while user-defined `__hash__`/`__eq__` run via dict operations. If those methods re-enter `allow`, the call deadlocks. An `RLock` would not fix the corruption this could cause, so this should be documented as unsupported."}, {"title": "Decimal and other convertible numeric types are rejected despite the stated rule", "severity": "low", "detail": "The spec says `now` must be a finite real number 'that can be converted to a float'. The code requires `isinstance(now, Real)`, so `decimal.Decimal` (convertible via `float()`) raises ValueError. That may be intended, but the `Real` check makes the try/except around `float()` mostly redundant and the contract ambiguous."}, {"title": "Unbounded stale-entry popping in a single call; stale heap entries accumulate between compactions", "severity": "low", "detail": "Every allowed call pushes a new heap entry, and stale entries are only removed when they reach the top or when the heap exceeds 2*len(last_seen)+32. In `_evict_idle`, stale pops do not count against `_EVICTIONS_PER_CALL`, so a single call can pop many entries. The cost is amortized O(log n) but not constant per call, and the 'constant amortized work' claim in the docstring is slightly loose. Memory is bounded by the compaction threshold."}, {"title": "Idle-key cleanup is lazy and depends on continued traffic", "severity": "low", "detail": "Eviction happens only inside `allow`, at most 4 keys per call, with the sweep clock advancing at most one window per recorded call. After a burst of many keys followed by silence, or after a legitimate large time jump, the state remains until enough later calls arrive. This is documented as a trade-off, but there is no explicit `purge`/`close` method for callers who need bounded memory without traffic."}], "review_text": "", "fix_cost": 0.0710589, "fix_ok": true, "step_changed": 104, "code_after": "import heapq\nimport itertools\nimport math\nimport threading\nfrom collections import deque\nfrom numbers import Integral\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive integers (any\n      `numbers.Integral`, e.g. numpy integers; bool is rejected), otherwise\n      ValueError\n    - `key` must be hashable, otherwise TypeError\n    - keys are compared with normal dict semantics: keys that are equal and\n      hash equal (e.g. 1, 1.0 and True) share one limit, and a NaN key is not\n      equal to other NaN objects, so distinct NaN objects are distinct keys\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n      (a denied call changes no last-seen time, no sweep clock and no\n      ordering; it only discards hits that had already expired of the\n      key being served, and, like every call, may evict other idle keys)\n    - keys are independent\n    - `now` must be a finite number that float() can convert (bool, str and\n      bytes are rejected), otherwise ValueError; it is normalized with\n      float() before use. A timestamp so large that adding or subtracting\n      window_seconds does not change it in floating point is rejected with\n      ValueError, because window arithmetic would be meaningless\n    - `now` must not be earlier than the latest `now` already recorded\n      (allowed) for that key, otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n\n    Each key's hits are pruned against that key's own `now` on every call, so\n    a key holds at most `limit` timestamps plus one last-seen value.\n\n    Idle-key eviction: keys are kept in a min-heap ordered by last-seen time\n    (stale heap entries are skipped lazily and the heap is compacted when it\n    grows), so the idlest key is always examined first regardless of the order\n    in which keys were called. The limiter keeps a sweep clock that follows\n    the largest recorded `now`, but advances by at most `window_seconds` per\n    recorded call. Eviction uses the reference time min(now, sweep clock), so\n    a far-future timestamp, whether it seeded the clock or arrives later,\n    never makes keys with ordinary timestamps look idle. On every call at most\n    a few keys whose last-seen time is at least `window_seconds` older than the\n    reference time are removed (no full scans; amortized O(log n) per call,\n    since skipping stale heap entries is not capped per call). All\n    hits of such a key have expired, so evicting it cannot change any\n    allow/deny decision for callers whose time is consistent with the\n    reference time. The reference time is always strictly earlier than the\n    current call's `now`, so the key being served is never evicted.\n\n    Relaxed guarantees (explicit trade-offs): once a key is evicted its\n    last-seen value is forgotten, so the monotonic-time check for that key is\n    no longer enforced against its old history. A later call with a `now`\n    earlier than the evicted last-seen time is accepted and starts fresh\n    history. After a legitimate jump of many windows the sweep clock catches\n    up by one window per recorded call, so cleanup of idle keys is delayed\n    accordingly (bounded retention). Cleanup only happens inside calls; callers\n    who need memory released without further traffic can call `purge(now)`.\n    A key that was served with a far-future\n    `now` rejects later ordinary timestamps as \"time went backwards\" and\n    lingers until the sweep clock reaches it; the limiter cannot tell a bad\n    timestamp from a good one, so use trusted timestamps if that matters.\n    The monotonic check is guaranteed for any key that has been seen within\n    `window_seconds` of the reference time.\n\n    The limiter is thread-safe; all state changes happen under one lock, so\n    calls on different keys are serialized (each call is short).\n\n    Unsupported: keys whose __hash__/__eq__ call back into this limiter (the\n    lock is not reentrant, so that would deadlock). Keys whose __hash__/__eq__\n    raise are tolerated: per-key state is kept in a single dict entry that is\n    inserted before anything else is recorded, so a failing lookup leaves the\n    limiter consistent. Idle-key eviction is best effort and never turns an\n    already recorded request into an exception.\n    \"\"\"\n\n    _EVICTIONS_PER_CALL = 4\n\n    def __init__(self, limit, window_seconds):\n        if isinstance(limit, bool) or not isinstance(limit, Integral) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if (\n            isinstance(window_seconds, bool)\n            or not isinstance(window_seconds, Integral)\n            or window_seconds <= 0\n        ):\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = int(limit)\n        self.window = int(window_seconds)\n        # key -> [time of the latest recorded (allowed) request, deque of hits]\n        self._entries = {}\n        # Min-heap of (last_seen, seq, key); entries that no longer match\n        # the key's last-seen are stale and skipped lazily. seq avoids\n        # comparing keys.\n        self._heap = []\n        self._seq = itertools.count()\n        self._clock = None\n        self._lock = threading.Lock()\n\n    def _check_now(self, now):\n        if isinstance(now, (bool, str, bytes, bytearray)):\n            raise ValueError(\"now must be a finite real number\")\n        try:\n            now = float(now)\n        except (OverflowError, TypeError, ValueError, ArithmeticError):\n            raise ValueError(\"now must be a finite real number\") from None\n        if not math.isfinite(now):\n            raise ValueError(\"now must be a finite real number\")\n        if now - self.window >= now or now + self.window <= now:\n            raise ValueError(\"now is too large for window arithmetic\")\n        return now\n\n    def allow(self, key, now):\n        now = self._check_now(now)\n        try:\n            hash(key)\n        except TypeError:\n            raise TypeError(\"key must be hashable\") from None\n\n        with self._lock:\n            entry = self._entries.get(key)\n            if entry is not None and now < entry[0]:\n                raise ValueError(\"time went backwards\")\n\n            cutoff = now - self.window\n            if entry is not None:\n                hits = entry[1]\n                while hits and hits[0] <= cutoff:\n                    hits.popleft()\n\n            allowed = entry is None or len(entry[1]) < self.limit\n            if allowed:\n                if entry is None:\n                    # The only operation that may run user code while\n                    # mutating; if it raises, nothing else has changed.\n                    entry = [now, deque()]\n                    self._entries[key] = entry\n                entry[1].append(now)\n                entry[0] = now\n                heapq.heappush(self._heap, (now, next(self._seq), key))\n                if self._clock is None:\n                    self._clock = now\n                elif now > self._clock:\n                    self._clock = min(now, self._clock + self.window)\n\n            try:\n                self._evict_idle(now, self._EVICTIONS_PER_CALL)\n            except Exception:\n                pass  # best effort; the request is already decided\n            return allowed\n\n    def purge(self, now):\n        \"\"\"Evict every idle key as of `now` without recording a request.\n\n        Uses the same reference time as normal eviction, so the sweep clock\n        limits how far a single jump can reach.\n        \"\"\"\n        now = self._check_now(now)\n        with self._lock:\n            self._evict_idle(now, None)\n\n    def _evict_idle(self, now, max_evictions):\n        \"\"\"Evict idle keys. Caller must hold the lock.\"\"\"\n        if self._clock is None:\n            return\n        # Strictly earlier than `now`, so the current key is never idle.\n        cutoff = min(now, self._clock) - self.window\n        heap = self._heap\n        entries = self._entries\n        evicted = 0\n        while heap and (max_evictions is None or evicted < max_evictions):\n            ts, _, k = heap[0]\n            entry = entries.get(k)\n            if entry is None or entry[0] != ts:\n                heapq.heappop(heap)  # stale entry\n                continue\n            if ts > cutoff:\n                break\n            # Delete from the dict first: if that raises, the heap is intact.\n            del entries[k]\n            heapq.heappop(heap)\n            evicted += 1\n\n        if len(heap) > 2 * len(entries) + 32:\n            rebuilt = [(e[0], next(self._seq), k) for k, e in entries.items()]\n            heapq.heapify(rebuilt)\n            self._heap = rebuilt\n"}
{"module": "rate_limiter", "model": "sonnet", "prompt": "open", "rep": 1, "round": 6, "tests_passed": 14, "tests_failed": 1, "code_lines": 193, "cum_changed": 192, "review_cost": 0.08005469999999999, "parse_ok": true, "findings": [{"title": "Very large window_seconds passes validation but makes every call raise OverflowError", "severity": "low", "detail": "The constructor accepts any positive Integral, e.g. 10**400. `_check_now` then evaluates `now - self.window`, and a float minus an int too large for float raises OverflowError. That sits outside the try/except, so `allow` and `purge` fail with OverflowError instead of the documented ValueError. Either validate in `__init__` that `float(window)` is finite and that window arithmetic is meaningful, or do the window arithmetic inside the guarded block."}, {"title": "Idle-key eviction can reset a key's history when callers' timestamps are skewed across keys", "severity": "low", "detail": "A key is evicted when its last-seen time is at least `window` older than min(now, sweep clock) for ANY call, not for that key's own next call. With concurrent callers who sample `now` before taking the lock, a call for key B can advance the clock and evict key A. A later call for A with a slightly older but still in-window `now` is accepted with fresh history, so A can exceed `limit` in a window. The docstring discloses this as a relaxed guarantee, but it is a real over-admission path for a rate limiter. Taking the timestamp inside the limiter, or keeping a grace margin, would avoid it."}, {"title": "Exceptions during eviction are swallowed and can leave a poisoned heap head that blocks all later eviction", "severity": "low", "detail": "`allow` wraps `_evict_idle` in `except Exception: pass`. If `entries.get(k)` raises for the heap's top entry (for example a key whose __hash__ or __eq__ is unstable or raising), the entry is never popped. Every later call hits the same entry and fails silently, so no key behind it is ever evicted and memory grows without any signal. Mutable keys whose hash changes after insertion also become unreachable in `entries` and leak. Consider dropping or quarantining the offending heap entry on failure and logging the error."}, {"title": "A single far-future timestamp pins a key and rejects ordinary timestamps afterwards", "severity": "low", "detail": "A key served once with a far-future `now` has that value as its last-seen time. Every later normal timestamp for it raises 'time went backwards', and the entry cannot be evicted until real time reaches it, because the heap orders by last-seen. This is documented, but with untrusted or buggy clock input it is a per-key denial of service and a memory-retention vector. A sanity bound on how far ahead of the sweep clock a timestamp may be would prevent it."}, {"title": "numpy.bool_ is accepted as `now` even though bool is documented as rejected", "severity": "low", "detail": "`_check_now` rejects only the built-in bool, str, bytes and bytearray. `numpy.bool_` is not a bool subclass and float() converts it, so `allow(k, np.True_)` is treated as 1.0. The same inconsistency applies to other bool-like types. It is minor, but it contradicts the stated contract."}, {"title": "Window boundary decisions depend on floating-point rounding of `now - window`", "severity": "low", "detail": "Expiry compares `hits[0] <= now - window`, computed in floats. For large timestamps or fractional values, a hit exactly `window` seconds old can be mis-classified because of rounding in the subtraction, which blurs the half-open window semantics. Storing integer or Decimal times, or comparing `now - hit >= window`, would reduce but not eliminate the effect."}], "review_text": "", "stopped": "round_cap"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.013336499999999998, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged (off-by-one in comparison)", "severity": "high", "detail": "The merge condition `start < merged[-1][1]` only merges strictly overlapping ranges. The spec says ranges that touch (end of one equals start of next) must be merged, so [(1, 3), (3, 5)] currently yields [(1, 3), (3, 5)] instead of [(1, 5)]. The condition should be `start <= merged[-1][1]`. Note: if ranges are inclusive integer ranges, one could also argue adjacent ranges like (1, 2) and (3, 4) are mergeable, but the stated rule only requires end == start, so `<=` is the fix the spec calls for."}], "review_text": "", "fix_cost": 0.014341800000000002, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0196901, "parse_ok": true, "findings": [{"title": "Iterable input is consumed by validation loop", "severity": "low", "detail": "The validation loop iterates `ranges` once and `sorted(ranges)` iterates it again. If a generator or other one-shot iterable is passed, the second pass sees nothing and the function silently returns []. The docstring says the input is a list, so this is a robustness issue rather than a spec violation. Calling `items = sorted(ranges)` once and validating and merging over `items` would avoid it."}, {"title": "No validation of element types or shape", "severity": "low", "detail": "The docstring says (start, end) tuples of ints, but nothing checks this. Floats, bools, or mixed types pass through, and a malformed element such as a 3-tuple fails with an unpacking ValueError whose message is unrelated to the 'invalid range' rule. This is acceptable for an internal helper but worth noting if the function takes external input."}, {"title": "Adjacent integer ranges (e.g. (1,2) and (3,4)) are not merged", "severity": "low", "detail": "For inclusive integer ranges, (1,2) and (3,4) cover a contiguous set, but the code (`start <= merged[-1][1]`) leaves them separate. This matches the stated rule, which only merges when the end equals the next start, so it is not a bug. It is a possible semantic surprise that the docstring could state explicitly."}], "review_text": "", "fix_cost": 0.0198287, "fix_ok": true, "step_changed": 23, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: iterable of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - an element that is not a pair of ints (bools and floats are rejected)\n      -> TypeError if a value is not an int, ValueError if it is not a pair\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - ranges that are merely adjacent as integers, e.g. (1, 2) and (3, 4),\n      are NOT merged, because 2 != 3\n    - the result is sorted by start and contains no mergeable ranges\n    - the input is not modified; one-shot iterables are accepted\n    - empty input -> []\n    \"\"\"\n    items = []\n    for item in ranges:\n        try:\n            start, end = item\n        except (TypeError, ValueError):\n            raise ValueError(f\"invalid range: {item!r}\") from None\n        for value in (start, end):\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"invalid range: {item!r} (values must be ints)\")\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n        items.append((start, end))\n\n    items.sort()\n\n    merged = []\n    for start, end in items:\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 38, "cum_changed": 25, "review_cost": 0.0180237, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0125813, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged", "severity": "high", "detail": "Input: [(1, 2), (2, 3)]. Expected (per spec, end of one equals start of next must merge): [(1, 3)]. Actual: the condition `start < merged[-1][1]` is false when start == previous end (2 < 2), so the result is [(1, 2), (2, 3)]. Fix: use `<=`."}], "review_text": "", "fix_cost": 0.014445900000000001, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0126909, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.012464800000000002, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged", "severity": "high", "detail": "The merge condition is `start < merged[-1][1]`, which is strict. The spec says ranges that touch (end of one equals start of the next) must be merged. Input: [(1, 2), (2, 3)]. Expected: [(1, 3)]. Actual: [(1, 2), (2, 3)]. The condition should be `<=`."}], "review_text": "", "fix_cost": 0.014726900000000001, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0130209, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.012504300000000001, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged", "severity": "high", "detail": "The merge condition is `start < merged[-1][1]`, so ranges whose end equals the next start are not merged, although the spec says they must be. Input: [(1, 2), (2, 3)]. Expected: [(1, 3)]. Actual: [(1, 2), (2, 3)]."}], "review_text": "", "fix_cost": 0.0148658, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0133369, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0395871, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged", "severity": "high", "detail": "The condition `start < merged[-1][1]` should be `start <= merged[-1][1]`. Ranges that touch (where the end of one equals the start of the next) are not merged according to the spec. For example, [(1, 2), (2, 3)] should return [(1, 3)] but instead returns [(1, 2), (2, 3)]. The docstring explicitly states ranges that 'touch' should be merged, but the code only merges when `start` is strictly less than the previous range's end."}], "review_text": "", "fix_cost": 0.0190855, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0352362, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 0, "review_cost": 0.0775102, "parse_ok": true, "findings": [{"title": "FLAT5 discount not explicitly rounded", "severity": "medium", "detail": "The FLAT5 discount is calculated as `min(Decimal(\"5.00\"), subtotal)` without explicit rounding, while the SAVE10 discount is rounded with `_round()`. While `subtotal` is already rounded and `min()` returns one of its arguments, this inconsistency could be error-prone during future refactoring. For consistency and robustness, use `discount = _round(min(Decimal(\"5.00\"), subtotal))`."}, {"title": "Total not rounded to 2 decimals", "severity": "medium", "detail": "The `total` is calculated as `subtotal - discount + tax` but is never explicitly rounded. While Decimal arithmetic should preserve 2 decimal places when all operands have 2 decimals, this is not guaranteed and is inconsistent with the docstring which states that subtotal, discount, and tax are 'each rounded to 2 decimals'. Consider adding `total = _round(total)` to ensure consistent precision."}], "review_text": "", "fix_cost": 0.0216371, "fix_ok": true, "step_changed": 4, "code_after": "from decimal import Decimal, ROUND_HALF_UP\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += Decimal(unit_price) * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * Decimal(tax_rate))\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 17, "tests_failed": 0, "code_lines": 44, "cum_changed": 4, "review_cost": 0.08088739999999998, "parse_ok": true, "findings": [{"title": "Missing validation for tax_rate parameter", "severity": "medium", "detail": "The function accepts tax_rate as a parameter but doesn't validate it's a valid decimal string. If a float is passed (e.g., tax_rate=0.10 instead of \"0.10\"), the tax calculation will produce inaccurate results due to float precision issues. For example, Decimal(0.10) returns a Decimal with unexpected precision due to binary floating-point representation. If an invalid string is passed, the function raises an unclear InvalidOperation exception rather than a helpful ValueError. The docstring should explicitly state that tax_rate must be a decimal string, or the function should validate and convert the input appropriately."}], "review_text": "", "fix_cost": 0.042677900000000005, "fix_ok": true, "step_changed": 19, "code_after": "from decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    tax_rate: decimal value as a string, int, or float (default \"0.10\").\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    # Validate and convert tax_rate\n    if isinstance(tax_rate, str):\n        try:\n            tax_rate_decimal = Decimal(tax_rate)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid decimal string, got {tax_rate!r}\")\n    elif isinstance(tax_rate, (int, float)):\n        try:\n            tax_rate_decimal = Decimal(str(tax_rate))\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    else:\n        raise ValueError(f\"tax_rate must be a string or number, got {type(tax_rate).__name__}\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        subtotal += Decimal(unit_price) * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 17, "tests_failed": 0, "code_lines": 59, "cum_changed": 23, "review_cost": 0.0523852, "parse_ok": true, "findings": [{"title": "Invalid unit_price raises InvalidOperation instead of ValueError", "severity": "medium", "detail": "When an invalid unit_price is passed (e.g., a non-numeric string or incompatible type), Decimal(unit_price) raises InvalidOperation or TypeError instead of the expected ValueError. This is inconsistent with how quantity and tax_rate are validated—both raise ValueError with clear error messages. Users will receive confusing exceptions like 'decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]' instead of a descriptive ValueError. The fix: wrap Decimal(unit_price) in a try/except block inside the loop to catch InvalidOperation/TypeError and raise ValueError with a message like 'invalid unit_price for {name}'."}], "review_text": "", "fix_cost": 0.0219273, "fix_ok": true, "step_changed": 6, "code_after": "from decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    tax_rate: decimal value as a string, int, or float (default \"0.10\").\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    # Validate and convert tax_rate\n    if isinstance(tax_rate, str):\n        try:\n            tax_rate_decimal = Decimal(tax_rate)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid decimal string, got {tax_rate!r}\")\n    elif isinstance(tax_rate, (int, float)):\n        try:\n            tax_rate_decimal = Decimal(str(tax_rate))\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    else:\n        raise ValueError(f\"tax_rate must be a string or number, got {type(tax_rate).__name__}\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        try:\n            price = Decimal(unit_price)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"invalid unit_price for {name}\")\n        subtotal += price * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 3, "tests_passed": 17, "tests_failed": 0, "code_lines": 63, "cum_changed": 29, "review_cost": 0.0507501, "parse_ok": true, "findings": [{"title": "Discount calculations produce negative values for negative subtotal", "severity": "high", "detail": "The code allows negative unit_price values (any string-convertible-to-Decimal is accepted). If negative unit prices are provided, the subtotal can become negative, which causes both discount calculations to produce negative discount amounts. For example: with subtotal = -10 and discount code 'SAVE10', the discount calculates to -1.00 instead of 0. With 'FLAT5', it calculates to min(5.00, -10) = -10. This violates the spec constraint that 'discount never exceeds the subtotal' and produces semantically incorrect invoices. Either validate unit_price >= 0 at the start, or use max(Decimal('0'), ...) in discount calculations to ensure discount is never negative."}], "review_text": "", "fix_cost": 0.0334561, "fix_ok": true, "step_changed": 2, "code_after": "from decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    tax_rate: decimal value as a string, int, or float (default \"0.10\").\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    # Validate and convert tax_rate\n    if isinstance(tax_rate, str):\n        try:\n            tax_rate_decimal = Decimal(tax_rate)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid decimal string, got {tax_rate!r}\")\n    elif isinstance(tax_rate, (int, float)):\n        try:\n            tax_rate_decimal = Decimal(str(tax_rate))\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    else:\n        raise ValueError(f\"tax_rate must be a string or number, got {type(tax_rate).__name__}\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        try:\n            price = Decimal(unit_price)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"invalid unit_price for {name}\")\n        if price < 0:\n            raise ValueError(f\"unit_price must be non-negative for {name}\")\n        subtotal += price * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 4, "tests_passed": 17, "tests_failed": 0, "code_lines": 65, "cum_changed": 31, "review_cost": 0.03911099999999999, "parse_ok": true, "findings": [{"title": "Float to Decimal conversion can introduce precision errors", "severity": "medium", "detail": "When tax_rate is passed as a float and converted via Decimal(str(tax_rate)), float representation issues can cause precision loss. For example, if a user computes tax_rate as 0.1 + 0.2 (which is 0.30000000000000004 in IEEE 754), the tax calculation will use the imprecise value. Recommend requiring tax_rate as a string or using Decimal(str(int(tax_rate * 10000))) / 10000 for float inputs."}, {"title": "Decimal special values (Infinity, NaN) not validated", "severity": "medium", "detail": "The code accepts and processes Decimal('Infinity') and Decimal('NaN') without validation, which can silently produce invalid invoice results. A user could pass unit_price='Infinity' and get Infinity as the total. Add validation to reject non-finite Decimal values: use `price.is_finite()` and `tax_rate_decimal.is_finite()`."}, {"title": "Negative tax_rate values not rejected", "severity": "low", "detail": "The code allows negative tax_rate values, which would produce negative tax and potentially negative totals. While this might be intentional (e.g., for rebates), the docstring does not specify this is allowed. Consider adding validation: `if tax_rate_decimal < 0: raise ValueError(...)` to match expected business logic."}], "review_text": "", "fix_cost": 0.0380066, "fix_ok": true, "step_changed": 16, "code_after": "from decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    tax_rate: decimal value as a string, int, or float (default \"0.10\").\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    # Validate and convert tax_rate\n    if isinstance(tax_rate, str):\n        try:\n            tax_rate_decimal = Decimal(tax_rate)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid decimal string, got {tax_rate!r}\")\n    elif isinstance(tax_rate, float):\n        try:\n            tax_rate_decimal = Decimal(str(int(tax_rate * 10000))) / 10000\n        except (InvalidOperation, TypeError, ValueError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    elif isinstance(tax_rate, int):\n        try:\n            tax_rate_decimal = Decimal(str(tax_rate))\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    else:\n        raise ValueError(f\"tax_rate must be a string or number, got {type(tax_rate).__name__}\")\n\n    # Validate tax_rate is finite and non-negative\n    if not tax_rate_decimal.is_finite():\n        raise ValueError(f\"tax_rate must be a finite number, got {tax_rate!r}\")\n    if tax_rate_decimal < 0:\n        raise ValueError(f\"tax_rate must be non-negative\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        try:\n            price = Decimal(unit_price)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"invalid unit_price for {name}\")\n        # Validate price is finite and non-negative\n        if not price.is_finite():\n            raise ValueError(f\"unit_price must be a finite number for {name}\")\n        if price < 0:\n            raise ValueError(f\"unit_price must be non-negative for {name}\")\n        subtotal += price * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 5, "tests_passed": 17, "tests_failed": 0, "code_lines": 79, "cum_changed": 45, "review_cost": 0.07427520000000001, "parse_ok": true, "findings": [{"title": "OverflowError not caught for infinite floats", "severity": "medium", "detail": "If a user passes `tax_rate=float('inf')`, `float('-inf')`, or `float('nan')`, the code will raise an unhandled OverflowError when attempting `int(tax_rate * 10000)`. This is inconsistent with the string branch, which correctly handles infinite values via the `is_finite()` check. The except clause should include `OverflowError` to raise a consistent ValueError instead."}, {"title": "Float conversion silently truncates precision", "severity": "low", "detail": "The conversion `Decimal(str(int(tax_rate * 10000))) / 10000` truncates floats to 4 decimal places. For example, `tax_rate=0.12345` becomes `0.1234`. While this may be intentional to avoid floating-point representation issues, it is not documented in the docstring, and users may not expect silent loss of precision."}], "review_text": "", "fix_cost": 0.029672100000000003, "fix_ok": true, "step_changed": 3, "code_after": "from decimal import Decimal, ROUND_HALF_UP, InvalidOperation\n\nCENT = Decimal(\"0.01\")\n\n\ndef _round(value):\n    return value.quantize(CENT, rounding=ROUND_HALF_UP)\n\n\ndef invoice_total(items, discount_code=None, tax_rate=\"0.10\"):\n    \"\"\"Compute an invoice.\n\n    items: list of (name, unit_price, quantity). unit_price is a string like \"19.99\".\n    tax_rate: decimal value as a string, int, or float (default \"0.10\").\n    Rules:\n    - line total = unit_price * quantity\n    - subtotal = sum of line totals\n    - discount codes: \"SAVE10\" = 10% off the subtotal; \"FLAT5\" = 5.00 off the\n      subtotal (the discount never exceeds the subtotal); any other code or None = no discount\n    - tax = tax_rate * (subtotal - discount)\n    - subtotal, discount and tax are each rounded to 2 decimals (half up);\n      total = subtotal - discount + tax\n    - quantity must be a positive int, otherwise ValueError; empty items -> ValueError\n    - float tax_rate values are truncated to 4 decimal places\n    Returns a dict with Decimal values: subtotal, discount, tax, total.\n    \"\"\"\n    if not items:\n        raise ValueError(\"items must not be empty\")\n\n    # Validate and convert tax_rate\n    if isinstance(tax_rate, str):\n        try:\n            tax_rate_decimal = Decimal(tax_rate)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid decimal string, got {tax_rate!r}\")\n    elif isinstance(tax_rate, float):\n        try:\n            tax_rate_decimal = Decimal(str(int(tax_rate * 10000))) / 10000\n        except (InvalidOperation, TypeError, ValueError, OverflowError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    elif isinstance(tax_rate, int):\n        try:\n            tax_rate_decimal = Decimal(str(tax_rate))\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"tax_rate must be a valid number, got {tax_rate!r}\")\n    else:\n        raise ValueError(f\"tax_rate must be a string or number, got {type(tax_rate).__name__}\")\n\n    # Validate tax_rate is finite and non-negative\n    if not tax_rate_decimal.is_finite():\n        raise ValueError(f\"tax_rate must be a finite number, got {tax_rate!r}\")\n    if tax_rate_decimal < 0:\n        raise ValueError(f\"tax_rate must be non-negative\")\n\n    subtotal = Decimal(\"0\")\n    for name, unit_price, quantity in items:\n        if not isinstance(quantity, int) or isinstance(quantity, bool) or quantity <= 0:\n            raise ValueError(f\"invalid quantity for {name}\")\n        try:\n            price = Decimal(unit_price)\n        except (InvalidOperation, TypeError):\n            raise ValueError(f\"invalid unit_price for {name}\")\n        # Validate price is finite and non-negative\n        if not price.is_finite():\n            raise ValueError(f\"unit_price must be a finite number for {name}\")\n        if price < 0:\n            raise ValueError(f\"unit_price must be non-negative for {name}\")\n        subtotal += price * quantity\n    subtotal = _round(subtotal)\n\n    if discount_code == \"SAVE10\":\n        discount = _round(subtotal * Decimal(\"0.10\"))\n    elif discount_code == \"FLAT5\":\n        discount = _round(min(Decimal(\"5.00\"), subtotal))\n    else:\n        discount = Decimal(\"0.00\")\n\n    tax = _round((subtotal - discount) * tax_rate_decimal)\n    total = _round(subtotal - discount + tax)\n    return {\"subtotal\": subtotal, \"discount\": discount, \"tax\": tax, \"total\": total}\n"}
{"module": "invoice", "model": "haiku", "prompt": "open", "rep": 2, "round": 6, "tests_passed": 17, "tests_failed": 0, "code_lines": 80, "cum_changed": 46, "review_cost": 0.068338, "parse_ok": true, "findings": [{"title": "Floating point precision loss when truncating float tax_rate values", "severity": "high", "detail": "When converting float tax_rate to Decimal via truncation (multiplying by 10000, converting to int, then dividing by 10000), floating point arithmetic can introduce errors. For example, 0.7499 * 10000 = 7498.999999999999, so int(7498.999999999999) = 7498, resulting in 0.7498 instead of 0.7499. This violates the documented truncation behavior. Better approaches: use round() with proper rounding mode, or format the float string directly to 4 decimal places before converting to Decimal."}], "review_text": "", "stopped": "round_cap"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0524856, "parse_ok": true, "findings": [{"title": "Merge condition doesn't handle touching ranges", "severity": "high", "detail": "The condition `start < merged[-1][1]` should be `start <= merged[-1][1]`. This causes ranges that touch (where the end of one equals the start of the next) to not be merged. For example, merge_ranges([(1, 3), (3, 5)]) returns [(1, 3), (3, 5)] instead of the expected [(1, 5)]. The docstring explicitly states that touching ranges should be merged: 'the end of one equals the start of the next'."}], "review_text": "", "fix_cost": 0.019035599999999996, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.05412770000000001, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0502391, "parse_ok": true, "findings": [{"title": "`_last_seen` dictionary grows unbounded", "severity": "high", "detail": "The `_last_seen` dictionary stores one entry per unique key and is never cleaned up. In long-running systems with many unique keys, this causes unbounded memory growth. The docstring explicitly requires that \"timestamps that can no longer matter must not accumulate forever\", but `_last_seen` entries are never removed and will persist for the lifetime of the instance. After a key hasn't been used for `window_seconds` or longer, its entry in `_last_seen` could be safely discarded to prevent memory leaks."}, {"title": "Type validation accepts boolean values", "severity": "low", "detail": "The validation uses `isinstance(limit, int)` and `isinstance(window_seconds, int)`. Since `bool` is a subclass of `int` in Python, boolean values like `True` will pass validation (True == 1, which is a positive int). This allows calls like `SlidingWindowLimiter(True, 5)` to succeed, which violates the documented requirement that \"limit and window_seconds must be positive ints\". To strictly enforce int-only types, use `type(limit) is int` instead of `isinstance(limit, int)`."}], "review_text": "", "fix_cost": 0.0857838, "fix_ok": true, "step_changed": 6, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if type(limit) is not int or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if type(window_seconds) is not int or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now - last >= self.window:\n            del self._last_seen[key]\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 45, "cum_changed": 6, "review_cost": 0.06975490000000001, "parse_ok": true, "findings": [{"title": "Unnecessary deletion and re-addition of `_last_seen[key]`", "severity": "low", "detail": "When `now - last >= self.window`, the code deletes `_last_seen[key]` and then immediately re-adds it on the next line. This operation is wasteful and serves no purpose—the deletion doesn't prevent unbounded dict growth (new keys are still added), and the immediate re-addition makes the deletion pointless. The code would be clearer and equivalent without the `del` statement."}, {"title": "Use of `type(x) is int` instead of `isinstance(x, int)`", "severity": "low", "detail": "The input validation uses `type(limit) is not int` and `type(window_seconds) is not int` instead of `isinstance()`. While this approach intentionally rejects booleans (which are `int` subclasses in Python), it is not Pythonic. The idiomatic Python approach would be `isinstance(limit, int) and limit > 0`. If rejecting booleans is intentional, a comment explaining why would help, or the validation could use `isinstance()` with an additional `not isinstance(..., bool)` check."}], "review_text": "", "fix_cost": 0.026918799999999996, "fix_ok": true, "step_changed": 6, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 4, "review_cost": 0.05996130000000001, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0459544, "parse_ok": true, "findings": [{"title": "Timestamps accumulate forever for keys never accessed again", "severity": "medium", "detail": "If allow('key', 0) is called with window_seconds=1, then 'key' is never accessed again, the timestamp 0 remains in self._hits['key'] forever even after time 1 when it can no longer matter. Expected per spec: 'timestamps that can no longer matter must not accumulate forever'. Actual: lazy cleanup only removes old timestamps when a key is accessed again."}], "review_text": "", "fix_cost": 0.0439476, "fix_ok": true, "step_changed": 11, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        cutoff = now - self.window\n        for k in list(self._hits.keys()):\n            hits = self._hits[k]\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n            if not hits:\n                del self._hits[k]\n\n        hits = self._hits[key]\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 48, "cum_changed": 11, "review_cost": 0.06128000000000001, "parse_ok": true, "findings": [{"title": "Global cleanup removes hits that may still be valid for other keys", "severity": "high", "detail": "Input: limit=2, window_seconds=10. Call allow('b', 60)→True, allow('b', 65)→True [hits['b']=[60,65]], allow('a', 100) [cleanup removes hits['b']], allow('b', 66). Expected: False (since 60 and 65 are both in window (56,66]). Actual: True (hits were prematurely removed by cleanup triggered from key 'a'). The cleanup loop uses cutoff from the current key to clean all keys, removing hits for key 'b' that are still valid when key 'b' is accessed at a later time."}], "review_text": "", "fix_cost": 0.0238145, "fix_ok": true, "step_changed": 10, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        cutoff = now - self.window\n        hits = self._hits[key]\n        while hits and hits[0] <= cutoff:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 2, "tests_passed": 15, "tests_failed": 0, "code_lines": 44, "cum_changed": 3, "review_cost": 0.0459955, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0212319, "parse_ok": true, "findings": [{"title": "Touching ranges not merged", "severity": "high", "detail": "Input: [(1, 3), (3, 5)]. Expected: [(1, 5)] (ranges that touch should merge per spec). Actual: [(1, 3), (3, 5)]. The condition `start < merged[-1][1]` should be `start <= merged[-1][1]` to handle ranges where the end of one equals the start of the next."}], "review_text": "", "fix_cost": 0.018166099999999998, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.027191999999999997, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.0422086, "parse_ok": true, "findings": [{"title": "Unbounded memory growth in `_last_seen` dictionary", "severity": "medium", "detail": "The `_last_seen` dictionary stores the last timestamp for each key and is never cleaned up. This violates the docstring requirement that \"timestamps that can no longer matter must not accumulate forever.\" Once a key hasn't been used for more than `window_seconds`, its entry in `_last_seen` no longer affects rate-limiting behavior (all hits outside the window are purged), but the dictionary entry persists indefinitely. For services processing millions of unique keys over time, this could cause unbounded memory growth. Consider removing `_last_seen` entries for keys after `window_seconds` have elapsed with no activity."}], "review_text": "", "fix_cost": 0.06699719999999999, "fix_ok": true, "step_changed": 6, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if not hits:\n            self._last_seen.pop(key, None)\n\n        if len(hits) >= self.limit:\n            return False\n        \n        self._last_seen[key] = now\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 47, "cum_changed": 6, "review_cost": 0.058193100000000005, "parse_ok": true, "findings": [{"title": "Monotonicity constraint violated by _last_seen cleanup", "severity": "high", "detail": "When the hits deque becomes empty after old timestamps are removed, the code pops the key from _last_seen. This discards the maximum timestamp ever seen for that key, breaking the monotonicity guarantee. A future call with a backwards timestamp won't raise ValueError as required. Example: allow(key, 100) returns True, allow(key, 111) cleans old hits and pops the key, then allow(key, 99) doesn't detect the violation."}, {"title": "_last_seen not updated when allow() returns False", "severity": "medium", "detail": "_last_seen[key] is only updated when a request is successfully recorded (return True), not when rate-limited (return False). This means the maximum timestamp ever provided is incompletely tracked. Combined with the cleanup bug, this causes loss of state needed to enforce the 'now must not go backwards' constraint."}], "review_text": "", "fix_cost": 0.0333563, "fix_ok": true, "step_changed": 6, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        \n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 0, "round": 2, "tests_passed": 15, "tests_failed": 0, "code_lines": 45, "cum_changed": 2, "review_cost": 0.05667930000000001, "parse_ok": true, "findings": [{"title": "_last_seen dictionary grows unboundedly", "severity": "medium", "detail": "The `_last_seen` dictionary is never cleaned up and can grow indefinitely as new keys are encountered. The docstring explicitly requires that 'timestamps that can no longer matter must not accumulate forever,' but `_last_seen` entries persist indefinitely once added. While `_hits` timestamps are correctly pruned when they fall outside the window, `_last_seen` entries for keys are never removed. In systems with many ephemeral keys (e.g., many unique IPs or session identifiers), this causes unbounded memory growth. Consider implementing periodic cleanup or using a bounded cache structure with eviction (e.g., LRU) for `_last_seen`."}], "review_text": "", "fix_cost": 0.0672915, "fix_ok": true, "step_changed": 6, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if not hits:\n            self._last_seen.pop(key, None)\n\n        if len(hits) >= self.limit:\n            return False\n        \n        hits.append(now)\n        self._last_seen[key] = now\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 0, "round": 3, "tests_passed": 15, "tests_failed": 0, "code_lines": 47, "cum_changed": 6, "review_cost": 0.0759621, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.014259399999999998, "parse_ok": true, "findings": [{"title": "Expiry check is off by one: access is still granted exactly at expires_at", "severity": "high", "detail": "The spec says shared access ends exactly AT expires_at, so a shared user may view only while now < expires_at. The code uses `now <= expires_at`, which still allows access when now == expires_at. It should be `now < expires_at`."}, {"title": "Inconsistent handling of missing keys: 'suspended' is optional but 'shared_with' and 'expires_at' are required", "severity": "low", "detail": "`doc.get(\"suspended\", set())` tolerates a missing key, while `doc[\"shared_with\"]`, `doc[\"expires_at\"]` and `doc[\"owner\"]` raise KeyError if absent. Either treat all optional fields leniently (for example `doc.get(\"shared_with\", set())`, `doc.get(\"expires_at\")`) or validate the input consistently. A malformed doc fails in some paths and not others, depending on who the user is."}], "review_text": "", "fix_cost": 0.0147215, "fix_ok": true, "step_changed": 8, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    if user in doc.get(\"shared_with\", set()):\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 8, "review_cost": 0.0168168, "parse_ok": true, "findings": [{"title": "Missing or None owner matches a None user", "severity": "low", "detail": "`doc.get(\"owner\")` returns None when the key is absent or None, so `user == doc.get(\"owner\")` is True for an unauthenticated `user=None`. That grants owner access on a malformed doc. The spec says owner is a str, so this only matters for bad input. Guard with `user is not None and ...`, or require the key."}], "review_text": "", "fix_cost": 0.0142153, "fix_ok": true, "step_changed": 3, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    owner = doc.get(\"owner\")\n    if user is not None and owner is not None and user == owner:\n        return True\n    if user in doc.get(\"shared_with\", set()):\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 0, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 24, "cum_changed": 9, "review_cost": 0.0187316, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0236094, "parse_ok": true, "findings": [{"title": "Touching ranges not merged", "severity": "high", "detail": "Input: [(1, 5), (5, 10)]. Expected: [(1, 10)] (ranges touch at 5, should merge per spec). Actual: [(1, 5), (5, 10)] (condition `start < merged[-1][1]` only catches overlap, not touching). Fix: change condition to `start <= merged[-1][1]`."}], "review_text": "", "fix_cost": 0.0178177, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.042006999999999996, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0160275, "parse_ok": true, "findings": [{"title": "Touching ranges are not merged (off-by-one in comparison)", "severity": "high", "detail": "The condition `start < merged[-1][1]` only merges strictly overlapping ranges. The spec says ranges whose end equals the next start must be merged, and for inclusive integer ranges this should arguably be handled consistently too. For example, [(1, 2), (2, 3)] returns [(1, 2), (2, 3)] instead of [(1, 3)]. Change the comparison to `start <= merged[-1][1]`. If adjacent integers (e.g. (1, 2) and (3, 4)) are also meant to be merged because the ranges are inclusive, use `start <= merged[-1][1] + 1`, but the spec as written only requires touching endpoints."}, {"title": "No handling or validation of malformed input elements", "severity": "low", "detail": "Inputs that are not 2-tuples, or that contain non-integers (e.g. floats, None, strings), either raise an unhelpful unpacking/TypeError or are silently accepted. The docstring says ints only, but nothing enforces it (bool is also an int subclass). Consider explicit validation or documenting the behavior."}, {"title": "Missing tests / edge case coverage", "severity": "low", "detail": "There are no tests covering empty input, single ranges, touching ranges, fully contained ranges, duplicates, unsorted input, or input immutability. The touching-range bug above would have been caught by a basic test."}], "review_text": "", "fix_cost": 0.0250128, "fix_ok": true, "step_changed": 51, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for item in ranges:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n        start, end = item\n        for value in (start, end):\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(tuple(r) for r in ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\ndef _raises(exc, ranges):\n    try:\n        merge_ranges(ranges)\n    except exc:\n        return True\n    return False\n\n\ndef _run_tests():\n    assert merge_ranges([]) == []\n    assert merge_ranges([(1, 2)]) == [(1, 2)]\n    assert merge_ranges([(1, 2), (2, 3)]) == [(1, 3)]\n    assert merge_ranges([(1, 2), (3, 4)]) == [(1, 2), (3, 4)]\n    assert merge_ranges([(1, 10), (2, 3)]) == [(1, 10)]\n    assert merge_ranges([(1, 2), (1, 2)]) == [(1, 2)]\n    assert merge_ranges([(5, 6), (1, 2), (2, 5)]) == [(1, 6)]\n    assert merge_ranges([(3, 3)]) == [(3, 3)]\n\n    original = [(5, 6), (1, 2), (2, 3)]\n    snapshot = list(original)\n    merge_ranges(original)\n    assert original == snapshot\n\n    assert _raises(ValueError, [(2, 1)])\n    assert _raises(ValueError, [(1, 2, 3)])\n    assert _raises(ValueError, [5])\n    assert _raises(TypeError, [(1.0, 2)])\n    assert _raises(TypeError, [(None, 2)])\n    assert _raises(TypeError, [(\"a\", \"b\")])\n    assert _raises(TypeError, [(True, 2)])\n\n\nif __name__ == \"__main__\":\n    _run_tests()\n    print(\"ok\")\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 68, "cum_changed": 51, "review_cost": 0.0246822, "parse_ok": true, "findings": [{"title": "One-shot iterables are silently mishandled", "severity": "low", "detail": "`ranges` is traversed twice, once to validate and once in `sorted(...)`. If a caller passes a generator or iterator instead of a list, the validation loop exhausts it and the function returns `[]` with no error, and nothing gets validated. The docstring says the input is a list, so this is outside the contract. A defensive `ranges = list(ranges)` at the top, or a single combined pass, would remove the silent-wrong-result case."}, {"title": "Test suite misses some specified behaviors", "severity": "low", "detail": "The tests never use a list as an element (e.g. `[[1, 2], [2, 3]]`), which the spec allows. They also don't cover chains where a later range bridges several earlier merged ranges, negative bounds, or an invalid element that appears after valid ones. Behavior matches the spec for all of these as far as I can tell, but they are unguarded against regressions."}], "review_text": "", "fix_cost": 0.0305644, "fix_ok": true, "step_changed": 25, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    ranges = list(ranges)\n    for item in ranges:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n        start, end = item\n        for value in (start, end):\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(tuple(r) for r in ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\ndef _raises(exc, ranges):\n    try:\n        merge_ranges(ranges)\n    except exc:\n        return True\n    return False\n\n\ndef _run_tests():\n    assert merge_ranges([]) == []\n    assert merge_ranges([(1, 2)]) == [(1, 2)]\n    assert merge_ranges([(1, 2), (2, 3)]) == [(1, 3)]\n    assert merge_ranges([(1, 2), (3, 4)]) == [(1, 2), (3, 4)]\n    assert merge_ranges([(1, 10), (2, 3)]) == [(1, 10)]\n    assert merge_ranges([(1, 2), (1, 2)]) == [(1, 2)]\n    assert merge_ranges([(5, 6), (1, 2), (2, 5)]) == [(1, 6)]\n    assert merge_ranges([(3, 3)]) == [(3, 3)]\n\n    # list elements are allowed\n    assert merge_ranges([[1, 2], [2, 3]]) == [(1, 3)]\n    assert merge_ranges([[5, 6], (1, 2)]) == [(1, 2), (5, 6)]\n\n    # a later range bridging several earlier merged ranges\n    assert merge_ranges([(1, 2), (4, 5), (7, 8), (2, 7)]) == [(1, 8)]\n    assert merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]) == [(1, 2), (3, 5), (7, 8)]\n\n    # negative bounds\n    assert merge_ranges([(-5, -3), (-3, 0), (2, 4)]) == [(-5, 0), (2, 4)]\n    assert merge_ranges([(-2, -1), (-10, -5)]) == [(-10, -5), (-2, -1)]\n\n    # one-shot iterables are handled\n    assert merge_ranges(iter([(1, 2), (2, 3)])) == [(1, 3)]\n    assert merge_ranges(r for r in [(3, 4), (1, 2)]) == [(1, 4)]\n    assert _raises(ValueError, iter([(2, 1)]))\n\n    original = [(5, 6), (1, 2), (2, 3)]\n    snapshot = list(original)\n    merge_ranges(original)\n    assert original == snapshot\n\n    assert _raises(ValueError, [(2, 1)])\n    assert _raises(ValueError, [(1, 2, 3)])\n    assert _raises(ValueError, [5])\n    assert _raises(TypeError, [(1.0, 2)])\n    assert _raises(TypeError, [(None, 2)])\n    assert _raises(TypeError, [(\"a\", \"b\")])\n    assert _raises(TypeError, [(True, 2)])\n\n    # invalid element after valid ones\n    assert _raises(ValueError, [(1, 2), (3, 4), (6, 5)])\n    assert _raises(ValueError, [(1, 2), (3, 4), (1, 2, 3)])\n    assert _raises(ValueError, [(1, 2), 7])\n    assert _raises(TypeError, [(1, 2), (3, 4), (1.5, 2)])\n    assert _raises(TypeError, [(1, 2), (3, False)])\n\n\nif __name__ == \"__main__\":\n    _run_tests()\n    print(\"ok\")\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 93, "cum_changed": 76, "review_cost": 0.030543400000000002, "parse_ok": true, "findings": [{"title": "Test asserts adjacent-integer merging, contradicting the spec and the implementation", "severity": "high", "detail": "`assert merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]) == [(1, 2), (3, 5), (7, 8)]` expects (3,3) to merge with (1,2) and (4,5). The spec says ranges merge only when they overlap or touch (end of one == start of the next). The implementation's `start <= merged[-1][1]` follows that rule, and another test (`[(1, 2), (3, 4)]` stays separate) confirms it. The actual result is `[(1, 2), (3, 3), (4, 5), (7, 8)]`, so `_run_tests()` raises AssertionError and the module's self-test fails. The expected value in this assertion is wrong and should be `[(1, 2), (3, 3), (4, 5), (7, 8)]`. The implementation itself is correct."}, {"title": "Tests only run via __main__ rather than a test framework", "severity": "low", "detail": "Tests are plain asserts in `_run_tests()`, so a pytest or unittest run will not collect them. The first failing assert also stops the run, hiding other failures. Python's `-O` flag would strip the asserts entirely. Converting these to pytest or unittest cases would fix this."}, {"title": "Non-iterable or odd inputs give incidental errors", "severity": "low", "detail": "Passing `None` or an int makes `list(ranges)` raise a generic TypeError. A `str` or `dict` input is iterated element by element and produces a ValueError about a single character or key. This is not covered by the spec, but the behavior is incidental and undocumented."}], "review_text": "", "fix_cost": 0.042570000000000004, "fix_ok": true, "step_changed": 140, "code_after": "import unittest\n\n\ndef merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: an iterable (list, tuple, generator, ...) of (start, end) tuples\n    of ints, inclusive on both ends.\n    Rules:\n    - ranges that is not an iterable, or is a str, bytes or dict -> TypeError\n    - a range with start > end -> ValueError\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input must not be modified\n    - empty input -> []\n    \"\"\"\n    if isinstance(ranges, (str, bytes, bytearray, dict)):\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        )\n    try:\n        ranges = list(ranges)\n    except TypeError:\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        ) from None\n\n    for item in ranges:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n        start, end = item\n        for value in (start, end):\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(tuple(r) for r in ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\nclass MergeRangesTests(unittest.TestCase):\n    def test_basic(self):\n        self.assertEqual(merge_ranges([]), [])\n        self.assertEqual(merge_ranges([(1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(1, 2), (2, 3)]), [(1, 3)])\n        self.assertEqual(merge_ranges([(1, 2), (3, 4)]), [(1, 2), (3, 4)])\n        self.assertEqual(merge_ranges([(1, 10), (2, 3)]), [(1, 10)])\n        self.assertEqual(merge_ranges([(1, 2), (1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(5, 6), (1, 2), (2, 5)]), [(1, 6)])\n        self.assertEqual(merge_ranges([(3, 3)]), [(3, 3)])\n\n    def test_list_elements(self):\n        self.assertEqual(merge_ranges([[1, 2], [2, 3]]), [(1, 3)])\n        self.assertEqual(merge_ranges([[5, 6], (1, 2)]), [(1, 2), (5, 6)])\n\n    def test_bridging(self):\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (2, 7)]), [(1, 8)]\n        )\n        # adjacent integers do not merge; only overlapping/touching ranges do\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]),\n            [(1, 2), (3, 3), (4, 5), (7, 8)],\n        )\n\n    def test_negative_bounds(self):\n        self.assertEqual(\n            merge_ranges([(-5, -3), (-3, 0), (2, 4)]), [(-5, 0), (2, 4)]\n        )\n        self.assertEqual(\n            merge_ranges([(-2, -1), (-10, -5)]), [(-10, -5), (-2, -1)]\n        )\n\n    def test_one_shot_iterables(self):\n        self.assertEqual(merge_ranges(iter([(1, 2), (2, 3)])), [(1, 3)])\n        self.assertEqual(merge_ranges(r for r in [(3, 4), (1, 2)]), [(1, 4)])\n        with self.assertRaises(ValueError):\n            merge_ranges(iter([(2, 1)]))\n\n    def test_input_not_modified(self):\n        original = [(5, 6), (1, 2), (2, 3)]\n        snapshot = list(original)\n        merge_ranges(original)\n        self.assertEqual(original, snapshot)\n\n    def test_value_errors(self):\n        for bad in (\n            [(2, 1)],\n            [(1, 2, 3)],\n            [5],\n            [(1, 2), (3, 4), (6, 5)],\n            [(1, 2), (3, 4), (1, 2, 3)],\n            [(1, 2), 7],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_type_errors(self):\n        for bad in (\n            [(1.0, 2)],\n            [(None, 2)],\n            [(\"a\", \"b\")],\n            [(True, 2)],\n            [(1, 2), (3, 4), (1.5, 2)],\n            [(1, 2), (3, False)],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n    def test_non_iterable_or_string_like_input(self):\n        for bad in (None, 5, \"ab\", b\"ab\", {(1, 2): 3}):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 3, "tests_passed": 10, "tests_failed": 0, "code_lines": 131, "cum_changed": 118, "review_cost": 0.0342454, "parse_ok": true, "findings": [{"title": "TypeError raised inside a generator input is masked as \"not an iterable\"", "severity": "low", "detail": "`list(ranges)` is wrapped in `except TypeError`. If `ranges` is a valid generator or iterator whose own code raises TypeError while being consumed, the caller gets the misleading message \"ranges must be an iterable ... not generator\". `from None` also discards the original traceback. Check iterability first with `iter(ranges)` in its own try block, then call `list()` outside the try so errors from the iterable propagate unchanged."}, {"title": "Mapping types other than `dict` are not rejected", "severity": "low", "detail": "The spec says dicts must raise TypeError, but the check is `isinstance(..., dict)`. A `MappingProxyType`, a `collections.abc.Mapping` implementation, or a `UserDict` passes through and its keys are iterated as ranges. A mapping whose keys are 2-tuples would be silently accepted. Checking `collections.abc.Mapping` would cover all of these."}, {"title": "Validation failures depend on element order when an input has several problems", "severity": "low", "detail": "Validation runs per item, so the exception type depends on which bad element comes first. `[(2, 1), (1.5, 2)]` raises ValueError, but `[(1.5, 2), (2, 1)]` raises TypeError. The spec does not define the precedence, and the tests only use inputs with a single kind of error, so this behaviour is neither documented nor tested."}, {"title": "`int` subclasses other than `bool` are accepted and leak into the output", "severity": "low", "detail": "`isinstance(value, int)` lets through `IntEnum` members and other int subclasses. They are returned unchanged in the merged tuples. Whether that is intended is unclear, because the docstring only excludes bool explicitly. Decide on the intended behaviour, either normalising with `int(value)` or checking `type(value) is int`, and document it."}, {"title": "Test coverage gaps", "severity": "low", "detail": "There are no tests for: a generator that raises TypeError mid-iteration; non-dict mappings; sets or frozensets as input; int subclasses; very large ints; unsorted input with many chained merges; or validation order across mixed error types. `test_input_not_modified` does not cover list elements, and does not check that the returned tuples are new objects rather than the input's inner lists."}, {"title": "Docstring grammar and clarity", "severity": "low", "detail": "\"ranges that is not an iterable\" is ungrammatical. The docstring also does not say that the output is always a list of tuples, even when the input elements are lists."}], "review_text": "", "fix_cost": 0.0618892, "fix_ok": true, "step_changed": 126, "code_after": "import unittest\nfrom collections.abc import Mapping\n\n\ndef merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: an iterable (list, tuple, set, generator, ...) of (start, end)\n    pairs of ints, inclusive on both ends. Each pair may be a tuple or a list.\n    The result is always a new list of tuples, even when the input elements\n    are lists.\n    Rules:\n    - ranges that is not iterable, or is a str, bytes, bytearray or any\n      Mapping (dict, MappingProxyType, UserDict, ...) -> TypeError\n    - an exception raised while consuming the iterable propagates unchanged\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - a range with start > end -> ValueError\n    - int subclasses (e.g. IntEnum members) are accepted and normalised to\n      plain ints in the result\n    - validation runs in stages over the whole input, so the error type does\n      not depend on element order: element shape (ValueError) is checked\n      first, then bound types (TypeError), then start > end (ValueError)\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input must not be modified\n    - empty input -> []\n    \"\"\"\n    if isinstance(ranges, (str, bytes, bytearray, Mapping)):\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        )\n    try:\n        iterator = iter(ranges)\n    except TypeError:\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        ) from None\n    items = list(iterator)\n\n    for item in items:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n\n    for item in items:\n        for value in item:\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n\n    pairs = []\n    for item in items:\n        start, end = int(item[0]), int(item[1])\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n        pairs.append((start, end))\n\n    merged = []\n    for start, end in sorted(pairs):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\nclass MergeRangesTests(unittest.TestCase):\n    def test_basic(self):\n        self.assertEqual(merge_ranges([]), [])\n        self.assertEqual(merge_ranges([(1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(1, 2), (2, 3)]), [(1, 3)])\n        self.assertEqual(merge_ranges([(1, 2), (3, 4)]), [(1, 2), (3, 4)])\n        self.assertEqual(merge_ranges([(1, 10), (2, 3)]), [(1, 10)])\n        self.assertEqual(merge_ranges([(1, 2), (1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(5, 6), (1, 2), (2, 5)]), [(1, 6)])\n        self.assertEqual(merge_ranges([(3, 3)]), [(3, 3)])\n\n    def test_list_elements(self):\n        self.assertEqual(merge_ranges([[1, 2], [2, 3]]), [(1, 3)])\n        self.assertEqual(merge_ranges([[5, 6], (1, 2)]), [(1, 2), (5, 6)])\n\n    def test_bridging(self):\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (2, 7)]), [(1, 8)]\n        )\n        # adjacent integers do not merge; only overlapping/touching ranges do\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]),\n            [(1, 2), (3, 3), (4, 5), (7, 8)],\n        )\n\n    def test_chained_unsorted_merges(self):\n        ranges = [(i, i + 1) for i in range(0, 200, 2)]\n        ranges.reverse()\n        self.assertEqual(merge_ranges(ranges), [(0, 199)])\n        shuffled = [(9, 10), (1, 2), (5, 6), (3, 4), (7, 8), (2, 3), (4, 5),\n                    (6, 7), (8, 9)]\n        self.assertEqual(merge_ranges(shuffled), [(1, 10)])\n\n    def test_negative_bounds(self):\n        self.assertEqual(\n            merge_ranges([(-5, -3), (-3, 0), (2, 4)]), [(-5, 0), (2, 4)]\n        )\n        self.assertEqual(\n            merge_ranges([(-2, -1), (-10, -5)]), [(-10, -5), (-2, -1)]\n        )\n\n    def test_very_large_ints(self):\n        big = 10**40\n        self.assertEqual(\n            merge_ranges([(big, big + 5), (big + 5, big + 9), (-big, -big)]),\n            [(-big, -big), (big, big + 9)],\n        )\n\n    def test_one_shot_iterables(self):\n        self.assertEqual(merge_ranges(iter([(1, 2), (2, 3)])), [(1, 3)])\n        self.assertEqual(merge_ranges(r for r in [(3, 4), (1, 2)]), [(1, 4)])\n        with self.assertRaises(ValueError):\n            merge_ranges(iter([(2, 1)]))\n\n    def test_set_inputs(self):\n        self.assertEqual(merge_ranges({(3, 4), (1, 2), (2, 3)}), [(1, 4)])\n        self.assertEqual(merge_ranges(frozenset({(5, 6), (1, 2)})),\n                         [(1, 2), (5, 6)])\n\n    def test_generator_type_error_propagates(self):\n        def gen():\n            yield (1, 2)\n            raise TypeError(\"boom from generator\")\n\n        with self.assertRaises(TypeError) as ctx:\n            merge_ranges(gen())\n        self.assertEqual(str(ctx.exception), \"boom from generator\")\n\n    def test_input_not_modified(self):\n        original = [(5, 6), (1, 2), (2, 3)]\n        snapshot = list(original)\n        merge_ranges(original)\n        self.assertEqual(original, snapshot)\n\n    def test_list_elements_not_modified_or_returned(self):\n        inner = [[5, 6], [1, 2], [2, 3]]\n        snapshot = [list(x) for x in inner]\n        result = merge_ranges(inner)\n        self.assertEqual(inner, snapshot)\n        self.assertEqual(result, [(1, 3), (5, 6)])\n        for r in result:\n            self.assertIsInstance(r, tuple)\n            for x in inner:\n                self.assertIsNot(r, x)\n\n    def test_tuple_elements_are_new_objects(self):\n        a = (1, 2)\n        result = merge_ranges([a])\n        self.assertEqual(result, [(1, 2)])\n        self.assertIsInstance(result, list)\n\n    def test_int_subclass_normalised(self):\n        import enum\n\n        class Num(enum.IntEnum):\n            ONE = 1\n            TWO = 2\n            THREE = 3\n\n        result = merge_ranges([(Num.ONE, Num.TWO), (Num.TWO, Num.THREE)])\n        self.assertEqual(result, [(1, 3)])\n        for start, end in result:\n            self.assertIs(type(start), int)\n            self.assertIs(type(end), int)\n\n    def test_value_errors(self):\n        for bad in (\n            [(2, 1)],\n            [(1, 2, 3)],\n            [5],\n            [(1, 2), (3, 4), (6, 5)],\n            [(1, 2), (3, 4), (1, 2, 3)],\n            [(1, 2), 7],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_type_errors(self):\n        for bad in (\n            [(1.0, 2)],\n            [(None, 2)],\n            [(\"a\", \"b\")],\n            [(True, 2)],\n            [(1, 2), (3, 4), (1.5, 2)],\n            [(1, 2), (3, False)],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n    def test_validation_order_independent(self):\n        # shape errors beat type errors, which beat start > end errors,\n        # regardless of element position\n        for bad in ([(2, 1), (1.5, 2)], [(1.5, 2), (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n        for bad in ([(1.5, 2), (1, 2, 3)], [(1, 2, 3), (1.5, 2)],\n                    [(2, 1), 7], [7, (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_non_iterable_or_string_like_input(self):\n        from collections import UserDict\n        from types import MappingProxyType\n\n        for bad in (\n            None,\n            5,\n            \"ab\",\n            b\"ab\",\n            bytearray(b\"ab\"),\n            {(1, 2): 3},\n            MappingProxyType({(1, 2): 3}),\n            UserDict({(1, 2): 3}),\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 4, "tests_passed": 10, "tests_failed": 0, "code_lines": 233, "cum_changed": 220, "review_cost": 0.04586369999999999, "parse_ok": true, "findings": [{"title": "TypeError raised inside a custom __iter__ is masked and rewritten", "severity": "medium", "detail": "`iter(ranges)` is wrapped in `try/except TypeError` and re-raised as a generic 'ranges must be an iterable' error with `from None`. If an object's own `__iter__` raises a TypeError, that original exception and its traceback are discarded. This contradicts the documented rule that an exception raised while consuming the iterable propagates unchanged. The existing generator test misses it because generators only raise on `next()`, which happens in `list(iterator)` outside the try. A safer approach is to check iterability without calling `__iter__` (for example, look up `type(ranges).__iter__`/`__getitem__`), or to catch the error only when the type is genuinely non-iterable."}, {"title": "Normalisation with int() can call an overridden __int__ on int subclasses", "severity": "low", "detail": "The spec says int subclasses are normalised to plain ints. `int(x)` on a subclass with a custom `__int__` may return a different value than the underlying integer, or raise. This is an edge case, but it silently changes bounds after type validation. `int.__index__(x)`, or `x + 0` with an int-based approach, avoids user-defined hooks. IntEnum is fine, so the current test does not expose this."}, {"title": "Test 'test_tuple_elements_are_new_objects' does not test what its name says", "severity": "low", "detail": "The variable `a` is created but never compared against the result, and there is no identity assertion. The test only checks equality and that the result is a list, so it duplicates basic tests and gives false confidence about the 'always new objects' guarantee. Tuples of small ints may also be shared by the interpreter, so an identity check would need care."}, {"title": "Weak assertion in test_list_elements_not_modified_or_returned", "severity": "low", "detail": "The `assertIsNot(r, x)` check compares result tuples with input lists, which can never be the same object, so it is vacuous. The nested loop also re-runs the `isinstance` check redundantly. It would be more meaningful to compare against the input's own element types, or simply to assert that the result elements are tuples."}, {"title": "Error message for start > end reports normalised values, without repr", "severity": "low", "detail": "`f\"invalid range: {(start, end)}\"` formats the already-converted ints and omits `!r`. Other messages use `{item!r}` of the original element, so the style is inconsistent, and for int subclasses the message shows the plain int rather than what the caller passed."}, {"title": "Docstring grammar and missing coverage for some documented rules", "severity": "low", "detail": "The sentence 'ranges that is not iterable...' is ungrammatical. Tests also do not cover tuple/list subclasses such as namedtuples, an `__iter__` that raises TypeError (see above), or a non-TypeError exception from a generator, which the 'propagates unchanged' rule covers."}], "review_text": "", "fix_cost": 0.07707589999999999, "fix_ok": true, "step_changed": 86, "code_after": "import unittest\nfrom collections.abc import Mapping\n\n\ndef merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: an iterable (list, tuple, set, generator, ...) of (start, end)\n    pairs of ints, inclusive on both ends. Each pair may be a tuple or a list.\n    The result is always a new list of tuples, even when the input elements\n    are lists.\n    Rules:\n    - a ranges argument that is not iterable, or is a str, bytes, bytearray\n      or any Mapping (dict, MappingProxyType, UserDict, ...) -> TypeError\n    - an exception raised while consuming the iterable (including a\n      TypeError raised by its own __iter__) propagates unchanged\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - a range with start > end -> ValueError\n    - int subclasses (e.g. IntEnum members) are accepted and normalised to\n      plain ints in the result\n    - validation runs in stages over the whole input, so the error type does\n      not depend on element order: element shape (ValueError) is checked\n      first, then bound types (TypeError), then start > end (ValueError)\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input must not be modified\n    - empty input -> []\n    \"\"\"\n    if isinstance(ranges, (str, bytes, bytearray, Mapping)):\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        )\n    cls = type(ranges)\n    # Check iterability on the type so a TypeError raised by the object's own\n    # __iter__ is never intercepted.\n    if (getattr(cls, \"__iter__\", None) is None\n            and getattr(cls, \"__getitem__\", None) is None):\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {cls.__name__}\"\n        )\n    items = list(ranges)\n\n    for item in items:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n\n    for item in items:\n        for value in item:\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n\n    pairs = []\n    for item in items:\n        # int.__index__ avoids user-defined __int__/__index__ on subclasses\n        start, end = int.__index__(item[0]), int.__index__(item[1])\n        if start > end:\n            raise ValueError(f\"invalid range: {item!r}\")\n        pairs.append((start, end))\n\n    merged = []\n    for start, end in sorted(pairs):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\nclass MergeRangesTests(unittest.TestCase):\n    def test_basic(self):\n        self.assertEqual(merge_ranges([]), [])\n        self.assertEqual(merge_ranges([(1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(1, 2), (2, 3)]), [(1, 3)])\n        self.assertEqual(merge_ranges([(1, 2), (3, 4)]), [(1, 2), (3, 4)])\n        self.assertEqual(merge_ranges([(1, 10), (2, 3)]), [(1, 10)])\n        self.assertEqual(merge_ranges([(1, 2), (1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(5, 6), (1, 2), (2, 5)]), [(1, 6)])\n        self.assertEqual(merge_ranges([(3, 3)]), [(3, 3)])\n\n    def test_list_elements(self):\n        self.assertEqual(merge_ranges([[1, 2], [2, 3]]), [(1, 3)])\n        self.assertEqual(merge_ranges([[5, 6], (1, 2)]), [(1, 2), (5, 6)])\n\n    def test_bridging(self):\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (2, 7)]), [(1, 8)]\n        )\n        # adjacent integers do not merge; only overlapping/touching ranges do\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]),\n            [(1, 2), (3, 3), (4, 5), (7, 8)],\n        )\n\n    def test_chained_unsorted_merges(self):\n        ranges = [(i, i + 1) for i in range(0, 200, 2)]\n        ranges.reverse()\n        self.assertEqual(merge_ranges(ranges), [(0, 199)])\n        shuffled = [(9, 10), (1, 2), (5, 6), (3, 4), (7, 8), (2, 3), (4, 5),\n                    (6, 7), (8, 9)]\n        self.assertEqual(merge_ranges(shuffled), [(1, 10)])\n\n    def test_negative_bounds(self):\n        self.assertEqual(\n            merge_ranges([(-5, -3), (-3, 0), (2, 4)]), [(-5, 0), (2, 4)]\n        )\n        self.assertEqual(\n            merge_ranges([(-2, -1), (-10, -5)]), [(-10, -5), (-2, -1)]\n        )\n\n    def test_very_large_ints(self):\n        big = 10**40\n        self.assertEqual(\n            merge_ranges([(big, big + 5), (big + 5, big + 9), (-big, -big)]),\n            [(-big, -big), (big, big + 9)],\n        )\n\n    def test_one_shot_iterables(self):\n        self.assertEqual(merge_ranges(iter([(1, 2), (2, 3)])), [(1, 3)])\n        self.assertEqual(merge_ranges(r for r in [(3, 4), (1, 2)]), [(1, 4)])\n        with self.assertRaises(ValueError):\n            merge_ranges(iter([(2, 1)]))\n\n    def test_set_inputs(self):\n        self.assertEqual(merge_ranges({(3, 4), (1, 2), (2, 3)}), [(1, 4)])\n        self.assertEqual(merge_ranges(frozenset({(5, 6), (1, 2)})),\n                         [(1, 2), (5, 6)])\n\n    def test_generator_type_error_propagates(self):\n        def gen():\n            yield (1, 2)\n            raise TypeError(\"boom from generator\")\n\n        with self.assertRaises(TypeError) as ctx:\n            merge_ranges(gen())\n        self.assertEqual(str(ctx.exception), \"boom from generator\")\n\n    def test_generator_other_exception_propagates(self):\n        class Boom(Exception):\n            pass\n\n        def gen():\n            yield (1, 2)\n            raise Boom(\"boom from generator\")\n\n        with self.assertRaises(Boom):\n            merge_ranges(gen())\n\n    def test_custom_iter_type_error_propagates(self):\n        class BadIter:\n            def __iter__(self):\n                raise TypeError(\"boom from __iter__\")\n\n        with self.assertRaises(TypeError) as ctx:\n            merge_ranges(BadIter())\n        self.assertEqual(str(ctx.exception), \"boom from __iter__\")\n\n    def test_input_not_modified(self):\n        original = [(5, 6), (1, 2), (2, 3)]\n        snapshot = list(original)\n        merge_ranges(original)\n        self.assertEqual(original, snapshot)\n\n    def test_list_elements_not_modified_or_returned(self):\n        inner = [[5, 6], [1, 2], [2, 3]]\n        snapshot = [list(x) for x in inner]\n        result = merge_ranges(inner)\n        self.assertEqual(inner, snapshot)\n        self.assertEqual(result, [(1, 3), (5, 6)])\n        for r in result:\n            self.assertIsInstance(r, tuple)\n\n    def test_tuple_elements_are_new_objects(self):\n        a = tuple([10**6, 10**6 + 1])\n        result = merge_ranges([a])\n        self.assertEqual(result, [a])\n        self.assertIsInstance(result, list)\n        self.assertIsNot(result[0], a)\n\n    def test_namedtuple_and_list_subclass_elements(self):\n        from collections import namedtuple\n\n        Pair = namedtuple(\"Pair\", \"start end\")\n\n        class MyList(list):\n            pass\n\n        result = merge_ranges([Pair(1, 2), MyList([2, 3]), Pair(5, 6)])\n        self.assertEqual(result, [(1, 3), (5, 6)])\n        for r in result:\n            self.assertIs(type(r), tuple)\n\n    def test_int_subclass_normalised(self):\n        import enum\n\n        class Num(enum.IntEnum):\n            ONE = 1\n            TWO = 2\n            THREE = 3\n\n        result = merge_ranges([(Num.ONE, Num.TWO), (Num.TWO, Num.THREE)])\n        self.assertEqual(result, [(1, 3)])\n        for start, end in result:\n            self.assertIs(type(start), int)\n            self.assertIs(type(end), int)\n\n    def test_int_subclass_with_custom_int_hook(self):\n        class Weird(int):\n            def __int__(self):\n                return 999\n\n        result = merge_ranges([(Weird(1), Weird(2))])\n        self.assertEqual(result, [(1, 2)])\n        self.assertIs(type(result[0][0]), int)\n\n    def test_value_errors(self):\n        for bad in (\n            [(2, 1)],\n            [(1, 2, 3)],\n            [5],\n            [(1, 2), (3, 4), (6, 5)],\n            [(1, 2), (3, 4), (1, 2, 3)],\n            [(1, 2), 7],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_start_after_end_message_uses_original(self):\n        import enum\n\n        class Num(enum.IntEnum):\n            ONE = 1\n            TWO = 2\n\n        with self.assertRaises(ValueError) as ctx:\n            merge_ranges([(Num.TWO, Num.ONE)])\n        self.assertIn(repr((Num.TWO, Num.ONE)), str(ctx.exception))\n\n    def test_type_errors(self):\n        for bad in (\n            [(1.0, 2)],\n            [(None, 2)],\n            [(\"a\", \"b\")],\n            [(True, 2)],\n            [(1, 2), (3, 4), (1.5, 2)],\n            [(1, 2), (3, False)],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n    def test_validation_order_independent(self):\n        # shape errors beat type errors, which beat start > end errors,\n        # regardless of element position\n        for bad in ([(2, 1), (1.5, 2)], [(1.5, 2), (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n        for bad in ([(1.5, 2), (1, 2, 3)], [(1, 2, 3), (1.5, 2)],\n                    [(2, 1), 7], [7, (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_non_iterable_or_string_like_input(self):\n        from collections import UserDict\n        from types import MappingProxyType\n\n        for bad in (\n            None,\n            5,\n            \"ab\",\n            b\"ab\",\n            bytearray(b\"ab\"),\n            {(1, 2): 3},\n            MappingProxyType({(1, 2): 3}),\n            UserDict({(1, 2): 3}),\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 5, "tests_passed": 10, "tests_failed": 0, "code_lines": 289, "cum_changed": 278, "review_cost": 0.055466299999999996, "parse_ok": true, "findings": [{"title": "test_chained_unsorted_merges first assertion contradicts the function's own rules and will fail", "severity": "high", "detail": "The test builds [(i, i+1) for i in range(0, 200, 2)], which gives (0,1), (2,3), (4,5), ... Consecutive ranges do not overlap or touch, because 1 != 2, 3 != 4, and so on. The docstring and test_bridging both say adjacent integers are not merged. merge_ranges therefore returns 100 separate tuples, not [(0, 199)], and the assertEqual fails. The test data should be something like (i, i+2) so each end equals the next start, with the expected result adjusted to [(0, 200)]. Or the expectation should be 100 disjoint ranges. The second assertion in that test (the shuffled touching ranges merging into (1, 10)) is correct."}, {"title": "Validation reads elements inconsistently for tuple/list subclasses", "severity": "low", "detail": "The type-check stage iterates with `for value in item`, while the normalisation stage uses `item[0]` and `item[1]`. A tuple or list subclass that overrides __iter__ or __getitem__ differently could pass validation on one path and yield different values on the other. Unpacking once (`start, end = item`) or indexing consistently in both stages would remove the gap."}, {"title": "Iterability pre-check is redundant and accepts any type with __getitem__", "severity": "low", "detail": "list(ranges) already raises TypeError for non-iterables, so the getattr-on-type check only customises the message. Accepting any type that has __getitem__ lets non-sequence objects, such as a custom class whose __getitem__ never raises IndexError, reach list() and potentially loop forever. This is an edge case, but the check adds complexity for little benefit."}, {"title": "Tests import modules inside test methods", "severity": "low", "detail": "enum, namedtuple, UserDict and MappingProxyType are imported inside individual tests and are repeated (enum appears twice). Moving them to the top of the module is more conventional and removes the duplication."}], "review_text": "", "fix_cost": 0.0752288, "fix_ok": true, "step_changed": 40, "code_after": "import enum\nimport unittest\nfrom collections import UserDict, namedtuple\nfrom collections.abc import Mapping\nfrom types import MappingProxyType\n\n\ndef merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: an iterable (list, tuple, set, generator, ...) of (start, end)\n    pairs of ints, inclusive on both ends. Each pair may be a tuple or a list.\n    The result is always a new list of tuples, even when the input elements\n    are lists.\n    Rules:\n    - a ranges argument that is not iterable, or is a str, bytes, bytearray\n      or any Mapping (dict, MappingProxyType, UserDict, ...) -> TypeError\n    - an exception raised while consuming the iterable (including a\n      TypeError raised by its own __iter__) propagates unchanged\n    - an element that is not a 2-item tuple/list -> ValueError\n    - a start or end that is not an int (bool included) -> TypeError\n    - a range with start > end -> ValueError\n    - int subclasses (e.g. IntEnum members) are accepted and normalised to\n      plain ints in the result\n    - validation runs in stages over the whole input, so the error type does\n      not depend on element order: element shape (ValueError) is checked\n      first, then bound types (TypeError), then start > end (ValueError)\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input must not be modified\n    - empty input -> []\n    \"\"\"\n    if isinstance(ranges, (str, bytes, bytearray, Mapping)):\n        raise TypeError(\n            f\"ranges must be an iterable of (start, end) pairs, \"\n            f\"not {type(ranges).__name__}\"\n        )\n    # list() raises TypeError for non-iterables and lets any exception from\n    # the object's own __iter__ propagate unchanged.\n    items = list(ranges)\n\n    for item in items:\n        if not isinstance(item, (tuple, list)) or len(item) != 2:\n            raise ValueError(f\"invalid range: {item!r}\")\n\n    # Unpack each element exactly once so every later stage sees the same\n    # values, even for tuple/list subclasses with custom __iter__/__getitem__.\n    unpacked = []\n    for item in items:\n        start, end = item\n        unpacked.append((item, start, end))\n\n    for item, start, end in unpacked:\n        for value in (start, end):\n            if isinstance(value, bool) or not isinstance(value, int):\n                raise TypeError(f\"range bounds must be ints: {item!r}\")\n\n    pairs = []\n    for item, start, end in unpacked:\n        # int.__index__ avoids user-defined __int__/__index__ on subclasses\n        start, end = int.__index__(start), int.__index__(end)\n        if start > end:\n            raise ValueError(f\"invalid range: {item!r}\")\n        pairs.append((start, end))\n\n    merged = []\n    for start, end in sorted(pairs):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n\n\nclass MergeRangesTests(unittest.TestCase):\n    def test_basic(self):\n        self.assertEqual(merge_ranges([]), [])\n        self.assertEqual(merge_ranges([(1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(1, 2), (2, 3)]), [(1, 3)])\n        self.assertEqual(merge_ranges([(1, 2), (3, 4)]), [(1, 2), (3, 4)])\n        self.assertEqual(merge_ranges([(1, 10), (2, 3)]), [(1, 10)])\n        self.assertEqual(merge_ranges([(1, 2), (1, 2)]), [(1, 2)])\n        self.assertEqual(merge_ranges([(5, 6), (1, 2), (2, 5)]), [(1, 6)])\n        self.assertEqual(merge_ranges([(3, 3)]), [(3, 3)])\n\n    def test_list_elements(self):\n        self.assertEqual(merge_ranges([[1, 2], [2, 3]]), [(1, 3)])\n        self.assertEqual(merge_ranges([[5, 6], (1, 2)]), [(1, 2), (5, 6)])\n\n    def test_bridging(self):\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (2, 7)]), [(1, 8)]\n        )\n        # adjacent integers do not merge; only overlapping/touching ranges do\n        self.assertEqual(\n            merge_ranges([(1, 2), (4, 5), (7, 8), (3, 3)]),\n            [(1, 2), (3, 3), (4, 5), (7, 8)],\n        )\n\n    def test_chained_unsorted_merges(self):\n        ranges = [(i, i + 2) for i in range(0, 200, 2)]\n        ranges.reverse()\n        self.assertEqual(merge_ranges(ranges), [(0, 200)])\n        shuffled = [(9, 10), (1, 2), (5, 6), (3, 4), (7, 8), (2, 3), (4, 5),\n                    (6, 7), (8, 9)]\n        self.assertEqual(merge_ranges(shuffled), [(1, 10)])\n\n    def test_negative_bounds(self):\n        self.assertEqual(\n            merge_ranges([(-5, -3), (-3, 0), (2, 4)]), [(-5, 0), (2, 4)]\n        )\n        self.assertEqual(\n            merge_ranges([(-2, -1), (-10, -5)]), [(-10, -5), (-2, -1)]\n        )\n\n    def test_very_large_ints(self):\n        big = 10**40\n        self.assertEqual(\n            merge_ranges([(big, big + 5), (big + 5, big + 9), (-big, -big)]),\n            [(-big, -big), (big, big + 9)],\n        )\n\n    def test_one_shot_iterables(self):\n        self.assertEqual(merge_ranges(iter([(1, 2), (2, 3)])), [(1, 3)])\n        self.assertEqual(merge_ranges(r for r in [(3, 4), (1, 2)]), [(1, 4)])\n        with self.assertRaises(ValueError):\n            merge_ranges(iter([(2, 1)]))\n\n    def test_set_inputs(self):\n        self.assertEqual(merge_ranges({(3, 4), (1, 2), (2, 3)}), [(1, 4)])\n        self.assertEqual(merge_ranges(frozenset({(5, 6), (1, 2)})),\n                         [(1, 2), (5, 6)])\n\n    def test_generator_type_error_propagates(self):\n        def gen():\n            yield (1, 2)\n            raise TypeError(\"boom from generator\")\n\n        with self.assertRaises(TypeError) as ctx:\n            merge_ranges(gen())\n        self.assertEqual(str(ctx.exception), \"boom from generator\")\n\n    def test_generator_other_exception_propagates(self):\n        class Boom(Exception):\n            pass\n\n        def gen():\n            yield (1, 2)\n            raise Boom(\"boom from generator\")\n\n        with self.assertRaises(Boom):\n            merge_ranges(gen())\n\n    def test_custom_iter_type_error_propagates(self):\n        class BadIter:\n            def __iter__(self):\n                raise TypeError(\"boom from __iter__\")\n\n        with self.assertRaises(TypeError) as ctx:\n            merge_ranges(BadIter())\n        self.assertEqual(str(ctx.exception), \"boom from __iter__\")\n\n    def test_input_not_modified(self):\n        original = [(5, 6), (1, 2), (2, 3)]\n        snapshot = list(original)\n        merge_ranges(original)\n        self.assertEqual(original, snapshot)\n\n    def test_list_elements_not_modified_or_returned(self):\n        inner = [[5, 6], [1, 2], [2, 3]]\n        snapshot = [list(x) for x in inner]\n        result = merge_ranges(inner)\n        self.assertEqual(inner, snapshot)\n        self.assertEqual(result, [(1, 3), (5, 6)])\n        for r in result:\n            self.assertIsInstance(r, tuple)\n\n    def test_tuple_elements_are_new_objects(self):\n        a = tuple([10**6, 10**6 + 1])\n        result = merge_ranges([a])\n        self.assertEqual(result, [a])\n        self.assertIsInstance(result, list)\n        self.assertIsNot(result[0], a)\n\n    def test_namedtuple_and_list_subclass_elements(self):\n        Pair = namedtuple(\"Pair\", \"start end\")\n\n        class MyList(list):\n            pass\n\n        result = merge_ranges([Pair(1, 2), MyList([2, 3]), Pair(5, 6)])\n        self.assertEqual(result, [(1, 3), (5, 6)])\n        for r in result:\n            self.assertIs(type(r), tuple)\n\n    def test_int_subclass_normalised(self):\n        class Num(enum.IntEnum):\n            ONE = 1\n            TWO = 2\n            THREE = 3\n\n        result = merge_ranges([(Num.ONE, Num.TWO), (Num.TWO, Num.THREE)])\n        self.assertEqual(result, [(1, 3)])\n        for start, end in result:\n            self.assertIs(type(start), int)\n            self.assertIs(type(end), int)\n\n    def test_int_subclass_with_custom_int_hook(self):\n        class Weird(int):\n            def __int__(self):\n                return 999\n\n        result = merge_ranges([(Weird(1), Weird(2))])\n        self.assertEqual(result, [(1, 2)])\n        self.assertIs(type(result[0][0]), int)\n\n    def test_value_errors(self):\n        for bad in (\n            [(2, 1)],\n            [(1, 2, 3)],\n            [5],\n            [(1, 2), (3, 4), (6, 5)],\n            [(1, 2), (3, 4), (1, 2, 3)],\n            [(1, 2), 7],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_start_after_end_message_uses_original(self):\n        class Num(enum.IntEnum):\n            ONE = 1\n            TWO = 2\n\n        with self.assertRaises(ValueError) as ctx:\n            merge_ranges([(Num.TWO, Num.ONE)])\n        self.assertIn(repr((Num.TWO, Num.ONE)), str(ctx.exception))\n\n    def test_type_errors(self):\n        for bad in (\n            [(1.0, 2)],\n            [(None, 2)],\n            [(\"a\", \"b\")],\n            [(True, 2)],\n            [(1, 2), (3, 4), (1.5, 2)],\n            [(1, 2), (3, False)],\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n    def test_validation_order_independent(self):\n        # shape errors beat type errors, which beat start > end errors,\n        # regardless of element position\n        for bad in ([(2, 1), (1.5, 2)], [(1.5, 2), (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n        for bad in ([(1.5, 2), (1, 2, 3)], [(1, 2, 3), (1.5, 2)],\n                    [(2, 1), 7], [7, (2, 1)]):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(ValueError):\n                    merge_ranges(bad)\n\n    def test_non_iterable_or_string_like_input(self):\n        for bad in (\n            None,\n            5,\n            \"ab\",\n            b\"ab\",\n            bytearray(b\"ab\"),\n            {(1, 2): 3},\n            MappingProxyType({(1, 2): 3}),\n            UserDict({(1, 2): 3}),\n        ):\n            with self.subTest(ranges=bad):\n                with self.assertRaises(TypeError):\n                    merge_ranges(bad)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"}
{"module": "ranges", "model": "sonnet", "prompt": "open", "rep": 2, "round": 6, "tests_passed": 10, "tests_failed": 0, "code_lines": 283, "cum_changed": 272, "review_cost": 0.0495092, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.021623399999999997, "parse_ok": true, "findings": [{"title": "Touching ranges not merged", "severity": "high", "detail": "Input: [(1, 3), (3, 5)]. Expected: [(1, 5)] (ranges that touch should merge). Actual: [(1, 3), (3, 5)]. The condition `start < merged[-1][1]` should be `start <= merged[-1][1]` to handle ranges where the end of one equals the start of the next."}], "review_text": "", "fix_cost": 0.0178938, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.054096, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0128057, "parse_ok": true, "findings": [{"title": "Shared user retains access exactly at expires_at (off-by-one boundary)", "severity": "high", "detail": "The spec says access ends exactly AT expires_at, so a shared user may view only while now < expires_at. The code uses `now <= expires_at`. Input: doc={'owner':'a','shared_with':{'b'},'expires_at':100,'suspended':set()}, user='b', now=100. Expected: False (access ends at expires_at). Actual: True."}], "review_text": "", "fix_cost": 0.0140608, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0110761, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.012572900000000001, "parse_ok": true, "findings": [{"title": "Shared access incorrectly includes the expiry instant (<= instead of <)", "severity": "medium", "detail": "Spec: a shared user can view only while now is strictly before expires_at, so access ends exactly AT expires_at. Code uses `now <= expires_at`. Input: doc={'owner':'o','shared_with':{'bob'},'expires_at':100,'suspended':set()}, user='bob', now=100. Expected: False (access ends at expires_at). Actual: True."}], "review_text": "", "fix_cost": 0.0140905, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0110596, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0127762, "parse_ok": true, "findings": [{"title": "Shared user retains access exactly at expires_at (off-by-one boundary)", "severity": "medium", "detail": "The spec says access ends exactly AT expires_at, so a shared user may view only while now < expires_at. The code uses `now <= expires_at`. Input: doc={'owner':'a','shared_with':{'b'},'expires_at':100,'suspended':set()}, user='b', now=100. Expected: False (access ends at expires_at). Actual: True."}], "review_text": "", "fix_cost": 0.014057400000000001, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "evidence", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0110841, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.016671899999999996, "parse_ok": true, "findings": [{"title": "Expiry check is off by one: access is granted at exactly expires_at", "severity": "high", "detail": "The spec says shared access ends exactly AT expires_at, so a shared user may view only while now < expires_at. The code uses `now <= expires_at`, which still allows access at the instant of expiry. Fix: `return expires_at is None or now < expires_at`."}, {"title": "Inconsistent key access: doc[\"owner\"], doc[\"shared_with\"] and doc[\"expires_at\"] raise KeyError when missing", "severity": "low", "detail": "`suspended` is read defensively with `doc.get(..., set())`, but `owner`, `shared_with` and `expires_at` are indexed directly. A doc without `expires_at` (a plausible way to say 'never expires') or without `shared_with` raises KeyError instead of being handled. Either treat all optional keys uniformly with `.get` or document that they are required."}, {"title": "Explicit None for `suspended` or `shared_with` would raise TypeError", "severity": "low", "detail": "`doc.get(\"suspended\", set())` returns None if the key is present with value None, and `user in None` raises TypeError. The same applies to `shared_with`. Use `doc.get(\"suspended\") or set()` if such data is possible."}, {"title": "No handling of invalid `now` or `expires_at` types", "severity": "low", "detail": "If `now` is None or `expires_at` is a non-numeric value such as a string, the comparison raises TypeError. Failing closed (deny) or validating input would be safer for an access-control function."}], "review_text": "", "fix_cost": 0.016263000000000003, "fix_ok": true, "step_changed": 22, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str (optional, missing or None = nobody)\n      \"expires_at\": number or None (None or missing = never expires)\n      \"suspended\": set of str (optional; users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    - invalid input (e.g. non-comparable `now` or `expires_at`) fails closed\n    \"\"\"\n    if user in (doc.get(\"suspended\") or set()):\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    if user in (doc.get(\"shared_with\") or set()):\n        expires_at = doc.get(\"expires_at\")\n        if expires_at is None:\n            return True\n        try:\n            return bool(now < expires_at)\n        except TypeError:\n            return False\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 29, "cum_changed": 22, "review_cost": 0.0271915, "parse_ok": true, "findings": [{"title": "Missing/None owner lets a None user be treated as the owner (fails open)", "severity": "high", "detail": "`user == doc.get(\"owner\")` is True when the doc has no \"owner\" key (or owner is None) and `user` is None. An unauthenticated or unset user then gets owner-level access, which bypasses expiry. The check should require a real owner, e.g. `owner = doc.get(\"owner\"); if owner is not None and user == owner`, and ideally reject `user is None` up front."}, {"title": "Invalid input can raise instead of failing closed", "severity": "medium", "detail": "The spec says invalid input fails closed, but only the `now < expires_at` comparison is guarded. An unhashable `user` (e.g. a list) makes `user in <set>` raise TypeError in the suspended and shared_with checks. A `doc` of None raises AttributeError. `bool(now < expires_at)` can raise ValueError for array-like values (e.g. numpy), and that is not caught because only TypeError is handled. Wrap the whole decision in a try/except that returns False, or validate the inputs."}, {"title": "String-valued `suspended`/`shared_with` cause substring matches", "severity": "medium", "detail": "If either field is a plain string rather than a set (e.g. \"alice\"), `user in value` does substring matching, so user \"ali\" or \"a\" would count as shared (granting access) or suspended (wrongly denying). A malformed `shared_with` therefore fails open. Validate that these fields are sets/frozensets (or other non-string collections) and fail closed otherwise."}, {"title": "Malformed `suspended` can fail open", "severity": "low", "detail": "`(doc.get(\"suspended\") or set())` treats any falsy value as 'nobody suspended'. A non-iterable truthy value such as an int raises TypeError. A malformed suspension list is silently ignored when falsy, so a user who should be denied may pass through. Validate the type rather than relying on truthiness."}, {"title": "No handling of `now` being None when expires_at is set, and no validation of numeric types", "severity": "low", "detail": "Comparison works only because Python raises TypeError for None or mismatched types. Values such as bools, strings vs strings (lexicographic comparison), or NaN get accepted or rejected by accident rather than by design. `now='2025'` with `expires_at='2026'` compares as strings and may grant access. Consider requiring `isinstance(..., (int, float))` (excluding bool) for both values."}], "review_text": "", "fix_cost": 0.023312299999999998, "fix_ok": true, "step_changed": 53, "code_after": "import math\n\n\ndef _is_number(value):\n    return (\n        isinstance(value, (int, float))\n        and not isinstance(value, bool)\n        and not math.isnan(value)\n    )\n\n\ndef can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str (optional, missing or None = nobody)\n      \"expires_at\": number or None (None or missing = never expires)\n      \"suspended\": set of str (optional; users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    - invalid input (e.g. non-comparable `now` or `expires_at`) fails closed\n    \"\"\"\n    try:\n        if not isinstance(doc, dict) or user is None:\n            return False\n\n        suspended = doc.get(\"suspended\")\n        if suspended is None:\n            suspended = frozenset()\n        elif not isinstance(suspended, (set, frozenset)):\n            return False\n        if user in suspended:\n            return False\n\n        owner = doc.get(\"owner\")\n        if owner is not None and user == owner:\n            return True\n\n        shared_with = doc.get(\"shared_with\")\n        if shared_with is None:\n            return False\n        if not isinstance(shared_with, (set, frozenset)):\n            return False\n        if user in shared_with:\n            expires_at = doc.get(\"expires_at\")\n            if expires_at is None:\n                return True\n            if not _is_number(expires_at) or not _is_number(now):\n                return False\n            return now < expires_at\n        return False\n    except Exception:\n        return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 58, "cum_changed": 55, "review_cost": 0.0256603, "parse_ok": true, "findings": [{"title": "_is_number raises OverflowError for very large ints, wrongly denying access", "severity": "low", "detail": "math.isnan(value) converts the value to a float. For an int too large for a float (e.g. 10**400) it raises OverflowError. The blanket except in can_view catches it and returns False. A shared user whose expires_at or now is such an int is denied, even though now < expires_at would be a valid comparison. The result fails closed, so it is not a security hole, but it contradicts the stated rules. Fix: only call math.isnan when isinstance(value, float)."}, {"title": "Blanket `except Exception` hides programming errors and invalid input alike", "severity": "low", "detail": "Every exception becomes False, including genuine bugs such as the OverflowError above and TypeErrors from unhashable users. Most invalid input is already handled by explicit type checks, so the catch-all mostly masks defects. Failures like this are silent and hard to diagnose, because nothing is logged. Narrow the except clause, or at least log the exception."}], "review_text": "", "fix_cost": 0.021558, "fix_ok": true, "step_changed": 14, "code_after": "import math\n\n\ndef _is_number(value):\n    if isinstance(value, bool) or not isinstance(value, (int, float)):\n        return False\n    # Only floats can be NaN; calling math.isnan on a huge int would overflow.\n    if isinstance(value, float) and math.isnan(value):\n        return False\n    return True\n\n\ndef can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str (optional, missing or None = nobody)\n      \"expires_at\": number or None (None or missing = never expires)\n      \"suspended\": set of str (optional; users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    - invalid input (e.g. non-comparable `now` or `expires_at`) fails closed\n    \"\"\"\n    try:\n        if not isinstance(doc, dict) or user is None:\n            return False\n\n        suspended = doc.get(\"suspended\")\n        if suspended is None:\n            suspended = frozenset()\n        elif not isinstance(suspended, (set, frozenset)):\n            return False\n        if user in suspended:\n            return False\n\n        owner = doc.get(\"owner\")\n        if owner is not None and user == owner:\n            return True\n\n        shared_with = doc.get(\"shared_with\")\n        if shared_with is None:\n            return False\n        if not isinstance(shared_with, (set, frozenset)):\n            return False\n        if user in shared_with:\n            expires_at = doc.get(\"expires_at\")\n            if expires_at is None:\n                return True\n            if not _is_number(expires_at) or not _is_number(now):\n                return False\n            return now < expires_at\n        return False\n    except TypeError:\n        # Unhashable `user` in a set membership test: invalid input, fail closed.\n        return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 10, "tests_failed": 0, "code_lines": 60, "cum_changed": 57, "review_cost": 0.024268099999999997, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 6, "tests_failed": 4, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0357811, "parse_ok": true, "findings": [{"title": "Merging condition excludes touching ranges", "severity": "high", "detail": "The condition `start < merged[-1][1]` should be `start <= merged[-1][1]`. According to the docstring, ranges that touch (where the end of one equals the start of the next) should be merged. Since ranges are inclusive on both ends, [0, 5] and [5, 10] both include 5 and should merge to [0, 10], but the current condition `5 < 5` evaluates to False and prevents the merge. Example: merge_ranges([(0, 5), (5, 10)]) returns [(0, 5), (5, 10)] instead of [(0, 10)]."}], "review_text": "", "fix_cost": 0.0183917, "fix_ok": true, "step_changed": 2, "code_after": "def merge_ranges(ranges):\n    \"\"\"Merge integer ranges.\n\n    ranges: list of (start, end) tuples of ints, inclusive on both ends.\n    Rules:\n    - a range with start > end -> ValueError\n    - ranges that overlap OR touch (the end of one equals the start of the\n      next) are merged into one\n    - the result is sorted by start and contains no mergeable ranges\n    - the input list must not be modified\n    - empty input -> []\n    \"\"\"\n    for start, end in ranges:\n        if start > end:\n            raise ValueError(f\"invalid range: {(start, end)}\")\n\n    merged = []\n    for start, end in sorted(ranges):\n        if merged and start <= merged[-1][1]:\n            merged[-1] = (merged[-1][0], max(merged[-1][1], end))\n        else:\n            merged.append((start, end))\n    return merged\n"}
{"module": "ranges", "model": "haiku", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0901072, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.018968299999999997, "parse_ok": true, "findings": [{"title": "Shared user access check uses incorrect time boundary", "severity": "high", "detail": "Input: doc={'owner':'alice','shared_with':{'bob'},'expires_at':100,'suspended':set()}, user='bob', now=100. Expected: False (spec says 'access ends exactly AT expires_at', so at time 100 access should be denied). Actual: True (code uses 'now <= expires_at' instead of 'now < expires_at')."}], "review_text": "", "fix_cost": 0.0189733, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0387997, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0185128, "parse_ok": true, "findings": [{"title": "Shared-with access allows viewing at expires_at, should deny", "severity": "high", "detail": "Spec: 'a user in shared_with can view only while now is strictly before expires_at (access ends exactly AT expires_at)'. Input: doc={owner:'alice', shared_with:{'bob'}, expires_at:100}, user='bob', now=100. Expected: False. Actual: True. Code uses `now <= expires_at` but spec requires `now < expires_at`."}], "review_text": "", "fix_cost": 0.018104799999999997, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.0255042, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.019757300000000002, "parse_ok": true, "findings": [{"title": "expires_at boundary condition is off-by-one", "severity": "high", "detail": "Spec: 'a user in shared_with can view only while now is strictly before expires_at (access ends exactly AT expires_at)'. Input: doc={'owner': 'alice', 'shared_with': {'bob'}, 'expires_at': 100, 'suspended': set()}, user='bob', now=100. Expected: False (access denied at the boundary). Actual: True (code returns now <= expires_at, allowing access at now=100)."}], "review_text": "", "fix_cost": 0.017618599999999998, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "evidence", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.042468200000000005, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.015122899999999998, "parse_ok": true, "findings": [{"title": "Expiry check is off by one: access is allowed exactly at expires_at", "severity": "high", "detail": "The docstring says shared access ends exactly AT expires_at, so a shared user may view only while now < expires_at. The code uses `now <= expires_at`, which still grants access at the instant of expiry. Change it to `expires_at is None or now < expires_at`."}, {"title": "Inconsistent handling of missing keys", "severity": "low", "detail": "`suspended` is read with doc.get and a default, but `owner`, `shared_with` and `expires_at` are read with direct indexing and raise KeyError if absent. A doc without `expires_at` but with a shared user crashes rather than being treated as non-expiring or denied. Either treat all optional keys consistently (e.g. doc.get('shared_with', set()), doc.get('expires_at')) or validate the input up front."}, {"title": "No handling of None for shared_with or suspended values", "severity": "low", "detail": "If a doc has `suspended: None` or `shared_with: None`, the `in` checks raise TypeError. doc.get('suspended', set()) only covers a missing key, not an explicit None."}], "review_text": "", "fix_cost": 0.0151742, "fix_ok": true, "step_changed": 12, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    suspended = doc.get(\"suspended\") or set()\n    if user in suspended:\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    shared_with = doc.get(\"shared_with\") or set()\n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 25, "cum_changed": 12, "review_cost": 0.0167865, "parse_ok": true, "findings": [{"title": "Unauthenticated/None user is treated as owner when doc has no owner", "severity": "low", "detail": "`user == doc.get(\"owner\")` is True when both are None. This happens if the \"owner\" key is missing or None and the caller passes user=None (for example, an anonymous or unauthenticated request). That user is then granted view access via the owner branch. The module otherwise fails closed, so this is an inconsistent edge case. Guard with `user is not None and user == doc.get(\"owner\")`, or reject a falsy user up front. The rest of the logic matches the stated rules: suspension is checked first and overrides the owner, the owner ignores expiry, and shared access uses a strict `now < expires_at` with None meaning never expires."}], "review_text": "", "fix_cost": 0.0148146, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    suspended = doc.get(\"suspended\") or set()\n    if user in suspended:\n        return False\n    if user is not None and user == doc.get(\"owner\"):\n        return True\n    shared_with = doc.get(\"shared_with\") or set()\n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 25, "cum_changed": 12, "review_cost": 0.0220885, "parse_ok": true, "findings": [{"title": "Anonymous (None) user is not rejected consistently in the shared_with check", "severity": "low", "detail": "The owner branch explicitly guards `user is not None`, but the shared_with branch does not. If `shared_with` ever contains None (for example, built from a missing or unset user id), an unauthenticated caller passing `user=None` is granted access. The same applies to empty-string ids. An early `if user is None: return False` would make the function fail closed."}, {"title": "Membership tests assume `suspended` and `shared_with` are real sets", "severity": "low", "detail": "The docstring says they are sets, but nothing enforces it. If either is a str (e.g. a single user id), `user in value` becomes a substring test, so user 'al' matches 'alice'. For `suspended` this wrongly denies users, and for `shared_with` it wrongly grants access. If a list is passed it works but is O(n). Normalizing or validating the types would avoid this."}, {"title": "Unhandled TypeError for unhashable user or incomparable `now`/`expires_at`", "severity": "low", "detail": "An unhashable `user` raises TypeError at the set membership test. A `now` of None, or of a different type than `expires_at` (e.g. datetime vs. float), raises TypeError in `now < expires_at`. The function raises instead of denying, which is acceptable if callers are trusted but not fail-closed. Document the expected types or catch the error and return False."}, {"title": "Missing or malformed doc fields silently degrade to defaults", "severity": "low", "detail": "Using `doc.get(...) or set()` means a missing 'suspended' key is treated as nobody suspended, and a missing 'owner' means no owner. A malformed document therefore fails open for the suspension rule. Since suspension is a security-relevant deny rule, consider requiring the key, or at least logging when it is absent."}], "review_text": "", "fix_cost": 0.018600899999999997, "fix_ok": true, "step_changed": 39, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n\n    The function fails closed: an anonymous (None) or empty user, a missing\n    or malformed \"suspended\" field, non-set collections, unhashable users and\n    incomparable `now`/`expires_at` values all result in a denial.\n    \"\"\"\n    if user is None or user == \"\":\n        return False\n\n    if not isinstance(doc, dict):\n        return False\n\n    # Suspension is a security-relevant deny rule, so it must be present\n    # and well-formed; otherwise deny.\n    suspended = doc.get(\"suspended\")\n    if not isinstance(suspended, (set, frozenset)):\n        return False\n\n    shared_with = doc.get(\"shared_with\")\n    if shared_with is None:\n        shared_with = frozenset()\n    elif not isinstance(shared_with, (set, frozenset)):\n        return False\n\n    try:\n        if user in suspended:\n            return False\n        if user == doc.get(\"owner\"):\n            return True\n        if user in shared_with:\n            expires_at = doc.get(\"expires_at\")\n            return expires_at is None or bool(now < expires_at)\n    except TypeError:\n        return False\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 3, "tests_passed": 9, "tests_failed": 1, "code_lines": 48, "cum_changed": 37, "review_cost": 0.028421800000000004, "parse_ok": true, "findings": [{"title": "Fail-closed guarantee only covers TypeError", "severity": "low", "detail": "The try block catches only TypeError, but the docstring promises that incomparable or malformed inputs result in denial. Other exceptions escape. For example, if `now` or `expires_at` is a numpy array, `bool(now < expires_at)` raises ValueError. A user or owner object with a custom `__eq__`, or a `now` with a custom `__lt__`, can raise anything. The caller then gets an exception instead of False. The fix is to catch `Exception` around the checks, or to validate that `now` and `expires_at` are real numbers."}, {"title": "Owner equality uses loose `==` with no type check", "severity": "low", "detail": "`user == doc.get('owner')` and the `in` checks accept any type with a matching `__eq__` and hash. Examples are `1` vs `True`, or a str subclass with an overridden `__eq__`. Docs say users are strings. The module does not enforce this, so a non-str user could be mistaken for the owner. Adding `isinstance(user, str)` makes the identity check strict."}, {"title": "Malformed `shared_with` denies the owner", "severity": "low", "detail": "The `shared_with` type check runs before the owner check, so a document with a bad `shared_with` value locks out its owner. This conflicts with the stated rule that the owner can always view. It is consistent with the docstring's fail-closed statement, but it is also inconsistent with a missing `shared_with`, which is treated as empty. Validating `shared_with` only when the share branch is reached would remove both inconsistencies."}], "review_text": "", "fix_cost": 0.0237453, "fix_ok": true, "step_changed": 30, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n\n    The function fails closed: an anonymous (None), empty or non-str user, a\n    missing or malformed \"suspended\" field, non-set collections, unhashable\n    users and incomparable `now`/`expires_at` values all result in a denial.\n    Any exception raised while evaluating the checks also results in a denial.\n    A malformed \"shared_with\" only affects the share branch, so it never\n    locks out the owner; a missing \"shared_with\" is treated as empty.\n    \"\"\"\n    if not isinstance(user, str) or user == \"\":\n        return False\n\n    if not isinstance(doc, dict):\n        return False\n\n    # Suspension is a security-relevant deny rule, so it must be present\n    # and well-formed; otherwise deny.\n    suspended = doc.get(\"suspended\")\n    if not isinstance(suspended, (set, frozenset)):\n        return False\n\n    try:\n        if user in suspended:\n            return False\n\n        owner = doc.get(\"owner\")\n        if isinstance(owner, str) and user == owner:\n            return True\n\n        shared_with = doc.get(\"shared_with\")\n        if shared_with is None:\n            return False\n        if not isinstance(shared_with, (set, frozenset)):\n            return False\n\n        if user in shared_with:\n            expires_at = doc.get(\"expires_at\")\n            return expires_at is None or bool(now < expires_at)\n    except Exception:\n        return False\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 4, "tests_passed": 9, "tests_failed": 1, "code_lines": 54, "cum_changed": 43, "review_cost": 0.0271007, "parse_ok": true, "findings": [{"title": "`doc.get(\"suspended\")` runs outside the try/except, so the 'any exception means denial' guarantee has a gap", "severity": "low", "detail": "The docstring promises that any exception raised while evaluating the checks results in a denial. The `suspended = doc.get(\"suspended\")` lookup and the `isinstance` checks sit before the `try`. A `dict` subclass or proxy whose `.get` raises (or a `Mapping` wrapper that passes `isinstance(doc, dict)`) would propagate the exception to the caller instead of returning False. This is only reachable with contrived inputs. Moving the whole body into the `try`, or narrowing the docstring claim, would make the code match its contract."}], "review_text": "", "fix_cost": 0.019368899999999998, "fix_ok": true, "step_changed": 20, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n\n    The function fails closed: an anonymous (None), empty or non-str user, a\n    missing or malformed \"suspended\" field, non-set collections, unhashable\n    users and incomparable `now`/`expires_at` values all result in a denial.\n    Any exception raised while evaluating the checks also results in a denial.\n    A malformed \"shared_with\" only affects the share branch, so it never\n    locks out the owner; a missing \"shared_with\" is treated as empty.\n    \"\"\"\n    try:\n        if not isinstance(user, str) or user == \"\":\n            return False\n\n        if not isinstance(doc, dict):\n            return False\n\n        # Suspension is a security-relevant deny rule, so it must be present\n        # and well-formed; otherwise deny.\n        suspended = doc.get(\"suspended\")\n        if not isinstance(suspended, (set, frozenset)):\n            return False\n\n        if user in suspended:\n            return False\n\n        owner = doc.get(\"owner\")\n        if isinstance(owner, str) and user == owner:\n            return True\n\n        shared_with = doc.get(\"shared_with\")\n        if shared_with is None:\n            return False\n        if not isinstance(shared_with, (set, frozenset)):\n            return False\n\n        if user in shared_with:\n            expires_at = doc.get(\"expires_at\")\n            return expires_at is None or bool(now < expires_at)\n    except Exception:\n        return False\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 5, "tests_passed": 9, "tests_failed": 1, "code_lines": 54, "cum_changed": 43, "review_cost": 0.0286382, "parse_ok": true, "findings": [{"title": "Missing \"expires_at\" key is treated as \"never expires\" (fails open)", "severity": "medium", "detail": "`doc.get(\"expires_at\")` returns None both when the key is absent and when it is explicitly None. A shared user therefore gets permanent access if the key is dropped by a bug, a partial record or a bad migration. The docstring promises fail-closed behaviour and already denies for a missing \"suspended\" field. Use a sentinel, e.g. `if \"expires_at\" not in doc: return False`, so only an explicit None means no expiry."}, {"title": "No type validation on `now` and `expires_at`; any mutually comparable types are accepted", "severity": "low", "detail": "The spec says `expires_at` is a number or None, but the code only relies on `now < expires_at` raising for incomparable values. Two strings, two datetimes, or bools compare without error, so a malformed `expires_at` such as \"2030\" with a string `now` can grant access based on lexicographic order. Validate that both are real numbers (`isinstance(x, (int, float))`, excluding bool) and reject NaN explicitly rather than relying on NaN comparisons returning False. Note that `now` is never inspected when `expires_at` is None, which is fine."}, {"title": "`isinstance(user, str)` accepts str subclasses with overridden `__eq__`/`__hash__`", "severity": "low", "detail": "A str subclass can customise equality and hashing, so it could defeat the suspended-set lookup or spoof the owner comparison. This only matters if user identifiers can come from untrusted code. Using `type(user) is str`, or normalising with `str(user)`, closes it."}, {"title": "Blanket `except Exception` hides errors and data corruption", "severity": "low", "detail": "Failing closed is correct, but swallowing every exception silently makes malformed documents, such as a set with a broken `__contains__` or a bad `now`, indistinguishable from legitimate denials. This includes denials of the owner. Log the exception, or narrow it to `TypeError`/`ValueError`, so real bugs and data problems surface."}], "review_text": "", "fix_cost": 0.027201200000000002, "fix_ok": true, "step_changed": 39, "code_after": "import logging\nimport math\n\nlogger = logging.getLogger(__name__)\n\n\ndef _is_real_number(value):\n    \"\"\"True for int/float (not bool) that is not NaN.\"\"\"\n    if isinstance(value, bool) or not isinstance(value, (int, float)):\n        return False\n    return not math.isnan(value)\n\n\ndef can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); an explicit None means\n      never expires\n    - everyone else is denied\n\n    The function fails closed: an anonymous (None), empty or non-str user, a\n    missing or malformed \"suspended\" field, non-set collections, unhashable\n    users, a missing \"expires_at\" key, and non-numeric (including bool or\n    NaN) `now`/`expires_at` values all result in a denial.\n    Any exception raised while evaluating the checks also results in a denial,\n    and is logged so that data problems and bugs remain visible.\n    A malformed \"shared_with\" only affects the share branch, so it never\n    locks out the owner; a missing \"shared_with\" is treated as empty.\n    \"\"\"\n    try:\n        # Exact type check: str subclasses could override __eq__/__hash__.\n        if type(user) is not str or user == \"\":\n            return False\n\n        if not isinstance(doc, dict):\n            return False\n\n        # Suspension is a security-relevant deny rule, so it must be present\n        # and well-formed; otherwise deny.\n        suspended = doc.get(\"suspended\")\n        if not isinstance(suspended, (set, frozenset)):\n            return False\n\n        if user in suspended:\n            return False\n\n        owner = doc.get(\"owner\")\n        if type(owner) is str and user == owner:\n            return True\n\n        shared_with = doc.get(\"shared_with\")\n        if shared_with is None:\n            return False\n        if not isinstance(shared_with, (set, frozenset)):\n            return False\n\n        if user in shared_with:\n            # A missing key must not be confused with an explicit None.\n            if \"expires_at\" not in doc:\n                return False\n            expires_at = doc[\"expires_at\"]\n            if expires_at is None:\n                return True\n            if not _is_real_number(expires_at) or not _is_real_number(now):\n                return False\n            return now < expires_at\n    except Exception:\n        logger.exception(\"can_view failed; denying access\")\n        return False\n    return False\n"}
{"module": "access", "model": "sonnet", "prompt": "open", "rep": 2, "round": 6, "tests_passed": 9, "tests_failed": 1, "code_lines": 79, "cum_changed": 70, "review_cost": 0.0279804, "parse_ok": true, "findings": [{"title": "Huge int values make math.isnan raise OverflowError, so valid shares are denied", "severity": "low", "detail": "_is_real_number calls math.isnan(value) on any int. For ints too large for a float (e.g. expires_at = 10**400, a plausible 'effectively never' sentinel), math.isnan raises OverflowError. can_view catches it and denies a legitimate shared user, and logs it as a failure. The int/int comparison `now < expires_at` would have worked. Fix: check NaN only for floats, e.g. `isinstance(value, float) and math.isnan(value)`."}, {"title": "Silent denials for malformed data contradict the 'data problems remain visible' goal", "severity": "low", "detail": "Only exceptions are logged. Malformed or missing 'suspended', a non-set 'shared_with', a missing 'expires_at', or a non-numeric/NaN timestamp all return False with no log entry. These are data-integrity problems that lock out users (including the owner when 'suspended' is malformed), and they are invisible to operators. Consider logging a warning on each fail-closed branch other than the plain 'not authorized' outcomes."}, {"title": "Infinite timestamps are accepted as valid numbers", "severity": "low", "detail": "_is_real_number rejects NaN but allows +/-inf. expires_at=float('inf') acts as never-expires and expires_at=-inf as always-expired, while now=inf denies every share. This is probably unintended and is not covered by the docstring. Use math.isfinite if infinities should be treated as malformed."}, {"title": "Exact-type and container checks can be bypassed or fooled by subclasses", "severity": "low", "detail": "The user and owner are checked with `type(...) is str` to avoid __eq__/__hash__ overrides. The doc and its collections only use isinstance, so a set/frozenset/dict subclass with overridden __contains__ or get can still alter the result. If the docs come from untrusted sources, use exact type checks there too. For trusted data this is a minor inconsistency."}], "review_text": "", "stopped": "round_cap"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 2, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.024678000000000002, "parse_ok": true, "findings": [{"title": "Incorrect expiration boundary condition", "severity": "high", "detail": "The docstring specifies 'now is strictly before expires_at' (meaning access ends AT expires_at), but the code uses `now <= expires_at`. This grants access at the exact moment of expiration when it should be denied. The condition should be `now < expires_at` instead."}, {"title": "Inconsistent error handling for missing keys", "severity": "medium", "detail": "The function uses `.get('suspended', set())` with a default for one key, but directly accesses 'owner', 'shared_with', and 'expires_at' without defaults. This will raise KeyError if those required keys are missing, while gracefully handling missing 'suspended'. Either all keys should be accessed defensively with defaults, or the docstring should explicitly state that 'suspended' is the only optional key."}], "review_text": "", "fix_cost": 0.0414694, "fix_ok": true, "step_changed": 8, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    if user in doc.get(\"shared_with\", set()):\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 2, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 8, "review_cost": 0.05648740000000001, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 0, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0227875, "parse_ok": true, "findings": [{"title": "Expiry check uses incorrect comparison operator", "severity": "high", "detail": "Line: `return expires_at is None or now <= expires_at`. The docstring explicitly states 'now is strictly before expires_at' and 'access ends exactly AT expires_at', meaning the user should NOT be able to view when now == expires_at. The condition should use `now < expires_at` instead of `now <= expires_at`."}], "review_text": "", "fix_cost": 0.0167271, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 0, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.05538190000000001, "parse_ok": true, "findings": [{"title": "Inconsistent handling of potentially missing dictionary keys", "severity": "medium", "detail": "The code defensively handles a potentially missing 'suspended' key using `doc.get(\"suspended\", set())`, but directly accesses `doc[\"owner\"]` and `doc[\"shared_with\"]` without defaults. This inconsistency suggests either (a) these keys are sometimes missing and the code will crash with KeyError, or (b) they are always required but the design is unclear. The docstring lists these keys but doesn't explicitly state which are required. If 'shared_with' or 'owner' can be missing in practice, this code will fail. For consistency and robustness, either all keys should use `.get()` with appropriate defaults, or the docstring should explicitly state the keys are required."}], "review_text": "", "fix_cost": 0.0243579, "fix_ok": true, "step_changed": 6, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    if user in doc.get(\"shared_with\", set()):\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 0, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 8, "review_cost": 0.0328914, "parse_ok": true, "findings": [], "review_text": "", "stopped": "no_findings"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 0, "review_cost": 0.07933409999999999, "parse_ok": true, "findings": [{"title": "bool values accepted as limit/window_seconds due to isinstance check", "severity": "low", "detail": "In Python, bool is a subclass of int, so isinstance(True, int) returns True. This means SlidingWindowLimiter(True, 10) or SlidingWindowLimiter(1, True) would be accepted. While True==1 makes the behavior predictable, the docstring states the parameters 'must be positive ints', implying the int type specifically. Consider using type(limit) is int instead of isinstance to reject boolean values."}], "review_text": "", "fix_cost": 0.02384, "fix_ok": true, "step_changed": 4, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if type(limit) is not int or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if type(window_seconds) is not int or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 4, "review_cost": 0.0602855, "parse_ok": true, "findings": [{"title": "`_last_seen` dictionary accumulates keys indefinitely", "severity": "medium", "detail": "The docstring requires that 'timestamps that can no longer matter must not accumulate forever', but the `_last_seen` dictionary grows without bound. While hits in deques are properly cleaned up after exiting the window, metadata for all keys ever seen is retained indefinitely. In systems with many transient keys (e.g., rate-limiting by user ID in a large service), this causes unbounded memory growth with no way to evict inactive keys. The implementation never removes entries from `_last_seen`, even after keys become inactive for extended periods."}, {"title": "Type checking uses `type()` instead of `isinstance()`", "severity": "low", "detail": "The code checks `type(limit) is not int` rather than `isinstance(limit, int)`. While this correctly rejects bools (since `type(True) is bool`, not `int`), it also rejects valid integer subclasses like `numpy.int64` or other custom int types that users might reasonably pass. This is overly strict and less Pythonic. Using `isinstance(limit, int) and not isinstance(limit, bool)` would be more flexible while still rejecting bools."}], "review_text": "", "fix_cost": 0.0278583, "fix_ok": true, "step_changed": 7, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if not hits:\n            del self._last_seen[key]\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 14, "tests_failed": 1, "code_lines": 46, "cum_changed": 7, "review_cost": 0.09116419999999999, "parse_ok": true, "findings": [{"title": "Deletion of _last_seen[key] breaks monotonicity checking", "severity": "high", "detail": "The line `if not hits: del self._last_seen[key]` deletes the tracked timestamp for a key immediately after it's set (at the beginning of `allow()`). This breaks the time-backwards validation: if all hits expire and the key is deleted from _last_seen, a subsequent request with an earlier timestamp won't trigger the required ValueError. For example: request at t=15 expires all hits and deletes _last_seen[key], then a request at t=14 arrives and is silently accepted instead of raising 'time went backwards'."}], "review_text": "", "fix_cost": 0.0300175, "fix_ok": true, "step_changed": 3, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 15, "tests_failed": 0, "code_lines": 43, "cum_changed": 4, "review_cost": 0.06009530000000001, "parse_ok": true, "findings": [{"title": "Memory accumulation of stale timestamps", "severity": "medium", "detail": "The docstring states 'timestamps that can no longer matter must not accumulate forever'. However, if a key is accessed and then never accessed again, old timestamps outside the current window remain in the deque indefinitely. The cleanup of old timestamps only happens on subsequent access to that key. This violates the stated requirement, though the memory impact per key is bounded by `limit`. In scenarios with many unique keys accessed once and never again, this could cause memory leaks."}], "review_text": "", "fix_cost": 0.04477899999999999, "fix_ok": true, "step_changed": 11, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n        self._global_max_now = None\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        if self._global_max_now is None or now > self._global_max_now:\n            self._cleanup_all(now)\n            self._global_max_now = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n\n    def _cleanup_all(self, now):\n        cutoff = now - self.window\n        for hits in self._hits.values():\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 4, "tests_passed": 15, "tests_failed": 0, "code_lines": 54, "cum_changed": 15, "review_cost": 0.04683380000000001, "parse_ok": true, "findings": [{"title": "Memory leak in `_last_seen` dictionary", "severity": "high", "detail": "The `_last_seen` dictionary accumulates entries forever and is never cleaned up. Every unique key ever seen persists in this dict indefinitely, even after the key's request deque is empty and all timestamps have aged past the window. This violates the stated requirement that 'timestamps that can no longer matter must not accumulate forever.' Over time, this causes unbounded memory growth proportional to the number of unique keys ever encountered."}], "review_text": "", "fix_cost": 0.0294118, "fix_ok": true, "step_changed": 9, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n        self._global_max_now = None\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n        self._last_seen[key] = now\n\n        if self._global_max_now is None or now > self._global_max_now:\n            self._cleanup_all(now)\n            self._global_max_now = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        return True\n\n    def _cleanup_all(self, now):\n        cutoff = now - self.window\n        keys_to_delete = []\n        for key, hits in self._hits.items():\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n            if not hits:\n                keys_to_delete.append(key)\n        \n        for key in keys_to_delete:\n            del self._hits[key]\n            del self._last_seen[key]\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 5, "tests_passed": 15, "tests_failed": 0, "code_lines": 61, "cum_changed": 22, "review_cost": 0.08265189999999999, "parse_ok": true, "findings": [{"title": "Unbounded memory accumulation in _last_seen", "severity": "high", "detail": "The `_last_seen` dictionary entries can persist indefinitely for keys that are accessed once and never accessed again. The `_cleanup_all()` method that removes entries from `_last_seen` is only called when a request time is strictly greater than the previous global maximum (`self._global_max_now`). In scenarios where all requests occur at the same time or no request ever exceeds the initial maximum time, inactive key entries remain in memory forever. This violates the stated requirement: 'timestamps that can no longer matter must not accumulate forever'. Example: after `allow('a', 100)`, `allow('b', 100)`, `allow('c', 100)`, if no future calls are made with times > 100, all three keys persist in `_last_seen` indefinitely."}, {"title": "Potential empty deque accumulation in _hits", "severity": "medium", "detail": "When the rate limit is reached and `allow()` returns False without appending a hit, `self._hits[key]` is accessed and creates an empty deque via the defaultdict. If a key is never accessed again before `_cleanup_all()` runs, these empty deques persist as unnecessary entries in `_hits`. While the memory impact per empty deque is small, the quantity can grow unbounded under the same conditions that cause `_last_seen` accumulation."}], "review_text": "", "fix_cost": 0.0801057, "fix_ok": true, "step_changed": 11, "code_after": "from collections import defaultdict, deque\n\n\nclass SlidingWindowLimiter:\n    \"\"\"Allow at most `limit` requests per key in any window of `window_seconds`.\n\n    Rules:\n    - limit and window_seconds must be positive ints, otherwise ValueError\n    - a request at time `now` is counted against the half-open window\n      (now - window_seconds, now]; a request made exactly window_seconds ago\n      no longer counts\n    - allow(key, now) returns True and records the request if fewer than `limit`\n      requests are in the window, otherwise returns False and records nothing\n    - keys are independent\n    - `now` must not be earlier than the latest `now` already seen for that key,\n      otherwise ValueError\n    - timestamps that can no longer matter must not accumulate forever\n    \"\"\"\n\n    def __init__(self, limit, window_seconds):\n        if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:\n            raise ValueError(\"limit must be a positive int\")\n        if not isinstance(window_seconds, int) or isinstance(window_seconds, bool) or window_seconds <= 0:\n            raise ValueError(\"window_seconds must be a positive int\")\n        self.limit = limit\n        self.window = window_seconds\n        self._hits = defaultdict(deque)\n        self._last_seen = {}\n        self._global_max_now = None\n\n    def allow(self, key, now):\n        last = self._last_seen.get(key)\n        if last is not None and now < last:\n            raise ValueError(\"time went backwards\")\n\n        if self._global_max_now is None or now > self._global_max_now:\n            self._cleanup_all(now)\n            self._global_max_now = now\n\n        hits = self._hits[key]\n        while hits and hits[0] <= now - self.window:\n            hits.popleft()\n\n        if not hits:\n            del self._hits[key]\n            if key in self._last_seen:\n                del self._last_seen[key]\n            hits = self._hits[key]\n\n        if len(hits) >= self.limit:\n            return False\n        hits.append(now)\n        self._last_seen[key] = now\n        return True\n\n    def _cleanup_all(self, now):\n        cutoff = now - self.window\n        keys_to_delete = []\n        for key, hits in self._hits.items():\n            while hits and hits[0] <= cutoff:\n                hits.popleft()\n            if not hits:\n                keys_to_delete.append(key)\n        \n        for key in keys_to_delete:\n            del self._hits[key]\n            if key in self._last_seen:\n                del self._last_seen[key]\n"}
{"module": "rate_limiter", "model": "haiku", "prompt": "open", "rep": 1, "round": 6, "tests_passed": 15, "tests_failed": 0, "code_lines": 68, "cum_changed": 31, "review_cost": 0.0619759, "parse_ok": true, "findings": [{"title": "Time-went-backwards constraint violation", "severity": "high", "detail": "The code deletes entries from `_last_seen` when all hits for a key are cleaned up (in both `allow()` and `_cleanup_all()`). This allows subsequent calls with earlier timestamps to bypass the time-went-backwards check. For example: (1) allow(K, 100) records K at time 100, (2) global cleanup at time 200+ removes K from _last_seen, (3) allow(K, 50) succeeds with time 50 < 100, violating the documented constraint that \"now must not be earlier than the latest now already seen for that key, otherwise ValueError\". Fix: Never delete from `_last_seen`, or track maximum-seen-time separately from current hits."}, {"title": "Confusing and unnecessary cleanup pattern", "severity": "medium", "detail": "The code deletes `self._hits[key]` then immediately accesses `self._hits[key]` to create a new empty deque via `defaultdict` behavior (lines: `del self._hits[key]` followed by `hits = self._hits[key]`). This is non-obvious—the deletion's purpose is unclear, and relying on `defaultdict` side effects makes the code harder to maintain and more prone to bugs if modified. Consider explicitly creating a new deque or reconsidering why deletion is needed."}], "review_text": "", "stopped": "round_cap"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 0, "tests_passed": 9, "tests_failed": 1, "code_lines": 23, "cum_changed": 0, "review_cost": 0.0365625, "parse_ok": true, "findings": [{"title": "Off-by-one error in expires_at comparison", "severity": "high", "detail": "The docstring explicitly states 'a user in shared_with can view only while now is strictly before expires_at (access ends exactly AT expires_at)'. This means access should be denied when now == expires_at. However, the code uses `now <= expires_at`, which allows access at the exact moment expiration occurs. The condition should be `now < expires_at` instead."}], "review_text": "", "fix_cost": 0.0166456, "fix_ok": true, "step_changed": 2, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc[\"owner\"]:\n        return True\n    if user in doc[\"shared_with\"]:\n        expires_at = doc[\"expires_at\"]\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 1, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 2, "review_cost": 0.04131340000000001, "parse_ok": true, "findings": [{"title": "Inconsistent defensive programming for dict keys", "severity": "high", "detail": "The function uses `doc.get(\"suspended\", set())` with a safe default, but directly accesses `doc[\"owner\"]`, `doc[\"shared_with\"]`, and `doc[\"expires_at\"]` without checking if they exist. If any of these keys are missing, the function will crash with KeyError. Either handle all keys consistently with `.get()` with appropriate defaults, or document clearly that these keys are guaranteed to be present and explain why \"suspended\" is treated differently."}], "review_text": "", "fix_cost": 0.0206489, "fix_ok": true, "step_changed": 6, "code_after": "def can_view(doc, user, now):\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if user in doc.get(\"suspended\", set()):\n        return False\n    if user == doc.get(\"owner\"):\n        return True\n    if user in doc.get(\"shared_with\", set()):\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 2, "tests_passed": 10, "tests_failed": 0, "code_lines": 23, "cum_changed": 8, "review_cost": 0.04603340000000001, "parse_ok": true, "findings": [{"title": "No input validation for collection types", "severity": "medium", "detail": "The function assumes 'suspended' and 'shared_with' are sets but doesn't validate them. If a caller passes non-set values (e.g., a string), the `in` operator may use substring matching instead of membership testing. For example, if suspended='abc' and user='ab', then `user in 'abc'` evaluates to True (substring match), incorrectly denying access. This is especially concerning for security-sensitive access control logic. Consider validating: `isinstance(doc.get('suspended'), set)` and `isinstance(doc.get('shared_with'), set)`."}, {"title": "Missing type hints", "severity": "medium", "detail": "The function lacks type annotations for parameters and return type. This reduces code clarity and prevents static type checking tools from catching usage errors. Consider adding: `def can_view(doc: dict[str, Any], user: str, now: float) -> bool:`"}], "review_text": "", "fix_cost": 0.0193088, "fix_ok": true, "step_changed": 19, "code_after": "from typing import Any\n\ndef can_view(doc: dict[str, Any], user: str, now: float) -> bool:\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    suspended = doc.get(\"suspended\", set())\n    if not isinstance(suspended, set):\n        raise TypeError(f\"'suspended' must be a set, got {type(suspended).__name__}\")\n    \n    if user in suspended:\n        return False\n    \n    if user == doc.get(\"owner\"):\n        return True\n    \n    shared_with = doc.get(\"shared_with\", set())\n    if not isinstance(shared_with, set):\n        raise TypeError(f\"'shared_with' must be a set, got {type(shared_with).__name__}\")\n    \n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        return expires_at is None or now < expires_at\n    \n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 3, "tests_passed": 10, "tests_failed": 0, "code_lines": 36, "cum_changed": 25, "review_cost": 0.0547274, "parse_ok": true, "findings": [{"title": "Missing type validation for `expires_at` field", "severity": "high", "detail": "The docstring specifies `expires_at` as 'number or None', but the code doesn't validate the type. If `expires_at` is a non-numeric, non-None value (e.g., a string or list), the comparison `now < expires_at` on line 27 will raise a TypeError at runtime. Add type validation before use, like the isinstance checks for `suspended` and `shared_with`."}, {"title": "Inconsistent type validation pattern", "severity": "medium", "detail": "The code validates `suspended` and `shared_with` as sets using isinstance checks, but doesn't validate `owner` (documented as str) or `expires_at` (documented as number or None), creating an incomplete defensive programming pattern. The missing `expires_at` validation is especially problematic since it can cause runtime crashes."}], "review_text": "", "fix_cost": 0.031393700000000004, "fix_ok": true, "step_changed": 8, "code_after": "from typing import Any\n\ndef can_view(doc: dict[str, Any], user: str, now: float) -> bool:\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    suspended = doc.get(\"suspended\", set())\n    if not isinstance(suspended, set):\n        raise TypeError(f\"'suspended' must be a set, got {type(suspended).__name__}\")\n    \n    if user in suspended:\n        return False\n    \n    owner = doc.get(\"owner\")\n    if owner is not None and not isinstance(owner, str):\n        raise TypeError(f\"'owner' must be a str, got {type(owner).__name__}\")\n    \n    if user == owner:\n        return True\n    \n    shared_with = doc.get(\"shared_with\", set())\n    if not isinstance(shared_with, set):\n        raise TypeError(f\"'shared_with' must be a set, got {type(shared_with).__name__}\")\n    \n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        if expires_at is not None and not isinstance(expires_at, (int, float)):\n            raise TypeError(f\"'expires_at' must be a number or None, got {type(expires_at).__name__}\")\n        return expires_at is None or now < expires_at\n    \n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 4, "tests_passed": 10, "tests_failed": 0, "code_lines": 42, "cum_changed": 31, "review_cost": 0.05924460000000001, "parse_ok": true, "findings": [{"title": "Owner field type documentation mismatch", "severity": "medium", "detail": "The docstring declares owner as \"str\" but the code permits owner to be None by using doc.get(\"owner\") without validating presence. The docstring should clarify that owner is optional and can be None, or the code should validate owner is always a string."}, {"title": "User parameter not validated", "severity": "medium", "detail": "The function validates all dict fields (suspended, shared_with, expires_at, owner) but does not validate the user parameter, despite the type hint indicating it should be a string. This is inconsistent. If user is not hashable, the set membership checks will fail with a confusing error. Either validate user upfront or document that callers must ensure it is hashable."}], "review_text": "", "fix_cost": 0.0231141, "fix_ok": true, "step_changed": 5, "code_after": "from typing import Any\n\ndef can_view(doc: dict[str, Any], user: str, now: float) -> bool:\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str or None\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if not isinstance(user, str):\n        raise TypeError(f\"'user' must be a str, got {type(user).__name__}\")\n    \n    suspended = doc.get(\"suspended\", set())\n    if not isinstance(suspended, set):\n        raise TypeError(f\"'suspended' must be a set, got {type(suspended).__name__}\")\n    \n    if user in suspended:\n        return False\n    \n    owner = doc.get(\"owner\")\n    if owner is not None and not isinstance(owner, str):\n        raise TypeError(f\"'owner' must be a str, got {type(owner).__name__}\")\n    \n    if user == owner:\n        return True\n    \n    shared_with = doc.get(\"shared_with\", set())\n    if not isinstance(shared_with, set):\n        raise TypeError(f\"'shared_with' must be a set, got {type(shared_with).__name__}\")\n    \n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        if expires_at is not None and not isinstance(expires_at, (int, float)):\n            raise TypeError(f\"'expires_at' must be a number or None, got {type(expires_at).__name__}\")\n        return expires_at is None or now < expires_at\n    \n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 5, "tests_passed": 10, "tests_failed": 0, "code_lines": 45, "cum_changed": 36, "review_cost": 0.045536299999999995, "parse_ok": true, "findings": [{"title": "Missing validation of `now` parameter type", "severity": "medium", "detail": "The function validates all document fields extensively (suspended, owner, shared_with, expires_at) but doesn't validate that `now` is actually a number. If `now` is passed as a non-numeric value (e.g., a string) and a user in shared_with tries to access an expiring document, the comparison `now < expires_at` will raise a confusing TypeError from Python's comparison operator rather than a clear type validation error. This is inconsistent with the function's defensive programming approach and could result in obscure error messages."}, {"title": "Missing validation of `doc` parameter type", "severity": "low", "detail": "The function assumes `doc` is a dict and calls `.get()` on it without validation, though it does validate the contents of the dict. This is inconsistent—if validating dict contents, the function should also validate that `doc` is actually a dict. While the type hint is clear and the resulting error would be an AttributeError (less confusing), this inconsistency reduces robustness."}], "review_text": "", "fix_cost": 0.024459099999999998, "fix_ok": true, "step_changed": 6, "code_after": "from typing import Any\n\ndef can_view(doc: dict[str, Any], user: str, now: float) -> bool:\n    \"\"\"Decide whether `user` may view `doc` at time `now`.\n\n    doc: dict with keys\n      \"owner\": str or None\n      \"shared_with\": set of str\n      \"expires_at\": number or None (None = never expires)\n      \"suspended\": set of str (users who are denied everything)\n    Rules:\n    - a suspended user is always denied, even the owner\n    - the owner can always view, even after expires_at\n    - a user in shared_with can view only while now is strictly before\n      expires_at (access ends exactly AT expires_at); None means never expires\n    - everyone else is denied\n    \"\"\"\n    if not isinstance(doc, dict):\n        raise TypeError(f\"'doc' must be a dict, got {type(doc).__name__}\")\n    \n    if not isinstance(user, str):\n        raise TypeError(f\"'user' must be a str, got {type(user).__name__}\")\n    \n    if not isinstance(now, (int, float)):\n        raise TypeError(f\"'now' must be a number, got {type(now).__name__}\")\n    \n    suspended = doc.get(\"suspended\", set())\n    if not isinstance(suspended, set):\n        raise TypeError(f\"'suspended' must be a set, got {type(suspended).__name__}\")\n    \n    if user in suspended:\n        return False\n    \n    owner = doc.get(\"owner\")\n    if owner is not None and not isinstance(owner, str):\n        raise TypeError(f\"'owner' must be a str, got {type(owner).__name__}\")\n    \n    if user == owner:\n        return True\n    \n    shared_with = doc.get(\"shared_with\", set())\n    if not isinstance(shared_with, set):\n        raise TypeError(f\"'shared_with' must be a set, got {type(shared_with).__name__}\")\n    \n    if user in shared_with:\n        expires_at = doc.get(\"expires_at\")\n        if expires_at is not None and not isinstance(expires_at, (int, float)):\n            raise TypeError(f\"'expires_at' must be a number or None, got {type(expires_at).__name__}\")\n        return expires_at is None or now < expires_at\n    \n    return False\n"}
{"module": "access", "model": "haiku", "prompt": "open", "rep": 1, "round": 6, "tests_passed": 10, "tests_failed": 0, "code_lines": 51, "cum_changed": 42, "review_cost": 0.054214700000000005, "parse_ok": true, "findings": [{"title": "Incomplete validation of set contents", "severity": "medium", "detail": "The function validates that 'suspended' and 'shared_with' are sets but does not validate that they contain only strings as specified in the docstring. If these sets contain non-string values (e.g., integers), membership checks will silently fail, potentially bypassing security rules. Example: a suspended user '1' (string) would not match integer 1 in the suspended set, allowing them access when they should be denied."}], "review_text": "", "stopped": "round_cap"}
