Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ different event from one that moved because it was wrong.
ranking (`site/search.js`) runs in the browser with no library.
`scripts/check_search.mjs` holds the ranking to its cases and every index
entry to a page and id that exist.
- The explainer opens with the example report's result drawn as a card (the
measured ECE against its floor's 95th percentile, and the verdict), written
at build time from the report's own line so it reads the same with scripts
off, and keeps its contents in a rail beside the text on wide screens.
- The calculator and planner each run in their own worker and can be
cancelled; their inputs are kept in the address, so a link reproduces a
result, and each result has "Copy link" and "Copy result" buttons.
- Every page carries a Content-Security-Policy that allows nothing but the
site's own files (no inline script or style), and `check_site_links.mjs`
fails a page that lacks it or carries anything it would block.

### Changed

Expand All @@ -59,6 +69,17 @@ different event from one that moved because it was wrong.

### Fixed

- Every validation message on the site's calculator and planner read
"[object Object]" (#25). Messages now say what is wrong, mark the field
invalid, and are tied to it for screen readers; a result is announced as one
line rather than the whole result block (#53).
- A failure to draw the worked example was reported as the example being
missing; the page now says which happened. `floor.js` turned a bin count of 0
into 10 where the Python refuses it, and now refuses it too. The parity
check holds the worked example's floor to 1e-9, not only to the four
decimals the report prints (#55).
- A link to a later section of the explainer (`#planner`) stopped short of it,
because the worked example grows the page after it loads.
- The inconclusive verdict read as a pass. "Not distinguishable from a
perfectly calibrated model at this sample size" is what the arithmetic
establishes and close to the opposite of what it means, and readers took it
Expand Down
92 changes: 85 additions & 7 deletions scripts/build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
from typing import Any, TypedDict

from plumbline.cli import app
from plumbline.metrics.calibration import synthetic_floor
from plumbline.metrics.calibration import calibration_floor, synthetic_floor
from plumbline.runner.execute import RunResult

ROOT = Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -292,6 +292,7 @@ def build_example() -> dict[str, Any]:
n_bins = int(ece_match["bins"])
accuracy = sum(outcomes) / len(outcomes)
summary = synthetic_floor(len(probabilities), n_bins=n_bins, accuracy=accuracy)["ece"]
real = calibration_floor(run.probabilities(), n_bins=n_bins)["ece"]

return {
"about": (
Expand All @@ -317,6 +318,10 @@ def build_example() -> dict[str, Any]:
"python": {
"accuracy": accuracy,
"summary_floor": {"mean": summary.mean, "p95": summary.p95},
# The floor from the rows themselves, unrounded, so the page's
# calibrationFloor is held to 1e-9 and not only to the 4 decimals
# the report prints.
"calibration_floor": {"mean": real.mean, "p95": real.p95},
},
}

Expand Down Expand Up @@ -572,10 +577,22 @@ def social_meta(title: str, description: str, url: str) -> str:
)


#: The Content Security Policy every page carries. Everything the site loads is
#: its own: no inline script or style, no other origin, and data: only for the
#: inline favicon. ``site/index.html`` carries the same tag, which the build
#: requires, and ``scripts/check_site_links.mjs`` checks every page for it.
CSP = (
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"font-src 'self'; connect-src 'self'; worker-src 'self'; frame-src 'none'; "
"object-src 'none'; base-uri 'none'; form-action 'none'"
)
CSP_META = f'<meta http-equiv="Content-Security-Policy" content="{CSP}">'

PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
{csp}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} | plumbline</title>
<meta name="description" content="{description}">
Expand Down Expand Up @@ -622,6 +639,7 @@ def _page(
) -> str:
"""One doc-shaped page: header, docs sidebar, the body, and its contents list."""
return PAGE.format(
csp=CSP_META,
title=html.escape(title),
description=html.escape(description, quote=True),
meta=meta,
Expand Down Expand Up @@ -702,11 +720,71 @@ def write_404(out: Path) -> None:
HEADER_SLOT = "<!-- site:header -->"


def explainer_page(source: str) -> str:
"""The explainer as written, with the shared header in its slot."""
if source.count(HEADER_SLOT) != 1:
raise BuildError(f"site/index.html must contain {HEADER_SLOT} exactly once")
return source.replace(HEADER_SLOT, site_header("", "calculator"))
#: Where the example's result card goes, in the explainer's opening block.
CARD_SLOT = "<!-- site:example-card -->"


def example_card(report: dict[str, Any]) -> str:
"""The example report's headline result, drawn at build time.

``report`` is the ``report`` block of ``example-run.json``: the figures parsed
from the report's own ECE line, so the card says exactly what the report
says, and says it with scripts off. The gauge puts the measured ECE against
the calibrated-model floor's 95th percentile on one scale.
"""
ece, p95, n = float(report["ece"]), float(report["floor_p95"]), int(report["n"])
inconclusive = "INCONCLUSIVE" in report["ece_line"]
width, scale = 320.0, max(ece, p95) * 1.3
x_ece, x_p95 = width * ece / scale, width * p95 / scale
anchor = "start" if x_ece < 60 else "end" if x_ece > width - 60 else "middle"
gauge = (
f'<svg class="gauge" viewBox="0 0 {width:.0f} 58" role="img" '
f'aria-label="Measured ECE {ece:.4f} against a calibrated-model floor whose 95th '
f'percentile is {p95:.4f}">'
f'<rect class="track" x="0" y="22" width="{width:.0f}" height="10" rx="5"/>'
f'<rect class="band" x="0" y="22" width="{x_p95:.1f}" height="10" rx="5"/>'
f'<line class="marker" x1="{x_ece:.1f}" x2="{x_ece:.1f}" y1="14" y2="40"/>'
f'<text class="marker-label" x="{x_ece:.1f}" y="10" text-anchor="{anchor}">'
f"measured {ece:.4f}</text>"
f'<text class="band-label" x="{x_p95:.1f}" y="54" text-anchor="end">'
f"floor, 95th percentile {p95:.4f}</text></svg>"
)
if inconclusive:
chip = '<span class="chip inconclusive">INCONCLUSIVE</span>'
says = (
f"The measured figure sits inside what a perfectly calibrated model scores on "
f"{n} rows, so this dataset establishes nothing either way."
)
else:
chip = '<span class="chip distinguishable">Distinguishable</span>'
says = (
f"The measured figure is above what a perfectly calibrated model scores on {n} "
"rows nineteen times in twenty."
)
return (
'<figure class="hero-card">\n'
f' <figcaption class="card-label">The example report, {n} rows</figcaption>\n'
' <div class="figures">\n'
f' <p><span class="k">Measured ECE</span> <span class="v">{ece:.4f}</span></p>\n'
' <p><span class="k">Calibrated-model floor, 95th percentile</span> '
f'<span class="v">{p95:.4f}</span></p>\n'
" </div>\n"
f" {gauge}\n"
f' <p class="card-verdict">{chip} {says} '
'<a href="#example">See it derived from the rows</a>.</p>\n'
"</figure>"
)


def explainer_page(source: str, report: dict[str, Any]) -> str:
"""The explainer as written, with the shared header and the example card in their slots."""
for slot in (HEADER_SLOT, CARD_SLOT):
if source.count(slot) != 1:
raise BuildError(f"site/index.html must contain {slot} exactly once")
if source.count(CSP_META) != 1:
raise BuildError("site/index.html must carry the site's Content-Security-Policy tag")
page = source.replace(HEADER_SLOT, site_header("", "calculator"))
return page.replace(CARD_SLOT, example_card(report))


#: The explainer's name in search results.
Expand Down Expand Up @@ -789,7 +867,7 @@ def main() -> int:
prov = provenance()
rendered = render_docs(prov)
source = (SITE / "index.html").read_text(encoding="utf-8")
explainer = explainer_page(source)
explainer = explainer_page(source, example["report"])
index = search_index(rendered, source)
except BuildError as problem:
print(f"site build refused: {problem}", file=sys.stderr)
Expand Down
4 changes: 4 additions & 0 deletions scripts/check_floor_parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ if (examplePath) {
for (const key of ["mean", "p95"]) {
const gap = Math.abs(summary[key] - example.python.summary_floor[key]);
if (!(gap <= tolerance)) failures.push(`worked example: summary floor ${key} off by ${gap}`);
// The line above agrees to 4 decimals; this holds the floor from the rows
// to the same 1e-9 as every other case.
const real = Math.abs(band[key] - example.python.calibration_floor[key]);
if (!(real <= tolerance)) failures.push(`worked example: calibration floor ${key} off by ${real}`);
}
console.log(`worked example: ${derived.slice(0, 110)}...`);
}
Expand Down
22 changes: 21 additions & 1 deletion scripts/check_site_links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
// - a page loads anything (script, stylesheet, image, frame) from another origin,
// - a page lacks its canonical, Open Graph, or Twitter card tags, or its
// canonical and og:url do not name the page itself,
// - a page does not carry exactly the site's Content-Security-Policy tag, or
// has an inline script, an inline style block, or a style attribute (which
// that policy would block, so the page would silently break),
// - og.png is not a 1200x630 PNG,
// - the 404 page is missing, links anywhere that does not resolve, or (live)
// a missing path is not answered with it and a 404 status.
Expand All @@ -23,6 +26,13 @@ import path from "node:path";
const SITE_URL = "https://tmhsdigital.github.io/plumbline/";
const SITE_PATH = new URL(SITE_URL).pathname; // "/plumbline/"

// Stated here rather than read from build_site.py, so a change to the policy
// is a change to what this check requires.
const CSP =
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
"font-src 'self'; connect-src 'self'; worker-src 'self'; frame-src 'none'; " +
"object-src 'none'; base-uri 'none'; form-action 'none'";

const target = process.argv[2];
if (!target) {
console.error("usage: check_site_links.mjs <site directory | site URL>");
Expand Down Expand Up @@ -119,6 +129,15 @@ function checkSocial(sitePath, html) {
}
}

function checkPolicy(where, html) {
const tags = [...html.matchAll(/<meta http-equiv="Content-Security-Policy" content="([^"]*)">/g)];
if (tags.length !== 1) problems.push(`${where}: ${tags.length} Content-Security-Policy tags, expected 1`);
else if (tags[0][1] !== CSP) problems.push(`${where}: Content-Security-Policy is not the site's policy`);
if (/<script\b(?![^>]*\bsrc=)[^>]*>/i.test(html)) problems.push(`${where}: an inline <script>, which the policy blocks`);
if (/<style\b/i.test(html)) problems.push(`${where}: an inline <style>, which the policy blocks`);
if (/<[a-z][^>]*\sstyle="/i.test(html)) problems.push(`${where}: a style attribute, which the policy blocks`);
}

// Check one page's references; queue the internal HTML pages it links to.
async function crawl(sitePath, { social = true, servedAs = sitePath } = {}) {
if (checked.has(sitePath)) return;
Expand All @@ -129,6 +148,7 @@ async function crawl(sitePath, { social = true, servedAs = sitePath } = {}) {
return;
}
if (social) checkSocial(sitePath, html);
checkPolicy(servedAs, html);
const queue = [];
for (const [tag, , value, rel] of references(html)) {
const where = `${servedAs}: <${tag}> ${value}`;
Expand Down Expand Up @@ -192,4 +212,4 @@ if (problems.length) {
console.error(`${problems.length} problem(s) on ${live ? base : target}:\n ${problems.join("\n ")}`);
process.exit(1);
}
console.log(`${htmlPages} pages checked on ${live ? base : target}: every link, anchor, and meta tag resolves; nothing loads from another origin.`);
console.log(`${htmlPages} pages checked on ${live ? base : target}: every link, anchor, and meta tag resolves; nothing loads from another origin; every page carries the CSP.`);
102 changes: 102 additions & 0 deletions site/explainer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* The explainer (index.html): its calculator, worked example, and planner. */
section { padding: 2rem 0; border-top: 1px solid var(--line); }
.lede { font-size: 1.05rem; }
.label { display: inline-block; font: 600 0.75rem var(--mono); text-transform: uppercase; letter-spacing: 0.05em; color: var(--warn-fg); background: var(--warn-bg); padding: 0.1rem 0.4rem; border-radius: 3px; margin-bottom: 0.5rem; }

form { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 1rem; margin: 1.25rem 0 1rem; }
label { display: block; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem; }
.hint { display: block; font-size: 0.8rem; font-weight: 400; color: var(--muted); }
input { width: 100%; font: 1rem var(--mono); padding: 0.5rem 0.6rem; color: var(--fg); background: var(--bg); border: 1px solid var(--control); border-radius: 4px; }
.actions { grid-column: 1 / -1; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
button { font: 600 1rem var(--sans); padding: 0.55rem 1.1rem; color: var(--bg); background: var(--fg); border: 0; border-radius: 4px; cursor: pointer; }
button[disabled] { opacity: 0.55; cursor: progress; }
progress { flex: 1 1 8rem; height: 0.5rem; }
.error { color: var(--error); }

.result { border: 1px solid var(--line); border-radius: 6px; padding: 1rem; background: var(--panel); }
.result[hidden], [hidden] { display: none !important; }
.result h3:first-child { margin-top: 0; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1rem; margin: 0 0 1rem; }
dt { color: var(--muted); }
dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; }
.verdict { padding: 0.75rem; border-radius: 4px; margin: 0 0 1rem; }
.verdict.inconclusive { background: var(--warn-bg); color: var(--warn-fg); }
.verdict.distinguishable { background: var(--ok-bg); color: var(--ok-fg); }
.verdict strong { display: block; margin-bottom: 0.25rem; }
.big { font: 600 1.6rem var(--mono); margin: 0 0 0.5rem; }

.table-wrap { overflow-x: auto; margin: 0 0 1rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
caption { text-align: left; color: var(--muted); font-size: 0.85rem; padding-bottom: 0.4rem; }
th, td { padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--line); text-align: right; font-family: var(--mono); white-space: nowrap; }
th { font-family: var(--sans); font-weight: 600; color: var(--muted); }
th:first-child, td:first-child { text-align: left; }
tfoot td { border-bottom: 0; font-weight: 600; }
@media (max-width: 30rem) {
table { font-size: 0.8rem; }
th, td { padding: 0.3rem 0.25rem; }
th { white-space: normal; }
}

figure { margin: 0 0 1rem; }
figcaption { font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }
svg.hist { width: 100%; height: auto; display: block; }
svg.hist .bar { fill: var(--bar); }
svg.hist .axis { stroke: var(--line); }
svg.hist text { fill: var(--muted); font: 13px var(--mono); }
svg.hist .measured { stroke: var(--mark-measured); stroke-width: 2; }
svg.hist .p95 { stroke: var(--mark-p95); stroke-width: 2; stroke-dasharray: 5 3; }
svg.hist text.measured-label { fill: var(--mark-measured); }
svg.hist text.p95-label { fill: var(--mark-p95); }

ol.steps { padding-left: 1.25rem; }
ol.steps > li { margin-bottom: 1.25rem; }
.trace { font: 0.85rem var(--mono); color: var(--muted); padding-left: 1.25rem; margin: 0 0 1rem; }

/* ---- layout: the text, and the contents rail beside it from 64rem ------- */

.explainer .rail ul { list-style: none; padding: 0; margin: 0 0 2rem; display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; }
.explainer .rail a { font-size: 0.95rem; }
.explainer .rail .rail-label { display: none; }
@media (min-width: 64rem) {
main.explainer { max-width: 64rem; display: grid; grid-template-columns: minmax(0, 46rem) 12rem; column-gap: 3rem; justify-content: center; }
main.explainer > * { grid-column: 1; }
main.explainer > .rail { grid-column: 2; grid-row: 1 / span 10; position: sticky; top: calc(var(--header-h) + 2.5rem); align-self: start; padding-top: 2.5rem; }
.explainer .rail ul { display: block; margin: 0; }
.explainer .rail .rail-label { display: block; font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.5rem 0.6rem; }
.explainer .rail a { display: block; padding: 0.2rem 0.6rem; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; font-size: 0.9rem; }
.explainer .rail a:hover { color: var(--fg); text-decoration: underline; }
.explainer .rail a[aria-current] { color: var(--fg); font-weight: 600; border-left-color: var(--accent); }
}

/* ---- the opening block ---------------------------------------------------- */

.hero h1 { font-size: clamp(1.9rem, 4.5vw, 2.6rem); letter-spacing: -0.01em; margin-bottom: 1rem; }
.hero .lede { font-size: 1.1rem; max-width: 42rem; }
.hero-card { margin: 1.5rem 0; padding: 1rem 1.25rem; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
.card-label { font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.75rem; }
.hero-card .figures { display: flex; flex-wrap: wrap; gap: 0.5rem 2.5rem; }
.hero-card .figures p { margin: 0; }
.hero-card .k { display: block; font-size: 0.85rem; color: var(--muted); }
.hero-card .v { display: block; font: 600 1.6rem var(--mono); }
svg.gauge { display: block; width: 100%; max-width: 24rem; height: auto; margin: 0.75rem 0; }
svg.gauge .track { fill: var(--bg); stroke: var(--line); }
svg.gauge .band { fill: var(--bar); }
svg.gauge .marker { stroke: var(--mark-measured); stroke-width: 2.5; }
svg.gauge text { font: 10px var(--mono); fill: var(--muted); }
svg.gauge .marker-label { fill: var(--mark-measured); }
.card-verdict { margin: 0; font-size: 0.95rem; }
.chip { display: inline-block; font: 600 0.75rem var(--mono); letter-spacing: 0.04em; padding: 0.1rem 0.45rem; border-radius: 3px; margin-right: 0.35rem; }
.chip.inconclusive { color: var(--warn-fg); background: var(--warn-bg); }
.chip.distinguishable { color: var(--ok-fg); background: var(--ok-bg); }
.cta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0 0 2rem; }

/* Links that look like buttons, and the quieter button. */
a.button { display: inline-block; font: 600 1rem var(--sans); padding: 0.55rem 1.1rem; color: var(--bg); background: var(--fg); border: 1px solid var(--fg); border-radius: 4px; text-decoration: none; }
a.button:hover { opacity: 0.9; }
a.button.secondary, button.secondary { color: var(--fg); background: var(--bg); border: 1px solid var(--control); }
a.button.secondary:hover, button.secondary:hover { border-color: var(--fg); }
button.small-button { font-size: 0.85rem; font-weight: 400; padding: 0.3rem 0.7rem; }
.share { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem; }
input[aria-invalid="true"] { border-color: var(--error); outline: 1px solid var(--error); }
@media print { .rail, .cta, .share, .actions { display: none !important; } }
Loading
Loading