Autocomplete

Ranked Suggestion lists for picker UIs. Six-bucket priority with frequency boost; replaceable with embedding or server-side rankers.

Autocomplete

Call isa.zyins.medications.autocomplete(), isa.zyins.conditions.autocomplete(), or isa.zyins.concepts.autocomplete() to get ranked suggestions for any free-text query.

The default ranker uses a six-bucket priority scheme with frequency boost. This is a direct port of the bpp2.0 picker hook (useAutocomplete.js), now built into the SDK so every project uses the same algorithm instead of re-implementing it.

The AutocompleteAlgorithm interface

import type { Concept, ConceptKind, Suggestion } from 'isa-sdk';

interface AutocompleteAlgorithm {
  rank(
    query: string,
    candidates: readonly Concept[],
    options: AutocompleteOptions,
  ): Promise<Suggestion[]>;
}

interface AutocompleteOptions {
  readonly limit: number;
  readonly kinds: readonly ConceptKind[];
  readonly frequencies: ReadonlyMap<string, number>;
}

The default implementation resolves synchronously (no I/O). The Promise wrapper lets future implementations add server-side reranking or embedding lookup without breaking the API.

Quick start

import { Isa } from 'isa-sdk';

const isa = await Isa.withBearer();
await isa.zyins.datasets.get();

const top5 = await isa.zyins.medications.autocomplete('lisi', { limit: 5 });
for (const s of top5) {
  console.log(s.rank, s.name, s.score);
}
// 0 LISINOPRIL                4121
// 1 LISDEXAMFETAMINE          2241
// 2 LISTERIA VACCINE             5

Bucket priority (highest → lowest)

The ranker sorts candidates into buckets and walks them in order. All candidates in bucket 1 rank higher than bucket 2, regardless of frequency. Within each bucket, the frequency boost applies.

#BucketMatchSub-sort
1startsWithCandidate name starts with the literal input.option.wordCount asc.
2sameWordsSame word set AND same word count.Frequency boost.
3wordCountNoTolerance[d]Every input word is in the candidate; d extra words.d asc, then frequency.
4independentWordIntersectionEvery input word appears in candidate (any order).Frequency boost.
5sameNumWithToleranceSame word count, different word sets.Frequency boost.
6wordCountWithTolerance[d]d words differ or extra.d asc, then frequency.

Candidates are bucketed once for primary categorization (buckets 1–3, 5–6). The independentWordIntersection bucket (4) is an additional pass — a candidate in bucket 1 may also match bucket 4. Deduplication by ID keeps the first occurrence only.

Frequency boost

Within each bucket, the score is:

scaleFactor = max(1, totalGroups - groupIndex)   // 6, 5, 4, 3, 2, 1
score       = (frequency + 1) * scaleFactor

Bucket order always wins over frequency: a rare word in bucket 1 ranks higher than a common word in bucket 2.

If no candidate has a frequency entry, the boost is skipped and candidates sort by the bucket's intrinsic rule (alphabetical for ties).

The default frequency map comes from buildFrequencyMap(bundle), which sums prescription_count across each concept's treated_with[] / used_for[] relationships. The SDK builds and caches this map. You can override it if needed:

import { Isa } from 'isa-sdk';

const isa = await Isa.withBearer();
const top5 = await isa.zyins.medications.autocomplete('lisi', {
  limit: 5,
  frequencies: new Map([['LISINOPRIL', 99_999]]),
});

The Suggestion shape

import type { Concept } from 'isa-sdk';

interface Suggestion extends Concept {
  readonly score: number;
  readonly rank: number;
  readonly matchedSpan: readonly [number, number];
}

Suggestion extends Concept, so you can call .medications(sort) and .conditions(sort) directly without a separate match() call:

import { Isa } from 'isa-sdk';

const isa = await Isa.withBearer();
for (const s of await isa.zyins.conditions.autocomplete('diabetes', { limit: 3 })) {
  console.log(s.rank, s.name);
  for (const med of s.medications(isa.zyins.reference.Sort.MostCommonFirst)) {
    console.log('  ', med.name);
  }
}

matchedSpan is the [start, end] offset of the query inside the suggestion's name — feed it to your picker's highlighter for inline match emphasis. [0, 0] when no literal substring matched.

Options

OptionDefaultMeaning
limit25 (max 250)Maximum suggestions to return.
kinds[] (all kinds)Restrict to 'condition', 'medication', 'nicotine'.
frequenciesbundle-derived buildFrequencyMap() mapPer-id frequency override.

A kinds filter applied to concepts.autocomplete() is the only way to narrow concepts cross-kind results. For medications.autocomplete() and conditions.autocomplete() the kind is already pinned.

Replacing the ranker

Plug an embedding-based ranker, a server-side reranker, or a different bucketization at constructor time:

import {
  Isa,
  type AutocompleteAlgorithm,
  type AutocompleteOptions,
  type Concept,
  type Suggestion,
} from 'isa-sdk';
import { buildSuggestion } from 'isa-sdk/zyins';

// Your embedding service — whatever returns candidates in relevance order.
interface EmbeddingService {
  rank(query: string, candidates: readonly Concept[]): Promise<readonly Concept[]>;
}

class EmbeddingRanker implements AutocompleteAlgorithm {
  constructor(private readonly embeddingService: EmbeddingService) {}

  async rank(
    query: string,
    candidates: readonly Concept[],
    options: AutocompleteOptions,
  ): Promise<Suggestion[]> {
    const ranked = await this.embeddingService.rank(query, candidates);
    return ranked.slice(0, options.limit).map((c, i) => buildSuggestion(c, {
      score: ranked.length - i,
      rank: i,
      matchedSpan: [0, 0],
    }));
  }
}

// Register the ranker at construction; every autocomplete() call routes
// through it instead of the default six-bucket scheme.
declare const myEmbeddingService: EmbeddingService;
const isa = await Isa.withBearer(undefined, undefined, {
  autocompleteAlgorithm: new EmbeddingRanker(myEmbeddingService),
});

const top5 = await isa.zyins.medications.autocomplete('blood thinner', { limit: 5 });

The async signature is required even for synchronous implementations so the contract stays stable across SDK releases.

Why Promise<Suggestion[]> even when sync

The default resolves synchronously (no I/O). The Promise wrapper lets future implementations add server-side reranking, embedding lookup, or other async work without breaking the API. Consumers can always await unconditionally.

Code samples

TypeScript

import { Isa } from 'isa-sdk';

const isa = await Isa.withBearer();
await isa.zyins.datasets.get();
const top5 = await isa.zyins.medications.autocomplete('lisi', { limit: 5 });
for (const s of top5) console.log(s.rank, s.name, s.score);

Python

await isa.zyins.datasets.get()
top5 = await isa.zyins.medications.autocomplete('lisi', limit=5)
for s in top5:
    print(s.rank, s.name, s.score)

Go

ctx := context.Background()
isa.Zyins.Datasets.Get(ctx)

top5, _ := isa.Zyins.Medications.Autocomplete(ctx, "lisi", reference.AutocompleteOptions{Limit: 5})
for _, s := range top5 {
    fmt.Println(s.Rank, s.Name, s.Score)
}

PHP

$isa->zyins->datasets->get();

$top5 = $isa->zyins->medications->autocomplete('lisi', ['limit' => 5]);
foreach ($top5 as $s) {
    echo $s->rank, ' ', $s->name, ' ', $s->score, PHP_EOL;
}

curl

Autocomplete is SDK-side; canonical data flows through GET /v3/datasets.

See also