diff --git a/.assets-revision b/.assets-revision index ab6fabd3..f14521a1 100644 --- a/.assets-revision +++ b/.assets-revision @@ -7,12 +7,31 @@ # # The revision is a commit sha, never a moving branch name. # -# Current pin = the commit that HF dataset `main` points at (verified -# 2026-09-13): the squash-merge commit of HF dataset PR #85 ("Upload nvidia.tar.gz -# with distinct official product and hero images (#107 follow-up)"), which sits on -# top of the merged PR #84 ("Upload repacked nvidia.tar.gz with de-duplicated -# official product images (#107)"), PR #75 ("Add reviewed NVIDIA asset bundle") and -# PR #74 ("Upload kaggle.tar.gz with huggingface_hub"), so this sha is on HF `main`. +# Current pin = c32018ca3b3d67e7b858b1b85fb101aea5090cd7, the head commit of HF +# dataset `main` (verified 2026-09-15): the squash-merge commit of HF dataset PR +# #91 "berkeley: synthetic imagery bundle (164 files)" (merged 2026-09-15T04:00:54Z), +# which added berkeley.tar.gz (sha256 +# ab9d2716ae8d06540a181b5e60c37f613d87b103864b467511da546b1b173789, +# 6951483 bytes, 171 managed members) — the site's first archive, so that +# fetch_assets.sh can install sites/berkeley/static/images/. +# +# The berkeley bundle first shipped through the interim pin `refs/pr/91` while that +# dataset PR was open; the pin was moved to this merged commit once the PR merged, +# because a `refs/pr/` ref moves whenever its branch is updated. Checked at both +# pin changes (2026-09-14 and 2026-09-15): for all 29 sites registered before +# berkeley, the archive size and LFS oid on this commit are identical to the +# b7e605c0 pin below, and all 30 site archives on this commit are byte-identical to +# the ones on `refs/pr/91` (verified by LFS oid and by comparing the downloaded +# berkeley.tar.gz byte for byte), so the pin change alters no site's assets. This +# revision carries an archive for all 30 registered sites. +# +# Previous pin = b7e605c0ec5fc47de85b09e7427162cc50e38980, the commit HF dataset +# `main` pointed at before this pin (verified 2026-09-14): the squash-merge commit +# of HF dataset PR #85 ("Upload nvidia.tar.gz with distinct official product and +# hero images (#107 follow-up)"), which sits on top of the merged PR #84 ("Upload +# repacked nvidia.tar.gz with de-duplicated official product images (#107)"), PR +# #75 ("Add reviewed NVIDIA asset bundle") and PR #74 ("Upload kaggle.tar.gz with +# huggingface_hub"). # # Its nvidia.tar.gz is the repacked bundle (sha256 # 617a3e3740ba6706bcab786c8a5c3f9a22ecbb39eff5728ad2c12e4992cb098b, 16340955 bytes, @@ -25,15 +44,22 @@ # original 6706395-byte bundle (blob 0f1d8068af602748d0dec19ef04b52c7559fef28), # which carried duplicates. # -# Why this sha and not the other candidate (refs/pr/37, resolved +# Pin before that = 64264d065cdb0b7755ee99dab356be5733d9ddef, the commit HF dataset +# `main` pointed at (verified 2026-09-13). It carries the merged HF dataset PR +# #74 ("Upload kaggle.tar.gz with huggingface_hub") and the later PR #75 ("Add +# reviewed NVIDIA asset bundle"). +# +# Why these pins and not the other candidate (refs/pr/37, resolved # 600a3e1de158ae56dc82a5fab56c2ca25acb1e27): -# * this revision carries an archive for every registered site (29 of 29), and +# * the pinned revision carries an archive for every registered site (30 of 30 for +# c32018ca and for refs/pr/91: the 29 main-site archives plus berkeley.tar.gz; +# 29 of 29 for b7e605c0), and # the kaggle seed DB it ships was found identical to the seed the site's own # code generates (13/13 tables, row by row) in the PR #106 audit; # * refs/pr/37 ships an older kaggle seed (models table has 10 rows instead of # 11: densenet121-chestxray is absent) and its archives cover only 17 of the -# 29 registered sites, so pinning there would break fetch_assets.sh. -# * this revision's kaggle.tar.gz (sha256 +# 30 registered sites, so pinning there would break fetch_assets.sh. +# * the pinned revision's kaggle.tar.gz (sha256 # dd6f1ab34f99989b300e3366225a8f8b0932d49d033b48f141938d16f1c0607d, # 11278106 bytes) passes the repository's own # scripts/validate_asset_archive.py: @@ -43,4 +69,4 @@ # superseded 11278264-byte pack, sha256 c533c283...) no longer applies and # `scripts/fetch_assets.sh` installs sites/kaggle's assets from this pin. repo: ChilleD/WebHarbor -revision: b7e605c0ec5fc47de85b09e7427162cc50e38980 +revision: c32018ca3b3d67e7b858b1b85fb101aea5090cd7 diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index d070bd96..4b526132 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 29 sites still return 200 on the alt-port container +- All 30 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index 5b9ba2eb..f2233da0 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40028) +# 1. Stop test container (don't touch user's working container on :40000-40029) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 29 sites still return 200 +- All 30 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index f0cbeaa1..31155506 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index 54cc7b5e..0d86dae8 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,18 +33,18 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 29 sites (40000-40028). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 30 sites (40000-40029). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 29 sites return 200 -for p in $(seq 41000 41028); do +# 1. all 30 sites return 200 +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 29 sites return 200 +- [x] All 30 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index f19c070d..8de12d69 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,10 +169,10 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev -# all 29 sites return 200 -for p in $(seq 41000 41028); do +# all 30 sites return 200 +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/AGENTS.md b/AGENTS.md index e595698b..ed62c98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -29 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +30 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40029:40000-40029 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40028:40000-40028 \ +docker run -d -p 8101:8101 -p 40000-40029:40000-40029 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40028` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40029` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41028); do +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 5e84cb62..cf9ded2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40028`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41028`). +If a container is already running on `:8101` / `:40000-40029`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41029`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22e4c1c2..936c7ba5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev + -p 8101:8101 -p 40000-40029:40000-40029 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index c5a2cbab..6d238e1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 29 Flask mirror sites + control plane on :8101. +# 30 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -70,6 +70,17 @@ RUN cd /opt/WebSyn/healthline && test -f instance_seed/healthline.db && \ PYTHONHASHSEED=0 python3 migrate_seed.py && \ python3 prune_unreferenced_images.py --apply && rm -rf instance +# Berkeley's generated imagery ships in the pinned asset bundle while its SQLite +# seed stays build-generated from tracked source — see .build-generated-seed. The +# inventory gate enforces exact coverage + per-file SHA-256 + decode at the planned +# dimensions of all 164 generated images (82 FLUX scenes + 82 Pillow avatars), the +# same contract as the webmd_doctor / compass / walmart_careers inventories. +RUN python3 /opt/WebSyn/berkeley/check_generated_assets.py +# No wall clock and no random salt reaches a row, so the artifact is +# byte-reproducible; websyn_start.sh copies it into instance/ at boot. +RUN cd /opt/WebSyn/berkeley && rm -rf instance instance_seed && \ + PYTHONHASHSEED=0 python seed_data.py && rm -rf instance + COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py @@ -92,6 +103,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40028 +EXPOSE 8101 40000-40029 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index 7826d592..b9e214ca 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 29 sites today, scaling to 100+ together +- **Community-driven** — 30 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40029:40000-40029 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40028` to explore 29 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, and NVIDIA`. +Then point your agent at `http://localhost:40000` through `http://localhost:40029` to explore 30 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, and UC Berkeley`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,17 +65,19 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ### Local NVIDIA review candidate -This branch registers **29 sites**, the 29 entries listed above; NVIDIA is the last -entry, registry index 28, container port 40028 (local review host port 48028). The +This branch registers **30 sites**, the 30 entries listed above; NVIDIA took index 28 +when #107 merged, so UC Berkeley (the site under review here) is the last entry, +registry index 29, container port 40029 (local review host port 48029). The published-image quickstart above is not a claim that this review candidate has been published or accepted. | Site | Registry position | Container port | Local review host port | | --- | --- | --- | --- | | NVIDIA | 28 | 40028 | 48028 | +| UC Berkeley | 29 | 40029 | 48029 | `websyn_start.sh`, `control_server.py`, the `Dockerfile` `EXPOSE` line and every -site's `tasks.jsonl` `web` URL agree on 29 sites and `40000-40028`; +site's `tasks.jsonl` `web` URL agree on 30 sites and `40000-40029`; `scripts/check_site_registry.py` (run by `scripts/check_assets.sh`) fails when they drift. @@ -83,7 +85,7 @@ After preparing the candidate assets and building `webharbor:dev`, the local review deployment uses: ```bash -docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48028:40000-40028 webharbor:dev +docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48029:40000-40029 webharbor:dev ``` NVIDIA inherits the site contribution from @KaKituken @@ -95,28 +97,39 @@ passed. ### Asset delivery status -`.assets-revision` is pinned to `b7e605c0ec5fc47de85b09e7427162cc50e38980`, the -squash-merge commit of HF dataset PR -[#85](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/85) on the -dataset's `main`. It sits on top of PR -[#84](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/84) and PR -[#75](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/75), which -added the first reviewed NVIDIA bundle. Those PRs are merged, the pinned commit is -on `main`, and the earlier "PR not merged" and "archive rejected by the -validator" blockers are cleared: +`.assets-revision` is pinned to `c32018ca3b3d67e7b858b1b85fb101aea5090cd7`, the +head commit of HF dataset `main` and the squash-merge commit of HF dataset PR +[#91](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/91) +("berkeley: synthetic imagery bundle (164 files)", merged 2026-09-15T04:00:54Z). +Every other registered site's archive on that commit has the same size and LFS oid +as on the previous pin `b7e605c0ec5fc47de85b09e7427162cc50e38980`, and all 30 site +archives are byte-identical to the ones the interim `refs/pr/91` pin served, so the +pin change adds the UC Berkeley bundle without altering any other site's assets: -- the pinned revision carries 31 `*.tar.gz` (one per registered site plus +- the pinned revision carries 32 `*.tar.gz` (30 registered sites plus `bandcamp.tar.gz` and `drugs_com.tar.gz`, which `fetch_assets.sh` ignores for sites this checkout does not register); -- `nvidia.tar.gz` at that revision has 37 file members and no directory members, - so `scripts/validate_asset_archive.py nvidia.tar.gz nvidia` prints +- `nvidia.tar.gz` at that revision is the same 37-member archive as at the previous + pin, so `scripts/validate_asset_archive.py nvidia.tar.gz nvidia` prints `[fetch] validated 37 managed members for nvidia` and exits 0; -- `./scripts/fetch_assets.sh` at this pin extracts all 29 registered sites - (`[fetch] done — 29 site(s) extracted into sites/`). +- `berkeley.tar.gz` at that revision has 171 managed members, so + `scripts/validate_asset_archive.py berkeley.tar.gz berkeley` prints + `[fetch] validated 171 managed members for berkeley`; +- `./scripts/fetch_assets.sh` at this pin extracts all 30 registered sites + (`[fetch] done — 30 site(s) extracted into sites/`). + +The previous pin `b7e605c0ec5fc47de85b09e7427162cc50e38980` is the squash-merge +commit of HF dataset PR +[#85](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/85) on the +dataset's `main`. It sits on top of PR +[#84](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/84) and PR +[#75](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/75), which +added the first reviewed NVIDIA bundle. | Artifact | Members | Bytes | SHA-256 | | --- | --- | --- | --- | -| `nvidia.tar.gz` at the current pin (HF PR #85) | 37 | 16,340,955 | `617a3e3740ba6706bcab786c8a5c3f9a22ecbb39eff5728ad2c12e4992cb098b` | +| `nvidia.tar.gz` at the current pin | 37 | 16,340,955 | `617a3e3740ba6706bcab786c8a5c3f9a22ecbb39eff5728ad2c12e4992cb098b` | +| `berkeley.tar.gz` at the current pin (HF PR #91) | 171 | 6,951,483 | `ab9d2716ae8d06540a181b5e60c37f613d87b103864b467511da546b1b173789` | | previous pin's `nvidia.tar.gz` (HF PR #84, superseded) | 34 | 9,927,312 | `ee8c6ba966e7a8f7fb5ad2d7ff0134ab98e7b80d6cc77f3328217405b8b34e2f` | PR #85 replaces five product images and adds three dedicated hero images (see @@ -204,7 +217,7 @@ itself cannot be edited from this repository. ## 🤝 Contribute -We have built 29 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 30 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/agent_demo/README.md b/agent_demo/README.md index 0570c745..dbc66a0e 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40029:40000-40029 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 9e2ca716..41dd14ff 100644 --- a/control_server.py +++ b/control_server.py @@ -30,7 +30,7 @@ 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'fedex', 'webmd_doctor', 'healthline', 'kaggle', - 'nvidia', + 'nvidia', 'berkeley', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/scripts/fetch_assets.sh b/scripts/fetch_assets.sh index 943bc4af..d9cb4b67 100755 --- a/scripts/fetch_assets.sh +++ b/scripts/fetch_assets.sh @@ -59,6 +59,13 @@ missing=() if [[ -n "$ONLY_SITE" ]]; then TARBALLS=("$CACHE_DIR/$ONLY_SITE.tar.gz") if [[ ! -f "${TARBALLS[0]}" ]]; then + # A build-generated site may legitimately carry no archive at all (its + # seed is produced by the Dockerfile), so there is nothing to download + # and nothing to extract — same exemption check_assets.sh/build.sh use. + if [[ -f "sites/$ONLY_SITE/.build-generated-seed" ]]; then + echo "[fetch] $ONLY_SITE: build-generated seed, no archive at this revision — nothing to fetch" + exit 0 + fi echo "fetch_assets: expected archive for $ONLY_SITE" >&2 exit 1 fi @@ -73,6 +80,9 @@ else site=$(basename "$site_dir") if [[ -f "$CACHE_DIR/$site.tar.gz" ]]; then TARBALLS+=("$CACHE_DIR/$site.tar.gz") + elif [[ -f "sites/$site/.build-generated-seed" ]]; then + # Seed comes from the Dockerfile, so no archive is expected. + echo "[fetch] $site: build-generated seed, no archive at this revision — skipping" else missing+=("$site") fi diff --git a/sites/berkeley/.build-generated-seed b/sites/berkeley/.build-generated-seed new file mode 100644 index 00000000..cd47496c --- /dev/null +++ b/sites/berkeley/.build-generated-seed @@ -0,0 +1 @@ +The Dockerfile generates instance_seed/berkeley.db deterministically from tracked source data; static/images/ ships from the pinned Hugging Face archive. diff --git a/sites/berkeley/.requires-images b/sites/berkeley/.requires-images new file mode 100644 index 00000000..e8dd3029 --- /dev/null +++ b/sites/berkeley/.requires-images @@ -0,0 +1,3 @@ +UC Berkeley requires its generated scene imagery and faculty avatars +(static/images/{campus,colleges,research,news,events,faculty}/) from the pinned +Hugging Face archive. diff --git a/sites/berkeley/README.md b/sites/berkeley/README.md new file mode 100644 index 00000000..32cb607f --- /dev/null +++ b/sites/berkeley/README.md @@ -0,0 +1,122 @@ +# UC Berkeley mirror + +Offline Flask mirror of `https://www.berkeley.edu/`. In the 30-site registry it is site index 29 and runs on container port `40029`. Every college, department, programme, faculty member, research centre, article, event and account is deterministic synthetic benchmark data; only the page chrome mirrors upstream. + +## Runtime + +```bash +uv venv .venv --python 3.12 +uv pip install --python .venv/bin/python Flask==3.1.0 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 \ + Flask-WTF==1.2.2 Flask-Bcrypt==1.0.1 email-validator==2.2.0 # the shared pins; no site-specific deps +./scripts/fetch_assets.sh berkeley # static/images/ from the HF tarball +cd sites/berkeley && PYTHONHASHSEED=0 ../../.venv/bin/python seed_data.py # writes instance_seed/berkeley.db +PORT=40029 ../../.venv/bin/python app.py +``` + +`.build-generated-seed` marks the **seed** as build-generated: the Docker build regenerates `instance_seed/berkeley.db` from the tracked `seed_data.py` (`cd /opt/WebSyn/berkeley && rm -rf instance instance_seed && PYTHONHASHSEED=0 python seed_data.py && rm -rf instance`) and `scripts/fetch_assets.sh` drops any `instance_seed/` it finds in the archive. The seed is byte-reproducible — md5 `3001bcf4bcec169f4192c08609160ab6`, identical under `PYTHONHASHSEED=0` and `=1` — because the four benchmark password hashes are precomputed bcrypt strings, every `created_at` is the frozen clock, and `User.email`/`User.username` carry `unique=True` without `index=True` (SQLAlchemy emits named indexes in set-iteration order, which moved SQLite root pages between runs). + +The **imagery** is the opposite: `.requires-images` marks `static/images/` as archive-shipped, so `scripts/check_assets.sh` treats an empty `static/images/` as a hard failure until `fetch_assets.sh` has run. The Docker build gates the bundle with `check_generated_assets.py` before generating the seed. + +## Frozen benchmark clock + +`app.py` defines `BENCHMARK_NOW = datetime(2026, 5, 12)` and uses it everywhere a date is compared or stamped (the `/events` upcoming/past/today filters, the homepage "Upcoming events" block, and the `created_at` / `published_date` column defaults via `utcnow()`). No request or seed path calls `datetime.utcnow()`, so the rendered site is identical on any run date; against the seeded calendar `/events` admits 52 of 64 rows, 15 of them Lecture. Reseeding with a different `now` means re-pinning the verifier contract in `verify/verify_lib.py` (schema hash, counts, catalog fingerprint). + +## Imagery — synthetic, and what that means + +164 generated files, all declared in `generated_asset_inventory.json` and gated by +`check_generated_assets.py`: + +| kind | files | pixels | how | +|---|---|---|---| +| `campus` | 8 | 1024×768 WebP | section heroes and banners | +| `college` | 14 | 1024×768 WebP | one per college; odd ids a building exterior, even ids an interior | +| `research` | 25 | 1024×768 WebP | one per research centre | +| `news` | 21 | 1024×768 WebP | 7 categories × 3 variants, variant = `article.id % 3 + 1` | +| `event` | 14 | 1024×768 WebP | 7 categories × 2 variants, variant = `event.id % 2 + 1` | +| `faculty` | 82 | 256×256 PNG | deterministic Pillow initials avatars | + +**Provenance.** The 82 scene photographs were generated on 2026-09-14 with +**fal.ai FLUX.1 [schnell]** (4 inference steps, one fixed seed per slot derived +from its slug) and converted to WebP q82. The 82 faculty avatars are **not** +model-generated: `scripts/gen_avatars.py` draws each one with Pillow 11.0.0 +from the faculty row's initials and id, so the bytes are reproducible with no +network and no RNG. + +**Prompt policy.** Every scene prompt is one subject clause plus a fixed style +suffix plus a fixed negative list — `no text, no lettering, no logos, no +watermarks, no faces, no portraits, no recognizable landmarks or signage, +photographic, natural light, no posters, no framed pictures, no screens with +visible content, unlabeled containers` — with `bright natural daylight` and +`plain blank walls and panels` in the style suffix. Subjects come from the seed +row the page already renders (name, description, and for centres the focus +areas); the exact prompt for each file is recorded in the inventory. Slots are +occupancy-classified: **labs and research interiors are prompted empty**; +campus, event and college-exterior slots permit only figures seen from behind or +at a distance. + +**No real people, no real places.** No image depicts a real person, a +recognisable face, or a real landmark: the campus scenes are generic +institutional architecture, specifically *not* the Campanile, Sather Gate, the +Golden Gate or any other identifiable Berkeley or Bay Area feature. The faculty +avatars are monogram discs, never likenesses. As with the seeded rows, the whole +site is labelled synthetic on every page. + +**Verification, and its limits.** `check_generated_assets.py` enforces exact +coverage (no missing, stale or undeclared file), per-file SHA-256, a full decode +at the planned dimensions, a letterbox test (three of the first 82 frames came +back with black bars), and — where `pytesseract` is present — an OCR pass. + +Neither pass is a guarantee, and the limits are measured rather than assumed. +The OCR pass has a positive control that proves it **can** fire on clean +rendered text, but it does not reliably detect legible text at all: it returned +zero tokens for the word `STCK` rendered in large red capitals across a window +in `colleges/chemistry.webp`, and missed a warning placard in +`research/bair.webp` entirely. The face pass is a *frontal-face* cascade, not a +person detector: the OpenCV defaults returned 15 false positives across five +clean pilot scenes (tree foliage, mown grass) and were replaced with settings +that score zero there and detect a generated face control — which is exactly +why it also misses the small, profile faces the no-faces rule cares about, and +why the `event` family was moved to unoccupied prompts. Tesseract also flagged +six clean scenes on three-character junk (`aif`, `hea`, `saks`) until the +confidence floor was raised. + +`scripts/IMAGE_PLAN.md` §9 records every measurement and every miss. +`tests/test_generated_assets.py` exercises both detectors against positive +controls, so a green run shows the detectors can fire — not that the bundle is +text- and face-free. What actually bounds text and faces is the prompt plus the +contact sheet reviewed by eye, which is what found `STCK`, the seated person, +the crowds with faces to camera, the framed portrait and the black bars. + +**Non-affiliation.** This is an unofficial offline benchmark mirror. The names, +descriptions and numbers are synthetic benchmark data, and the imagery is +machine-generated; nothing here is provided by, endorsed by, or affiliated with +the University of California, Berkeley or the Regents of the University of +California. + +**Regenerating.** `scripts/gen_images.py` (scenes; needs `FAL_KEY`) and +`scripts/gen_avatars.py` (avatars; offline). A slot whose bytes, prompt and +seed already match an accepted inventory row is skipped, so a partial re-run +costs only the missing calls. + +## Seeded rows + +| Model | Rows | Model | Rows | +|---|---|---|---| +| colleges | 14 | departments | 30 | +| programmes | 83 (25 PhD / 21 BA / 16 BS / 16 MS / MBA, JD, MEng, MD, MPH ×1; 17 GRE-required, 1 online) | faculty | 82 (19 EECS) | +| research centres | 25 | news articles | 121 (7 Athletics) | +| events | 64 (19 Lecture / 14 Career / …) | users | 4 | + +Benchmark accounts: `alice`, `bob`, `carol`, `dave` `@berkeley.edu`, password `test1234` (public by design; the hashes are hardcoded in `seed_data.py`). The `bookmarks` table starts empty, so the two stateful tasks bind their insert/delete ordering to the row ids the app assigns. + +## Routes + +`/`, `/news` (search + category + pagination), `/news/`, `/academics`, `/programs` (search, college and degree filters, pagination), `/programs/`, `/events` (category + upcoming/past/today), `/events/`, `/research`, `/research/`, `/departments`, `/departments/`, `/admissions`, `/about`, `/search` (programmes / news / events / faculty / centres), `/faculty` (name, interest and department filters), `/faculty/`, `/login`, `/register`, `/logout` (POST-only, CSRF-protected: a prefetching GET gets 405), `/account` (bookmarks), `/bookmark/add` (POST), `/bookmark/remove` (POST), `/_health`. + +Article detail, programme detail, event detail, faculty profiles and centre pages are pure reads: no GET path writes the database, so a read-only benchmark task's after-state always equals its initial snapshot. `sites/berkeley/tests/` holds the runnable checks: registry/seed integration, the answer-leak sweep (`test_answer_leaks.py`), the app-robustness suite (`test_app_robustness.py`) and the generated-asset gate (`test_generated_assets.py`, which verifies inventory count and hashes, that no undeclared file sits under `static/images/`, that every `` the templates render resolves to a file on disk, and that the OCR and face passes hold with their positive controls — skipping if the bundle has not been fetched). + +Every `` carries an explicit `width`/`height` and sits in an aspect-ratio or fixed-height box so nothing shifts as photos decode, and its `alt` is built from the same seed fields the page already renders — never a title, director, founding year or focus area, which are the graded answers on the detail pages. + +## Grading contract + +`sites/berkeley/verify/` holds the deterministic verifiers (one per `tasks.jsonl` row), the shared `verify_lib.py`/`ground_truth.py`, and `TASK_REVIEW.md` with the per-row ACCEPT/DROP/ADDED record. See `verify/README.md` for the snapshot contract and how to run them. diff --git a/sites/berkeley/_health.py b/sites/berkeley/_health.py new file mode 100644 index 00000000..f404286f --- /dev/null +++ b/sites/berkeley/_health.py @@ -0,0 +1,57 @@ +"""UC Berkeley mirror health check.""" +from healthcheck import random_user + + +def run(p): + # 1. Home page renders + p.assert_get('home', '/', must_contain='Berkeley') + + # 2. News list renders + p.assert_get('news list', '/news', must_contain='article') + + # 3. Programs list renders + p.assert_get('programs list', '/programs', must_contain='program') + + # 4. Faculty list renders + p.assert_get('faculty list', '/faculty', must_contain='Professor') + + # 5. Search returns results + p.assert_get('search', '/search?q=computer+science', must_contain='result') + + # 6. Register page renders with CSRF + user = random_user() + html = p.assert_get('register page', '/register', must_contain='csrf_token') + token = p.csrf(html) + if not token: + p.check('register csrf token', False, 'no csrf in register form') + return + + # 7. Submit registration + p.assert_post('register submit', '/register', { + 'csrf_token': token, + 'username': user['first_name'].lower() + user['last_name'].lower(), + 'full_name': user['name'], + 'email': user['email'], + 'password': user['password'], + 'confirm': user['password'], + }, accept_status=(200, 302, 303)) + + # Logout so login form is real + p.get('/logout') + + # 8. Login page renders + html = p.assert_get('login page', '/login', accept_status=(200, 302, 303)) + token = p.csrf(html) if html else '' + + # 9. Submit login + if token: + p.assert_post('login submit', '/login', { + 'csrf_token': token, + 'email': user['email'], + 'password': user['password'], + }, accept_status=(200, 302, 303)) + else: + p.check('login submit', True, 'already authenticated from register') + + # 10. Authenticated account page + p.assert_get('account page', '/account', must_contain=user['first_name']) diff --git a/sites/berkeley/app.py b/sites/berkeley/app.py new file mode 100644 index 00000000..4f8c0d8d --- /dev/null +++ b/sites/berkeley/app.py @@ -0,0 +1,965 @@ +#!/usr/bin/env python3 +"""UC Berkeley mirror — Flask application.""" +import os +import re +import secrets +import sys +from datetime import datetime +from math import ceil +from urllib.parse import urlsplit + +from flask import (Flask, render_template, request, redirect, url_for, + flash, jsonify, session, abort, g) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import event +from sqlalchemy.exc import IntegrityError +from sqlalchemy.engine import Engine +from flask_login import (LoginManager, UserMixin, login_user, logout_user, + login_required, current_user) +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from flask_bcrypt import Bcrypt +from wtforms import StringField, PasswordField, TextAreaField, SelectField +from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__) +# Repo convention (webmd_doctor / walmart_careers): env-provided secret or a +# per-process random key. Never a committed constant: with a known key anyone +# can sign their own session cookie and read /account without the password. +app.config['SECRET_KEY'] = os.environ.get('BERKELEY_SECRET_KEY') or secrets.token_hex(32) +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'berkeley.db')}") +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +# Explicit request-size cap on top of Flask's MAX_FORM_MEMORY_SIZE default; +# mirrors webmd_doctor. Every form on this site is a few KB. +app.config['MAX_CONTENT_LENGTH'] = 256 * 1024 +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + + +@event.listens_for(Engine, "connect") +def enable_sqlite_foreign_keys(connection, _record): + """SQLite defaults PRAGMA foreign_keys=0, so an orphan bookmark row is + accepted without complaint. The seeded schema declares the foreign keys; + turn enforcement on for every connection.""" + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to continue.' +login_manager.login_message_category = 'info' +csrf = CSRFProtect(app) + +PER_PAGE = 20 + +# ─── Benchmark clock ────────────────────────────────────────────────────────── +# The site must render identically on every run and on every day. The seeded +# event calendar is pinned (seed_data.py: `now = datetime(2026, 5, 12)`), so the +# app compares against that same frozen instant instead of the wall clock — +# otherwise every "upcoming" filter drains as the image ages (the seed's last +# event is 2026-07-16, after which /events rendered 0 of 0) and the task +# ground truth rots. Same pattern as sites/osu/app.py: BENCHMARK_NOW. +BENCHMARK_NOW = datetime(2026, 5, 12) + + +def utcnow(): + """Frozen stand-in for datetime.utcnow() — see BENCHMARK_NOW. + + Used as the column default for created_at / published_date so no request + path (register, bookmark_add) or seed path can stamp the wall clock into a + row, which would break byte-reproducibility of instance_seed. + """ + return BENCHMARK_NOW + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def slugify(text): + if not text: + return '' + s = re.sub(r'[^a-zA-Z0-9\s-]', '', text) + s = re.sub(r'[\s]+', '-', s.strip().lower()) + return s + + +def bounded_int(raw, maximum_digits=9): + """int() for all-digit strings within a fixed length; None otherwise. + + Query and form values reach SQLAlchemy as bound integers; an unbounded + conversion ('9' * 20) overflows SQLite's INTEGER and turns a normal 404 + path into a 500. Mirrors the webmd_doctor helper. + """ + raw = str(raw or '') + if not raw.isdigit() or len(raw) > maximum_digits: + return None + return int(raw) + + +MAX_PAGE = 10**4 + + +def page_arg(name='page'): + """A 1..MAX_PAGE page number; malformed or huge values fall back to 1.""" + value = bounded_int(request.args.get(name, ''), maximum_digits=6) + if value is None or value < 1: + return 1 + return min(value, MAX_PAGE) + + +def safe_next(raw): + """Same-origin relative redirect target or None. + + ``?next=https://evil.example/`` on /login (and the hidden ``next`` field on + the bookmark form) would otherwise bounce the browser off the mirror. + """ + if not raw or not isinstance(raw, str): + return None + if any(ord(char) < 32 or ord(char) == 127 for char in raw): + return None + if raw.startswith('//') or '\\' in raw: + return None + parsed = urlsplit(raw) + if parsed.scheme or parsed.netloc or not parsed.path.startswith('/'): + return None + return raw + +# ─── Models ─────────────────────────────────────────────────────────────────── + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + # Deliberately no `index=True` here. SQLAlchemy emits a table's named indexes + # in set-iteration order, so two indexes on one table are assigned different + # root pages from run to run (observed: ix_users_email / ix_users_username + # swapping pages 3 and 4), which breaks byte-reproducibility of the seed DB. + # unique=True already gives each column SQLite's implicit index, created in + # declaration order as part of CREATE TABLE. + email = db.Column(db.String(120), unique=True, nullable=False) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + full_name = db.Column(db.String(150), nullable=False, default='') + role = db.Column(db.String(30), default='student') + bio = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=utcnow) + + bookmarks = db.relationship('Bookmark', backref='user', lazy=True, + cascade='all, delete-orphan') + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode('utf-8') + + def check_password(self, pw): + return bcrypt.check_password_hash(self.password_hash, pw) + + +class College(db.Model): + __tablename__ = 'colleges' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + description = db.Column(db.Text, default='') + dean = db.Column(db.String(150), default='') + founded_year = db.Column(db.Integer, default=1868) + undergrad_count = db.Column(db.Integer, default=1000) + grad_count = db.Column(db.Integer, default=500) + dept_count = db.Column(db.Integer, default=10) + + departments = db.relationship('Department', backref='college', lazy=True) + programs = db.relationship('Program', backref='college', lazy=True) + research_centers = db.relationship('ResearchCenter', backref='college', lazy=True) + + +class Department(db.Model): + __tablename__ = 'departments' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=False) + description = db.Column(db.Text, default='') + chair = db.Column(db.String(150), default='') + phone = db.Column(db.String(30), default='') + location = db.Column(db.String(200), default='') + + faculty = db.relationship('Faculty', backref='department', lazy=True) + programs = db.relationship('Program', backref='department', lazy=True) + + +class Program(db.Model): + __tablename__ = 'programs' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + degree_type = db.Column(db.String(20), default='BA') + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=True) + department_id = db.Column(db.Integer, db.ForeignKey('departments.id'), nullable=True) + description = db.Column(db.Text, default='') + requirements = db.Column(db.Text, default='') + units = db.Column(db.Integer, default=120) + duration_years = db.Column(db.Float, default=4.0) + application_deadline = db.Column(db.String(80), default='') + is_online = db.Column(db.Boolean, default=False) + gre_required = db.Column(db.Boolean, default=False) + + +class NewsArticle(db.Model): + __tablename__ = 'news_articles' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + category = db.Column(db.String(50), default='Campus Life') + author = db.Column(db.String(150), default='Berkeley News Staff') + published_date = db.Column(db.DateTime, default=utcnow) + content = db.Column(db.Text, default='') + summary = db.Column(db.Text, default='') + tags = db.Column(db.String(500), default='') + view_count = db.Column(db.Integer, default=0) + featured = db.Column(db.Boolean, default=False) + + +class Event(db.Model): + __tablename__ = 'events' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(300), nullable=False) + description = db.Column(db.Text, default='') + start_datetime = db.Column(db.DateTime, nullable=False) + end_datetime = db.Column(db.DateTime, nullable=True) + location = db.Column(db.String(300), default='') + building = db.Column(db.String(200), default='') + category = db.Column(db.String(50), default='Lecture') + organizer = db.Column(db.String(200), default='') + registration_required = db.Column(db.Boolean, default=False) + cost = db.Column(db.String(50), default='Free') + url = db.Column(db.String(300), default='') + + +class ResearchCenter(db.Model): + __tablename__ = 'research_centers' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), unique=True, nullable=False, index=True) + description = db.Column(db.Text, default='') + director = db.Column(db.String(150), default='') + college_id = db.Column(db.Integer, db.ForeignKey('colleges.id'), nullable=True) + focus_areas = db.Column(db.String(500), default='') + url = db.Column(db.String(300), default='') + founded_year = db.Column(db.Integer, default=2000) + + +class Faculty(db.Model): + __tablename__ = 'faculty' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + title = db.Column(db.String(200), default='Professor') + department_id = db.Column(db.Integer, db.ForeignKey('departments.id'), nullable=True) + email = db.Column(db.String(120), default='') + office = db.Column(db.String(200), default='') + phone = db.Column(db.String(30), default='') + research_interests = db.Column(db.String(500), default='') + bio = db.Column(db.Text, default='') + is_emeritus = db.Column(db.Boolean, default=False) + + +class Bookmark(db.Model): + __tablename__ = 'bookmarks' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + item_type = db.Column(db.String(50), nullable=False) + item_id = db.Column(db.Integer, nullable=False) + note = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=utcnow) + + +# ─── Forms ──────────────────────────────────────────────────────────────────── + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + +class RegisterForm(FlaskForm): + username = StringField('Username', validators=[DataRequired(), Length(3, 80)]) + full_name = StringField('Full Name', validators=[DataRequired(), Length(2, 150)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired(), Length(8, 100)]) + confirm = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')]) + +class ProfileForm(FlaskForm): + full_name = StringField('Full Name', validators=[DataRequired(), Length(2, 150)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + bio = TextAreaField('Bio', validators=[Optional(), Length(max=1000)]) + +class BookmarkForm(FlaskForm): + item_type = StringField('Type', validators=[DataRequired()]) + item_id = StringField('ID', validators=[DataRequired()]) + note = TextAreaField('Note', validators=[Optional(), Length(max=500)]) + +# ─── Login Manager ──────────────────────────────────────────────────────────── + +@login_manager.user_loader +def load_user(user_id): + # A tampered session cookie with a non-numeric or out-of-range id must fail + # closed (anonymous), not raise int()/OverflowError into a 500. + value = bounded_int(user_id) + if value is None: + return None + return db.session.get(User, value) + +# ─── Generated imagery ──────────────────────────────────────────────────────── +# Slot names, sizes and subject policy live in scripts/IMAGE_PLAN.md; the +# files ship from the pinned Hugging Face tarball (.requires-images) and are +# declared in generated_asset_inventory.json, which check_generated_assets.py +# gates at build time. Every variant binds to the row's primary key, never to a +# render index, so a listing card and a detail banner agree on the same file and +# pagination cannot reselect it. +NEWS_CATEGORIES = ['Research', 'Campus Life', 'Faculty', 'Student', 'Athletics', + 'Science', 'Arts'] +# The seed carries one 'Academics' article outside this taxonomy; it uses the +# campus-life family (IMAGE_PLAN.md §6.2). +NEWS_FALLBACK_CATEGORY = 'Campus Life' +EVENT_CATEGORIES = ['Lecture', 'Sports', 'Arts', 'Career', 'Health', 'Social', 'Virtual'] +EVENT_FALLBACK_CATEGORY = 'Lecture' + + +def campus_image(scene): + return url_for('static', filename=f'images/campus/{scene}.webp') + + +def college_image(college): + return url_for('static', filename=f'images/colleges/{college.slug}.webp') + + +def centre_image(centre): + return url_for('static', filename=f'images/research/{centre.slug}.webp') + + +def faculty_image(member): + return url_for('static', filename=f'images/faculty/{member.slug}.png') + + +def dept_image(dept): + """A department shows its parent college's scene (IMAGE_PLAN.md §6.1).""" + if dept.college: + return college_image(dept.college) + return campus_image('campus-lecture-hall') + + +def article_image(article): + category = (article.category if article.category in NEWS_CATEGORIES + else NEWS_FALLBACK_CATEGORY) + return url_for('static', + filename=f'images/news/{slugify(category)}-{(article.id % 3) + 1}.webp') + + +def event_image(event): + category = (event.category if event.category in EVENT_CATEGORIES + else EVENT_FALLBACK_CATEGORY) + return url_for('static', + filename=f'images/events/{slugify(category)}-{(event.id % 2) + 1}.webp') + + +# ─── Context Processors ─────────────────────────────────────────────────────── + +@app.context_processor +def inject_globals(): + return { + 'now': BENCHMARK_NOW, + 'colleges': College.query.order_by(College.name).all(), + 'article_image': article_image, + 'event_image': event_image, + 'centre_image': centre_image, + 'college_image': college_image, + 'faculty_image': faculty_image, + 'campus_image': campus_image, + 'dept_image': dept_image, + } + +# ─── Routes ─────────────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + featured_news = NewsArticle.query.filter_by(featured=True).order_by( + NewsArticle.published_date.desc()).limit(6).all() + if len(featured_news) < 3: + featured_news = NewsArticle.query.order_by( + NewsArticle.published_date.desc()).limit(6).all() + upcoming_events = Event.query.filter( + Event.start_datetime >= BENCHMARK_NOW + ).order_by(Event.start_datetime).limit(4).all() + recent_research = ResearchCenter.query.order_by(ResearchCenter.name).limit(4).all() + stats = { + 'nobel_laureates': 12, + 'top_10_programs': 50, + 'varsity_sports': 30, + 'national_titles': 105, + 'faculty_count': 1629, + 'undergrad_count': 31800, + 'grad_count': 12000, + 'degree_programs': 350, + } + return render_template('index.html', + featured_news=featured_news, + upcoming_events=upcoming_events, + recent_research=recent_research, + stats=stats) + + +@app.route('/news') +def news(): + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + featured = request.args.get('featured', '') + page = page_arg() + + query = NewsArticle.query + if q: + query = query.filter( + db.or_( + NewsArticle.title.ilike(f'%{q}%'), + NewsArticle.summary.ilike(f'%{q}%'), + NewsArticle.content.ilike(f'%{q}%'), + NewsArticle.tags.ilike(f'%{q}%'), + )) + if category: + query = query.filter(NewsArticle.category == category) + if featured == '1': + query = query.filter(NewsArticle.featured == True) + + query = query.order_by(NewsArticle.published_date.desc()) + total = query.count() + articles = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + categories = ['Research', 'Campus Life', 'Faculty', 'Student', 'Athletics', + 'Science', 'Arts'] + return render_template('news.html', + articles=articles, + total=total, + page=page, + total_pages=total_pages, + categories=categories, + current_category=category, + q=q, + featured=featured) + + +@app.route('/news/') +def news_article(slug): + article = NewsArticle.query.filter_by(slug=slug).first_or_404() + # Deliberately no view_count increment: this GET is a pure read. Bumping the + # counter made every article visit a DB write, which broke the read-only grading + # contract (a read-only task's after-state could never equal its initial snapshot) + # and the byte-identical reset invariant (instance/ diverges from instance_seed + # as soon as an agent opens one article). The column is kept and is displayed as + # "N views" on /news and the article page; those numbers are the frozen seed + # values, and nothing orders or filters by them. + related = NewsArticle.query.filter( + NewsArticle.category == article.category, + NewsArticle.id != article.id + ).order_by(NewsArticle.published_date.desc()).limit(3).all() + return render_template('news_article.html', article=article, related=related) + + +@app.route('/academics') +def academics(): + colleges = College.query.order_by(College.name).all() + total_programs = Program.query.count() + total_depts = Department.query.count() + return render_template('academics.html', + colleges=colleges, + total_programs=total_programs, + total_depts=total_depts) + + +@app.route('/programs') +def programs(): + q = request.args.get('q', '').strip() + college_slug = request.args.get('college', '') + degree = request.args.get('degree', '') + page = page_arg() + + query = Program.query + if q: + query = query.filter( + db.or_( + Program.name.ilike(f'%{q}%'), + Program.description.ilike(f'%{q}%'), + )) + if college_slug: + col = College.query.filter_by(slug=college_slug).first() + if col: + query = query.filter(Program.college_id == col.id) + if degree: + query = query.filter(Program.degree_type == degree) + + query = query.order_by(Program.name) + total = query.count() + progs = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + all_colleges = College.query.order_by(College.name).all() + degree_types = ['BA', 'BS', 'MA', 'MS', 'PhD', 'MPH', 'MBA', 'MEng', 'JD', 'MD'] + return render_template('programs.html', + programs=progs, + total=total, + page=page, + total_pages=total_pages, + all_colleges=all_colleges, + degree_types=degree_types, + current_college=college_slug, + current_degree=degree, + q=q) + + +@app.route('/programs/') +def program_detail(slug): + program = Program.query.filter_by(slug=slug).first_or_404() + related = Program.query.filter( + Program.college_id == program.college_id, + Program.id != program.id + ).order_by(Program.name).limit(4).all() + return render_template('program_detail.html', program=program, related=related) + + +@app.route('/events') +def events(): + q = request.args.get('q', '').strip() + category = request.args.get('category', '') + date_filter = request.args.get('date', 'upcoming') + page = page_arg() + now = BENCHMARK_NOW + + query = Event.query + if q: + query = query.filter( + db.or_( + Event.title.ilike(f'%{q}%'), + Event.description.ilike(f'%{q}%'), + Event.location.ilike(f'%{q}%'), + Event.organizer.ilike(f'%{q}%'), + )) + if category: + query = query.filter(Event.category == category) + if date_filter == 'upcoming': + query = query.filter(Event.start_datetime >= now) + query = query.order_by(Event.start_datetime) + elif date_filter == 'past': + query = query.filter(Event.start_datetime < now) + query = query.order_by(Event.start_datetime.desc()) + elif date_filter == 'today': + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = now.replace(hour=23, minute=59, second=59) + query = query.filter(Event.start_datetime.between(today_start, today_end)) + query = query.order_by(Event.start_datetime) + else: + query = query.order_by(Event.start_datetime) + + total = query.count() + evts = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + categories = ['Lecture', 'Sports', 'Arts', 'Career', 'Health', 'Social', 'Virtual'] + return render_template('events.html', + events=evts, + total=total, + page=page, + total_pages=total_pages, + categories=categories, + current_category=category, + date_filter=date_filter, + q=q) + + +@app.route('/events/') +def event_detail(event_id): + if not 0 < event_id < 2**31: + # A 20-digit id would overflow SQLite's INTEGER and raise a 500. + abort(404) + event = db.session.get(Event, event_id) + if event is None: + abort(404) + related = Event.query.filter( + Event.category == event.category, + Event.id != event.id, + Event.start_datetime >= BENCHMARK_NOW + ).order_by(Event.start_datetime).limit(3).all() + return render_template('event_detail.html', event=event, related=related) + + +@app.route('/research') +def research(): + centers = ResearchCenter.query.order_by(ResearchCenter.name).all() + colleges = College.query.order_by(College.name).all() + return render_template('research.html', centers=centers, colleges=colleges) + + +@app.route('/research/') +def research_center(slug): + center = ResearchCenter.query.filter_by(slug=slug).first_or_404() + related = ResearchCenter.query.filter( + ResearchCenter.college_id == center.college_id, + ResearchCenter.id != center.id + ).order_by(ResearchCenter.name).limit(3).all() + return render_template('research_center.html', center=center, related=related) + + +@app.route('/departments') +def departments(): + colleges = College.query.order_by(College.name).all() + depts_by_college = {} + for college in colleges: + depts_by_college[college] = Department.query.filter_by( + college_id=college.id).order_by(Department.name).all() + return render_template('departments.html', depts_by_college=depts_by_college) + + +@app.route('/departments/') +def department_detail(slug): + dept = Department.query.filter_by(slug=slug).first_or_404() + faculty_list = Faculty.query.filter_by(department_id=dept.id).order_by(Faculty.name).all() + programs = Program.query.filter_by( + department_id=dept.id).order_by(Program.name).all() + return render_template('department_detail.html', + dept=dept, + faculty_list=faculty_list, + programs=programs) + + +@app.route('/admissions') +def admissions(): + undergrad_programs = Program.query.filter( + Program.degree_type.in_(['BA', 'BS']) + ).count() + grad_programs = Program.query.filter( + Program.degree_type.in_(['MA', 'MS', 'PhD', 'MPH', 'MBA', 'MEng', 'JD', 'MD']) + ).count() + return render_template('admissions.html', + undergrad_programs=undergrad_programs, + grad_programs=grad_programs) + + +@app.route('/about') +def about(): + stats = { + 'nobel_laureates': 12, + 'top_10_programs': 50, + 'varsity_sports': 30, + 'national_titles': 105, + 'faculty_count': 1629, + 'undergrad_count': 31800, + 'grad_count': 12000, + 'degree_programs': 350, + 'founded': 1868, + 'acres': 1232, + 'libraries': 32, + 'alumni': 600000, + } + return render_template('about.html', stats=stats) + + +@app.route('/search') +def search(): + q = request.args.get('q', '').strip() + results = {'programs': [], 'news': [], 'events': [], 'faculty': [], + 'research': []} + total = 0 + if q: + results['programs'] = Program.query.filter( + db.or_( + Program.name.ilike(f'%{q}%'), + Program.description.ilike(f'%{q}%'), + )).order_by(Program.name).limit(10).all() + results['news'] = NewsArticle.query.filter( + db.or_( + NewsArticle.title.ilike(f'%{q}%'), + NewsArticle.summary.ilike(f'%{q}%'), + NewsArticle.content.ilike(f'%{q}%'), + NewsArticle.tags.ilike(f'%{q}%'), + )).order_by(NewsArticle.published_date.desc()).limit(10).all() + results['events'] = Event.query.filter( + db.or_( + Event.title.ilike(f'%{q}%'), + Event.description.ilike(f'%{q}%'), + Event.location.ilike(f'%{q}%'), + Event.organizer.ilike(f'%{q}%'), + )).order_by(Event.start_datetime).limit(10).all() + results['faculty'] = Faculty.query.filter( + db.or_( + Faculty.name.ilike(f'%{q}%'), + Faculty.research_interests.ilike(f'%{q}%'), + Faculty.title.ilike(f'%{q}%'), + Faculty.bio.ilike(f'%{q}%'), + )).order_by(Faculty.name).limit(10).all() + results['research'] = ResearchCenter.query.filter( + db.or_( + ResearchCenter.name.ilike(f'%{q}%'), + ResearchCenter.description.ilike(f'%{q}%'), + ResearchCenter.focus_areas.ilike(f'%{q}%'), + )).order_by(ResearchCenter.name).limit(10).all() + total = sum(len(v) for v in results.values()) + return render_template('search.html', q=q, results=results, total=total) + + +@app.route('/faculty') +def faculty(): + q = request.args.get('q', '').strip() + dept_slug = request.args.get('dept', '') + page = page_arg() + + query = Faculty.query + if q: + query = query.filter( + db.or_( + Faculty.name.ilike(f'%{q}%'), + Faculty.research_interests.ilike(f'%{q}%'), + Faculty.title.ilike(f'%{q}%'), + )) + if dept_slug: + dept = Department.query.filter_by(slug=dept_slug).first() + if dept: + query = query.filter(Faculty.department_id == dept.id) + + query = query.order_by(Faculty.name) + total = query.count() + faculty_list = query.offset((page - 1) * PER_PAGE).limit(PER_PAGE).all() + total_pages = ceil(total / PER_PAGE) if total else 1 + + all_depts = Department.query.order_by(Department.name).all() + return render_template('faculty.html', + faculty_list=faculty_list, + total=total, + page=page, + total_pages=total_pages, + all_depts=all_depts, + current_dept=dept_slug, + q=q) + + +@app.route('/faculty/') +def faculty_profile(slug): + member = Faculty.query.filter_by(slug=slug).first_or_404() + colleagues = [] + if member.department_id: + colleagues = Faculty.query.filter( + Faculty.department_id == member.department_id, + Faculty.id != member.id + ).order_by(Faculty.name).limit(5).all() + return render_template('faculty_profile.html', member=member, colleagues=colleagues) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower().strip()).first() + if user and user.check_password(form.password.data): + login_user(user) + next_page = safe_next(request.args.get('next')) + flash('Welcome back!', 'success') + return redirect(next_page or url_for('index')) + flash('Invalid email or password.', 'danger') + return render_template('login.html', form=form) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + if User.query.filter_by(email=form.email.data.lower().strip()).first(): + flash('Email already registered.', 'danger') + elif User.query.filter_by(username=form.username.data.strip()).first(): + flash('Username already taken.', 'danger') + else: + user = User( + email=form.email.data.lower().strip(), + username=form.username.data.strip(), + full_name=form.full_name.data.strip(), + ) + user.set_password(form.password.data) + db.session.add(user) + try: + db.session.commit() + except IntegrityError: + # Concurrent duplicate registration: the unique constraint won; + # roll back so the session stays usable and re-render the form. + db.session.rollback() + flash('Email already registered.', 'danger') + else: + login_user(user) + flash('Account created! Welcome to UC Berkeley.', 'success') + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/logout', methods=['POST']) +@login_required +def logout(): + # POST-only: a prefetcher (or any GET crawler) must not be able to end a + # session; GET/HEAD now answer 405. + logout_user() + flash('You have been logged out.', 'info') + return redirect(url_for('index')) + + +@app.route('/account') +@login_required +def account(): + bookmarks = Bookmark.query.filter_by(user_id=current_user.id).order_by( + Bookmark.created_at.desc()).all() + bookmark_details = [] + for bm in bookmarks: + detail = {'bookmark': bm, 'item': None, 'title': '', 'url': '#'} + if bm.item_type == 'program': + item = db.session.get(Program, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('program_detail', slug=item.slug) + elif bm.item_type == 'news': + item = db.session.get(NewsArticle, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.title + detail['url'] = url_for('news_article', slug=item.slug) + elif bm.item_type == 'event': + item = db.session.get(Event, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.title + detail['url'] = url_for('event_detail', event_id=item.id) + elif bm.item_type == 'faculty': + item = db.session.get(Faculty, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('faculty_profile', slug=item.slug) + elif bm.item_type == 'research': + item = db.session.get(ResearchCenter, bm.item_id) + if item: + detail['item'] = item + detail['title'] = item.name + detail['url'] = url_for('research_center', slug=item.slug) + bookmark_details.append(detail) + return render_template('account.html', bookmark_details=bookmark_details) + + +BOOKMARK_TYPES = { + 'program': Program, + 'news': NewsArticle, + 'event': Event, + 'faculty': Faculty, + 'research': ResearchCenter, +} + + +@app.route('/bookmark/add', methods=['POST']) +@login_required +def bookmark_add(): + # An empty or invalid submission must fail loudly (400), never redirect as + # if something was saved: `item_type` is a closed vocabulary and the target + # row must exist, otherwise a bogus row lands in the bookmarks table. + item_type = request.form.get('item_type', '') + item_id = bounded_int(request.form.get('item_id', '')) + note = request.form.get('note', '')[:500] + model = BOOKMARK_TYPES.get(item_type) + if model is None or not item_id: + abort(400) + if db.session.get(model, item_id) is None: + abort(404) + existing = Bookmark.query.filter_by( + user_id=current_user.id, item_type=item_type, item_id=item_id + ).first() + if not existing: + bm = Bookmark(user_id=current_user.id, item_type=item_type, + item_id=item_id, note=note) + db.session.add(bm) + db.session.commit() + flash('Saved to bookmarks.', 'success') + else: + flash('Already bookmarked.', 'info') + next_url = (safe_next(request.form.get('next')) or safe_next(request.referrer) + or url_for('account')) + return redirect(next_url) + + +@app.route('/bookmark/remove', methods=['POST']) +@login_required +def bookmark_remove(): + bookmark_id = bounded_int(request.form.get('bookmark_id', '')) + if bookmark_id is None: + abort(400) + # Scoped to the signed-in user: another user's id is a 404, not a no-op. + bm = Bookmark.query.filter_by(id=bookmark_id, user_id=current_user.id).first_or_404() + db.session.delete(bm) + db.session.commit() + flash('Bookmark removed.', 'info') + return redirect(safe_next(request.referrer) or url_for('account')) + + +@app.route('/_health') +def health(): + try: + college_count = College.query.count() + program_count = Program.query.count() + return jsonify({ + 'status': 'ok', + 'site': 'berkeley', + 'colleges': college_count, + 'programs': program_count, + }) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + + +# ─── Startup ────────────────────────────────────────────────────────────────── + +def bootstrap_site(): + """Create the DB and seed it if it is empty. + + Importing this module seeds, so `from app import app` still materializes an + empty instance/ (site_runner.py and the Dockerfile's generator both rely on + that). On a populated DB — the shipped instance_seed copy, i.e. every boot + and every /reset — seed() returns at its College gate before touching a + session, leaving the file byte-identical. + """ + from seed_data import seed + with app.app_context(): + db.create_all() + seed() + + +# `python app.py` loads this file as __main__; register it under its import name +# too so seed_data's `from app import ...` reuses this module instead of building +# a second Flask app + SQLAlchemy instance. +sys.modules.setdefault('app', sys.modules[__name__]) + +if os.environ.get('WEBSYN_SKIP_BOOTSTRAP') != '1': + bootstrap_site() + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', '40029')) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/berkeley/check_generated_assets.py b/sites/berkeley/check_generated_assets.py new file mode 100644 index 00000000..f831de71 --- /dev/null +++ b/sites/berkeley/check_generated_assets.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Validate the generated UC Berkeley image bundle (scene photography + avatars). + +The 164 files ship through the pinned Hugging Face tarball: 82 FLUX.1 [schnell] +scene images at 1024x768 WebP and 82 deterministic Pillow initials avatars at +256x256 PNG, all declared in ``generated_asset_inventory.json``. + +This checker enforces exact coverage (no missing, extra, stale or corrupt +file), extension/kind agreement, per-file size + SHA-256 equality against the +inventory, and a full decode of every file at its planned dimensions. It runs +in the Docker build and from ``scripts/check_assets.sh``, mirroring the +webmd_doctor / compass / walmart_careers asset gates. + +An OCR pass over the scene images runs when ``pytesseract`` is importable and is +reported as untested otherwise. It is a *large-legible-text* detector, not a +"contains no text" detector — see ``scripts/IMAGE_PLAN.md`` §9.1 for the +calibration, including what it is known to miss. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path, PurePosixPath + +SITE = Path(__file__).resolve().parent +# Committed test fixture: a frontal portrait generated by the same FLUX model +# with a deliberately face-forward prompt, used to prove the detector can fire. +# Not a real person's photograph. +FACE_CONTROL = SITE / "tests" / "face_control.png" +FACE_SCALE_FACTOR = 1.1 +FACE_MIN_NEIGHBOURS = 10 +FACE_MIN_SIZE = (40, 40) +# A provider can return a 16:9 frame inside a 4:3 canvas — black bars top and +# bottom that look wrong in a card. Three of the first 82 scenes came back that +# way, so it is a checked defect, not a surprise. +LETTERBOX_BAND = 0.02 +LETTERBOX_DARK = 40 + +# kind -> (managed root, extension, (width, height)) +KINDS = { + "campus": ("static/images/campus", ".webp", (1024, 768)), + "college": ("static/images/colleges", ".webp", (1024, 768)), + "research": ("static/images/research", ".webp", (1024, 768)), + "news": ("static/images/news", ".webp", (1024, 768)), + "event": ("static/images/events", ".webp", (1024, 768)), + "faculty": ("static/images/faculty", ".png", (256, 256)), +} +MANAGED_ROOTS = tuple(root for root, _suffix, _size in KINDS.values()) +SCENE_ROOTS = tuple(root for kind, (root, _s, _z) in KINDS.items() if kind != "faculty") + +# Large-legible-text gate (IMAGE_PLAN.md §9.1). Calibrated on the bundle: at +# (3 chars, confidence 40) clean scenes yield up to seven junk fragments, all of +# them three characters, so the floor sits at 4 chars / confidence 50. +OCR_MIN_LENGTH = 4 +OCR_MIN_CONFIDENCE = 50 +OCR_MAX_TOKENS = 2 + + +def ocr_tokens(path: Path) -> list[str] | None: + """Distinct alphabetic tokens at the calibrated floor; None when untestable. + + A large-legible-text detector, NOT a "contains no text" detector — see + IMAGE_PLAN.md §9.1 for the calibration and its known blind spot. + """ + try: + import pytesseract + from PIL import Image + except ImportError: + return None + try: + image = Image.open(path).convert("RGB") + found: set[str] = set() + for psm in (3, 11): + try: + data = pytesseract.image_to_data( + image, config=f"--psm {psm}", output_type=pytesseract.Output.DICT) + except Exception: + continue + for index, raw in enumerate(data.get("text", [])): + token = (raw or "").strip() + if len(token) < OCR_MIN_LENGTH or not token.isalpha(): + continue + try: + confidence = float(data["conf"][index]) + except (TypeError, ValueError): + continue + if confidence >= OCR_MIN_CONFIDENCE: + found.add(token.lower()) + return sorted(found) + except Exception: + return None + + +def letterboxed(path: Path) -> tuple[float, float] | None: + """Mean brightness of the top and bottom bands; None when NumPy is absent. + + A frame is letterboxed when both bands are near-black, i.e. the provider + drew a wider aspect inside the 4:3 canvas. + """ + try: + import numpy as np + from PIL import Image + except ImportError: + return None + try: + with Image.open(path) as handle: + handle.load() + pixels = np.asarray(handle.convert("RGB")).astype(int) + band = max(1, int(pixels.shape[0] * LETTERBOX_BAND)) + top = float(pixels[:band].mean()) + bottom = float(pixels[-band:].mean()) + return top, bottom + except Exception: + return None + + +def is_letterboxed(path: Path) -> bool | None: + bands = letterboxed(path) + if bands is None: + return None + top, bottom = bands + return top < LETTERBOX_DARK and bottom < LETTERBOX_DARK + + +def detect_faces(path: Path) -> list[tuple[int, int, int, int]] | None: + """Frontal-face boxes; None when OpenCV is unavailable. + + A coarse tripwire, not a person detector: calibrated to keep clean scenes at + zero boxes, which also makes it miss small and profile faces — see + IMAGE_PLAN.md §9.2 for the measured false-positive / false-negative trade-off. + """ + try: + import cv2 + import numpy as np + from PIL import Image + except ImportError: + return None + try: + cascade = cv2.CascadeClassifier( + Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml") + if cascade.empty(): + return None + with Image.open(path) as handle: + handle.load() + grey = cv2.cvtColor(np.asarray(handle.convert("RGB")), cv2.COLOR_RGB2GRAY) + return [(int(x), int(y), int(w), int(h)) + for (x, y, w, h) in cascade.detectMultiScale( + grey, scaleFactor=FACE_SCALE_FACTOR, + minNeighbors=FACE_MIN_NEIGHBOURS, minSize=FACE_MIN_SIZE)] + except Exception: + return None + + +def verify(ocr: bool = False) -> int: + manifest = json.loads((SITE / "generated_asset_inventory.json").read_text()) + rows = manifest.get("assets") + if manifest.get("schema_version") != 1 or not isinstance(rows, list): + raise ValueError("unsupported generated asset inventory") + if manifest.get("asset_count") != len(rows): + raise ValueError("asset_count does not match the number of rows") + + expected: set[str] = set() + for row in rows: + kind = row.get("kind") + if kind not in KINDS: + raise ValueError(f"unknown generated asset kind: {kind!r}") + root, suffix, _size = KINDS[kind] + relative = PurePosixPath(row["path"]) + if (relative.is_absolute() or ".." in relative.parts + or not row["path"].startswith(root + "/") + or not row["path"].endswith(suffix)): + raise ValueError(f"unsafe generated asset path: {row['path']!r}") + if row["path"] in expected: + raise ValueError(f"duplicate inventory path: {row['path']!r}") + if not row.get("model"): + raise ValueError(f"generated asset row lacks provenance: {row['path']!r}") + if kind != "faculty" and not row.get("prompt"): + raise ValueError(f"scene row lacks its prompt: {row['path']!r}") + expected.add(row["path"]) + + actual = { + path.relative_to(SITE).as_posix() + for root in MANAGED_ROOTS + for path in (SITE / root).rglob("*") + if path.is_file() and path.name != ".gitkeep" + } + if actual != expected: + raise ValueError( + "generated asset mismatch: " + f"missing={sorted(expected - actual)[:5]} extra={sorted(actual - expected)[:5]}" + ) + + try: + from PIL import Image # optional: full decode only where Pillow exists + except ImportError: + Image = None + + for row in rows: + path = SITE / row["path"] + data = path.read_bytes() + if len(data) != row["bytes"] or hashlib.sha256(data).hexdigest() != row["sha256"]: + raise ValueError(f"generated asset hash mismatch: {row['path']}") + _root, _suffix, size = KINDS[row["kind"]] + if Image is not None: + with Image.open(path) as image: + image.load() + if image.size != size: + raise ValueError( + f"wrong dimensions for {row['path']}: {image.size} != {size}") + if row["kind"] != "faculty" and is_letterboxed(path): + raise ValueError(f"letterboxed frame (black bars top and bottom): {row['path']}") + elif not data.startswith(b"\x89PNG\r\n\x1a\n") and data[:4] != b"RIFF": + raise ValueError(f"unrecognised image signature: {row['path']}") + + if ocr: + checked = 0 + missed: list[str] = [] + for row in rows: + if row["kind"] == "faculty": + continue + tokens = ocr_tokens(SITE / row["path"]) + if tokens is None: + print("[check] ocr: not tested (pytesseract or its binary is absent)") + break + checked += 1 + if len(tokens) > OCR_MAX_TOKENS: + missed.append(f"{row['path']} ({len(tokens)} tokens: {tokens[:4]})") + else: + if missed: + raise ValueError("legible text detected in generated scenes: " + + "; ".join(missed[:5])) + print(f"[check] ocr: {checked} scene(s) checked, no large legible text") + return len(rows) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ocr", action="store_true", + help="also run the OCR pass (slow: ~10 min over 82 scenes; the " + "build gate leaves it off and tests/test_generated_assets.py " + "is where it runs)") + args = parser.parse_args() + count = verify(ocr=args.ocr) + if not args.ocr: + print("[check] ocr: skipped by default (pass --ocr to run it; the permanent " + "pass lives in tests/test_generated_assets.py)") + print(f"[check] verified {count} generated UC Berkeley assets") diff --git a/sites/berkeley/generated_asset_inventory.json b/sites/berkeley/generated_asset_inventory.json new file mode 100644 index 00000000..ba4cfbda --- /dev/null +++ b/sites/berkeley/generated_asset_inventory.json @@ -0,0 +1,2058 @@ +{ + "schema_version": 1, + "asset_count": 164, + "total_bytes": 6958963, + "note": "Synthetic imagery: scenes generated with fal-ai/flux/schnell (4 inference steps, per-slot seed derived from the slug) and faculty initials avatars drawn deterministically with Pillow 11.0.0. No text, no logos, no watermarks, no faces, no posters, no real landmarks, no real people. Regenerate with scripts/gen_images.py + scripts/gen_avatars.py; ships via the pinned Hugging Face tarball.", + "assets": [ + { + "path": "static/images/campus/campus-admissions.webp", + "kind": "campus", + "source_row": "campus-admissions", + "model": "fal-ai/flux/schnell", + "prompt": "a sunlit campus gateway path lined with trees leading toward generic institutional buildings, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2555903186, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 210122, + "sha256": "482069c0319259cc86cd346ffe5e54fb09ed042f62668ac8791082bdb767a9a4", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-events-green.webp", + "kind": "campus", + "source_row": "campus-events-green", + "model": "fal-ai/flux/schnell", + "prompt": "an open campus green with a small outdoor stage and rows of folding chairs, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2716603882, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 187168, + "sha256": "903808bee63c9011926c1ff76e4993e288bfc123b170ce7df0c5792caf582a5e", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-faculty-office.webp", + "kind": "campus", + "source_row": "campus-faculty-office", + "model": "fal-ai/flux/schnell", + "prompt": "a faculty office with bare shelves, a writing desk and a window onto trees, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1559579526, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 42488, + "sha256": "ddc8a87ad1d302ba38ba01686116cbb5a2807e0ce4ee57644ebadd07fda721a5", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-labs.webp", + "kind": "campus", + "source_row": "campus-labs", + "model": "fal-ai/flux/schnell", + "prompt": "a modern research laboratory interior with fume hoods and analytical instruments, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1381186772, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 71148, + "sha256": "8610a8a35e45b68bfeb6df5dc02d57491f0edd9811623f0a67d2fe30c14e86f6", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-lecture-hall.webp", + "kind": "campus", + "source_row": "campus-lecture-hall", + "model": "fal-ai/flux/schnell", + "prompt": "a tiered lecture hall with wooden seats and a large bare chalkboard, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 96382925, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 76402, + "sha256": "aaccfd632e34e69841d6f0379f17e795876114a5d911ed1d6298325b6d2c6eaf", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-library.webp", + "kind": "campus", + "source_row": "campus-library", + "model": "fal-ai/flux/schnell", + "prompt": "a university library reading room with tall windows, long tables and green reading lamps, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1015795384, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 72370, + "sha256": "d2f04c11f77da8286cda97578116db54cc058442b7ac6f476d55eba349e951c5", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/campus/campus-newsroom.webp", + "kind": "campus", + "source_row": "campus-newsroom", + "model": "fal-ai/flux/schnell", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)", + "prompt": "a university newsroom of completely bare desks with dark blank monitors, an entirely empty floor and completely bare walls, nothing pinned or taped to the walls, no whiteboards, no boxes, no crates, no packaging and no printed material anywhere in the scene, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1737621081, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 54176, + "sha256": "e7546ec9a264eb2fee4a2a65f51b9f8779044230cc9c2f3c8a745d0aa4efe4cb" + }, + { + "path": "static/images/campus/campus-quad.webp", + "kind": "campus", + "source_row": "campus-quad", + "model": "fal-ai/flux/schnell", + "prompt": "a wide grassy quad framed by generic institutional university buildings and mature trees, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3374827268, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 188548, + "sha256": "6b50c48048eb1fade1c7cbfd624ac28bf415c390d808cd42e4b46d0d88880afb", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/chemistry.webp", + "kind": "college", + "source_row": "chemistry", + "model": "fal-ai/flux/schnell", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)", + "prompt": "College of Chemistry at an unnamed American public research university: The College of Chemistry is the oldest professional school at Berkeley, and consistently ranks among the top chemistry and chemical engineering programs in the, a spotless empty chemistry laboratory with completely bare stainless steel benches, nothing on the benches, no glassware, no bottles, no containers, no trays and no boxes to carry a label, plain unmarked windows and plain bare walls, no whiteboards, no chalkboards, no notice boards and nothing mounted on the walls on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3109562594, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 67436, + "sha256": "81d2baef82ee0ad02651c6dba4c9c4ea007ba39584e188682179efc9fcc02906" + }, + { + "path": "static/images/colleges/education.webp", + "kind": "college", + "source_row": "education", + "model": "fal-ai/flux/schnell", + "prompt": "Graduate School of Education at an unnamed American public research university: Berkeley's Graduate School of Education advances educational equity and excellence through research, practice, and policy. Faculty and students work to, education school entrance with a wide low staircase on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2185495392, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 64200, + "sha256": "c4bf5d9b43fad4d663d71ae35be773de2f85a1661771ba8c5877577294d8788a", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/engineering.webp", + "kind": "college", + "source_row": "engineering", + "model": "fal-ai/flux/schnell", + "prompt": "College of Engineering at an unnamed American public research university: One of the top engineering schools in the world, Berkeley Engineering offers programs that combine rigorous technical training with broad interdisciplinary, engineering workshop with long benches and bare walls on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3920102967, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 36446, + "sha256": "93a17107a0cfe5bb70b505deae91e5e824c66d0f9d03c8261bf67c7dc5717243", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/environmental-design.webp", + "kind": "college", + "source_row": "environmental-design", + "model": "fal-ai/flux/schnell", + "prompt": "College of Environmental Design at an unnamed American public research university: The College of Environmental Design prepares students to shape the built and natural environment through architecture, landscape architecture, city planning, design studio with drafting tables and bare partitions, no wall art, no framed pictures, no posters, no books with visible spines on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1415079077, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 71856, + "sha256": "d82850cc059b28f325bf99abc836495ccaa7d52f0ec62ba5c6f69dbecd6078ea", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/haas-business.webp", + "kind": "college", + "source_row": "haas-business", + "model": "fal-ai/flux/schnell", + "prompt": "Haas School of Business at an unnamed American public research university: The Haas School of Business develops leaders who redefine how we do business. Ranked among the top business schools globally, Haas emphasizes innovation, business school courtyard with clean modern paving on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2217313907, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 71316, + "sha256": "9713e65c530acf171ee308606f2bff5483ef1876cbb08321eebb65ed87c52d8d", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/information.webp", + "kind": "college", + "source_row": "information", + "model": "fal-ai/flux/schnell", + "prompt": "School of Information at an unnamed American public research university: The School of Information is dedicated to creating and sharing knowledge that serves individuals, organizations, and communities in an information-intensive, glass-fronted information school entrance on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 663465589, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 41986, + "sha256": "72a7d21c4d6e56839cc6aa6064a9709785bbf29cf6d37d6ac90f0a3566cb17ef", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/journalism.webp", + "kind": "college", + "source_row": "journalism", + "model": "fal-ai/flux/schnell", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)", + "prompt": "School of Journalism at an unnamed American public research university: The Graduate School of Journalism at Berkeley trains journalists to serve the public interest through rigorous reporting, ethical practice, and innovative, journalism school entrance with a glazed corner, seen from behind at a distance; any figure is walking away with its back to the camera and no face visible to the viewer on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 401401379, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 26820, + "sha256": "8853bb9d105afe62a25851cad3101834589a2a9f30f8183680beb36515cef02f" + }, + { + "path": "static/images/colleges/law.webp", + "kind": "college", + "source_row": "law", + "model": "fal-ai/flux/schnell", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)", + "prompt": "School of Law at an unnamed American public research university: Berkeley Law is one of the nation's premier law schools. Known for its commitment to public interest law and social justice, Boalt Hall has educated, a completely unoccupied law library reading room with long empty tables, empty shelves and no people at all, bare walls, no wall art, no framed pictures, no posters, no books with visible spines on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2583472791, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 114528, + "sha256": "b89eb6adc760c50b796e56161fea0f444b2a01ff54c385a336d2abcc40c6acdd" + }, + { + "path": "static/images/colleges/letters-and-science.webp", + "kind": "college", + "source_row": "letters-and-science", + "model": "fal-ai/flux/schnell", + "prompt": "College of Letters and Science at an unnamed American public research university: The largest and most diverse college at Berkeley, encompassing the humanities, social sciences, physical sciences, and biological sciences. With over 80, limestone humanities quad with arcades and mature trees on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 286784987, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 191400, + "sha256": "ce54638993d2321c70c9817bbc861c046f303eafba342d629c91011f29bd7ae2", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/natural-resources.webp", + "kind": "college", + "source_row": "natural-resources", + "model": "fal-ai/flux/schnell", + "prompt": "College of Natural Resources at an unnamed American public research university: The College of Natural Resources integrates natural, social, and human sciences to develop sustainable approaches to environmental and resource management, environmental sciences building beside a stand of trees on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3837299629, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 239192, + "sha256": "ad6a2e16bcede3a89842b5568d735d760d07882c4827e68b4340d64b15b68133", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/optometry.webp", + "kind": "college", + "source_row": "optometry", + "model": "fal-ai/flux/schnell", + "prompt": "School of Optometry at an unnamed American public research university: The School of Optometry provides doctoral education in optometry and vision science, combining clinical training with cutting-edge research on visual function, optometry clinic room with a bare examination chair on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 876479884, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 47422, + "sha256": "9e2ebacc0bc15a7bda1c9263f083c7fc767f55d5a9ff1169fbf3849c7899e3aa", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/public-health.webp", + "kind": "college", + "source_row": "public-health", + "model": "fal-ai/flux/schnell", + "prompt": "School of Public Health at an unnamed American public research university: The School of Public Health at Berkeley prepares leaders to improve health through research, education, and public service. With expertise spanning, public health laboratory with bare benches on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2042590038, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 52970, + "sha256": "e6bc78e577121b02333bb402535075d3021a8b45134f5ab588aad3a6b9d4d427", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/public-policy.webp", + "kind": "college", + "source_row": "public-policy", + "model": "fal-ai/flux/schnell", + "prompt": "Goldman School of Public Policy at an unnamed American public research university: The Goldman School of Public Policy educates students to develop and implement effective, evidence-based policies that address society's most pressing, policy school entrance with a stepped plaza on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, at most two or three people far away in the middle distance walking away from the camera, backs turned, faces completely hidden, no crowds, no visible faces, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1315192776, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 67150, + "sha256": "4315bd90a5be49084f8bed538e18b3094d4ba6467b534488182295700e6825f1", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/colleges/social-welfare.webp", + "kind": "college", + "source_row": "social-welfare", + "model": "fal-ai/flux/schnell", + "prompt": "School of Social Welfare at an unnamed American public research university: The School of Social Welfare advances the well-being of vulnerable populations and communities through education, research, and service. The school prepares, community services office with bare desks, no wall art, no framed pictures, no posters, no books with visible spines on a generic university campus, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2945735470, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 81798, + "sha256": "a7a9c238f3aeec2d5811f2d6ce14256e540d79ab7e20d25292ed2764c50b26f2", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/arts-1.webp", + "kind": "event", + "source_row": "arts-1", + "model": "fal-ai/flux/schnell", + "prompt": "a performance hall stage with warm stage lighting and a bare backdrop for a campus event in the Arts category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2710317381, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 51310, + "sha256": "854a0fd777bb2be6ee9fbae41463190ef88c6843649c9d820b22696db992c111", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/arts-2.webp", + "kind": "event", + "source_row": "arts-2", + "model": "fal-ai/flux/schnell", + "prompt": "a performance hall stage with warm stage lighting and a bare backdrop for a campus event in the Arts category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2451164900, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 61204, + "sha256": "24571b231bf6076162ee8e99a3ebfc80862683697b48b4b812aceb5a89393f48", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/career-1.webp", + "kind": "event", + "source_row": "career-1", + "model": "fal-ai/flux/schnell", + "prompt": "a a deserted exhibition hall with long bare folding tables and no display material, no brochures, no leaflets, no stands, no people for a campus event in the Career category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1632457047, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 77464, + "sha256": "9742bd16f99df88d74fc242129d7a78b8e3d8a22c07cb31ba6ed0414d021da54", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/career-2.webp", + "kind": "event", + "source_row": "career-2", + "model": "fal-ai/flux/schnell", + "prompt": "a a deserted exhibition hall with long bare folding tables and no display material, no brochures, no leaflets, no stands, no people for a campus event in the Career category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1884252843, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 71648, + "sha256": "5b0f1d286ccb3e0cf7c4f826da8c309c8b1eda0da36e29f7060b056129ad202c", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/health-1.webp", + "kind": "event", + "source_row": "health-1", + "model": "fal-ai/flux/schnell", + "prompt": "a health sciences seminar room with a long table for a campus event in the Health category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 314460108, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 41616, + "sha256": "1d9b28cc816310caf2a486668b472ef12e42c29162b580ec9235ece4e69eed90", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/health-2.webp", + "kind": "event", + "source_row": "health-2", + "model": "fal-ai/flux/schnell", + "prompt": "a health sciences seminar room with a long table for a campus event in the Health category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1261357801, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 51222, + "sha256": "b847a1c73ba2b32db31296ba5ad20476c012550f480616c1401e3c46793cfdf6", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/lecture-1.webp", + "kind": "event", + "source_row": "lecture-1", + "model": "fal-ai/flux/schnell", + "prompt": "a lecture hall auditorium with rows of seats and a bare podium for a campus event in the Lecture category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 716600221, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 103952, + "sha256": "f99db48558a038da029f336a593259702e4ced7ec4379510362ec69d18323619", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/lecture-2.webp", + "kind": "event", + "source_row": "lecture-2", + "model": "fal-ai/flux/schnell", + "prompt": "a lecture hall auditorium with rows of seats and a bare podium for a campus event in the Lecture category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3101383, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 91884, + "sha256": "f60242c6edc7c843a6b51da532950e69276e5e5ee3719bb60d77022c051e497a", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/social-1.webp", + "kind": "event", + "source_row": "social-1", + "model": "fal-ai/flux/schnell", + "prompt": "a campus courtyard set with long tables for an outdoor gathering for a campus event in the Social category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3780122178, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 64128, + "sha256": "483e01d951d1fa1df8aff6ddec9a18bcefdea59773e192620d5c41d91f3ff998", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/social-2.webp", + "kind": "event", + "source_row": "social-2", + "model": "fal-ai/flux/schnell", + "prompt": "a campus courtyard set with long tables for an outdoor gathering for a campus event in the Social category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1420075108, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 127200, + "sha256": "1c987f3ee959cf12f95de9aa9901efb56bb83e0410ad52326aefed606ae5ebc7", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/sports-1.webp", + "kind": "event", + "source_row": "sports-1", + "model": "fal-ai/flux/schnell", + "prompt": "a outdoor stadium field and running track for a campus event in the Sports category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4223462868, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 78368, + "sha256": "e4636dd6a3b7b4568ff70115b64029e8f8fe3dfa9f9d1a498a5f77ea5ec9eda3", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/sports-2.webp", + "kind": "event", + "source_row": "sports-2", + "model": "fal-ai/flux/schnell", + "prompt": "a outdoor stadium field and running track for a campus event in the Sports category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 851296695, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 95428, + "sha256": "df907418807d1cb804ffd9d997fa73b8471bc6947baf7ad44ce59a03333e51db", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/virtual-1.webp", + "kind": "event", + "source_row": "virtual-1", + "model": "fal-ai/flux/schnell", + "prompt": "a video-conferencing studio with dark blank screens and a desk for a campus event in the Virtual category, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 69459445, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 63622, + "sha256": "43e989e7af4fac3b0a2e467faaf74fb65a8e27b9952c99ce7b4defa6c9aa7987", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/events/virtual-2.webp", + "kind": "event", + "source_row": "virtual-2", + "model": "fal-ai/flux/schnell", + "prompt": "a video-conferencing studio with dark blank screens and a desk for a campus event in the Virtual category, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4087088002, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 61768, + "sha256": "47735250e9bbaff0ec45239a91583791c78f3561ab8985204902f86f2efd0a99", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/faculty/adam-arkin.png", + "kind": "faculty", + "source_row": "adam-arkin", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2919, + "sha256": "6582696b8bdd0f9332cc0490dadb8bef54499dc2404f4000cbbb8fd7aac47618", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/aditya-garg.png", + "kind": "faculty", + "source_row": "aditya-garg", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4858, + "sha256": "a88570e9e7958a92de989d01529b9552a6cab43ed22a16e68d207ae0694da8be", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/alexei-efros.png", + "kind": "faculty", + "source_row": "alexei-efros", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2986, + "sha256": "0c785bbc9c1da82a02a22215d5f7b02da8cc12595f83fa983702d596a21f5929", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/alison-gopnik.png", + "kind": "faculty", + "source_row": "alison-gopnik", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4650, + "sha256": "c7be31419e54f90dfeb234a091243ad85b2739b3a896ec578da393e64d90370e", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/amy-herr.png", + "kind": "faculty", + "source_row": "amy-herr", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2936, + "sha256": "a72c3826b4589f1320392cbaeecfd58b30e66cf4a022ece098c746788db803db", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/anca-dragan.png", + "kind": "faculty", + "source_row": "anca-dragan", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3882, + "sha256": "28074ba179963461e285aefd006051c04d31a93c0c42b9d48d2d5b67a75a097d", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/andrew-ng.png", + "kind": "faculty", + "source_row": "andrew-ng", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3762, + "sha256": "3af45ef6dbb99a9d705ccae1aa2f6bc2c994e0991b1ebf939c0601f3739718cf", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/anne-baranger.png", + "kind": "faculty", + "source_row": "anne-baranger", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4042, + "sha256": "9088c492f370603a4d796d0b719844b92f5dca1bffc4ed87debfd804f3a4f004", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/bernadette-park.png", + "kind": "faculty", + "source_row": "bernadette-park", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3386, + "sha256": "522af15a3899d9e42ba2bdfdb3de51f7b10240f3c272e709e3b113d33cf518c1", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/camille-crittenden.png", + "kind": "faculty", + "source_row": "camille-crittenden", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3403, + "sha256": "a57569a6a5873de8991b21b9b685f02b20b24a4b0989f4bd64bb8a1183761a7d", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/charis-thompson.png", + "kind": "faculty", + "source_row": "charis-thompson", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3487, + "sha256": "6c073bc12afa13383cb599bf9516d93e746165d7065de0baed84514b8c9abb92", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/clifford-nass.png", + "kind": "faculty", + "source_row": "clifford-nass", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4338, + "sha256": "b474e34e0fd22dcf407d35c3fe6fc8eb02a32ef2a750fdc095e988d6a71eb26a", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/constance-penley.png", + "kind": "faculty", + "source_row": "constance-penley", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4160, + "sha256": "846c3fbcc946e955cdb457feb4156069c4eae6093528705964c25dd3efb90be2", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/cybelle-fox.png", + "kind": "faculty", + "source_row": "cybelle-fox", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3541, + "sha256": "e390ad9a7b465a25bed544c44bc520d168158e28d4389435b97867929e4817d9", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/dan-klein.png", + "kind": "faculty", + "source_row": "dan-klein", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3982, + "sha256": "f9df15ae4f73e12635bbacee029eaaadc58871a6805b1b498a451605b801fe52", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/daryl-chrzan.png", + "kind": "faculty", + "source_row": "daryl-chrzan", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4462, + "sha256": "b0099396eaceb60e2c93a7f14b73b1ba84fda577abcec7be64448d821c12835e", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/david-card.png", + "kind": "faculty", + "source_row": "david-card", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4503, + "sha256": "8c00473a3ccef0fcbb5d8397f0b7619d1305127c0aa4d95d6db6950165fd0701", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/dawn-song.png", + "kind": "faculty", + "source_row": "dawn-song", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4697, + "sha256": "ac37be7a7c70f9b33cadbb6d28ceb95f61fd0481dd3f08a88c341b7dd988f9e8", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/elaine-ostrander.png", + "kind": "faculty", + "source_row": "elaine-ostrander", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3632, + "sha256": "5615ccf9a089c1c58ae139f02ac48d63337830b49835a5cadb35af0df8e148d6", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/eliza-strickland.png", + "kind": "faculty", + "source_row": "eliza-strickland", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3644, + "sha256": "834d1a8e3386f80ab03a9d847c690823039aad5f2f6eaa97e7d5242d28e411ad", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/emmanuel-saez.png", + "kind": "faculty", + "source_row": "emmanuel-saez", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3643, + "sha256": "173dae02bf6d773b47b2de0d957f826b6e38c19d00f2c2c99667d881a83835f1", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/erwin-chemerinsky.png", + "kind": "faculty", + "source_row": "erwin-chemerinsky", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3531, + "sha256": "a1cb856dbefe75fbfedc22c85d6c8fe3b1241452e0f99f2fe32a5658a7c7a500", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/f-dean-toste.png", + "kind": "faculty", + "source_row": "f-dean-toste", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 1372, + "sha256": "12bea38890e4a0e2347c421606ea75c8b7efc14fee9723571608846d95b2670f", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/franz-franchetti.png", + "kind": "faculty", + "source_row": "franz-franchetti", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 1347, + "sha256": "26adbd708583445183e55da92a19bcb9b084a7579127db2398fc9f7f1ff5b7fc", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/gail-murphy.png", + "kind": "faculty", + "source_row": "gail-murphy", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4653, + "sha256": "d5dc6a1912b2d4d640f5cccb4b94e0da7cef12ef69631b559b196a5dffaca105", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/george-akerlof.png", + "kind": "faculty", + "source_row": "george-akerlof", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4851, + "sha256": "f200b774071069f9fbcf6641971ddcf0ff6c84c1a7fdf00c58093f2aad662ca1", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/george-smoot.png", + "kind": "faculty", + "source_row": "george-smoot", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 5396, + "sha256": "ea1476b7ea92a58e9b09aa3ca0aa8912c27014c11adfb6db1617b33cb2f2b1a4", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/hany-farid.png", + "kind": "faculty", + "source_row": "hany-farid", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 1361, + "sha256": "71a52384944e67df73418ded4d55e1665f492e6c8eb56fa28cd7f7d5dc820750", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/hartmut-haeffner.png", + "kind": "faculty", + "source_row": "hartmut-haeffner", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 1291, + "sha256": "353da19e54c948cd2f42a226b28c0eac04804b3bbe8347bdd21df6ef84fa1031", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/ion-stoica.png", + "kind": "faculty", + "source_row": "ion-stoica", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3646, + "sha256": "36fe510d64d9b2bab33060375797545a781e4b11b7ba9e1e126a8ba0bdfc10a4", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/jennifer-chayes.png", + "kind": "faculty", + "source_row": "jennifer-chayes", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4002, + "sha256": "e26cc6710bf0b7815b9206c3b805def966b2a15c9dbb3abf78fc6e7ed3cbb32a", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/jennifer-doudna.png", + "kind": "faculty", + "source_row": "jennifer-doudna", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3216, + "sha256": "6d0f4a72b86c205609622f941e28ab6496d44910e464e7092f7541ddb6199306", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/jennifer-granick.png", + "kind": "faculty", + "source_row": "jennifer-granick", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3977, + "sha256": "b96de37048c6341a778f5948dbf0d2d5a91fff779c516ccfaba1ac0f1c7474fc", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/john-balmes.png", + "kind": "faculty", + "source_row": "john-balmes", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3225, + "sha256": "2728b8b340d017930f1c31a2e60a514ed96c73999fbca91514fd260c1a8bcf1a", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/john-denero.png", + "kind": "faculty", + "source_row": "john-denero", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3222, + "sha256": "1477eab0f1e558fc1a93dfb3801818f0a0e8102a0961ff68636da37db5652761", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/john-hartwig.png", + "kind": "faculty", + "source_row": "john-hartwig", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2088, + "sha256": "6ebcd3c7510f700e8adf03c4645e2cf707d42ca6770212c77b9597e04c9caa34", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/karl-pister.png", + "kind": "faculty", + "source_row": "karl-pister", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3520, + "sha256": "1be03cfefc62709940e391d62a2efa3375b9177cb96abdc387e0ec4c9f98ef45", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/katharine-mach.png", + "kind": "faculty", + "source_row": "katharine-mach", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4058, + "sha256": "c9f9a9b78dae44136a9baf25e6e2219d413b1d0a8cc62623c13314b51f034a3c", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/kenichi-soga.png", + "kind": "faculty", + "source_row": "kenichi-soga", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4859, + "sha256": "57e0a7f6fbd6ae2d223b9973eaae80f1fe5ea61433e57edcb44ae4ff310d49a5", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/koushik-sen.png", + "kind": "faculty", + "source_row": "koushik-sen", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4867, + "sha256": "810c84aef73026f403b76ce75502819a596a4d47d080c342d9c62200996b7995", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/kristin-scott.png", + "kind": "faculty", + "source_row": "kristin-scott", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4815, + "sha256": "65742693c846d1cbd2526994100ec17290e0edd15b98e228c9268059fa6fffb5", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/laura-nader.png", + "kind": "faculty", + "source_row": "laura-nader", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2349, + "sha256": "1d0f280b183a9caccf45e72f325624f1f0557001eebe445fbd4bb8fd4a2f0447", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/lisa-goldberg.png", + "kind": "faculty", + "source_row": "lisa-goldberg", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3455, + "sha256": "5e4c6528350424330cc9e7b746a1c37baec4504dbba159be81bbd87c5803f113", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/maja-mataric.png", + "kind": "faculty", + "source_row": "maja-mataric", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2820, + "sha256": "e29edc15477f87d2b5ff0f5fdbed90f9278b599cb6209c108889118f78ae7b6d", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/mario-molina.png", + "kind": "faculty", + "source_row": "mario-molina", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2763, + "sha256": "f06c171c934ea58bdfae43a13ccebafc8a1e9622f8930a501bc15b53525744c7", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/massimiliano-fratoni.png", + "kind": "faculty", + "source_row": "massimiliano-fratoni", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2802, + "sha256": "16bb13efeb25c92d903699fb970466b49487b0cd1b975a50fb4a534ca5b13bbf", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/melissa-dell.png", + "kind": "faculty", + "source_row": "melissa-dell", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3939, + "sha256": "d3994bef631c1a148dbc6f3a131d79536141c6c6200d3f97cbbebedef134d8e8", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/michael-botchan.png", + "kind": "faculty", + "source_row": "michael-botchan", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4068, + "sha256": "e44183a25a6dbfdf681d6691907c4c16a67e6ded7bee287e6a6eecae7faa14f5", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/michael-christ.png", + "kind": "faculty", + "source_row": "michael-christ", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4692, + "sha256": "f0942f066b201c83fcf2cfbd1c0f407b2f5ee438f206a25098ae90027abb7ee3", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/michael-jordan.png", + "kind": "faculty", + "source_row": "michael-jordan", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3390, + "sha256": "7eb6060e01bc8071874fafdcae057279605fdcd7c9bd3d077e4754fbf615643e", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/mina-aganagic.png", + "kind": "faculty", + "source_row": "mina-aganagic", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4052, + "sha256": "dd9f09828d89065379735443c93d418885185030d24ca9e03f2aa0a44e8fefef", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/nathan-seiberg.png", + "kind": "faculty", + "source_row": "nathan-seiberg", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4504, + "sha256": "9b8c0e4c6207f8a552b245d2e85cfddc189af960ac3b780e892e5c8b93db58b7", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/nicholas-jewell.png", + "kind": "faculty", + "source_row": "nicholas-jewell", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3014, + "sha256": "5e9c164e40c0ac38c99f1af7352c8c1af50a9f79c401158024d6d5386d4d1031", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/nils-gehlenborg.png", + "kind": "faculty", + "source_row": "nils-gehlenborg", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4284, + "sha256": "478e32c6c52eb56fefcc12937f15fbc107fc28fd8bb35d4173e51e7f21bac957", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/owen-chamberlain.png", + "kind": "faculty", + "source_row": "owen-chamberlain", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 5226, + "sha256": "c6a18bebf57ed97b2a7f40357dd22f5ed90ae5408d6164587dc4d01e965eeffc", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/pablo-spiller.png", + "kind": "faculty", + "source_row": "pablo-spiller", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4237, + "sha256": "7b1179a706e9a71ac55a6232a2c7b9aeb8a4d1ecaf0cd758618269069f188fa1", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/philip-darby.png", + "kind": "faculty", + "source_row": "philip-darby", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3221, + "sha256": "5a3bfd17063b347819e0b85ac85879d190e2a5c618f44adb491bd8151dc31f2b", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/philip-stark.png", + "kind": "faculty", + "source_row": "philip-stark", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4144, + "sha256": "48b1595368334b244ce8c1658946a5ac31762fd6edf43199a47cdac938b4dcc0", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/pieter-abbeel.png", + "kind": "faculty", + "source_row": "pieter-abbeel", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3540, + "sha256": "0d9e8b0623b4c5647cb17bba0cd57650e0c00ae70b981f4c81f31d6e73357ce4", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/pilar-ossorio.png", + "kind": "faculty", + "source_row": "pilar-ossorio", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4207, + "sha256": "d9a947ac4607d62f7b23dded0f1066d7da503e29ffe0ab0bc58a252a804297ef", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/reinhard-genzel.png", + "kind": "faculty", + "source_row": "reinhard-genzel", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4461, + "sha256": "f3d542ebd53b7a6b791e7fe6c2ce942dc78e540be80eac9b23f8b6b4d3921e9f", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/ren-ng.png", + "kind": "faculty", + "source_row": "ren-ng", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3419, + "sha256": "2340001349cb8186e9ac48ece1ee1b39ca9821f592793587ae5903c867a64e71", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/ricardo-fraiman.png", + "kind": "faculty", + "source_row": "ricardo-fraiman", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2561, + "sha256": "39dd4b164be71570a067bfd94afca6e50fd07ddf23d73c26027c19a4643c0224", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/robert-van-houweling.png", + "kind": "faculty", + "source_row": "robert-van-houweling", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2543, + "sha256": "cbf20b02c88c57cf2867c52e22f4aaa8ca9b12a6ab05091ac237194f4abe22d9", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/rosemary-joyce.png", + "kind": "faculty", + "source_row": "rosemary-joyce", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3184, + "sha256": "89a8c941862ede821c9dbabb20437d436cd74ae5985b545915008ac4bdb3697c", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/russell-poldrack.png", + "kind": "faculty", + "source_row": "russell-poldrack", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3215, + "sha256": "73a414e7c65be94c716cd41cd69b37a7a35a6db94a37dce4ad338b164b6e75d7", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/sanjay-kumar.png", + "kind": "faculty", + "source_row": "sanjay-kumar", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4868, + "sha256": "2914bc024b7d0bb77bed3fc228bd1c3c88e7f925e6ddbb26d2244e843cd7c31f", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/sanjit-seshia.png", + "kind": "faculty", + "source_row": "sanjit-seshia", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3483, + "sha256": "089da513fd6d2a52a887087cf06cbd904b24d3f61d2210548c17d6c81855cb59", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/saul-perlmutter.png", + "kind": "faculty", + "source_row": "saul-perlmutter", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4206, + "sha256": "773fd14ddb70bf6522ed4ca12a896e636a8eee05683cb23bb70b710fe790b15c", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/shachar-kariv.png", + "kind": "faculty", + "source_row": "shachar-kariv", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4868, + "sha256": "2914bc024b7d0bb77bed3fc228bd1c3c88e7f925e6ddbb26d2244e843cd7c31f", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/shafi-goldwasser.png", + "kind": "faculty", + "source_row": "shafi-goldwasser", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 5356, + "sha256": "4bfe110ce318582ee84a27c0243ec793bfffac6d51ccc447e820d66703dc66e4", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/stephen-hinshaw.png", + "kind": "faculty", + "source_row": "stephen-hinshaw", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3580, + "sha256": "bed4bfa352dbf01423c795395183e06b566828f5bc81885b28626d0d852d626f", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/stuart-russell.png", + "kind": "faculty", + "source_row": "stuart-russell", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 4546, + "sha256": "16e0199f9049098a0e8f9eb23a8d9c2c5dd18095422609cd5e0a9b7f199c23d6", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/tatiana-toro.png", + "kind": "faculty", + "source_row": "tatiana-toro", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 1288, + "sha256": "0f705cabaa0a9181c95d06232db2108a7aa519cd747f27cdf337869d69a0b4e9", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/terrance-odean.png", + "kind": "faculty", + "source_row": "terrance-odean", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3579, + "sha256": "bc6724fdf77d0c6e093cddb6d3035eabb00e5255f61d69cfb70a4d0790a5c2ed", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/tom-buresh.png", + "kind": "faculty", + "source_row": "tom-buresh", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2659, + "sha256": "9adf6c57dff927bbc2af3238ccf93d55a85b5e46e831aaa0a358cffe2da924cf", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/trevor-darrell.png", + "kind": "faculty", + "source_row": "trevor-darrell", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 2564, + "sha256": "e634fb04cde53700a1b0eb886d21d7c47f823cafa27244b4eddb49674a0e6aa0", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/ulrike-malmendier.png", + "kind": "faculty", + "source_row": "ulrike-malmendier", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3539, + "sha256": "0bf4b4638effa37411e460afd8dbdec37bb2b3b6d52d57ff79af5129f0c1fac6", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/venus-ranieri.png", + "kind": "faculty", + "source_row": "venus-ranieri", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3943, + "sha256": "93791d69cfaff43299d44ef8704a2f35cf187182b212164cf4aa1f05e3802633", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/wendy-brown.png", + "kind": "faculty", + "source_row": "wendy-brown", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 5024, + "sha256": "a8a5541dee25a47dc4633c848dc4ef0ee8833fd66831b3fc148af384e175106a", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/zackary-berger.png", + "kind": "faculty", + "source_row": "zackary-berger", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3590, + "sha256": "326614977547a7e4f4914bddecbad4b29ec550f097ffe42ba046e710b8d7aa90", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/faculty/zeynep-erkin-baz.png", + "kind": "faculty", + "source_row": "zeynep-erkin-baz", + "model": "pillow-initials-11.0.0", + "prompt": null, + "seed": null, + "generated_on": "2026-09-14", + "bytes": 3598, + "sha256": "4c43965a2c607cf11c117d1cd1ff3a6776adeddc67c71b7200a09c5b56fdde81", + "generator": "sites/berkeley/scripts/gen_avatars.py (Pillow 11.0.0)" + }, + { + "path": "static/images/news/arts-1.webp", + "kind": "news", + "source_row": "arts-1", + "model": "fal-ai/flux/schnell", + "prompt": "a art studio with easels and a small bare stage for a campus news story about Arts, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2710317381, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 51790, + "sha256": "cdbe38d4ad178c6fa25a85c2bb10cf26ab811e1ca7c2925e1e59996533b8555b", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/arts-2.webp", + "kind": "news", + "source_row": "arts-2", + "model": "fal-ai/flux/schnell", + "prompt": "a art studio with easels and a small bare stage for a campus news story about Arts, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2451164900, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 45314, + "sha256": "f07da435069868bf6692d4f6f52198c75269f9dda452da52dea0bb9de98c5928", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/arts-3.webp", + "kind": "news", + "source_row": "arts-3", + "model": "fal-ai/flux/schnell", + "prompt": "a art studio with easels and a small bare stage for a campus news story about Arts, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1802005107, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 59968, + "sha256": "923fb984ce54a6b106427e5bdaf7ded6c289a0e0ce8e0c880575fb84a9c8d0fa", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/athletics-1.webp", + "kind": "news", + "source_row": "athletics-1", + "model": "fal-ai/flux/schnell", + "prompt": "a outdoor running track and sports field at the edge of campus for a campus news story about Athletics, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 589403613, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 102250, + "sha256": "6a06cc52b3d88afa11c4c09206a812ef5cb2b23deb17f56d5ae1a44c587e88fc", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/athletics-2.webp", + "kind": "news", + "source_row": "athletics-2", + "model": "fal-ai/flux/schnell", + "prompt": "a outdoor running track and sports field at the edge of campus for a campus news story about Athletics, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3640443959, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 70690, + "sha256": "8c6f60c570b4f2e49aaee34b483c91f6267a0aed5dd30a55ef9e542b72dbd017", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/athletics-3.webp", + "kind": "news", + "source_row": "athletics-3", + "model": "fal-ai/flux/schnell", + "prompt": "a outdoor running track and sports field at the edge of campus for a campus news story about Athletics, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1084601861, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 88526, + "sha256": "008d76e27a2ff13ff76e1da1f6e28a1d2ee7f2699d44c5d3ad935be0dbcd7972", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/campus-life-1.webp", + "kind": "news", + "source_row": "campus-life-1", + "model": "fal-ai/flux/schnell", + "prompt": "a campus plaza with benches and trees between buildings for a campus news story about Campus Life, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1368872259, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 59878, + "sha256": "23ef9a127deb9e27a54947d6bcd01b1eea286b76097381cbc3cb8519d75ad07b", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/campus-life-2.webp", + "kind": "news", + "source_row": "campus-life-2", + "model": "fal-ai/flux/schnell", + "prompt": "a campus plaza with benches and trees between buildings for a campus news story about Campus Life, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 337846443, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 110604, + "sha256": "da1f7d9c355b949af83d08d236200d7fb684605f86eff8a240223a06e9f254ef", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/campus-life-3.webp", + "kind": "news", + "source_row": "campus-life-3", + "model": "fal-ai/flux/schnell", + "prompt": "a campus plaza with benches and trees between buildings for a campus news story about Campus Life, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2403095273, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 92980, + "sha256": "71b297406f3262141acbfe4d8079c46e50943809ecc70b531546041cb2381895", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/faculty-1.webp", + "kind": "news", + "source_row": "faculty-1", + "model": "fal-ai/flux/schnell", + "prompt": "a faculty office with bare shelves, a writing desk and a window, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Faculty, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2166948584, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 57496, + "sha256": "4f2e310b772596434ddc9bc08df70566bcc81c7ce1649f7a6ebba67f2c10d9ae", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/faculty-2.webp", + "kind": "news", + "source_row": "faculty-2", + "model": "fal-ai/flux/schnell", + "prompt": "a faculty office with bare shelves, a writing desk and a window, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Faculty, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4089367846, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 40306, + "sha256": "696a97661ece7f627fdfbc63271fc5fe5609f9a3ebde12707030131fc7363124", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/faculty-3.webp", + "kind": "news", + "source_row": "faculty-3", + "model": "fal-ai/flux/schnell", + "prompt": "a faculty office with bare shelves, a writing desk and a window, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Faculty, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3415573959, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 39950, + "sha256": "906dd646c1396ab2d8201d4b395fa1a6a22bd704a305e1b955bfb324bb74d812", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/research-1.webp", + "kind": "news", + "source_row": "research-1", + "model": "fal-ai/flux/schnell", + "prompt": "a research laboratory bench with glassware and stainless instruments for a campus news story about Research, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1224901217, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 89910, + "sha256": "30610eab6813eba1b317bf13f2ccd1e222804a73a7eff010e5b69bdd63cd11dc", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/research-2.webp", + "kind": "news", + "source_row": "research-2", + "model": "fal-ai/flux/schnell", + "prompt": "a research laboratory bench with glassware and stainless instruments for a campus news story about Research, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1623858341, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 28100, + "sha256": "fd04295e8925624b07d3005cf2e4ac92a12ee878c07869da4a2208df34f94e43", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/research-3.webp", + "kind": "news", + "source_row": "research-3", + "model": "fal-ai/flux/schnell", + "prompt": "a research laboratory bench with glassware and stainless instruments for a campus news story about Research, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3707392277, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 46810, + "sha256": "6a0bbc7954f996cb01f664bf7dd06627063002550419025e8e3a1ed0d44ffcf2", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/science-1.webp", + "kind": "news", + "source_row": "science-1", + "model": "fal-ai/flux/schnell", + "prompt": "a an empty science building corridor with plain bare walls, a terrazzo floor and tall windows, nothing mounted on the walls for a campus news story about Science, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3525533137, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 81962, + "sha256": "1cb6ac65d738f85b5d275ca4d79e0d75056c23190f2c54499f9f286d35bb0891", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/science-2.webp", + "kind": "news", + "source_row": "science-2", + "model": "fal-ai/flux/schnell", + "prompt": "a an empty science building corridor with plain bare walls, a terrazzo floor and tall windows, nothing mounted on the walls for a campus news story about Science, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1681672709, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 70568, + "sha256": "b4edbdc5a9e8b6ea07f20e75822a0bba6187a43b88167f39e40913f40e962637", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/science-3.webp", + "kind": "news", + "source_row": "science-3", + "model": "fal-ai/flux/schnell", + "prompt": "a an empty science building corridor with plain bare walls, a terrazzo floor and tall windows, nothing mounted on the walls for a campus news story about Science, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3447379556, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 64160, + "sha256": "164c5f8a44a785b112a0a09f6c2ba3f1010a0a585ef136714ce9eb78a41f5250", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/student-1.webp", + "kind": "news", + "source_row": "student-1", + "model": "fal-ai/flux/schnell", + "prompt": "a student study area with long tables and reading lamps, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Student, variant 1: a wide establishing view of the whole space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2354502282, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 70610, + "sha256": "1b501bafada7c9f423ba326582d272fded41d25865d330cd2eb27d703b9bc70c", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/student-2.webp", + "kind": "news", + "source_row": "student-2", + "model": "fal-ai/flux/schnell", + "prompt": "a student study area with long tables and reading lamps, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Student, variant 2: a closer view of the objects and materials in the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1845670581, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 101184, + "sha256": "7d176788aba0117a6d6dd9814f246685056cef31473908b705929ff932054f19", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/news/student-3.webp", + "kind": "news", + "source_row": "student-3", + "model": "fal-ai/flux/schnell", + "prompt": "a student study area with long tables and reading lamps, on completely bare walls with no wall art, no framed pictures, no posters and no books with visible spines for a campus news story about Student, variant 3: an interior view from a raised corner of the space, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2099418187, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 82214, + "sha256": "2b4a01f1b3e16eb9b334ca76bf0c49a3f3611a9e3132e15dc42ca76150ec95f0", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bair.webp", + "kind": "research", + "source_row": "bair", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Artificial Intelligence Research Lab: BAIR brings together UC Berkeley researchers across machine learning, deep learning, robotics, computer vision, and natural language processing Research focus: Machine Learning, Deep Learning, Computer Vision. bright open-plan robotics and computing laboratory with long workstations, every monitor switched off and dark, and a single articulated robot arm at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3609172629, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 78578, + "sha256": "0dc3a4962f790fe765d35f5efa550abddc677170830c0d9a5e73d5ebe172ebc9", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bcgc.webp", + "kind": "research", + "source_row": "bcgc", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Center for Green Chemistry: BCGC advances the science and practice of green chemistry by developing safer, more sustainable chemicals and processes Research focus: Green Chemistry, Sustainable Processes, Chemical Safety. green chemistry bench with glassware and stainless steel fume hoods at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1541185901, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 98532, + "sha256": "8312e9b542314f494284b6ade92dfd593bac4cdb321abca3a5034075ab92bbdc", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bcnm.webp", + "kind": "research", + "source_row": "bcnm", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Center for New Media: BCNM explores the theoretical, empirical, and creative dimensions of digital media and their impact on society and culture Research focus: Digital Media, Art and Technology, Cultural Studies. new media studio with open floor space, bare partitions and soft light at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3441005248, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 45546, + "sha256": "6ca165ae3eee34574de57ddee66302d6cf6186f0d774fa55bf31354aa08f456e", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bcsp.webp", + "kind": "research", + "source_row": "bcsp", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for the Science of Psychedelics: BCSP studies the mechanisms and therapeutic potential of psychedelic compounds for mental health treatment Research focus: Psychedelic Research, Mental Health, Neuroscience. clinical research suite with a bare examination couch and soft natural light at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2086984707, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 74250, + "sha256": "8d1386f7bcb1efd95deb650c2637e9a4e324312f500590db4e3bd99c02bb35df", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bgc.webp", + "kind": "research", + "source_row": "bgc", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Global Campus Initiative: BGC advances Berkeley's global research partnerships and international collaborative programs across disciplines Research focus: International Collaboration, Global Research, Education Policy. international programmes office with rows of empty desks and bare walls, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2761115774, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 68072, + "sha256": "2ce3c577cc732fca8481f3f5748ee743d028d111124ce9c356a648620efc87b2", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bids.webp", + "kind": "research", + "source_row": "bids", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Institute for Data Science: BIDS catalyzes data science research and education across all disciplines at Berkeley through collaborative data science projects Research focus: Data Science, Statistics, Computational Methods. open data-science collaboration space with long shared desks and bare walls, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2323207968, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 130024, + "sha256": "2a00d36adfc4e5d301f3a6363ad5558ccb5c425ffbf2c224882b6ef0fb1bfb15", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/bpc.webp", + "kind": "research", + "source_row": "bpc", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Population Center: BPC supports interdisciplinary research in demography and population health across the social and biological sciences Research focus: Demography, Population Health, Aging. demography research office with rows of empty desks and bare partitions, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4187339658, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 93150, + "sha256": "33fb538371f3970bd9857a155baab30d192d9fd54bebbbfa34bad10109b0200f", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/cbe.webp", + "kind": "research", + "source_row": "cbe", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for the Built Environment: CBE conducts research on building energy efficiency, indoor environmental quality, and sustainable design strategies Research focus: Building Performance, Thermal Comfort, Energy Efficiency. building-performance test chamber with a bare concrete wall and instrumented panels at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1424131275, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 70638, + "sha256": "596ce72cb16be59b7aa0a9db6e62b1362df493d13ade593172e67a07e9c40deb", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/cega.webp", + "kind": "research", + "source_row": "cega", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for Effective Global Action: CEGA generates evidence on what interventions work to improve lives in the developing world, informing policy and program design Research focus: Development Economics, Health, Education. field-research staging room with shelving of bare plastic crates and a workbench, no wall art, no framed pictures, no posters at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1398012264, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 50224, + "sha256": "06ea172e82401be16076005786f4734beac8f2f7db9506eadb51e18440a5db8d", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/chpr.webp", + "kind": "research", + "source_row": "chpr", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for Healthcare Policy and Research: CHPR conducts research on health system performance, access to care, quality improvement, and health policy effectiveness Research focus: Health Policy, Quality of Care, Health Insurance. health policy seminar room with a long table and rows of empty chairs at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 875005619, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 93472, + "sha256": "fd30b2c387bcf6f8749ac0ed92738ddfd9dfbcb6d7daa1a2fa333406e613da84", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/citris.webp", + "kind": "research", + "source_row": "citris", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for Information Technology Research in the Interest of Society: CITRIS develops information technology solutions for society's most pressing challenges in health, energy, and environment Research focus: IoT, Health Technology, Energy Systems. sensor and robotics bay with benches of bare metal instruments at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2493226121, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 132160, + "sha256": "2e41a22883ec186ce690988582fc345a52a89f68fe7b0407dd4c741312572b71", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/cpl.webp", + "kind": "research", + "source_row": "cpl", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the California Policy Lab: The California Policy Lab partners with state and local governments to use data and rigorous research to improve policy outcomes for Californians Research focus: California Policy, Poverty, Housing. policy analysis workspace with bare desks and tall windows, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3541034465, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 84254, + "sha256": "6f9412e85594cc728840c37390401182f49842798696a428990f0fbe60dadbcd", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/drn.webp", + "kind": "research", + "source_row": "drn", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Disaster Resilience Network: The Disaster Resilience Network conducts interdisciplinary research on earthquake engineering, infrastructure resilience, and community recovery Research focus: Earthquake Engineering, Infrastructure, Resilience. resilience operations room with a large bare table and empty chairs at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3505299494, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 69140, + "sha256": "d1bfd4fac8476d26a8c300874bf46424f9fd5981e23b4578570de29b2607b51d", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/energy-biosciences.webp", + "kind": "research", + "source_row": "energy-biosciences", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Energy Biosciences Institute: EBI conducts research on biofuels, bioenergy, and the biological conversion of plant biomass to transportation fuels Research focus: Biofuels, Plant Biology, Enzyme Engineering. plant-growth chamber with rows of seedlings under grow lights at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3178337487, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 104810, + "sha256": "46ac03c28d275854fdeb724e9bc182d90f59b7c51c2016ecd5af031e762947a1", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/iis.webp", + "kind": "research", + "source_row": "iis", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Institute of International Studies: IIS promotes Berkeley's role as a global leader in international and area studies research, education, and public engagement Research focus: International Relations, Area Studies, Global Policy. area studies reading room with long tables, bare walls and tall windows, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3243941522, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 53438, + "sha256": "a43f9a7efbb2bec3a967ac3e3911600d69325597bfce35b3f2c02cd222415588", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/iurd.webp", + "kind": "research", + "source_row": "iurd", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Institute of Urban and Regional Development: IURD conducts policy-oriented research on cities, regions, and communities to promote equitable and sustainable development Research focus: Urban Policy, Housing, Regional Development. urban planning studio with long drafting tables and bare walls, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3577572034, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 135694, + "sha256": "15789d04ab956aa14e1ed55e1f82af4216b8e8533ec146b9564b589fd2296f95", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/jacobs-institute.webp", + "kind": "research", + "source_row": "jacobs-institute", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Jacobs Institute for Design Innovation: The Jacobs Institute is the hub for design education at Berkeley, providing access to digital fabrication, prototyping, and design expertise Research focus: Design, Prototyping, Human-Computer Interaction. design and fabrication shop with bare plywood benches and hand tools, no wall art, no framed pictures, no posters at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2590008526, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 91330, + "sha256": "1a23b0a390b41dc80bc4c9363ad24ac7eec2314ec835d8625722bf33a2826614", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/kavli-ensi.webp", + "kind": "research", + "source_row": "kavli-ensi", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Kavli Energy NanoSciences Institute: Kavli ENSI investigates fundamental energy conversion processes at the nanoscale to enable next-generation solar cells, batteries, and catalysts Research focus: Nanoscience, Energy Conversion, Photovoltaics. nanoscience clean room with stainless steel equipment and soft ambient light at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 2084090448, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 52174, + "sha256": "31e5e979095559eaa9f5fb9e7e864427dccd39864f1bfdd8a4dc1d84d6bcbe27", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/labor-center.webp", + "kind": "research", + "source_row": "labor-center", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for Labor Research and Education: The Labor Center bridges academic research and the working world, providing education programs for workers and policy analysis on labor issues Research focus: Labor Policy, Worker Rights, Inequality. labour studies reading room with long tables and bare walls, no wall art, no framed pictures, no posters, no books with visible spines at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 846082207, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 57472, + "sha256": "b489bb64abc1c57dea134e1576344510080cea740ddfa87644b4fc09413dcbb6", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/msri.webp", + "kind": "research", + "source_row": "msri", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Mathematical Sciences Research Institute: MSRI (now SLMath) is the world's leading mathematical research center, hosting programs in all areas of pure and applied mathematics Research focus: Pure Mathematics, Applied Mathematics, Statistics. mathematics institute courtyard with a bare stone wall, planters and benches at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4133787444, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 134348, + "sha256": "202d81b002a77975546f0bb3a53a039fbd53b8b5b92e8b1ba2b0fc4f15c270de", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/qb3.webp", + "kind": "research", + "source_row": "qb3", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the QB3 Institute: QB3 accelerates the translation of biological discoveries into products and companies that benefit human health and the environment Research focus: Biotechnology, Drug Discovery, Biomanufacturing. biotechnology wet laboratory with stainless benches and glassware at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3724737607, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 96644, + "sha256": "805811c1f7e9543b7befc2082e250c923e921f97cd0cbde02c5a8c68d2e7ea45", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/rdai.webp", + "kind": "research", + "source_row": "rdai", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Center for Responsible, Decentralized Intelligence: RDI advances the responsible development of decentralized technologies including blockchain, cryptocurrencies, and distributed AI Research focus: Blockchain, Cryptography, Decentralized Finance. decentralized-systems laboratory with rows of dark blank screens and bare desks at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 4180122518, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 61626, + "sha256": "20a8672a01a42dbe5e4b851568177f874f082f3e4486bf3c7faeacd08b936133", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/sccn.webp", + "kind": "research", + "source_row": "sccn", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Swartz Center for Computational Neuroscience: SCCN develops advanced computational methods and tools for neuroscience research, including brain-computer interfaces Research focus: Computational Neuroscience, EEG, Brain-Computer Interface. EEG recording room with a bare reclining chair and instrument trolley at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3409580179, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 48284, + "sha256": "93d7802ce08e5800ee64082f0cd2070eac12818ea4e9385556564742dede4b33", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/seismo-lab.webp", + "kind": "research", + "source_row": "seismo-lab", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Berkeley Seismological Laboratory: The Seismological Lab monitors and studies earthquakes in Northern California and conducts fundamental research on seismic hazard Research focus: Earthquake Science, Seismic Hazard, Tectonic Geology. seismograph vault with a concrete pier and cable runs at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 3587638364, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 69072, + "sha256": "f3f12fa8428310cbfda9c0c1e7bda583b54ca3c08026930dab6492c25dc6dfea", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + }, + { + "path": "static/images/research/simons-institute.webp", + "kind": "research", + "source_row": "simons-institute", + "model": "fal-ai/flux/schnell", + "prompt": "a research institute interior for the Simons Institute for the Theory of Computing: The Simons Institute advances theoretical computer science through semester-long programs bringing together researchers from around the world Research focus: Algorithms, Complexity Theory, Cryptography. mathematics institute seminar room with a large bare chalkboard and rows of empty chairs at an unnamed American public research university, editorial photograph for a university website, bright natural daylight, natural but vivid colours, wide establishing shot, shallow depth of field, completely unoccupied space with no people present and no human figures anywhere in frame, plain blank walls and panels, no placards, no labels, no notices, no banners, no lettering of any kind anywhere in the scene, 4:3 landscape composition, no text, no lettering, no logos, no watermarks, no faces, no portraits, no recognizable landmarks or signage, photographic, natural light, no posters, no framed pictures, no screens with visible content, unlabeled containers", + "seed": 1517014978, + "qa": "ok", + "generated_on": "2026-09-14", + "bytes": 92014, + "sha256": "614f89089d71ef057d49976c2eabddf9c56eaf89f692d3c37ba5c49d8dec918b", + "generator": "sites/berkeley/scripts/gen_images.py (fal-ai/flux/schnell)" + } + ] +} diff --git a/sites/berkeley/scripts/IMAGE_PLAN.md b/sites/berkeley/scripts/IMAGE_PLAN.md new file mode 100644 index 00000000..08f94380 --- /dev/null +++ b/sites/berkeley/scripts/IMAGE_PLAN.md @@ -0,0 +1,730 @@ +# UC Berkeley mirror — imagery plan (normative) + +Status: Phase 1 discovery output, revised after the pilot review. +Owner: `scripts/gen_images.py` (scenes) + `scripts/gen_avatars.py` (faculty). + +This file is the specification, not a description: `gen_images.py` parses the +`gen_images:prompt-spec` JSON block in §3 at run time, so the slot table, the +style suffixes, the negative list and every subject template below are the +single source of truth for what gets generated and what lands in +`generated_asset_inventory.json`. + +Revision 2 (post-pilot) changed: the negative list (§3.1), two occupancy-aware +style suffixes replacing one (§3.2), a poster/screen-safe rewrite of every +motif and theme table, exterior/interior alternation for the 14 colleges +(§3.3), and a mandatory automated QA pass with per-slot seed retry (§9). + +## 0. Why this site had none, and what changes + +`sites/berkeley` ships **no images at all** today: `static/` holds only +`static/css/.gitkeep` and `static/js/.gitkeep`, `base.html` carries one inline +stylesheet, the logo is a CSS circle (`
C
`, +`templates/base.html:324`), and the card imagery is a flat `background:` colour +plus a text label (`.card-img`, `templates/base.html:134`). `README.md` +§Imagery states the omission is "by design". + +That is a real fidelity gap for a mirror of `berkeley.edu`, whose every section +page leads with a photograph. This plan closes it without touching a single +task-visible fact: every image is synthetic, generated from the same seed rows +the templates already render, and carries no text. + +Deviations from the brief's slot model are listed in §6 with their reasons. + +## 1. Slot model + +Counts follow the brief: hero/campus 8, colleges 14, research centres 25, +news 7 categories × 3 variants, events 7 categories × 2 variants, faculty +82 deterministic avatars. 82 scene files + 82 avatars = **164 files**. + +| # | kind | root | files | one per | slug | driven by | +|---|---|---|---|---|---|---| +| A | `campus` | `static/images/campus/` | 8 | section hero | fixed table §3.4 | fixed scene table | +| B | `college` | `static/images/colleges/` | 14 | `colleges.slug` | `colleges.slug` | `colleges.name`, `.description`, id parity | +| C | `research` | `static/images/research/` | 25 | `research_centers.slug` | `research_centers.slug` | `.name`, `.description`, `.focus_areas` | +| D | `news` | `static/images/news/` | 21 | 7 categories × 3 variants | `-` | category theme (no article fields) | +| E | `events` | `static/images/events/` | 14 | 7 categories × 2 variants | `-` | category theme (no event fields) | +| F | `faculty` | `static/images/faculty/` | 82 | `faculty.slug` | `faculty.slug.png` | `faculty.name` (initials only) | + +### Deterministic variant assignment + +Nothing about a slot may depend on render order, so multi-variant families bind +the variant to the **primary key**, not to a loop index: + +```python +news_variant(article) = (article.id % 3) + 1 # 1..3 +event_variant(event) = (event.id % 2) + 1 # 1..2 +``` + +`news` category → family: the app exposes exactly seven categories +(`app.py:384`). The seed holds an eighth value, `Academics`, on **one** row of +121; that row falls back to `campus-life` and is the only documented family +mismatch (§6.2). + +`events` categories are exactly the seven seeded values, so no fallback is +needed. The category → slug map is `lower()`, spaces → `-`. + +## 2. Template slots — every `` this plan adds + +Read-only survey of `sites/berkeley/templates/*.html` at `8e5bb7f`. Line +numbers are as of that commit. "alt" is the template expression, never a +literal, so the alt text is always the same seed row the page already renders. + +### 2.1 `index.html` — home page + +| line | block | slot | img | +|---|---|---|---| +| 7 | hero `linear-gradient(135deg, #003262 60%, #2E6C8B 100%)` overlay div, inside `
` | A `campus-quad` | full-bleed background layer under the gradient, `alt=""` (decorative; the hero already has an `h1`) | +| 60–65 | lead-story `
` holding `
🌐
` | D news family of `featured_news[0]` | `` replaces the globe glyph | +| 82–84 | `
{{ article.category }}
` | D for each `featured_news[1:]` | `` inside `.card-img`; the label span stays as the overlay caption | +| 108–111 | event date block `
` | E family of each `upcoming_events` | date block stays; image added above it inside `.card` | +| 135–140 | research card `
` (no image block today) | C `recent_research` | new `.card-img` inserted before `.card-body` | + +### 2.2 `news.html` — news listing + +| line | block | slot | img | +|---|---|---|---| +| 53–56 | `
{{ article.category }}` | D | `` inside `.card-img`; the per-category background colour stays as the fallback layer, and the label span stays | + +### 2.3 `news_article.html` — news detail + +| line | block | slot | img | +|---|---|---|---| +| 38–43 | "Article image placeholder" `
` with the 🎥 glyph and the literal `Berkeley News` | D, the article's own variant | `` replaces the glyph block | + +### 2.4 `events.html` — events listing + +| line | block | slot | img | +|---|---|---|---| +| 53–59 | `
` date block + `{{ event.category }}` | E | `` inserted above the date block; date block and badge untouched | + +### 2.5 `event_detail.html` — event detail + +| line | block | slot | img | +|---|---|---|---| +| 20–25 | `.container.py-4 > .sidebar-layout > div`, opening with `

About This Event

` | E, the event's own variant | 280 px banner between the page header and "About This Event" | + +### 2.6 `faculty.html` — faculty directory + +| line | block | slot | img | +|---|---|---|---| +| 39–43 | `
…{{ member.name[0] }}
` | F avatar | `` replaces the 60 px initials disc; the 80 px band and the emeritus grey stay | + +### 2.7 `faculty_profile.html` — faculty detail + +| line | block | slot | img | +|---|---|---|---| +| 16–18 | `
{{ member.name[0] }}
` inside the page-header flex row | F avatar | `` replaces the disc, same 80 px box | + +### 2.8 `academics.html` — schools & colleges + +| line | block | slot | img | +|---|---|---|---| +| 34–35 | `
` (no image block) | B `college.slug` | new `.card-img` before `.card-body` | + +### 2.9 `departments.html` / `department_detail.html` + +| line | block | slot | img | +|---|---|---|---| +| `departments.html:21–22` | `
` per department | B of `dept.college` (§6.1) | new `.card-img` before `.card-body` | +| `department_detail.html:13–18` | `.page-header` with `

{{ dept.name }}

` | B of `dept.college` | banner behind the page header, blue scrim, `alt=""` | + +### 2.10 `research.html` / `research_center.html` + +| line | block | slot | img | +|---|---|---|---| +| `research.html:32–33` | `
` per centre | C `center.slug` | new `.card-img` before `.card-body` | +| `research.html:14` | overview `split-3-2` panel | A `campus-labs` | 4:3 image above the "The World's Top Public Research University" copy | +| `research_center.html:13–18` | `.page-header` | C `center.slug` | banner behind the page header, blue scrim | + +### 2.11 `about.html`, `admissions.html`, remaining section headers + +| file | line | block | slot | +|---|---|---|---| +| `about.html` | 12–17 | mission block, `max-width: 800px; text-align: center` | A `campus-library` full-width band above the block | +| `about.html` | 37–55 | History/Location `split-3-2` | A `campus-quad` in the History column | +| `admissions.html` | 12–17 | `#undergraduate` section opening | A `campus-admissions` banner | +| `academics.html` | 5–10 | `.page-header` | A `campus-lecture-hall` banner behind the header, blue scrim | +| `news.html` | 5–10 | `.page-header` | A `campus-newsroom` banner | +| `events.html` | 5–10 | `.page-header` | A `campus-events-green` banner | +| `faculty.html` | 5–10 | `.page-header` | A `campus-faculty-office` banner | + +Not imaged, deliberately: `/programs`, `/programs/`, `/search`, `/login`, +`/register`, `/account`, `404.html`, `500.html`, `base.html` (§6.3). + +## 3. Prompt construction + +For every scene slot: + +``` +prompt = ", + {% block head %}{% endblock %} + + + + + + +
+
+
+ University of California, Berkeley + Berkeley, CA 94720 +
+
+ {% if current_user.is_authenticated %} + {{ current_user.full_name or current_user.username }} +
+ + +
+ {% else %} + Sign In + Create Account + {% endif %} +
+
+
+ + +
+ + + +
+ + +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ +
+ {% endif %} +{% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + +
+ Unofficial offline benchmark mirror of berkeley.edu with synthetic data. Not affiliated with or + endorsed by the University of California, Berkeley. +
+ + + + diff --git a/sites/berkeley/templates/department_detail.html b/sites/berkeley/templates/department_detail.html new file mode 100644 index 00000000..f6fee7ae --- /dev/null +++ b/sites/berkeley/templates/department_detail.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}{{ dept.name }} — UC Berkeley{% endblock %} + +{% block content %} + + + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/departments.html b/sites/berkeley/templates/departments.html new file mode 100644 index 00000000..51f641b0 --- /dev/null +++ b/sites/berkeley/templates/departments.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Departments — UC Berkeley{% endblock %} + +{% block content %} + + +
+ {% for college, depts in depts_by_college.items() %} + {% if depts %} +
+

+ {{ college.name }} +

+
+ {% for dept in depts %} +
+
+ {{ college.name }} +
+
+

+ {{ dept.name }} +

+

{{ dept.description[:110] }}{% if dept.description|length > 110 %}...{% endif %}

+ {# Chair and location live on the department's own page: rendering + them on the listing would show task answers before the required + detail-page visit. #} + +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+{% endblock %} diff --git a/sites/berkeley/templates/event_detail.html b/sites/berkeley/templates/event_detail.html new file mode 100644 index 00000000..75f3da45 --- /dev/null +++ b/sites/berkeley/templates/event_detail.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}{{ event.title }} — Berkeley Events{% endblock %} + +{% block content %} + + + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/events.html b/sites/berkeley/templates/events.html new file mode 100644 index 00000000..2ea5cd57 --- /dev/null +++ b/sites/berkeley/templates/events.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% block title %}Events — UC Berkeley{% endblock %} + +{% block content %} + + +
+
+
+ + + + + {% if q or current_category %} + Clear + {% endif %} +
+
+ +
+ Upcoming + Today + Past Events + {% for cat in categories %} + {{ cat }} + {% endfor %} +
+ +

+ Showing {{ events|length }} of {{ total }} events +

+ + {% if events %} +

Events

+
+ {% for event in events %} +
+
+ {{ event.category }} event at Berkeley +
+
+
+
{{ event.start_datetime.strftime('%d') }}
+
{{ event.start_datetime.strftime('%b %Y') }}
+
+ {{ event.category }} +
+
+

+ {{ event.title }} +

+

{{ event.description[:120] }}{% if event.description|length > 120 %}...{% endif %}

+
+
🕐 {{ event.start_datetime.strftime('%I:%M %p') }}
+
📍 {{ event.location }}
+ {% if event.registration_required %}
⚠ Registration Required
{% endif %} +
💳 {{ event.cost }}
+
+
+ Details +
+
+
+ {% endfor %} +
+ + {% if total_pages > 1 %} + + {% endif %} + + {% else %} +
+

No events found.

+ View All Events +
+ {% endif %} +
+{% endblock %} diff --git a/sites/berkeley/templates/faculty.html b/sites/berkeley/templates/faculty.html new file mode 100644 index 00000000..155f5287 --- /dev/null +++ b/sites/berkeley/templates/faculty.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Faculty Directory — UC Berkeley{% endblock %} + +{% block content %} + + +
+
+
+ + + + {% if q or current_dept %} + Clear + {% endif %} +
+
+ +

+ Showing {{ faculty_list|length }} of {{ total }} faculty members + {% if q %} matching "{{ q }}"{% endif %} +

+ + {% if faculty_list %} +

Faculty members

+
+ {% for member in faculty_list %} +
+
+ Initials avatar for {{ member.name }} +
+
+ {% if member.is_emeritus %} + Emeritus + {% endif %} +

{{ member.name }}

+

{{ member.title }}

+ {% if member.department %} +

{{ member.department.name }}

+ {% endif %} + {% if member.research_interests %} +

{{ member.research_interests[:100] }}{% if member.research_interests|length > 100 %}...{% endif %}

+ {% endif %} + +
+
+ {% endfor %} +
+ + {% if total_pages > 1 %} + + {% endif %} + + {% else %} +
+

No faculty members found.

+ View All Faculty +
+ {% endif %} +
+{% endblock %} diff --git a/sites/berkeley/templates/faculty_profile.html b/sites/berkeley/templates/faculty_profile.html new file mode 100644 index 00000000..9f9f0f2e --- /dev/null +++ b/sites/berkeley/templates/faculty_profile.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}{{ member.name }} — Berkeley Faculty{% endblock %} + +{% block content %} + + + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/index.html b/sites/berkeley/templates/index.html new file mode 100644 index 00000000..23de0a86 --- /dev/null +++ b/sites/berkeley/templates/index.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% block title %}UC Berkeley — The University of California, Berkeley{% endblock %} + +{% block content %} + +
+ {# Decorative: the h1 beside it already names the page, and a non-empty alt + would make a screen reader announce the hero twice (IMAGE_PLAN.md §6.5). #} + + {# The scrim sits over a photograph now, so both stops are computed for the + worst case (a pure-white pixel behind): white heading 8.7:1, the 18px + paragraph 7.3:1, the gold eyebrow 4.8:1 — all above the 4.5:1 floor, and + better than the opaque gradient's own light end (gold was 3.25:1). #} +
+
+
+

Est. 1868 · Berkeley, California

+

Fiat Lux.
Let There Be Light.

+

UC Berkeley is the world's premier public university, where bold ideas, transformative research, and a deep commitment to the public good have shaped the world for over 155 years.

+ +
+
+
+ + +
+
+
+
+
{{ stats.nobel_laureates }}
+
Nobel Laureates on Faculty
+
+
+
#1
+
Public Research University
+
+
+
{{ stats.top_10_programs }}+
+
Top-10 Graduate Programs
+
+
+
{{ stats.degree_programs }}+
+
Degree Programs
+
+
+
{{ stats.national_titles }}
+
NCAA National Titles
+
+
+
+
+ + +
+
+
+

Berkeley News

+ All News → +
+ {% if featured_news %} + +
+
+
+ {{ featured_news[0].category }} — Berkeley News +
+
+ {{ featured_news[0].category }} +

+ {{ featured_news[0].title }} +

+

{{ featured_news[0].summary }}

+
+ By {{ featured_news[0].author }} · {{ featured_news[0].published_date.strftime('%B %d, %Y') }} +
+
+
+
+ +
+ {% for article in featured_news[1:] %} +
+
+ {{ article.category }} — Berkeley News + {{ article.category }} +
+
+ {{ article.category }} +

{{ article.title }}

+

{{ article.summary[:120] }}{% if article.summary|length > 120 %}...{% endif %}

+

{{ article.published_date.strftime('%B %d, %Y') }}

+
+
+ {% endfor %} +
+ {% endif %} +
+
+ + +
+
+
+

Upcoming Events

+ All Events → +
+
+ {% for event in upcoming_events %} +
+
+ {{ event.category }} event at Berkeley +
+
+
{{ event.start_datetime.strftime('%d') }}
+
{{ event.start_datetime.strftime('%B %Y') }}
+
+
+ {{ event.category }} +

{{ event.title }}

+

+ 🕐 {{ event.start_datetime.strftime('%I:%M %p') }}
+ 📍 {{ event.location }} +

+
+
+ {% endfor %} +
+
+
+ + +
+
+
+

Research at Berkeley

+ All Research Centers → +
+
+ {% for center in recent_research %} +
+
+ {{ center.name }} +
+
+

{{ center.name }}

+

{{ center.description[:140] }}{% if center.description|length > 140 %}...{% endif %}

+
+
+ {% endfor %} +
+
+
+ + +
+
+

14 Schools & Colleges

+

From humanities to engineering, law to public health — explore Berkeley's diverse academic community.

+
+ {% for college in colleges %} + + {{ college.name }} + + {% endfor %} +
+ +
+
+{% endblock %} diff --git a/sites/berkeley/templates/login.html b/sites/berkeley/templates/login.html new file mode 100644 index 00000000..55771a89 --- /dev/null +++ b/sites/berkeley/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Sign In — UC Berkeley{% endblock %} + +{% block content %} +
+
+
+
C
+

Sign In to Berkeley

+

Access your bookmarks and personalized content

+
+ +
+
+ {{ form.csrf_token }} +
+ {{ form.email.label }} + {{ form.email(class="form-control", placeholder="your@berkeley.edu") }} + {% if form.email.errors %} +
{{ form.email.errors[0] }}
+ {% endif %} +
+
+ {{ form.password.label }} + {{ form.password(class="form-control", placeholder="Password") }} + {% if form.password.errors %} +
{{ form.password.errors[0] }}
+ {% endif %} +
+ +
+
+ +

+ Don't have an account? Create one +

+
+
+{% endblock %} diff --git a/sites/berkeley/templates/news.html b/sites/berkeley/templates/news.html new file mode 100644 index 00000000..d5618f10 --- /dev/null +++ b/sites/berkeley/templates/news.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Berkeley News{% endblock %} + +{% block content %} + + +
+ +
+
+ + + + + {% if q or current_category or featured %} + Clear + {% endif %} +
+
+ + +
+ All + {% for cat in categories %} + {{ cat }} + {% endfor %} +
+ +

+ Showing {{ articles|length }} of {{ total }} articles + {% if q %} for "{{ q }}"{% endif %} + {% if current_category %} in {{ current_category }}{% endif %} +

+ + {% if articles %} +

News articles

+
+ {% for article in articles %} +
+
+ {{ article.category }} — Berkeley News + {{ article.category }} + {% if article.featured %}Featured{% endif %} +
+
+ {{ article.category }} +

{{ article.title }}

+

{{ article.summary[:140] }}{% if article.summary|length > 140 %}...{% endif %}

+

+ By {{ article.author }} · {{ article.published_date.strftime('%B %d, %Y') }} + · {{ article.view_count }} views +

+
+ {% for tag in article.tags.split(',')[:3] %} + {{ tag.strip() }} + {% endfor %} +
+
+
+ {% endfor %} +
+ + + {% if total_pages > 1 %} + + {% endif %} + + {% else %} +
+

No articles found.

+ View All News +
+ {% endif %} +
+{% endblock %} diff --git a/sites/berkeley/templates/news_article.html b/sites/berkeley/templates/news_article.html new file mode 100644 index 00000000..fb0c92c2 --- /dev/null +++ b/sites/berkeley/templates/news_article.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} — Berkeley News{% endblock %} + +{% block content %} + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/program_detail.html b/sites/berkeley/templates/program_detail.html new file mode 100644 index 00000000..84d4caf8 --- /dev/null +++ b/sites/berkeley/templates/program_detail.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}{{ program.name }} ({{ program.degree_type }}) — UC Berkeley{% endblock %} + +{% block content %} + + + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/programs.html b/sites/berkeley/templates/programs.html new file mode 100644 index 00000000..2f84c0c4 --- /dev/null +++ b/sites/berkeley/templates/programs.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}Programs — UC Berkeley{% endblock %} + +{% block content %} + + +
+
+
+ + + + + {% if q or current_college or current_degree %} + Clear + {% endif %} +
+
+ + +
+ Degree: + All + {% for dt in degree_types %} + {{ dt }} + {% endfor %} +
+ +

+ Showing {{ programs|length }} of {{ total }} programs + {% if q %} matching "{{ q }}"{% endif %} +

+ + {% if programs %} +

Degree programs

+
+ {% for program in programs %} +
+
+
+ {{ program.degree_type }} + {% if program.is_online %}Online{% endif %} +
+

+ {{ program.name }} +

+ {% if program.college %} +

{{ program.college.name }}

+ {% endif %} +

{{ program.description[:130] }}{% if program.description|length > 130 %}...{% endif %}

+
+ {% if program.duration_years > 0 %}{{ program.duration_years|int if program.duration_years == program.duration_years|int else program.duration_years }} years{% endif %} + {% if program.units > 0 %} · {{ program.units }} units{% endif %} + {% if program.gre_required %} · GRE Required{% endif %} +
+ +
+
+ {% endfor %} +
+ + {% if total_pages > 1 %} + + {% endif %} + + {% else %} +
+

No programs found.

+ View All Programs +
+ {% endif %} +
+{% endblock %} diff --git a/sites/berkeley/templates/register.html b/sites/berkeley/templates/register.html new file mode 100644 index 00000000..8ac24a75 --- /dev/null +++ b/sites/berkeley/templates/register.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Create Account — UC Berkeley{% endblock %} + +{% block content %} +
+
+
+
C
+

Create Your Account

+

Join the Berkeley community

+
+ +
+
+ {{ form.csrf_token }} +
+ {{ form.username.label }} + {{ form.username(class="form-control", placeholder="Choose a username") }} + {% if form.username.errors %} +
{{ form.username.errors[0] }}
+ {% endif %} +
+
+ {{ form.full_name.label }} + {{ form.full_name(class="form-control", placeholder="Your full name") }} + {% if form.full_name.errors %} +
{{ form.full_name.errors[0] }}
+ {% endif %} +
+
+ {{ form.email.label }} + {{ form.email(class="form-control", placeholder="your@email.com") }} + {% if form.email.errors %} +
{{ form.email.errors[0] }}
+ {% endif %} +
+
+ {{ form.password.label }} + {{ form.password(class="form-control", placeholder="At least 8 characters") }} + {% if form.password.errors %} +
{{ form.password.errors[0] }}
+ {% endif %} +
+
+ {{ form.confirm.label }} + {{ form.confirm(class="form-control", placeholder="Repeat password") }} + {% if form.confirm.errors %} +
{{ form.confirm.errors[0] }}
+ {% endif %} +
+ +
+
+ +

+ Already have an account? Sign in +

+
+
+{% endblock %} diff --git a/sites/berkeley/templates/research.html b/sites/berkeley/templates/research.html new file mode 100644 index 00000000..f91e8972 --- /dev/null +++ b/sites/berkeley/templates/research.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}Research — UC Berkeley{% endblock %} + +{% block content %} + + +
+ +
+
+
+ +
+

The World's Top Public Research University

+

UC Berkeley is home to more than 200 research institutes, centers, and programs. Our faculty and students conduct groundbreaking research across every discipline, from artificial intelligence to environmental science, from genomics to urban planning.

+

Berkeley researchers have developed the technology behind the internet, discovered fundamental particles, sequenced genomes, and modeled the climate. Every year, Berkeley generates more than $1 billion in research expenditures.

+
+
+
$1B+Annual Research
+
12Nobel Laureates on Faculty
+
200+Research Institutes
+
#1Public Research Uni
+
+
+ +

Research Centers & Institutes

+ +
+ {% for center in centers %} +
+
+ {{ center.name }} +
+
+

{{ center.name }}

+ {% if center.college %} +

{{ center.college.name }}

+ {% endif %} +

{{ center.description[:160] }}{% if center.description|length > 160 %}...{% endif %}

+ {# Director, founding year and focus areas live on the centre's own page: + rendering them on the listing would show task answers before the + required detail-page visit. #} + +
+
+ {% endfor %} +
+ + +
+

Research by School

+
+ {% for college in colleges %} + {% set college_centers = centers | selectattr('college_id', 'equalto', college.id) | list %} + {% if college_centers %} +
+

{{ college.name }}

+

{{ college_centers|length }} center{{ 's' if college_centers|length != 1 else '' }}

+
+ {% endif %} + {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/berkeley/templates/research_center.html b/sites/berkeley/templates/research_center.html new file mode 100644 index 00000000..749a9ff4 --- /dev/null +++ b/sites/berkeley/templates/research_center.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}{{ center.name }} — Berkeley Research{% endblock %} + +{% block content %} + + + + +
+ +
+{% endblock %} diff --git a/sites/berkeley/templates/search.html b/sites/berkeley/templates/search.html new file mode 100644 index 00000000..0ee46c82 --- /dev/null +++ b/sites/berkeley/templates/search.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} +{% block title %}Search{% if q %}: {{ q }}{% endif %} — UC Berkeley{% endblock %} + +{% block content %} + + +
+ +
+
+ + +
+
+ + {% if q %} +

+ Found {{ total }} result{{ 's' if total != 1 else '' }} for "{{ q }}" +

+ + {% if total == 0 %} +
+

No results found for "{{ q }}"

+

Try different keywords, or browse our content directly:

+ +
+ {% else %} + + + {% if results.programs %} +
+

+ 🏫 Degree Programs + {{ results.programs|length }} result{{ 's' if results.programs|length != 1 else '' }} +

+
+ {% for p in results.programs %} +
+
+ {{ p.degree_type }} +

{{ p.name }}

+ {% if p.college %}

{{ p.college.name }}

{% endif %} +
+
+ {% endfor %} +
+ +
+ {% endif %} + + + {% if results.news %} +
+

+ 📰 News Articles + {{ results.news|length }} result{{ 's' if results.news|length != 1 else '' }} +

+
+ {% for article in results.news %} +
+ {{ article.category }} +
+

{{ article.title }}

+

{{ article.published_date.strftime('%B %d, %Y') }} · {{ article.author }}

+
+
+ {% endfor %} +
+ +
+ {% endif %} + + + {% if results.faculty %} +
+

+ 👥 Faculty + {{ results.faculty|length }} result{{ 's' if results.faculty|length != 1 else '' }} +

+
+ {% for member in results.faculty %} +
+
+

{{ member.name }}

+

{{ member.title }}

+ {% if member.department %}

{{ member.department.name }}

{% endif %} +
+
+ {% endfor %} +
+ +
+ {% endif %} + + + {% if results.events %} +
+

+ 📅 Events + {{ results.events|length }} result{{ 's' if results.events|length != 1 else '' }} +

+
+ {% for event in results.events %} +
+
+
{{ event.start_datetime.strftime('%d') }}
+
{{ event.start_datetime.strftime('%b') }}
+
+
+

{{ event.title }}

+

{{ event.location }} · {{ event.category }}

+
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if results.research %} +
+

+ 🔬 Research Centers + {{ results.research|length }} result{{ 's' if results.research|length != 1 else '' }} +

+
+ {% for center in results.research %} +
+
+

{{ center.name }}

+ {# Director and focus areas stay on the centre's own page: the results + card must not hand over a task answer before the required visit. #} + {% if center.college %}

{{ center.college.name }}

{% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + + {% endif %} + {% else %} + +
+

Enter a search term above to find programs, faculty, news, events, and research centers.

+ +
+ {% endif %} +
+{% endblock %} diff --git a/sites/berkeley/tests/face_control.png b/sites/berkeley/tests/face_control.png new file mode 100644 index 00000000..e4a29ca7 Binary files /dev/null and b/sites/berkeley/tests/face_control.png differ diff --git a/sites/berkeley/tests/test_answer_leaks.py b/sites/berkeley/tests/test_answer_leaks.py new file mode 100644 index 00000000..d3baec6d --- /dev/null +++ b/sites/berkeley/tests/test_answer_leaks.py @@ -0,0 +1,379 @@ +"""Answer-leak sweep: task answer facts must not appear before discovery. + +Follows the maintainers' ``webmd_doctor/tests/test_answer_leaks.py`` pattern: +every accepted task's answer facts are enumerated from ``verify/ground_truth.py`` +(never "ground truth minus the ques tokens" — that anti-pattern is checked in +``verify/tests/test_tasks_contract.py``), classified, and asserted absent from +the rendered surfaces *outside the task's own discovery route* (the routes its +verifier requires). + +Value classes +- UNIQUE: one-off facts that identify the answer — person names (directors, + chairs, deans), award names, focus-area phrases, requirement items, exact + figures tied to one entity. These must not be rendered on any surface outside + the task's discovery set, except the documented SHARED entries below. +- GENERIC: catalogue vocabulary that legitimately appears everywhere (college + and department names, degree types, programme names, single common words, + small integers such as durations/counts). A shared word cannot identify an + answer; scoring still binds it to the target entity's required page. + +Documented SHARED occurrences (each carries its reason) are listed in +``SHARED``; everything else must be absent, so a new leak fails this test. + +The entity-bound tests at the bottom pin the two listing-card leak classes the +review found: research-centre cards (index / /research / search results / +related-centre blocks) must not render a centre's director, founding year or +focus areas, and department listing cards must not render the chair/location. +Both were mutation-verified (injecting the field back into the template fails +the test). +""" +from __future__ import annotations + +import os +import re +import sqlite3 +import sys +from pathlib import Path + +import pytest + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / "instance_seed" / "berkeley.db" + +os.environ["WEBSYN_SKIP_BOOTSTRAP"] = "1" +sys.path.insert(0, str(SITE)) +sys.path.insert(0, str(SITE / "verify")) + +import ground_truth # noqa: E402 + +# Routes each task's verifier requires (its discovery surface). +DISCOVERY = { + 1: [r"/programs(?:\?|$)", r"/programs/business-administration-mba$"], + 2: [r"/programs(?:\?|$)", r"/programs/computer-science-bs$"], + 4: [r"/news(?:\?|$)", r"/news/crispr-pioneer"], + 6: [r"/events(?:\?|$)", r"/events/\d+$"], + 7: [r"/faculty(?:\?|$)", r"/faculty/stuart-russell$", r"/departments/eecs$"], + 10: [r"/research(?:\?|$)", r"/research/bair$"], + 11: [r"/admissions$"], + 12: [r"/programs(?:\?|$)", r"/programs/business-administration-mba$"], + 13: [r"/departments$", r"/departments/eecs$"], + 14: [r"/academics$"], + 16: [r"/programs(?:\?|$)", r"/programs/data-science-ms$"], + 17: [r"/about$"], + 19: [r"/news(?:\?|$)", r"/news/(womens-gymnastics|cal-wins-pac-12)"], + 20: [r"/programs(?:\?|$)", r"/programs/juris-doctor-jd$"], + 22: [r"/departments$"], + 23: [r"/research(?:\?|$)", r"/research/bids$"], + 24: [r"/programs(?:/economics-phd|\?|$)", r"/departments/economics$", r"/faculty/"], + 25: [r"/events(?:\?|$)", r"/events/\d+$"], + 27: [r"/programs(?:\?|$)", r"/programs/master-of-engineering-meng$", + r"/programs/computer-science-ms$"], + 28: [r"/programs(?:\?|$)"], + 30: [r"/login$", r"/research/seismo-lab$", r"/account$"], + 31: [r"/login$", r"/research/(msri|cpl)$", r"/account$"], +} + +# Catalogue vocabulary / shared words: a match cannot identify the answer. +GENERIC_VALUES = { + "haas school of business", "college of engineering", + "college of letters and science", "school of information", + "electrical engineering and computer sciences", + "department of electrical engineering and computer sciences", + "berkeley artificial intelligence research lab", "statistics", "economics", + "artificial intelligence", "machine learning", "ai safety", "ai", + "computer architecture", "algorithms", "software engineering", "phd", "ms", + "master of engineering", "computer science", "data science", "mba", "j.d.", + "juris doctor", "february 1", "november 30", "online", + "business administration", # the programme's catalogue name +} + +# Genuine co-occurrences outside the discovery route, with the reason. +SHARED = { + (4, "National Medal of Science"): "the award is in the article's own headline, which the home page features as site publicity", + (4, "Jennifer Doudna"): "public figure named in headlines, event titles and department prose; the verifier requires the CRISPR article visit and the award+person binding", + (6, "Nobel Laureate Lecture: Jennifer Doudna on the Future of Gene Editing"): "home-page 'Upcoming Events' promo; the verifier requires the /events?category=Lecture listing and binds 3 events", + (6, "Berkeley AI Lab Open House"): "home-page 'Upcoming Events' promo of the same listing the task must open", + (10, "2013"): "BIDS and BAIR share the founding year; a same-value row on another centre's page is not the BAIR answer", + (14, "Dean Tsu-Jae King Liu"): "a Berkeley News article reports on the dean; the verifier requires the /academics card", + (17, "105"): "home-page stat tile repeats the About-page NCAA-title figure; the verifier requires the /about visit", + (19, "Women's Gymnastics Wins NCAA Championship"): "related-article links on other Athletics articles; the verifier requires /news?category=Athletics and a championship article visit", + (20, "February 1"): "the graduate deadline string is rendered by other programme pages too", + (23, "Statistics"): "a department/interest word, not identifying", + (23, "Computational Methods"): "a focus phrase SCCN and EECS faculty also use; not identifying BIDS", + (25, "Spring Career Fair 2026"): "the event is named in the ques and is catalogue listing data; the verifier requires the /events?category=Career listing and the event page", + (24, "Emmanuel Saez"): "named in a Berkeley News article; the verifier requires the faculty profile visit and binds the interests", + (31, "Prof. Tatiana Toro"): "she chairs the Mathematics department as well as directing MSRI; another entity's page is not the MSRI answer", +} + +SURFACE_PATHS = [ + "/", "/about", "/academics", "/admissions", "/departments", "/research", + "/news", "/news?q=CRISPR", "/news?category=Athletics", "/news?featured=1", + "/programs", "/programs?q=MBA", "/programs?q=Computer%20Science", + "/programs?q=Master%20of%20Engineering", "/programs?degree=PhD", + "/programs?degree=MS", "/programs?college=haas-business", "/programs?page=2", + "/events", "/events?category=Lecture", "/events?category=Career", + "/search?q=Berkeley", "/search?q=MBA", "/search?q=Economics", "/search?q=Data Science", + "/faculty", "/faculty?dept=eecs", "/login", "/register", "/nope-404", +] + + +def _facts(): + return ground_truth.all_ground_truth(str(SEED)) + + +def scan_values(facts: dict) -> dict[int, list[tuple[str, str]]]: + """task -> [(label, value)] for the UNIQUE-class answer facts.""" + out: dict[int, list[tuple[str, str]]] = {} + + def add(n, label, value): + if value in (None, "", [], {}): + return + text = str(value).strip() + if not text or text.lower() in GENERIC_VALUES: + return + out.setdefault(n, []).append((label, text)) + + for n, row in facts.items(): + if n == 1: + add(n, "college", row["college"]) + elif n == 2: + for i, item in enumerate(row["items"]): + add(n, f"requirement{i}", item) + elif n == 4: + add(n, "person", row["person"]); add(n, "award", row["award"]) + elif n == 6: + for i, ev in enumerate(row["upcoming"][:6]): + add(n, f"event_title{i}", ev["title"]) + elif n == 7: + # The verifier accepts any AI-family EECS professor, and a faculty + # name is directory data (shown on the listing the task must open), + # so the identity is catalogue vocabulary; the graded binding is the + # profile visit plus the interest tokens. + pass + elif n == 10: + add(n, "director", row["director"]); add(n, "founded", row["founded_year"]) + elif n == 11: + add(n, "deadline", row["deadline"]); add(n, "rate", row["acceptance_rate"]) + elif n == 12: + add(n, "programme", row["programmes"][0]["name"]) + elif n == 13: + add(n, "chair", row["chair"]); add(n, "location", row["location"]) + elif n == 14: + add(n, "dean", row["dean"]) + elif n == 16: + add(n, "programme", row["program"]["name"]) + elif n == 17: + add(n, "nobel", row["nobel_laureates"]); add(n, "sports", row["varsity_sports"]) + add(n, "titles", row["national_titles"]) + elif n == 19: + for i, art in enumerate(row["championships"]): + add(n, f"championship_title{i}", art["title"]) + elif n == 20: + add(n, "deadline", row["deadline"]) + elif n == 22: + add(n, "count", len(row["departments"])) + elif n == 23: + add(n, "director", row["centre"]["director"]) + for i, area in enumerate(row["focus_areas"]): + add(n, f"focus{i}", area) + # related-centre names are the catalogue's own centre names (the + # /research listing shows them all and the ques quotes the target), + # so they are not scanned as unique facts. + elif n == 24: + add(n, "chair", row["chair"]); add(n, "member", "Emmanuel Saez") + elif n == 25: + add(n, "anchor", row["anchor"]["title"]) + elif n == 27: + add(n, "department", row["department"]) + elif n == 28: + add(n, "count", row["count"]); add(n, "degree", row["most_common_degree"]) + elif n == 30: + add(n, "director", row["director"]) + elif n == 31: + add(n, "director1", row["directors"][0]); add(n, "director2", row["directors"][1]) + return out + + +@pytest.fixture(scope="module") +def client(): + import app as app_module + + app_module.app.config["TESTING"] = True + with app_module.app.test_client() as test_client: + yield test_client + + +def _all_paths() -> list[str]: + paths = list(SURFACE_PATHS) + con = sqlite3.connect(SEED) + try: + for (slug,) in con.execute("SELECT slug FROM programs"): + paths.append(f"/programs/{slug}") + for (slug,) in con.execute("SELECT slug FROM news_articles"): + paths.append(f"/news/{slug}") + for (slug,) in con.execute("SELECT slug FROM research_centers"): + paths.append(f"/research/{slug}") + for (slug,) in con.execute("SELECT slug FROM departments"): + paths.append(f"/departments/{slug}") + for (slug,) in con.execute("SELECT slug FROM faculty"): + paths.append(f"/faculty/{slug}") + for (eid,) in con.execute("SELECT id FROM events"): + paths.append(f"/events/{eid}") + finally: + con.close() + return sorted(set(paths)) + + +def _outside_discovery(task: int, path: str) -> bool: + return not any(re.search(pattern, path) for pattern in DISCOVERY[task]) + + +def _digit_bound(value: str, haystack: str) -> bool: + """Match with digit boundaries so '12' does not hit '1,200' or '14.4'.""" + return re.search(r"(? {response.status_code}" + body = response.get_data(as_text=True) + assert "Director:" not in body, f"{path} renders a Director field" + assert "Chair:" not in body, f"{path} renders a Chair field" + + +def _card_windows(html: str, slug: str, window: int = 700) -> list[str]: + """Text windows around every link to /research/ (its listing cards).""" + windows = [] + for match in re.finditer(rf"/research/{re.escape(slug)}", html): + windows.append(html[max(0, match.start() - window): match.end() + window]) + return windows + + +def test_related_centre_cards_hide_director_founded_and_focus(client): + """Entity-bound: the related-centre cards on another centre's page (and the + home page / search cards) must not render a centre's director, founding year + or focus-area tags before its own detail page is visited.""" + facts = _facts() + targets = [facts[10]["centre"], facts[23]["centre"], facts[30]["centre"], + facts[31]["first"], facts[31]["second"]] + pages = ["/", "/research", "/search?q=Berkeley"] + pages += [f"/research/{t['slug']}" for t in targets] + checked = 0 + for path in pages: + response = client.get(path) + if response.status_code != 200: + continue + body = response.get_data(as_text=True) + for centre in targets: + if path == f"/research/{centre['slug']}": + continue # the centre's own detail page is where they belong + windows = _card_windows(body, centre["slug"]) + if not windows: + continue + for window in windows: + assert centre["director"] not in window, ( + f"{path}: card for {centre['slug']} renders its director") + assert f">{centre['founded_year']}<" not in window, ( + f"{path}: card for {centre['slug']} renders its founding year") + for area in (centre["focus_areas"] or "").split(","): + area = area.strip() + if len(area) < 4 or area.lower() in GENERIC_VALUES: + continue + assert f">{area}<" not in window, ( + f"{path}: card for {centre['slug']} renders focus area {area!r}") + checked += 1 + assert checked >= 6, f"too few centre cards inspected ({checked})" + # positive control: each centre's own detail page still carries them + for centre in targets: + page = client.get(f"/research/{centre['slug']}").get_data(as_text=True) + assert centre["director"] in page, f"/research/{centre['slug']} lost its director" + assert str(centre["founded_year"]) in page, f"/research/{centre['slug']} lost founded_year" + + +def test_department_listing_cards_hide_chair_and_location(client): + """Entity-bound: the /departments cards must not render a chair or location + (they belong to the department's own page, which tasks 13/24 require).""" + facts = _facts() + eecs = facts[13] + economics = facts[24] + body = client.get("/departments").get_data(as_text=True) + for field in (eecs["chair"], economics["chair"]): + assert field not in body, f"/departments: department card renders {field!r}" + # positive control: the detail pages still carry them + page = client.get(f"/departments/{eecs['department']['slug']}").get_data(as_text=True) + assert eecs["chair"] in page and eecs["location"] in page + page = client.get(f"/departments/{economics['department']['slug']}").get_data(as_text=True) + assert economics["chair"] in page + + +def test_no_verifier_uses_ground_truth_minus_ques_tokens(): + """Appendix A §3 anti-pattern: the answer set must never be computed by + subtracting the question's tokens from the ground truth.""" + verifiers = sorted(SITE.glob("verify/verify_*.py")) + [SITE / "verify/verify_lib.py"] + forbidden = re.compile(r"ques[a-z_]*\s*[-−]|-\s*set\(\s*ques|difference\(.*ques|remove\(.*ques", + re.I) + hits = [] + for file in verifiers: + text = file.read_text() + for match in forbidden.finditer(text): + hits.append(f"{file.name}: {match.group(0)!r}") + assert not hits, f"ques-subtraction anti-pattern found: {hits}" diff --git a/sites/berkeley/tests/test_app_robustness.py b/sites/berkeley/tests/test_app_robustness.py new file mode 100644 index 00000000..53029130 --- /dev/null +++ b/sites/berkeley/tests/test_app_robustness.py @@ -0,0 +1,254 @@ +"""App-robustness regression tests (Appendix A §1 forms, §6 hardening). + +Every test here was written after the corresponding probe failed on the +pre-fix tree, and each detector was mutation-checked by re-introducing the +defect and confirming the test then fails. + +Covers: POST-only logout, session-cookie forgery and malformed user ids, +bookmark-form validation (empty/invalid -> 400, unknown row -> 404, another +user's row -> 404), bounded query/form integers, same-origin redirects, +SQLite foreign-key enforcement, read-only GETs, the 404/500 handlers, form +preselection, and the accessibility chrome the maintainers require. +""" +from __future__ import annotations + +import hashlib +import os +import re +import sys +from pathlib import Path + +import pytest + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / "instance_seed" / "berkeley.db" +RUNTIME = SITE / "instance" / "berkeley.db" + +os.environ["WEBSYN_SKIP_BOOTSTRAP"] = "1" +sys.path.insert(0, str(SITE)) + + +@pytest.fixture(scope="module") +def app_module(): + import app + + return app + + +@pytest.fixture() +def client(app_module): + app_module.app.config["TESTING"] = True + app_module.app.config["WTF_CSRF_ENABLED"] = False + with app_module.app.test_client() as test_client: + yield test_client + app_module.app.config["WTF_CSRF_ENABLED"] = True + + +def _login(client, email="alice@berkeley.edu", password="test1234"): + return client.post("/login", data={"email": email, "password": password}, + follow_redirects=False) + + +def _md5(path: Path) -> str: + return hashlib.md5(path.read_bytes()).hexdigest() + + +# --------------------------------------------------------------------------- # +# §6 — auth and session hardening +# --------------------------------------------------------------------------- # +def test_logout_is_post_only(client): + assert _login(client).status_code == 302 + assert client.get("/account").status_code == 200 + # a prefetcher's GET/HEAD must not end the session + assert client.get("/logout").status_code == 405 + assert client.head("/logout").status_code == 405 + assert client.get("/account").status_code == 200 + # the real POST logs out + assert client.post("/logout").status_code == 302 + assert client.get("/account").status_code == 302 # bounced to /login + + +def test_secret_key_is_not_a_committed_literal(app_module): + source = (SITE / "app.py").read_text() + assert "berkeley-mirror-secret-key" not in source + assert "os.environ.get('BERKELEY_SECRET_KEY') or secrets.token_hex(32)" in source + assert app_module.app.config["SECRET_KEY"] != "berkeley-mirror-secret-key-2024" + + +def test_wrong_key_session_cookie_is_rejected(app_module): + from flask.json.tag import TaggedJSONSerializer + from itsdangerous import URLSafeTimedSerializer + + serializer = URLSafeTimedSerializer( + "wrong-key-not-the-site-key", salt="cookie-session", + serializer=TaggedJSONSerializer(), + signer_kwargs={"key_derivation": "hmac"}) + cookie = serializer.dumps({"_user_id": "1", "_fresh": True}) + with app_module.app.test_client() as test_client: + response = test_client.get("/account", headers={"Cookie": f"session={cookie}"}) + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_malformed_user_id_fails_closed(app_module): + """A signed cookie with a non-numeric / huge id must be anonymous, not a 500.""" + signer = app_module.app.session_interface.get_signing_serializer(app_module.app) + for bad in ("not-a-number", "9" * 20): + cookie = signer.dumps({"_user_id": bad, "_fresh": True}) + with app_module.app.test_client() as test_client: + response = test_client.get("/account", headers={"Cookie": f"session={cookie}"}) + assert response.status_code == 302, f"{bad!r} -> {response.status_code}" + + +def test_csrf_is_enforced_and_unexempted(app_module): + app_module.app.config["WTF_CSRF_ENABLED"] = True + try: + with app_module.app.test_client() as test_client: + assert test_client.post("/login", data={"email": "a@b.co", "password": "x"} + ).status_code == 400 + assert test_client.post("/bookmark/add", data={"item_type": "research", + "item_id": "1"}).status_code == 400 + finally: + app_module.app.config["WTF_CSRF_ENABLED"] = False + + +# --------------------------------------------------------------------------- # +# §1 — bookmark form: empty/invalid submissions fail loudly +# --------------------------------------------------------------------------- # +def test_bookmark_add_rejects_invalid_submissions(client): + assert _login(client).status_code == 302 + assert client.post("/bookmark/add", data={}).status_code == 400 + assert client.post("/bookmark/add", data={"item_type": "bogus", "item_id": "1"}).status_code == 400 + assert client.post("/bookmark/add", data={"item_type": "research", "item_id": "999999"}).status_code == 404 + assert client.post("/bookmark/add", data={"item_type": "research", "item_id": "9" * 20}).status_code == 400 + # valid submission still works + assert client.post("/bookmark/add", data={"item_type": "research", "item_id": "1"} + ).status_code == 302 + + +def test_bookmark_remove_is_owner_scoped(app_module): + import sqlite3 + + app_module.app.config["TESTING"] = True + app_module.app.config["WTF_CSRF_ENABLED"] = False + try: + alice = app_module.app.test_client() + assert _login(alice, "alice@berkeley.edu").status_code == 302 + alice.post("/bookmark/add", data={"item_type": "research", "item_id": "1"}) + con = sqlite3.connect(RUNTIME) + row = con.execute("SELECT b.id FROM bookmarks b JOIN users u ON u.id=b.user_id " + "WHERE u.email='alice@berkeley.edu' ORDER BY b.id DESC LIMIT 1").fetchone() + con.close() + bookmark_id = row[0] + + bob = app_module.app.test_client() + assert _login(bob, "bob@berkeley.edu").status_code == 302 + # bob cannot delete alice's row (and a malformed id is a 400) + assert bob.post("/bookmark/remove", + data={"bookmark_id": str(bookmark_id)}).status_code == 404 + assert bob.post("/bookmark/remove", data={"bookmark_id": "x"}).status_code == 400 + # alice's row survived + con = sqlite3.connect(RUNTIME) + still = con.execute("SELECT count(*) FROM bookmarks WHERE id=?", (bookmark_id,)).fetchone()[0] + con.close() + assert still == 1 + finally: + app_module.app.config["WTF_CSRF_ENABLED"] = True + + +# --------------------------------------------------------------------------- # +# §6 — bounds, redirects, foreign keys, read-only, error pages +# --------------------------------------------------------------------------- # +def test_huge_ints_are_not_500s(client): + for path in ("/events/99999999999999999999", "/news?page=99999999999999999999", + "/programs?page=99999999999999999999", "/faculty?page=99999999999999999999", + "/events?page=99999999999999999999"): + response = client.get(path) + assert response.status_code in (200, 404), f"{path} -> {response.status_code}" + + +def test_redirect_targets_stay_same_origin(client): + response = client.post("/login?next=https://evil.example/", + data={"email": "alice@berkeley.edu", "password": "test1234"}) + assert response.status_code == 302 + assert response.headers["Location"].startswith("/") + assert "evil.example" not in response.headers["Location"] + assert _login(client).status_code == 302 + response = client.post("/bookmark/add", data={"item_type": "research", "item_id": "1", + "next": "https://evil.example/"}) + assert response.status_code == 302 + assert not response.headers["Location"].startswith("http") + + +def test_sqlite_foreign_keys_enforced(app_module): + from sqlalchemy.exc import IntegrityError + + with app_module.app.app_context(): + with app_module.db.engine.begin() as conn: + assert conn.exec_driver_sql("PRAGMA foreign_keys").scalar() == 1 + with pytest.raises(IntegrityError): + with app_module.db.engine.begin() as conn: + conn.exec_driver_sql( + "INSERT INTO bookmarks (user_id, item_type, item_id, note, created_at) " + "VALUES (999999, 'research', 1, 'orphan', '2026-05-12 00:00:00')") + + +def test_read_only_gets_do_not_write(client): + before = _md5(RUNTIME) + for path in ("/", "/news?q=climate", "/programs?q=MBA", "/events?category=Lecture", + "/research", "/research/bair", "/departments", "/departments/eecs", + "/academics", "/admissions", "/about", "/search?q=climate", + "/faculty?dept=eecs", "/faculty/stuart-russell", "/login", "/register", + "/news/crispr-pioneer-jennifer-doudna-receives-national-medal-of-science"): + assert client.get(path).status_code == 200, path + assert _md5(RUNTIME) == before + + +def test_error_handlers_render_their_templates(client, app_module): + response = client.get("/no-such-page") + assert response.status_code == 404 + assert "Page Not Found" in response.get_data(as_text=True) + with app_module.app.test_request_context("/x"): + body, status = app_module.server_error(RuntimeError("boom")) + assert status == 500 and "Something Went Wrong" in body + + +# --------------------------------------------------------------------------- # +# §1 — no answer-bearing form defaults; §9 — accessibility chrome +# --------------------------------------------------------------------------- # +FORM_PAGES = ("/login", "/register", "/search", "/programs", "/news", "/events", "/faculty", + "/programs/business-administration-mba", "/research/bair", + "/news/crispr-pioneer-jennifer-doudna-receives-national-medal-of-science") +ALLOW_NONEMPTY = {"csrf_token", "item_type", "item_id", "next", "bookmark_id"} + + +def test_forms_arrive_empty_and_unchecked(client): + for path in FORM_PAGES: + body = client.get(path).get_data(as_text=True) + for tag in re.findall(r"]*>", body, re.I): + name = re.search(r'name="([^"]*)"', tag) + value = re.search(r'value="([^"]*)"', tag) + if name and value and value.group(1) and name.group(1) not in ALLOW_NONEMPTY: + raise AssertionError(f"{path}: {name.group(1)} arrives as {value.group(1)!r}") + assert "checked" not in tag.lower(), f"{path}: a checkbox arrives checked" + for match in re.finditer(r']*name="([^"]*)"[^>]*>(.*?)', body, re.I | re.S): + name, inner = match.group(1), match.group(2) + for opt in re.finditer(r"]*)>", inner, re.I): + if "selected" in opt.group(1).lower(): + value = re.search(r'value="([^"]*)"', opt.group(1)) + # the /events date filter defaults to the site's own + # "Upcoming" view; every task-relevant filter is empty-first + assert value and value.group(1) == "upcoming" and name == "date", ( + f"{path}: select {name} preselects {opt.group(0)!r}") + + +def test_accessibility_chrome_present(client): + body = client.get("/").get_data(as_text=True) + assert 'class="skip-link"' in body and 'id="main"' in body + assert "mirror-notice" in body and "Unofficial offline benchmark mirror" in body + assert "focus-visible" in body, "focus-visible styles missing" + assert 'role="alert"' in body or 'role="note"' in body + # the sign-out control is a POST form, not a GET link + _login(client) + body = client.get("/").get_data(as_text=True) + assert 'action="/logout"' in body and "Sign Out" in body diff --git a/sites/berkeley/tests/test_generated_assets.py b/sites/berkeley/tests/test_generated_assets.py new file mode 100644 index 00000000..55a5d5d9 --- /dev/null +++ b/sites/berkeley/tests/test_generated_assets.py @@ -0,0 +1,230 @@ +"""Generated-asset gate for the UC Berkeley mirror (82 scenes + 82 avatars). + +`static/images/` is a Hugging Face shipped artifact (`.requires-images`), so a +fresh clone has no images until `scripts/fetch_assets.sh` runs. The whole module +skips in that state rather than failing — the same "verify when present" pattern +`tests/test_integration.py` uses for the build-generated seed — and +`check_generated_assets.py` remains the hard gate in the Docker build. + +What is checked when the bundle *is* present: + +* the inventory verifies (count, coverage, extension/kind, SHA-256, decode, + planned dimensions) through the same code the build runs; +* no undeclared file sits under any managed root; +* every `` the templates render resolves to a file on disk; +* the §9 QA gates pass over every scene, each with a positive control so a + green run demonstrates the detector can fire rather than demonstrating + nothing was tested (scripts/IMAGE_PLAN.md §9.3). +""" +from __future__ import annotations + +import io +import json +import os +import re +import shutil +import sys +from pathlib import Path + +import pytest + +SITE = Path(__file__).resolve().parents[1] +SEED = SITE / "instance_seed" / "berkeley.db" +RUNTIME = SITE / "instance" / "berkeley.db" +IMAGES = SITE / "static" / "images" +MANIFEST = SITE / "generated_asset_inventory.json" + +EXPECTED_FILES = 164 +SIZES = {"faculty": (256, 256)} +SCENE_SIZE = (1024, 768) +MANAGED_ROOTS = ( + "static/images/campus", "static/images/colleges", "static/images/research", + "static/images/news", "static/images/events", "static/images/faculty", +) + +os.environ["WEBSYN_SKIP_BOOTSTRAP"] = "1" +if str(SITE) not in sys.path: + sys.path.insert(0, str(SITE)) + + +def bundle_present() -> bool: + return any((SITE / root).is_dir() and any((SITE / root).glob("*")) + for root in MANAGED_ROOTS) + + +pytestmark = pytest.mark.skipif( + not bundle_present(), + reason="static/images/ is empty — run scripts/fetch_assets.sh berkeley first") + + +def scene_paths() -> list[Path]: + root = SITE / "static/images" + return sorted(path for path in root.rglob("*.webp")) + + +# --------------------------------------------------------------------------- # +# Inventory / coverage / dimensions +# --------------------------------------------------------------------------- # +def test_inventory_verifies(): + import check_generated_assets + + # OCR is asserted separately below; this is the exact build-time call. + assert check_generated_assets.verify(ocr=False) == EXPECTED_FILES + + +def test_no_undeclared_files_under_managed_roots(): + declared = {row["path"] for row in json.loads(MANIFEST.read_text())["assets"]} + actual = { + path.relative_to(SITE).as_posix() + for root in MANAGED_ROOTS + for path in (SITE / root).rglob("*") + if path.is_file() and path.name != ".gitkeep" + } + assert actual == declared + assert len(declared) == EXPECTED_FILES + + +def test_every_image_decodes_at_its_planned_dimensions(): + from PIL import Image + + sizes_seen: dict[str, int] = {} + for row in json.loads(MANIFEST.read_text())["assets"]: + expected = SIZES.get(row["kind"], SCENE_SIZE) + with Image.open(SITE / row["path"]) as image: + image.load() + assert image.size == expected, f"{row['path']}: {image.size} != {expected}" + sizes_seen[str(expected)] = sizes_seen.get(str(expected), 0) + 1 + assert sizes_seen == {"(1024, 768)": 82, "(256, 256)": 82} + + +# --------------------------------------------------------------------------- # +# Every rendered resolves +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="module") +def client(): + if not RUNTIME.is_file() and SEED.is_file(): + RUNTIME.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(SEED, RUNTIME) + import app as app_module + + app_module.app.config["TESTING"] = True + with app_module.app.test_client() as test_client: + yield test_client + + +def rendered_paths() -> list[str]: + import sqlite3 + + paths = ["/", "/about", "/academics", "/admissions", "/departments", "/research", + "/news", "/news?category=Athletics", "/events", "/events?category=Lecture", + "/faculty", "/faculty?dept=eecs", "/search?q=Berkeley", "/programs"] + con = sqlite3.connect(RUNTIME) + try: + for table, column in (("colleges", "slug"), ("departments", "slug"), + ("programs", "slug"), ("news_articles", "slug"), + ("research_centers", "slug"), ("faculty", "slug")): + paths += [f"/{table.replace('_articles', '')}/{slug}" + for (slug,) in con.execute(f"select slug from {table}")] + paths += [f"/events/{rowid}" for (rowid,) in con.execute("select id from events")] + finally: + con.close() + return sorted(set(paths)) + + +def test_every_rendered_image_resolves(client): + seen: set[str] = set() + missing: list[str] = [] + for path in rendered_paths(): + response = client.get(path) + if response.status_code != 200: + continue + body = response.get_data(as_text=True) + for source in re.findall(r']+src="([^"]+)"', body): + seen.add(source) + if source.startswith(("http://", "https://", "data:")): + continue + if not source.startswith("/static/"): + missing.append(f"{path}: {source} is not a static asset") + continue + if not (SITE / source.lstrip("/")).is_file(): + missing.append(f"{path}: {source}") + assert not missing, "rendered image(s) do not resolve:\n" + "\n".join(missing[:20]) + assert len(seen) > 40, f"only {len(seen)} distinct rendered" + for source in seen: + if source.startswith("/static/images/"): + assert (SITE / source.lstrip("/")).is_file() + + +# --------------------------------------------------------------------------- # +# §9 QA gates over every scene, with positive controls +# --------------------------------------------------------------------------- # +def test_ocr_pass_finds_no_large_legible_text(tmp_path): + import check_generated_assets as cga + + if cga.ocr_tokens(scene_paths()[0]) is None: + pytest.skip("pytesseract or its binary is absent — OCR recorded as not tested") + + # Positive control: rendered text must be detected, or this check proves + # nothing. scripts/IMAGE_PLAN.md §9.1 records what the detector does *not* catch. + # Written to tmp_path, never into the served tree the inventory gates. + from PIL import Image, ImageDraw + + scribble = tmp_path / "ocr_control.png" + with Image.new("RGB", SCENE_SIZE, (255, 255, 255)) as canvas: + ImageDraw.Draw(canvas).text((60, 340), "BERKELEY RESEARCH LABORATORY", + fill=(0, 0, 0)) + canvas.save(scribble) + control_tokens = cga.ocr_tokens(scribble) + assert control_tokens and len(control_tokens) > cga.OCR_MAX_TOKENS, ( + f"OCR control did not fire ({control_tokens}) — the detector is unproven") + + over = [(str(path.relative_to(SITE)), cga.ocr_tokens(path)) + for path in scene_paths()] + over = [(name, found) for name, found in over + if found and len(found) > cga.OCR_MAX_TOKENS] + assert not over, f"legible text detected in {len(over)} scene(s): {over[:5]}" + + +def test_face_pass_finds_no_frontal_face(): + """A coarse tripwire, not the enforcement of the no-faces rule. + + scripts/IMAGE_PLAN.md §9.2 records the measured false-positive / false-negative + trade-off: these settings keep clean scenes at zero boxes and therefore miss + small and profile faces too. The prompt and the by-eye contact sheet are what + the rule actually rests on. + """ + import check_generated_assets as cga + + control = cga.FACE_CONTROL + assert control.is_file(), ( + f"the face positive control is missing from {control} — the detector " + "would be unproven") + assert cga.detect_faces(control), ( + "the face control produced no detection — the detector is broken or unproven") + + flagged = [str(path.relative_to(SITE)) for path in scene_paths() + if cga.detect_faces(path)] + assert not flagged, f"frontal face detected in {len(flagged)} scene(s): {flagged[:5]}" + + +def test_no_scene_is_letterboxed(): + """No frame may carry black bars: a 16:9 render inside the 4:3 canvas. + + Deterministic and cheap, and it is checked here as well as in the inventory + gate because it is the one defect class a by-eye pass found that no other + automated check would have (scripts/IMAGE_PLAN.md §9.3). + """ + import check_generated_assets as cga + + flagged = [str(path.relative_to(SITE)) for path in scene_paths() + if cga.is_letterboxed(path)] + assert not flagged, f"letterboxed frame(s) with black bars: {flagged}" + + +def test_flagged_rows_are_reported_not_silently_accepted(): + """A row the generator could not pass through QA must say so.""" + rows = json.loads(MANIFEST.read_text())["assets"] + flagged = [row["path"] for row in rows if row.get("qa") == "flagged"] + assert not flagged, ( + "these scenes exhausted both QA attempts and need a human look before " + f"they are shipped: {flagged}") diff --git a/sites/berkeley/tests/test_integration.py b/sites/berkeley/tests/test_integration.py new file mode 100644 index 00000000..db7078ce --- /dev/null +++ b/sites/berkeley/tests/test_integration.py @@ -0,0 +1,125 @@ +"""Integration checks for the UC Berkeley mirror (registry, marker, tasks, seed). + +Follows the walmart_careers pattern: the registry is *derived* from +``control_server.SITES`` so adding a later site does not require editing this +file, while the ordering guarantee (berkeley is index 29 → port 40029) is still +asserted exactly. +""" +from __future__ import annotations + +import ast +import hashlib +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +SITE = ROOT / "sites/berkeley" +SITE_INDEX = 29 +SITE_PORT = 40029 +# The build-generated seed value, asserted only when that seed is present in +# the worktree. +SEED_MD5 = "3001bcf4bcec169f4192c08609160ab6" + + +def shell_sites() -> list[str]: + text = (ROOT / "websyn_start.sh").read_text() + return re.search(r"SITES=\((.*?)\)", text, re.S).group(1).split() + + +def control_sites() -> list[str]: + module = ast.parse((ROOT / "control_server.py").read_text()) + for node in module.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "SITES" for target in node.targets + ): + return ast.literal_eval(node.value) + raise AssertionError("control_server.SITES not found") + + +def registered_sites() -> list[str]: + shell, control = shell_sites(), control_sites() + assert shell == control, "websyn_start.sh and control_server.py disagree" + return shell + + +def port_range() -> str: + return f"40000-{40000 + len(registered_sites()) - 1}" + + +def test_registry_places_berkeley_at_index_26() -> None: + sites = registered_sites() + assert len(sites) == len(set(sites)), "duplicate site in the registry" + assert sites[SITE_INDEX] == "berkeley", f"berkeley moved to index {sites.index('berkeley')}" + assert 40000 + sites.index("berkeley") == SITE_PORT + + +def test_dockerfile_exposes_the_current_range_and_builds_the_seed() -> None: + text = (ROOT / "Dockerfile").read_text() + assert f"{len(registered_sites())} Flask mirror sites" in text + assert f"EXPOSE 8101 {port_range()}" in text + # The seed is generated at build time from tracked source (no HF archive). + assert "cd /opt/WebSyn/berkeley" in text + assert "PYTHONHASHSEED=0 python seed_data.py" in text + + +def test_build_generated_seed_marker_and_fetch_exemption() -> None: + marker = SITE / ".build-generated-seed" + assert marker.is_file(), "missing .build-generated-seed marker" + # The marker speaks for the *seed* only: since the imagery PR the site does + # carry an archive (static/images/), so the text must claim the seed is + # build-generated and the images ship from Hugging Face — not the pre-imagery + # "the site has no Hugging Face asset archive", which is now false. + text = marker.read_text() + assert "generates instance_seed/berkeley.db deterministically" in text + assert "ships from the pinned Hugging Face archive" in text + fetch = (ROOT / "scripts/fetch_assets.sh").read_text() + assert ".build-generated-seed" in fetch, "fetch_assets.sh no longer honours the marker" + + +def test_seed_is_byte_reproducible_when_present() -> None: + seed = SITE / "instance_seed" / "berkeley.db" + if not seed.is_file(): + return # build-generated; the image (or `python seed_data.py`) creates it + assert hashlib.md5(seed.read_bytes()).hexdigest() == SEED_MD5 + + +def test_tasks_and_verifiers_are_complete_and_use_the_registered_port() -> None: + rows = [json.loads(line) for line in (SITE / "tasks.jsonl").read_text().splitlines() if line] + assert len(rows) == 22 + assert [int(row["id"].rsplit("--", 1)[1]) for row in rows] == [ + 1, 2, 4, 6, 7, 10, 11, 12, 13, 14, 16, 17, 19, 20, 22, 23, 24, 25, 27, 28, 30, 31 + ] + assert {row["web"] for row in rows} == {f"http://localhost:{SITE_PORT}/"} + assert all((ROOT / row["verifier_path"]).is_file() for row in rows) + assert all("answer" not in row for row in rows) + assert all("Checkpoints:" in row["judge_rubric"] for row in rows) + + +def test_app_clock_is_frozen_and_article_reads_do_not_write() -> None: + source = (SITE / "app.py").read_text() + assert "BENCHMARK_NOW = datetime(2026, 5, 12)" in source + module = ast.parse(source) + wall_clock_calls = [ + node for node in ast.walk(module) + if isinstance(node, ast.Attribute) and node.attr == "utcnow" + ] + assert not wall_clock_calls, "the app must not call datetime.utcnow() (BENCHMARK_NOW only)" + for node in module.body: + if isinstance(node, ast.FunctionDef) and node.name == "news_article": + body = ast.dump(node) + assert "view_count" not in body, "article detail must not write view_count" + assert "commit" not in body, "article detail must not commit" + break + else: + raise AssertionError("news_article route not found") + + +def test_shared_documentation_uses_the_current_site_range() -> None: + current = port_range() + stale = {f"40000-400{end}" for end in range(20, 27)} - {current} + for relative in ["README.md", "AGENTS.md", "CONTRIBUTING.md", "CLAUDE.md", "agent_demo/README.md"]: + text = (ROOT / relative).read_text() + for old in stale: + assert old not in text, f"{relative} still documents {old}" + assert current in text, relative diff --git a/sites/berkeley/verify/README.md b/sites/berkeley/verify/README.md new file mode 100644 index 00000000..ef729cb9 --- /dev/null +++ b/sites/berkeley/verify/README.md @@ -0,0 +1,83 @@ +# UC Berkeley deterministic grading contract + +Each row in `sites/berkeley/tasks.jsonl` points to `verify_1.py` … `verify_31.py` (22 verifiers, one +per row; the ids are the contributor's, so numbers are not contiguous). The wrappers use +`verify_lib.py` for package, URL, answer and state validation and `ground_truth.py` to re-derive +every target from the supplied initial SQLite snapshot. No verifier calls an LLM; a verdict never +depends on a key or a model. `TASK_REVIEW.md` records the per-row ACCEPT/DROP/ADDED decisions. + +## Inputs + +```bash +python sites/berkeley/verify/verify_1.py \ + --run_dir /absolute/path/to/run \ + --initial_db /absolute/path/to/initial.db \ + --after_db /absolute/path/to/after.db +``` + +If explicit snapshots are omitted, the verifier checks `/initial.db` and +`/after.db`, then falls back to `docker cp` from `$WH_CONTAINER` (default `wh-review`): +`instance_seed/berkeley.db` is the initial state and `instance/berkeley.db` the after state. Missing +or invalid inputs fail closed (`infra_error: true`, exit 1). Output is JSON with `task_id`, `pass`, +`reason` (the first failing check) and `evidence`; exit code 0 means PASS and 1 means FAIL. +`agent_demo/eval_judge.py --run_dir --verifier True` is the normal entry point; `--no_llm` is +accepted for parity and ignored. + +## Snapshot contract + +Both snapshots must carry the exact nine-table UC Berkeley schema (hash pinned), the frozen seed +counts (14 colleges, 30 departments, 83 programmes, 82 faculty, 25 research centres, 121 news +articles, 64 events, 4 users, 0 bookmarks) and the row-level catalog fingerprint pinned in +`verify_lib.py`. `ground_truth.py` then re-derives the task's target the way the app renders it — +the `BENCHMARK_NOW = 2026-05-12` event filter, `PER_PAGE`, the app's `ORDER BY` clauses and the +`ORDER BY name LIMIT 3` related-centres query — and fails closed on drift. The seven catalog tables must +be row-identical before and after; `users` and `bookmarks` are the only runtime tables. + +Read-only tasks (everything except 30 and 31) require **every** seeded table to be row-identical, +so an incidental bookmark, registration or catalog write fails. The stateful verifiers (30, 31) +check the exact bookmark row delta for the demo account first (`bookmarks_exact_delta`), then that no +other user's bookmarks changed (`bookmarks_other_users_unchanged`), then the row-id binding where the +task fixes the order (`bookmarks_surviving_row_ids`, task 31), then that `users` is unchanged. A run +that self-reports success without writing the row fails on the delta; a skipped removal, a reversed +add order or an extra save each fail on a named check. + +## Gates and answer matchers + +Navigation gates require an exact mirror path (any loopback port) carrying every required query +parameter; a listing hit never replaces a detail visit, and the final action's declared target +counts as a visit so a run that ends on a `navigate` is not penalised. Multi-hop tasks (19, 24, 25, +30, 31) gate each hop; the ordered ones (24, 30, 31) additionally require the hops in sequence +(`check_paths_in_order`), while 19 and 25 gate their listing and detail visits independently. The +catalog-scan tasks (16, 28) accept either the filtered listings or several pages of the full listing. + +Answer matchers are negation-aware whole-token matches: names (titles ignored), locations (leading +room numbers optional), counts (thousands separators and word forms, with "12" never matching inside +"1,200" or "14.4"), years, percentages, dates in six formats, month-day literals, degree types +(`Ph.D.` variants), durations (`1 year` never matches inside `1.5 years`; `18 months` accepted for +1.5), interest tokens bound to the named row, and title-token binding for events and articles. +A value the task derives is additionally protected against a confirming contrast being mistaken for +negation: "founded in 2013, not 2017" is affirmative, while "2013 was not the founding year" is not. + +## Source-rendered values + +Two rows (11 and 17) depend on values the app renders from tracked source rather than the DB. +`ground_truth.py` parses `templates/admissions.html` and `app.py` and fails closed if the labelled +literals move; `verify/tests` asserts they stay source literals and never become DB-derived. Row 17 +additionally rejects the page's "more than N Nobel Prizes" line when it is claimed as the faculty +count (a clause-local rule, so quoting the alumni line elsewhere is not a wrong answer). + +## Tests + +```bash +python -m pytest sites/berkeley/verify/tests -q +``` + +The fixtures copy the frozen seed and rewrite only `bookmarks` (stdlib `sqlite3`), so every fixture +DB reproduces the pinned fingerprint; trajectories follow the `agent_demo/agent.py` run signature. +Per task: genuine PASS, no-op, wrong task id, another task's trajectory, shortcuts (including +catalog-wide-token searches), one or two wrong answers, alternative phrasings, a negated answer, a +truncated run, corrupt and 1×1 PNG screenshots, a missing `after.db`, catalog and schema drift, an +incidental write, and — for 30/31 — the state-mismatch, wrong-target, wrong-order and collateral +cases. `test_tasks_contract.py` validates `tasks.jsonl` (22 rows, seven keys, verifier paths, and no +derived answer value in any rubric). `test_verify_lib.py` covers each matcher's accepted and rejected +forms, the gate semantics, the fingerprint recipe and the source-fact rules. diff --git a/sites/berkeley/verify/TASK_REVIEW.md b/sites/berkeley/verify/TASK_REVIEW.md new file mode 100644 index 00000000..74a7831b --- /dev/null +++ b/sites/berkeley/verify/TASK_REVIEW.md @@ -0,0 +1,64 @@ +# UC Berkeley task review + +All 32 rows (the contributor's 30 plus the reviewer's 2) were re-grounded against the *built* +`instance_seed/berkeley.db` and the templates the app renders — not against the contributor's +summaries. Task URLs use UC Berkeley's registered site index 29 and port `40029`; the verifiers +accept any loopback port, so alt-port review runs grade identically. + +Verdicts: **ACCEPT** (kept, graded), **DROP** (removed from `tasks.jsonl`), **ADDED** (written by +the reviewer). Values in the "Required visible workflow / ground truth" column are the reviewer's +record of what the snapshot derives; none of them appears in `tasks.jsonl` — the validator in +`verify/tests/test_tasks_contract.py` fails if one does. + +| Row | Verdict | Required visible workflow / ground truth | +|---:|---|---| +| 0 | DROP | *label-visible count*: `/programs?degree=PhD` prints "Showing 20 of 25 programs", so the count is readable without opening anything (CONTRIBUTING: no count answers when list counts are visible). Replaced by 27, re-anchored. | +| 1 | ACCEPT | programme search → MBA detail page; school and duration: Haas School of Business, 2 years | +| 2 | ACCEPT | Computer Science BS detail page (not the same-name MS/PhD pages); ≥4 of its requirement items; sibling programmes' items fail | +| 3 | DROP | *page-size artifact*: Research holds 42 articles, so the "first page" is exactly PER_PAGE = 20; overlaps 4/19 | +| 4 | ACCEPT | news search → CRISPR article detail; scientist and award: Jennifer Doudna, National Medal of Science (prior knowledge supplies "Nobel Prize" → wrong) | +| 5 | DROP | *date*: would need the frozen clock only; 25 already covers Career with a name anchor | +| 6 | ACCEPT | `/events` filtered to Lecture (15 upcoming of 19 seeded); ≥3 events with date and location | +| 7 | ACCEPT | faculty-directory route to EECS → named professor's profile; the AI-family allowlist has 11 EECS rows (the literal phrase "artificial intelligence" matches 1), and the reported interests must be that row's | +| 8 | DROP | *prior-knowledge*: Doudna's title/research area is a two-token recall; her award is already 4's anchor | +| 9 | DROP | *distractor breadth* + overlap: one AI-related centre only (BAIR), which 10 asks about by name | +| 10 | ACCEPT | BAIR centre page; director and founding year: Prof. Pieter Abbeel, 2013 (the real BAIR is 2017 → anti-recall) | +| 11 | ACCEPT | Admissions page; freshman deadline November 30 and acceptance rate 14.4% (source-rendered, not DB) | +| 12 | ACCEPT | Haas-filtered programme list; exactly one programme (Business Administration, MBA) — contradicts the "MBA, PhD…" prior | +| 13 | ACCEPT | EECS department page; chair and location: Prof. James Demmel, 253 Cory Hall | +| 14 | ACCEPT | schools-and-colleges page; College of Engineering: 4,500 undergraduate and 3,200 graduate students, Dean Tsu-Jae King Liu (university-wide totals are the distractor) | +| 15 | DROP | *ill-posed*: "name one from each category that appears" — `climate` has no research-centre hit, so it invites a hallucinated answer | +| 16 | ACCEPT | programme listing → Data Science MS detail; exactly one online programme, School of Information | +| 17 | ACCEPT | About page; 12 faculty Nobel laureates, 30 varsity sports, 105 NCAA national titles (the page's "more than 107 Nobel Prizes" line is the distractor) | +| 18 | DROP | *date*: "within the next two months" is unsatisfiable against the frozen calendar | +| 19 | ACCEPT | Athletics-filtered news → championship article detail; sport and achievement bound to the opened article (2 championship articles of 7) | +| 20 | ACCEPT | JD detail page; 3 years, February 1, School of Law (the Optometry MD shares the deadline) | +| 21 | DROP | *distractor breadth* + overlap: one faculty row for "quantum computing"; a thinner duplicate of 7 | +| 22 | ACCEPT | Departments page; 8 departments under the College of Letters and Science, named (the site-wide total of 30 fails) | +| 23 | ACCEPT | BIDS centre page; director Prof. David Culler, four focus areas, and ≥1 of the three related centres the page renders | +| 24 | ACCEPT | Economics PhD → Economics department → a department member's profile; chair Prof. Ulrike Malmendier, the department's BA + PhD, that member's interests | +| 25 | ACCEPT | Career-filtered events → Spring Career Fair 2026 detail (2026-05-17, Recreational Sports Facility, registration required) + two further career events | +| 26 | DROP | *prior-knowledge*: Nobel prizes are world knowledge and the seeded interests are generic; overlaps 4/8 | +| 27 | ACCEPT | **re-anchored** (was: "how does the duration differ", the fuzziest answer in the set): both detail pages must be opened and both durations given exactly — Master of Engineering, 1 year (offered by the Department of Electrical Engineering and Computer Sciences), Computer Science MS, 1.5 years. Old ques: *"Search the Berkeley site for 'Master of Engineering'. Find the MEng program, identify which department offers it, and compare it to other master's programs in the same college. How does the duration differ?"* New ques: see the row in `tasks.jsonl`. | +| 28 | ACCEPT | programme listings carrying the GRE badge (both degree-filtered listings, or four pages of the full listing); 17 programmes, most commonly PhD (13 of 17) | +| 29 | DROP | *count/duplicate*: same About page and statistics as 17, second visit | +| 30 | ADDED | **stateful**: sign in as alice → Berkeley Seismological Laboratory centre page → save → My Account; DB check: exactly one new bookmark row for alice on that centre. Ques text: *"Log in to the UC Berkeley website with the demo account (email: alice@berkeley.edu, password: test1234). Open the research center page for the Berkeley Seismological Laboratory and save the center to your bookmarks. Then open My Account and confirm the center is listed there, and report the director shown for the center."* | +| 31 | ADDED | **stateful**: sign in as bob → save the Mathematical Sciences Research Institute, then the California Policy Lab → My Account → remove the first → My Account again; DB check: exactly one surviving bookmark row on the second centre **with row id 2** (the seed's `bookmarks` table is empty, so two inserts take ids 1 and 2 and deleting id 1 leaves id 2 — a skipped removal, a wrong removal, a reversed add order or a missing first insert each fail on a named check). Ques text: see the row in `tasks.jsonl`. | + +## Corrections recorded during derivation + +* `REVIEW_STATUS` §5 said the Lecture filter holds 19 events; against the frozen clock + (`BENCHMARK_NOW = 2026-05-12`) `/events` admits 52 of 64 rows, of which 15 are Lecture. The + verifier accepts any seeded Lecture event (the `date=all` view also works) with a minimum of three. +* `REVIEW_STATUS` §5 said "≥8 [EECS faculty] match artificial intelligence"; the app's `ilike` + over the literal phrase matches one row. The AI-family allowlist (11 rows) is the rule the task + implies, and every accepted answer must still bind its quoted interests to the named row. +* BIDS has four focus areas (not three), and the "related centres" the page shows are the three + rows its `ORDER BY name LIMIT 3` query returns — naming any other same-college centre fails. +* `GET /news/` used to increment `view_count` and commit. That made a read-only task write the + DB and broke the byte-identical reset invariant, so it was removed before the verifiers were + written; the column is kept and still renders the frozen seed values. + +Read-only tasks are verified by comparing every seeded table before and after execution (there is no +column-level whitelist — no GET path writes the DB). The two stateful rows are verified by the exact +bookmark row delta for the demo account plus an unchanged-everything-else check. diff --git a/sites/berkeley/verify/ground_truth.py b/sites/berkeley/verify/ground_truth.py new file mode 100644 index 00000000..6bb62a4e --- /dev/null +++ b/sites/berkeley/verify/ground_truth.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Re-derive every UC Berkeley task target from a supplied initial SQLite snapshot. + +``task_ground_truth(db, n)`` selects the target exactly the way the task text and +the app do — the frozen benchmark clock (``BENCHMARK_NOW``), ``PER_PAGE``, the +app's ``ORDER BY`` clauses and its unordered ``LIMIT 3`` related-centres query — +using plain SQL. No answer constant is frozen anywhere except the snapshot +catalog fingerprint in ``verify_lib.py``; a re-frozen seed is caught there +before grading starts, and any derivation that cannot find its target raises +``ValueError`` so the verifier fails closed instead of guessing. + +Two rows (11, 17) read values that the app renders from tracked source rather +than from the DB; their derivations parse ``templates/admissions.html`` and +``app.py`` respectively, and ``verify/tests`` asserts those values stay +source-literals (never DB-derived). +""" +from __future__ import annotations + +import datetime as _dt +import re +import sqlite3 +import sys +from pathlib import Path +from typing import Any, Iterable, Sequence + +VERIFY_DIR = Path(__file__).resolve().parent +SITE_DIR = VERIFY_DIR.parent +TEMPLATES_DIR = SITE_DIR / "templates" +APP_SOURCE = SITE_DIR / "app.py" + +# app.py:48 — the frozen clock every "upcoming" view is compared against. +BENCHMARK_NOW = "2026-05-12 00:00:00.000000" +BENCHMARK_NOW_DATE = _dt.date(2026, 5, 12) + +# app.py:39 — the listing page size. +PER_PAGE = 20 + +# The AI-family allowlist for task 7. A *rule*, not an answer: it selects the +# EECS rows whose stated interests are AI-related, and the verifier still binds +# the quoted interests to the named row. +AI_INTEREST_RE = re.compile( + r"\b(?:ai|artificial intelligence|machine learning|deep learning|" + r"reinforcement learning|robot|robotics)\b", + re.I, +) + +# Title pattern that yields (subject, award) from an award-reporting headline. +AWARD_TITLE_RE = re.compile(r"^(?P.+?)\s+Receives?\s+(?P.+?)\s*$") + + +def _connect(db_path: str | Path) -> sqlite3.Connection: + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + return connection + + +def _rows(connection: sqlite3.Connection, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]: + return [dict(row) for row in connection.execute(sql, params)] + + +def _one(rows: Iterable[dict[str, Any]], description: str) -> dict[str, Any]: + items = list(rows) + if len(items) != 1: + raise ValueError(f"{description} must be unique; observed {len(items)} rows") + return items[0] + + +def _at_least(rows: Iterable[dict[str, Any]], count: int, description: str) -> list[dict[str, Any]]: + items = list(rows) + if len(items) < count: + raise ValueError(f"{description} needs at least {count} rows; observed {len(items)}") + return items + + +# --------------------------------------------------------------------------- # +# Catalog replicas of the app's own queries +# --------------------------------------------------------------------------- # +def _programmes(connection: sqlite3.Connection, where: str = "1=1", params: Sequence[Any] = ()) -> list[dict[str, Any]]: + """``ORDER BY Program.name`` — the ordering of /programs (app.py:368).""" + return _rows( + connection, + "SELECT p.*, c.name AS college_name, d.name AS department_name " + "FROM programs p LEFT JOIN colleges c ON c.id = p.college_id " + "LEFT JOIN departments d ON d.id = p.department_id " + f"WHERE {where} ORDER BY p.name", + params, + ) + + +def programme_by_slug(connection: sqlite3.Connection, slug: str) -> dict[str, Any]: + return _one(_programmes(connection, "p.slug = ?", (slug,)), f"programme slug {slug!r}") + + +def _events( + connection: sqlite3.Connection, + where: str = "1=1", + params: Sequence[Any] = (), + *, + upcoming: bool = True, +) -> list[dict[str, Any]]: + """``/events`` semantics: ``upcoming`` filters ``start_datetime >= BENCHMARK_NOW``.""" + clause = "start_datetime >= ?" if upcoming else "1=1" + clauses = [where, clause] + values: list[Any] = list(params) + ([BENCHMARK_NOW] if upcoming else []) + return _rows( + connection, + f"SELECT * FROM events WHERE {' AND '.join(clauses)} ORDER BY start_datetime", + values, + ) + + +def _departments(connection: sqlite3.Connection, college_id: int) -> list[dict[str, Any]]: + """``ORDER BY Department.name`` — the ordering of /departments (app.py:482).""" + return _rows( + connection, + "SELECT * FROM departments WHERE college_id = ? ORDER BY name", + (college_id,), + ) + + +def _faculty_of_department(connection: sqlite3.Connection, department_id: int) -> list[dict[str, Any]]: + """``ORDER BY Faculty.name`` — the ordering of /faculty (app.py:587).""" + return _rows( + connection, + "SELECT * FROM faculty WHERE department_id = ? ORDER BY name", + (department_id,), + ) + + +def related_centres(connection: sqlite3.Connection, centre: dict[str, Any]) -> list[dict[str, Any]]: + """The centres the detail page renders: ``ORDER BY name LIMIT 3`` (app.py research_center).""" + return _rows( + connection, + "SELECT * FROM research_centers WHERE college_id = ? AND id != ? " + "ORDER BY name LIMIT 3", + (centre["college_id"], centre["id"]), + ) + + +def centre_by_slug(connection: sqlite3.Connection, slug: str) -> dict[str, Any]: + return _one( + _rows(connection, "SELECT * FROM research_centers WHERE slug = ?", (slug,)), + f"research centre slug {slug!r}", + ) + + +def _college_by_slug(connection: sqlite3.Connection, slug: str) -> dict[str, Any]: + return _one(_rows(connection, "SELECT * FROM colleges WHERE slug = ?", (slug,)), f"college slug {slug!r}") + + +# --------------------------------------------------------------------------- # +# Source-rendered facts (not in the DB) +# --------------------------------------------------------------------------- # +def admissions_facts() -> dict[str, str]: + """The two Admissions figures, parsed from the tracked template. + + ``templates/admissions.html`` renders ``Freshman Application | November 30`` + and a ``stat-number`` acceptance rate labelled ``Acceptance Rate``. The + numbers are source literals (not DB rows), so the parser must find them or + the verifier fails closed. + """ + html = (TEMPLATES_DIR / "admissions.html").read_text(encoding="utf-8") + deadline = re.search(r"\s*Freshman Application\s*\s*\s*([^<]+?)\s*", html) + if not deadline: + raise ValueError("admissions.html no longer renders the Freshman Application deadline") + rate = re.search( + r'stat-number">\s*([\d.]+)\s*%\s*\s*\s*Acceptance Rate', + html, + ) + if not rate: + raise ValueError("admissions.html no longer renders a labelled Acceptance Rate stat") + return {"deadline": deadline.group(1).strip(), "acceptance_rate": f"{rate.group(1)}%"} + + +def about_facts() -> dict[str, int]: + """The three About-page statistics, parsed from ``app.py``'s ``about()`` dict.""" + source = APP_SOURCE.read_text(encoding="utf-8") + body = re.search(r"def about\(\):(.*?)\n@app\.route", source, re.S) + if not body: + raise ValueError("app.py no longer defines about()") + facts: dict[str, int] = {} + for key in ("nobel_laureates", "varsity_sports", "national_titles"): + match = re.search(rf"'{key}':\s*(\d+)", body.group(1)) + if not match: + raise ValueError(f"about() no longer defines {key!r} as an integer literal") + facts[key] = int(match.group(1)) + return facts + + +def about_distractor_prizes() -> int: + """The near-miss Nobel count the About page prints next to the faculty statistic.""" + html = (TEMPLATES_DIR / "about.html").read_text(encoding="utf-8") + match = re.search(r"more than\s+(\d+)\s+Nobel Prizes", html) + if not match: + raise ValueError("about.html no longer renders the 'more than N Nobel Prizes' distractor") + return int(match.group(1)) + + +# --------------------------------------------------------------------------- # +# Per-task derivation +# --------------------------------------------------------------------------- # +def requirement_items(requirements: str) -> list[str]: + """The comma/semicolon-separated requirement items of a programme detail page.""" + items: list[str] = [] + for raw in re.split(r"[;,]", str(requirements or "")): + item = re.sub(r"^\s*plus\s+", "", raw.strip()).strip(" .") + if len(item) >= 2: + items.append(item) + return items + + +def _task(db_path: str | Path, n: int) -> dict[str, Any]: + connection = _connect(db_path) + try: + return _derive(connection, n) + finally: + connection.close() + + +def task_ground_truth(db_path: str | Path, n: int) -> dict[str, Any]: + return _task(db_path, int(n)) + + +def _derive(c: sqlite3.Connection, n: int) -> dict[str, Any]: + if n == 1: + program = _one(_programmes(c, "p.degree_type = 'MBA'"), "the MBA programme") + return { + "task": n, + "program": program, + "college": program["college_name"], + "duration_years": float(program["duration_years"]), + } + + if n == 2: + target = programme_by_slug(c, "computer-science-bs") + items = requirement_items(target["requirements"]) + if len(items) < 6: + raise ValueError(f"task 2 needs a populated BS requirements list; observed {items!r}") + target_tokens = {token for item in items for token in re.findall(r"[a-z0-9]+", item.lower())} + foreign: list[str] = [] + for sibling in _programmes(c, "p.name = ? AND p.slug != ?", ("Computer Science", target["slug"])): + for item in requirement_items(sibling["requirements"]): + tokens = re.findall(r"[a-z0-9]+", item.lower()) + if len(tokens) >= 2 and not (set(tokens) & target_tokens): + foreign.append(item) + if not foreign: + raise ValueError("task 2 needs sibling requirement items that are absent from the BS list") + return {"task": n, "program": target, "items": items, "foreign_items": sorted(set(foreign))} + + if n == 4: + candidates = _rows( + c, + "SELECT * FROM news_articles WHERE title LIKE '%CRISPR%' OR content LIKE '%CRISPR%' " + "OR tags LIKE '%CRISPR%' ORDER BY published_date DESC", + ) + _at_least(candidates, 2, "task 4 CRISPR articles") + target = _one( + [row for row in candidates if AWARD_TITLE_RE.fullmatch(row["title"])], + "task 4 award-reporting article", + ) + match = AWARD_TITLE_RE.fullmatch(target["title"]) + subject_tokens = re.findall(r"[A-Z][a-z]+", match.group("subject")) + if len(subject_tokens) < 2: + raise ValueError(f"task 4 cannot derive a person from {target['title']!r}") + return { + "task": n, + "article": target, + "person": " ".join(subject_tokens[-2:]), + "award": match.group("award"), + "candidates": candidates, + } + + if n == 6: + upcoming = _events(c, "category = 'Lecture'") + every = _events(c, "category = 'Lecture'", upcoming=False) + _at_least(upcoming, 3, "task 6 upcoming Lecture events") + return {"task": n, "events": every, "upcoming": upcoming} + + if n == 7: + department = _one(_rows(c, "SELECT * FROM departments WHERE slug = 'eecs'"), "the EECS department") + members = _faculty_of_department(c, department["id"]) + if len(members) < 10: + raise ValueError(f"task 7 needs the full EECS roster; observed {len(members)}") + allowed = [row for row in members if AI_INTEREST_RE.search(row["research_interests"] or "")] + if len(allowed) < 5: + raise ValueError(f"task 7 needs at least five AI-family EECS faculty; observed {len(allowed)}") + return {"task": n, "department": department, "members": members, "allowed": allowed} + + if n == 10: + centre = centre_by_slug(c, "bair") + return {"task": n, "centre": centre, "director": centre["director"], "founded_year": int(centre["founded_year"])} + + if n == 11: + return {"task": n, **admissions_facts()} + + if n == 12: + college = _college_by_slug(c, "haas-business") + programmes = _programmes(c, "p.college_id = ?", (college["id"],)) + if len(programmes) != 1: + raise ValueError(f"task 12 expects the single-programme Haas catalogue; observed {len(programmes)}") + catalogue_types = sorted({row["degree_type"] for row in _programmes(c)}) + offered = {programmes[0]["degree_type"]} + return { + "task": n, + "college": college, + "programmes": programmes, + "offered_types": sorted(offered), + "other_types": [value for value in catalogue_types if value not in offered], + } + + if n == 13: + department = _one(_rows(c, "SELECT * FROM departments WHERE slug = 'eecs'"), "the EECS department") + return {"task": n, "department": department, "chair": department["chair"], "location": department["location"]} + + if n == 14: + college = _college_by_slug(c, "engineering") + return { + "task": n, + "college": college, + "dean": college["dean"], + "undergrad_count": int(college["undergrad_count"]), + "grad_count": int(college["grad_count"]), + } + + if n == 16: + online = _programmes(c, "p.is_online = 1") + programme = _one(online, "the single online programme") + others = [row["name"] for row in _programmes(c, "p.slug != ?", (programme["slug"],))] + return {"task": n, "program": programme, "others": others} + + if n == 17: + return {"task": n, **about_facts(), "distractor_nobel_prizes": about_distractor_prizes()} + + if n == 19: + articles = _rows(c, "SELECT * FROM news_articles WHERE category = 'Athletics' ORDER BY published_date DESC") + _at_least(articles, 5, "task 19 Athletics articles") + championships = [row for row in articles if "championship" in (row["title"] or "").lower()] + if len(championships) < 2: + raise ValueError(f"task 19 needs at least two championship articles; observed {len(championships)}") + return {"task": n, "articles": articles, "championships": championships} + + if n == 20: + program = programme_by_slug(c, "juris-doctor-jd") + return { + "task": n, + "program": program, + "duration_years": float(program["duration_years"]), + "deadline": program["application_deadline"], + "college": program["college_name"], + } + + if n == 22: + college = _college_by_slug(c, "letters-and-science") + departments = _departments(c, college["id"]) + if len(departments) < 4: + raise ValueError(f"task 22 needs the L&S department list; observed {len(departments)}") + return {"task": n, "college": college, "departments": departments} + + if n == 23: + centre = centre_by_slug(c, "bids") + focus = [part.strip() for part in (centre["focus_areas"] or "").split(",") if part.strip()] + related = related_centres(c, centre) + if len(focus) < 3 or not related: + raise ValueError("task 23 needs BIDS focus areas and its rendered related centres") + return { + "task": n, + "centre": centre, + "focus_areas": focus, + "related": related, + "related_names": [row["name"] for row in related], + } + + if n == 24: + program = programme_by_slug(c, "economics-phd") + if not program["department_id"]: + raise ValueError("task 24 needs the Economics PhD to carry a department") + department = _one( + _rows(c, "SELECT * FROM departments WHERE id = ?", (program["department_id"],)), + "the Economics department", + ) + programmes = _programmes(c, "p.department_id = ?", (department["id"],)) + members = _faculty_of_department(c, department["id"]) + if len(programmes) < 2 or len(members) < 3: + raise ValueError("task 24 needs the department's programme list and faculty roster") + return { + "task": n, + "program": program, + "department": department, + "programmes": programmes, + "members": members, + "chair": department["chair"], + } + + if n == 25: + career = _events(c, "category = 'Career'") + _at_least(career, 3, "task 25 upcoming Career events") + anchor = _one([row for row in career if "career fair" in (row["title"] or "").lower()], "the Spring Career Fair") + return {"task": n, "anchor": anchor, "others": [row for row in career if row["id"] != anchor["id"]]} + + if n == 27: + meng = programme_by_slug(c, "master-of-engineering-meng") + ms = programme_by_slug(c, "computer-science-ms") + if meng["department_id"] != ms["department_id"]: + raise ValueError("task 27 expects both programmes in the same department") + return { + "task": n, + "meng": meng, + "ms": ms, + "department": meng["department_name"], + "durations": (float(meng["duration_years"]), float(ms["duration_years"])), + } + + if n == 28: + gre = _programmes(c, "p.gre_required = 1") + _at_least(gre, 10, "task 28 GRE-required programmes") + counts: dict[str, int] = {} + for row in gre: + counts[row["degree_type"]] = counts.get(row["degree_type"], 0) + 1 + top = sorted(counts.items(), key=lambda item: (-item[1], item[0])) + if len(top) > 1 and top[0][1] == top[1][1]: + raise ValueError("task 28 degree-type mode is tied") + return {"task": n, "count": len(gre), "by_degree": counts, "most_common_degree": top[0][0]} + + if n == 30: + centre = centre_by_slug(c, "seismo-lab") + return {"task": n, "centre": centre, "director": centre["director"]} + + if n == 31: + first = centre_by_slug(c, "msri") + second = centre_by_slug(c, "cpl") + return {"task": n, "first": first, "second": second, "directors": (first["director"], second["director"])} + + raise ValueError(f"unsupported UC Berkeley task {n}") + + +def all_ground_truth(db_path: str | Path) -> dict[int, dict[str, Any]]: + return {number: task_ground_truth(db_path, number) + for number in (1, 2, 4, 6, 7, 10, 11, 12, 13, 14, 16, 17, 19, 20, 22, 23, 24, 25, 27, 28, 30, 31)} + + +if __name__ == "__main__": # pragma: no cover - manual inspection + import json + + facts = all_ground_truth(sys.argv[1]) + print(json.dumps(facts, indent=1, default=str)) diff --git a/sites/berkeley/verify/tests/_support.py b/sites/berkeley/verify/tests/_support.py new file mode 100644 index 00000000..9f2d506f --- /dev/null +++ b/sites/berkeley/verify/tests/_support.py @@ -0,0 +1,414 @@ +"""Shared fixtures for the UC Berkeley verifier tests. + +Snapshots are copies of the frozen seed (``instance_seed/berkeley.db``) with the +``bookmarks`` table rewritten from a small in-memory ``State`` using **stdlib +sqlite3 only**; hand-written trajectories follow the ``agent_demo/agent.py`` +run signature. No docker, no LLM, no Flask. + +Every fixture DB reproduces the pinned catalog fingerprint — the suite asserts +it, so a stale or tampered seed fails here rather than inside a verifier. +""" +from __future__ import annotations + +import base64 +import copy +import io +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +VERIFY_DIR = Path(__file__).resolve().parents[1] +SITE_DIR = VERIFY_DIR.parent +SEED_DB = SITE_DIR / "instance_seed" / "berkeley.db" +sys.path.insert(0, str(VERIFY_DIR)) + +import verify_lib # noqa: E402 (path inserted above) + +BASE = "http://localhost:41026" +PASSWORD = "test1234" +STAMP = "2026-05-12 00:00:00.000000" +ALL_TABLES = verify_lib.ALL_TABLES + + +def _build_seed() -> None: + """Build the deterministic seed when the worktree has not generated it yet.""" + try: + subprocess.run( + [sys.executable, str(SITE_DIR / "seed_data.py")], cwd=SITE_DIR, + env={**os.environ, "PYTHONHASHSEED": "0"}, check=True, + capture_output=True, text=True, timeout=300, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"frozen seed missing at {SEED_DB} and the automatic build failed " + f"(rc={exc.returncode}).\nstdout: {(exc.stdout or '')[-2000:]}\n" + f"stderr: {(exc.stderr or '')[-2000:]}\n" + f"Build it manually with: cd {SITE_DIR} && PYTHONHASHSEED=0 python seed_data.py" + ) from exc + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + f"frozen seed missing at {SEED_DB} and could not be built automatically: {exc}. " + f"Build it with: cd {SITE_DIR} && PYTHONHASHSEED=0 python seed_data.py" + ) from exc + + +if not SEED_DB.exists(): # pragma: no cover - environment guard + _build_seed() + + +def seed_fingerprint() -> str: + return verify_lib.catalog_fingerprint(str(SEED_DB)) + + +class State: + """Mutable copy of the seed's runtime table (bookmarks) for an after snapshot.""" + + def __init__(self) -> None: + self.bookmarks: list[dict[str, Any]] = [] + self.extra_sql: list[str] = [] + + # -- mutators ----------------------------------------------------------- + def add_bookmark(self, user_id: int, item_type: str, item_id: int, + row_id: int | None = None, note: str = "") -> dict[str, Any]: + """Insert a row the way SQLite does: id = max(existing id) + 1 unless pinned.""" + if row_id is None: + row_id = max([int(row["id"]) for row in self.bookmarks] + [0]) + 1 + row = {"id": int(row_id), "user_id": int(user_id), "item_type": str(item_type), + "item_id": int(item_id), "note": note} + self.bookmarks.append(row) + return row + + def remove_bookmark(self, row_id: int) -> None: + before = len(self.bookmarks) + self.bookmarks = [row for row in self.bookmarks if int(row["id"]) != int(row_id)] + assert len(self.bookmarks) == before - 1, f"no bookmark row {row_id}" + + # -- persistence -------------------------------------------------------- + def write(self, path: Path) -> Path: + shutil.copy2(SEED_DB, path) + connection = sqlite3.connect(path) + try: + connection.execute("DELETE FROM bookmarks") + connection.executemany( + "INSERT INTO bookmarks(id, user_id, item_type, item_id, note, created_at) " + "VALUES (:id, :user_id, :item_type, :item_id, :note, :stamp)", + [{**row, "stamp": STAMP} for row in self.bookmarks], + ) + for statement in self.extra_sql: + connection.execute(statement) + connection.commit() + finally: + connection.close() + return path + + def write_with_catalog_change(self, path: Path) -> Path: + """A tampered snapshot: catalog row edited (fingerprint / immutability drift).""" + self.write(path) + connection = sqlite3.connect(path) + try: + connection.execute("UPDATE programs SET name = name || ' (tampered)' WHERE id = 1") + connection.commit() + finally: + connection.close() + return path + + +def write_schema_drifted(path: Path) -> Path: + """A snapshot with an unexpected table (schema drift).""" + shutil.copy2(SEED_DB, path) + connection = sqlite3.connect(path) + try: + connection.execute("CREATE TABLE extra_drift (id INTEGER PRIMARY KEY)") + connection.commit() + finally: + connection.close() + return path + + +def step(path: str, action: str = "click", text: str | None = None) -> dict[str, Any]: + """One trajectory step in the agent.py shape; ``path`` is relative to BASE.""" + params: dict[str, Any] = {"text": text} if text is not None else {} + url = path if path.startswith("http") else f"{BASE}{path}" + return {"url": url, "action": action, "params": params} + + +def login_steps(email: str) -> list[dict[str, Any]]: + return [ + step("/login", "input", email), + step("/login", "input", PASSWORD), + step("/login", "click"), + ] + + +def only_paths(steps: list[dict[str, Any]], *allowed: str) -> list[dict[str, Any]]: + """Keep the steps whose URL path is one of ``allowed`` (shortcut trajectories).""" + from urllib.parse import urlparse + + def path_of(item: dict[str, Any]) -> str: + return urlparse(item["url"]).path.rstrip("/") or "/" + + return [item for item in steps if path_of(item) in allowed] + + +def _fixture_png() -> bytes: + """A real 640x480 PNG (not a 1x1 stub) so screenshot-size gates are exercised.""" + from PIL import Image + + buffer = io.BytesIO() + Image.new("RGB", (640, 480), (245, 246, 250)).save(buffer, format="PNG") + return buffer.getvalue() + + +FIXTURE_PNG = _fixture_png() +SMALL_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) +CORRUPT_PNG = b"this is not a portable network graphic" + + +def write_run( + run_dir: Path, + task_id: str, + steps: list[dict[str, Any]], + answer: str | None = None, + *, + initial: State | None = None, + after: State | None = None, + snapshots: bool = True, + after_snapshot: bool = True, + small_at: int | None = None, + corrupt_at: int | None = None, + terminated: bool | None = None, + termination_reason: str | None = None, + start_url: str = f"{BASE}/", + drifted_initial: bool = False, + schema_drifted_after: bool = False, + catalog_changed_after: bool = False, +) -> Path: + """Write a run directory carrying the full agent.py signature. + + ``small_at`` / ``corrupt_at`` degrade one step's screenshots; ``snapshots`` / + ``after_snapshot`` omit ``initial.db`` / ``after.db``; ``drifted_initial``, + ``schema_drifted_after`` and ``catalog_changed_after`` tamper with them. + """ + run_dir.mkdir(parents=True, exist_ok=True) + shots = run_dir / "screenshots" + shots.mkdir(exist_ok=True) + numbered = [] + for index, item in enumerate(steps): + before = f"step_{index:03d}.png" + after_name = f"step_{index + 1:03d}.png" + payload = FIXTURE_PNG + if small_at is not None and index >= small_at: + payload = SMALL_PNG + for name in (before, after_name): + (shots / name).write_bytes(payload) + numbered.append({ + "step": index, + "url": item["url"], + "title": "synthetic", + "thought": "synthetic step", + "action": item.get("action", "click"), + "params": item.get("params", {}), + "screenshot_before": before, + "screenshot_after": after_name, + }) + if corrupt_at is not None: + # Written after the loop: the next step's ``before`` screenshot has the + # same file name as this step's ``after`` one. + (shots / f"step_{corrupt_at + 1:03d}.png").write_bytes(CORRUPT_PNG) + trajectory = { + "task": "synthetic", + "task_id": task_id, + "start_url": start_url, + "model": "unit-test", + "max_steps": 30, + "steps": numbered, + "terminated": bool(answer) if terminated is None else bool(terminated), + "termination_reason": ( + ("agent_done" if answer else "max_steps") if termination_reason is None else termination_reason + ), + "final_answer": answer if answer else None, + "judge_rubric": "", + "verifier_path": "", + } + (run_dir / "trajectory.json").write_text(json.dumps(trajectory, indent=2), encoding="utf-8") + + if snapshots: + target = run_dir / "initial.db" + if drifted_initial: + (initial or State()).write_with_catalog_change(target) + else: + (initial or State()).write(target) + if after_snapshot: + target = run_dir / "after.db" + if schema_drifted_after: + write_schema_drifted(target) + elif catalog_changed_after: + (after or State()).write_with_catalog_change(target) + else: + (after or State()).write(target) + return run_dir + + +def run_verifier(n: int, run_dir: Path, *, container: str | None = None) -> tuple[dict[str, Any], int]: + """Invoke verify_.py as a subprocess and return (verdict, returncode).""" + command = [sys.executable, str(VERIFY_DIR / f"verify_{n}.py"), "--run_dir", str(run_dir)] + if container: + command += ["--container", container] + result = subprocess.run(command, capture_output=True, text=True, cwd=str(VERIFY_DIR)) + try: + verdict = json.loads(result.stdout) + except Exception: # noqa: BLE001 - an unparseable verifier is a failure, never a crash of the suite + verdict = { + "task_id": None, + "pass": False, + "reason": f"unparseable verifier output: stdout={result.stdout[-600:]!r} " + f"stderr={result.stderr[-800:]!r}", + "evidence": [], + } + return verdict, result.returncode + + +class VerifierTestCase(unittest.TestCase): + """Base class: ``self.N`` selects verify_N.py.""" + + N = -1 + + @property + def task_id(self) -> str: + return f"UC Berkeley--{self.N}" + + def setUp(self) -> None: + self._tmp = Path(tempfile.mkdtemp(prefix=f"bk_verify_{self.N}_")) + self.addCleanup(shutil.rmtree, str(self._tmp), True) + + def make_run(self, name: str = "run", **kwargs: Any) -> Path: + return write_run(self._tmp / name, kwargs.pop("task_id", self.task_id), **kwargs) + + def verdict(self, steps: list[dict[str, Any]], answer: str | None, **kwargs: Any) -> dict[str, Any]: + run_dir = self.make_run(steps=steps, answer=answer, **kwargs) + verdict, _ = run_verifier(self.N, run_dir, container="wh-berkeley-test-none") + return verdict + + # -- assertions --------------------------------------------------------- + def assertPasses(self, verdict: dict[str, Any]) -> None: + self.assertTrue( + verdict.get("pass"), + f"expected PASS; reason={verdict.get('reason')!r} evidence={verdict.get('evidence')!r}", + ) + + def assertFailsOn(self, verdict: dict[str, Any], check: str) -> None: + self.assertFalse(verdict.get("pass"), f"expected FAIL on {check!r}; verdict passed") + self.assertEqual( + verdict.get("reason"), check, + f"expected first failing check {check!r}; got {verdict.get('reason')!r}; " + f"evidence={verdict.get('evidence')!r}", + ) + + def assertFailsClosed(self, verdict: dict[str, Any], reason: str) -> None: + self.assertFalse(verdict.get("pass"), "expected a fail-closed verdict") + self.assertTrue(verdict.get("infra_error"), f"expected infra_error; got {verdict!r}") + self.assertEqual(verdict.get("reason"), reason, f"got {verdict.get('reason')!r}") + + +class SharedVerifierTests: + """Generic mutations every verifier must survive. + + Mixed into each per-task case *before* ``VerifierTestCase``; the concrete + class supplies ``GENUINE_STEPS`` / ``ANSWER`` / ``genuine_after``. + """ + + GENUINE_STEPS: list[dict[str, Any]] = [] + ANSWER = "" + + def genuine_after(self) -> State: + return State() + + # -- packaging ---------------------------------------------------------- + def test_no_op_fails_on_empty_answer(self) -> None: + verdict = self.verdict([step("/"), step("/", "done")], None) + self.assertFailsOn(verdict, "final_answer_nonempty") + + def test_wrong_task_id_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, task_id="UC Berkeley--999") + self.assertFailsOn(verdict, "trajectory_task_matches") + + def test_another_tasks_trajectory_fails(self) -> None: + other_id = "UC Berkeley--1" if self.N != 1 else "UC Berkeley--2" + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, task_id=other_id) + self.assertFailsOn(verdict, "trajectory_task_matches") + + def test_truncated_trajectory_fails(self) -> None: + """Steps sliced before the gates, with the run still claiming a final answer.""" + sliced = self.GENUINE_STEPS[: max(1, len(self.GENUINE_STEPS) // 2)] + verdict = self.verdict( + sliced, self.ANSWER, terminated=False, termination_reason="max_steps", + ) + self.assertFailsOn(verdict, "trajectory_completed") + + def test_max_steps_run_without_answer_fails(self) -> None: + verdict = self.verdict( + self.GENUINE_STEPS, None, terminated=False, termination_reason="max_steps", + ) + self.assertFailsOn(verdict, "final_answer_nonempty") + + def test_corrupt_screenshot_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, corrupt_at=0) + self.assertFailsOn(verdict, "screenshots_decode") + + def test_1x1_screenshot_fails(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, small_at=0) + self.assertFailsOn(verdict, "screenshots_decode") + + # -- snapshots ---------------------------------------------------------- + def test_missing_after_db_fails_closed(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, after_snapshot=False) + self.assertFailsClosed(verdict, "database_unavailable") + + def test_catalog_fingerprint_drift_fails_closed(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, drifted_initial=True) + self.assertFailsClosed(verdict, "snapshot_contract_invalid") + + def test_schema_drift_fails_closed(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, schema_drifted_after=True) + self.assertFailsClosed(verdict, "snapshot_contract_invalid") + + def test_catalog_mutation_in_after_fails_closed(self) -> None: + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, catalog_changed_after=True) + self.assertFailsClosed(verdict, "snapshot_contract_invalid") + + # -- shortcuts and writes ---------------------------------------------- + def test_catalog_wide_search_shortcut_fails(self) -> None: + shortcut = [step("/"), step("/search?q=california&page=1", "done")] + verdict = self.verdict(shortcut, self.ANSWER, after=self.genuine_after()) + self.assertFalse( + verdict.get("pass"), + f"a catalog-wide search must not satisfy any gate; reason={verdict.get('reason')!r}", + ) + + def test_incidental_write_fails(self) -> None: + after = self.genuine_after() + after.add_bookmark(1, "program", 1) + verdict = self.verdict(self.GENUINE_STEPS, self.ANSWER, after=after) + self.assertFalse( + verdict.get("pass"), + f"an incidental bookmark write must fail; reason={verdict.get('reason')!r}", + ) + self.assertIn( + str(verdict.get("reason")), + { + "read_only_bookmarks_unchanged", + "bookmarks_exact_delta", + "bookmarks_other_users_unchanged", + "bookmarks_surviving_row_ids", + }, + f"unexpected failing check for an incidental write: {verdict.get('reason')!r}", + ) diff --git a/sites/berkeley/verify/tests/run_matrix.py b/sites/berkeley/verify/tests/run_matrix.py new file mode 100644 index 00000000..89a51a4f --- /dev/null +++ b/sites/berkeley/verify/tests/run_matrix.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""Playwright run-signature writer and grading matrix for the UC Berkeley verifiers. + +For each task this boots the mirror from a fresh seed on an alt port, drives a +scripted workflow with a real browser, snapshots the live SQLite database, and +writes a run directory in the ``agent_demo/agent.py`` shape (``trajectory.json`` +with url-before-action steps, ``screenshots/step_NNN.png``, ``initial.db``, +``after.db``). It then grades each run through + + uv run python agent_demo/eval_judge.py --run_dir --verifier True + +and compares the verdict with the cell's expectation, so a verifier that stops +discriminating shows up as a matrix failure rather than a silent green. + +Run it from ``agent_demo/`` (that env has Playwright and the uv project the +verifiers are launched with): + + cd agent_demo + uv run python ../sites/berkeley/verify/tests/run_matrix.py --out ../sites/berkeley/scripts_dev/runs/matrix + +Cells emitted per task: ``pass`` (the genuine walk), ``no_op`` (homepage only, +no answer), ``shortcut`` (a catalog-wide search only, with the correct answer), +``wrong_answer`` (the genuine walk with a plausible wrong answer), +``collateral_write`` (the genuine walk plus one row written straight into the +live database — fault injection, not an app-driven write) and, for the two +stateful rows, ``state_mismatch`` (the genuine walk with the save skipped). + +All artifacts land under ``--out`` (default: the gitignored +``sites/berkeley/scripts_dev/runs/matrix``), which is also self-ignored by a +generated ``.gitignore`` inside the output root. +""" +from __future__ import annotations + +import argparse +import http.client +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +VERIFY_DIR = Path(__file__).resolve().parents[1] +SITE_DIR = VERIFY_DIR.parent +REPO = SITE_DIR.parents[1] +sys.path.insert(0, str(VERIFY_DIR)) + +import ground_truth # noqa: E402 +from verify_lib import title_tokens # noqa: E402 + +DEFAULT_PORT = 41026 +DEFAULT_OUT = SITE_DIR / "scripts_dev" / "runs" / "matrix" +VIEWPORT = {"width": 1280, "height": 800} +PASSWORD = "test1234" + +# --------------------------------------------------------------------------- # +# Workflows: each step is one browser action in the recorder's semantics +# (the recorded URL is the page *before* the action, as agent.py does). +# --------------------------------------------------------------------------- # +# {"goto": path} navigate +# {"fill": [selector, text], ...} type into fields (one entry per field) +# {"click": selector} click an element +# {"form": "/bookmark/add"} submit the form whose action matches +# +# ``answer`` is built from the derived facts, so the genuine run carries the +# ground truth without a second copy of it in this file. +WORKFLOWS: dict[int, dict[str, Any]] = { + 1: {"steps": [{"goto": "/"}, {"goto": "/programs?q=MBA"}, + {"goto": "/programs/business-administration-mba"}]}, + 2: {"steps": [{"goto": "/"}, {"goto": "/programs?q=Computer%20Science"}, + {"goto": "/programs/computer-science-bs"}]}, + 4: {"steps": [{"goto": "/"}, {"goto": "/news?q=CRISPR"}, + {"goto": "/news/crispr-pioneer-jennifer-doudna-receives-national-medal-of-science"}]}, + 6: {"steps": [{"goto": "/"}, {"goto": "/events?category=Lecture"}]}, + 7: {"steps": [{"goto": "/"}, {"goto": "/faculty?dept=eecs"}, + {"goto": "/faculty/stuart-russell"}]}, + 10: {"steps": [{"goto": "/"}, {"goto": "/research/bair"}]}, + 11: {"steps": [{"goto": "/"}, {"goto": "/admissions"}]}, + 12: {"steps": [{"goto": "/"}, {"goto": "/programs?college=haas-business"}]}, + 13: {"steps": [{"goto": "/"}, {"goto": "/departments"}, {"goto": "/departments/eecs"}]}, + 14: {"steps": [{"goto": "/"}, {"goto": "/academics"}]}, + 16: {"steps": [{"goto": "/"}, {"goto": "/programs?page=2"}, + {"goto": "/programs/data-science-ms"}]}, + 17: {"steps": [{"goto": "/"}, {"goto": "/about"}]}, + 19: {"steps": [{"goto": "/"}, {"goto": "/news?category=Athletics"}, + {"goto": "/news/womens-gymnastics-wins-ncaa-championship"}]}, + 20: {"steps": [{"goto": "/"}, {"goto": "/programs?degree=JD"}, + {"goto": "/programs/juris-doctor-jd"}]}, + 22: {"steps": [{"goto": "/"}, {"goto": "/departments"}]}, + 23: {"steps": [{"goto": "/"}, {"goto": "/research"}, {"goto": "/research/bids"}]}, + 24: {"steps": [{"goto": "/"}, {"goto": "/programs/economics-phd"}, + {"goto": "/departments/economics"}, {"goto": "/faculty/emmanuel-saez"}]}, + 25: {"steps": [{"goto": "/"}, {"goto": "/events?category=Career"}, {"goto": "/events/2"}]}, + 27: {"steps": [{"goto": "/"}, {"goto": "/programs?q=Master%20of%20Engineering"}, + {"goto": "/programs/master-of-engineering-meng"}, + {"goto": "/programs/computer-science-ms"}]}, + 28: {"steps": [{"goto": "/"}, {"goto": "/programs?degree=PhD"}, {"goto": "/programs?degree=MS"}]}, + 30: {"login": "alice@berkeley.edu", + "steps": [{"goto": "/login"}, + {"fill": [("input[name='email']", "alice@berkeley.edu"), + ("input[name='password']", PASSWORD)]}, + {"click": "form[action='/login'] button[type=submit]"}, + {"goto": "/research/seismo-lab"}, + {"form": "/bookmark/add", "skip_in_state_mismatch": True}, + {"goto": "/account"}]}, + 31: {"login": "bob@berkeley.edu", + "steps": [{"goto": "/login"}, + {"fill": [("input[name='email']", "bob@berkeley.edu"), + ("input[name='password']", PASSWORD)]}, + {"click": "form[action='/login'] button[type=submit]"}, + {"goto": "/research/msri"}, + {"form": "/bookmark/add", "skip_in_state_mismatch": True}, + {"goto": "/research/cpl"}, + {"form": "/bookmark/add", "skip_in_state_mismatch": True}, + {"goto": "/account"}, + {"click": "form[action='/bookmark/remove'] button", + "skip_in_state_mismatch": True}, + {"goto": "/account"}]}, +} + +WRONG_ANSWERS: dict[int, list[str]] = { + 1: ["The School of Law offers the MBA; it takes 3 years.", + "The Haas School of Business offers the MBA; it takes four years."], + 2: ["The Computer Science BS requires foundational coursework in theory, systems and AI, " + "plus a research project or thesis.", + "The Computer Science BS requires Data Structures, Algorithms, Computer Architecture and " + "Operating Systems, as well as a Qualifying Examination."], + 4: ["The featured scientist is Jennifer Doudna, who received the Nobel Prize.", + "The article is about a faster COVID test using CRISPR."], + 6: ["Events: 'Spring Career Fair 2026' on May 17, 2026 at the Recreational Sports Facility; " + "'Hackathon: Code for Climate 2026' on May 30, 2026 at Soda Hall; 'Berkeley Startup Pitch " + "Competition Finals' on June 4, 2026 at 310 Sutardja Dai Hall."], + 7: ["Eliza Strickland works on AI reporting and biomedical ethics.", + "Stuart Russell works on robotics and reinforcement learning."], + 10: ["BAIR was founded in 2017 and is directed by Prof. Pieter Abbeel."], + 11: ["The freshman deadline is December 1 and the acceptance rate is 11%."], + 12: ["Haas offers MBA, PhD and MFE programs.", + "The Haas School of Business offers the Business Administration MBA and a PhD in Business."], + 13: ["The EECS chair is Prof. Alexei Efros, in 253 Cory Hall."], + 14: ["The College of Engineering enrolls 31,800 undergraduates and 12,000 graduate students; " + "the dean is Dean Tsu-Jae King Liu."], + 16: ["Several programs can be completed online, including the Computer Science MS and the " + "Master of Engineering.", + "The Data Science MS from the School of Information is online, and so is the Civil " + "Engineering BS."], + 17: ["Berkeley has 107 Nobel Laureates on the faculty, 30 varsity sports and 105 NCAA titles.", + "Berkeley has 12 Nobel Laureates on the faculty, 32 varsity sports and 105 NCAA titles."], + 19: ["Berkeley athletes won a record 12 medals at the Winter World University Games.", + "Cal won the Pac-12 football championship."], + 20: ["The JD takes 2 years, has a January 5 deadline, and is offered by the Haas School of " + "Business."], + 22: ["The College of Letters and Science has 30 departments.", + "The College of Letters and Science lists 8 departments."], + 23: ["BIDS is directed by Prof. Douglas Dreger; focus areas are Data Science, Statistics and " + "Computational Methods; a related center is the Berkeley Seismological Laboratory.", + "BIDS is directed by Prof. David Culler and focuses on Machine Learning, Robotics and " + "Climate Policy."], + 24: ["The Economics department is chaired by Prof. David Card and offers the Economics BA and " + "the Economics PhD. Emmanuel Saez works on public economics and inequality."], + 25: ["The Spring Career Fair is on May 17, 2026 at the Recreational Sports Facility, and " + "registration is not required.", + "The Spring Career Fair is on May 17, 2026 at Pauley Ballroom, registration required."], + 27: ["The Master of Engineering is offered by EECS and takes 2 years; the Computer Science MS " + "takes 2 years."], + 28: ["There are 25 programs that require the GRE, all of them PhD programs."], + 30: ["I am not sure the Berkeley Seismological Laboratory was saved."], + 31: ["The Mathematical Sciences Research Institute was removed and the California Policy Lab " + "remains saved; its director is Prof. Tatiana Toro."], +} + + +def genuine_answer(number: int, facts: dict) -> str: + """The correct answer, rendered from the derived target.""" + if number == 1: + return f"The {facts['college']} offers the MBA; it takes {facts['duration_years']:g} years." + if number == 2: + return f"The Computer Science BS requires {', '.join(facts['items'])}." + if number == 4: + return f"The article features {facts['person']}, who received the {facts['award']}." + if number == 6: + # Rows with enough distinctive title tokens for the verifier's binding rule. + rows = [row for row in facts["upcoming"] if len(title_tokens(row["title"])) >= 3][:3] + listed = "; ".join(f"'{row['title']}' on {row['start_datetime'][:10]} at {row['location']}" for row in rows) + return f"Lecture events: {listed}." + if number == 7: + row = next(r for r in facts["allowed"] if r["slug"] == "stuart-russell") + return f"{row['name']} is an EECS professor whose research covers {row['research_interests']}." + if number == 10: + return f"BAIR was founded in {facts['founded_year']} and is directed by {facts['director']}." + if number == 11: + return (f"The freshman application deadline is {facts['deadline']}, and the acceptance " + f"rate is {facts['acceptance_rate']}.") + if number == 12: + programme = facts["programmes"][0] + return (f"The {facts['college']['name']} offers a single program: the " + f"{programme['degree_type']} in {programme['name']}.") + if number == 13: + return f"The chair of EECS is {facts['chair']}, and the department is located at {facts['location']}." + if number == 14: + return (f"The College of Engineering enrolls {facts['undergrad_count']:,} undergraduates and " + f"{facts['grad_count']:,} graduate students; the dean is {facts['dean']}.") + if number == 16: + programme = facts["program"] + return (f"Only one program offers an online option: the {programme['name']} " + f"{programme['degree_type']} from the {programme['college_name']}.") + if number == 17: + return (f"Berkeley has {facts['nobel_laureates']} Nobel Laureates on the faculty, " + f"{facts['varsity_sports']} varsity sports, and {facts['national_titles']} NCAA " + f"national titles.") + if number == 19: + row = facts["championships"][0] + return f"{row['title']}: the story reports the championship win and the team's run to it." + if number == 20: + return (f"The JD at Berkeley takes {facts['duration_years']:g} years, has a " + f"{facts['deadline']} deadline, and is offered by the {facts['college']}.") + if number == 22: + names = [row["name"] for row in facts["departments"]][:6] + return (f"The College of Letters and Science lists {len(facts['departments'])} departments: " + f"{', '.join(names)}.") + if number == 23: + centre, related = facts["centre"], facts["related_names"] + return (f"{centre['name']} is directed by {centre['director']}; its focus areas are " + f"{', '.join(facts['focus_areas'])}. A related center listed on the page is the " + f"{related[0]}.") + if number == 24: + member = next(r for r in facts["members"] if r["slug"] == "emmanuel-saez") + types = " and ".join(sorted({row["degree_type"] for row in facts["programmes"]})) + return (f"The Economics department is chaired by {facts['chair']} and offers the {types} in " + f"Economics. {member['name']} works on {member['research_interests']}.") + if number == 25: + anchor = facts["anchor"] + others = facts["others"][:2] + listed = "; ".join(f"'{row['title']}' on {row['start_datetime'][:10]} at {row['location']}" + for row in others) + return (f"The {anchor['title']} is on {anchor['start_datetime'][:10]} at {anchor['location']}, " + f"and registration is required. Two other career events: {listed}.") + if number == 27: + return (f"The {facts['meng']['name']} is offered by the {facts['department']} and takes " + f"{facts['durations'][0]:g} year; the {facts['ms']['name']} " + f"{facts['ms']['degree_type']} takes {facts['durations'][1]:g} years.") + if number == 28: + return (f"{facts['count']} programs in the catalogue require the GRE; the degree type that " + f"most commonly requires it is the {facts['most_common_degree']}.") + if number == 30: + centre = facts["centre"] + return (f"I signed in as alice, saved the {centre['name']} to my bookmarks, and it is listed " + f"under My Account. Its director is {facts['director']}.") + if number == 31: + first, second = facts["first"], facts["second"] + return (f"I signed in as bob, saved both centers in order, then removed the {first['name']} " + f"bookmark. The {second['name']} remains saved; its director is " + f"{second['director']}.") + raise ValueError(f"no genuine answer template for task {number}") + + +# --------------------------------------------------------------------------- # +# Browser driving +# --------------------------------------------------------------------------- # +class Recorder: + """Writes one run directory in the agent.py shape.""" + + def __init__(self, run_dir: Path, task_id: str, start_url: str) -> None: + self.run_dir = run_dir + self.shots = run_dir / "screenshots" + self.shots.mkdir(parents=True, exist_ok=True) + self.steps: list[dict[str, Any]] = [] + self.start_url = start_url + self.task_id = task_id + + def step(self, page, action: str, params: dict[str, Any], act) -> None: + index = len(self.steps) + before, after = f"step_{index:03d}.png", f"step_{index + 1:03d}.png" + url_before = page.url + page.screenshot(path=str(self.shots / before)) + if act is not None: + act() + page.screenshot(path=str(self.shots / after)) + self.steps.append({ + "step": index, "url": url_before, "title": page.title(), + "thought": "scripted matrix step", "action": action, "params": params, + "screenshot_before": before, "screenshot_after": after, + "action_result": {"is_done": False, "success": True, "error": None, "extracted_content": ""}, + }) + + def finish(self, page, answer: str | None) -> None: + self.step(page, "done", {"text": answer or "", "success": bool(answer)}, None) + trajectory = { + "task": "scripted matrix run", "task_id": self.task_id, "start_url": self.start_url, + "model": "playwright-matrix", "max_steps": 30, "steps": self.steps, + "terminated": bool(answer), "termination_reason": "agent_done" if answer else "max_steps", + "final_answer": answer, "success_self_report": bool(answer), + "judge_rubric": "", "verifier_path": f"sites/berkeley/verify/verify_{self.task_id.rsplit('--', 1)[1]}.py", + } + (self.run_dir / "trajectory.json").write_text(json.dumps(trajectory, indent=2), encoding="utf-8") + + +def drive(page, base_url: str, steps: list[dict[str, Any]], recorder: Recorder, + *, skip_saves: bool = False) -> None: + for item in steps: + if skip_saves and item.get("skip_in_state_mismatch"): + # The state-mismatch cell replays the workflow with every write + # skipped, so a later click that depends on a write (the bookmark + # removal) must be skipped with it, or the cell hangs on a form + # that a fresh instance never renders. + continue + if "goto" in item: + target = base_url + item["goto"] + recorder.step(page, "navigate", {"url": target}, lambda target=target: page.goto(target)) + elif "fill" in item: + # One step per field: the verifier reads the typed texts of the + # /login input steps (email and password are separate actions). + for selector, text in item["fill"]: + recorder.step( + page, "input", {"text": text}, + lambda selector=selector, text=text: page.fill(selector, text), + ) + elif "click" in item: + selector = item["click"] + recorder.step(page, "click", {"selector": selector}, lambda selector=selector: page.click(selector)) + elif "form" in item: + action = item["form"] + selector = f"form[action='{action}'] button" + recorder.step(page, "click", {"form": action}, lambda selector=selector: page.click(selector)) + else: # pragma: no cover - workflow table is static + raise ValueError(f"unsupported step: {item!r}") + + +# --------------------------------------------------------------------------- # +# Boot / snapshot / grade +# --------------------------------------------------------------------------- # +def fresh_instance(site_dir: Path) -> None: + instance = site_dir / "instance" + if instance.exists(): + shutil.rmtree(instance) + shutil.copytree(site_dir / "instance_seed", instance) + + +def boot(site_dir: Path, port: int) -> subprocess.Popen: + log = site_dir / "scripts_dev" / "runs" / "server.log" + log.parent.mkdir(parents=True, exist_ok=True) + # Refuse to run against a server we did not start. Otherwise the readiness + # probe below adopts any process already listening on the port (e.g. a + # leftover standalone `PORT=41026 python app.py`) and every snapshot and + # every verdict then describe that foreign instance while the cells look + # green. Observed for real: a stray server made the 30/31 pass cells fail + # with bookmarks_exact_delta. + try: + probe = http.client.HTTPConnection("127.0.0.1", port, timeout=1) + probe.request("GET", "/") + probe.getresponse() + probe.close() + except OSError: + pass + else: + raise RuntimeError( + f"port :{port} already serves a site that this run did not start; " + f"stop it (lsof -ti tcp:{port}) before running the matrix" + ) + handle = log.open("ab") + process = subprocess.Popen( + [sys.executable, "app.py"], cwd=str(site_dir), + env={**os.environ, "PORT": str(port)}, stdout=handle, stderr=handle, + ) + deadline = time.time() + 30 + while time.time() < deadline: + try: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2) + connection.request("GET", "/") + if connection.getresponse().status == 200: + connection.close() + return process + connection.close() + except OSError: + time.sleep(0.25) + process.terminate() + raise RuntimeError(f"site did not come up on :{port} (see {log})") + + +def snapshot(site_dir: Path, run_dir: Path, kind: str) -> Path: + source = site_dir / ("instance_seed" if kind == "initial" else "instance") / "berkeley.db" + target = run_dir / ("initial.db" if kind == "initial" else "after.db") + shutil.copy2(source, target) + return target + + +def inject_collateral_write(site_dir: Path) -> None: + """One row written straight into the live DB (fault injection, not a route).""" + connection = sqlite3.connect(str(site_dir / "instance" / "berkeley.db")) + try: + connection.execute( + "INSERT INTO bookmarks(user_id, item_type, item_id, note, created_at) " + "VALUES (2, 'research', 1, 'matrix collateral write', '2026-05-12 00:00:00')" + ) + connection.commit() + finally: + connection.close() + + +def grade(run_dir: Path) -> dict[str, Any]: + # eval_judge.py imports the agent_demo project's dependencies (openai, + # simpleArgParser), so it must be launched from inside agent_demo/ — the + # repo root has no pyproject and its .venv lacks them (verified: launching + # from the repo root dies with ModuleNotFoundError: No module named 'openai' + # and never writes eval.json). + command = ["uv", "run", "python", "eval_judge.py", + "--run_dir", str(run_dir), "--verifier", "True"] + result = subprocess.run(command, cwd=str(REPO / "agent_demo"), capture_output=True, text=True) + verdict_path = run_dir / "eval.json" + if not verdict_path.is_file(): + return {"pass": None, "reason": f"no eval.json (rc={result.returncode}): " + f"{(result.stderr or result.stdout)[-300:]}"} + verdict = json.loads(verdict_path.read_text(encoding="utf-8")) + return {"pass": bool(verdict.get("pass")), "reason": verdict.get("reason"), + "infra_error": bool(verdict.get("infra_error"))} + + +def emit_cells(number: int, facts: dict, out_root: Path, base_url: str, + wanted: set[str] | None = None) -> list[tuple[str, Path, bool]]: + """Build every run directory for one task; returns (cell, run_dir, expects_pass).""" + from playwright.sync_api import sync_playwright # noqa: PLC0415 - optional dependency at runtime + + workflow = WORKFLOWS[number] + answer = genuine_answer(number, facts) + cells: list[tuple[str, Path, bool]] = [] + task_id = f"UC Berkeley--{number}" + + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + for cell, expects_pass in (("pass", True), ("no_op", False), ("shortcut", False), + ("wrong_answer", False), ("collateral_write", False), + ("state_mismatch", False)): + if cell == "state_mismatch" and not workflow.get("login"): + continue + if wanted and cell not in wanted: + continue + run_dir = out_root / f"task_{number:02d}" / cell + run_dir.mkdir(parents=True, exist_ok=True) + fresh_instance(SITE_DIR) + process = boot(SITE_DIR, DEFAULT_PORT) + try: + snapshot(SITE_DIR, run_dir, "initial") + page = browser.new_page(viewport=VIEWPORT) + # agent.py navigates to the start URL before its first recorded + # step, so step 0's ``url`` is the start URL. Without this the + # page is still about:blank and the verifier's + # all_urls_match_local_origin gate fails the genuine run. + page.goto(base_url + "/") + recorder = Recorder(run_dir, task_id, f"{base_url}/") + if cell == "no_op": + drive(page, base_url, [{"goto": "/"}], recorder) + recorder.finish(page, None) + elif cell == "shortcut": + drive(page, base_url, [{"goto": "/"}, {"goto": "/search?q=california"}], recorder) + recorder.finish(page, answer) + elif cell == "wrong_answer": + drive(page, base_url, workflow["steps"], recorder) + recorder.finish(page, WRONG_ANSWERS[number][0]) + elif cell == "state_mismatch": + drive(page, base_url, workflow["steps"], recorder, skip_saves=True) + recorder.finish(page, answer) + else: + drive(page, base_url, workflow["steps"], recorder) + if cell == "collateral_write": + inject_collateral_write(SITE_DIR) + recorder.finish(page, answer) + page.close() + snapshot(SITE_DIR, run_dir, "after") + cells.append((cell, run_dir, expects_pass)) + finally: + process.terminate() + process.wait(timeout=10) + browser.close() + return cells + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--out", default=str(DEFAULT_OUT)) + parser.add_argument("--tasks", default="", help="comma-separated task numbers (default: all)") + parser.add_argument("--cells", default="", + help="comma-separated cell names to drive (default: all), e.g. no_op") + parser.add_argument("--grade-only", action="store_true", + help="re-grade the run directories under --out without driving browsers") + args = parser.parse_args() + wanted_cells = {value.strip() for value in args.cells.split(",") if value.strip()} or None + + out_root = Path(args.out).resolve() + out_root.mkdir(parents=True, exist_ok=True) + (out_root / ".gitignore").write_text("*\n", encoding="utf-8") + numbers = ([int(value) for value in args.tasks.split(",") if value.strip()] + if args.tasks else sorted(WORKFLOWS)) + facts_by_task = {number: ground_truth.task_ground_truth(str(SITE_DIR / "instance_seed" / "berkeley.db"), number) + for number in numbers} + + results: list[dict[str, Any]] = [] + for number in numbers: + if args.grade_only: + cells = [(cell.name, cell, cell.name == "pass") + for cell in sorted((out_root / f"task_{number:02d}").iterdir()) if cell.is_dir()] + else: + cells = emit_cells(number, facts_by_task[number], out_root, + f"http://localhost:{DEFAULT_PORT}", wanted_cells) + for cell, run_dir, expects_pass in cells: + verdict = grade(run_dir) + ok = verdict["pass"] is expects_pass + results.append({"task": number, "cell": cell, "expected_pass": expects_pass, + "observed_pass": verdict["pass"], "reason": verdict["reason"], "ok": ok}) + flag = "ok " if ok else "MISMATCH" + print(f"[{flag}] {number:>2} {cell:<16} expected={'PASS' if expects_pass else 'FAIL'} " + f"observed={verdict['pass']} reason={verdict['reason']}") + + summary = out_root / "summary.json" + summary.write_text(json.dumps(results, indent=2), encoding="utf-8") + failures = [row for row in results if not row["ok"]] + print(f"\n{len(results)} cells, {len(failures)} mismatches -> {summary}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sites/berkeley/verify/tests/test_run_matrix_contract.py b/sites/berkeley/verify/tests/test_run_matrix_contract.py new file mode 100644 index 00000000..f3c853bc --- /dev/null +++ b/sites/berkeley/verify/tests/test_run_matrix_contract.py @@ -0,0 +1,114 @@ +"""Browser-free contract tests for run_matrix.py. + +The matrix itself runs in the validation window; these tests check that the +scripted workflows, the derived genuine answers and the wrong-answer table all +line up with the verifiers, by replaying each workflow as a synthetic +url-before-action trajectory and grading it. No Playwright, no docker. +""" +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import BASE, SEED_DB, State, VerifierTestCase, run_verifier, write_run # noqa: E402 + +VERIFY_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(VERIFY_DIR)) + +import ground_truth # noqa: E402 + +_spec = importlib.util.spec_from_file_location("run_matrix", Path(__file__).resolve().parent / "run_matrix.py") +run_matrix = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(run_matrix) + +KEPT = [1, 2, 4, 6, 7, 10, 11, 12, 13, 14, 16, 17, 19, 20, 22, 23, 24, 25, 27, 28, 30, 31] + + +def replay_steps(number: int) -> list[dict]: + """A recorder-faithful step list: each step carries the URL *before* its action.""" + steps = run_matrix.WORKFLOWS[number]["steps"] + targets = [BASE + item["goto"] for item in steps if "goto" in item] + replay: list[dict] = [] + current = f"{BASE}/" + for item in steps: + if "goto" in item: + replay.append({"url": current, "action": "navigate", "params": {"url": BASE + item["goto"]}}) + current = BASE + item["goto"] + elif "fill" in item: + # One recorder step per filled field (see run_matrix.drive). + for _, text in item["fill"]: + replay.append({"url": current, "action": "input", "params": {"text": text}}) + else: + replay.append({"url": current, "action": "click", "params": {}}) + replay.append({"url": current, "action": "done", "params": {}}) + return replay + + +def genuine_after(number: int) -> State: + if number == 30: + state = State() + state.add_bookmark(1, "research", 8) + return state + if number == 31: + state = State() + state.add_bookmark(2, "research", 9) # MSRI, id 1 + state.add_bookmark(2, "research", 22) # CPL, id 2 + state.remove_bookmark(1) + return state + return State() + + +class RunMatrixWorkflowTests(VerifierTestCase): + def test_workflows_cover_the_task_set(self) -> None: + self.assertEqual(sorted(run_matrix.WORKFLOWS), KEPT) + self.assertEqual(sorted(run_matrix.WRONG_ANSWERS), KEPT) + + def test_genuine_workflow_passes_every_verifier(self) -> None: + for number in KEPT: + facts = ground_truth.task_ground_truth(str(SEED_DB), number) + answer = run_matrix.genuine_answer(number, facts) + self.assertTrue(answer and answer.strip(), f"task {number} has an empty genuine answer") + run_dir = write_run( + self._tmp / f"matrix_{number}", f"UC Berkeley--{number}", + replay_steps(number), answer, after=genuine_after(number), + ) + verdict, _ = run_verifier(number, run_dir, container="wh-berkeley-test-none") + self.assertTrue( + verdict.get("pass"), + f"task {number} genuine workflow FAILED: reason={verdict.get('reason')!r} " + f"evidence={verdict.get('evidence')!r}", + ) + + def test_wrong_answers_are_all_rejected(self) -> None: + for number in KEPT: + for index, wrong in enumerate(run_matrix.WRONG_ANSWERS[number]): + run_dir = write_run( + self._tmp / f"wrong_{number}_{index}", f"UC Berkeley--{number}", + replay_steps(number), wrong, after=genuine_after(number), + ) + verdict, _ = run_verifier(number, run_dir, container="wh-berkeley-test-none") + self.assertFalse( + verdict.get("pass"), + f"task {number} wrong answer {index} PASSED the verifier: {wrong!r}", + ) + + def test_collateral_write_cell_fails_for_every_task(self) -> None: + """The matrix's injected write must be rejected by every row.""" + for number in KEPT: + facts = ground_truth.task_ground_truth(str(SEED_DB), number) + after = genuine_after(number) + after.add_bookmark(2, "research", 1, note="matrix collateral write") + run_dir = write_run( + self._tmp / f"collateral_{number}", f"UC Berkeley--{number}", + replay_steps(number), run_matrix.genuine_answer(number, facts), after=after, + ) + verdict, _ = run_verifier(number, run_dir, container="wh-berkeley-test-none") + self.assertFalse(verdict.get("pass"), f"task {number} tolerated a collateral write") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_tasks_contract.py b/sites/berkeley/verify/tests/test_tasks_contract.py new file mode 100644 index 00000000..c25e08ae --- /dev/null +++ b/sites/berkeley/verify/tests/test_tasks_contract.py @@ -0,0 +1,218 @@ +"""The tasks.jsonl contract, and the rule that no rubric carries a ground-truth value. + +``sites/berkeley/tasks.jsonl`` is agent-facing: the rubric may state *rules* but +must not contain any value the verifiers derive from the snapshot. This module +re-derives every target and asserts that no derived phrase, number or +distinctive token appears in the rubrics — unless the task text itself already +names it (then it is not a leak). +""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import SITE_DIR, SEED_DB # noqa: E402 + +VERIFY_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(VERIFY_DIR)) + +import ground_truth # noqa: E402 + +TASKS = SITE_DIR / "tasks.jsonl" +EXPECTED_KEYS = {"web_name", "id", "ques", "web", "upstream_url", "verifier_path", "judge_rubric"} +EXPECTED_IDS = [1, 2, 4, 6, 7, 10, 11, 12, 13, 14, 16, 17, 19, 20, 22, 23, 24, 25, 27, 28, 30, 31] + +# Tokens that appear across many derived values are domain vocabulary, not answers. +GENERIC_TASK_FREQUENCY = 4 + + +def load_rows() -> list[dict]: + return [json.loads(line) for line in TASKS.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def strings_in(value) -> list[str]: + """Every string (and numeric) literal inside a derived fact, recursively.""" + found: list[str] = [] + if isinstance(value, dict): + for item in value.values(): + found.extend(strings_in(item)) + elif isinstance(value, (list, tuple)): + for item in value: + found.extend(strings_in(item)) + elif isinstance(value, bool): + pass + elif isinstance(value, (int, float)): + found.append(f"{value:g}") + elif isinstance(value, str): + found.append(value) + return found + + +# Long prose (programme/article descriptions) is rendered on the page but is not +# an answer value; only short labels and names are treated as leak candidates. +MAX_VALUE_LENGTH = 100 +MAX_VALUE_TOKENS = 10 + + +def answer_values(facts: dict) -> list[str]: + return [ + value for value in strings_in({key: value for key, value in facts.items() if key != "task"}) + if len(value.strip()) <= MAX_VALUE_LENGTH and len(value.split()) <= MAX_VALUE_TOKENS + ] + + +def forbidden_for(facts: dict, ques: str, generic_tokens: set[str]) -> set[str]: + """Phrases, numbers and distinctive tokens that must not appear in the rubric. + + Anything the task text itself names is not a leak; tokens are compared with a + crude stem rule so "requirement" matches the task's "requirements". + """ + values = answer_values(facts) + lowered_ques = ques.lower() + ques_tokens = set(re.findall(r"[a-z]{4,}", lowered_ques)) + + def in_ques_token(token: str) -> bool: + return any(token == other or token.startswith(other) or other.startswith(token) + for other in ques_tokens) + + phrases = { + value.strip().lower() for value in values + if len(value.strip()) >= 4 and value.strip().lower() not in lowered_ques + } + numbers = {number_text for value in values for number_text in re.findall(r"\d+(?:\.\d+)?", value)} + tokens = {token for value in values for token in re.findall(r"[a-z]{4,}", value.lower())} + return phrases | numbers | { + token for token in tokens if not in_ques_token(token) and token not in generic_tokens + } + + +def leaks_in(rubric: str, forbidden: set[str]) -> list[str]: + """Forbidden phrases/numbers anywhere; single tokens only as whole words.""" + lowered = rubric.lower() + found = [] + for value in sorted(forbidden): + if len(value) < 4: + continue + if re.fullmatch(r"[a-z]+", value): + if re.search(rf"\b{re.escape(value)}\b", lowered): + found.append(value) + elif value in lowered: + found.append(value) + return found + + +def generic_vocabulary(all_facts: dict[int, dict]) -> set[str]: + """Tokens that recur across many tasks are domain vocabulary, not answers.""" + per_task = { + number: { + token + for value in answer_values(facts) + for token in re.findall(r"[a-z]{4,}", value.lower()) + } + for number, facts in all_facts.items() + } + counts: dict[str, int] = {} + for tokens in per_task.values(): + for token in tokens: + counts[token] = counts.get(token, 0) + 1 + return {token for token, count in counts.items() if count >= GENERIC_TASK_FREQUENCY} + + +class TaskFileContractTests(unittest.TestCase): + def setUp(self) -> None: + self.rows = load_rows() + self.facts = ground_truth.all_ground_truth(str(SEED_DB)) + self.generic_tokens = generic_vocabulary(self.facts) + + def test_row_count_ids_and_keys(self) -> None: + self.assertEqual(len(self.rows), 22) + self.assertEqual( + [int(row["id"].rsplit("--", 1)[1]) for row in self.rows], EXPECTED_IDS + ) + for row in self.rows: + self.assertEqual(set(row), EXPECTED_KEYS, row["id"]) + self.assertEqual(row["web_name"], "UC Berkeley") + self.assertEqual(row["web"], "http://localhost:40029/") + self.assertEqual(row["upstream_url"], "https://www.berkeley.edu/") + self.assertTrue(row["ques"].strip()) + self.assertIn("Checkpoints:", row["judge_rubric"]) + + def test_verifier_paths_exist_and_match_the_task_id(self) -> None: + for row in self.rows: + number = int(row["id"].rsplit("--", 1)[1]) + path = Path(row["verifier_path"]) + self.assertFalse(path.is_absolute(), row["id"]) + self.assertEqual(path.name, f"verify_{number}.py") + full = SITE_DIR.parents[1] / path + self.assertTrue(full.is_file(), f"{row['id']}: missing {full}") + self.assertIn(f'TASK_ID = "UC Berkeley--{number}"', full.read_text(encoding="utf-8")) + + def test_every_verifier_file_is_referenced(self) -> None: + referenced = {Path(row["verifier_path"]).name for row in self.rows} + present = {path.name for path in (SITE_DIR / "verify").glob("verify_[0-9]*.py")} + self.assertEqual(referenced, present) + + def test_rubrics_are_unique(self) -> None: + rubrics = [row["judge_rubric"] for row in self.rows] + self.assertEqual(len(set(rubrics)), len(rubrics)) + + def test_no_rubric_contains_a_derived_ground_truth_value(self) -> None: + # The shared preamble is identical boilerplate across every row; the scan + # covers each row's own checkpoints, where a task-specific value would + # actually leak. + rubrics = [row["judge_rubric"] for row in self.rows] + preamble = os.path.commonprefix(rubrics) + for row in self.rows: + number = int(row["id"].rsplit("--", 1)[1]) + forbidden = forbidden_for(self.facts[number], row["ques"], self.generic_tokens) + leaked = leaks_in(row["judge_rubric"][len(preamble):], forbidden) + self.assertEqual(leaked, [], f"{row['id']} rubric leaks derived values: {leaked}") + + def test_leak_detector_has_teeth(self) -> None: + """The scan must reject a rubric that carries the derived answer.""" + row = next(row for row in self.rows if row["id"] == "UC Berkeley--4") + forbidden = forbidden_for(self.facts[4], row["ques"], self.generic_tokens) + self.assertTrue(forbidden) + self.assertTrue( + leaks_in("the answer must name Jennifer Doudna and the National Medal of Science", forbidden) + ) + self.assertEqual( + leaks_in("the article's page must have been opened and the award it reports given", forbidden), + [], + ) + + def test_no_rubric_contains_a_derived_number(self) -> None: + for row in self.rows: + number = int(row["id"].rsplit("--", 1)[1]) + rubric = row["judge_rubric"] + numbers = { + found + for value in strings_in({k: v for k, v in self.facts[number].items() if k != "task"}) + for found in re.findall(r"\d+(?:\.\d+)?", value) + } + ques_numbers = set(re.findall(r"\d+(?:\.\d+)?", row["ques"])) + for value in sorted(numbers - ques_numbers): + self.assertIsNone( + re.search(rf"(? None: + """--30/--31 assume an empty bookmarks table (see VERIFIER_PLAN.md B.2).""" + worker = sqlite3.connect(str(SEED_DB)) + try: + count = worker.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0] + finally: + worker.close() + self.assertEqual(count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_1.py b/sites/berkeley/verify/tests/test_verify_1.py new file mode 100644 index 00000000..5efa2ff2 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_1.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?q=MBA"), + step("/programs/business-administration-mba", "done"), +] +ANSWER = "The Haas School of Business offers the MBA; it takes 2 years." + + +class VerifyTask1Tests(SharedVerifierTests, VerifierTestCase): + N = 1 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_degree_filter_also_satisfies_search_gate(self) -> None: + steps = [step("/"), step("/programs?degree=MBA"), step("/programs/business-administration-mba", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_shortcut_without_detail_fails_on_gate(self) -> None: + steps = [step("/"), step("/programs?q=MBA", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_business-administration-mba") + + def test_shortcut_without_listing_fails_on_search_gate(self) -> None: + steps = [step("/"), step("/programs/business-administration-mba", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_search") + + def test_wrong_school_fails(self) -> None: + answer = "The School of Law offers the MBA; it takes 2 years." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_school") + + def test_wrong_duration_fails(self) -> None: + answer = "The Haas School of Business offers the MBA; it takes 3 years." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_duration") + + def test_negated_school_fails(self) -> None: + answer = "The MBA is not offered by the Haas School of Business; it takes 2 years." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_school") + + def test_alternative_phrasing_passes(self) -> None: + answer = "Berkeley's MBA programme is run by the Haas School of Business and lasts two years." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 41) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_10.py b/sites/berkeley/verify/tests/test_verify_10.py new file mode 100644 index 00000000..d35abc8f --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_10.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/research/bair", "done")] +ANSWER = "BAIR was founded in 2013 and is directed by Prof. Pieter Abbeel." + + +class VerifyTask10Tests(SharedVerifierTests, VerifierTestCase): + N = 10 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_listing_fails(self) -> None: + steps = [step("/"), step("/research", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_research_detail_bair") + + def test_real_world_founding_year_fails(self) -> None: + answer = "BAIR was founded in 2017 and is directed by Prof. Pieter Abbeel." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_founded_year") + + def test_wrong_director_fails(self) -> None: + answer = "BAIR was founded in 2013 and is directed by Prof. David Culler." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_director") + + def test_negated_year_fails(self) -> None: + answer = "BAIR is directed by Prof. Pieter Abbeel, but it was not founded in 2013." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_founded_year") + + def test_fully_negated_answer_fails_on_first_check(self) -> None: + answer = "BAIR was not founded in 2013." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_director") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "The Berkeley Artificial Intelligence Research Lab's director is Pieter Abbeel, and " + "it was established in 2013." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "research", 1) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_11.py b/sites/berkeley/verify/tests/test_verify_11.py new file mode 100644 index 00000000..00a8c9fa --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_11.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/admissions", "done")] +ANSWER = "The freshman application deadline is November 30, and the acceptance rate is 14.4%." + + +class VerifyTask11Tests(SharedVerifierTests, VerifierTestCase): + N = 11 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_homepage_fails(self) -> None: + steps = [step("/"), step("/about", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_admissions_page") + + def test_transfer_or_graduate_deadline_fails(self) -> None: + answer = "The freshman deadline is December 1 and the acceptance rate is 11%." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_freshman_deadline") + + def test_wrong_rate_fails(self) -> None: + answer = "The freshman deadline is November 30 and the acceptance rate is 11%." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_acceptance_rate") + + def test_negated_deadline_fails(self) -> None: + answer = "The freshman deadline is not November 30; the acceptance rate is 14.4%." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_freshman_deadline") + + def test_alternative_phrasing_passes(self) -> None: + answer = "Freshmen apply by Nov. 30, and Berkeley's acceptance rate is 14.4 percent." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 1) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_12.py b/sites/berkeley/verify/tests/test_verify_12.py new file mode 100644 index 00000000..9b39560b --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_12.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/programs?college=haas-business", "done")] +ANSWER = "The Haas School of Business offers a single program: the MBA in Business Administration." + + +class VerifyTask12Tests(SharedVerifierTests, VerifierTestCase): + N = 12 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_degree_filter_fails(self) -> None: + steps = [step("/"), step("/programs?degree=MBA", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_haas_programme_listing") + + def test_prior_knowledge_multi_program_fails(self) -> None: + answer = "Haas offers MBA, PhD, and MFE programs." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_programme") + + def test_extra_degree_type_near_haas_fails(self) -> None: + answer = ( + "The Haas School of Business offers the Business Administration MBA and a PhD in " + "Business." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_no_other_haas_degrees") + + def test_negated_programme_fails(self) -> None: + answer = "Haas does not offer the MBA in Business Administration." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_programme") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Only one degree type is available at the Haas School of Business: the MBA " + "(Business Administration)." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_other_schools_mention_does_not_trip_the_negative(self) -> None: + answer = ( + "The Haas School of Business offers only the MBA in Business Administration, while " + "other schools award the PhD." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 41) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_13.py b/sites/berkeley/verify/tests/test_verify_13.py new file mode 100644 index 00000000..91d23563 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_13.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/departments"), step("/departments/eecs", "done")] +ANSWER = "The chair of EECS is Prof. James Demmel, and the department is located at 253 Cory Hall." + + +class VerifyTask13Tests(SharedVerifierTests, VerifierTestCase): + N = 13 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_listing_fails(self) -> None: + steps = [step("/"), step("/departments", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_department_detail") + + def test_real_world_chair_fails(self) -> None: + answer = "The EECS chair is Prof. Alexei Efros, in 253 Cory Hall." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_wrong_location_fails(self) -> None: + answer = "The chair of EECS is Prof. James Demmel, located in Soda Hall." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_location") + + def test_negated_chair_fails(self) -> None: + answer = "James Demmel is not the chair of EECS." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_negated_titled_chair_fails(self) -> None: + """C2 regression: the negation sits before the name, after "is" and the + title's period — the abbreviation must not hide it.""" + answer = ("The chair of EECS is not Prof. James Demmel, and the department is " + "located at 253 Cory Hall.") + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_alternative_phrasing_passes(self) -> None: + answer = "EECS is chaired by James Demmel; its location is Cory Hall." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "faculty", 1) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_14.py b/sites/berkeley/verify/tests/test_verify_14.py new file mode 100644 index 00000000..2d2a4e87 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_14.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/academics", "done")] +ANSWER = ( + "The College of Engineering enrolls 4,500 undergraduates and 3,200 graduate students; the " + "dean is Dean Tsu-Jae King Liu." +) + + +class VerifyTask14Tests(SharedVerifierTests, VerifierTestCase): + N = 14 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_about_page_fails(self) -> None: + steps = [step("/"), step("/about", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_academics_page") + + def test_university_wide_totals_fail(self) -> None: + answer = ( + "The College of Engineering enrolls 31,800 undergraduates and 12,000 graduate " + "students; the dean is Dean Tsu-Jae King Liu." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_undergrad_count") + + def test_wrong_dean_fails(self) -> None: + answer = "The College of Engineering enrolls 4,500 undergraduates and 3,200 graduate students." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_dean") + + def test_negated_dean_fails(self) -> None: + answer = ( + "The College of Engineering enrolls 4,500 undergraduates and 3,200 graduate " + "students; the dean is not Tsu-Jae King Liu." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_dean") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Engineering has 4500 undergrads and 3200 grad students, led by Dean Tsu-Jae King Liu." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 1) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_16.py b/sites/berkeley/verify/tests/test_verify_16.py new file mode 100644 index 00000000..bbcfff5f --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_16.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?page=3"), + step("/programs/data-science-ms", "done"), +] +ANSWER = "Only one program offers an online option: the Data Science MS from the School of Information." + + +class VerifyTask16Tests(SharedVerifierTests, VerifierTestCase): + N = 16 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_online_search_is_not_a_listing_visit(self) -> None: + steps = [step("/"), step("/search?q=online", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_programme_listing") + + def test_shortcut_without_detail_fails(self) -> None: + steps = [step("/"), step("/programs", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_data-science-ms") + + def test_prior_knowledge_multiple_online_fails(self) -> None: + answer = ( + "Several programs can be completed online, including the Computer Science MS and " + "the Master of Engineering." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_programme") + + def test_second_online_program_fails(self) -> None: + answer = ( + "The Data Science MS from the School of Information is online, and so is the Civil " + "Engineering BS." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_no_other_online_programmes") + + def test_negated_programme_fails(self) -> None: + answer = "The Data Science MS is not online." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_programme") + + def test_alternative_phrasing_passes(self) -> None: + answer = "The Data Science MS (School of Information) is the single degree with an online option." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 26) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_17.py b/sites/berkeley/verify/tests/test_verify_17.py new file mode 100644 index 00000000..902de549 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_17.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/about", "done")] +ANSWER = ( + "Berkeley has 12 Nobel Laureates on the faculty, 30 varsity sports, and 105 NCAA national " + "titles." +) + + +class VerifyTask17Tests(SharedVerifierTests, VerifierTestCase): + N = 17 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_about_page_fails(self) -> None: + steps = [step("/"), step("/", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_about_page") + + def test_prizes_line_misquoted_as_faculty_count_fails(self) -> None: + answer = "Berkeley has 107 Nobel Laureates on the faculty, 30 varsity sports, and 105 NCAA titles." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_nobel_laureates") + + def test_wrong_sports_count_fails(self) -> None: + answer = "Berkeley has 12 Nobel Laureates on the faculty, 32 varsity sports, and 105 NCAA titles." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_varsity_sports") + + def test_negated_count_fails(self) -> None: + answer = "Berkeley does not have 12 Nobel Laureates on the faculty." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_nobel_laureates") + + def test_distractor_claimed_as_laureates_fails(self) -> None: + answer = ( + "Berkeley has 12 Nobel Laureates on the faculty (though the page notes 107 Nobel " + "Laureates overall), 30 varsity sports and 105 NCAA titles." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_rejects_distractor_nobel_count") + + def test_alternative_phrasing_passes(self) -> None: + answer = "The About page lists 12 faculty Nobel laureates, 30 varsity sports and 105 national titles." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_alumni_prizes_line_elsewhere_is_not_a_wrong_answer(self) -> None: + answer = ( + "Berkeley has 12 Nobel Laureates on the faculty, 30 varsity sports and 105 NCAA " + "national titles. The page also notes that faculty, researchers and alumni have won " + "more than 107 Nobel Prizes in total." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "research", 2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_19.py b/sites/berkeley/verify/tests/test_verify_19.py new file mode 100644 index 00000000..9ce89b46 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_19.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GYM = "/news/womens-gymnastics-wins-ncaa-championship" +MEDALS = "/news/berkeley-athletes-win-record-12-medals-at-winter-world-university-games" +GENUINE_STEPS = [step("/"), step("/news?category=Athletics"), step(GYM, "done")] +ANSWER = ( + "Women's Gymnastics won the NCAA Championship, led by an all-around champion and three " + "perfect 10.0 scores on the balance beam." +) + + +class VerifyTask19Tests(SharedVerifierTests, VerifierTestCase): + N = 19 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_article_fails(self) -> None: + steps = [step("/"), step("/news?category=Athletics", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_championship_article") + + def test_non_championship_article_fails(self) -> None: + steps = [step("/"), step("/news?category=Athletics"), step(MEDALS, "done")] + answer = "Berkeley athletes won a record 12 medals at the Winter World University Games." + self.assertFailsOn(self.verdict(steps, answer), "visited_championship_article") + + def test_swimming_championship_also_passes(self) -> None: + steps = [ + step("/"), + step("/news?category=Athletics"), + step("/news/cal-wins-pac-12-swimming-and-diving-championship", "done"), + ] + answer = "Cal won the Pac-12 Swimming and Diving Championship with 1,400 points." + self.assertPasses(self.verdict(steps, answer)) + + def test_wrong_sport_for_visited_article_fails(self) -> None: + answer = "Cal won the Pac-12 football championship." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_binds_to_championship_article") + + def test_negated_championship_fails(self) -> None: + answer = "Women's Gymnastics did not win the NCAA Championship." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_binds_to_championship_article") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "The NCAA championship was won by the women's gymnastics team, which scored three " + "perfect 10.0s on the balance beam." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "news", 19) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_2.py b/sites/berkeley/verify/tests/test_verify_2.py new file mode 100644 index 00000000..ad013258 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_2.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?q=Computer%20Science"), + step("/programs/computer-science-bs", "done"), +] +ANSWER = ( + "The Computer Science BS requires Data Structures, Algorithms, Computer Architecture, " + "Operating Systems, AI, Machine Learning, Software Engineering, and technical electives." +) +BS_ITEMS = ["Data Structures", "Algorithms", "Computer Architecture", "Operating Systems", + "AI", "Machine Learning", "Software Engineering"] + + +class VerifyTask2Tests(SharedVerifierTests, VerifierTestCase): + N = 2 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_detail_fails_on_gate(self) -> None: + steps = [step("/"), step("/programs?q=Computer%20Science", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_computer-science-bs") + + def test_ms_requirements_fail(self) -> None: + answer = ( + "The Computer Science MS requires foundational coursework in theory, systems, and AI, " + "plus a research project or thesis." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_requirements_match_bs") + + def test_sibling_requirement_items_fail(self) -> None: + answer = ( + "The BS requires Data Structures, Algorithms, Computer Architecture and Operating " + "Systems, as well as a Qualifying Examination and a Dissertation Proposal." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_no_sibling_requirements") + + def test_negated_requirements_fail(self) -> None: + answer = ( + "The BS does not require Data Structures, Algorithms, Computer Architecture or " + "Operating Systems." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_requirements_match_bs") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Requirements for the Computer Science BS include Algorithms, Computer Architecture, " + "Data Structures, Machine Learning, Operating Systems and Software Engineering, " + "along with technical electives." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 10) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_20.py b/sites/berkeley/verify/tests/test_verify_20.py new file mode 100644 index 00000000..8b10f829 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_20.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?degree=JD"), + step("/programs/juris-doctor-jd", "done"), +] +ANSWER = ( + "The JD at Berkeley takes 3 years, has a February 1 deadline, and is offered by the School " + "of Law." +) + + +class VerifyTask20Tests(SharedVerifierTests, VerifierTestCase): + N = 20 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_detail_fails(self) -> None: + steps = [step("/"), step("/programs?degree=JD", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_juris-doctor-jd") + + def test_mba_values_fail(self) -> None: + answer = "The JD takes 2 years, has a January 5 deadline, and is offered by the Haas School of Business." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_duration") + + def test_optometry_deadline_is_not_enough(self) -> None: + answer = "The JD takes 3 years, has a February 1 deadline, and is offered by the School of Optometry." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_school") + + def test_negated_duration_fails(self) -> None: + answer = ( + "The JD does not take 3 years; it has a February 1 deadline and is offered by the " + "School of Law." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_duration") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Berkeley Law's Juris Doctor is a 3-year degree; applications are due by Feb 1 and " + "the school is the School of Law." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 42) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_22.py b/sites/berkeley/verify/tests/test_verify_22.py new file mode 100644 index 00000000..5407e3d6 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_22.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/departments", "done")] +ANSWER = ( + "The College of Letters and Science lists 8 departments: Economics, English, History, " + "Mathematics, Physics and Political Science." +) + + +class VerifyTask22Tests(SharedVerifierTests, VerifierTestCase): + N = 22 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_college_filter_fails(self) -> None: + steps = [step("/"), step("/programs?college=letters-and-science", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_departments_page") + + def test_sitewide_department_total_fails(self) -> None: + answer = "The College of Letters and Science has 30 departments." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_department_count") + + def test_count_without_names_fails(self) -> None: + answer = "The College of Letters and Science lists 8 departments." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_names_ls_departments") + + def test_count_of_wrong_college_fails(self) -> None: + answer = "The College of Engineering has 7 departments: Economics and English." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_department_count") + + def test_negated_count_fails(self) -> None: + answer = "The College of Letters and Science does not have 8 departments." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_department_count") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "There are eight departments under the College of Letters and Science (Economics, " + "English, History and Mathematics)." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 8) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_23.py b/sites/berkeley/verify/tests/test_verify_23.py new file mode 100644 index 00000000..34a207d1 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_23.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/research"), step("/research/bids", "done")] +ANSWER = ( + # The related centre is the first of BIDS's ORDER BY name LIMIT 3 list + # (app.py research_center; ground_truth.related_centres mirrors it). + "BIDS is directed by Prof. David Culler; its focus areas are Data Science, Statistics, " + "Computational Methods and Open Science. A related center listed on the page is the " + "Berkeley Center for New Media." +) + + +class VerifyTask23Tests(SharedVerifierTests, VerifierTestCase): + N = 23 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_from_listing_fails(self) -> None: + steps = [step("/"), step("/research", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_research_detail_bids") + + def test_wrong_director_fails(self) -> None: + answer = ( + "BIDS is directed by Prof. Douglas Dreger; its focus areas are Data Science, " + "Statistics, and Computational Methods; a related center is the Berkeley " + "Seismological Laboratory." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_director") + + def test_invented_focus_areas_fail(self) -> None: + answer = ( + "BIDS is directed by Prof. David Culler and focuses on Machine Learning, Robotics, " + "and Climate Policy." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_focus_areas") + + def test_unrendered_related_centre_fails(self) -> None: + answer = ( + "BIDS is directed by Prof. David Culler; focus areas are Data Science, Statistics " + "and Computational Methods; a related center on the page is the California Policy Lab." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_names_rendered_related_centre") + + def test_negated_director_fails(self) -> None: + answer = ( + "Prof. David Culler is not the director of BIDS; its focus areas are Data Science, " + "Statistics and Computational Methods." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_director") + + def test_negated_director_with_title_fails(self) -> None: + """C2 regression: "is not directed by Prof. X" — the title's period must + not hide the negation from the director matcher.""" + answer = ( + "Berkeley Institute for Data Science is not directed by Prof. David Culler; its " + "focus areas are Data Science, Statistics, Computational Methods, Open Science." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_director") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "The Berkeley Institute for Data Science, led by David Culler, works across Data " + "Science, Statistics, Computational Methods and Open Science; the Berkeley " + "Population Center is listed among its related centers." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "research", 2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_24.py b/sites/berkeley/verify/tests/test_verify_24.py new file mode 100644 index 00000000..f6817428 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_24.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs/economics-phd"), + step("/departments/economics"), + step("/faculty/emmanuel-saez", "done"), +] +ANSWER = ( + "The Economics department is chaired by Prof. Ulrike Malmendier and offers the Economics BA " + "and the Economics PhD. One faculty member, Emmanuel Saez, works on public economics, " + "inequality, taxation and labor economics." +) + + +class VerifyTask24Tests(SharedVerifierTests, VerifierTestCase): + N = 24 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_missing_faculty_hop_fails_workflow(self) -> None: + steps = [step("/"), step("/programs/economics-phd"), step("/departments/economics", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "workflow_in_order") + + def test_missing_programme_hop_fails_workflow(self) -> None: + steps = [step("/"), step("/departments/economics"), step("/faculty/emmanuel-saez", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "workflow_in_order") + + def test_wrong_chair_fails(self) -> None: + answer = ( + "The Economics department is chaired by Prof. David Card and offers the Economics BA " + "and the Economics PhD. Emmanuel Saez works on public economics and inequality." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_single_programme_fails(self) -> None: + answer = ( + "The Economics department is chaired by Prof. Ulrike Malmendier and offers only the " + "Economics PhD. Emmanuel Saez works on public economics and inequality." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_department_programmes") + + def test_interests_of_a_non_economics_professor_fail(self) -> None: + steps = [step("/"), step("/programs/economics-phd"), step("/departments/economics"), + step("/faculty/alexei-efros", "done")] + answer = ( + "The Economics department is chaired by Prof. Ulrike Malmendier and offers the BA and " + "PhD in Economics. Alexei Efros works on computer vision and image synthesis." + ) + self.assertFailsOn(self.verdict(steps, answer), "answer_has_economics_faculty_interests") + + def test_negated_chair_fails(self) -> None: + answer = ( + "Ulrike Malmendier is not the department chair; the department offers the Economics " + "BA and PhD, and Emmanuel Saez works on public economics and inequality." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_negated_titled_chair_fails(self) -> None: + """C2 regression: "is not chaired by Prof. X" — the title's period must + not hide the negation from the chair matcher.""" + answer = ( + "The Economics department is not chaired by Prof. Ulrike Malmendier and offers the " + "BA and PhD in Economics; Emmanuel Saez works on Public economics, inequality, " + "taxation, labor economics." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_chair") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Prof. Ulrike Malmendier chairs the Department of Economics, which offers a BA and a " + "PhD in Economics. Prof. David Card studies labor economics, immigration and the " + "minimum wage." + ) + steps = [step("/"), step("/programs/economics-phd"), step("/departments/economics"), + step("/faculty/david-card", "done")] + self.assertPasses(self.verdict(steps, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "faculty", 17) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_25.py b/sites/berkeley/verify/tests/test_verify_25.py new file mode 100644 index 00000000..f6ba485a --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_25.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [step("/"), step("/events?category=Career"), step("/events/2", "done")] +ANSWER = ( + "The Spring Career Fair 2026 is on May 17, 2026 at the Recreational Sports Facility, and " + "registration is required. Two other career events: the Health Sciences Information Fair on " + "May 15, 2026 at 50 Warren Hall, and the Graduate School Information Fair on May 26, 2026 at " + "Pauley Ballroom, MLK Student Union." +) + + +class VerifyTask25Tests(SharedVerifierTests, VerifierTestCase): + N = 25 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_detail_fails(self) -> None: + steps = [step("/"), step("/events?category=Career", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_event_detail_2") + + def test_unfiltered_events_listing_fails(self) -> None: + steps = [step("/"), step("/events"), step("/events/2", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_career_events_listing") + + def test_wrong_venue_fails(self) -> None: + answer = ANSWER.replace("Recreational Sports Facility", "Pauley Ballroom") + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_anchor_location") + + def test_registration_not_required_fails(self) -> None: + answer = ANSWER.replace("registration is required", "registration is not required") + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_registration_required") + + def test_only_one_other_event_fails(self) -> None: + answer = ( + "The Spring Career Fair 2026 is on May 17, 2026 at the Recreational Sports Facility, " + "registration required. Also the Health Sciences Information Fair on May 15, 2026 at " + "50 Warren Hall." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_lists_two_other_career_events") + + def test_negated_anchor_fails(self) -> None: + answer = "The Spring Career Fair is not on May 17, 2026." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_anchor_date") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Spring Career Fair 2026 — 2026-05-17, Recreational Sports Facility, registration " + "required. Other career events: Graduate School Information Fair (2026-05-26, Pauley " + "Ballroom, MLK Student Union) and Health Sciences Information Fair (2026-05-15, 50 " + "Warren Hall)." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "event", 2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_27.py b/sites/berkeley/verify/tests/test_verify_27.py new file mode 100644 index 00000000..03ba6de4 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_27.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?q=Master%20of%20Engineering"), + step("/programs/master-of-engineering-meng"), + step("/programs/computer-science-ms", "done"), +] +ANSWER = ( + "The Master of Engineering is offered by the Department of Electrical Engineering and " + "Computer Sciences (EECS). The MEng takes 1 year, while the Computer Science MS takes 1.5 " + "years." +) + + +class VerifyTask27Tests(SharedVerifierTests, VerifierTestCase): + N = 27 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_degree_filter_also_satisfies_search_gate(self) -> None: + steps = [step("/"), step("/programs?degree=MEng"), + step("/programs/master-of-engineering-meng"), + step("/programs/computer-science-ms", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_site_search_route_also_satisfies_search_gate(self) -> None: + """§0.4 loosening: the ques says "Search the Berkeley site for …", so the site search counts.""" + steps = [step("/"), step("/search?q=Master+of+Engineering"), + step("/programs/master-of-engineering-meng"), + step("/programs/computer-science-ms", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_catalog_wide_search_still_fails_search_gate(self) -> None: + """The loosening must not admit a search that does not name the MEng term.""" + steps = [step("/"), step("/search?q=california"), + step("/programs/master-of-engineering-meng"), + step("/programs/computer-science-ms", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_search") + + def test_site_search_alone_without_detail_pages_still_fails(self) -> None: + """The detail gates, not the search gate, stay the binding anti-shortcut anchors.""" + steps = [step("/"), step("/search?q=Master+of+Engineering", "done")] + self.assertFailsOn( + self.verdict(steps, ANSWER), "visited_program_detail_master-of-engineering-meng" + ) + + def test_missing_ms_hop_fails(self) -> None: + steps = [ + step("/"), step("/programs?q=Master%20of%20Engineering"), + step("/programs/master-of-engineering-meng", "done"), + ] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_computer-science-ms") + + def test_missing_meng_hop_fails(self) -> None: + steps = [ + step("/"), step("/programs?q=Master%20of%20Engineering"), + step("/programs/computer-science-ms", "done"), + ] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_detail_master-of-engineering-meng") + + def test_no_listing_at_all_fails_search_gate(self) -> None: + steps = [step("/"), step("/programs/computer-science-ms", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_program_search") + + def test_swapped_or_wrong_durations_fail(self) -> None: + answer = ( + "The Master of Engineering is offered by EECS and takes 2 years; the Computer Science " + "MS takes 2 years." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_meng_duration") + + def test_wrong_ms_duration_fails(self) -> None: + answer = ( + "The Master of Engineering is offered by EECS and takes 1 year, while the Computer " + "Science MS takes 2 years." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_ms_duration") + + def test_negated_duration_fails(self) -> None: + answer = ( + "The Master of Engineering, offered by EECS, is not a 1-year program; the Computer " + "Science MS takes 1.5 years." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_meng_duration") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "The Master of Engineering in EECS lasts one year, while the Computer Science MS " + "lasts 1.5 years." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 43) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_28.py b/sites/berkeley/verify/tests/test_verify_28.py new file mode 100644 index 00000000..c49d0a8e --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_28.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/programs?degree=PhD"), + step("/programs?degree=MS", "done"), +] +ANSWER = ( + "17 programs in the catalogue require the GRE; the degree type that most commonly requires " + "it is the PhD." +) + + +class VerifyTask28Tests(SharedVerifierTests, VerifierTestCase): + N = 28 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_four_unfiltered_pages_also_satisfy_gate(self) -> None: + steps = [step("/"), step("/programs?page=1"), step("/programs?page=2"), + step("/programs?page=3"), step("/programs?page=4", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_single_degree_listing_fails_gate(self) -> None: + steps = [step("/"), step("/programs?degree=PhD", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_gre_programme_listings") + + def test_all_phd_count_fails(self) -> None: + answer = "There are 25 programs that require the GRE, all of them PhD programs." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_gre_count") + + def test_wrong_modal_degree_fails(self) -> None: + answer = "17 programs require the GRE, mostly master's degrees such as the MS." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_modal_degree_type") + + def test_negated_count_fails(self) -> None: + answer = "The catalogue does not have 17 GRE-required programs." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_gre_count") + + def test_alternative_phrasing_passes(self) -> None: + answer = "There are seventeen GRE-required programs, mostly PhD programs." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "program", 31) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_30.py b/sites/berkeley/verify/tests/test_verify_30.py new file mode 100644 index 00000000..c9e3124b --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_30.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, step, +) + +CENTRE_ID = 8 # Berkeley Seismological Laboratory (slug seismo-lab) +GENUINE_STEPS = [ + *login_steps("alice@berkeley.edu"), + step("/research/seismo-lab"), + step("/research/seismo-lab", "click"), + step("/account"), + step("/account", "done"), +] +ANSWER = ( + "I signed in as alice, saved the Berkeley Seismological Laboratory to my bookmarks, and it " + "is listed under My Account. Its director is Prof. Douglas Dreger." +) + + +class VerifyTask30Tests(SharedVerifierTests, VerifierTestCase): + N = 30 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + state = State() + state.add_bookmark(1, "research", CENTRE_ID) + return state + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER, after=self.genuine_after())) + + def test_state_mismatch_without_save_fails_on_delta(self) -> None: + verdict = self.verdict(GENUINE_STEPS, ANSWER) # agent self-reports success, DB untouched + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_saving_the_wrong_centre_fails_on_delta(self) -> None: + after = State() + after.add_bookmark(1, "research", 1) # BAIR, not the named centre + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_duplicate_save_fails_on_delta(self) -> None: + after = State() + after.add_bookmark(1, "research", CENTRE_ID, row_id=1) + after.add_bookmark(1, "research", CENTRE_ID, row_id=2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_saving_under_another_account_fails_on_delta(self) -> None: + after = State() + after.add_bookmark(2, "research", CENTRE_ID) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_missing_login_fails(self) -> None: + steps = [step("/research/seismo-lab"), step("/research/seismo-lab", "click"), + step("/account"), step("/account", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=self.genuine_after()), "visited_login_page") + + def test_signing_in_as_the_wrong_account_fails(self) -> None: + steps = [ + *login_steps("bob@berkeley.edu"), + step("/research/seismo-lab"), step("/research/seismo-lab", "click"), + step("/account"), step("/account", "done"), + ] + self.assertFailsOn( + self.verdict(steps, ANSWER, after=self.genuine_after()), "entered_expected_account_email" + ) + + def test_missing_centre_page_fails_workflow(self) -> None: + steps = [*login_steps("alice@berkeley.edu"), step("/account"), step("/account", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER, after=self.genuine_after()), "workflow_in_order") + + def test_negated_save_fails(self) -> None: + answer = "I did not save the Berkeley Seismological Laboratory." + verdict = self.verdict(GENUINE_STEPS, answer, after=self.genuine_after()) + self.assertFailsOn(verdict, "answer_has_centre") + + def test_wrong_director_fails(self) -> None: + answer = ANSWER.replace("Douglas Dreger", "David Culler") + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer, after=self.genuine_after()), "answer_has_director") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Logged in as alice@berkeley.edu; the Berkeley Seismological Laboratory now appears " + "in My Bookmarks, directed by Douglas Dreger." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer, after=self.genuine_after())) + + def test_registration_write_fails(self) -> None: + after = self.genuine_after() + after.extra_sql.append( + "INSERT INTO users(id, email, username, password_hash, full_name, role, bio, created_at) " + "VALUES (99, 'eve@berkeley.edu', 'eve', 'x', 'Eve', 'student', '', '2026-05-12 00:00:00')" + ) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_users_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_31.py b/sites/berkeley/verify/tests/test_verify_31.py new file mode 100644 index 00000000..bb3a59b7 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_31.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, login_steps, step, +) + +MSRI_ID = 9 +CPL_ID = 22 +GENUINE_STEPS = [ + *login_steps("bob@berkeley.edu"), + step("/research/msri"), + step("/research/msri", "click"), + step("/research/cpl"), + step("/research/cpl", "click"), + step("/account"), + step("/account", "click"), + step("/account", "done"), +] +ANSWER = ( + "I signed in as bob, saved both centers in order, then removed the Mathematical Sciences " + "Research Institute bookmark. The California Policy Lab remains saved; its director is " + "Prof. Jesse Rothstein." +) + + +def _saved_then_removed() -> State: + """The genuine history: MSRI takes row id 1, CPL row id 2, then MSRI is deleted.""" + state = State() + state.add_bookmark(2, "research", MSRI_ID) # id 1 + state.add_bookmark(2, "research", CPL_ID) # id 2 + state.remove_bookmark(1) + return state + + +class VerifyTask31Tests(SharedVerifierTests, VerifierTestCase): + N = 31 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def genuine_after(self) -> State: + return _saved_then_removed() + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER, after=self.genuine_after())) + + def test_skipped_removal_fails_on_delta(self) -> None: + after = State() + after.add_bookmark(2, "research", MSRI_ID) # id 1 + after.add_bookmark(2, "research", CPL_ID) # id 2 + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_nothing_added_fails_on_delta(self) -> None: + verdict = self.verdict(GENUINE_STEPS, ANSWER) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_wrong_add_order_fails_on_row_id_pin(self) -> None: + """The trajectory matches, but the DB proves CPL was saved before MSRI (survivor id 1).""" + after = State() + after.add_bookmark(2, "research", CPL_ID) # id 1 + after.add_bookmark(2, "research", MSRI_ID) # id 2 + after.remove_bookmark(2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_surviving_row_ids") + + def test_removing_the_wrong_centre_fails_on_delta(self) -> None: + after = State() + after.add_bookmark(2, "research", MSRI_ID) # id 1 + after.add_bookmark(2, "research", CPL_ID) # id 2 + after.remove_bookmark(2) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + def test_missing_login_fails(self) -> None: + steps = GENUINE_STEPS[len(login_steps("bob@berkeley.edu")):] + self.assertFailsOn( + self.verdict(steps, ANSWER, after=self.genuine_after()), "visited_login_page" + ) + + def test_single_account_visit_fails_workflow(self) -> None: + steps = [ + *login_steps("bob@berkeley.edu"), + step("/research/msri"), step("/research/msri", "click"), + step("/research/cpl"), step("/research/cpl", "click"), + step("/account", "done"), + ] + self.assertFailsOn( + self.verdict(steps, ANSWER, after=self.genuine_after()), "workflow_in_order" + ) + + def test_wrong_remaining_director_fails(self) -> None: + answer = ANSWER.replace("Jesse Rothstein", "Tatiana Toro") + self.assertFailsOn( + self.verdict(GENUINE_STEPS, answer, after=self.genuine_after()), "answer_has_remaining_director" + ) + + def test_negated_removal_fails(self) -> None: + answer = ( + "The California Policy Lab (director: Prof. Jesse Rothstein) remains saved, but the " + "Mathematical Sciences Research Institute bookmark was not removed." + ) + self.assertFailsOn( + self.verdict(GENUINE_STEPS, answer, after=self.genuine_after()), "answer_confirms_removal" + ) + + def test_collateral_write_for_another_user_fails(self) -> None: + after = self.genuine_after() + after.add_bookmark(1, "research", 8) # alice, not bob + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_other_users_unchanged") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Bob saved both centers, then removed the MSRI bookmark; the California Policy Lab " + "is still saved (director: Prof. Jesse Rothstein)." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer, after=self.genuine_after())) + + def test_unrelated_bookmark_write_fails_on_delta(self) -> None: + after = self.genuine_after() + after.add_bookmark(2, "program", 41) # collateral write for the same user + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "bookmarks_exact_delta") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_4.py b/sites/berkeley/verify/tests/test_verify_4.py new file mode 100644 index 00000000..bd0fc50f --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_4.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +SLUG = "crispr-pioneer-jennifer-doudna-receives-national-medal-of-science" +GENUINE_STEPS = [step("/"), step("/news?q=CRISPR"), step(f"/news/{SLUG}", "done")] +ANSWER = "The article features Jennifer Doudna, who received the National Medal of Science." + + +class VerifyTask4Tests(SharedVerifierTests, VerifierTestCase): + N = 4 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_shortcut_without_article_fails_on_gate(self) -> None: + steps = [step("/"), step("/news?q=CRISPR", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), f"visited_news_detail_{SLUG}") + + def test_shortcut_from_research_category_still_needs_article(self) -> None: + steps = [step("/"), step("/news?category=Research", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), f"visited_news_detail_{SLUG}") + + def test_nobel_prize_answer_fails(self) -> None: + answer = "The featured scientist is Jennifer Doudna, who received the Nobel Prize." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_award") + + def test_negated_award_fails(self) -> None: + answer = ( + "Jennifer Doudna is the featured scientist, but the National Medal of Science was " + "not the award she received; she won the Nobel Prize." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_award") + + def test_negated_everything_fails_on_first_check(self) -> None: + answer = "Jennifer Doudna did not receive the National Medal of Science." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_has_scientist") + + def test_wrong_article_covid_fails(self) -> None: + covid = "berkeley-researchers-develop-faster-covid-test-using-crispr" + steps = [step("/"), step("/news?q=CRISPR"), step(f"/news/{covid}", "done")] + answer = "The article is about a faster COVID test using CRISPR; 98% sensitivity." + self.assertFailsOn(self.verdict(steps, answer), f"visited_news_detail_{SLUG}") + + def test_alternative_phrasing_passes(self) -> None: + answer = "That story is about Prof. Jennifer Doudna; the award she received was the National Medal of Science." + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "news", 4) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_6.py b/sites/berkeley/verify/tests/test_verify_6.py new file mode 100644 index 00000000..3169f30c --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_6.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/events?category=Lecture"), + step("/events/1"), + step("/events/31"), + step("/events/51", "done"), +] +ANSWER = ( + "Lecture events: 'Nobel Laureate Lecture: Jennifer Doudna on the Future of Gene Editing' " + "on May 15, 2026 at 2050 Valley Life Sciences Building; 'Public Lecture: The Future of " + "Democracy in the Digital Age' on May 21, 2026 at 145 Dwinelle Hall; and 'Berkeley Science " + "Lecture: Origins of Life' on May 19, 2026 at 1 Pimentel Hall." +) + + +class VerifyTask6Tests(SharedVerifierTests, VerifierTestCase): + N = 6 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_date_all_listing_also_satisfies_gate(self) -> None: + steps = [step("/"), step("/events?category=Lecture&date=all"), + step("/events/1"), step("/events/31"), step("/events/51", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_catalog_wide_search_does_not_satisfy_listing_gate(self) -> None: + steps = [step("/"), step("/events?q=lecture", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_lecture_listing") + + def test_other_category_events_fail(self) -> None: + answer = ( + "Events: 'Spring Career Fair 2026' on May 17, 2026 at Recreational Sports Facility; " + "'Hackathon: Code for Climate 2026' on May 30, 2026 at Soda Hall; and 'Berkeley " + "Startup Pitch Competition Finals' on June 4, 2026 at 310 Sutardja Dai Hall." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_lists_three_lecture_events") + + def test_two_events_only_fails(self) -> None: + answer = ( + "Lecture events: 'Nobel Laureate Lecture: Jennifer Doudna on the Future of Gene " + "Editing' on May 15, 2026 at 2050 Valley Life Sciences Building, and 'Public Lecture: " + "The Future of Democracy in the Digital Age' on May 21, 2026 at 145 Dwinelle Hall." + ) + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_lists_three_lecture_events") + + def test_wrong_date_fails(self) -> None: + answer = ANSWER.replace("May 15, 2026", "May 16, 2026").replace("May 21, 2026", "May 22, 2026").replace("May 19, 2026", "May 20, 2026") + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_lists_three_lecture_events") + + def test_negated_listing_fails(self) -> None: + answer = "There are no Lecture events I could list." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_lists_three_lecture_events") + + def test_alternative_phrasing_passes(self) -> None: + answer = ( + "Lecture events: 'Nobel Laureate Lecture: Jennifer Doudna on the Future of Gene " + "Editing' 2026-05-15, 2050 Valley Life Sciences Building; 'Public Lecture: The Future " + "of Democracy in the Digital Age' 2026-05-21, 145 Dwinelle Hall; 'Berkeley Science " + "Lecture: Origins of Life' 2026-05-19, 1 Pimentel Hall." + ) + self.assertPasses(self.verdict(GENUINE_STEPS, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "event", 1) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_7.py b/sites/berkeley/verify/tests/test_verify_7.py new file mode 100644 index 00000000..a32864d6 --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_7.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402,F401 + SharedVerifierTests, State, VerifierTestCase, step, +) + +GENUINE_STEPS = [ + step("/"), + step("/faculty?dept=eecs"), + step("/faculty/stuart-russell", "done"), +] +ANSWER = ( + "Stuart Russell is an EECS professor whose research covers Artificial intelligence and " + "machine learning, including AI safety." +) + + +class VerifyTask7Tests(SharedVerifierTests, VerifierTestCase): + N = 7 + GENUINE_STEPS = GENUINE_STEPS + ANSWER = ANSWER + + def test_genuine_run_passes(self) -> None: + self.assertPasses(self.verdict(GENUINE_STEPS, ANSWER)) + + def test_department_page_route_satisfies_gate(self) -> None: + steps = [step("/"), step("/departments/eecs"), step("/faculty/stuart-russell", "done")] + self.assertPasses(self.verdict(steps, ANSWER)) + + def test_shortcut_without_profile_fails(self) -> None: + steps = [step("/"), step("/faculty?dept=eecs", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_named_professor_profile") + + def test_other_department_professor_fails(self) -> None: + steps = [step("/"), step("/faculty?dept=eecs"), step("/faculty/eliza-strickland", "done")] + answer = "Eliza Strickland works on AI reporting and biomedical ethics." + self.assertFailsOn(self.verdict(steps, answer), "named_eecs_ai_professor") + + def test_unfiltered_listing_alone_fails_route_gate(self) -> None: + steps = [step("/"), step("/faculty"), step("/faculty/stuart-russell", "done")] + self.assertFailsOn(self.verdict(steps, ANSWER), "visited_eecs_faculty_route") + + def test_interests_of_another_professor_fail(self) -> None: + answer = "Stuart Russell works on robotics and reinforcement learning." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "answer_interests_bind_to_profile") + + def test_catalog_wide_search_only_fails(self) -> None: + steps = [step("/"), step("/search?q=artificial%20intelligence", "done")] + verdict = self.verdict(steps, ANSWER) + self.assertFalse(verdict.get("pass")) + self.assertEqual(verdict.get("reason"), "visited_eecs_faculty_route") + + def test_negated_department_fails(self) -> None: + answer = "Stuart Russell is not in the EECS department; his interests are not machine learning." + self.assertFailsOn(self.verdict(GENUINE_STEPS, answer), "named_eecs_ai_professor") + + def test_alternative_phrasing_passes(self) -> None: + steps = [step("/"), step("/faculty?dept=eecs"), step("/faculty/dawn-song", "done")] + answer = "Prof. Dawn Song (EECS) works on AI security, blockchain, deep learning and privacy." + self.assertPasses(self.verdict(steps, answer)) + + def test_read_only_write_fails(self) -> None: + after = State() + after.add_bookmark(1, "faculty", 5) + verdict = self.verdict(GENUINE_STEPS, ANSWER, after=after) + self.assertFailsOn(verdict, "read_only_bookmarks_unchanged") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/tests/test_verify_lib.py b/sites/berkeley/verify/tests/test_verify_lib.py new file mode 100644 index 00000000..deaebd3a --- /dev/null +++ b/sites/berkeley/verify/tests/test_verify_lib.py @@ -0,0 +1,363 @@ +"""Unit tests for the shared helpers, the snapshot contract and the source-fact rules. + +No docker, no LLM: matcher behaviour, gate semantics, the fingerprint recipe and +the three assertions that keep the About/Admissions values source-sourced. +""" +from __future__ import annotations + +import datetime as _dt +import json +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _support import ( # noqa: E402 + BASE, SITE_DIR, SEED_DB, State, seed_fingerprint, step, write_run, +) + +VERIFY_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(VERIFY_DIR)) + +import ground_truth # noqa: E402 +import verify_lib as lib # noqa: E402 + + +class SnapshotContractTests(unittest.TestCase): + def test_seed_fingerprint_matches_the_pinned_contract(self) -> None: + self.assertEqual(lib.catalog_fingerprint(str(SEED_DB)), lib.CATALOG_FINGERPRINT) + self.assertEqual(seed_fingerprint(), lib.CATALOG_FINGERPRINT) + + def test_seed_counts_and_schema_hash_match(self) -> None: + observed = {table: len(lib.table_rows(str(SEED_DB), table)) for table in lib.EXPECTED_COUNTS} + self.assertEqual(observed, lib.EXPECTED_COUNTS) + schema_hash = lib.hashlib.sha256( + json.dumps(lib._schema_objects(str(SEED_DB)), separators=(",", ":")).encode() + ).hexdigest() + self.assertEqual(schema_hash, lib.SCHEMA_HASH) + + def test_stdlib_fixture_pipeline_reproduces_the_fingerprint(self) -> None: + """A copy of the seed rewritten through the stdlib fixture keeps the catalog intact.""" + with tempfile.TemporaryDirectory() as tmp: + path = State().write(Path(tmp) / "fixture.db") + self.assertEqual(lib.catalog_fingerprint(str(path)), lib.CATALOG_FINGERPRINT) + + def test_state_rewrite_changes_only_the_bookmarks_table(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + after = Path(tmp) / "after.db" + state = State() + state.add_bookmark(1, "research", 8) + state.write(after) + changed = [table for table in lib.ALL_TABLES + if lib.table_rows(str(SEED_DB), table) != lib.table_rows(str(after), table)] + self.assertEqual(changed, ["bookmarks"]) + + def test_contract_rejects_catalog_drift(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + initial = State().write_with_catalog_change(Path(tmp) / "initial.db") + after = State().write(Path(tmp) / "after.db") + with self.assertRaises(ValueError): + lib._validate_snapshot_contract(str(initial), str(after)) + + def test_contract_rejects_a_catalog_mutation_in_after(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + initial = State().write(Path(tmp) / "initial.db") + after = State().write_with_catalog_change(Path(tmp) / "after.db") + with self.assertRaises(ValueError): + lib._validate_snapshot_contract(str(initial), str(after)) + + def test_contract_accepts_a_bookmark_only_delta(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + initial = State().write(Path(tmp) / "initial.db") + state = State() + state.add_bookmark(1, "research", 8) + after = state.write(Path(tmp) / "after.db") + lib._validate_snapshot_contract(str(initial), str(after)) # must not raise + + +class GroundTruthTests(unittest.TestCase): + def test_every_kept_task_derives(self) -> None: + facts = ground_truth.all_ground_truth(str(SEED_DB)) + self.assertEqual( + sorted(facts), + [1, 2, 4, 6, 7, 10, 11, 12, 13, 14, 16, 17, 19, 20, 22, 23, 24, 25, 27, 28, 30, 31], + ) + + def test_unsupported_task_fails(self) -> None: + with self.assertRaises(ValueError): + ground_truth.task_ground_truth(str(SEED_DB), 3) + + def test_dropped_and_unrelated_ids_fail_closed(self) -> None: + for dropped in (0, 3, 5, 8, 9, 15, 18, 21, 26, 29, 32): + with self.assertRaises(ValueError): + ground_truth.task_ground_truth(str(SEED_DB), dropped) + + def test_event_targets_respect_the_frozen_clock(self) -> None: + lecture = ground_truth.task_ground_truth(str(SEED_DB), 6) + worker = sqlite3.connect(str(SEED_DB)) + try: + total = worker.execute("SELECT COUNT(*) FROM events WHERE category='Lecture'").fetchone()[0] + finally: + worker.close() + self.assertLess(len(lecture["upcoming"]), total) + self.assertTrue(all(row["start_datetime"] >= ground_truth.BENCHMARK_NOW for row in lecture["upcoming"])) + + def test_task_27_reanchored_targets(self) -> None: + facts = ground_truth.task_ground_truth(str(SEED_DB), 27) + self.assertEqual(facts["durations"], (1.0, 1.5)) + self.assertEqual(facts["meng"]["department_name"], facts["ms"]["department_name"]) + + def test_stateful_targets_bind_to_empty_bookmark_table(self) -> None: + facts = ground_truth.task_ground_truth(str(SEED_DB), 31) + self.assertLess(facts["first"]["id"], facts["second"]["id"]) + self.assertEqual(len(lib.table_rows(str(SEED_DB), "bookmarks")), 0) + + +class SourceFactsTests(unittest.TestCase): + """Decision 6: the About/Admissions values are tracked source, not DB rows.""" + + def test_admissions_facts_come_from_the_template(self) -> None: + facts = ground_truth.admissions_facts() + template = (SITE_DIR / "templates" / "admissions.html").read_text(encoding="utf-8") + self.assertIn(facts["deadline"], template) + self.assertIn(facts["acceptance_rate"], template) + self.assertRegex(facts["deadline"], r"^[A-Z][a-z]+ \d{1,2}$") + self.assertRegex(facts["acceptance_rate"], r"^\d+(?:\.\d+)?%$") + + def test_about_stats_are_source_literals_not_db(self) -> None: + facts = ground_truth.about_facts() + self.assertEqual(set(facts), {"nobel_laureates", "varsity_sports", "national_titles"}) + source = (SITE_DIR / "app.py").read_text(encoding="utf-8") + template = (SITE_DIR / "templates" / "about.html").read_text(encoding="utf-8") + worker = sqlite3.connect(str(SEED_DB)) + try: + columns = { + column[1] + for table in lib.EXPECTED_TABLES + for column in worker.execute(f"PRAGMA table_info({table})") + } + finally: + worker.close() + for key, value in facts.items(): + self.assertRegex(source, rf"'{key}':\s*{value}\b") + self.assertIn(f"stats.{key}", template) + self.assertNotIn(key, columns) + self.assertGreater(ground_truth.about_distractor_prizes(), max(facts.values())) + + def test_verifier_package_never_imports_an_llm(self) -> None: + for path in sorted(VERIFY_DIR.glob("verify_*.py")): + text = path.read_text(encoding="utf-8") + for banned in ("openai", "llm_text_match", "llm_screenshot_shows"): + self.assertNotIn(banned, text, f"{path.name} references {banned}") + + +class MatcherTests(unittest.TestCase): + def test_contains_count_forms_and_guards(self) -> None: + self.assertTrue(lib.contains_count("There are 17 programs.", 17)) + self.assertTrue(lib.contains_count("seventeen programs", 17)) + self.assertTrue(lib.contains_count("4,500 undergraduates", 4500)) + self.assertTrue(lib.contains_count("4500 undergraduates", 4500)) + self.assertTrue(lib.contains_count("count: 8", 8)) + self.assertTrue(lib.contains_count("eight departments", 8)) + self.assertFalse(lib.contains_count("25 programs", 17)) + self.assertFalse(lib.contains_count("12,000 students", 12)) + self.assertFalse(lib.contains_count("14.4%", 14)) + self.assertFalse(lib.contains_count("built in 2013", 1)) + self.assertFalse(lib.contains_count("18 departments", 8)) + + def test_contains_percent(self) -> None: + self.assertTrue(lib.contains_percent("14.4%", "14.4%")) + self.assertTrue(lib.contains_percent("14.4 percent", "14.4%")) + self.assertTrue(lib.contains_percent("rate: 14.4 per cent", "14.4%")) + self.assertFalse(lib.contains_percent("11%", "14.4%")) + self.assertFalse(lib.contains_percent("14.4", "14.4%")) + + def test_contains_date_variants(self) -> None: + value = _dt.date(2026, 5, 15) + for text in ("May 15, 2026", "May 15 2026", "15 May 2026", "2026-05-15", "05/15/2026", "May 15"): + self.assertTrue(lib.contains_date(text, value), text) + self.assertFalse(lib.contains_date("May 16, 2026", value)) + self.assertFalse(lib.contains_date("May 2015", value)) + + def test_contains_month_day(self) -> None: + self.assertTrue(lib.contains_month_day("due by November 30", "November 30")) + self.assertTrue(lib.contains_month_day("due by Nov. 30, 2026", "November 30")) + self.assertFalse(lib.contains_month_day("due by December 1", "November 30")) + self.assertFalse(lib.contains_month_day("due by November 3", "November 30")) + + def test_contains_person_ignores_titles(self) -> None: + self.assertTrue(lib.contains_person("Prof. James Demmel is the chair.", "Prof. James Demmel")) + self.assertTrue(lib.contains_person("the dean is Tsu-Jae King Liu", "Dean Tsu-Jae King Liu")) + self.assertFalse(lib.contains_person("Demmel", "Prof. James Demmel")) + self.assertFalse(lib.contains_person("James", "Prof. James Demmel")) + + def test_contains_location_room_number_optional(self) -> None: + self.assertTrue(lib.contains_location("located at 253 Cory Hall", "253 Cory Hall")) + self.assertTrue(lib.contains_location("located at Cory Hall", "253 Cory Hall")) + self.assertFalse(lib.contains_location("located at Soda Hall", "253 Cory Hall")) + + def test_contains_degree_type_variants(self) -> None: + self.assertTrue(lib.contains_degree_type("offers a Ph.D.", "PhD")) + self.assertTrue(lib.contains_degree_type("the MEng programme", "MEng")) + self.assertTrue(lib.contains_degree_type("a BS in CS", "BS")) + self.assertFalse(lib.contains_degree_type("the master's programme", "MS")) + self.assertFalse(lib.contains_degree_type("business administration", "BA")) + + def test_contains_duration_years_distinguishes_one_and_one_and_a_half(self) -> None: + self.assertTrue(lib.contains_duration_years("takes 1 year", 1.0)) + self.assertTrue(lib.contains_duration_years("lasts one year", 1.0)) + self.assertTrue(lib.contains_duration_years("runs 1.5 years", 1.5)) + self.assertTrue(lib.contains_duration_years("about 18 months", 1.5)) + self.assertFalse(lib.contains_duration_years("takes 1.5 years", 1.0)) + self.assertFalse(lib.contains_duration_years("takes 2 years", 1.0)) + self.assertFalse(lib.contains_duration_years("takes 1 year", 1.5)) + + def test_contains_year_allows_sentence_final_stop(self) -> None: + self.assertTrue(lib.contains_year("established in 2013.", 2013)) + self.assertTrue(lib.contains_year("founded 2013, not 2017", 2013)) + self.assertFalse(lib.contains_year("founded in 2017.", 2013)) + + def test_negation_semantics(self) -> None: + self.assertFalse(lib.contains_phrase("did not receive the National Medal of Science", "National Medal of Science")) + self.assertFalse(lib.contains_phrase("the award was not the National Medal of Science", "National Medal of Science")) + self.assertFalse(lib.contains_count("does not have 12 departments", 12)) + self.assertFalse(lib.contains_phrase("no Lecture events", "Lecture")) + # A confirming contrast stays affirmative. + self.assertTrue(lib.contains_year("founded in 2013, not 2017", 2013)) + self.assertTrue(lib.contains_count("1 year, not 2 years", 1)) + # Negation after the value still rejects it. + self.assertFalse(lib.contains_year("2013 was not the founding year", 2013)) + # An honorific's period must not split the clause and hide a negation + # (C2 mutation rows on tasks 13/23/24). + self.assertFalse(lib.contains_person( + "The chair of EECS is not Prof. James Demmel, and the department is located at 253 Cory Hall.", + "Prof. James Demmel", + )) + self.assertTrue(lib.contains_person( + "The chair of EECS is Prof. James Demmel, located at 253 Cory Hall.", "Prof. James Demmel")) + self.assertFalse(lib.contains_phrase( + "BIDS is not directed by Prof. David Culler.", "The Berkeley Institute")) + self.assertFalse(lib.contains_phrase("holds a Ph.D. It is not the largest college.", "largest college")) + + def test_contains_count_as_pins_the_label(self) -> None: + self.assertTrue(lib.contains_count_as("107 Nobel Laureates on the faculty", 107, "laureates")) + self.assertFalse(lib.contains_count_as("more than 107 Nobel Prizes in total", 107, "laureates")) + + def test_title_tokens_and_matching(self) -> None: + title = "Women's Gymnastics Wins NCAA Championship" + self.assertGreaterEqual(lib.title_tokens_matched("women's gymnastics won the NCAA championship", title), 3) + self.assertLess(lib.title_tokens_matched("won a championship", title), 3) + + def test_department_aliases_and_acronyms(self) -> None: + name = "Department of Electrical Engineering and Computer Sciences" + self.assertTrue(lib.contains_department("offered by EECS", name)) + self.assertTrue(lib.contains_department("the Electrical Engineering and Computer Sciences department", name)) + self.assertFalse(lib.contains_department("offered by the Math department", name)) + self.assertEqual(lib.acronym("Mathematical Sciences Research Institute"), "msri") + self.assertEqual(lib.acronym("Economics"), "") + + def test_interest_token_matches_counts_distinct_tokens(self) -> None: + interests = "Artificial intelligence, machine learning, AI safety, probabilistic reasoning" + self.assertGreaterEqual(lib.interest_token_matches("works on machine learning and AI safety", interests), 2) + self.assertLess(lib.interest_token_matches("works on robotics", interests), 2) + + def test_affirmative_near(self) -> None: + self.assertTrue( + lib.affirmative_near("the Spring Career Fair: registration is required", "career fair", "required", 80) + ) + self.assertFalse( + lib.affirmative_near("the Spring Career Fair is not open; registration not required", "career fair", "required", 80) + ) + + +class UrlGateTests(unittest.TestCase): + def trajectory(self, steps): + return {"start_url": f"{BASE}/", "steps": steps} + + def test_params_visited_matches_first_value_only(self) -> None: + traj = self.trajectory([step("/programs?degree=PhD°ree=MS")]) + self.assertTrue(lib.params_visited(traj, "/programs", degree="PhD")) + self.assertFalse(lib.params_visited(traj, "/programs", degree="MS")) + + def test_params_visited_alternatives_and_regex(self) -> None: + traj = self.trajectory([step("/news?q=CRISPR")]) + self.assertTrue(lib.params_visited(traj, "/news", q="crispr")) + self.assertTrue(lib.params_visited(traj, "/news", q=lib.re.compile(r"crisp"))) + self.assertFalse(lib.params_visited(traj, "/news", category="Research")) + + def test_detail_gates_are_exact(self) -> None: + traj = self.trajectory([step("/programs/computer-science-bs-extra")]) + self.assertFalse(lib.detail_visited(traj, "program", "computer-science-bs")) + traj = self.trajectory([step("/programs/computer-science-bs")]) + self.assertTrue(lib.detail_visited(traj, "program", "computer-science-bs")) + self.assertEqual(lib.detail_path("event", 2), "/events/2") + + def test_last_action_target_counts_as_a_visit(self) -> None: + traj = self.trajectory([step("/"), {"url": f"{BASE}/", "action": "navigate", + "params": {"url": f"{BASE}/about"}}]) + self.assertTrue(lib.navigated_to_path(traj, "/about")) + + def test_paths_in_order(self) -> None: + traj = self.trajectory([step("/departments"), step("/departments/eecs")]) + lib.check_paths_in_order(lib.Judge("t"), traj, "order", [("/departments", {}), ("/departments/eecs", {})]) + bad = self.trajectory([step("/departments/eecs"), step("/departments")]) + judge = lib.Judge("t") + self.assertFalse(lib.check_paths_in_order(judge, bad, "order", [("/departments", {}), ("/departments/eecs", {})])) + + def test_listing_pages_are_distinct(self) -> None: + traj = self.trajectory([step("/programs?page=1"), step("/programs?page=1"), step("/programs?page=2")]) + self.assertEqual(len(lib.listing_pages_visited(traj, "/programs")), 2) + + def test_origin_rules(self) -> None: + self.assertTrue(lib.is_site_url("http://localhost:41026/x")) + self.assertTrue(lib.is_site_url("http://127.0.0.1:41026/x")) + self.assertFalse(lib.is_site_url("http://example.com/x")) + self.assertFalse(lib._same_local_origin("http://localhost:9999/x", f"{BASE}/")) + + def test_screenshot_size_gate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) / "run" + write_run(run_dir, "UC Berkeley--1", [step("/")], "answer") + judge = lib.Judge("t") + trajectory = lib.load_run(run_dir) + lib.check_trajectory_identity(judge, trajectory, "UC Berkeley--1") + self.assertTrue(judge.passed) + + +class BookmarkHelperTests(unittest.TestCase): + def test_delta_and_row_ids(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + initial = State().write(Path(tmp) / "initial.db") + state = State() + state.add_bookmark(2, "research", 9) # id 1 + state.add_bookmark(2, "research", 22) # id 2 + state.remove_bookmark(1) + after = state.write(Path(tmp) / "after.db") + delta = lib.bookmark_delta(str(initial), str(after), 2) + self.assertEqual([lib.bookmark_identity(row) for row in delta["added"]], [(2, "research", 22)]) + self.assertEqual(delta["removed"], []) + judge = lib.Judge("t") + lib.check_bookmarks_delta(judge, str(initial), str(after), user_id=2, + added=[(2, "research", 22)], surviving_ids=[2]) + self.assertTrue(judge.passed, judge.evidence) + + def test_surviving_id_pin_rejects_the_reversed_order(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + initial = State().write(Path(tmp) / "initial.db") + state = State() + state.add_bookmark(2, "research", 22) # id 1: the wrong order + state.add_bookmark(2, "research", 9) # id 2 + state.remove_bookmark(2) + after = state.write(Path(tmp) / "after.db") + judge = lib.Judge("t") + lib.check_bookmarks_delta(judge, str(initial), str(after), user_id=2, + added=[(2, "research", 22)], surviving_ids=[2]) + self.assertFalse(judge.passed) + self.assertEqual(judge.reason, "bookmarks_surviving_row_ids") + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/berkeley/verify/verify_1.py b/sites/berkeley/verify/verify_1.py new file mode 100644 index 00000000..c6092a00 --- /dev/null +++ b/sites/berkeley/verify/verify_1.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--1: the MBA programme's school and duration. + +Deterministic only: no LLM calls. Targets are derived from the run's initial +snapshot (ground_truth.py), never from frozen constants. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_duration_years, + contains_phrase, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--1" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 1) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited( + judge, trajectory, "visited_program_search", "/programs", + {"q": "mba"}, {"degree": "MBA"}, + ) + check_visited_detail(judge, trajectory, "program", facts["program"]["slug"]) + judge.check( + "answer_has_school", + contains_phrase(answer, facts["college"]), + f"expected_school={facts['college']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_duration", + contains_duration_years(answer, facts["duration_years"]), + f"expected_duration_years={facts['duration_years']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_10.py b/sites/berkeley/verify/verify_10.py new file mode 100644 index 00000000..d0d25312 --- /dev/null +++ b/sites/berkeley/verify/verify_10.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--10: BAIR's director and founding year. + +The seed diverges from the real-world founding year (2017), so a remembered +value fails; both facts are read off the centre page. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_person, + contains_year, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--10" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 10) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_detail(judge, trajectory, "research", facts["centre"]["slug"]) + judge.check( + "answer_has_director", + contains_person(answer, facts["director"]), + f"expected_director={facts['director']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_founded_year", + contains_year(answer, facts["founded_year"]), + f"expected_founded_year={facts['founded_year']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_11.py b/sites/berkeley/verify/verify_11.py new file mode 100644 index 00000000..c5092bd0 --- /dev/null +++ b/sites/berkeley/verify/verify_11.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--11: the freshman deadline and the acceptance rate. + +Both values are rendered from tracked source rather than the DB; the verifier +derives them from templates/admissions.html and fails closed if the labelled +literals move, and verify/tests asserts they are not DB-derived. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_path, + contains_month_day, + contains_percent, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--11" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 11) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_path(judge, trajectory, "visited_admissions_page", "/admissions") + judge.check( + "answer_has_freshman_deadline", + contains_month_day(answer, facts["deadline"]), + f"expected_deadline={facts['deadline']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_acceptance_rate", + contains_percent(answer, facts["acceptance_rate"]), + f"expected_acceptance_rate={facts['acceptance_rate']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_12.py b/sites/berkeley/verify/verify_12.py new file mode 100644 index 00000000..ae940c92 --- /dev/null +++ b/sites/berkeley/verify/verify_12.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--12: the degree types the Haas School of Business offers. + +The snapshot has exactly one Haas programme, contradicting the real-world +"MBA, PhD, …" prior. The negative check is clause-local: a degree type mentioned +in a clause with no Haas anchor (or in a negated clause) is not an offering. +""" +from __future__ import annotations + +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + contains_degree_type, + contains_phrase, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + reconfirm_clause_split, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--12" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 12) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + programme = facts["programmes"][0] + + check_params_visited( + judge, trajectory, "visited_haas_programme_listing", "/programs", + {"college": facts["college"]["slug"]}, + ) + judge.check( + "answer_has_programme", + contains_phrase(answer, programme["name"]), + f"expected_programme={programme['name']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_degree_type", + contains_degree_type(answer, *facts["offered_types"]), + f"expected_degree_types={facts['offered_types']!r}, answer={answer!r}", + ) + claimed = sorted({ + other + for clause in reconfirm_clause_split(answer) + if re.search(r"\bhaas\b|business administration", clause) + for other in facts["other_types"] + if contains_degree_type(clause, other) + }) + judge.check( + "answer_no_other_haas_degrees", + not claimed, + f"catalogue_degree_types_not_offered_by_haas={facts['other_types']!r}, " + f"claimed_near_haas={claimed!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_13.py b/sites/berkeley/verify/verify_13.py new file mode 100644 index 00000000..65de5867 --- /dev/null +++ b/sites/berkeley/verify/verify_13.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--13: the EECS department chair and its location.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_path, + contains_location, + contains_person, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--13" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 13) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_path(judge, trajectory, "visited_department_detail", f"/departments/{facts['department']['slug']}") + judge.check( + "answer_has_chair", + contains_person(answer, facts["chair"]), + f"expected_chair={facts['chair']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_location", + contains_location(answer, facts["location"]), + f"expected_location={facts['location']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_14.py b/sites/berkeley/verify/verify_14.py new file mode 100644 index 00000000..a132aeb4 --- /dev/null +++ b/sites/berkeley/verify/verify_14.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--14: College of Engineering enrolment counts and dean. + +The university-wide totals on the homepage/About page (31,800 / 12,000) are the +near-miss distractors: the card values are per-college. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_path, + contains_count, + contains_person, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--14" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 14) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_path(judge, trajectory, "visited_academics_page", "/academics") + judge.check( + "answer_has_undergrad_count", + contains_count(answer, facts["undergrad_count"]), + f"expected_undergrad_count={facts['undergrad_count']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_grad_count", + contains_count(answer, facts["grad_count"]), + f"expected_grad_count={facts['grad_count']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_dean", + contains_person(answer, facts["dean"]), + f"expected_dean={facts['dean']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_16.py b/sites/berkeley/verify/verify_16.py new file mode 100644 index 00000000..4d43d09c --- /dev/null +++ b/sites/berkeley/verify/verify_16.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--16: the single online degree programme. + +``q=online`` finds nothing (the word is not in any programme name or +description), so the only route is scanning the listings for the online badge; +the detail visit pins the programme and its school. Naming any other catalog +programme near "online" fails. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + affirmative_near, + check_read_only, + check_trajectory_identity, + check_visited_detail, + check_visited_path, + contains_degree_type, + contains_phrase, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + title_tokens, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--16" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 16) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + programme = facts["program"] + + check_visited_path(judge, trajectory, "visited_programme_listing", "/programs") + check_visited_detail(judge, trajectory, "program", programme["slug"]) + judge.check( + "answer_has_programme", + contains_phrase(answer, programme["name"]), + f"expected_programme={programme['name']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_degree_type", + contains_degree_type(answer, programme["degree_type"]), + f"expected_degree_type={programme['degree_type']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_school", + contains_phrase(answer, programme["college_name"]), + f"expected_school={programme['college_name']!r}, answer={answer!r}", + ) + other_online = [ + name for name in facts["others"] + if len(title_tokens(name)) >= 2 and affirmative_near(answer, name, "online", 80) + ] + judge.check( + "answer_no_other_online_programmes", + not other_online, + f"other_programmes_reported_online={other_online!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_17.py b/sites/berkeley/verify/verify_17.py new file mode 100644 index 00000000..8fa44c90 --- /dev/null +++ b/sites/berkeley/verify/verify_17.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--17: the three About-page statistics. + +The values (and the "more than N Nobel Prizes" distractor) are derived from +tracked source; the verifier fails closed if either literal moves. The +distractor check is clause-local — quoting the alumni line elsewhere is not a +wrong answer, claiming it as the faculty count is. +""" +from __future__ import annotations + +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_path, + contains_count, + contains_count_as, + fail_closed, + final_answer, + Judge, + load_run, + normalize_text, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--17" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 17) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_path(judge, trajectory, "visited_about_page", "/about") + for name, key in ( + ("answer_has_nobel_laureates", "nobel_laureates"), + ("answer_has_varsity_sports", "varsity_sports"), + ("answer_has_national_titles", "national_titles"), + ): + judge.check( + name, + contains_count(answer, facts[key]), + f"expected_{key}={facts[key]!r}, answer={answer!r}", + ) + faculty_clauses = [ + clause for clause in re.split(r"[.!?;\n]+", normalize_text(answer)) + if re.search(r"\blaureates?\b", clause) + ] + distractor = facts["distractor_nobel_prizes"] + misquoted = [ + clause for clause in faculty_clauses + if contains_count_as(clause, distractor, "laureates") + or contains_count_as(clause, distractor, "laureate") + ] + judge.check( + "answer_rejects_distractor_nobel_count", + not misquoted, + f"distractor={distractor!r}, laureate_clauses={faculty_clauses!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_19.py b/sites/berkeley/verify/verify_19.py new file mode 100644 index 00000000..7697af08 --- /dev/null +++ b/sites/berkeley/verify/verify_19.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--19: an Athletics championship article, summarised. + +Set-valued: the accepted championship set is derived from the Athletics rows +(titles carrying "championship"); the summary must bind to one of them — a +medals/football/academic-rating article fails. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + contains_phrase, + detail_visited, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + title_tokens_matched, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--19" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 19) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited(judge, trajectory, "visited_athletics_listing", "/news", {"category": "Athletics"}) + visited = [row for row in facts["championships"] if detail_visited(trajectory, "news", row["slug"])] + judge.check( + "visited_championship_article", + bool(visited), + f"championships={[row['slug'] for row in facts['championships']]!r}", + ) + bound = [ + row for row in visited + if title_tokens_matched(answer, row["title"]) >= 3 and contains_phrase(answer, "championship") + ] + judge.check( + "answer_binds_to_championship_article", + bool(bound), + f"visited={[row['title'] for row in visited]!r}, " + f"title_token_hits={[title_tokens_matched(answer, row['title']) for row in visited]!r}; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_2.py b/sites/berkeley/verify/verify_2.py new file mode 100644 index 00000000..656c42e3 --- /dev/null +++ b/sites/berkeley/verify/verify_2.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--2: the Computer Science BS requirements block. + +Three same-name programmes (BS/MS/PhD) make the detail slug the discriminator; +the sibling requirement items are derived from the snapshot and must not appear. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + check_visited_detail, + fail_closed, + final_answer, + Judge, + load_run, + mentions, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--2" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 2) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited( + judge, trajectory, "visited_program_search", "/programs", + {"q": "computer science"}, {"degree": "BS"}, + ) + check_visited_detail(judge, trajectory, "program", facts["program"]["slug"]) + matched = mentions(answer, facts["items"]) + judge.check( + "answer_requirements_match_bs", + len(matched) >= 4, + f"matched_items={sorted(matched)!r} of {facts['items']!r}; answer={answer!r}", + ) + foreign = mentions(answer, facts["foreign_items"]) + judge.check( + "answer_no_sibling_requirements", + not foreign, + f"foreign_items_matched={sorted(foreign)!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_20.py b/sites/berkeley/verify/verify_20.py new file mode 100644 index 00000000..56aaec52 --- /dev/null +++ b/sites/berkeley/verify/verify_20.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--20: the JD programme's duration, deadline and school. + +The Optometry MD shares the February 1 deadline and the MBA shares the +"professional degree" register; only the JD row has all three values. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_duration_years, + contains_month_day, + contains_phrase, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--20" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 20) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited( + judge, trajectory, "visited_program_search", "/programs", + {"degree": "JD"}, {"q": "juris"}, + ) + check_visited_detail(judge, trajectory, "program", facts["program"]["slug"]) + judge.check( + "answer_has_duration", + contains_duration_years(answer, facts["duration_years"]), + f"expected_duration_years={facts['duration_years']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_deadline", + contains_month_day(answer, facts["deadline"]), + f"expected_deadline={facts['deadline']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_school", + contains_phrase(answer, facts["college"]), + f"expected_school={facts['college']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_22.py b/sites/berkeley/verify/verify_22.py new file mode 100644 index 00000000..53d5eeb5 --- /dev/null +++ b/sites/berkeley/verify/verify_22.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--22: how many departments the College of Letters and Science lists. + +The /departments page carries no per-college total (and the stale +``colleges.dept_count`` column is never rendered), so the count is enumeration +work: the answer must carry it and name several of the listed departments. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_path, + contains_count, + contains_department, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--22" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 22) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + count = len(facts["departments"]) + + check_visited_path(judge, trajectory, "visited_departments_page", "/departments") + judge.check( + "answer_has_department_count", + contains_count(answer, count), + f"expected_count={count!r}, answer={answer!r}", + ) + named = [row["name"] for row in facts["departments"] if contains_department(answer, row["name"])] + judge.check( + "answer_names_ls_departments", + len(named) >= 4, + f"named={named!r} of {[row['name'] for row in facts['departments']]!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_23.py b/sites/berkeley/verify/verify_23.py new file mode 100644 index 00000000..5f18968b --- /dev/null +++ b/sites/berkeley/verify/verify_23.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--23: BIDS focus areas, director and its related centres. + +The related-centre names are the three rows the page's unordered ``LIMIT 3`` +query actually renders (app.py:469-472); naming a same-college centre that the +page does not list fails. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_person, + fail_closed, + final_answer, + Judge, + load_run, + mentions, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--23" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 23) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_visited_detail(judge, trajectory, "research", facts["centre"]["slug"]) + judge.check( + "answer_has_director", + contains_person(answer, facts["centre"]["director"]), + f"expected_director={facts['centre']['director']!r}, answer={answer!r}", + ) + focus = mentions(answer, facts["focus_areas"]) + judge.check( + "answer_has_focus_areas", + len(focus) >= 3, + f"expected_focus_areas={facts['focus_areas']!r}, matched={sorted(focus)!r}; answer={answer!r}", + ) + related = mentions(answer, facts["related_names"]) + judge.check( + "answer_names_rendered_related_centre", + bool(related), + f"rendered_related_centres={facts['related_names']!r}, matched={sorted(related)!r}; answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_24.py b/sites/berkeley/verify/verify_24.py new file mode 100644 index 00000000..aabf368e --- /dev/null +++ b/sites/berkeley/verify/verify_24.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--24: the Economics PhD -> department -> faculty walk. + +Three hops, each gated in order; each answer fact binds to the page it came from +(chair and programme list to the department page, interests to a member profile). +""" +from __future__ import annotations + +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_paths_in_order, + check_read_only, + check_trajectory_identity, + contains_degree_type, + contains_person, + detail_path, + fail_closed, + final_answer, + interest_token_matches, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--24" +FACULTY_PATH_RE = re.compile(r"/faculty/[a-z0-9-]+") + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 24) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_paths_in_order( + judge, trajectory, "workflow_in_order", + [ + (detail_path("program", facts["program"]["slug"]), {}), + (detail_path("department", facts["department"]["slug"]), {}), + (FACULTY_PATH_RE, {}), + ], + ) + judge.check( + "answer_has_chair", + contains_person(answer, facts["chair"]), + f"expected_chair={facts['chair']!r}, answer={answer!r}", + ) + degree_types = sorted({row["degree_type"] for row in facts["programmes"]}) + missing = [value for value in degree_types if not contains_degree_type(answer, value)] + judge.check( + "answer_has_department_programmes", + not missing, + f"expected_degree_types={degree_types!r}, missing={missing!r}; answer={answer!r}", + ) + bound = [ + member for member in facts["members"] + if contains_person(answer, member["name"]) + and interest_token_matches(answer, member["research_interests"]) >= 2 + ] + judge.check( + "answer_has_economics_faculty_interests", + bool(bound), + f"members={[row['name'] for row in facts['members']]!r}, " + f"interest_token_hits={[interest_token_matches(answer, row['research_interests']) for row in facts['members']]!r}; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_25.py b/sites/berkeley/verify/verify_25.py new file mode 100644 index 00000000..4d99d5f7 --- /dev/null +++ b/sites/berkeley/verify/verify_25.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--25: the Spring Career Fair plus two other Career events. + +The anchor event is derived by name (it survives the frozen clock), its detail +page is gated, and the other two events must bind title + date + location to +distinct rows of the Career listing. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + affirmative_near, + check_params_visited, + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_date, + contains_location, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + title_tokens_matched, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--25" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 25) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + anchor = facts["anchor"] + + check_params_visited(judge, trajectory, "visited_career_events_listing", "/events", {"category": "Career"}) + check_visited_detail(judge, trajectory, "event", anchor["id"]) + judge.check( + "answer_has_anchor_date", + contains_date(answer, str(anchor["start_datetime"])[:10]), + f"expected_date={str(anchor['start_datetime'])[:10]!r}, answer={answer!r}", + ) + judge.check( + "answer_has_anchor_location", + contains_location(answer, anchor["location"]), + f"expected_location={anchor['location']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_registration_required", + affirmative_near(answer, "career fair", "required", 200) + or affirmative_near(answer, anchor["title"], "required", 200), + f"answer={answer!r}", + ) + matched = [ + row for row in facts["others"] + if title_tokens_matched(answer, row["title"]) >= 3 + and contains_date(answer, str(row["start_datetime"])[:10]) + and contains_location(answer, row["location"]) + ] + judge.check( + "answer_lists_two_other_career_events", + len(matched) >= 2, + f"matched_events={[row['id'] for row in matched]!r} of {len(facts['others'])} other Career events; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_27.py b/sites/berkeley/verify/verify_27.py new file mode 100644 index 00000000..0e934b62 --- /dev/null +++ b/sites/berkeley/verify/verify_27.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--27 (re-anchored): both programme durations and the MEng department. + +The task now asks for the two exact durations printed on the two detail pages +(MEng vs Computer Science MS), which is deterministic; both detail visits are +gated and the department is accepted by name or by its acronym. + +The search gate follows the ques text: "Search the Berkeley site for 'Master of +Engineering'". The site search (``/search?q=…``) and the programme catalogue +filters (``/programs?q=…``, ``/programs?degree=MEng``) are all routes the task +permits, so any of them satisfies the gate as long as the query names the MEng +term — a catalog-wide search (``/search?q=california``) does not. MINOR +loosening under PIPELINE §0.4: the two detail-page gates and every answer check +are unchanged, and both remain the binding anti-shortcut anchors. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_department, + contains_duration_years, + fail_closed, + final_answer, + Judge, + load_run, + params_visited, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--27" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 27) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + meng_duration, ms_duration = facts["durations"] + + search_ok = ( + params_visited(trajectory, "/programs", q="master of engineering") + or params_visited(trajectory, "/programs", degree="MEng") + or params_visited(trajectory, "/search", q="master of engineering") + ) + judge.check( + "visited_program_search", + search_ok, + "required: /programs?q~'master of engineering', /programs?degree~'MEng', or the site " + "search /search?q~'master of engineering'; the query must name the MEng term", + ) + check_visited_detail(judge, trajectory, "program", facts["meng"]["slug"]) + check_visited_detail(judge, trajectory, "program", facts["ms"]["slug"]) + judge.check( + "answer_has_department", + contains_department(answer, facts["department"]), + f"expected_department={facts['department']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_meng_duration", + contains_duration_years(answer, meng_duration), + f"expected_meng_duration={meng_duration!r}, answer={answer!r}", + ) + judge.check( + "answer_has_ms_duration", + contains_duration_years(answer, ms_duration), + f"expected_ms_duration={ms_duration!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_28.py b/sites/berkeley/verify/verify_28.py new file mode 100644 index 00000000..19f1a3e1 --- /dev/null +++ b/sites/berkeley/verify/verify_28.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--28: how many programmes require the GRE, and which degree type. + +No GRE filter exists, so the badge is only visible by scanning the programme +listings: the gate accepts the two degree-filtered listings (which cover every +badged row) or four distinct pages of the full listing. The count and the modal +degree type are derived from the snapshot. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + contains_count, + contains_degree_type, + fail_closed, + final_answer, + Judge, + listing_pages_visited, + load_run, + params_visited, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--28" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 28) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + pages = listing_pages_visited(trajectory, "/programs") + both_filters = ( + params_visited(trajectory, "/programs", degree="PhD") + and params_visited(trajectory, "/programs", degree="MS") + ) + judge.check( + "visited_gre_programme_listings", + both_filters or len(pages) >= 4, + f"degree_filtered_listings={both_filters}, distinct_unfiltered_or_filtered_pages={len(pages)}; " + f"observed={pages!r}", + ) + judge.check( + "answer_has_gre_count", + contains_count(answer, facts["count"]), + f"expected_count={facts['count']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_modal_degree_type", + contains_degree_type(answer, facts["most_common_degree"]), + f"expected_degree_type={facts['most_common_degree']!r}, by_degree={facts['by_degree']!r}; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_30.py b/sites/berkeley/verify/verify_30.py new file mode 100644 index 00000000..191a7737 --- /dev/null +++ b/sites/berkeley/verify/verify_30.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--30: alice saves a named research centre to her bookmarks. + +Stateful. The gate is the ordered workflow (sign-in, centre page, My Account) +and the binding check is the exact bookmark row delta against the initial +snapshot — a run that claims the save without writing it fails on the delta. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_bookmarks_delta, + check_paths_in_order, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + contains_any, + contains_person, + contains_phrase, + detail_path, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + user_id_for_email, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--30" +EMAIL = "alice@berkeley.edu" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 30) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + centre = facts["centre"] + + check_signed_in_as(judge, trajectory, EMAIL) + check_paths_in_order( + judge, trajectory, "workflow_in_order", + [ + ("/login", {}), + (detail_path("research", centre["slug"]), {}), + ("/account", {}), + ], + ) + judge.check( + "answer_has_centre", + contains_phrase(answer, centre["name"]), + f"expected_centre={centre['name']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_director", + contains_person(answer, facts["director"]), + f"expected_director={facts['director']!r}, answer={answer!r}", + ) + judge.check( + "answer_confirms_saved", + contains_any(answer, ("saved", "listed", "bookmarks", "my account")), + f"answer={answer!r}", + ) + + user_id = user_id_for_email(initial_db, EMAIL) + if user_id is None: + judge.check("benchmark_user_present", False, f"missing benchmark user {EMAIL!r}") + else: + judge.check("benchmark_user_present", True, f"user_id={user_id}") + check_bookmarks_delta( + judge, initial_db, after_db, + user_id=user_id, + added=[(user_id, "research", centre["id"])], + ) + check_tables_unchanged(judge, initial_db, after_db, ("users",), prefix="read_only_") + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_31.py b/sites/berkeley/verify/verify_31.py new file mode 100644 index 00000000..5bdce830 --- /dev/null +++ b/sites/berkeley/verify/verify_31.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--31: bob saves two centres in order, then removes the first. + +Stateful, with a row-id proof: the bookmarks table starts empty, so the two +inserts take ids 1 and 2; deleting the id-1 row leaves the id-2 row behind. +Requiring the surviving row's id to be exactly 2 therefore proves that both +inserts happened and that the first was deleted. A run that skips the removal +(two rows added), removes the wrong one, or adds only the second centre fails. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_bookmarks_delta, + check_paths_in_order, + check_signed_in_as, + check_tables_unchanged, + check_trajectory_identity, + contains_acronym, + contains_person, + contains_phrase, + detail_path, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + title_tokens_matched, + user_id_for_email, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--31" +EMAIL = "bob@berkeley.edu" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 31) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + first, second = facts["first"], facts["second"] + + check_signed_in_as(judge, trajectory, EMAIL) + check_paths_in_order( + judge, trajectory, "workflow_in_order", + [ + ("/login", {}), + (detail_path("research", first["slug"]), {}), + (detail_path("research", second["slug"]), {}), + ("/account", {}), + ("/account", {}), + ], + ) + judge.check( + "answer_has_remaining_centre", + contains_phrase(answer, second["name"]), + f"expected_centre={second['name']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_remaining_director", + contains_person(answer, second["director"]), + f"expected_director={second['director']!r}, answer={answer!r}", + ) + removed_named = title_tokens_matched(answer, first["name"]) >= 3 or contains_acronym(answer, first["name"]) + judge.check( + "answer_confirms_removal", + contains_phrase(answer, "removed") and removed_named, + f"expected_removed_centre_tokens={title_tokens_matched(answer, first['name'])!r} " + f"or acronym={'yes' if contains_acronym(answer, first['name']) else 'no'}; " + f"centre={first['name']!r}; answer={answer!r}", + ) + + user_id = user_id_for_email(initial_db, EMAIL) + if user_id is None: + judge.check("benchmark_user_present", False, f"missing benchmark user {EMAIL!r}") + else: + judge.check("benchmark_user_present", True, f"user_id={user_id}") + check_bookmarks_delta( + judge, initial_db, after_db, + user_id=user_id, + added=[(user_id, "research", second["id"])], + surviving_ids=[2], + ) + check_tables_unchanged(judge, initial_db, after_db, ("users",), prefix="read_only_") + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_4.py b/sites/berkeley/verify/verify_4.py new file mode 100644 index 00000000..87f7c9cd --- /dev/null +++ b/sites/berkeley/verify/verify_4.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--4: the CRISPR article's scientist and award. + +The person and the award are derived from the article's own headline (the row +the app renders), so prior knowledge ("Nobel Prize") cannot satisfy the check. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + check_visited_detail, + contains_person, + contains_phrase, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--4" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 4) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited( + judge, trajectory, "visited_news_listing", "/news", + {"q": "crispr"}, {"category": "Research"}, + ) + check_visited_detail(judge, trajectory, "news", facts["article"]["slug"]) + judge.check( + "answer_has_scientist", + contains_person(answer, facts["person"]), + f"expected_person={facts['person']!r}, answer={answer!r}", + ) + judge.check( + "answer_has_award", + contains_phrase(answer, facts["award"]), + f"expected_award={facts['award']!r}, answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_6.py b/sites/berkeley/verify/verify_6.py new file mode 100644 index 00000000..206a6856 --- /dev/null +++ b/sites/berkeley/verify/verify_6.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--6: at least three Lecture events with dates and locations. + +Set-valued: the accepted set is every seeded Lecture event (the ``date=all`` +rendering); each reported event must bind title + date + location to one row. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_params_visited, + check_read_only, + check_trajectory_identity, + contains_date, + contains_location, + fail_closed, + final_answer, + Judge, + load_run, + parse_args, + resolve_snapshots, + title_tokens_matched, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--6" + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 6) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + check_params_visited(judge, trajectory, "visited_lecture_listing", "/events", {"category": "Lecture"}) + matched = [ + row for row in facts["events"] + if title_tokens_matched(answer, row["title"]) >= 3 + and contains_date(answer, str(row["start_datetime"])[:10]) + and contains_location(answer, row["location"]) + ] + judge.check( + "answer_lists_three_lecture_events", + len(matched) >= 3, + f"matched_events={[row['id'] for row in matched]!r} of {len(facts['events'])} Lecture events; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_7.py b/sites/berkeley/verify/verify_7.py new file mode 100644 index 00000000..5f2a09ab --- /dev/null +++ b/sites/berkeley/verify/verify_7.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Verify UC Berkeley--7: an EECS professor who works on artificial intelligence. + +The accepted set is derived from the department roster with an AI-family rule +over ``research_interests`` (the literal phrase "artificial intelligence" matches +one row; the allowlist is the rule the task text implies). The answer must bind +to one named row: the profile must have been opened and the reported interests +must be that row's. +""" +from __future__ import annotations + +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from verify_lib import ( # noqa: E402 + check_read_only, + check_trajectory_identity, + contains_person, + detail_visited, + fail_closed, + final_answer, + interest_token_matches, + Judge, + load_run, + navigated_to_path, + params_visited, + parse_args, + resolve_snapshots, +) +from ground_truth import task_ground_truth # noqa: E402 + + +TASK_ID = "UC Berkeley--7" +AI_QUERY_RE = re.compile(r"(artificial|machine learning|deep learning|reinforcement|robot|ai\b)") + + +def run_checks(judge: Judge, trajectory: dict, initial_db: str, after_db: str) -> None: + facts = task_ground_truth(initial_db, 7) + check_trajectory_identity(judge, trajectory, TASK_ID) + answer = final_answer(trajectory) + + route_ok = ( + params_visited(trajectory, "/faculty", dept=facts["department"]["slug"]) + or params_visited(trajectory, "/faculty", q=AI_QUERY_RE) + or navigated_to_path(trajectory, f"/departments/{facts['department']['slug']}") + ) + judge.check( + "visited_eecs_faculty_route", + route_ok, + "required: /faculty?dept=eecs, an AI-keyword /faculty search, or the EECS department page", + ) + + named = [row for row in facts["allowed"] if contains_person(answer, row["name"])] + judge.check( + "named_eecs_ai_professor", + bool(named), + f"allowed={[row['name'] for row in facts['allowed']]!r}, answer={answer!r}", + ) + visited = [row for row in named if detail_visited(trajectory, "faculty", row["slug"])] + judge.check( + "visited_named_professor_profile", + bool(visited), + f"named={[row['slug'] for row in named]!r}, observed={[row['slug'] for row in facts['members'] if detail_visited(trajectory, 'faculty', row['slug'])]!r}", + ) + bound = [ + row for row in visited + if interest_token_matches(answer, row["research_interests"]) >= 2 + ] + judge.check( + "answer_interests_bind_to_profile", + bool(bound), + f"named={[row['name'] for row in named]!r}, " + f"interest_token_hits={[interest_token_matches(answer, row['research_interests']) for row in visited]!r}; " + f"answer={answer!r}", + ) + check_read_only(judge, initial_db, after_db) + + +def main() -> None: + args = parse_args() + try: + trajectory = load_run(args.run_dir) + except (OSError, ValueError) as exc: + fail_closed(TASK_ID, "trajectory_unavailable", str(exc)) + initial_db, after_db = resolve_snapshots(args, TASK_ID) + judge = Judge(TASK_ID) + try: + run_checks(judge, trajectory, initial_db, after_db) + except Exception as exc: # noqa: BLE001 - any verifier error fails closed + fail_closed(TASK_ID, "verifier_error", f"{type(exc).__name__}: {exc}") + judge.emit() + + +if __name__ == "__main__": + main() diff --git a/sites/berkeley/verify/verify_lib.py b/sites/berkeley/verify/verify_lib.py new file mode 100644 index 00000000..4414fd8a --- /dev/null +++ b/sites/berkeley/verify/verify_lib.py @@ -0,0 +1,1210 @@ +#!/usr/bin/env python3 +"""Shared deterministic helpers for UC Berkeley task verifiers. + +Each verifier consumes an agent run directory plus before/after SQLite snapshots +and emits ``{task_id, pass, reason, evidence[]}`` with exit code 0/1. + +No helper in this module calls an LLM; a verdict never depends on a key or a +model. Targets are re-derived from the run's ``initial.db`` by ``ground_truth.py`` +(never frozen answer constants); the only pinned content constants are the +snapshot contract below (schema hash, table set, seed counts, catalog +fingerprint), which fail closed when the seed drifts. +""" +from __future__ import annotations + +import argparse +import atexit +import datetime as _dt +import hashlib +import ipaddress +import json +import os +import re +import sqlite3 +import subprocess +import tempfile +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Sequence +from urllib.parse import parse_qs, urlparse + +from PIL import Image + + +SITE = "berkeley" +DEFAULT_CONTAINER = os.environ.get("WH_CONTAINER", "wh-review") + +# Public benchmark password (documented in the site README and tasks.jsonl). +BENCHMARK_PASSWORD = "test1234" + +# Screenshots must be plausible viewport captures, not replayed 1x1 stubs. +MIN_SCREENSHOT_WIDTH = 320 +MIN_SCREENSHOT_HEIGHT = 240 + +# The nine tables the site ships. A read-only task must leave every one of them +# row-identical: after the article-view fix (commit 1) no GET path writes the DB. +ALL_TABLES = ( + "bookmarks", "colleges", "departments", "events", "faculty", "news_articles", + "programs", "research_centers", "users", +) +READ_ONLY_TABLES = ALL_TABLES + +# Tables no task may ever change (the catalog). Runtime tables are ``users`` and +# ``bookmarks``; a stateful verifier pins their exact delta instead. +IMMUTABLE_TABLES = ( + "colleges", "departments", "events", "faculty", "news_articles", "programs", + "research_centers", +) + +# --- Snapshot contract ------------------------------------------------------- +# Recomputed from the shipped instance_seed/berkeley.db (md5 +# 3001bcf4bcec169f4192c08609160ab6). A re-frozen seed must re-pin these and +# re-run the verifier suite; until then every verifier fails closed. +SCHEMA_HASH = "2e12a903a802cd4691481320edd80ccc24dddc65e55c0d44f890544057ea654e" +CATALOG_FINGERPRINT = "99e17923920cb88698802655a1fb9b7b03805cd214522f3fc45183bb13b143de" +EXPECTED_TABLES = frozenset(ALL_TABLES) +EXPECTED_COUNTS = { + "bookmarks": 0, "colleges": 14, "departments": 30, "events": 64, "faculty": 82, + "news_articles": 121, "programs": 83, "research_centers": 25, "users": 4, +} + + +# --------------------------------------------------------------------------- # +# CLI / run loading +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class VerifyArgs: + run_dir: str + initial_db: str | None + after_db: str | None + container: str + no_llm: bool + + +def parse_args() -> VerifyArgs: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default=DEFAULT_CONTAINER) + parser.add_argument("--no_llm", nargs="?", const=True, default=True) + args = parser.parse_args() + run_dir = Path(args.run_dir) + initial_snapshot = run_dir / "initial.db" + after_snapshot = run_dir / "after.db" + return VerifyArgs( + run_dir=args.run_dir, + initial_db=( + args.initial_db + or (str(initial_snapshot) if initial_snapshot.is_file() else None) + ), + after_db=( + args.after_db or (str(after_snapshot) if after_snapshot.is_file() else None) + ), + container=args.container, + no_llm=True, + ) + + +def load_run(run_dir: str | os.PathLike[str]) -> dict[str, Any]: + path = Path(run_dir) / "trajectory.json" + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("trajectory.json must contain a JSON object") + data["_run_dir"] = str(Path(run_dir).resolve()) + return data + + +def final_answer(trajectory: dict[str, Any]) -> str: + return str(trajectory.get("final_answer") or "").strip() + + +def last_action_target_url(trajectory: dict[str, Any]) -> str: + """The final action's declared destination, when it names one. + + ``agent.py`` records the URL *before* each action, so a non-final action's + destination appears as the next step's URL. The final action has no next + step, so a ``navigate`` there would otherwise be invisible; the gate helpers + credit its ``params.url`` as an alternative satisfier (decision 5). Click + actions carry only an element index and never contribute. + """ + steps = trajectory.get("steps") + if not isinstance(steps, list) or not steps: + return "" + last = steps[-1] + if not isinstance(last, dict): + return "" + params = last.get("params") + if isinstance(params, dict) and params.get("url"): + return str(params["url"]) + return "" + + +def trajectory_urls(trajectory: dict[str, Any]) -> list[str]: + """Every browser URL recorded by supported trajectory producers. + + ``last_action_target_url`` is appended so the final action's declared target + is a first-class visit for every gate. + """ + urls: list[str] = [] + if trajectory.get("start_url"): + urls.append(str(trajectory["start_url"])) + for step in trajectory.get("steps") or []: + if not isinstance(step, dict): + continue + for key in ("url", "url_before", "url_after"): + value = step.get(key) + if value: + urls.append(str(value)) + if trajectory.get("final_url"): + urls.append(str(trajectory["final_url"])) + target = last_action_target_url(trajectory) + if target: + urls.append(target) + return urls + + +def normalized_url_path(url: str) -> str: + path = urlparse(str(url or "")).path or "/" + return path.rstrip("/") or "/" + + +def is_site_url(url: str) -> bool: + """Accept HTTP(S) URLs on a loopback host while allowing any port. + + Runs hit the alt-port container (41026) while tasks.jsonl says 40029, so the + port is deliberately not checked here. + """ + parsed = urlparse(str(url or "")) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return False + hostname = parsed.hostname.casefold() + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def site_urls(trajectory: dict[str, Any]) -> list[str]: + return [url for url in trajectory_urls(trajectory) if is_site_url(url)] + + +def _path_matches(url: str, expected: str | re.Pattern[str]) -> bool: + path = normalized_url_path(url) + if isinstance(expected, re.Pattern): + return expected.fullmatch(path) is not None + return path == normalized_url_path(expected) + + +def navigated_to_path(trajectory: dict[str, Any], expected_path: str | re.Pattern[str]) -> bool: + """Require an exact mirror path (or a full-path regex) on a loopback origin.""" + return any(_path_matches(url, expected_path) for url in site_urls(trajectory)) + + +def final_url_is_path(trajectory: dict[str, Any], expected_path: str | re.Pattern[str]) -> bool: + observed_url = final_url(trajectory) + return is_site_url(observed_url) and _path_matches(observed_url, expected_path) + + +def trajectory_task_matches(trajectory: dict[str, Any], task_id: str) -> bool: + return str(trajectory.get("task_id") or "").strip() == task_id + + +def trajectory_input_texts(trajectory: dict[str, Any], on_path: str | None = None) -> list[str]: + """Typed texts, optionally only from steps whose (before-action) URL path is ``on_path``.""" + values: list[str] = [] + for step in trajectory.get("steps") or []: + if not isinstance(step, dict) or normalize_text(step.get("action")) != "input": + continue + if on_path is not None and normalized_url_path(str(step.get("url") or "")) != normalized_url_path(on_path): + continue + params = step.get("params") + if isinstance(params, dict) and params.get("text") is not None: + values.append(str(params["text"])) + return values + + +_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+") + + +def trajectory_last_email(trajectory: dict[str, Any], on_path: str | None = None) -> str: + emails = [ + normalize_text(value) + for value in trajectory_input_texts(trajectory, on_path) + if _EMAIL_RE.fullmatch(value.strip()) + ] + return emails[-1] if emails else "" + + +# --------------------------------------------------------------------------- # +# Detail-path gates (ids and slugs exposed in hrefs) +# --------------------------------------------------------------------------- # +_DETAIL_ROUTES = { + "program": "/programs/{}", + "event": "/events/{}", + "news": "/news/{}", + "faculty": "/faculty/{}", + "research": "/research/{}", + "department": "/departments/{}", +} + + +def detail_path(kind: str, key: Any) -> str: + """Exact detail path for an entity; ids are rendered as integers.""" + try: + pattern = _DETAIL_ROUTES[kind] + except KeyError: + raise ValueError(f"unsupported detail kind: {kind}") from None + return pattern.format(key) + + +def detail_visited(trajectory: dict[str, Any], kind: str, key: Any) -> bool: + """An exact detail-page visit; listing snippets that merely carry the href do not count.""" + return navigated_to_path(trajectory, detail_path(kind, key)) + + +def check_visited_detail(judge: Judge, trajectory: dict[str, Any], kind: str, key: Any) -> bool: + path = detail_path(kind, key) + return judge.check( + f"visited_{kind}_detail_{key}", + navigated_to_path(trajectory, path), + f"required_path={path}", + ) + + +def _param_matches(query: dict[str, list[str]], key: str, expected: Any) -> bool: + """Query-parameter matcher; a value may be a regex, a tuple of alternatives or exact text. + + The app reads every parameter with ``request.args.get`` (first value only), so + duplicate-parameter tricks cannot satisfy a gate while the rendered page used + another value. + """ + if isinstance(expected, (tuple, list, set, frozenset)): + return any(_param_matches(query, key, alt) for alt in expected) + raw_values = query.get(key) or [] + first = raw_values[0] if raw_values else None + values = [str(first)] if first is not None and str(first).strip() else [] + if expected == "": + return not values # the parameter must be absent (or blank) + if key == "q": + if isinstance(expected, re.Pattern): + return any(expected.search(normalize_text(value)) for value in values) + # A plain-text query satisfies the gate when every expected token appears + # in the recorded value ("Master of Engineering" ~ "master+engineering"). + expected_tokens = set(re.findall(r"[a-z0-9]+", normalize_text(expected))) + return bool(expected_tokens) and any( + expected_tokens <= set(re.findall(r"[a-z0-9]+", normalize_text(value))) + for value in values + ) + if key == "page": + return any(str(value).strip() == str(expected) for value in values) + if isinstance(expected, re.Pattern): + return any(expected.fullmatch(normalize_text(value)) for value in values) + return any(normalize_text(expected) == normalize_text(value) for value in values) + + +def params_visited( + trajectory: dict[str, Any], path: str | re.Pattern[str], **params: Any +) -> bool: + """Some visit of ``path`` carries every requested query parameter.""" + for url in site_urls(trajectory): + if not _path_matches(url, path): + continue + query = parse_qs(urlparse(url).query, keep_blank_values=True) + if all(_param_matches(query, key, expected) for key, expected in params.items()): + return True + return False + + +def _describe_params(params: dict[str, Any]) -> str: + pieces = [] + for key, value in params.items(): + if isinstance(value, re.Pattern): + value = f"/{value.pattern}/" + pieces.append(f"{key}~{value!r}") + return "&".join(pieces) or "(any)" + + +def check_params_visited( + judge: Judge, + trajectory: dict[str, Any], + name: str, + path: str | re.Pattern[str], + *alternatives: dict[str, Any], +) -> bool: + """PASS when any of the ``alternatives`` param sets matches a visit of ``path``.""" + matched = any(params_visited(trajectory, path, **params) for params in alternatives) + described = " OR ".join(_describe_params(params) for params in alternatives) + shown_path = f"/{path.pattern}/" if isinstance(path, re.Pattern) else path + observed = [url for url in site_urls(trajectory) if _path_matches(url, path)] + return judge.check(name, matched, f"required={shown_path}?{described}; observed_urls={observed!r}") + + +def listing_pages_visited(trajectory: dict[str, Any], path: str) -> list[str]: + """Distinct normalized listing URLs of ``path`` (used by the catalog-scan gates).""" + seen: list[str] = [] + for url in site_urls(trajectory): + if _path_matches(url, path) and url not in seen: + seen.append(url) + return seen + + +def check_paths_in_order( + judge: Judge, + trajectory: dict[str, Any], + name: str, + requirements: Sequence[tuple[str | re.Pattern[str], dict[str, Any]]], +) -> bool: + urls = site_urls(trajectory) + cursor = 0 + described = [ + (f"/{path.pattern}/" if isinstance(path, re.Pattern) else path, _describe_params(params)) + for path, params in requirements + ] + for expected_path, params in requirements: + for index in range(cursor, len(urls)): + url = urls[index] + query = parse_qs(urlparse(url).query, keep_blank_values=True) + if _path_matches(url, expected_path) and all( + _param_matches(query, key, value) for key, value in params.items() + ): + cursor = index + 1 + break + else: + return judge.check(name, False, f"requirements={described!r}, observed={urls!r}") + return judge.check(name, True, f"requirements={described!r}") + + +def check_visited_before( + judge: Judge, + trajectory: dict[str, Any], + name: str, + before_path: str | re.Pattern[str], + after_path: str | re.Pattern[str], + before_params: Sequence[dict[str, Any]] = (), +) -> bool: + """Require a qualifying ``before_path`` visit strictly earlier than ``after_path``.""" + urls = site_urls(trajectory) + matches = list(enumerate(urls)) + before_index = None + for index, url in matches: + if not _path_matches(url, before_path): + continue + query = parse_qs(urlparse(url).query, keep_blank_values=True) + if not before_params or any( + all(_param_matches(query, key, value) for key, value in params.items()) + for params in before_params + ): + before_index = index + break + after_index = next( + (index for index, url in matches + if _path_matches(url, after_path) and (before_index is None or index > before_index)), + None, + ) + ok = before_index is not None and after_index is not None and before_index < after_index + return judge.check( + name, + ok, + f"before_path={before_path!r} params={before_params!r} at {before_index}; " + f"after_path={after_path!r} at {after_index}; observed={urls!r}", + ) + + +# --------------------------------------------------------------------------- # +# Text normalization and answer matchers +# --------------------------------------------------------------------------- # +DASH = r"[-‐‑‒–—−]" + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")) + text = text.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"') + text = re.sub(DASH, "-", text) + text = text.replace("&", " and ") + return re.sub(r"\s+", " ", text).strip().casefold() + + +_NEGATION_RE = re.compile( + r"\b(?:not|no|never|without|wrong|incorrect|false|failed|nor|neither|unlike" + r"|isn'?t|wasn'?t|aren'?t|weren'?t|didn'?t|doesn'?t|don'?t|cannot|can'?t)\b", + re.I, +) +_CLAUSE_SPLIT_RE = re.compile(r"[.!?;:\n]+|\b(?:but|however|instead)\b", re.I) + + +_AFTER_NEGATION_WINDOW = 15 +_CONTRAST_CHARS = ",;–—(" +# Honorific / degree abbreviations whose period must not be read as a sentence +# end. Without this a name that follows "Prof." starts a fresh clause, so the +# negation in front of it is invisible: "The chair of EECS is not Prof. James +# Demmel" graded as an affirmative chair answer (found by the C2 mutation rows +# on tasks 13, 23 and 24). +_ABBREV_RE = re.compile(r"\b(?:prof|dr|mr|mrs|ms|miss|mx|rev|fr|sr|jr|ph\.?\s?d)\.", re.I) +_ABBREV_MASK = "\x00" + + +def _mask_abbreviations(text: str) -> str: + """Length-preserving mask of abbreviation periods (match indices stay valid).""" + return _ABBREV_RE.sub(lambda match: match.group(0).replace(".", _ABBREV_MASK), text) + + +def _match_is_affirmative(text: str, match: re.Match[str]) -> bool: + """Reject a match when a negation token contradicts it. + + Negation *before* the match (anywhere in the clause) rejects it — "did not + receive the National Medal of Science", "does not have 12", "is not Prof. + James Demmel". Negation *after* the match rejects it only inside a short + window that a contrastive comma has not already closed, so a confirming + contrast ("founded in 2013, not 2017") stays affirmative while "2013 was not + the founding year" does not. Abbreviation periods do not split clauses. + """ + view = _mask_abbreviations(text) + starts = [m.end() for m in _CLAUSE_SPLIT_RE.finditer(view[:match.start()])] + clause_start = starts[-1] if starts else 0 + end_match = _CLAUSE_SPLIT_RE.search(view, match.end()) + clause_end = end_match.start() if end_match else len(view) + before = text[clause_start:match.start()] + if _NEGATION_RE.search(before): + return False + after = text[match.end():clause_end] + contrast = min((after.find(char) for char in _CONTRAST_CHARS if char in after), default=len(after)) + window = after[:min(contrast, _AFTER_NEGATION_WINDOW)] + return not _NEGATION_RE.search(window) + + +def _affirmative_search(pattern: str, text: str, flags: int = 0) -> bool: + return any(_match_is_affirmative(text, match) for match in re.finditer(pattern, text, flags)) + + +def _phrase_pattern(phrase: str) -> str: + tokens = re.findall(r"[a-z0-9]+", normalize_text(phrase)) + if not tokens: + return r"(?!x)x" + return r"(? bool: + """Whole-token phrase match: punctuation, dash style, ``&``/``and`` and case are ignored.""" + return _affirmative_search(_phrase_pattern(phrase), normalize_text(text)) + + +def contains_all(text: Any, expected: Iterable[str]) -> bool: + return all(contains_phrase(text, value) for value in expected) + + +def contains_any(text: Any, expected: Iterable[str]) -> bool: + return any(contains_phrase(text, value) for value in expected) + + +def contains_year(text: Any, year: int) -> bool: + """A standalone four-digit year. + + A trailing full stop is normal ("…established in 2013."); a period or comma + that introduces more digits (``2013.5``, ``1,2013``) is not a match. + """ + raw = unicodedata.normalize("NFKC", str(text or "")) + return _affirmative_search(rf"(? str: + """The integer as digits, tolerant of thousands separators (4,500 / 4500). + + The trailing guard allows a sentence-ending full stop ("…requires the GRE: + 17.") but rejects a period or comma that introduces more digits, so "12" + never matches inside "12,000" or "14.4". + """ + digits = str(int(number)) + if len(digits) <= 3: + body = re.escape(digits) + else: + head, tail = digits[:-3], digits[-3:] + body = re.escape(head) + r",?" + re.escape(tail) + return rf"(? bool: + """A standalone integer (digits with optional thousands separator, or the word form). + + Years, decimals, percents, times and phone numbers are naturally excluded by + the neighbouring-character guards, so "12" never matches inside "1,629", + "12,000", "2012" or "14.4". + """ + if _affirmative_search(_integer_pattern(int(number)), normalize_text(text)): + return True + word = _NUMBER_WORDS.get(int(number)) + return bool(word) and contains_phrase(text, word) + + +def contains_count_as(text: Any, number: int, phrase: str) -> bool: + """The count labelling a phrase ("107 Nobel Laureates"), affirmative. + + Used for near-miss rules: the page's "more than 107 Nobel Prizes" line is + only a wrong answer when it is claimed *as the laureate count*. + """ + normalized = normalize_text(text) + pattern = _integer_pattern(int(number)) + r"[\s\S]{0,12}" + _phrase_pattern(phrase) + return _affirmative_search(pattern, normalized) + + +def contains_percent(text: Any, value: str | float) -> bool: + """``14.4%`` / ``14.4 percent`` / ``14.4 per cent`` (the printed rate).""" + normalized = normalize_text(text) + literal = normalize_text(value).rstrip("%").strip() + try: + number = float(literal) + except ValueError: + return False + body = re.escape(f"{number:g}") + pattern = rf"(? bool: + """``May 15`` / ``May 15, 2026`` / ``15 May 2026`` / ``2026-05-15`` / ``05/15/2026``. + + The year is optional: the events listing prints the day and month separately. + """ + if isinstance(value, str): + match = re.fullmatch(r"\s*(\d{4})-(\d{2})-(\d{2})\s*", value) + if not match: + raise ValueError(f"unsupported date literal: {value!r}") + value = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3))) + normalized = normalize_text(text) + month = _MONTHS[value.month - 1] + month_re = rf"(?:{month}|{month[:3]}\.?)" + day_re = rf"(? str: + """Strip academic titles and trailing decorations from a rendered name.""" + tokens = [token for token in normalize_text(person).split() + if token.strip(".,") not in _TITLE_WORDS] + return " ".join(tokens).strip(" ,") + + +def contains_person(text: Any, person: str) -> bool: + """The person's full name as one contiguous phrase (titles are ignored).""" + name = bare_name(person) + return bool(name) and contains_phrase(text, name) + + +def contains_location(text: Any, location: str) -> bool: + """``253 Cory Hall`` or ``Cory Hall`` — the leading room number is optional.""" + normalized = normalize_text(location) + if not normalized: + return False + if contains_phrase(text, normalized): + return True + tokens = normalized.replace(",", " ").split() + if tokens and re.fullmatch(r"[\d][\d\-/.]*", tokens[0]): + return contains_phrase(text, " ".join(tokens[1:])) + return False + + +_DEGREE_PATTERNS = { + "phd": r"ph\.?\s?d\.?", "meng": r"m\.?\s?eng\.?", "mba": r"m\.?b\.?a\.?", + "mph": r"m\.?p\.?h\.?", "jd": r"j\.?d\.?", "md": r"m\.?d\.?", + "ba": r"b\.?a\.?", "bs": r"b\.?s\.?", "ma": r"m\.?a\.?", "ms": r"m\.?s\.?", +} + + +def contains_degree_type(text: Any, *types: str) -> bool: + """Word-boundary degree-type match; ``Ph.D.`` / ``MS`` / ``MBA`` variants accepted.""" + normalized = normalize_text(text) + for value in types: + pattern = _DEGREE_PATTERNS.get(normalize_text(value)) + if not pattern: + raise ValueError(f"unsupported degree type: {value!r}") + if _affirmative_search(rf"(? bool: + """``2 years`` / ``two years`` / ``1.5 years`` / ``18 months`` for a 1.5-year program. + + The integer guard keeps ``1 year`` from matching inside ``1.5 years``. + """ + value = float(years) + if value == int(value): + pattern = rf"(? bool: + """A rendered month/day string without a year, e.g. ``November 30`` / ``February 1``.""" + match = re.fullmatch(r"\s*([A-Za-z]+)\.?\s+(\d{1,2})(?:,?\s+(\d{4}))?\s*", str(literal or "")) + if not match: + raise ValueError(f"unsupported month-day literal: {literal!r}") + month_name = normalize_text(match.group(1)) + month_index = next( + (index for index, month in enumerate(_MONTHS) if month.startswith(month_name[:3])), None + ) + if month_index is None: + raise ValueError(f"unsupported month literal: {literal!r}") + year = int(match.group(3)) if match.group(3) else 2000 + return contains_date(text, _dt.date(year, month_index + 1, int(match.group(2)))) + + +def department_aliases(name: str) -> list[str]: + """A department name plus its acronym (``Department of Electrical Engineering and + Computer Sciences`` → ``electrical engineering and computer sciences`` / ``eecs``).""" + base = re.sub(r"^\s*department of\s+", "", normalize_text(name)).strip() + words = [word for word in re.findall(r"[a-z]+", base) if word not in {"and", "of", "the", "in"}] + if not base or not words: + return [base] if base else [] + return [base, "".join(word[0] for word in words)] + + +def contains_department(text: Any, name: str) -> bool: + """The full department name or its acronym.""" + return any(contains_phrase(text, alias) for alias in department_aliases(name)) + + +def acronym(name: str) -> str: + """Initials of the significant words ("Mathematical Sciences Research Institute" → msri). + + Single-word names have no acronym: a bare initial would match far too much. + """ + words = [word for word in re.findall(r"[a-z]+", normalize_text(name)) + if word not in {"and", "of", "the", "in", "for"}] + if len(words) < 2: + return "" + return "".join(word[0] for word in words) + + +def contains_acronym(text: Any, name: str) -> bool: + """The acronym of a rendered name, as a whole token.""" + initials = acronym(name) + return len(initials) >= 2 and bool( + _affirmative_search(rf"(? int: + """How many distinct ≥4-character interest tokens the answer carries (row binding).""" + tokens = { + token for token in re.findall(r"[a-z0-9]+", normalize_text(interests)) if len(token) >= 4 + } + normalized = normalize_text(text) + return sum( + 1 for token in tokens + if re.search(rf"(? set[str]: + """The candidate phrases that appear affirmatively (set-valued answers).""" + return {value for value in candidates if contains_phrase(text, value)} + + +_STOPWORDS = { + "with", "from", "will", "that", "this", "have", "into", "over", "after", "before", + "wins", "win", "won", "the", "and", "for", "its", "their", "about", "held", "open", +} + + +def title_tokens(title: str, minimum_length: int = 4) -> list[str]: + """Distinctive words of a rendered title (stopwords and short words dropped).""" + tokens = re.findall(r"[a-z0-9][a-z0-9'-]*", normalize_text(title)) + return [token for token in tokens + if len(token) >= minimum_length and token not in _STOPWORDS] + + +def title_tokens_matched(text: Any, title: str, minimum_length: int = 4) -> int: + normalized = normalize_text(text) + return sum( + 1 for token in title_tokens(title, minimum_length) + if re.search(rf"(? list[str]: + """Clause split for the "offering" negatives: commas and contrast words split too.""" + return [clause for clause in re.split( + r"[.!?;:,\n]+|\b(?:but|however|instead|while|whereas)\b", normalize_text(text) + ) if clause.strip()] + + +def contains_near(text: Any, anchor: str, pattern: str, window: int = 100) -> bool: + """``pattern`` (a regex source) must occur within ``window`` chars of ``anchor``.""" + normalized = normalize_text(text) + for match in re.finditer(_phrase_pattern(anchor), normalized): + segment = normalized[max(0, match.start() - window):match.end() + window] + if re.search(pattern, segment): + return True + return False + + +def affirmative_near(text: Any, anchor: str, phrase: str, window: int = 150) -> bool: + """``phrase`` appears, negation-free, inside a window around ``anchor``.""" + normalized = normalize_text(text) + pattern = _phrase_pattern(phrase) + for anchor_match in re.finditer(_phrase_pattern(anchor), normalized): + segment = normalized[max(0, anchor_match.start() - window):anchor_match.end() + window] + if _affirmative_search(pattern, segment): + return True + return False + + +# --------------------------------------------------------------------------- # +# Judge harness +# --------------------------------------------------------------------------- # +class Judge: + def __init__(self, task_id: str): + self.task_id = task_id + self.passed = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str) -> bool: + marker = "PASS" if condition else "FAIL" + self.evidence.append(f"[{marker}] {name}: {evidence}") + if not condition: + self.passed = False + if not self.reason: + self.reason = name + return condition + + def emit(self) -> None: + result = { + "task_id": self.task_id, + "pass": self.passed, + "reason": self.reason or "all checks passed", + "evidence": self.evidence, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + raise SystemExit(0 if self.passed else 1) + + +def fail_closed(task_id: str, reason: str, detail: str) -> None: + print( + json.dumps( + { + "task_id": task_id, + "pass": False, + "infra_error": True, + "reason": reason, + "evidence": [f"[FAIL] {reason}: {detail}"], + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(1) + + +def _same_local_origin(url: str, start_url: str) -> bool: + try: + observed = urlparse(str(url or "")) + start = urlparse(str(start_url or "")) + return ( + observed.scheme == start.scheme == "http" + and observed.hostname is not None + and start.hostname is not None + and not observed.username + and not observed.password + and observed.port == start.port + and observed.hostname.casefold() == start.hostname.casefold() + and is_site_url(url) + ) + except ValueError: + return False + + +def _screenshots_decode(trajectory: dict[str, Any]) -> tuple[bool, str]: + root = Path(str(trajectory.get("_run_dir") or "")) + steps = trajectory.get("steps") + if not root.is_dir() or not isinstance(steps, list) or not steps: + return False, "run directory or steps are missing" + checked = 0 + for index, step in enumerate(steps): + if not isinstance(step, dict): + return False, f"step {index} is not an object" + for key in ("screenshot_before", "screenshot_after"): + name = step.get(key) + relative = Path(str(name or "")) + if not name or relative.is_absolute() or ".." in relative.parts: + return False, f"step {index} has unsafe {key}" + candidates = (root / "screenshots" / relative, root / relative) + path = next((item for item in candidates if item.is_file()), None) + if path is None: + return False, f"step {index} is missing {key}={name!r}" + try: + with Image.open(path) as image: + image.load() + if image.format != "PNG" or image.width < 1 or image.height < 1: + return False, f"step {index} {key} is not a nonempty PNG" + if image.width < MIN_SCREENSHOT_WIDTH or image.height < MIN_SCREENSHOT_HEIGHT: + return False, ( + f"step {index} {key} is {image.width}x{image.height}; a real viewport " + f"capture must be at least {MIN_SCREENSHOT_WIDTH}x{MIN_SCREENSHOT_HEIGHT}" + ) + except Exception as exc: + return False, f"step {index} {key} cannot decode: {type(exc).__name__}" + checked += 1 + return True, f"decoded {checked} PNG screenshots" + + +def check_trajectory_identity(judge: Judge, trajectory: dict[str, Any], task_id: str) -> None: + judge.check( + "final_answer_nonempty", + bool(final_answer(trajectory)), + f"final_answer={final_answer(trajectory)!r}", + ) + judge.check( + "trajectory_task_matches", + trajectory_task_matches(trajectory, task_id), + f"expected_task_id={task_id!r}, observed_task_id={trajectory.get('task_id')!r}", + ) + steps = trajectory.get("steps") + judge.check( + "trajectory_completed", + trajectory.get("terminated") is True and trajectory.get("termination_reason") == "agent_done", + f"terminated={trajectory.get('terminated')!r}, reason={trajectory.get('termination_reason')!r}", + ) + judge.check("trajectory_has_steps", isinstance(steps, list) and bool(steps), f"steps={len(steps) if isinstance(steps, list) else 'invalid'}") + recorded = trajectory_urls(trajectory) + judge.check( + "all_urls_match_local_origin", + bool(recorded) and all(_same_local_origin(url, trajectory.get("start_url", "")) for url in recorded), + f"start_url={trajectory.get('start_url')!r}, recorded_urls={recorded!r}", + ) + screenshots_ok, screenshot_evidence = _screenshots_decode(trajectory) + judge.check("screenshots_decode", screenshots_ok, screenshot_evidence) + + +def check_signed_in_as(judge: Judge, trajectory: dict[str, Any], email: str) -> None: + judge.check("visited_login_page", navigated_to_path(trajectory, "/login"), "required_path=/login") + login_inputs = trajectory_input_texts(trajectory, on_path="/login") + typed_email = "" + for value in login_inputs: + if _EMAIL_RE.fullmatch(normalize_text(value).strip()): + typed_email = normalize_text(value) + judge.check( + "entered_expected_account_email", + typed_email == normalize_text(email), + f"expected_email={email!r}, last_email_typed_on_login={typed_email!r}", + ) + judge.check( + "entered_account_password_on_login", + any(normalize_text(value) == normalize_text(BENCHMARK_PASSWORD) for value in login_inputs), + "the benchmark password must be typed on /login", + ) + + +def check_visited_path(judge: Judge, trajectory: dict[str, Any], name: str, path: str | re.Pattern[str]) -> bool: + described = f"/{path.pattern}/" if isinstance(path, re.Pattern) else path + return judge.check(name, navigated_to_path(trajectory, path), f"required_path={described}") + + +# --------------------------------------------------------------------------- # +# SQLite state +# --------------------------------------------------------------------------- # +def db_query(db_path: str | os.PathLike[str], sql: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]: + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + try: + return connection.execute(sql, params).fetchall() + finally: + connection.close() + + +def fetch_db(container: str, kind: str) -> str: + if kind not in {"instance", "instance_seed"}: + raise ValueError(f"unsupported DB kind: {kind}") + handle, destination = tempfile.mkstemp(prefix=f"{SITE}_{kind}_", suffix=".db") + os.close(handle) + source = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + result = subprocess.run(["docker", "cp", source, destination], capture_output=True, text=True) + if result.returncode: + Path(destination).unlink(missing_ok=True) + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"could not copy {source}: {detail}") + atexit.register(Path(destination).unlink, missing_ok=True) + return destination + + +def resolve_db(explicit_path: str | None, container: str, kind: str) -> str | None: + if explicit_path: + path = Path(explicit_path) + return str(path) if path.is_file() else None + try: + return fetch_db(container, kind) + except (OSError, RuntimeError): + return None + + +def _schema_objects(db_path: str) -> list[tuple[Any, ...]]: + return [ + tuple(row) + for row in db_query( + db_path, + "SELECT type, name, tbl_name, sql FROM sqlite_schema " + "WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type, name", + ) + ] + + +def catalog_fingerprint(db_path: str | os.PathLike[str]) -> str: + """Row-level fingerprint of the nine seeded tables. + + Recipe (fixed; the pinned value in this module was produced by it): + + payload = [[table, [dict(row) for row in SELECT * FROM table ORDER BY id]] + for table in ALL_TABLES] + sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode()).hexdigest() + + It is computed on ``initial.db`` only, so a runtime write can never mask a + seed change. + """ + payload = [ + [table, [dict(row) for row in db_query(db_path, f"SELECT * FROM {table} ORDER BY id")]] + for table in ALL_TABLES + ] + blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(blob.encode()).hexdigest() + + +def _validate_snapshot_contract(initial_db: str, after_db: str) -> None: + table_sql = "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" + initial_tables = {row["name"] for row in db_query(initial_db, table_sql)} + after_tables = {row["name"] for row in db_query(after_db, table_sql)} + if initial_tables != EXPECTED_TABLES or after_tables != EXPECTED_TABLES: + raise ValueError(f"unexpected tables: initial={sorted(initial_tables)}, after={sorted(after_tables)}") + initial_schema = _schema_objects(initial_db) + if initial_schema != _schema_objects(after_db): + raise ValueError("initial and after database schemas differ") + schema_hash = hashlib.sha256(json.dumps(initial_schema, separators=(",", ":")).encode()).hexdigest() + if schema_hash != SCHEMA_HASH: + raise ValueError(f"unsupported UC Berkeley schema hash: {schema_hash}") + observed = {table: len(table_rows(initial_db, table)) for table in EXPECTED_COUNTS} + if observed != EXPECTED_COUNTS: + raise ValueError(f"initial database counts differ: expected={EXPECTED_COUNTS}, observed={observed}") + fingerprint = catalog_fingerprint(initial_db) + if fingerprint != CATALOG_FINGERPRINT: + raise ValueError( + f"catalog fingerprint differs from the pinned seed: {fingerprint}; " + "re-freeze the seed contract before grading" + ) + changed = [table for table in IMMUTABLE_TABLES if table_rows(initial_db, table) != table_rows(after_db, table)] + if changed: + raise ValueError(f"immutable catalog tables changed: {changed}") + + +def resolve_snapshots(args: VerifyArgs, task_id: str) -> tuple[str, str]: + """Return validated (initial_db, after_db) snapshots or fail closed.""" + initial_db = resolve_db(args.initial_db, args.container, "instance_seed") + after_db = resolve_db(args.after_db, args.container, "instance") + if not initial_db or not after_db: + fail_closed( + task_id, + "database_unavailable", + "both initial and after berkeley database snapshots are required", + ) + try: + _validate_snapshot_contract(str(initial_db), str(after_db)) + from ground_truth import task_ground_truth + task_number = int(task_id.rsplit("--", 1)[1]) + task_ground_truth(str(initial_db), task_number) + except (ImportError, OSError, sqlite3.Error, ValueError) as exc: + fail_closed(task_id, "snapshot_contract_invalid", str(exc)) + return str(initial_db), str(after_db) + + +def table_rows(db_path: str, table: str) -> list[tuple[Any, ...]]: + if not re.fullmatch(r"[a-z_]+", table): + raise ValueError(f"unsupported table: {table}") + return [tuple(row) for row in db_query(db_path, f"SELECT * FROM {table} ORDER BY 1")] + + +def rows_where(db_path: str, table: str, **filters: Any) -> list[dict[str, Any]]: + if not re.fullmatch(r"[a-z_]+", table): + raise ValueError(f"unsupported table: {table}") + clauses, params = [], [] + for column, value in filters.items(): + if not re.fullmatch(r"[a-z_0-9]+", column): + raise ValueError(f"unsupported column: {column}") + clauses.append(f"{column} = ?") + params.append(value) + where = f" WHERE {' AND '.join(clauses)}" if clauses else "" + return [dict(row) for row in db_query(db_path, f"SELECT * FROM {table}{where} ORDER BY 1", params)] + + +def table_delta(initial_db: str, after_db: str, table: str) -> dict[str, list[Any]]: + before = {row[0]: row for row in table_rows(initial_db, table)} + after = {row[0]: row for row in table_rows(after_db, table)} + common = before.keys() & after.keys() + return { + "added": [after[key] for key in sorted(after.keys() - before.keys())], + "removed": [before[key] for key in sorted(before.keys() - after.keys())], + "changed": [(before[key], after[key]) for key in sorted(common) if before[key] != after[key]], + } + + +def new_table_rows(initial_db: str, after_db: str, table: str) -> list[dict[str, Any]]: + """Rows present in ``after`` whose primary key is absent from ``initial`` (as dicts).""" + initial_ids = {row[0] for row in table_rows(initial_db, table)} + return [row for row in rows_where(after_db, table) if list(row.values())[0] not in initial_ids] + + +def tables_unchanged(initial_db: str, after_db: str, tables: Iterable[str]) -> dict[str, bool]: + return {table: table_rows(initial_db, table) == table_rows(after_db, table) for table in tables} + + +def check_tables_unchanged(judge: Judge, initial_db: str, after_db: str, tables: Iterable[str], prefix: str = "") -> None: + """One ``_unchanged`` check per table.""" + for table, same in tables_unchanged(initial_db, after_db, tables).items(): + judge.check( + f"{prefix}{table}_unchanged", + same, + f"table={table}, initial_rows={len(table_rows(initial_db, table))}, " + f"after_rows={len(table_rows(after_db, table))}, identical={same}", + ) + + +def check_read_only(judge: Judge, initial_db: str, after_db: str) -> None: + """Read-only tasks: every seeded table must be row-identical. + + No GET path writes the DB (the article view counter was removed in commit 1), + so this is strict — there is no column-level whitelist. + """ + check_tables_unchanged(judge, initial_db, after_db, READ_ONLY_TABLES, prefix="read_only_") + + +def check_exact_delta( + judge: Judge, initial_db: str, after_db: str, table: str, added: int = 0, removed: int = 0, changed: int = 0 +) -> dict[str, list[Any]]: + delta = table_delta(initial_db, after_db, table) + judge.check( + f"{table}_exact_delta", + len(delta["added"]) == added and len(delta["removed"]) == removed and len(delta["changed"]) == changed, + f"expected added={added} removed={removed} changed={changed}; delta={delta!r}", + ) + return delta + + +def user_id_for_email(db_path: str, email: str) -> int | None: + rows = db_query(db_path, "SELECT id FROM users WHERE lower(email) = lower(?) ORDER BY id LIMIT 1", (email,)) + return int(rows[0]["id"]) if rows else None + + +def user_emails(db_path: str) -> set[str]: + return {normalize_text(row["email"]) for row in db_query(db_path, "SELECT email FROM users") if row["email"]} + + +def bookmark_rows(db_path: str, user_id: int | None = None) -> list[dict[str, Any]]: + if user_id is None: + return rows_where(db_path, "bookmarks") + return rows_where(db_path, "bookmarks", user_id=int(user_id)) + + +def bookmark_delta(initial_db: str, after_db: str, user_id: int | None = None) -> dict[str, list[Any]]: + """Bookmark row delta for one user (or all users when ``user_id`` is None).""" + before = {row["id"]: row for row in bookmark_rows(initial_db, user_id)} + after = {row["id"]: row for row in bookmark_rows(after_db, user_id)} + return { + "added": [after[key] for key in sorted(after.keys() - before.keys())], + "removed": [before[key] for key in sorted(before.keys() - after.keys())], + "changed": [(before[key], after[key]) for key in sorted(before.keys() & after.keys()) + if before[key] != after[key]], + } + + +def bookmark_identity(row: Any) -> tuple[int, str, int]: + """``(user_id, item_type, item_id)`` of a bookmark row.""" + return (int(row["user_id"]), str(row["item_type"]), int(row["item_id"])) + + +def check_bookmarks_delta( + judge: Judge, + initial_db: str, + after_db: str, + *, + user_id: int, + added: Sequence[Any] = (), + surviving_ids: Sequence[int] | None = None, +) -> dict[str, list[Any]]: + """Exact bookmark delta for one user plus the identities that must survive. + + ``added`` lists the expected ``(user_id, item_type, item_id)`` identities of + the added rows. ``surviving_ids`` pins the row ids that must still exist — + the ``--31`` ordering proof (a surviving row whose id is 2 can only exist if + the id-1 row was inserted and then deleted). + """ + delta = bookmark_delta(initial_db, after_db, user_id) + observed_added = sorted(bookmark_identity(row) for row in delta["added"]) + expected_added = sorted(tuple(identity) for identity in added) + judge.check( + "bookmarks_exact_delta", + len(delta["added"]) == len(expected_added) + and len(delta["removed"]) == 0 + and len(delta["changed"]) == 0 + and observed_added == expected_added, + f"expected added={expected_added} removed=[] changed=[]; observed added={observed_added!r} " + f"removed={[bookmark_identity(row) for row in delta['removed']]!r} " + f"changed={delta['changed']!r}", + ) + global_delta = table_delta(initial_db, after_db, "bookmarks") + judge.check( + "bookmarks_other_users_unchanged", + {row[0] for row in global_delta["added"]} == {row["id"] for row in delta["added"]} + and {row[0] for row in global_delta["removed"]} == {row["id"] for row in delta["removed"]} + and {pair[0][0] for pair in global_delta["changed"]} == {pair[0]["id"] for pair in delta["changed"]}, + f"bookmark rows changed outside user_id={user_id}: global_added={len(global_delta['added'])}, " + f"user_added={len(delta['added'])}, global_removed={len(global_delta['removed'])}, " + f"user_removed={len(delta['removed'])}, global_changed={len(global_delta['changed'])}, " + f"user_changed={len(delta['changed'])}", + ) + if surviving_ids is not None: + surviving = {int(row["id"]) for row in bookmark_rows(after_db, user_id)} + judge.check( + "bookmarks_surviving_row_ids", + surviving == {int(value) for value in surviving_ids}, + f"expected surviving_row_ids={sorted(int(v) for v in surviving_ids)}; observed={sorted(surviving)}", + ) + return delta diff --git a/websyn_start.sh b/websyn_start.sh index 941bf323..ee744a26 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -7,7 +7,7 @@ SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers - fedex webmd_doctor healthline kaggle nvidia) + fedex webmd_doctor healthline kaggle nvidia berkeley) BASE_PORT=40000 SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids