Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82b71b3
fix: restore overwritten search features
BigSimmo Aug 12, 2026
9585517
fix(search): address ticker and fuzzy ranking review
BigSimmo Aug 12, 2026
8452532
Merge main and resolve PR 1851 conflicts
BigSimmo Aug 12, 2026
07481f9
test(search): run submitted-root ticker regression in critical CI
BigSimmo Aug 12, 2026
422184d
Merge latest main into PR 1851
BigSimmo Aug 12, 2026
a5ee629
test(search): require fuzzy field reason signals
BigSimmo Aug 12, 2026
7206fe7
fix(search): expose fuzzy field match reasons
BigSimmo Aug 12, 2026
741e3c3
Merge latest main into PR 1851
BigSimmo Aug 12, 2026
d877376
fix(ci): register submitted-root regression in UI shards
BigSimmo Aug 12, 2026
5c73ce3
Merge latest main into PR 1851
BigSimmo Aug 12, 2026
f0ea7a6
Merge latest main into PR 1851
BigSimmo Aug 12, 2026
b39ee76
Merge branch 'main' into codex/investigate-recent-regression-issues
BigSimmo Aug 12, 2026
49278b4
Merge branch 'main' into codex/investigate-recent-regression-issues
BigSimmo Aug 12, 2026
29e50d0
fix(search): bound long-token typo recovery to one edit
BigSimmo Aug 12, 2026
2b9891a
test(search): prevent cross-drug fuzzy matches
BigSimmo Aug 12, 2026
547015a
Merge branch 'main' into codex/investigate-recent-regression-issues
BigSimmo Aug 12, 2026
d681976
Merge branch 'main' into codex/investigate-recent-regression-issues
BigSimmo Aug 12, 2026
22d66d5
Merge branch 'main' into codex/investigate-recent-regression-issues
BigSimmo Aug 12, 2026
d2d3256
Add wrong-drug exclusion regression test for the fuzzy typo cap
claude Aug 13, 2026
9b29417
Merge remote-tracking branch 'origin/main' into codex/investigate-rec…
BigSimmo Aug 13, 2026
37b704b
Merge current main into PR #1851
BigSimmo Aug 13, 2026
488d57a
style(tests): format merged navigation assertions
BigSimmo Aug 13, 2026
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
2 changes: 2 additions & 0 deletions scripts/playwright-pr-shards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export const prUiSpecProfiles = Object.freeze([
{ file: "tests/ui-visual-artifacts.spec.ts", shard: 3, fullSeconds: 2.7, criticalSeconds: 0 },
{ file: "tests/ui-forms-section-nav.spec.ts", shard: 3, fullSeconds: 2.5, criticalSeconds: 0 },
{ file: "tests/ui-therapy-nav-scroll.spec.ts", shard: 3, fullSeconds: 1.9, criticalSeconds: 0 },
// Critical-only regression; use a conservative estimate until the next hosted timing sample.
{ file: "tests/ui-phone-scroll-submitted-root.spec.ts", shard: 3, fullSeconds: 1.0, criticalSeconds: 1.0 },
// Skipped in the sampled runner because pdf.js could not raster there.
{ file: "tests/ui-document-canvas.spec.ts", shard: 3, fullSeconds: 0, criticalSeconds: 0 },
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ function GlobalStandaloneSearchShellBody({
}
mobileBottomSearchAddonKind={differentialsCompareAddonActive ? "differentials-compare" : undefined}
desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"}
showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"}
showPhoneSuggestionTickerOnHome={isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)}
searchComposerVisible={shouldShowSearchComposer}
desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined}
desktopPageComposerSlotId={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,7 @@ function SmartRotatingHint({
const activeExample = examples[activeExampleIndex % examples.length];

useEffect(() => {
if (isTickerHeld) {
return;
}

if (isTickerHeld) return;
if (examples.length <= 1) return;
const intervalId = window.setInterval(() => {
setActiveExampleIndex((current) => (current + 1) % examples.length);
Expand All @@ -200,12 +197,13 @@ function SmartRotatingHint({
setHeldTickerExample(activeExample);
setIsTickerHeld(true);
}, [activeExample]);

const releaseTicker = useCallback(() => {
setIsTickerHeld(false);
}, []);

const resolvedTickerExample = isTickerHeld ? (heldTickerExample ?? activeExample) : activeExample;
const releaseTicker = useCallback(() => setIsTickerHeld(false), []);
// A mode change can replace the examples while a pointer/focus hold is
// active. Never keep displaying or submit a held value that is no longer in
// the current mode's suggestion set.
const currentHeldTickerExample =
heldTickerExample && examples.includes(heldTickerExample) ? heldTickerExample : activeExample;
const resolvedTickerExample = isTickerHeld ? currentHeldTickerExample : activeExample;

if (!activeExample) return null;

Expand Down
113 changes: 101 additions & 12 deletions src/lib/catalog-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,66 @@ export function compactSearchText(value: string) {
return value.replace(/\s+/g, "");
}

function typoDistanceLimit(term: string) {
// One edit recovers common clinical typos without allowing exact long drug
// names to cross-match distinct catalogue entries (for example fluoxetine
// and duloxetine, or prednisone and prednisolone).
if (term.length >= 5) return 1;
// Four-character clinical abbreviations (SSRI/SNRI, ADHD/ODD, etc.) are
// often one edit apart; require five characters before allowing a typo.
return 0;
}

/** Bounded Damerau-Levenshtein distance for conservative catalogue typo recovery. */
function boundedTypoDistance(left: string, right: string, limit: number) {
if (Math.abs(left.length - right.length) > limit) return limit + 1;
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
let previousPrevious: number[] | undefined;

for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
const current = [leftIndex];
let rowMinimum = current[0];
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
let distance = Math.min(
current[rightIndex - 1] + 1,
previous[rightIndex] + 1,
previous[rightIndex - 1] + substitutionCost,
);
if (
previousPrevious &&
leftIndex > 1 &&
rightIndex > 1 &&
left[leftIndex - 1] === right[rightIndex - 2] &&
left[leftIndex - 2] === right[rightIndex - 1]
) {
distance = Math.min(distance, previousPrevious[rightIndex - 2] + 1);
}
current[rightIndex] = distance;
rowMinimum = Math.min(rowMinimum, distance);
}
if (rowMinimum > limit) return limit + 1;
previousPrevious = previous.slice();
previous.splice(0, previous.length, ...current);
}
return previous[right.length];
}

/** Number of query tokens with a conservative near-word match in normalized text. */
function fuzzySearchTokens(query: string, text: string) {
const queryTokens = normalizeSearchText(query).split(/\s+/).filter(Boolean);
const words = Array.from(new Set(normalizeSearchText(text).split(/\s+/).filter(Boolean)));
return queryTokens.filter((term) => {
if (words.some((word) => word.includes(term))) return false;
const limit = typoDistanceLimit(term);
return limit > 0 && words.some((word) => boundedTypoDistance(term, word, limit) <= limit);
});
}

export function fuzzySearchTokenCount(query: string, text: string) {
return fuzzySearchTokens(query, text).length;
}

export type CatalogField<T> = {
// Wrapper-facing key used to build human-readable reasons (e.g. "title", "contact").
id: string;
Expand All @@ -32,13 +92,15 @@ export type CatalogField<T> = {
};

export type CatalogMatchSignals = {
// Matched term count per field id (only fields with at least one match are present).
// Literal or conservative fuzzy matched term count per field id
// (only fields with at least one match are present).
fields: Record<string, number>;
// Matched term count against the full-text haystack.
content: number;
// Matched term count for terms introduced by expandTokens (e.g. symptom
// aliases) that were not part of the raw query.
expanded: number;
fuzzy: number;
compact: boolean;
phrase: boolean;
prefix: boolean;
Expand Down Expand Up @@ -113,6 +175,14 @@ export function rankCatalogRecords<T>(
const text = options.fullText(record);
const fields: Record<string, number> = {};
let score = 0;
let fuzzy = 0;
const compact =
compactBonus > 0 &&
compactQuery.length >= compactMinLength &&
(compactSearchText(text).includes(compactQuery) ||
(options.compactExtraText
? compactSearchText(options.compactExtraText(record)).includes(compactQuery)
: false));

for (const field of options.fields) {
const haystack = field.text(record);
Expand All @@ -121,23 +191,32 @@ export function rankCatalogRecords<T>(
// align with a word boundary — substring hits ("renal" inside
// "adrenaline") stay confined to the low-weight content haystack.
const matched = terms.filter((term) => matchesTermAtWordBoundary(haystack, term)).length;
if (!matched) continue;
fields[field.id] = matched;
score += matched * field.weight;
if (matched) {
fields[field.id] = matched;
score += matched * field.weight;
}

// A typo in a title/name/code field must retain that field's weight.
// Otherwise an intended record ties incidental full-text mentions and
// a limited universal-search result can omit the best match entirely.
const fuzzyFieldMatches = compact ? 0 : fuzzySearchTokenCount(normalizedQuery, haystack);
if (fuzzyFieldMatches) {
fields[field.id] = (fields[field.id] ?? 0) + fuzzyFieldMatches;
}
fuzzy += fuzzyFieldMatches;
score += fuzzyFieldMatches * field.weight;
}

const content = terms.filter((term) => text.includes(term)).length;
score += content * contentWeight;

const expanded = expandedTerms.filter((term) => text.includes(term)).length;
const fuzzyContentFallback = !compact && fuzzy === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0;
fuzzy += fuzzyContentFallback;
// Broad full-text fuzzy evidence is only a fallback and stays weaker than
// literal content or a weighted field match.
score += fuzzyContentFallback * Math.max(1, contentWeight * 0.5);

const compact =
compactBonus > 0 &&
compactQuery.length >= compactMinLength &&
(compactSearchText(text).includes(compactQuery) ||
(options.compactExtraText
? compactSearchText(options.compactExtraText(record)).includes(compactQuery)
: false));
if (compact) score += compactBonus;

const phrase = phraseBonus > 0 && text.includes(normalizedQuery);
Expand All @@ -158,7 +237,17 @@ export function rankCatalogRecords<T>(
record,
index,
score,
signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals,
signals: {
fields,
content,
expanded,
fuzzy,
compact,
phrase,
prefix,
exact,
broad,
} satisfies CatalogMatchSignals,
};
})
.filter((match) => match.score > 0)
Expand Down
10 changes: 10 additions & 0 deletions tests/audit-navigation-auth-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,18 @@ function sourceSegment(contents: string, startMarker: string, endMarker: string)
const clinicalDashboardSource = source("src/components/ClinicalDashboard.tsx");
const masterSearchHeaderSource = source("src/components/clinical-dashboard/master-search-header.tsx");
const universalAlsoMatchesSource = source("src/components/clinical-dashboard/universal-search-also-matches.tsx");
const universalCommandSurfaceSource = source("src/components/clinical-dashboard/universal-search-command-surface.tsx");
const globalSearchShellSource = source("src/components/clinical-dashboard/global-search-shell.tsx");

describe("audit navigation and auth regressions", () => {
it("keeps the tappable phone suggestion ticker connected to standalone homes", () => {
expect(globalSearchShellSource).toContain('isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)');
expect(masterSearchHeaderSource).toContain("showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome}");
expect(universalCommandSurfaceSource).toContain('data-testid="smart-search-phone-ticker"');
expect(universalCommandSurfaceSource).toContain("onClick={() => onPickExample(resolvedTickerExample)}");
expect(universalCommandSurfaceSource).toContain("examples.includes(heldTickerExample)");
expect(universalCommandSurfaceSource).toContain("onQueryChange(example);");
});
it("redirects exact legacy route handlers at request time while retaining useful query state", () => {
const applications = redirectApplications(
new NextRequest("https://clinical-kb.test/applications?q=acute+care&tag=one&tag=two"),
Expand Down
32 changes: 32 additions & 0 deletions tests/catalog-search-drug-name-regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search";

type Drug = { name: string };

const drugs: Drug[] = [
{ name: "Fluoxetine" },
{ name: "Duloxetine" },
{ name: "Prednisone" },
{ name: "Prednisolone" },
];

function search(query: string) {
return rankCatalogRecords(drugs, query, {
fields: [{ id: "name", weight: 8, text: (drug) => normalizeSearchText(drug.name) }],
fullText: (drug) => normalizeSearchText(drug.name),
exactValues: (drug) => [normalizeSearchText(drug.name)],
exactBonus: 10,
});
}

describe("catalogue drug-name typo recovery", () => {
it("does not fuzzy-match a distinct long medication name beside an exact match", () => {
expect(search("fluoxetine").map((match) => match.record.name)).toEqual(["Fluoxetine"]);
expect(search("prednisone").map((match) => match.record.name)).toEqual(["Prednisone"]);
});

it("still recovers a single-edit typo in a long medication name", () => {
expect(search("fluoxetne")[0]?.record.name).toBe("Fluoxetine");
expect(search("prednisne")[0]?.record.name).toBe("Prednisone");
});
});
63 changes: 62 additions & 1 deletion tests/catalog-search.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { compactSearchText, normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search";
import {
compactSearchText,
fuzzySearchTokenCount,
normalizeSearchText,
rankCatalogRecords,
} from "../src/lib/catalog-search";

type Item = { title: string; slug: string; tags: string[]; body: string };

Expand Down Expand Up @@ -56,6 +61,62 @@ describe("rankCatalogRecords", () => {
expect(results.some((match) => match.record.slug === "lithium-levels")).toBe(false);
});

it("finds close catalogue words after an insertion, omission, or transposition", () => {
expect(rank("clozpaine")[0]?.record.slug).toBe("clozapine-monitoring");
expect(rank("lithum")[0]?.record.slug).toBe("lithium-levels");
expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1);
});

it("weights and reports a fuzzy title above an incidental full-text mention", () => {
const records = [
{ title: "Schizophrenia", body: "Core diagnostic record" },
{ title: "Other condition", body: "Consider schizophrenia in the differential" },
];
const results = rankCatalogRecords(records, "schizophrnia", {
fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }],
fullText: (record) => normalizeSearchText(`${record.title} ${record.body}`),
});

expect(results[0]?.record.title).toBe("Schizophrenia");
expect(results[0]?.signals.fuzzy).toBe(1);
expect(results[0]?.signals.fields.title).toBe(1);
});

it("does not fuzz short clinical abbreviations or unrelated words", () => {
expect(fuzzySearchTokenCount("GAD", "Major depressive disorder")).toBe(0);
expect(fuzzySearchTokenCount("SSRI", "SNRI")).toBe(0);
expect(rank("transport").some((match) => match.record.slug === "clozapine-monitoring")).toBe(false);
});

it("never cross-matches a distinct drug two edits away, even with both records present", () => {
// Ledger #310: with a >=8-char / 2-edit tier, fluoxetine matched duloxetine (substitute
// f->d + transpose lu->ul counts as 2 Damerau edits) and prednisone matched prednisolone.
// The 1-edit cap must exclude the wrong drug while the exact drug still ranks, and while
// genuine single-edit typo recovery keeps working.
const drugs = [
{ title: "Fluoxetine", body: "SSRI dosing" },
{ title: "Duloxetine", body: "SNRI dosing" },
{ title: "Prednisone", body: "Corticosteroid taper" },
{ title: "Prednisolone", body: "Corticosteroid taper" },
{ title: "Sertraline", body: "SSRI dosing" },
];
const rankDrugs = (query: string) =>
rankCatalogRecords(drugs, query, {
fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }],
fullText: (record) => normalizeSearchText(record.title),
});

const fluoxetine = rankDrugs("fluoxetine");
expect(fluoxetine[0]?.record.title).toBe("Fluoxetine");
expect(fluoxetine.some((match) => match.record.title === "Duloxetine")).toBe(false);

const prednisone = rankDrugs("prednisone");
expect(prednisone[0]?.record.title).toBe("Prednisone");
expect(prednisone.some((match) => match.record.title === "Prednisolone")).toBe(false);

expect(rankDrugs("setraline")[0]?.record.title).toBe("Sertraline");
});

it("applies the whole-phrase bonus on top of term matches", () => {
const [top] = rank("clozapine monitoring");
// 2 title terms (12) + 2 content terms (4) + phrase (4).
Expand Down
4 changes: 4 additions & 0 deletions tests/dsm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ describe("DSM clinical catalogue", () => {
).toBe(true);
});

it("keeps a typo-corrected diagnosis title ahead of incidental clinical mentions", () => {
expect(rankDsmDiagnoses("schizophrnia", 5).some((match) => match.diagnosis.slug === "schizophrenia")).toBe(true);
});

it("links named differential considerations back to catalogue records", () => {
const diagnosis = getDsmDiagnosis("major-depressive-disorder");
const bipolar = diagnosis?.differentials.find((item) => item.startsWith("Bipolar I or II"));
Expand Down
Loading
Loading