Market data can show that a transaction happened without giving a portable, research-ready answer to who initiated it. When quotes are unavailable, what direction does the last meaningful trade-price change imply? Tick Test turns that ambiguity into an auditable calculation with explicit references, unknown states, and sensitivity profiles.
This tutorial is for builders who need signed flow for research, execution analytics, or data-quality work. You will implement the method in Python and TypeScript, reproduce a 60-record synthetic trace, inspect failure cases, and learn why a side label is not evidence of informed trading.
Start with the output contract
A useful classifier returns more than buy or sell. Each row needs a sign, a human-readable reason, the exact price or quote used as reference, and cumulative signed volume. The summary needs buy, sell, and unknown volume; coverage; imbalance; exclusions; and the active profile. If the system cannot show why a label exists, downstream users cannot audit it.
The canonical definition is: A higher trade price is signed buy, a lower price is signed sell, and an unchanged price inherits the most recent non-zero tick under the canonical carry rule.
The formula is s_i = sign(P_i - P_{i-1}) when non-zero; otherwise s_i = s_{i-1}^{non-zero}..
Point-in-time data before arithmetic
Records are sorted by event time and deterministic sequence. Availability time prevents using a quote that was not knowable when the trade record arrived. Status and condition identify records that must not update state. Instrument, venue, source, session, units, and correction lineage belong in a production envelope.
The fixture is deliberately synthetic. No row is a market observation. This protects licensing boundaries and lets equality, missing state, crossed quotes, breaks, and long zero runs be designed exactly.
Work the canonical sequence
Under the carry-zero profile, the mixed-ticks scenario exposes the method's main branches. The reference implementation creates a 60-row trace and stores the exact output beside the input.
Read the trace in order. First confirm the record was eligible. Then inspect the causal reference. Next read the sign and reason code. Finally check cumulative signed volume. A visually plausible final imbalance is not enough; every transition must follow the frozen rule.
Diagnostic deep dive: state belongs to the last different price
The tick test is not simply current price minus previous price. Its important state is the direction of the last non-zero price change. Suppose prices arrive as 100.00, 100.01, 100.01, 100.01. The second trade is an uptick. Under the canonical carry rule, the next two equal-price prints are zero-upticks and remain buyer-classified. If the next distinct price is 100.00, state flips to a downtick; later equal prints become zero-downticks.
This makes initialization and reset rules part of the algorithm. The first eligible trade has no prior price and must remain unknown. An all-flat sequence has no defensible direction unless the caller supplies an external seed. The canonical package refuses that seed. It also resets at a session change, because carrying yesterday's last different price into today's first print can create an accidental overnight label.
Corrections, crosses, and ordering
Trade condition and finality must be resolved before updating state. A broken execution cannot remain in the reference chain. Opening and closing crosses are not ordinary continuous-auction prints, so the teaching contract excludes them. Production reconstruction may need a reversible event store because a correction can invalidate state already used by later trades.
Event time alone is insufficient when feeds can revise or arrive out of order. The operational record keeps event time, availability time, sequence, source, condition, and finality. The synthetic fixture is already final, but the contract names the evidence a live implementation would require.
Compare profiles before interpreting the result
Three profiles are pre-registered: carry-zero, abstain-zero, and continuous. They vary a genuine method choice—zero-tick behavior, quote lag, tolerance, sigma, or Student-t tails. The playground displays all three results together so coverage changes cannot be hidden.
A sensitivity reversal is an analytical finding. It says the record's label depends on an assumption. It does not identify which assumption matches a particular live feed. That requires source-specific documentation and ground truth.
Implementation walkthrough
The Python entry point is:
from trade_classification import run_topic
result = run_topic("tick", input_data, config)
TypeScript uses the same input and output semantics:
import { runTopic } from "./trade-classification";
const result = runTopic("tick", inputData, config);
Both implementations reject non-finite inputs, preserve nulls and reason codes, and round stored floating results to ten decimal places. Shared fixtures compare all scenario/profile cubes with 1e-7 tolerance.
Test the boundary, not just the attractive example
The eight scenarios include causal initialization, equality, missing references, invalid or cancelled records, session or latency changes, and noisy comparisons. Independent arithmetic checks one defining boundary for the method. Those checks establish executable truth, not empirical predictive power.
The most dangerous failure is silent hindsight: matching a future quote, estimating a scale from future bars, or leaving a later-broken trade in recursive state. The next most dangerous is semantic drift: treating an aggressor-side inference as trader identity, private information, or a trading signal.
Guided lab
The self-contained lab begins in a fully rendered canonical state. Choose a scenario and profile, move one record at a time, use teaching anchors to jump to important states, and compare profile summaries. The chart, formula trace, diagnostics, guidance, and audit table remain synchronized.
Reduced-motion users retain every state transition. Play becomes one deterministic step instead of a timer. The standalone lab has no wallet, provider call, credential, or external network dependency.
Historical evidence decision
A named market example is not useful for this package. Without licensed raw records, corrections, feed-specific clocks, conditions, and a truth label, a famous symbol would add narrative confidence without reproducibility. The correct next step for empirical research is a licensed, venue-bounded evaluation—not an invented tape.
What the result can and cannot support
It can support a reproducible signed-flow feature, classifier comparison, data-quality diagnosis, and instructional trace. It cannot certify trader motive, informed trading, future price direction, execution quality, or profitability.
Reject these statements:
- Every unchanged price is neutral.
- The first trade may be assigned an arbitrary side.
- A broken trade can remain in the state chain.
- File row order is automatically causal.
- A tick-test buy proves informed buying.
The durable habit is to attach the convention to the number: method, profile, source, clocks, filters, coverage, and unknown policy.
Where to go next
Continue to Quote Test. Within Trade Classification, each tutorial adds a different observable: transaction-price state, quote position, a hybrid branch, or aggregated price response.
Sources
- Inferring Trade Direction from Intraday Data — Tick-test states, quote matching, midpoint classification, and the historical five-second TAQ correction.
- Short Sales: Rule 10a-1 Tick Test Discussion — Plus-tick, zero-plus-tick, minus-tick, and zero-minus-tick definitions.
- Nasdaq TotalView-ITCH 5.0 Specification — Nanosecond event timestamps, executions, non-display trade messages, crosses, and broken-trade processing.
Source facts are bounded to their named markets and methods. All examples and calculations here are synthetic or author-derived.
Rendered from the canonical Mermaid sources linked by this article.
Tick Test Calculation Flow
Purpose: show where causal normalization ends and classification begins.
Takeaway: the classification formula is only one stage; record eligibility and causal alignment determine what it is allowed to see.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
LEE_READY — Inferring Trade Direction from Intraday Data
- Organization or authors: Charles M. C. Lee and Mark J. Ready
- Source type: Original peer-reviewed paper
- Publication or effective date: 1991-06
- Version: Journal of Finance 46(2)
- URL or DOI: https://doi.org/10.1111/j.1540-6261.1991.tb02683.x
- Accessed: 2026-07-29
- Jurisdiction: NYSE and AMEX empirical setting
- Supports: Tick-test states, quote matching, midpoint classification, and the historical five-second TAQ correction.
- Limitations: The five-second adjustment was an empirical repair for 1988 data and is not a universal modern-market lag.
- Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
- Redistribution decision: No licensed market observations are redistributed.
SEC_TICK — Short Sales: Rule 10a-1 Tick Test Discussion
- Organization or authors: U.S. Securities and Exchange Commission
- Source type: Official regulatory release
- Publication or effective date: 1999-10-20
- Version: Release No. 34-42037
- URL or DOI: https://www.sec.gov/rules-regulations/1999/10/short-sales
- Accessed: 2026-07-29
- Jurisdiction: United States
- Supports: Plus-tick, zero-plus-tick, minus-tick, and zero-minus-tick definitions.
- Limitations: Historical short-sale regulation is evidence for tick-state terminology, not a current trading recommendation.
- Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
- Redistribution decision: No licensed market observations are redistributed.
NASDAQ_ITCH — Nasdaq TotalView-ITCH 5.0 Specification
- Organization or authors: Nasdaq
- Source type: Official exchange technical specification
- Publication or effective date: 2026
- Version: ITCH 5.0
- URL or DOI: https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHspecification.pdf
- Accessed: 2026-07-29
- Jurisdiction: Nasdaq market data
- Supports: Nanosecond event timestamps, executions, non-display trade messages, crosses, and broken-trade processing.
- Limitations: ITCH message semantics are venue-specific; the published non-display buy/sell field is not a reliable universal aggressor label.
- Evidence role: Sourced fact only; all fixtures and displayed outputs are synthetic or author-derived.
- Redistribution decision: No licensed market observations are redistributed.
Full dependency-light reference implementations in both supported languages.
export type NumericRecord = Record<string, any>;
function finite(value: unknown, name: string, minimum?: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
if (minimum !== undefined && value < minimum) throw new Error(`${name} must be >= ${minimum}`);
return value;
}
function roundValue(value: any): any {
if (typeof value === "number") return Number(value.toFixed(10));
if (Array.isArray(value)) return value.map(roundValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, roundValue(item)]));
return value;
}
function validTrade(row: NumericRecord): boolean {
return (row.status ?? "valid") === "valid" && (row.condition ?? "regular") === "regular";
}
function sortedRows(rows: NumericRecord[]): NumericRecord[] {
return [...rows].sort((left, right) => (left.event_time_ms ?? 0) - (right.event_time_ms ?? 0) || (left.sequence ?? 0) - (right.sequence ?? 0));
}
function summarize(method: string, trace: NumericRecord[]): NumericRecord {
const valid = trace.filter((row) => !["excluded-condition", "broken-or-cancelled"].includes(row.reason));
const buys = valid.filter((row) => row.sign === 1);
const sells = valid.filter((row) => row.sign === -1);
const unknown = valid.filter((row) => row.sign === 0);
const sum = (rows: NumericRecord[]) => rows.reduce((total, row) => total + row.volume, 0);
const buyVolume = sum(buys);
const sellVolume = sum(sells);
const unknownVolume = sum(unknown);
const totalVolume = buyVolume + sellVolume + unknownVolume;
let cumulative = 0;
for (const row of trace) {
cumulative += row.sign * row.volume;
row.cumulative_signed_volume = roundValue(cumulative);
}
return roundValue({
state: valid.length ? "ok" : "no-eligible-records",
method,
trace,
total_valid: valid.length,
classified_count: buys.length + sells.length,
unknown_count: unknown.length,
buy_count: buys.length,
sell_count: sells.length,
buy_volume: buyVolume,
sell_volume: sellVolume,
unknown_volume: unknownVolume,
signed_volume: buyVolume - sellVolume,
total_volume: totalVolume,
coverage: valid.length ? (buys.length + sells.length) / valid.length : 0,
imbalance_fraction: totalVolume ? (buyVolume - sellVolume) / totalVolume : 0,
classification_counts: {buy: buys.length, sell: sells.length, unknown: unknown.length},
});
}
export function tickTest(data: NumericRecord, config: NumericRecord): NumericRecord {
if (!Array.isArray(data.trades)) throw new Error("trades must be a list");
const zeroMode = config.zero_tick_mode ?? "carry";
if (!["carry", "abstain"].includes(zeroMode)) throw new Error("zero_tick_mode must be carry or abstain");
const resetOnSession = config.reset_on_session ?? true;
let previousPrice: number | null = null;
let lastNonzero = 0;
let previousSession: string | null = null;
const trace: NumericRecord[] = [];
for (const row of sortedRows(data.trades)) {
const volume = finite(row.size ?? 0, "size", 0);
const price = finite(row.price, "price", 0);
const session = String(row.session ?? "session");
if (resetOnSession && previousSession !== null && session !== previousSession) {
previousPrice = null;
lastNonzero = 0;
}
previousSession = session;
if (!validTrade(row)) {
const reason = row.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
trace.push({id: row.id, sequence: row.sequence, event_time_ms: row.event_time_ms, price, volume, sign: 0, side: "excluded", reason, reference_price: previousPrice});
continue;
}
let sign = 0;
let reason = "no-prior-trade";
if (previousPrice !== null) {
if (price > previousPrice) {
sign = 1; reason = "uptick"; lastNonzero = 1;
} else if (price < previousPrice) {
sign = -1; reason = "downtick"; lastNonzero = -1;
} else if (zeroMode === "carry" && lastNonzero) {
sign = lastNonzero; reason = lastNonzero > 0 ? "zero-uptick" : "zero-downtick";
} else {
reason = "zero-tick-unknown";
}
}
const reference = previousPrice;
previousPrice = price;
trace.push({id: row.id, sequence: row.sequence, event_time_ms: row.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, reference_price: reference});
}
return summarize("tick-test", trace);
}
function matchedQuote(trade: NumericRecord, quotes: NumericRecord[], quoteLagMs: number): NumericRecord | null {
const cutoff = finite(trade.event_time_ms, "trade event_time_ms", 0) - quoteLagMs;
const knowledge = finite(trade.available_time_ms ?? trade.event_time_ms, "trade available_time_ms", 0);
const eligible = quotes.filter((quote) => {
if ((quote.status ?? "valid") !== "valid") return false;
const eventTime = finite(quote.event_time_ms, "quote event_time_ms", 0);
const availableTime = finite(quote.available_time_ms ?? eventTime, "quote available_time_ms", 0);
const bid = finite(quote.bid, "bid", 0);
const ask = finite(quote.ask, "ask", 0);
return bid <= ask && eventTime <= cutoff && availableTime <= knowledge;
});
if (!eligible.length) return null;
return eligible.reduce((best, row) =>
row.event_time_ms > best.event_time_ms || (row.event_time_ms === best.event_time_ms && row.sequence > best.sequence) ? row : best
);
}
export function quoteTest(data: NumericRecord, config: NumericRecord): NumericRecord {
if (!Array.isArray(data.trades) || !Array.isArray(data.quotes)) throw new Error("trades and quotes must be lists");
const lag = finite(config.quote_lag_ms ?? 0, "quote_lag_ms", 0);
const tolerance = finite(config.midpoint_tolerance ?? 0, "midpoint_tolerance", 0);
const quotes = sortedRows(data.quotes);
const trace: NumericRecord[] = [];
for (const trade of sortedRows(data.trades)) {
const volume = finite(trade.size ?? 0, "size", 0);
const price = finite(trade.price, "price", 0);
if (!validTrade(trade)) {
const reason = trade.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign: 0, side: "excluded", reason, midpoint: null, bid: null, ask: null, quote_id: null});
continue;
}
const quote = matchedQuote(trade, quotes, lag);
let sign = 0, reason = "no-causal-quote";
let bid: number | null = null, ask: number | null = null, midpoint: number | null = null;
if (quote) {
bid = finite(quote.bid, "bid", 0);
ask = finite(quote.ask, "ask", 0);
midpoint = (bid + ask) / 2;
if (price > midpoint + tolerance) { sign = 1; reason = "above-midpoint"; }
else if (price < midpoint - tolerance) { sign = -1; reason = "below-midpoint"; }
else reason = "midpoint-or-tolerance-band";
}
trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, midpoint, bid, ask, quote_id: quote?.id ?? null});
}
const output = summarize("quote-test", trace);
output.quote_lag_ms = lag;
output.midpoint_tolerance = tolerance;
output.outside_spread_count = trace.filter((row) => row.bid !== null && (row.price < row.bid || row.price > row.ask)).length;
return roundValue(output);
}
export function leeReady(data: NumericRecord, config: NumericRecord): NumericRecord {
if (!Array.isArray(data.trades) || !Array.isArray(data.quotes)) throw new Error("trades and quotes must be lists");
const lag = finite(config.quote_lag_ms ?? 0, "quote_lag_ms", 0);
const tolerance = finite(config.midpoint_tolerance ?? 0, "midpoint_tolerance", 0);
const zeroMode = config.zero_tick_mode ?? "carry";
const quotes = sortedRows(data.quotes);
let previousPrice: number | null = null;
let lastNonzero = 0;
const trace: NumericRecord[] = [];
for (const trade of sortedRows(data.trades)) {
const volume = finite(trade.size ?? 0, "size", 0);
const price = finite(trade.price, "price", 0);
if (!validTrade(trade)) {
const reason = trade.status !== "valid" ? "broken-or-cancelled" : "excluded-condition";
trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign: 0, side: "excluded", reason, rule: "excluded", midpoint: null, quote_id: null});
continue;
}
let tickSign = 0, tickReason = "no-prior-trade";
if (previousPrice !== null) {
if (price > previousPrice) { tickSign = 1; tickReason = "uptick"; lastNonzero = 1; }
else if (price < previousPrice) { tickSign = -1; tickReason = "downtick"; lastNonzero = -1; }
else if (zeroMode === "carry" && lastNonzero) { tickSign = lastNonzero; tickReason = lastNonzero > 0 ? "zero-uptick" : "zero-downtick"; }
else tickReason = "zero-tick-unknown";
}
previousPrice = price;
const quote = matchedQuote(trade, quotes, lag);
let sign = 0, reason = "no-causal-quote", rule = "quote-required";
let midpoint: number | null = null;
if (quote) {
const bid = finite(quote.bid, "bid", 0);
const ask = finite(quote.ask, "ask", 0);
midpoint = (bid + ask) / 2;
if (price > midpoint + tolerance) { sign = 1; reason = "above-midpoint"; rule = "quote"; }
else if (price < midpoint - tolerance) { sign = -1; reason = "below-midpoint"; rule = "quote"; }
else { sign = tickSign; reason = `midpoint-fallback:${tickReason}`; rule = "tick-fallback"; }
}
trace.push({id: trade.id, sequence: trade.sequence, event_time_ms: trade.event_time_ms, price, volume, sign, side: sign > 0 ? "buy" : sign < 0 ? "sell" : "unknown", reason, rule, midpoint, quote_id: quote?.id ?? null});
}
const output = summarize("lee-ready", trace);
output.quote_rule_count = trace.filter((row) => row.rule === "quote").length;
output.tick_fallback_count = trace.filter((row) => row.rule === "tick-fallback").length;
output.missing_quote_count = trace.filter((row) => row.rule === "quote-required").length;
output.quote_lag_ms = lag;
return roundValue(output);
}
function logGamma(z: number): number {
const coefficients = [0.9999999999998099, 676.5203681218851, -1259.1392167224028, 771.3234287776531, -176.6150291621406, 12.507343278686905, -0.13857109526572012, 9.984369578019572e-6, 1.5056327351493116e-7];
if (z < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * z)) - logGamma(1 - z);
z -= 1;
let x = coefficients[0];
for (let i = 1; i < coefficients.length; i += 1) x += coefficients[i] / (z + i);
const t = z + 7.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
function betaFraction(a: number, b: number, x: number): number {
const qab = a + b, qap = a + 1, qam = a - 1;
let c = 1;
let d = 1 - qab * x / qap;
if (Math.abs(d) < 3e-14) d = 3e-14;
d = 1 / d;
let result = d;
for (let m = 1; m <= 200; m += 1) {
const m2 = 2 * m;
let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
d = 1 + aa * d; if (Math.abs(d) < 3e-14) d = 3e-14;
c = 1 + aa / c; if (Math.abs(c) < 3e-14) c = 3e-14;
d = 1 / d; result *= d * c;
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
d = 1 + aa * d; if (Math.abs(d) < 3e-14) d = 3e-14;
c = 1 + aa / c; if (Math.abs(c) < 3e-14) c = 3e-14;
d = 1 / d;
const delta = d * c;
result *= delta;
if (Math.abs(delta - 1) < 3e-14) break;
}
return result;
}
function regularizedBeta(x: number, a: number, b: number): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const front = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));
if (x < (a + 1) / (a + b + 2)) return front * betaFraction(a, b, x) / a;
return 1 - front * betaFraction(b, a, 1 - x) / b;
}
export function studentTCdf(value: number, degreesOfFreedom: number): number {
finite(value, "standardized price change");
finite(degreesOfFreedom, "degrees_of_freedom", 1e-9);
if (value === 0) return 0.5;
const x = degreesOfFreedom / (degreesOfFreedom + value * value);
const tail = 0.5 * regularizedBeta(x, degreesOfFreedom / 2, 0.5);
return value > 0 ? 1 - tail : tail;
}
export function bulkVolumeClassification(data: NumericRecord, config: NumericRecord): NumericRecord {
if (!Array.isArray(data.bars)) throw new Error("bars must be a list");
if (data.starting_price === null || data.starting_price === undefined) throw new Error("starting_price is required");
let previousPrice = finite(data.starting_price, "starting_price", 0);
const sigma = finite(config.sigma, "sigma", 1e-12);
const df = finite(config.degrees_of_freedom, "degrees_of_freedom", 1e-9);
const trace: NumericRecord[] = [];
for (const bar of sortedRows(data.bars)) {
const price = finite(bar.close, "close", 0);
const volume = finite(bar.volume ?? 0, "volume", 0);
if ((bar.status ?? "valid") !== "valid") {
trace.push({id: bar.id, sequence: bar.sequence, event_time_ms: bar.event_time_ms, price, volume, sign: 0, side: "excluded", reason: "broken-or-cancelled", price_change: null, z_score: null, buy_fraction: null, buy_volume: 0, sell_volume: 0});
continue;
}
const priceChange = price - previousPrice;
const zScore = priceChange / sigma;
const buyFraction = studentTCdf(zScore, df);
const buyVolume = volume * buyFraction;
const sellVolume = volume - buyVolume;
const sign = buyFraction > 0.5 ? 1 : buyFraction < 0.5 ? -1 : 0;
trace.push({id: bar.id, sequence: bar.sequence, event_time_ms: bar.event_time_ms, price, volume, sign, side: sign > 0 ? "buy-leaning" : sign < 0 ? "sell-leaning" : "balanced", reason: "student-t-volume-split", price_change: priceChange, z_score: zScore, buy_fraction: buyFraction, buy_volume: buyVolume, sell_volume: sellVolume});
previousPrice = price;
}
const valid = trace.filter((row) => row.reason !== "broken-or-cancelled");
const buyVolume = valid.reduce((total, row) => total + row.buy_volume, 0);
const sellVolume = valid.reduce((total, row) => total + row.sell_volume, 0);
const total = buyVolume + sellVolume;
let cumulative = 0;
for (const row of trace) {
cumulative += row.buy_volume - row.sell_volume;
row.cumulative_signed_volume = roundValue(cumulative);
}
return roundValue({
state: valid.length ? "ok" : "no-eligible-records",
method: "bulk-volume-classification",
trace,
total_valid: valid.length,
classified_count: valid.length,
unknown_count: 0,
buy_count: valid.filter((row) => row.sign > 0).length,
sell_count: valid.filter((row) => row.sign < 0).length,
buy_volume: buyVolume,
sell_volume: sellVolume,
unknown_volume: 0,
signed_volume: buyVolume - sellVolume,
total_volume: total,
coverage: valid.length ? 1 : 0,
imbalance_fraction: total ? (buyVolume - sellVolume) / total : 0,
sigma,
degrees_of_freedom: df,
});
}
export function runTopic(kind: string, data: NumericRecord, config: NumericRecord): NumericRecord {
if (kind === "tick") return tickTest(data, config);
if (kind === "quote") return quoteTest(data, config);
if (kind === "lee_ready") return leeReady(data, config);
if (kind === "bvc") return bulkVolumeClassification(data, config);
throw new Error(`unsupported topic kind: ${kind}`);
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.