From 209e5da8776c57cc9b750f55b161812361b64b8a Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 17 Jul 2026 06:23:08 -0700 Subject: [PATCH 1/4] #172: flip substrate search to default; ?fts=off escape hatch Ship decision 2026-07-17 (RY): most recent progress ships unless obviously broken, with a flag back to the prior path. Rationale: - Substrate wins every benchmark row vs interim (#171 post-fix table, e.g. no-hit 62.7s -> 5.5s cold; pottery 22.3s -> 11.6s). - All #172 hard-fail invariants pass (stopword equivalence, duplicate-term identity, concept-only non-empty, no-hit zero, tokenizer parity). - Hand review of benchmark top-10s (2026-07-17 AM) found no glaring ranking regressions; SME A/B comparison sheet goes out today as the post-ship ranking-quality input (calibrates #172 thresholds; BM25/field weights tunable post-ship without index format changes). Mechanics: - substrateEnabled(): default true; only `?fts=off` reverts to the interim ILIKE scan (kept intact as rollback + A/B baseline). `?fts=v1` still accepted, so pre-flip opt-in links behave identically. - Spec: 'no param -> interim' becomes 'fts=off -> interim' (escape hatch), plus a new 'no param -> substrate' test asserting the flip (basalt 785). The #172 gate does NOT close: Section 7 end-to-end budgets remain open as the pin-overlay / downstream-latency track (both backends miss them today; the dominant cost is shared post-search refiltering, not the backend). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y --- explorer.qmd | 22 +++++++++++++++------- tests/playwright/fts-v1.spec.js | 24 ++++++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 8f233f3..14ea5c8 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -5751,21 +5751,29 @@ zoomWatcher = { } } - // ===== #171: substrate search path (feature flag ?fts=v1) ===== + // ===== #171: substrate search path (default; escape hatch ?fts=off) ===== // Replaces buildSearchFilter's 69 MB facets ILIKE scan with the // sharded inverted index at data.isamples.org/isamples_202608_search_index_v1/ // (built by tools/build_search_index.py, contract SEARCH_INDEX_V1.md). // Everything downstream is UNCHANGED: this produces the same singleton // `search_pids` table (pid, label, source, place_name, relevance_score), // so the table/points/facet-count semi-joins and the side panel work - // exactly as with the interim path. Opt-in only until the #172 gate. + // exactly as with the interim path. + // DEFAULT ON as of 2026-07-17 (#172 ship decision): the substrate wins + // every benchmark row vs the interim path (e.g. no-hit 62.7s → 5.5s), + // all hard-fail invariants pass, and a hand review of the benchmark + // top-10s found no glaring ranking regressions. `?fts=off` reverts to + // the interim ILIKE scan — kept intact as the rollback path and for + // SME A/B ranking comparison (see #172); `?fts=v1` remains accepted so + // pre-flip opt-in links behave identically. §7 end-to-end budgets stay + // open in #172 as the pin-overlay / downstream-latency track. const SUBSTRATE_BASE = `${R2_BASE}/isamples_202608_search_index_v1`; let _substrateMeta = null; function substrateEnabled() { try { - return new URLSearchParams(location.search).get('fts') === 'v1'; - } catch (e) { return false; } + return new URLSearchParams(location.search).get('fts') !== 'off'; + } catch (e) { return true; } } async function loadSubstrateMeta() { @@ -6041,9 +6049,9 @@ zoomWatcher = { // empty-results branch say "Search error" rather than "No results". let searchFilterBuildFailed = false; try { - // #171: ?fts=v1 routes to the sharded-substrate path; both - // producers emit the identical `search_pids` singleton, so - // everything downstream is shared. + // #171/#172: the sharded-substrate path is the default; ?fts=off + // reverts to the interim scan. Both producers emit the identical + // `search_pids` singleton, so everything downstream is shared. if (substrateEnabled()) { await buildSearchFilterSubstrate(term); } else { diff --git a/tests/playwright/fts-v1.spec.js b/tests/playwright/fts-v1.spec.js index b18a0f0..74e3681 100644 --- a/tests/playwright/fts-v1.spec.js +++ b/tests/playwright/fts-v1.spec.js @@ -87,12 +87,14 @@ test.describe('substrate search (?fts=v1)', () => { null, { timeout: 60_000 }); }); - test('default path untouched: no fts param → interim search', async ({ page }) => { - // The INTERIM path cold-downloads the 69 MB facets parquet — the one - // test here that can't fit the 30 s config default. (The five substrate + test('escape hatch: ?fts=off → interim search', async ({ page }) => { + // Default flipped to substrate 2026-07-17 (#172 ship decision); ?fts=off + // is the rollback path back to the interim ILIKE scan, which + // cold-downloads the 69 MB facets parquet — the one test here that + // can't fit the 30 s config default. (The five substrate // tests above all do; that asymmetry is the point of #171.) test.setTimeout(240_000); - await page.goto(`${BASE}/explorer.html`, { timeout: 90_000 }); + await page.goto(`${BASE}/explorer.html?fts=off`, { timeout: 90_000 }); await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); await page.fill('#sampleSearch', 'basalt'); await page.locator('#searchSubmitBtn').first().click(); @@ -101,4 +103,18 @@ test.describe('substrate search (?fts=v1)', () => { const substrate = await page.evaluate(() => window.__searchFilter.substrate); expect(substrate).toBeFalsy(); }); + + test('default: no fts param → substrate search', async ({ page }) => { + // The flip itself (#172 ship decision): a plain URL now routes to the + // substrate path. Mirrors the ?fts=v1 boot flow, minus the param. + await page.goto(`${BASE}/explorer.html`, { timeout: 90_000 }); + await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); + await page.fill('#sampleSearch', 'basalt'); + await page.locator('#searchSubmitBtn').first().click(); + await page.waitForFunction(() => + window.__searchFilter && window.__searchFilter.substrate === true, + null, { timeout: 120_000 }); + const total = await page.evaluate(() => window.__searchFilter.total); + expect(total).toBe(785); // == 'basalt' ground truth for the 202608 index + }); }); From 5332e89decc9538e6007ef1b21eacdaa668975ff Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 17 Jul 2026 06:33:17 -0700 Subject: [PATCH 2/4] #172 flip, Codex round 1: PID routing + rollback/retry hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: PID-looking queries (ARK/IGSN/DOI/resolver/pid:) route to the interim builder even under default substrate — the substrate index has no PID field (SEARCH_INDEX_V1 s2), so tokenizing a pasted identifier returned junk. Interim's exact-PID arm (#278/#26) handles it. New no-flag Playwright regression using a PID from the interim benchmark's own top-10. - P2: ?fts=off now dominant over duplicate params (getAll, not first-wins). - P2: a REJECTED substrate module import resets the cache so the next search retries instead of failing forever on one transient hiccup. - P2: routing tests moved to their own describe (no double cold-boot via the ?fts=v1 beforeEach); stale opt-in comments updated; "exactly as interim" overstatement corrected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y --- explorer.qmd | 33 +++++++++++++------ tests/playwright/fts-v1.spec.js | 56 ++++++++++++++++++++++++--------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 14ea5c8..607fea0 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -5755,10 +5755,10 @@ zoomWatcher = { // Replaces buildSearchFilter's 69 MB facets ILIKE scan with the // sharded inverted index at data.isamples.org/isamples_202608_search_index_v1/ // (built by tools/build_search_index.py, contract SEARCH_INDEX_V1.md). - // Everything downstream is UNCHANGED: this produces the same singleton - // `search_pids` table (pid, label, source, place_name, relevance_score), - // so the table/points/facet-count semi-joins and the side panel work - // exactly as with the interim path. + // Downstream shares one contract: both producers emit the singleton + // `search_pids` table (pid, label, source, place_name, relevance_score); + // the semi-join surfaces are common, with substrate-specific branches + // where display columns arrive null (backfilled from the wide parquet). // DEFAULT ON as of 2026-07-17 (#172 ship decision): the substrate wins // every benchmark row vs the interim path (e.g. no-hit 62.7s → 5.5s), // all hard-fail invariants pass, and a hand review of the benchmark @@ -5772,7 +5772,9 @@ zoomWatcher = { function substrateEnabled() { try { - return new URLSearchParams(location.search).get('fts') !== 'off'; + // getAll: `off` dominates even with duplicate params + // (?fts=v1&fts=off) — a rollback switch must win ties. + return !new URLSearchParams(location.search).getAll('fts').includes('off'); } catch (e) { return true; } } @@ -5802,11 +5804,15 @@ zoomWatcher = { async function buildSearchFilterSubstrate(term) { const token = ++_searchFilterToken; window.a1dbg?.('substrate-search-start', { term, token }); - // Lazy module import (Codex review): flag-off visitors must not + // Lazy module import (Codex review): ?fts=off visitors must not // download/evaluate the substrate modules, and a module-load failure - // must not be able to kill the default search cell — so the import - // happens HERE, on first flagged search, not as an OJS dependency. - _substrateModPromise ??= import(new URL('assets/js/search_substrate.js', document.baseURI).href); + // must not be able to kill the search cell — so the import happens + // HERE, on first substrate search, not as an OJS dependency. A + // REJECTED import resets the cache so the next search retries + // instead of failing forever on one transient network hiccup + // (Codex P2 on PR #335 — matters now that substrate is the default). + _substrateModPromise ??= import(new URL('assets/js/search_substrate.js', document.baseURI).href) + .catch(e => { _substrateModPromise = null; throw e; }); const _searchSubstrate = await _substrateModPromise; if (token !== _searchFilterToken) return false; // superseded during import const meta = await loadSubstrateMeta(); @@ -6052,7 +6058,14 @@ zoomWatcher = { // #171/#172: the sharded-substrate path is the default; ?fts=off // reverts to the interim scan. Both producers emit the identical // `search_pids` singleton, so everything downstream is shared. - if (substrateEnabled()) { + // PID-looking queries (ARK / IGSN / DOI / resolver URL / pid:) + // always take the interim builder: the substrate index carries + // only text fields (SEARCH_INDEX_V1 §2 — label/description/place/ + // concept labels), so tokenizing a pasted identifier would return + // zero or unrelated matches. The interim path has the exact-PID + // arm (#278/#26). Rare query class — paying its cold cost beats + // silently wrong results. (Codex P1 on PR #335.) + if (substrateEnabled() && !terms.some(t => looksLikePid(t))) { await buildSearchFilterSubstrate(term); } else { await buildSearchFilter(terms, term); diff --git a/tests/playwright/fts-v1.spec.js b/tests/playwright/fts-v1.spec.js index 74e3681..3b0055c 100644 --- a/tests/playwright/fts-v1.spec.js +++ b/tests/playwright/fts-v1.spec.js @@ -1,4 +1,5 @@ -// #171: substrate search path (?fts=v1) — end-to-end against the published +// #171/#172: substrate search path (DEFAULT since 2026-07-17; ?fts=off +// escape hatch, ?fts=v1 still accepted) — end-to-end against the published // index at data.isamples.org/isamples_202608_search_index_v1/. // // Run: BASE_URL=https://rdhyee.github.io/isamplesorg.github.io \ @@ -87,12 +88,31 @@ test.describe('substrate search (?fts=v1)', () => { null, { timeout: 60_000 }); }); +}); + +// #172 default-flip routing: these tests each boot their own URL, so they +// live OUTSIDE the ?fts=v1 describe — its beforeEach would cold-boot a page +// they immediately re-navigate away from (Codex P2 on PR #335). +test.describe('default-flip routing (#172)', () => { + + test('default: no fts param → substrate search', async ({ page }) => { + // The flip itself (#172 ship decision): a plain URL now routes to the + // substrate path. Mirrors the ?fts=v1 boot flow, minus the param. + test.setTimeout(150_000); + await page.goto(`${BASE}/explorer.html`, { timeout: 90_000 }); + await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); + await page.fill('#sampleSearch', 'basalt'); + await page.locator('#searchSubmitBtn').first().click(); + await page.waitForFunction(() => + window.__searchFilter && window.__searchFilter.substrate === true, + null, { timeout: 120_000 }); + const total = await page.evaluate(() => window.__searchFilter.total); + expect(total).toBe(785); // == 'basalt' ground truth for the 202608 index + }); + test('escape hatch: ?fts=off → interim search', async ({ page }) => { - // Default flipped to substrate 2026-07-17 (#172 ship decision); ?fts=off - // is the rollback path back to the interim ILIKE scan, which - // cold-downloads the 69 MB facets parquet — the one test here that - // can't fit the 30 s config default. (The five substrate - // tests above all do; that asymmetry is the point of #171.) + // ?fts=off is the rollback path back to the interim ILIKE scan, which + // cold-downloads the 69 MB facets parquet — hence the big budget. test.setTimeout(240_000); await page.goto(`${BASE}/explorer.html?fts=off`, { timeout: 90_000 }); await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); @@ -104,17 +124,25 @@ test.describe('substrate search (?fts=v1)', () => { expect(substrate).toBeFalsy(); }); - test('default: no fts param → substrate search', async ({ page }) => { - // The flip itself (#172 ship decision): a plain URL now routes to the - // substrate path. Mirrors the ?fts=v1 boot flow, minus the param. + test('PID query routes to interim even under default substrate', async ({ page }) => { + // Codex P1 on PR #335: the substrate index has no PID field (SEARCH_INDEX_V1 + // §2), so a pasted ARK/IGSN/DOI must take the interim builder's exact-PID + // arm (#278/#26) — under the DEFAULT (no-flag) URL. Interim cold cost → + // big budget. + test.setTimeout(240_000); await page.goto(`${BASE}/explorer.html`, { timeout: 90_000 }); await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); - await page.fill('#sampleSearch', 'basalt'); + // PID chosen from the interim benchmark's own top-10 (pottery), so it is + // guaranteed present in the facets parquet the interim path scans. + await page.fill('#sampleSearch', 'ark:/28722/k2000hz7r'); await page.locator('#searchSubmitBtn').first().click(); await page.waitForFunction(() => - window.__searchFilter && window.__searchFilter.substrate === true, - null, { timeout: 120_000 }); - const total = await page.evaluate(() => window.__searchFilter.total); - expect(total).toBe(785); // == 'basalt' ground truth for the 202608 index + window.__searchFilter && window.__searchFilter.active, null, { timeout: 150_000 }); + const r = await page.evaluate(() => ({ + substrate: window.__searchFilter.substrate, + total: window.__searchFilter.total, + })); + expect(r.substrate).toBeFalsy(); // interim path handled it + expect(r.total).toBeGreaterThan(0); // exact-PID arm found the sample }); }); From f3e7b8d59b1047346e737b7ddab5fdaf662012f3 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 17 Jul 2026 06:41:00 -0700 Subject: [PATCH 3/4] #172 flip, Codex round 2: CI budget + comment accuracy - explorer-e2e dispatch job cap 20 -> 35 min: fts-v1 spec worst-case is 20m30s of test budget alone (5x120s substrate, 150s default, 2x240s interim-path routing) before install/render; a slow dispatch run was killable mid-suite with no diagnostics. - Contract comment: display-column backfill joins samples_map_lite, not the wide parquet. - Interim builder comment: #171 landed and is default; this path now serves ?fts=off rollback + PID-looking queries, not "goes away". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y --- .github/workflows/explorer-e2e.yml | 6 +++++- explorer.qmd | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/explorer-e2e.yml b/.github/workflows/explorer-e2e.yml index d9f596c..16b9e57 100644 --- a/.github/workflows/explorer-e2e.yml +++ b/.github/workflows/explorer-e2e.yml @@ -67,7 +67,11 @@ env: jobs: smoke: runs-on: ubuntu-latest - timeout-minutes: 20 + # 35: the auto smoke set fits well under 20, but workflow_dispatch runs of + # the long data-dependent specs (fts-v1: 5x120s substrate + 150s + 2x240s + # interim-path routing = 20m30s worst-case per attempt, before install and + # render) were killable at 20 (Codex P2, PR #335). + timeout-minutes: 35 steps: - uses: actions/checkout@v4 diff --git a/explorer.qmd b/explorer.qmd index 607fea0..3b5d31d 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -5758,7 +5758,7 @@ zoomWatcher = { // Downstream shares one contract: both producers emit the singleton // `search_pids` table (pid, label, source, place_name, relevance_score); // the semi-join surfaces are common, with substrate-specific branches - // where display columns arrive null (backfilled from the wide parquet). + // where display columns arrive null (backfilled via samples_map_lite). // DEFAULT ON as of 2026-07-17 (#172 ship decision): the substrate wins // every benchmark row vs the interim path (e.g. no-hit 62.7s → 5.5s), // all hard-fail invariants pass, and a hand review of the benchmark @@ -6114,7 +6114,10 @@ zoomWatcher = { // This is NOT the FTS solution. The proper substrate work in // #169-#172 builds a sample-centric document projection with // hash-partitioned BM25 indexes that fixes both recall AND - // latency. This code path goes away when #171 lands. + // latency. #171 HAS landed and is the default (#172 flip, + // 2026-07-17): this interim builder now serves ?fts=off rollback + // and PID-looking queries (exact-PID arm — the substrate index + // has no PID field), so it stays until the substrate grows one. // // The search-term match + relevance score are already materialized // in `search_pids` by buildSearchFilter (aliased `s` below), so the From 2d587c749ca8bff729925c9c9a69693b2ec86feb Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Fri, 17 Jul 2026 06:45:42 -0700 Subject: [PATCH 4/4] #172 flip, Codex round 3: retry envelope + last stale comments - fts-v1 spec caps itself to 1 CI retry (file-level configure): 2 attempts x 20m30s = 41m envelope; workflow dispatch cap 35 -> 55 min so retries and artifact upload survive a data outage. - A1 strategy header and warm-buffer comments now state the substrate default vs interim (?fts=off / PID) split instead of universal ILIKE. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y --- .github/workflows/explorer-e2e.yml | 9 +++++---- explorer.qmd | 18 ++++++++++-------- tests/playwright/fts-v1.spec.js | 6 ++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/explorer-e2e.yml b/.github/workflows/explorer-e2e.yml index 16b9e57..4983c33 100644 --- a/.github/workflows/explorer-e2e.yml +++ b/.github/workflows/explorer-e2e.yml @@ -67,11 +67,12 @@ env: jobs: smoke: runs-on: ubuntu-latest - # 35: the auto smoke set fits well under 20, but workflow_dispatch runs of + # 55: the auto smoke set fits well under 20, but workflow_dispatch runs of # the long data-dependent specs (fts-v1: 5x120s substrate + 150s + 2x240s - # interim-path routing = 20m30s worst-case per attempt, before install and - # render) were killable at 20 (Codex P2, PR #335). - timeout-minutes: 35 + # interim-path routing = 20m30s worst-case per attempt; that spec caps + # itself to 1 CI retry -> 41m envelope) need room for 2 attempts plus + # install/render before the cap kills artifact upload (Codex P2, PR #335). + timeout-minutes: 55 steps: - uses: actions/checkout@v4 diff --git a/explorer.qmd b/explorer.qmd index 3b5d31d..36599ac 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -5559,10 +5559,12 @@ zoomWatcher = { // === A1: search-as-global-filter pid-set (#234 Step 4) === // // Strategy B: materialize the set of pids matching the committed search - // term ONCE (one ILIKE scan over facets_url), then let every count - // surface (table, points, facet legend, stats) constrain itself with a - // cheap `pid IN (SELECT pid FROM search_pids)` semi-join — instead of - // re-running the expensive ILIKE per surface per camera move. + // term ONCE (substrate index probe by default since the #172 flip; one + // ILIKE scan over facets_url on the ?fts=off / PID-query interim path), + // then let every count surface (table, points, facet legend, stats) + // constrain itself with a cheap `pid IN (SELECT pid FROM search_pids)` + // semi-join — instead of re-running the expensive scan per surface per + // camera move. // // `search_pids` is a NON-TEMP table: Observable's DuckDBClient opens a // fresh connection per db.query(), so a connection-local TEMP table would @@ -6041,10 +6043,10 @@ zoomWatcher = { // A1 (#234 Step 4): materialize the search pid-set ONCE, then refresh // the table (and, in later increments, points / facet counts / stats) // so every count surface constrains to this search via a cheap - // semi-join. One ILIKE scan here warms the text columns in the DuckDB - // buffer, so the LIMIT-50 side-panel query below reads them warm. - // (Follow-up: derive the side-panel list from search_pids to drop the - // second scan entirely.) Failure leaves surfaces unfiltered, not broken. + // semi-join. On the interim path (?fts=off / PID queries) the ILIKE + // scan warms the text columns in the DuckDB buffer; the substrate + // default probes the sharded index instead and touches no facets + // columns. Failure leaves surfaces unfiltered, not broken. const buildingMsg = effectiveScope === 'area' ? 'Building search filter for selected areas…' : 'Building search filter…'; diff --git a/tests/playwright/fts-v1.spec.js b/tests/playwright/fts-v1.spec.js index 3b0055c..44e071f 100644 --- a/tests/playwright/fts-v1.spec.js +++ b/tests/playwright/fts-v1.spec.js @@ -14,6 +14,12 @@ const { test, expect } = require('@playwright/test'); const BASE = process.env.BASE_URL || 'https://rdhyee.github.io/isamplesorg.github.io'; const URL = `${BASE}/explorer.html?fts=v1`; +// This file's worst-case budget is ~20m30s per attempt; the config's CI +// default of 2 retries would put the envelope (3 attempts, 1 worker) far past +// the dispatch job cap. One retry keeps flake protection while fitting the +// 55-minute cap (Codex P2 x2, PR #335). +test.describe.configure({ retries: process.env.CI ? 1 : 0 }); + async function bootAndSearch(page, term) { await page.fill('#sampleSearch', term); await page.locator('#searchSubmitBtn').first().click();