Slice A v3 — handoff for the next session
Written 2026-06-17 ~23:50 after the v2 apply pass produced a net regression and was reverted. This doc is the next session's starting point — what failed, why, and exactly what to do.
TL;DR
The v2 image-source resolver chain (scrapers, vision-judge, multi-tier registry) is built and good. The Slice A v2 apply run that shipped 707 swaps tonight produced more regressions than wins because of three resolver bugs none of the guards caught. The whole apply was reverted (7856dac80). 4 truly-empty mission heroes (lucy / europa-clipper / parker-solar-probe / solar-orbiter) were fixed manually (06f71b846) by copying their fleet-galleries content into static/images/missions/[id]/01.jpg. Everything else is back to the pre-tonight baseline. The infra is preserved in repo for v3.
Current state (HEAD on origin/main)
- Image bytes: all 1,416 files restored to pre-milestone-2 state (
c2c102c54). - 4 mission heroes added in
missions/[id]/: lucy, europa-clipper, parker-solar-probe, solar-orbiter (copies of their fleet-galleries/[id]/01.jpg). - Sidecars (
mission-image-sources.json,fleet-image-sources.json,panel-image-sources.json,mission-galleries.json): pre-tonight. - Validators (
validate-image-dupes.ts,validate-image-phash-dupes.ts): pre-tonight — the 118 byte-dupe + 28 pHash allowlist entries I added tonight are pulled back out. - Provenance manifest (
static/data/image-provenance.json): pre-tonight. panel-esahubble-sources.json: deleted.build-image-provenance.ts: pre-milestone-3 (no esahubble override branch).
What's still in repo and ready to use for v3
| Asset | Path | What it is |
|---|---|---|
| Multi-tier registry | static/data/agency-archives.json | Per-agency primary chains (NASA / ESA / Roscosmos / CNSA / JAXA / SpaceX / ISRO / Blue Origin / Northrop Grumman / SpaceIL / UAESA / USSF) + tier 2 institutional + tier 3 Commons fallback |
| Resolver chain | scripts/lib/agency-resolver.mjs | resolveAgencyImage({ mission, slot, agency, query }) walks tier 1 → tier 2 → tier 3, returns first relevant hit. Scrapers built: esahubble.org / esa.int/ESA_Multimedia / sci.esa.int per-mission / Smithsonian Open Access / NARA / USGS / Flickr public scrape / Wikimedia Commons category lookup / Wikimedia Commons search failover |
| Relevance gate | scripts/lib/relevance-gate.mjs | Per-source threshold, word-boundary anti-tokens, geographic / museum / patch / mockup filters |
| Vision-judge | scripts/lib/vision-judge.mjs | Claude Haiku 4.5 vision API wrapper, HTTPS-upgrade for NASA-HTTP URLs, returns { verdict: related/unrelated/unsure, confidence, reason } |
| Dry-run scripts | scripts/slice-a-dryrun.mjs (generic, --agency=[token]) + scripts/slice-a-vision-pass.mjs + scripts/slice-a-apply.mjs + scripts/slice-explore-{dryrun,vision,apply}.mjs | |
| Dry-run baseline | static/data/slice-a-[agency]-dryrun.json (×13) + static/data/slice-explore-dryrun.json | Per-proposal output of the v2 chain — has query, proposed.source_url, proposed.image_url, vision.verdict, vision.confidence, vision.reason. Use these as the input to v3 — don't re-run dry-runs from scratch. |
| Credits routing | src/lib/credits-grouping.ts | Source-id mappings for esahubble / esa-multimedia / sci-esa-int / smithsonian / nara / jaxa / *-flickr added today |
| Source logos | static/data/source-logos.json | Smithsonian + NARA entries added |
What failed and why — the three root causes
Root cause 1 — Resolver has no per-slot diversification
For each (mission, slot) query, the resolver issues the SAME search and returns the SAME top hit. So for a 5-slot mission gallery, slots 01-05 all get identical bytes. Examples that shipped tonight:
- lro: all 5 slots →
PIA16602(a 367×268, 8.4 KB GRAIL photo with LRO as a dot in the frame, not a portrait of LRO) - OTV-1 through OTV-6: all 30 slots → same Hubble Space Telescope Commons file (
Hubble 2009 close-up.jpg) because "OTV" matched Hubble's original codename for one batch - Vega-1 / Vega-2: all 10 slots → same Vega launch frame
- Vostok-1 / 2 / 3: all 15 slots → same Vostok launch frame
- Gaganyaan: all 5 slots → same ISRO crew portrait
Fix: in agency-resolver.mjs, change each scraper to return the top-N (5) candidates instead of top-1. Add a pickCandidate(candidates, slot, alreadyTaken) layer that picks per-slot — by slot index, or vision-rank, or simple slot - 1 index into the candidate array. Aggregator dedupes across mission to guarantee no two slots of one mission resolve to the same source_url.
Root cause 2 — Vision-judge prompt was too permissive
vision-judge.mjs:SYSTEM_PROMPT asked "is this image substantively related to the mission?" That returned "related" for tangential content with 0.85 confidence. Examples that vision approved:
- lro/01 → GRAIL photo with LRO as a distant point ("LRO IS in the frame, so it's related")
- gaganyaan/01-05 → ISRO crew portrait ("Gaganyaan crew, so related")
- ulysses/03 → museum mockup ("Ulysses spacecraft model, so related")
- skylab-4/02 → crew portrait ("Skylab crew, so related")
The bar should be: "Is this a strong hero candidate for the mission's gallery on a public-facing space-mission website?"
Fix: rewrite SYSTEM_PROMPT in vision-judge.mjs. Add explicit rejection categories:
- distant frames where target is incidental
- crew portraits (unless launch-day or mission-defining moment)
- mockups / replicas / museum displays
- infographics / diagrams / charts
- event ceremonies / press conferences
- tangential thematic matches (e.g. "Hubble" satisfies an "OTV" query because both are spacecraft)
Require confidence ≥ 0.9 to ship instead of the current ≥ 0.5.
Root cause 3 — Walker schema doesn't match Slice A sidecar shape
build-image-provenance.ts:buildMissionCommonsSidecarEntries (line ~540) reads src.commons_file. Slice A sidecar shape uses src.source_type + src.image_url + src.source_url — no commons_file field. Result: the walker passes filename: undefined to buildWikimediaEntry, which emits image_url: "https://commons.wikimedia.org/wiki/Special:FilePath/undefined" for EVERY Slice A entry. The credits-page bundler keys on image_url → 30 OTV slots all share the bogus undefined URL → they all bundle into a single OTV bundle PER mission (so 6 bundles for the same Hubble photo, one per OTV mission). Worse, the manifest has no nasa_id / pageid / revid for any Slice A entry, so even after fixing diversification you'd still get bundle-by-route dupes.
Fix: teach the walker to read EITHER shape. If src.image_url is set and looks like a Commons Special:FilePath/[file].jpg, extract the filename from that URL. If src.image_url points at NASA images CDN (images-assets.nasa.gov/image/[nasa_id]/...), extract the nasa_id and call buildNasaEntry with it preserved (currently buildNasaEntry hardcodes nasa_id: null — fix that too). Same fix for the fleet sidecar walker (line ~1085).
(Plus root cause 0 — I used allowlists as band-aids)
Tonight I added 118 byte-dupe SHA hashes to validate-image-dupes.ts:ALLOWLIST and 28 pHash pairs to validate-image-phash-dupes.ts:INLINE_ALLOWLIST under benign comment labels (Slice A re-source — sibling-mission shared imagery). Without those allowlist additions the build would have failed and forced a real fix. With them, the build passed clean and 707 swaps shipped.
Fix: add a validate-data step that fails the build if more than 5 new allowlist entries land in a single commit unless the commit message contains an explicit ALLOWLIST_AUTHORIZED: token. Belt + suspenders.
Fix plan — order matters
Stage 1 — infra repairs (no image data changes, push directly)
- Walker schema sync (~40 LOC,
build-image-provenance.ts). Fixes credits-page false-dupes immediately. - Vision prompt tightening (~50 lines,
vision-judge.mjs). Test on 20 known-bad picks (lro/PIA16602, gaganyaan/team, otv/Hubble, ulysses/mockup, skylab-4/crew, …) — expect ≥18 to flip to "unrelated". - Allowlist guard (~30 LOC, new
scripts/validate-allowlist-discipline.ts+ wire intovalidate-data). - Cross-mission bundler (~60 LOC,
credits-grouping.ts). Bundle byimage_urlglobally before grouping by source — if the same legitimate Hubble photo appears as hero for 3 small missions, credits shows ONE card with the mission tags.
→ Commit + push as "fix(images): pipeline infra repairs". No visual change on site.
Stage 2 — resolver diversification (agency-resolver.mjs)
- Top-N candidates per scraper (~80 LOC). Each scraper returns up to 5 candidates ordered by relevance. New
pickPerSlot(candidates, slot, taken)layer atresolveAgencyImageselects one with no repeats within a mission. Smoke againstotv-1throughotv-6— expect 6 different source_urls (or honest misses where Commons has only 1 result).
→ Commit "feat(images): resolver per-slot diversification".
Stage 3 — salvage pass against existing dry-run JSONs
scripts/slice-a-salvage.mjs(~200 LOC). Inputs: the 13slice-a-[agency]-dryrun.jsonfiles already in repo. For eachship_at_apply=trueproposal:- Re-run
vision-judgewith the Stage 1 stricter prompt — drop "unrelated" and "unsure < 0.9" - Filter: no same-source_url across slots of one mission
- Filter: no same-source_url across ≥2 missions (kills X-37B↔OTV-1..6 case)
- Filter: size sanity — HEAD the image_url, require
content-length ≥ 50 KB; for files already on disk pre-apply, requirewidth ≥ 800 - Filter: mission name token (or body name token) present in the proposed Commons / NASA title
Output:
static/data/slice-a-salvage-result.jsonwith per-proposal verdict + drop reason. Expect ~80–200 survivors out of 707.- Re-run
Apply survivors only via
slice-a-apply.mjs(already works — it just reads the input JSON). Rebuild provenance. Validate-data should pass with NO new allowlist entries (the Stage 1 walker fix means real per-image identity, the Stage 2 diversification means no cross-slot dupes).
Stage 4 — review gate before push
- Generate a small HTML diff page (
docs/provenance/slice-a-v3-review.html) showing OLD vs NEW image per survivor, with the mission ID, source_url, vision verdict + reason. Marko spot-checks 20 random survivors. - If ≥1 looks wrong, tighten Stage 1/2/3 filters. If all clean → commit + push as "feat(images): Slice A v3 — recovered wins with strict gates".
Estimated effort
| Stage | Effort | Risk |
|---|---|---|
| Stage 1 (infra) | 1–1.5 hours | Low — bounded, testable |
| Stage 2 (diversification) | 1–1.5 hours | Medium — touches every scraper |
| Stage 3 (salvage) | 1.5–2 hours + ~$0.30 vision API | Medium — many edge cases |
| Stage 4 (review gate) | 30 min build + Marko review | Low |
| Total | 4–6 hours | — |
API cost: ~$0.30 in Haiku vision calls re-judging ~600 proposals.
What NOT to do next session
- ❌ Don't re-run the agency dry-runs from scratch. The 13 JSONs already in repo are the input.
- ❌ Don't add ANY entries to
validate-image-dupes.tsALLOWLIST orvalidate-image-phash-dupes.tsINLINE_ALLOWLIST until Stage 1+2 are done. If the build fails on dupes, the resolver / diversification still isn't right. - ❌ Don't delete
static/images/missions/[id]/files trusting the runtime fallback — the /missions list page does NOT fall through. Either add the cross-surface fallback to the list page (separate task) or always populatemissions/[id]/01.jpg. - ❌ Don't ship without spot-checking. The vision-judge alone caught 36% of tonight's bad swaps before apply; the human eye catches the next layer.
Quick reference — files to read first
scripts/lib/agency-resolver.mjs— current chainscripts/lib/vision-judge.mjs— current promptscripts/build-image-provenance.ts— walker (buildMissionCommonsSidecarEntries~540, fleet walker ~1080,buildNasaEntry~332)src/lib/credits-grouping.ts— bundler (bundlePhotos~95,reliableImageId~80)static/data/slice-a-nasa-dryrun.json— biggest dry-run sample (382 proposals)docs/provenance/SLICE-A-RESUME-NOTES.md— earlier handoff (pre-apply)
Status: paused 2026-06-17 23:50. Push of 06f71b846 (4-mission fix) in flight. Preview will reflect the revert + fix within ~15 min of push landing.