← Files Biological Sequence & Alignment ViewerARCHIVED FILE
src/sequence/search.ts
7.53 KB · Sep 30, 2026 · 23:01 UTC
import type { SequenceMoleculeKind } from "../biological-sequence-artifact-classifier";
import { aminoAcidSymbolsMatch } from "../amino-acid-alphabet";
import {
hasOnlyNucleotideSymbols,
normalizeNucleotideSequence,
nucleotideSymbolsMatch,
reverseComplementNucleotide,
} from "../nucleotide-alphabet";
import { SEQUENCE_VIEWER_LIMITS } from "../runtime-contract";
import type {
SequenceRecord,
SequenceSearchHit,
SequenceSearchResult,
} from "./types";
type SearchComparisonBudget = {
comparisons: number;
exhausted: boolean;
maxComparisons: number;
};
export function searchSequenceRecords({
includeReverseComplement,
maxComparisons = SEQUENCE_VIEWER_LIMITS.search.maxComparisons,
maxHits = SEQUENCE_VIEWER_LIMITS.search.maxHits,
molecule,
query,
records,
}: {
includeReverseComplement: boolean;
maxComparisons?: number;
maxHits?: number;
molecule: SequenceMoleculeKind;
query: string;
records: Array<SequenceRecord>;
}): SequenceSearchResult {
const normalizedQuery = normalizeSequence(query);
const nucleicAcid =
molecule === "dna" ||
molecule === "rna" ||
molecule === "nucleic-acid-ambiguous";
if (
normalizedQuery.length === 0 ||
maxHits < 1 ||
(nucleicAcid && !hasOnlyNucleotideSymbols(normalizedQuery))
) {
return { hits: [], truncated: false };
}
const queries = createCandidateQueries({
includeReverseComplement,
molecule,
normalizedQuery,
nucleicAcid,
});
const hits: Array<SequenceSearchHit> = [];
const seen = new Set<string>();
const budget: SearchComparisonBudget = {
comparisons: 0,
exhausted: false,
maxComparisons,
};
for (const { orientation, sequence: candidateQuery } of queries) {
for (const record of records) {
for (const hit of findHits(
record,
candidateQuery,
orientation,
molecule,
budget,
)) {
const key = `${hit.recordId}:${hit.start}:${hit.end}`;
if (seen.has(key)) continue;
seen.add(key);
if (hits.length === maxHits) {
return { hits, truncated: true };
}
hits.push(hit);
}
if (budget.exhausted) return { hits, truncated: true };
}
}
return { hits, truncated: false };
}
export async function searchSequenceRecordsAsync({
includeReverseComplement,
maxComparisons = SEQUENCE_VIEWER_LIMITS.search.maxComparisons,
maxHits = SEQUENCE_VIEWER_LIMITS.search.maxHits,
molecule,
query,
records,
signal,
}: {
includeReverseComplement: boolean;
maxComparisons?: number;
maxHits?: number;
molecule: SequenceMoleculeKind;
query: string;
records: Array<SequenceRecord>;
signal?: AbortSignal;
}): Promise<SequenceSearchResult> {
const normalizedQuery = normalizeSequence(query);
const nucleicAcid =
molecule === "dna" ||
molecule === "rna" ||
molecule === "nucleic-acid-ambiguous";
if (
normalizedQuery.length === 0 ||
maxHits < 1 ||
(nucleicAcid && !hasOnlyNucleotideSymbols(normalizedQuery))
) {
return { hits: [], truncated: false };
}
const queries = createCandidateQueries({
includeReverseComplement,
molecule,
normalizedQuery,
nucleicAcid,
});
const hits: Array<SequenceSearchHit> = [];
const seen = new Set<string>();
const budget: SearchComparisonBudget = {
comparisons: 0,
exhausted: false,
maxComparisons,
};
let comparisonsAtLastYield = 0;
for (const { orientation, sequence: candidateQuery } of queries) {
for (const record of records) {
const sequence = normalizeSequence(record.sequence);
if (candidateQuery.length > sequence.length) continue;
for (
let offset = 0;
offset <= sequence.length - candidateQuery.length;
offset += 1
) {
throwIfAborted(signal);
const match = matchesAt({
budget,
molecule,
offset,
query: candidateQuery,
sequence,
});
if (budget.exhausted) {
return { hits, truncated: true };
}
if (budget.comparisons - comparisonsAtLastYield >= 50_000) {
comparisonsAtLastYield = budget.comparisons;
await yieldToMainThread();
throwIfAborted(signal);
}
if (!match) continue;
const hit = {
end: offset + candidateQuery.length,
orientation,
recordId: record.id,
start: offset + 1,
} satisfies SequenceSearchHit;
const key = `${hit.recordId}:${hit.start}:${hit.end}`;
if (seen.has(key)) continue;
seen.add(key);
if (hits.length === maxHits) {
return { hits, truncated: true };
}
hits.push(hit);
}
}
}
return { hits, truncated: false };
}
export function reverseComplement(sequence: string): string {
return reverseComplementNucleotide(sequence);
}
function* findHits(
record: SequenceRecord,
query: string,
orientation: SequenceSearchHit["orientation"],
molecule: SequenceMoleculeKind,
budget: SearchComparisonBudget,
): Generator<SequenceSearchHit> {
const sequence = normalizeSequence(record.sequence);
if (query.length > sequence.length) return;
for (let offset = 0; offset <= sequence.length - query.length; offset += 1) {
const matches = matchesAt({
budget,
molecule,
offset,
query,
sequence,
});
if (budget.exhausted) return;
if (matches) {
yield {
end: offset + query.length,
orientation,
recordId: record.id,
start: offset + 1,
};
}
}
}
function matchesAt({
budget,
molecule,
offset,
query,
sequence,
}: {
budget: SearchComparisonBudget;
molecule: SequenceMoleculeKind;
offset: number;
query: string;
sequence: string;
}): boolean {
for (let queryOffset = 0; queryOffset < query.length; queryOffset += 1) {
budget.comparisons += 1;
if (budget.comparisons > budget.maxComparisons) {
budget.exhausted = true;
return false;
}
const left = sequence[offset + queryOffset] ?? "";
const right = query[queryOffset] ?? "";
const symbolsMatch =
molecule === "dna" ||
molecule === "rna" ||
molecule === "nucleic-acid-ambiguous"
? nucleotideSymbolsMatch(left, right)
: molecule === "protein"
? aminoAcidSymbolsMatch(left, right)
: left === right;
if (!symbolsMatch) return false;
}
return true;
}
function createCandidateQueries({
includeReverseComplement,
molecule,
normalizedQuery,
nucleicAcid,
}: {
includeReverseComplement: boolean;
molecule: SequenceMoleculeKind;
normalizedQuery: string;
nucleicAcid: boolean;
}): Array<{
orientation: SequenceSearchHit["orientation"];
sequence: string;
}> {
const queries: Array<{
orientation: SequenceSearchHit["orientation"];
sequence: string;
}> = [{ orientation: "forward", sequence: normalizedQuery }];
if (includeReverseComplement && nucleicAcid) {
const reverse = reverseComplementNucleotide(
normalizedQuery,
molecule === "rna" ? "rna" : "dna",
);
if (reverse !== normalizedQuery) {
queries.push({ orientation: "reverse-complement", sequence: reverse });
}
}
return queries;
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted !== true) return;
const error = new Error("Sequence search was cancelled.");
error.name = "AbortError";
throw error;
}
function yieldToMainThread(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function normalizeSequence(sequence: string): string {
return normalizeNucleotideSequence(sequence);
}
SHA-256: b3839408c0ec3c1fe5176fdb75a7bdad834e5648f36fd6ca78f1ef90c3c6a9b6