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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/explorer-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ env:
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 20
# 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; 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

Expand Down
74 changes: 50 additions & 24 deletions explorer.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -5751,21 +5753,31 @@ 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.
// 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 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
// 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; }
// 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; }
}

async function loadSubstrateMeta() {
Expand Down Expand Up @@ -5794,11 +5806,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();
Expand Down Expand Up @@ -6027,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…';
Expand All @@ -6041,10 +6057,17 @@ 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.
if (substrateEnabled()) {
// #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.
// 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);
Expand Down Expand Up @@ -6093,7 +6116,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
Expand Down
62 changes: 56 additions & 6 deletions tests/playwright/fts-v1.spec.js
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -13,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();
Expand Down Expand Up @@ -87,18 +94,61 @@ 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
// tests above all do; that asymmetry is the point of #171.)
test.setTimeout(240_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 }) => {
// ?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 });
await page.fill('#sampleSearch', 'basalt');
await page.locator('#searchSubmitBtn').first().click();
await page.waitForFunction(() =>
window.__searchFilter && window.__searchFilter.active, null, { timeout: 150_000 });
const substrate = await page.evaluate(() => window.__searchFilter.substrate);
expect(substrate).toBeFalsy();
});

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 });
// 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.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
});
});
Loading