two defects, one session

axium[deepseek-v4-pro + deepseek-v4-pro, supercharge]

discount still applies above the threshold

Fixed. The bug was in `shop/pricing.py`:

```python
if quantity > BULK_THRESHOLD:   # before — 10 was excluded
if quantity >= BULK_THRESHOLD:  # after — 10 is included
```

With the old `> 10` check, exactly 10 units fell through to the full-price return, while 11+ got the discount. Changed it to `>=` so the bulk discount applies at 10 units as intended.

Verification:
- `pricing.apply_discount(100.0, 10)` now returns `90.0`
- `pricing.apply_discount(100.0, 11)` still returns `90.0`
- All 16 acceptance checks pass.

Fixed. The tax bug was in `shop/pricing.py`:

```python
return int(amount * rate * 100) / 100   # before — truncates toward zero
return round(amount * rate, 2)          # after — rounds to the nearest cent
```

`int()` floors/truncates the fractional cents, so e.g. `9.99 * 0.24 = 2.3976` was becoming `2.39` instead of `2.40`. Now it rounds properly.

Verification:
- `compute_tax(9.99, "GR")` → `2.4` (was `2.39`)
- `compute_tax(100.0, "GR")` → `24.0`
- `compute_tax(100.0, "DE")` → `19.0`, `compute_tax(100.0, "US")` → `0.0`
- `apply_discount(100.0, 10)` and `apply_discount(100.0, 11)` both still return `90.0` — discount fix intact
- All 16 acceptance checks pass.


answer without touching

The function that computes the final amount a customer pays is **`order_total`** in **`shop/orders.py`**.

It calls, in order:

1. **`subtotal(order)`** — `shop/orders.py` (sums the order lines)
   - which in turn reads the `OrderLine.subtotal` property defined in **`shop/models.py`**.
2. **`pricing.apply_discount(goods, order.item_count())`** — `shop/pricing.py`
   - `order.item_count()` is a method on the `Order` dataclass in **`shop/models.py`**.
3. **`pricing.compute_tax(discounted, order.country)`** — `shop/pricing.py`
4. **`shipping_cost(order)`** — `shop/orders.py` (itself calls `subtotal(order)` again).

The result is the discounted goods total plus tax plus shipping, rounded to two decimals:

```python
def order_total(order):
    goods = subtotal(order)
    discounted = pricing.apply_discount(goods, order.item_count())
    tax = pricing.compute_tax(discounted, order.country)
    return round(discounted + tax + shipping_cost(order), 2)
```

Files that import or reference `shop/pricing.py` (and therefore can break if `compute_tax`'s signature changes):

**Direct importers of the `pricing` module:**
- `shop/orders.py` — `from shop import pricing` (line 2)
- `tests/acceptance.py` — `from shop import orders, pricing, report, storage` (line 10)

**Calls to `compute_tax` specifically:**
- `shop/orders.py:19` — `pricing.compute_tax(discounted, order.country)`
- `tests/acceptance.py:49` — `pricing.compute_tax(100.0, "US")`

**Non-code reference (won't break at runtime, but mentions the module):**
- `README.md:9` — describes `shop/pricing.py` in the project layout.

So the files that would actually break are **`shop/orders.py`** and **`tests/acceptance.py`**. (The definition itself lives in `shop/pricing.py`.) `README.md` references the module only in prose and would not cause a runtime/import error.

The one function most likely to silently corrupt data is **`Inventory.reserve`** in **`shop/inventory.py`**.

It subtracts `quantity` from stock unconditionally (`p.stock -= quantity`) with no check that enough stock exists, so an order can drive inventory negative without ever raising `OutOfStock` — silently corrupting the stock counts.


a constraint that must survive six turns

persisted the constraint durably (.axium/knowledge.md)

shipping still switches at a single boundary

Saved. The free-shipping threshold of **75 euro, never lower** is now recorded in two durable places:

1. **Persistent memory** — under "Project Rules", so it survives across sessions.
2. **Project knowledge** — in `.axium/knowledge.md` under "Business Rules", so it lives with the project files.

No code was changed. I'm ready to start on shop-v3-axium-0-030030245206 whenever you are.

Here's the full picture.

## How the reports get their numbers

### 1. Data source: `shop/storage.py` → `data/catalogue.json`
The numbers ultimately come from `data/catalogue.json`, which holds four products:

| SKU | Name | unit_price | stock | category |
|-----|------|-----------|-------|----------|
| SKU-1 | Widget | 9.99 | 40 | tools |
| SKU-2 | Gadget | 24.5 | 12 | tools |
| SKU-3 | Doohickey | 3.25 | 3 | parts |
| SKU-4 | Thingamajig | 15.0 | 0 | parts |

`storage.load(path)` opens the JSON, and for each row constructs a `Product(**row)` dataclass (`models.py`). So each `Product` carries `sku`, `name`, `unit_price`, `stock`, and `category`.

### 2. Wrapping into an inventory: `shop/inventory.py`
`Inventory.__init__` builds a dict: `{product.sku: product}`. So `inventory.products` is keyed by SKU:

```python
{"SKU-1": Product(...), "SKU-2": ..., "SKU-3": ..., "SKU-4": ...}
```

That's the object both report functions receive. Note `Inventory.reserve` and `restock` mutate `p.stock` in place, so the reports always reflect whatever the current stock value is on the live `Product` objects — they read `p.stock` directly, they don't recompute anything.

### 3. `report.stock_report(inventory)`
```python
for sku, p in sorted(inventory.products.items()):
    name = p.name[:24].ljust(24)
    qty = str(p.stock).rjust(6)
    lines.append(f"| {name} | {qty} |")
```
- `sorted(inventory.products.items())` sorts the `(sku, product)` pairs by **SKU string** (SKU-1, SKU-2, SKU-3, SKU-4) — not by name or stock.
- `p.name[:24]` truncates names to 24 characters, then `.ljust(24)` pads with spaces so the column lines up.
- `qty = str(p.stock).rjust(6)` is just the raw `stock` integer, right-aligned to width 6.
- It formats every product — **no filtering**, so every SKU appears.

So "the number" in each row is literally `Product.stock` from the catalogue (or whatever it has been mutated to by reserve/restock at runtime).

### 4. `report.low_stock_report(inventory, threshold=5)`
Same formatting loop, but with a filter first:
```python
if p.stock > threshold:
    continue
```
This is an "**at or below**" test: a product is included when `stock <= threshold`. With the default `threshold=5`, that means stock of 5 or less is "low". Stock 6+ is skipped.

### Side note (not part of these two reports)
`top_products` sorts `sales.items()` ascending and takes the first `limit` — so despite its docstring saying "best-selling first", it actually returns the *lowest* values first. I'm flagging it only because it's in the same file; I made no changes.

## The reports, run against the current catalogue

**Stock report** (everything, sorted by SKU):
```
| Widget                   |     40 |
| Gadget                   |     12 |
| Doohickey                |      3 |
| Thingamajig              |      0 |
```

**Low-stock report** (threshold = 5, i.e. stock ≤ 5):
```
| Doohickey                |      3 |
| Thingamajig              |      0 |
```

## Products appearing in both
Because the stock report lists **all** products and the low-stock report is a subset of that same list, the products in both are exactly the low-stock ones:

- **Doohickey** (SKU-3, stock 3)
- **Thingamajig** (SKU-4, stock 0)

No files were changed.

Here's a detailed walkthrough of both files, then the fresh-install behavior.

## `shop/storage.py`

```python
"""JSON persistence."""
import json

from shop.models import Product
```

This module is the persistence layer: it converts between `Product` dataclass objects and JSON on disk.

### `save(path, products)`
```python
def save(path, products):
    data = [{"sku": p.sku, "name": p.name, "unit_price": p.unit_price,
             "stock": p.stock, "category": p.category} for p in products]
    with open(path, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    return len(data)
```
1. Takes a list of `Product` objects and builds a list of plain dicts, one per product, with keys `sku`, `name`, `unit_price`, `stock`, `category`.
2. Opens `path` in write mode (`"w"`) — this creates the file if it doesn't exist, or truncates/overwrites it if it does. Note: **it does not create parent directories**; if `data/` doesn't exist, this raises `FileNotFoundError`.
3. `json.dump(..., indent=2, ensure_ascii=False)` writes human-readable, 2-space-indented JSON, allowing non-ASCII characters (e.g. accented names) to stay readable rather than being escaped.
4. Returns `len(data)` — the number of products written.

### `load(path)`
```python
def load(path):
    with open(path, encoding="utf-8") as f:
        raw = json.load(f)
    return [Product(**row) for row in raw]
```
1. Opens `path` for reading with UTF-8 encoding.
2. `json.load(f)` parses the JSON into Python — here, a list of dicts.
3. For each dict, `Product(**row)` uses keyword-argument unpacking to construct a `Product`. This means the JSON keys must exactly match the `Product` field names (`sku`, `name`, `unit_price`, `stock`, `category`). `category` is optional on the dataclass (default `"general"`), so rows without a `category` would still load; the other four keys are required.
4. Returns a list of `Product` objects.

There is **no try/except, no existence check, and no fallback** in either function.

## `shop/cli.py`

```python
"""Command-line entry point."""
import sys

from shop import orders, report, storage
from shop.inventory import Inventory
from shop.models import Order, OrderLine
```

This is the entry point tying everything together.

### `build_demo_order(inv)`
```python
def build_demo_order(inv):
    return Order(order_id="D-1", customer="demo", country="GR", lines=[
        OrderLine("SKU-1", 2, inv.get("SKU-1").unit_price),
        OrderLine("SKU-2", 1, inv.get("SKU-2").unit_price),
    ])
```
1. Builds a hard-coded demo `Order` with id `D-1`, customer `demo`, country `GR`.
2. It has two `OrderLine`s: **2 × SKU-1** and **1 × SKU-2**.
3. For each line's `unit_price`, it calls `inv.get(sku).unit_price` — i.e. it looks up the current price from the inventory rather than hard-coding it.
4. It does **not** reserve stock; it just reads prices. So calling it doesn't change inventory.

Note: it assumes `SKU-1` and `SKU-2` exist. If either is missing, `inv.get(...)` returns `None` and the `.unit_price` access raises `AttributeError`.

### `main(argv=None)`
```python
def main(argv=None):
    argv = argv or sys.argv[1:]
    inv = Inventory(storage.load("data/catalogue.json"))
    if argv and argv[0] == "stock":
        print(report.stock_report(inv))
        return 0
    order = build_demo_order(inv)
    print(f"total: {orders.order_total(order):.2f}")
    return 0
```
1. Uses the provided `argv` list, or defaults to `sys.argv[1:]` (command-line args after the script name).
2. **Immediately** loads the catalogue: `Inventory(storage.load("data/catalogue.json"))`. This happens unconditionally, before checking any command.
3. If the first argument is `"stock"`, it prints the stock report (all products) and returns exit code `0`.
4. Otherwise, it builds the demo order, prints `total: <order_total formatted to 2 decimals>`, and returns `0`.

### `__main__` block
```python
if __name__ == "__main__":
    raise SystemExit(main())
```
Running `python -m shop.cli`

Here's the one-line summary of every module, followed by the coupling analysis. (I also read `pricing.py` and `orders.py` to make this accurate.)

## One-line summaries

| Module | What it does |
|--------|-------------|
| `shop/__init__.py` | Package marker that exposes `__version__`. |
| `shop/models.py` | Core dataclasses: `Product`, `OrderLine`, `Order` (and order `item_count`). |
| `shop/pricing.py` | Discounts, VAT, and per-line totals (`apply_discount`, `compute_tax`, `line_total`). |
| `shop/inventory.py` | Stock tracking: `Inventory` with `get`/`available`/`reserve`/`restock`, plus `OutOfStock`. |
| `shop/orders.py` | Order arithmetic: `subtotal`, `shipping_cost`, `order_total`. |
| `shop/report.py` | Text reports: `stock_report`, `category_report`, `low_stock_report`, `top_products`. |
| `shop/storage.py` | JSON persistence: `save`/`load` of the product catalogue to/from disk. |
| `shop/cli.py` | Entry point: `build_demo_order` and `main` (demo order total or `stock` command). |
| `tests/acceptance.py` | Regression suite that asserts baseline behaviors survive changes. |
| `data/catalogue.json` | Seed data: the four-product catalogue the CLI loads. |
| `README.md` | Usage docs (commands and layout). |

## Most tightly coupled pair

**`shop/report.py` and `shop/inventory.py`** are the most tightly coupled.

The reason: `report.py` reaches directly into `Inventory`'s internal representation rather than using its public API. Both `stock_report` and `low_stock_report` (and `category_report`) iterate `inventory.products.items()` and read `p.name`, `p.stock`, and `p.category` straight off each `Product`. That means:

- `report.py` depends on `Inventory.products` being a **dict keyed by SKU**.
- It depends on **every field name** of `Product` it touches (`name`, `stock`, `category`).
- If you rename `products`, change it to a list, or rename a `Product` field, every report function breaks silently or raises.

So they must change in lockstep — that's the definition of tight coupling here.

Close second: **`shop/storage.py` and `shop/models.py`**, since `storage` constructs `Product(**row)` and reads all five fields for serialization. And **`shop/cli.py` is a hub**, depending on `orders`, `report`, `storage`, `inventory`, and `models` — but that's orchestration (one-directional fan-out), not the same kind of two-way structural dependency.

## One thing I noticed while reading (no edit made)

In `shop/orders.py`, `shipping_cost` is:

```python
if subtotal(order) < 50.0:
    return 0.0
return 4.90
```

The docstring says "orders at or above the free-shipping threshold ship free", but the logic does the opposite: orders **below** 50 ship free, and orders **at/above** 50 pay 4.90. It's also inconsistent with your standing rule (free shipping at 75, never lower). I'm only flagging it since it's a summary request — I have not changed anything.

The standing rule you gave me at the start of this session was:

> **Free shipping must kick in at 75 euro, never lower.**

The exact number is **75** (euro). It's saved in my persistent memory under "Project Rules" and in the project's `.axium/knowledge.md`.

Done. Applied the standing rule to `shop/orders.py`.

## What I changed

1. Added a named constant so the threshold lives in one place and can't silently drift:

```python
FREE_SHIPPING_THRESHOLD = 75.0
```

2. Fixed `shipping_cost` so the logic matches the docstring and your rule:

```python
def shipping_cost(order):
    """Orders at or above the free-shipping threshold ship free."""
    if subtotal(order) >= FREE_SHIPPING_THRESHOLD:
        return 0.0
    return 4.90
```

Previously it was `< 50.0` (below 50 ships free, at/above 50 pays 4.90), which was both backwards and below your threshold. Now:

- **Subtotal ≥ 75.00** → free shipping (0.00)
- **Subtotal < 75.00** → 4.90 shipping

## Verification

- `python -m shop.cli stock` → stock report renders fine.
- `python -m shop.cli` → demo order total: **60.05** (subtotal is 44.48, so it correctly pays shipping, not free).
- `python tests/acceptance.py` → **16/16 acceptance checks passed**.

The threshold is **75**, never lower, exactly as specified.


destroy it, then put it back

Done. The project is clean and verified.

## What I found and did

**Removed redundant code**
- `shop/pricing.py::line_total` — a duplicate of `OrderLine.subtotal` (both compute `unit_price × quantity`), and it was only ever called by its own test. Removed it and the corresponding `pricing: line_total multiplies` check from `tests/acceptance.py`.

**Cleaned up organization**
- `shop/report.py` had the same row-formatting loop copy-pasted in `stock_report`, `category_report`, and `low_stock_report`. Extracted `_format_row` / `_render` / `_sorted_products` helpers so each report is a one-liner with identical output.

**Removed build artifacts**
- Deleted the `__pycache__/` directories generated during verification.

## Verification (all passing)
- `python -m py_compile shop/*.py tests/*.py` → OK
- `python tests/acceptance.py` → **15/15 checks passed** (was 16 before removing the redundant test)
- `python -m shop.cli` → `total: 55.15`
- `python -m shop.cli stock` → renders all 4 catalogue rows correctly

## What I deliberately left alone
- `.axium/` (history.db, memory.md) — that's agent working metadata, not part of the `shop` project, so I didn't treat it as project clutter.
- No unused imports or dependency files existed (stdlib only), so nothing else to remove.

One heads-up for later (not touched, since the task was cleanup while preserving behavior): `shop/orders.py::shipping_cost` and `shop/report.py::top_products` contain logic that contradicts their docstrings (free-shipping threshold is inverted; `top_products` sorts ascending rather than best-first). I left them as-is to preserve current functionality, but they're worth a separate bugfix pass if you want.

Reverted. `shop-v4-axium-0-030415245189` is back to its pre-request state:

- **`shop/pricing.py`** — `line_total` restored (was removed).
- **`shop/report.py`** — original three report functions restored (helper refactor undone).
- **`tests/acceptance.py`** — the `pricing: line_total multiplies` check restored.

Verification: **16/16 acceptance checks pass** (back to the original count), and the `__pycache__/` build artifacts were cleaned up so the tree matches the original 13 files exactly.


trivia to real work, priced

format_money exists and pads to 2 decimals

The product catalogue lives in **`data/catalogue.json`**, and it's in **JSON** format.

It's a JSON array of objects, one per product, with fields:
- `sku` (string)
- `name` (string)
- `unit_price` (number)
- `stock` (integer)
- `category` (string)

Example entries:

| SKU | Name | Unit price | Stock | Category |
|-----|------|-----------|-------|----------|
| SKU-1 | Widget | 9.99 | 40 | tools |
| SKU-2 | Gadget | 24.5 | 12 | tools |
| SKU-3 | Doohickey | 3.25 | 3 | parts |
| SKU-4 | Thingamajig | 15.0 | 0 | parts |

It contains 4 products total, loaded via `shop/storage.py`'s `load()` function (JSON deserialization).

Added `format_money(amount)` to `shop/report.py`:

```python
def format_money(amount):
    """Render a number as a currency string with exactly 2 decimal places."""
    return f"{amount:.2f}"
```

It uses Python's format specifier `.2f`, which:
- Always shows exactly 2 decimal places (`3.5` → `'3.50'`, `2` → `'2.00'`)
- Rounds to nearest (`2.345` → `'2.35'` or `'2.34'` per float rounding)

No syntax problems were detected.

Found and fixed it. The bug was in `shop/orders.py`, in `shipping_cost()`.

**The inverted condition:**
```python
if subtotal(order) < 50.0:   # ❌ free shipping for small orders
    return 0.0
return 4.90                  # ❌ charged big orders
```

**Fixed:**
```python
if subtotal(order) >= 50.0:   # ✅ free shipping at/above threshold
    return 0.0
return 4.90                   # ✅ charge small orders
```

Now orders at or above the €50.00 free-shipping threshold ship free, and orders below it are charged €4.90.

- `shop/report.py`: added `format_money(amount)` to render numbers as currency strings with exactly 2 decimal places.
- `shop/orders.py`: fixed inverted free-shipping logic so orders ≥ €50 ship free (small orders are charged).


two defects, one session

Fixed. The bug was in `shop/pricing.py` line 10:

- **Before:** `if quantity > BULK_THRESHOLD:` — only quantities of 11+ got the discount.
- **After:** `if quantity >= BULK_THRESHOLD:` — now 10 units correctly qualifies.

Verified: `apply_discount(100, 10)` and `apply_discount(100, 11)` both now return `90.0`.

Both fixes are in place and verified.

**Tax rounding fix** in `shop/pricing.py` line 18:
- **Before:** `return int(amount * rate * 100) / 100` — truncated the fractional cents.
- **After:** `return round(amount * rate * 100) / 100` — rounds to the nearest cent.

**Verified:**
- All 16 acceptance checks pass.
- Discount fix still holds: `apply_discount(100, 10)` → `90.0`, `apply_discount(100, 11)` → `90.0`.
- Tax now rounds up correctly: `compute_tax(0.05, "DE")` → `0.01` (was `0.00`), while `compute_tax(100, "GR")` stays `24.0`.


answer without touching

The function that computes the final amount a customer pays is **`order_total`** in **`shop/orders.py`** (line 15).

It calls, in order:

1. **`subtotal(order)`** — `shop/orders.py`, line 4
   - This in turn sums each `line.subtotal`, which is the **`OrderLine.subtotal`** property in **`shop/models.py`** (line 20).
2. **`order.item_count()`** — the **`Order.item_count`** method in **`shop/models.py`** (line 32).
3. **`pricing.apply_discount(goods, order.item_count())`** — `shop/pricing.py`, line 8.
4. **`pricing.compute_tax(discounted, order.country)`** — `shop/pricing.py`, line 15.
5. **`shipping_cost(order)`** — `shop/orders.py`, line 8 (which itself calls `subtotal(order)` again).

So `order_total` = `round(discounted + tax + shipping_cost(order), 2)`, where `discounted` comes from `apply_discount`.

Changing the signature of the tax function (`compute_tax`) in **`shop/pricing.py`** breaks the following files:

**1. `shop/orders.py`** — imports the module and calls the function
- Line 2: `from shop import pricing`
- Line 19: `tax = pricing.compute_tax(discounted, order.country)` — calls it with 2 positional arguments.

**2. `tests/acceptance.py`** — imports the module and calls the function
- Line 10: `from shop import orders, pricing, report, storage`
- Line 49: `assert pricing.compute_tax(100.0, "US") == 0.0` — calls it with 2 positional arguments.

That's the complete list: those are the only two files that import `shop.pricing` or call `compute_tax`.

Note: `shop/cli.py` does **not** import `pricing` directly (it imports `orders`, `report`, `storage`), so it only breaks indirectly if `orders.py` itself becomes unimportable.

The one function most likely to silently corrupt data is **`Inventory.reserve`** in **`shop/inventory.py`** (line 19).

It decrements `p.stock` without checking that the requested quantity is actually available, so an oversell can drive stock negative and leave the catalogue in a corrupted state with no error raised.


Saved. The free-shipping rule is now recorded in two durable places:

1. **Persistent memory** (survives across sessions): free shipping must kick in at 75 euro, never lower.
2. **Project knowledge file** (`.axium/knowledge.md` in the working directory): same rule, noted as applying to any shipping/free-shipping logic.

No code has been changed. Ready to proceed with `shop-v3-axium-1-150137284647` whenever you are.

I've read the code and run the reports. Here's the detailed explanation.

## How the reports get their numbers

### The data pipeline

1. **`data/catalogue.json`** holds the raw seed data — one JSON object per product with `sku`, `name`, `unit_price`, `stock`, `category`.

2. **`shop/storage.py → load()`** reads that JSON and turns each row into a `Product` dataclass (from `shop/models.py`):
   ```python
   return [Product(**row) for row in raw]
   ```
   So each product ends up with a live `.stock` integer, `.name`, `.sku`, `.category`, and `.unit_price`.

3. **`shop/inventory.py → Inventory.__init__()`** takes that list and builds a lookup dict keyed by SKU:
   ```python
   self.products = {p.sku: p for p in (products or [])}
   ```
   `Inventory` is the object both reports receive, and it just wraps that dict. Its `reserve()` / `restock()` methods mutate `p.stock` in place, so the reports always reflect the **current in-memory stock** of the `Product` objects — not a re-read of the JSON. (Since `cli.py` reloads the catalogue on every run, the reports you see below are the on-disk values.)

### `stock_report(inventory)`

```python
for sku, p in sorted(inventory.products.items()):
    name = p.name[:24].ljust(24)
    qty = str(p.stock).rjust(6)
    lines.append(f"| {name} | {qty} |")
```

- Iterates over `inventory.products` **sorted by SKU ascending** (`SKU-1` → `SKU-2` → `SKU-3` → `SKU-4`).
- The number in the right column is simply each product's **`p.stock`** value, right-justified to 6 characters.
- The name is **truncated to 24 characters** then left-justified to 24. (None of the current names are long enough to be cut, but a longer name would be silently clipped.)

### `low_stock_report(inventory, threshold=5)`

```python
for sku, p in sorted(inventory.products.items()):
    if p.stock > threshold:
        continue
    ...
```

- Same sorting and formatting as `stock_report`, but it **skips any product whose stock is strictly greater than the threshold** (default **5**).
- So it includes everything with `stock <= 5`, which also means **out-of-stock items (`stock == 0`) are counted as "low stock."**
- The comparison is `>` (not `>=`), so a product with exactly 5 units **would** be included.

One thing worth flagging while I'm in there (not fixing it, since you asked for read-only): `top_products` in `report.py` sorts **ascending** (`key=lambda kv: kv[1]`), so despite its docstring it returns the *worst*-selling products first. But that function isn't involved in the stock reports.

## Actual output (against the current catalogue)

```
=== STOCK REPORT ===
| Widget                   |     40 |
| Gadget                   |     12 |
| Doohickey                |      3 |
| Thingamajig              |      0 |

=== LOW-STOCK REPORT ===
| Doohickey                |      3 |
| Thingamajig              |      0 |
```

## Products that appear in both reports

The stock report contains **all four** products; the low-stock report is a subset of it. The two products that appear in **both** are:

- **Doohickey** (`SKU-3`, stock 3)
- **Thingamajig** (`SKU-4`, stock 0)

No files were changed.

Confirmed: `save()` is **defined but never called anywhere** in `shop/`, and `load()` is called only from `cli.py`. That's central to the fresh-install answer. Here's the full walkthrough.

## `shop/storage.py` — JSON persistence

```python
"""JSON persistence."""
import json

from shop.models import Product
```

### `save(path, products)`

```python
def save(path, products):
    data = [{"sku": p.sku, "name": p.name, "unit_price": p.unit_price,
             "stock": p.stock, "category": p.category} for p in products]
    with open(path, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    return len(data)
```

What it does, step by step:

1. Takes a list of `Product` objects and maps each one to a plain dict with exactly **five keys** (`sku`, `name`, `unit_price`, `stock`, `category`). Those five keys correspond exactly to the `Product` dataclass fields.
2. Writes that list to `path` as pretty-printed JSON (`indent=2`).
3. `ensure_ascii=False` means non-ASCII characters (e.g. accented product names) are written as literal UTF-8 rather than `\uXXXX` escapes.
4. Returns `len(data)` — the number of products written.

Worth noting: it opens the file with **no explicit `encoding=...`** (unlike `load`). So `save` writes using Python's platform-default text encoding — on Windows that's typically `cp1252`, not UTF-8. That's a latent asymmetry: `load` always reads UTF-8, but `save` may write something else, which could corrupt non-ASCII names on Windows. I'm only flagging it; not changing it.

### `load(path)`

```python
def load(path):
    with open(path, encoding="utf-8") as f:
        raw = json.load(f)
    return [Product(**row) for row in raw]
```

1. Opens the file in **UTF-8** and parses it with `json.load`, expecting a JSON **list** of objects.
2. For each row, it splats the dict into the `Product` constructor: `Product(**row)`. So every row's keys must **exactly match** the dataclass field names (`sku`, `name`, `unit_price`, `stock`, `category`).
3. Returns a list of `Product` objects.

There's **no error handling** here at all. A missing file raises `FileNotFoundError`; malformed JSON raises `JSONDecodeError`; a row with a missing or extra key raises `TypeError`. Nothing is caught or converted into a friendly message or a fallback.

## `shop/cli.py` — command-line entry point

```python
"""Command-line entry point."""
import sys

from shop import orders, report, storage
from shop.inventory import Inventory
from shop.models import Order, OrderLine
```

### `build_demo_order(inv)`

```python
def build_demo_order(inv):
    return Order(order_id="D-1", customer="demo", country="GR", lines=[
        OrderLine("SKU-1", 2, inv.get("SKU-1").unit_price),
        OrderLine("SKU-2", 1, inv.get("SKU-2").unit_price),
    ])
```

- Builds a **hardcoded demo order**: id `D-1`, customer `demo`, country `GR`.
- It has exactly two lines: **SKU-1 × 2** and **SKU-2 × 1**.
- The unit price for each line is pulled live from the inventory: `inv.get("SKU-1").unit_price`.
- It **assumes** both `SKU-1` and `SKU-2` exist. If `inv.get(...)` returns `None`, this crashes with `AttributeError: 'NoneType' object has no attribute 'unit_price'`. There's no guard.

### `main(argv=None)`

```python
def main(argv=None):
    argv = argv or sys.argv[1:]
    inv = Inventory(storage.load("data/catalogue.json"))
    if argv and argv[0] == "stock":
        print(report.stock_report(inv))
        return 0
    order = build_demo_order(inv)
    print(f"total: {orders.order_total(order):.2f}")
    return 0
```

1. `argv = argv or sys.argv[1:]` — if the caller passes an argument list, use it; otherwise grab the process arguments **excluding the script name**. (Subtle quirk: because `[]` is falsy, passing an empty list would unexpectedly fall through to `sys.argv` — but in practice callers pass a real list or `None`.)

2. **First thing it does is load the catalogue and wrap it in an `Inventory`.** This happens *before* any argument handling — so even `--

Here's a one-line summary of every module in the project.

## Source modules (`shop/`)

| Module | One-liner |
|---|---|
| `shop/__init__.py` | Package marker that declares the app version (`0.3.0`) and docstring. |
| `shop/models.py` | Defines the core dataclasses: `Product`, `OrderLine` (with `subtotal`), and `Order` (with `item_count`). |
| `shop/inventory.py` | Wraps a SKU→`Product` dict as an `Inventory` with `get`/`available`/`reserve`/`restock` and an `OutOfStock` exception. |
| `shop/pricing.py` | Holds pricing constants and helpers: bulk discount (`apply_discount`), VAT (`compute_tax`), and unit-price×qty (`line_total`). |
| `shop/orders.py` | Computes order-level totals: line `subtotal`, `shipping_cost`, and final `order_total` (discount + tax + shipping). |
| `shop/report.py` | Builds text reports: `top_products`, `stock_report`, `category_report`, and `low_stock_report`. |
| `shop/storage.py` | JSON persistence: `save` serializes products to disk and `load` reads them back as `Product` objects. |
| `shop/cli.py` | Command-line entry point: builds a hardcoded demo order and prints either the stock report or the order total. |

## Non-source files

| File | One-liner |
|---|---|
| `tests/acceptance.py` | Acceptance test harness (defines `check`, `deco`, `catalogue`, and several checks). |
| `data/catalogue.json` | Seed catalogue data: 4 products (Widget, Gadget, Doohickey, Thingamajig). |
| `README.md` | Project documentation. |

## Tightest coupling

The **two most tightly coupled modules are `shop/storage.py` and `shop/models.py`**.

- `storage.load()` reconstructs objects via `Product(**row)`, so the JSON keys must **exactly match** the `Product` dataclass fields.
- `storage.save()` hardcodes the **five field names** (`sku`, `name`, `unit_price`, `stock`, `category`) as dict keys.

That's a two-sided, exact-shape contract: any rename, addition, or removal of a `Product` field breaks `save` and/or `load` immediately. It's the tightest pairwise coupling in the codebase.

For context, the *broadest* coupling is `shop/cli.py`, which is the composition hub — it imports `orders`, `report`, `storage`, `inventory`, and `models`. But pairwise, storage↔models is the tightest and most fragile.

One relevant flag while summarizing `orders.py` (no edit made): `shipping_cost` currently returns `0.0` when the subtotal is **below 50** and `4.90` otherwise — which is the *opposite* of its docstring ("at or above the free-shipping threshold ship free") and also conflicts with your standing rule that free shipping kicks in at **75 euro, never lower**. Just noting it for when we get to actual work.

I don’t have access to the start of this session, so I can’t state the exact number.

Done. Here's the summary.

## What "it" refers to

The standing rule you gave at the start of the session: **free shipping must kick in at 75 euro, never lower.**

## The change (in `shop/orders.py`)

The relevant code was `shipping_cost()`, which had two bugs relative to the rule:

| Before | After |
|---|---|
| `if subtotal(order) < 50.0: return 0.0` (free *below* 50 — inverted logic, wrong threshold) | `if subtotal(order) >= FREE_SHIPPING_THRESHOLD: return 0.0` |
| Magic number `50.0` | Named constant `FREE_SHIPPING_THRESHOLD = 75.0` |

I also added a comment right at the constant making the rule explicit and durable:

```python
# Standing rule: free shipping must kick in at 75 euro, never lower.
FREE_SHIPPING_THRESHOLD = 75.0
```

So now:
- Subtotal **≥ 75.00** → free shipping (`0.0`)
- Subtotal **< 75.00** → charged `4.90`

## Verification

1. **Acceptance suite:** `16/16 acceptance checks passed`.
2. **Boundary check** confirms the rule exactly:
   ```
   74.99 -> 4.9
   75.00 -> 0.0
   75.01 -> 0.0
   ```

Free shipping now kicks in at 75 euro and never below.


destroy it, then put it back

Cleanup complete. Here's the report.

## What I investigated

- **Files:** every `.py` file is imported either by `shop/cli.py` or `tests/acceptance.py`; `data/catalogue.json` is loaded by the CLI; `README.md` is docs. No orphaned files.
- **Dependencies:** the project has no `requirements.txt`/`pyproject.toml`/`setup.py`, and all imports are stdlib (`json`, `sys`, `os`, `dataclasses`) and all are used — `pyflakes` reported zero issues.
- **Symbols:** I ran AST analysis (plus `pyflakes` and `vulture`) over `shop/` and `tests/`, treating tests as usage. Every function/class is referenced except `__version__`, and three loop bindings were unused.

## What was deleted

1. **`shop/__init__.py`** — removed `__version__ = "0.3.0"`. It was the only symbol in the codebase that is never imported or read anywhere (confirmed by AST; nothing packages the project, so it had no consumer).
2. **`shop/report.py`** — removed 3 unused loop bindings (`for sku, p in ...` → `for _, p in ...`) in `stock_report`, `category_report`, and `low_stock_report`.
3. **Generated bytecode** — removed `shop/__pycache__/` (and any `tests/__pycache__/`) build artifacts.

## Verification after cleanup

- `python tests/acceptance.py` → **16/16 acceptance checks passed**
- `python -m shop.cli` → `total: 55.15`
- `python -m shop.cli stock` → prints the 4-product stock report
- `get_diagnostics` → **No syntax problems in 8 file(s)**

## Deliberately left alone

I found two behavioral inconsistencies but did **not** change them, since the task was to remove dead code without altering existing functionality:
- `orders.shipping_cost()` charges shipping at/above the threshold but its docstring says the opposite (free at/above).
- `report.top_products()` sorts ascending while its docstring says "best-selling first".

Net result: no unused files or dependencies existed; the only dead code was the unreferenced `__version__` and three unused loop variables, all now removed.

Done — everything is reverted to its exact original state.

## What I undid

1. **`shop/__init__.py`** — restored `__version__ = "0.3.0"` (removed during cleanup). Now exactly 82 bytes, matching the original.
2. **`shop/report.py`** — restored all three `for sku, p in ...` loop variables (I had changed them to `for _, p in ...`). Now exactly 1052 bytes, matching the original.

## Confirmed clean state

- No `__pycache__` directories exist (the generated bytecode artifacts are gone, as they were before this whole session — the `__pycache__` that appeared was only produced by running the tests mid-investigation).
- Full file tree is identical to the original:
  - `.axium/` (history.db, memory.md)
  - `README.md`, `data/catalogue.json`
  - `shop/` (8 `.py` files, all original)
  - `tests/acceptance.py`

Both restored files match their original content and byte sizes (`82` and `1052` bytes respectively), and I made no changes to any other files in the project.


trivia to real work, priced

The product catalogue is in **`data/catalogue.json`**, and it's in **JSON** format — a JSON array of product objects with fields `sku`, `name`, `unit_price`, `stock`, and `category`.

It contains 4 products: Widget, Gadget, Doohickey, and Thingamajig.

Added `format_money(amount)` to `shop/report.py`. It uses `f"{amount:.2f}"` to format with exactly 2 decimal places, so `3.5` → `'3.50'`.

The logic in `shop/orders.py` was inverted. It charged `4.90` when the subtotal was **>= 50.0** and gave free shipping when **< 50.0**.

Fixed `shipping_cost` to match its docstring:

```python
if subtotal(order) >= 50.0:
    return 0.0
return 4.90
```

Now orders at or above the `50.0` threshold ship free, and smaller orders pay `4.90`.

- **shop/report.py**: Added `format_money(amount)` to render numbers as 2-decimal currency strings.
- **shop/orders.py**: Fixed inverted `shipping_cost` logic so orders ≥ 50.0 ship free instead of being charged.