# ADR 0002 — The published page quotes the code, and draws its own charts

Date: 2026-08-13
Status: accepted
Author: P0w3r223
Related to: [ADR 0001](0001_scope-and-site-expansion.md) (W6, which this partially
implements and partially supersedes)

---

## Context

The published page had not changed shape since v0.1. It carried an `<h1>` holding the
repository name, one metrics table, two matplotlib figures pasted in as 233 KB of base64, a
single grey box of prose about methodology, and a footer stamped with the wall clock at build
time.

Everything v0.2 established was invisible on it. Closed input vocabularies, the artifact
contract, de-duplication, the size↔quality curve, dated valuations — the work the project is
defensible on — appeared, if at all, as one sentence in that grey box. A reader arriving from
the portfolio index saw a page that could have been generated by any tutorial.

Three specific problems, each with a measurable consequence:

- **The page described the code instead of showing it.** Every claim on it was a paraphrase
  written at some point in the past, verified by nothing. The claim "SHAP over impurity
  importance" was true; the claim that valuations were "labelled" with their vintage was
  false for two weeks, and was caught by a review rather than by the build.
- **The charts were images.** They could not be selected, searched, restyled, or read by a
  screen reader; they ignored the reader's colour scheme; and re-encoding them as base64 made
  the committed HTML 233 KB, of which the actual content was under 10 KB.
- **Nothing connected the page to the model.** The metrics came from the artifact, but the
  prose around them did not, so the two could disagree indefinitely. They did: the site
  quoted RandomForest at 8 616 PLN on 117 859 rows for a week after de-duplication and the
  bake-off had changed both numbers.

A sibling project, [it-job-radar](https://github.com/P0w3r223/it-job-radar), had already
solved the shape of this: lead with a finding, carry the `n` on every figure, and print the
SQL behind each metric verbatim next to the chart it produced. What did not transfer was the
mechanism — that project's metrics are SQL files, and this one's are Python functions and a
pickled pipeline.

## Decision

### 1. The page quotes the code rather than describing it

The rules the page explains — de-duplication, province normalisation, the displacement
predicate, the declared one-hot domains, the artifact contract — are read out of their
modules at build time with `inspect.getsource` and rendered verbatim in `<details>` blocks.

A paraphrase can go stale silently; a quotation cannot. This is the same move as
it-job-radar's "one SQL definition per metric, shown to the reader", translated to a codebase
whose definitions are functions.

### 2. Every number the page prints is measured, and the ones about failure are measured by
provoking it

`site/export.py` produces `docs/data/*.json` by running the pipeline, not by reading a
document. The section the page now leads with — what the model refuses to price — is
generated by executing each refused input **with its guard removed** and recording the
answer:

| input | with the closed domain | without it |
| --- | --- | --- |
| `mark=ferrari, model=f40` | refused | 34 093 PLN — and `zzzz`/`qqqq` returns 34 093 PLN |
| `mark=Opel` | normalised to `opel`, 33 576 PLN | 38 399 PLN (+14 %) |
| `fuel=Diesel, vol_engine=0` | refused | 37 248 PLN (+11 %) |
| `fuel=Petrol` / `province=Berlin` | refused | the encoder raises before pricing |

The export fails rather than publishes if a refused input is priced, if the raised error does
not name the offending value, or if a spelling variant stops normalising. The table therefore
cannot outlive the guards it documents.

### 3. Charts are inline SVG, and the build refuses an incomplete one

matplotlib leaves the page. The three published charts are pure functions from rows to SVG
markup, which inherits the reader's colour scheme (so dark mode is not a second rendering),
stays sharp at any width, and is real text.

The comparison chart **raises** rather than draws when a MAE arrives without its
fold-to-fold spread, and the build asserts structurally — on the rendered markup, not on
wording — that it carried one. This project decides which model to serve on gaps of a few
hundred złoty; a bar without an error bar is exactly the picture that makes such a gap look
decisive.

Two charts are deliberately absent, both from ADR 0001's W6 list:

- **No PLN waterfall.** SHAP here runs on the regressor inside the
  `TransformedTargetRegressor`, so the values are log-price contributions: they sum to
  `log1p(prediction)` and turn multiplicative under `expm1`. "+8 400 PLN for age" would be
  this project's own named error class on its most-viewed page.
- **No province choropleth.** Median advert price per province is confounded by fleet mix —
  Mazowieckie's higher median is mostly premium marques — so the map would visually assert a
  premium the model does not hold.

### 4. Interpretation is derived, not written

The headline, the KPI row and the sentence judging the model comparison are all computed from
the measurements. If the heavy model ever wins the bake-off, the headline changes with it
instead of quietly becoming false, and a test asserts that.

This earned its keep immediately. The first draft's paragraph — inherited from ADR 0001's
prose — called LightGBM's lead "inside the fold-to-fold spread" while the chart above it drew
two whiskers that do not overlap: 186 PLN against spreads of 72 and 81, whose quadrature sum
is 108. The margin was also typed into the template, so a retrain would have moved every
other number on the page while that sentence stayed. Both halves are computed now.

### 5. The page is a pure function of committed inputs, and CI checks it

Exporting needs the 14 MB artifact and the dataset, neither of which is in the repository;
rendering needs only `docs/data/*.json` and the project's own source. So the split is not
tidiness — it is what lets CI rebuild the page and fail on any diff. Nothing in the build
reads the wall clock (the training date is stamped into the artifact by `save_model`, and the
commit SHA at export time), which is what makes a byte-for-byte comparison possible at all.

## Options considered

1. **Restyle the existing `report.py`.** Cheapest, and it would have produced a better-looking
   page carrying the same unverified claims. Rejected: the problem was never the CSS.
2. **Full W6 as specified in ADR 0001** — model exported to the browser, live valuation on
   input, what-if sliders. Rejected as sequencing, not as a goal: it depends on W5, which is
   not built, and the static page is worth having in the meantime. W6 remains the target.
3. **Quote the code (chosen), with SVG charts and a CI drift guard.**

## Consequences

- **`report.py` is deleted.** Publishing is now two commands, `site.export` then `site.build`,
  and both outputs are committed together.
- **A new optional dependency group, `[site]`** (Jinja2). Kept out of the runtime dependencies
  because the serving container has no use for a template engine, and out of `dev` because CI
  needs it without JupyterLab.
- **`save_model` stamps `trained_at`,** and the export refuses a bundle without it. The page
  previously dated the model from the artifact file's mtime — the date it was last *copied*.
  Artifacts predating the stamp must be retrained before the site can be republished.
- **The export refuses when the artifact's `n_train` disagrees with today's cleaned row
  count,** because the page dates and sizes one model while measuring another's behaviour.
- **`figures.py` keeps drawing the notebook's PNGs,** including the SHAP beeswarm, whose
  per-advert detail the page's mean-|SHAP| bars deliberately drop.
- **233 KB → 32 KB.** 90 → 118 tests.

### Accepted residual risk

CI verifies that the page matches `docs/data/*.json`. It cannot verify that those aggregates
match the model, because it has neither the artifact nor the dataset — so a hand-edited
`metrics.json` renders a self-consistent page and passes. `metrics.commit` records which
commit the measurements were taken from, which makes the edit visible to a reader, but nothing
enforces it. Closing this properly needs the export to run somewhere with the artifact, which
is the same infrastructure question W5 raises; it is deliberately left open rather than
papered over with a check that only looks like one.
