← Files Biological Sequence & Alignment ViewerARCHIVED FILE

src/sequence/fastq-quality-analysis.ts

14.7 KB · Sep 30, 2026 · 23:01 UTC

↓ Download file

import {
  analyzeScientificSequenceQcRecords,
  type ScientificSequenceQcRecord,
} from "@openai/scientific-viewer-platform/sequence/scientific-sequence-qc";

import type { SequenceDocument } from "./types";

export const FASTQ_QUALITY_REPORT_LIMITS = Object.freeze({
  maxAdapterBases: 64,
  maxAnalyzedBases: 100_000,
  maxAnalyzedReads: 1_000,
  maxConsideredReads: 5_000,
  maxCycleBins: 80,
  maxHistogramBins: 40,
  maxPatterns: 6,
  maxReadLength: 10_000,
  maxStructuredReportBytes: 48 * 1_024,
});

export const FASTQ_QUALITY_REPORT_METHODS = Object.freeze({
  adapter:
    "Local, contiguous forward-strand matching of your supplied sequence only (U is treated as T). No partial matches, mismatches or reverse-complement screen. A match alone does not establish adapter contamination.",
  baseComposition:
    "Percentage of observed bases; T and U are grouped together.",
  cycleQuality:
    "Mean Phred score with the observed minimum–maximum range; not quartiles.",
  cycleGrouping:
    "Adjacent-cycle groups use base-count-weighted means and the observed minimum and maximum. Table values describe the same groups.",
  disclaimer:
    "This is a descriptive, bounded report, not a FastQC run. No automatic pass/fail thresholds, contamination identification or library-complexity estimate are applied.",
  gc: "GC per read, rounded to 1%. Denominator: A, C, G, T and U only; ambiguous bases are excluded.",
  kmers:
    "Observed pattern frequency, not a statistical enrichment or contamination call.",
  kmerCounting:
    "Top six of the repeated overlapping A/C/G/T 7-mers. U is treated as T; windows with ambiguous bases are skipped. Fractions use all counted 7-mer windows, not reads.",
  kmerFractionDenominator:
    "all-counted-overlapping-canonical-7-mer-windows" as const,
  meanReadQuality:
    "Arithmetic mean of each read’s Phred scores, rounded to the nearest integer; not Phred of the mean error probability.",
  qualityEncoding: "phred+33-assumed" as const,
  repeatedSequences:
    "Complete forward-read sequences are compared exactly, without reverse-complement matching.",
  repeatedSequenceFractionDenominator: "analyzed-reads" as const,
  sampling: "first-eligible-retained-reads" as const,
});

export const FASTQ_QUALITY_TABLE_IDS = [
  "cycle-quality",
  "cycle-composition",
  "read-length",
  "read-gc",
  "read-mean-quality",
  "repeated-sequences",
  "frequent-kmers",
] as const;

export type FastqQualityTableId = (typeof FASTQ_QUALITY_TABLE_IDS)[number];

export type FastqQualityViewState = {
  distributionsExpanded: boolean;
  expandedTables: Array<FastqQualityTableId>;
  methodsExpanded: boolean;
};

export const DEFAULT_FASTQ_QUALITY_VIEW_STATE =
  Object.freeze<FastqQualityViewState>({
    distributionsExpanded: false,
    expandedTables: ["repeated-sequences", "frequent-kmers"],
    methodsExpanded: false,
  });

export function createFastqQualityViewState(): FastqQualityViewState {
  return {
    ...DEFAULT_FASTQ_QUALITY_VIEW_STATE,
    expandedTables: [...DEFAULT_FASTQ_QUALITY_VIEW_STATE.expandedTables],
  };
}

type ScientificQcResult = Awaited<
  ReturnType<typeof analyzeScientificSequenceQcRecords>
>;

export type FastqQualityReport = {
  adapterSequence: string | null;
  duplicateReadCount: number;
  frequentSequences: Array<{
    count: number;
    fraction: number;
    sequence: string;
  }>;
  meanReadQualityDistribution: Array<{ count: number; value: number }>;
  qc: ScientificQcResult;
  scope: {
    analyzedBases: number;
    analyzedReads: number;
    consideredReads: number;
    isSubset: boolean;
    missingQualityReads: number;
    oversizedReads: number;
    populationReads: number;
    retainedReads: number;
  };
  uniqueSequenceCount: number;
};

export type FastqCycleBin = {
  bases: {
    A: number;
    C: number;
    G: number;
    N: number;
    other: number;
    T: number;
  };
  count: number;
  end: number;
  maximum: number;
  mean: number;
  minimum: number;
  start: number;
};

export type FastqDistributionBin = {
  count: number;
  end: number;
  start: number;
};

export type FastqQualityReportSummary = ReturnType<
  typeof summarizeFastqQualityReport
>;

export type FastqQualityReportState = {
  adapterSequence: string | null;
  error?: string;
  jobId: string;
  pending: boolean;
  report: FastqQualityReportSummary | null;
};

/** Detailed QC uses only complete, quality-bearing retained reads, never a cut read. */
export async function analyzeFastqQualityReport(
  document: SequenceDocument,
  {
    adapterSequence,
    isCancelled,
    signal,
  }: {
    adapterSequence?: string;
    isCancelled?: () => boolean;
    signal?: AbortSignal;
  } = {},
): Promise<FastqQualityReport> {
  const throwIfCancelled = (): void => {
    signal?.throwIfAborted();
    if (isCancelled?.())
      throw new DOMException("Quality report cancelled.", "AbortError");
  };
  if (document.format !== "fastq") {
    throw new Error("A detailed quality report requires FASTQ reads.");
  }
  const adapter = parseFastqAdapterSequence(adapterSequence);
  const records: Array<ScientificSequenceQcRecord> = [];
  let analyzedBases = 0;
  let consideredReads = 0;
  let missingQualityReads = 0;
  let oversizedReads = 0;
  for (const record of document.records) {
    throwIfCancelled();
    if (
      consideredReads >= FASTQ_QUALITY_REPORT_LIMITS.maxConsideredReads ||
      records.length >= FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedReads
    ) {
      break;
    }
    consideredReads += 1;
    if (record.quality == null) {
      missingQualityReads += 1;
      continue;
    }
    if (record.quality.ascii.length !== record.sequence.length) {
      throw new Error(
        "FASTQ quality length does not match the complete read sequence.",
      );
    }
    if (record.sequence.length > FASTQ_QUALITY_REPORT_LIMITS.maxReadLength) {
      oversizedReads += 1;
      continue;
    }
    if (
      analyzedBases + record.sequence.length >
      FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedBases
    ) {
      break;
    }
    records.push({
      id: record.id,
      quality: record.quality.ascii,
      sequence: record.sequence,
    });
    analyzedBases += record.sequence.length;
  }
  if (records.length === 0) {
    throw new Error(
      "No retained reads have complete qualities within the 10,000-base per-read report limit. Aggregate statistics above remain available.",
    );
  }

  const sequenceCounts = new Map<string, number>();
  const meanQualities = new Map<number, number>();
  async function* reportRecords(): AsyncGenerator<ScientificSequenceQcRecord> {
    let basesSinceYield = 0;
    for (const record of records) {
      throwIfCancelled();
      const sequence = record.sequence.toUpperCase();
      sequenceCounts.set(sequence, (sequenceCounts.get(sequence) ?? 0) + 1);
      let qualitySum = 0;
      const quality = record.quality ?? "";
      for (let index = 0; index < quality.length; index += 1) {
        qualitySum += quality.charCodeAt(index) - 33;
      }
      // The chart bins the arithmetic mean of Phred scores to the nearest integer.
      const meanQuality = Math.round(qualitySum / quality.length);
      meanQualities.set(meanQuality, (meanQualities.get(meanQuality) ?? 0) + 1);
      yield record;
      basesSinceYield += sequence.length;
      if (basesSinceYield >= 10_000) {
        await new Promise<void>((resolve) => setTimeout(resolve, 0));
        throwIfCancelled();
        basesSinceYield = 0;
      }
    }
  }

  const qc = await analyzeScientificSequenceQcRecords({
    adapters:
      adapter == null ? [] : [{ id: "user-supplied", sequence: adapter }],
    budget: {
      maxAdapterBases: FASTQ_QUALITY_REPORT_LIMITS.maxAdapterBases,
      maxAdapters: 1,
      maxCycles: FASTQ_QUALITY_REPORT_LIMITS.maxReadLength,
      maxDistinctKmers: 4 ** 7,
      maxInputRecords: FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedReads,
      maxKmerLength: 7,
      maxOverrepresentedKmers: 4 ** 7,
      maxRecords: FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedReads,
      maxTotalBases: FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedBases,
      maxUniqueSequences: FASTQ_QUALITY_REPORT_LIMITS.maxAnalyzedReads,
    },
    format: "fastq",
    // Show observed frequent patterns, without inventing enrichment thresholds.
    kmer: { length: 7, minCount: 2, minFraction: 0 },
    qualityEncoding: "phred+33",
    records: reportRecords(),
  });
  throwIfCancelled();
  const populationReads =
    document.fastqSummary?.readCount ??
    document.recordInventory?.totalCount ??
    document.records.length;
  return {
    adapterSequence: adapter,
    duplicateReadCount: qc.recordsAnalyzed - sequenceCounts.size,
    frequentSequences: [...sequenceCounts.entries()]
      .filter(([, count]) => count > 1)
      .sort(
        ([leftSequence, leftCount], [rightSequence, rightCount]) =>
          rightCount - leftCount || leftSequence.localeCompare(rightSequence),
      )
      .slice(0, FASTQ_QUALITY_REPORT_LIMITS.maxPatterns)
      .map(([sequence, count]) => ({
        count,
        fraction: count / qc.recordsAnalyzed,
        sequence,
      })),
    meanReadQualityDistribution: [...meanQualities.entries()]
      .map(([value, count]) => ({ count, value }))
      .sort((left, right) => left.value - right.value),
    qc,
    scope: {
      analyzedBases,
      analyzedReads: qc.recordsAnalyzed,
      consideredReads,
      isSubset:
        qc.recordsAnalyzed !== populationReads ||
        document.recordInventory?.truncated === true,
      missingQualityReads,
      oversizedReads,
      populationReads,
      retainedReads: document.records.length,
    },
    uniqueSequenceCount: sequenceCounts.size,
  };
}

/** The model and UI receive the same bounded data, with no full read sequences. */
export function summarizeFastqQualityReport(report: FastqQualityReport) {
  const { scope } = report;
  const summary = {
    analysis: "quality-report" as const,
    adapter: report.qc.adapters[0] ?? null,
    adapterSequence: report.adapterSequence,
    cycleBins: groupFastqQualityCycles(report.qc.cycles),
    duplicateReadCount: report.duplicateReadCount,
    frequentKmers: report.qc.overrepresentedKmers.slice(
      0,
      FASTQ_QUALITY_REPORT_LIMITS.maxPatterns,
    ),
    frequentSequences: report.frequentSequences
      .slice(0, FASTQ_QUALITY_REPORT_LIMITS.maxPatterns)
      .map(({ count, fraction, sequence }) => ({
        count,
        fraction,
        sequenceLength: sequence.length,
        sequencePreview:
          sequence.length <= 48
            ? sequence
            : `${sequence.slice(0, 32)}…${sequence.slice(-12)}`,
        sequenceTruncated: sequence.length > 48,
      })),
    gcBins: groupFastqDistribution(
      report.qc.gcDistribution.flatMap(({ count, percent }) =>
        percent == null ? [] : [{ count, value: percent }],
      ),
    ),
    lengthBins: groupFastqDistribution(
      report.qc.lengthDistribution.map(({ count, length }) => ({
        count,
        value: length,
      })),
    ),
    limits: FASTQ_QUALITY_REPORT_LIMITS,
    meanQualityBins: groupFastqDistribution(report.meanReadQualityDistribution),
    methods: FASTQ_QUALITY_REPORT_METHODS,
    scope: {
      ...scope,
      description:
        `Analyzed ${scope.analyzedReads.toLocaleString()} of ${scope.populationReads.toLocaleString()} parsed reads · ${scope.analyzedBases.toLocaleString()} bases.` +
        (scope.isSubset
          ? ` First eligible retained reads in source order, not a random sample (${scope.retainedReads.toLocaleString()} reads retained for inspection). These charts do not establish whole-file QC.`
          : " All parsed reads are represented in this profile."),
      label: scope.isSubset ? "Retained-read subset" : "All parsed reads",
    },
    undefinedGcReadCount:
      report.qc.gcDistribution.find(({ percent }) => percent == null)?.count ??
      0,
    uniqueSequenceCount: report.uniqueSequenceCount,
  };
  if (
    new TextEncoder().encode(JSON.stringify(summary)).byteLength >
    FASTQ_QUALITY_REPORT_LIMITS.maxStructuredReportBytes
  ) {
    throw new Error("The quality report exceeds its structured-output budget.");
  }
  return summary;
}

export function groupFastqQualityCycles(
  cycles: ScientificQcResult["cycles"],
): Array<FastqCycleBin> {
  const width = Math.max(
    1,
    Math.ceil(cycles.length / FASTQ_QUALITY_REPORT_LIMITS.maxCycleBins),
  );
  const bins: Array<FastqCycleBin> = [];
  for (let offset = 0; offset < cycles.length; offset += width) {
    const selected = cycles.slice(offset, offset + width);
    const first = selected[0];
    const last = selected.at(-1);
    if (first == null || last == null) continue;
    const bin: FastqCycleBin = {
      bases: { A: 0, C: 0, G: 0, N: 0, other: 0, T: 0 },
      count: 0,
      end: last.cycle1,
      maximum: 0,
      mean: 0,
      minimum: 93,
      start: first.cycle1,
    };
    let qualitySum = 0;
    for (const cycle of selected) {
      bin.bases.A += cycle.bases.A;
      bin.bases.C += cycle.bases.C;
      bin.bases.G += cycle.bases.G;
      bin.bases.N += cycle.bases.N;
      bin.bases.T += cycle.bases.T + cycle.bases.U;
      bin.bases.other +=
        cycle.bases.ambiguous + cycle.bases.gap + cycle.bases.other;
      if (cycle.quality != null) {
        bin.count += cycle.quality.count;
        bin.maximum = Math.max(bin.maximum, cycle.quality.max);
        bin.minimum = Math.min(bin.minimum, cycle.quality.min);
        qualitySum += cycle.quality.sum;
      }
    }
    bin.mean = bin.count === 0 ? 0 : qualitySum / bin.count;
    bins.push(bin);
  }
  return bins;
}

/** Equal-width bins preserve every observation, including sparse empty intervals. */
export function groupFastqDistribution(
  points: ReadonlyArray<{ count: number; value: number }>,
): Array<FastqDistributionBin> {
  if (points.length === 0) return [];
  let minimum = points[0]!.value;
  let maximum = minimum;
  for (const { value } of points) {
    minimum = Math.min(minimum, value);
    maximum = Math.max(maximum, value);
  }
  const width = Math.max(
    1,
    Math.ceil(
      (maximum - minimum + 1) / FASTQ_QUALITY_REPORT_LIMITS.maxHistogramBins,
    ),
  );
  const bins = Array.from(
    { length: Math.floor((maximum - minimum) / width) + 1 },
    (_, index) => ({
      count: 0,
      end: Math.min(maximum, minimum + (index + 1) * width - 1),
      start: minimum + index * width,
    }),
  );
  for (const { count, value } of points) {
    const bin = bins[Math.floor((value - minimum) / width)];
    if (bin != null) bin.count += count;
  }
  return bins;
}

export function parseFastqAdapterSequence(sequence?: string): string | null {
  if (sequence == null || sequence.trim().length === 0) return null;
  if (sequence.length > 256) {
    throw new Error("Supply one adapter sequence of 8–64 A/C/G/T bases.");
  }
  const normalized = sequence.replaceAll(/\s/gu, "").toUpperCase();
  if (!/^[ACGT]{8,64}$/u.test(normalized)) {
    throw new Error("Supply one adapter sequence of 8–64 A/C/G/T bases.");
  }
  return normalized;
}

SHA-256: 9d3f9851b1d333a769fb5c45c571c18a7530fd3d4af33c3cfbe2042316f406b6