Calculate a high-minus-low portfolio return and state exactly how gross exposure and cost accounting scale it. The useful result is not just a scalar. You should be able to trace it back to eligible observations, explain which convention produced it and recognize when the calculation should stop.
This tutorial builds Long-one/short-one high-minus-low return, gross exposure two. Equal weighting within predeclared high and low memberships; cost approximation is explicit. You will calculate a small example, run matching Python and TypeScript implementations, inspect a controlled synthetic case and change one assumption in a guided lab. No historical market performance is claimed.
The chart shows the canonical fixture. Read the axis units before comparing values: a return, a score, a weight and a statistical diagnostic are different objects. Its numerical source is the same fixture used by the executable examples. Open the full-size chart when you need to inspect small labels.
Start with the question, then the mechanism
The factor return inherits the capital convention of both legs. Reporting three percent without explaining long-one/short-one hides a gross exposure of two. Cost accounting adds another convention: whether a basis-point charge applies to one-way turnover or total traded notional. The lab keeps those labels next to the waterfall, so a changing cost input visibly subtracts the exact ledger amount from the same fixed gross outcome.
Keep formation scores fixed, join labels by stable ID, and evaluate only outcomes whose full horizon and availability precede the evaluation cutoff.
Evaluation begins after the information clock is frozen
A factor evaluation needs two clocks. The score is fixed at formation from information then available. Its forward outcome becomes observable only later. Saving a formation date next to a return is not enough: the label must start after the formation decision, cover the intended horizon and be mature by the evaluation date. This package rejects inconsistent envelope dates. An upstream join must still verify every entity and period inside that envelope.
An IC measures association, not a portfolio return. A quantile spread measures two chosen return legs, not the full implementable strategy. Turnover measures trading under a particular denominator and pretrade-weight convention, not execution cost by itself. A decay curve compares overlapping horizons whose estimates are statistically dependent. Choose the measure that answers the decision you are actually making and retain the intermediate values that establish its meaning.
Cross-sectional observations can share sector or market shocks. Repeated formation dates can share both names and forward-return periods. Consequently, a large sample of entity-date rows is not automatically a large independent sample. This package does not attach an unjustified independent-observation significance claim to the IC. For inference across dates, specify the sampling unit, overlap, clustering or resampling design and multiple-testing policy before reporting a result.
Maintain a coverage report separately from the metric. Names lost through delisting, unavailable prices or late accounting data can change the apparent result. Use a common eligible cohort when comparing horizons here; allowing the cohort to change would mix a horizon effect with a composition effect. A production strategy then needs an execution calendar, costs, capacity and out-of-sample evaluation. The synthetic fixtures prove arithmetic and contract behavior, not the existence of an investable premium.
Freeze the definition
Each leg has nonnegative weights summing to one; r is a matched forward simple return; c is decimal cost per one-way traded unit and T is supplied one-way turnover.
The sources establish the method's research context; the stated variant fixes the implementation choices for this package. See MSCI, Foundations of Factor Investing, Sharpe, The Arithmetic of Active Management. Where a teaching convention differs from a published portfolio or test, it is labeled explicitly rather than borrowing the published method's empirical conclusions.
Work a small example before running the code
High-leg mean return 0.04 and low-leg mean 0.01 create gross spread 0.03. At 10 basis points per one-way unit with each leg turning over 0.5, cost is 0.001×(0.5+0.5)=0.001 and net spread is 0.029. A gross-one normalization would halve the return and exposure.
The machine-readable hand check is saved separately from the larger chart fixture. It asserts net_spread against 0.029. Some hand checks use a different small input from the prose example to test the same invariant from another direction. For a model with several regressors, a one-row attribution example cannot estimate the loadings; the multi-period executable fixture supplies the necessary observations.
To audit the arithmetic, carry full precision through intermediate values and round only for display. Ask whether the result's unit is consistent with the formula. Then consider a limiting case: does the method return an explicit rejection or undefined result when its denominator or identifying variation disappears?
Prepare data without borrowing from the future
| Input | Type | Meaning |
|---|---|---|
| ids | string[] | Unique security IDs. |
| buckets | integer[] | Predeclared ascending bucket membership. |
| forward_returns | number[] | Mature same-horizon forward total returns. |
| cost_bps | number | Nonnegative cost per one-way turnover unit. |
| turnover_high | number | Nonnegative high-leg one-way turnover. |
| turnover_low | number | Nonnegative low-leg one-way turnover. |
All calls also require formation_at, inputs_available_at and as_of as real ISO calendar dates. Inputs must be available by formation; formation cannot exceed the evaluation cutoff. Evaluation topics additionally require outcome start, end and availability dates. These envelope checks reject impossible chronology but cannot certify the provenance of individual rows. Your adapter must verify IDs, timestamps, frequency, currency, total-return adjustments, release dates and source vintages before building the arrays.
Missing, nonfinite, boolean or string-valued numbers are not silently repaired. The complete-case contract is intentional: changing eligibility changes the quantity being measured. Preserve the rejected records and the reason in a data-quality report, then choose a documented repair or a different model. Do not turn an undefined quantity into zero to make a chart look complete.
Follow the execution path
- Freeze formation information. Keep formation scores fixed, join labels by stable ID, and evaluate only outcomes whose full horizon and availability precede the evaluation cutoff.
- Join matured outcomes. Retain the inputs and the intermediate quantities; alignment is part of correctness.
- Calculate paired contributions. Calculate at full precision using the declared variant, not a convenient substitute.
- Inspect support and ambiguity. Check the method’s invariant and preserve undefined outcomes separately from numeric zero.
- Compare the alternative. A cross-sectional association on one date is neither a stable expected premium nor a net-of-cost strategy result.
Run the reference implementation
From the downloaded topic directory:
python examples/run.py
python -m unittest discover -s tests -p "test_*.py"
npx tsc -p implementations/typescript/tsconfig.json
node tests/test-typescript.mjs
The Python calculation has no third-party runtime dependency. TypeScript needs a compiler and an ES2022-capable JavaScript runtime. The public call accepts one JSON-shaped input and returns a discriminated success or error object. A minimal Python integration is:
from pathlib import Path
import importlib.util, json
root = Path.cwd() # Run from this topic directory.
spec = importlib.util.spec_from_file_location("topic", root / "implementations/python/algorithm.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data = json.loads((root / "datasets/canonical-input.json").read_text())
result = module.compute(data)
if result["status"] != "ok":
raise ValueError(result["code"])
print(result["primary"])
After compilation, the equivalent TypeScript module can be used from JavaScript:
import { readFileSync } from 'node:fs';
import { compute } from './implementations/typescript/dist/algorithm.js';
const input = JSON.parse(readFileSync('./datasets/canonical-input.json', 'utf8'));
const result = compute(input);
if (result.status !== 'ok') throw new Error(result.code);
console.log(result.primary);
The canonical primary display is 0.0161497402. More informative output fields include:
| Field | Canonical value / first values |
|---|---|
| ids | ["SYN-001", "SYN-002", "SYN-003", "SYN-004", "SYN-005", "SYN-006", …] |
| high_return | 0.0230091397 |
| low_return | 0.00585939946 |
| gross_spread | 0.0171497402 |
| cost | 0.001 |
Inspect the complete returned object rather than reducing every use case to primary. That field is a playground convenience; the named intermediate and result fields preserve the method's meaning. Both languages use the same defaults and reason codes and do not mutate the input. Package tests compare the whole output tree, while independent mathematical checks avoid treating one implementation as the sole authority for the other.
Use the playground as an experiment
Open the topic's Playground tab or the self-contained guided lab. It starts from a meaningful canonical preview. Choose a scenario, predict the result, use Step to follow the calculation, and explain the evidence before pressing Play. Back and Reset let you revisit exactly the same state. Reduced-motion mode advances one deliberate step instead of running a timed sequence.
The main control is Cost per one-way unit (bps), ranging from 0 to 100 with default 10. The comparison scenario is High and low labels reversed. Swapping the legs reverses gross spread; costs continue to subtract from both directions. Every change recomputes the result through the validated TypeScript kernel; it does not select a prerecorded result.
The deliberate failure scenario, Outcome not yet available at evaluation, should return IMMATURE_OUTCOME. First explain which assumption failed. Then return to the canonical case and identify the information that makes the calculation possible. This rejection is part of the lesson: it prevents an invalid model from producing a plausible-looking number.
This second chart uses the comparison scenario at the default parameter. The caption and diagnostics in the lab explain what changes and what remains invariant. Identical output can be the correct outcome of an invariance experiment; do not mistake it for a broken control.
Avoid these interpretation failures
- A costless spread is not a tradable result after borrow and financing.
- Do not sort securities using their forward returns.
- Empty or identical high and low buckets cannot form a meaningful spread.
A cross-sectional association on one date is neither a stable expected premium nor a net-of-cost strategy result.
Check your understanding
Predict: If high and low portfolio labels swap, should net return simply change sign?
Explain: Gross spread changes sign, but costs still subtract. Therefore net spread is not generally the negative of its previous value.
Investigate: Run the canonical case, the comparison and the deliberate rejection. Save the input, output and one sentence explaining each difference. Identify a field whose unit could be confused with another field, and describe the consequence of that confusion.
Transfer: Before substituting real data, write the upstream eligibility and alignment rules. Name the source vintage, decision time and missing-value policy. Then identify one out-of-sample or data-quality check needed for your intended use. A successful synthetic calculation is a correctness demonstration, not evidence that the market rewards the signal.
What this package does and does not establish
The implementation makes the declared formula reproducible, exposes intermediates and rejects known invalid inputs. The sources motivate the method. The synthetic fixture lets you control one mechanism at a time. A named historical case remains deferred until its source observations and decision-time provenance can be archived; no invented returns are presented as real history.
Production use needs dataset-specific validation, monitored numerical limits, error logging, independent review and an execution or inference design appropriate to the application. See the source-package data contract and reference ledger for the full boundary. Educational material is not a recommendation to buy, sell or allocate capital.
Sources and further reading
- MSCI, Foundations of Factor Investing. Construction choices matter; this package does not reproduce an MSCI index or its current methodology.
- Sharpe, The Arithmetic of Active Management. Beginning portfolio weights, costs and comparable benchmarks; our turnover convention is explicit.
Choosing the method and continuing the lesson
This lesson is for analysts and developers who can work with aligned numerical arrays, means and return units. Regression and statistical-test topics also assume familiarity with residuals and sampling uncertainty; review the linked prerequisite before interpreting an inferential result.
| Decision | Declared approach | Neighbor or alternative |
|---|---|---|
| Spread versus long-only high bucket | Subtracts a financed short leg. | Retains market direction and a different capital base. |
| Arithmetic spread versus return ratio | Subtracts same-period portfolio returns. | A wealth ratio answers a different relative-performance question. |
Use the declared approach when its input and interpretation match your research question. If you choose the alternative, freeze a new convention and rerun the examples; changing a label is not enough to change the calculation.
Related concepts
Holding-period return, Point-in-time dataset. For any use with observed market data, keep the point-in-time dataset boundary explicit.
Learning connections
- Prerequisite: Quantile Portfolio Sort. Establish the inputs or mathematical distinction used here.
- Comparison: Information Coefficient. Compare its question and output units before substituting it for this method.
- Continue with: Factor Decay Curve. Carry the same formation clock and declared units into the next calculation.
Rendered from the canonical Mermaid sources linked by this article.
Calculation flow
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.
MSCI, Foundations of Factor Investing
- Source: MSCI, Foundations of Factor Investing
- Version / date: 2013
- Accessed: 2026-09-22
- Supports: Construction choices matter; this package does not reproduce an MSCI index or its current methodology.
- Limitations: methodological context only; no claim that the source validates this synthetic sample or every educational convention.
- Reuse: cited, not copied. No source dataset is redistributed.
Sharpe, The Arithmetic of Active Management
- Source: Sharpe, The Arithmetic of Active Management
- Version / date: 1991
- Accessed: 2026-09-22
- Supports: Beginning portfolio weights, costs and comparable benchmarks; our turnover convention is explicit.
- Limitations: methodological context only; no claim that the source validates this synthetic sample or every educational convention.
- Reuse: cited, not copied. No source dataset is redistributed.
Evidence boundaries
The formulas are operationalized in the canonical README with explicit package conventions. Original synthetic fixtures isolate mechanisms and are not a historical performance claim. External source access can be restricted; the MacKinlay archive is a bibliographic reference, not a claim that its full text was retrieved during this build.
Historical case decision: deferred. A named empirical case would require a separately archived point-in-time universe, source vintage and outcome design. A synthetic control is used here to demonstrate long short wealth without attributing invented observations to a market. This limits empirical coverage; it does not change the mathematical contract.
When using live data, archive the retrieval date, provider query, license, currency, frequency, adjustment basis and transformation log. Do not imply that the primary authors endorsed this educational implementation.
Full dependency-light reference implementations in both supported languages.
/** D17 reference algorithms. JSON boundary validation is deliberate and shared.
* Arrays are copied before sorting; callers' inputs are never mutated.
* QR solves least squares without forming normal equations.
*/
type Data = Record<string, any>;
type Result = Record<string, any>;
class ContractError extends Error {
}
const fail = (code: string): never => { throw new ContractError(code); };
const num = (x: unknown): number => typeof x === 'number' && Number.isFinite(x) ? x : fail('INVALID_NUMBER');
function integer(x: unknown, lo: number, hi: number): number { const v = num(x); return Number.isInteger(v) && v >= lo && v <= hi ? v : fail('INVALID_PARAMETER'); }
function vec(x: unknown, min = 1): number[] { if (!Array.isArray(x))
fail('INVALID_SHAPE'); const a = x as unknown[]; if (a.length < min)
fail('INSUFFICIENT_DATA'); return a.map(num); }
function mat(x: unknown, min = 1): number[][] { if (!Array.isArray(x) || x.length < min)
fail('INSUFFICIENT_DATA'); const a = (x as unknown[]).map(v => vec(v)); if (new Set(a.map(r => r.length)).size !== 1)
fail('LENGTH_MISMATCH'); return a; }
function same(...x: {
length: number;
}[]): void { if (new Set(x.map(a => a.length)).size !== 1)
fail('LENGTH_MISMATCH'); }
function ids(d: Data, n: number): string[] { required(d, ['ids']); if (!Array.isArray(d.ids) || d.ids.length !== n)
fail('LENGTH_MISMATCH'); if (d.ids.some((v: unknown) => typeof v !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(v)))
fail('INVALID_ID'); if (new Set(d.ids).size !== n)
fail('DUPLICATE_ID'); return [...d.ids]; }
function required(d: Data, keys: string[]): void { for (const key of keys)
if (!Object.hasOwn(d, key))
fail('MISSING_FIELD'); }
function validDate(v: unknown): boolean { if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v) || v.startsWith('0000'))
return false; const date = new Date(v + 'T00:00:00Z'); return Number.isFinite(date.valueOf()) && date.toISOString().slice(0, 10) === v; }
function context(d: Data, op: string): void {
const keys = ['as_of', 'formation_at', 'inputs_available_at'];
const evaluation = ['ic', 'rank_ic', 'spread', 'decay'].includes(op);
if (evaluation)
keys.push('outcomes_start_at', 'outcomes_end_at', 'outcomes_available_at');
required(d, keys);
if (keys.some(k => !validDate(d[k])))
fail('INVALID_DATE');
if (d.formation_at > d.as_of || d.inputs_available_at > d.formation_at)
fail('FUTURE_INPUT');
if (evaluation) {
if (d.outcomes_start_at < d.formation_at || d.outcomes_end_at < d.outcomes_start_at)
fail('INVALID_OUTCOME_WINDOW');
if (d.outcomes_available_at < d.outcomes_end_at)
fail('INVALID_DATE_ORDER');
if (d.outcomes_available_at > d.as_of)
fail('IMMATURE_OUTCOME');
}
}
const sum = (a: number[]): number => a.reduce((s, v) => s + v, 0);
const mean = (a: number[]): number => sum(a) / a.length;
const dot = (a: number[], b: number[]): number => sum(a.map((v, i) => v * b[i]));
const tr = (a: number[][]): number[][] => a[0].map((_, j) => a.map(r => r[j]));
const mm = (a: number[][], b: number[][]): number[][] => { const cols = tr(b); return a.map(row => cols.map(col => dot(row, col))); };
function sd(x: number[], ddof = 0): number { const m = mean(x); return Math.sqrt(sum(x.map(v => (v - m) ** 2)) / (x.length - ddof)); }
function standard(x: number[]): number[] { const m = mean(x), s = sd(x); if (s <= 1e-14 * Math.max(1, ...x.map(Math.abs)))
fail('CONSTANT_CROSS_SECTION'); return x.map(v => (v - m) / s); }
function corr(x: number[], y: number[]): number | null { same(x, y); if (Math.max(...x) === Math.min(...x) || Math.max(...y) === Math.min(...y))
return null; const xm = mean(x), ym = mean(y), a = x.map(v => v - xm), b = y.map(v => v - ym), den = Math.sqrt(dot(a, a) * dot(b, b)); return den <= 0 ? null : Math.max(-1, Math.min(1, dot(a, b) / den)); }
function ranks(x: number[]): number[] { const order = x.map((_, i) => i).sort((a, b) => x[a] - x[b]); const out = x.map(() => 0); let start = 0; while (start < x.length) {
let end = start + 1;
while (end < x.length && x[order[end]] === x[order[start]])
end++;
for (let j = start; j < end; j++)
out[order[j]] = (start + 1 + end) / 2;
start = end;
} return out; }
function quantile(x: number[], p: number): number { const y = [...x].sort((a, b) => a - b), h = (y.length - 1) * p, j = Math.floor(h), f = h - j; return y[j] * (1 - f) + y[Math.min(j + 1, y.length - 1)] * f; }
function compound(x: number[]): number { if (x.some(v => v <= -1))
fail('INVALID_RETURN'); return Math.expm1(sum(x.map(Math.log1p))); }
/** erfc(|z|/sqrt(2)) via regularized Gamma(1/2,x); converged series/CF. */
function normalP(z: number): number {
const x = z * z / 2, a = 0.5, lg = 0.5723649429247001;
if (x === 0)
return 1;
const factor = Math.exp(-x + a * Math.log(x) - lg);
if (x < a + 1) {
let term = 1 / a, total = term, ap = a;
for (let i = 1; i < 500; i++) {
ap++;
term *= x / ap;
total += term;
if (Math.abs(term) < Math.abs(total) * 1e-15)
break;
}
return Math.max(0, 1 - total * factor);
}
let b = x + 1 - a, c = 1e300, d = 1 / b, h = d;
for (let i = 1; i < 500; i++) {
const an = -i * (i - a);
b += 2;
d = an * d + b;
if (Math.abs(d) < 1e-300)
d = 1e-300;
c = b + an / c;
if (Math.abs(c) < 1e-300)
c = 1e-300;
d = 1 / d;
const delta = d * c;
h *= delta;
if (Math.abs(delta - 1) < 1e-15)
break;
}
return Math.max(0, Math.min(1, factor * h));
}
export function ols(y: number[], x: number[][], lags = 0): Result {
const n = y.length, p = x[0].length;
same(y, x);
if (n <= p)
fail('INSUFFICIENT_DATA');
integer(lags, 0, n - 1);
const cols = tr(x), scales = cols.map(c => Math.sqrt(dot(c, c)));
if (scales.some(s => s === 0))
fail('SINGULAR_DESIGN');
const q: number[][] = [], r = Array.from({ length: p }, () => Array(p).fill(0) as number[]);
for (let j = 0; j < p; j++) {
let v = cols[j].map(z => z / scales[j]);
for (let pass = 0; pass < 2; pass++)
for (let i = 0; i < j; i++) {
const proj = dot(q[i], v);
r[i][j] += proj;
v = v.map((z, t) => z - proj * q[i][t]);
}
r[j][j] = Math.sqrt(dot(v, v));
if (r[j][j] < 1e-10)
fail('SINGULAR_DESIGN');
q.push(v.map(z => z / r[j][j]));
}
const solve = (v: number[]): number[] => { const b = Array(p).fill(0) as number[]; for (let i = p - 1; i >= 0; i--) {
let s = 0;
for (let j = i + 1; j < p; j++)
s += r[i][j] * b[j];
b[i] = (v[i] - s) / r[i][i];
} return b; };
const beta = solve(q.map(c => dot(c, y))).map((b, i) => b / scales[i]), fitted = x.map(row => dot(row, beta)), residuals = y.map((v, i) => v - fitted[i]);
const invr = tr(Array.from({ length: p }, (_, j) => solve(Array.from({ length: p }, (_, i) => Number(i === j)))));
const bread = mm(invr, tr(invr)).map((row, i) => row.map((v, j) => v / scales[i] / scales[j]));
const scores = x.map((row, t) => row.map(v => v * residuals[t])), meat = mm(tr(scores), scores);
for (let lag = 1; lag <= lags; lag++) {
const w = 1 - lag / (lags + 1);
for (let t = lag; t < n; t++)
for (let i = 0; i < p; i++)
for (let j = 0; j < p; j++)
meat[i][j] += w * (scores[t][i] * scores[t - lag][j] + scores[t - lag][i] * scores[t][j]);
}
const cov = mm(mm(bread, meat), bread), se = cov.map((row, i) => Math.sqrt(Math.max(0, row[i]))), sse = dot(residuals, residuals), ym = mean(y), sst = sum(y.map(v => (v - ym) ** 2));
return { coefficients: beta, standard_errors: se, fitted, residuals, r_squared: sst === 0 ? null : 1 - sse / sst, n, df_residual: n - p, hac_lags: lags, residual_sum_squares: sse, qr_min_diagonal: Math.min(...r.map((row, i) => row[i])) };
}
function evaluate(d: Data, op: string): Result {
if (op === 'turnover') {
required(d, ['old_weights', 'target_weights', 'holding_returns']);
const old = vec(d.old_weights, 2), target = vec(d.target_weights, 2), r = vec(d.holding_returns, 2);
same(old, target, r);
const names = ids(d, old.length);
if (Math.min(...old, ...target) < 0 || Math.abs(sum(old) - 1) > 1e-10 || Math.abs(sum(target) - 1) > 1e-10)
fail('INVALID_WEIGHTS');
if (Math.min(...r) <= -1)
fail('INVALID_RETURN');
const wealth = 1 + dot(old, r);
if (wealth <= 0)
fail('INVALID_WEALTH');
const pre = old.map((v, i) => v * (1 + r[i]) / wealth), trades = target.map((v, i) => v - pre[i]), t = sum(trades.map(Math.abs)) / 2;
return { ids: names, pretrade_weights: pre, trades, turnover: t, two_way: 2 * t, wealth, primary: t };
}
if (op === 'spread') {
required(d, ['forward_returns', 'buckets']);
const r = vec(d.forward_returns, 2), names = ids(d, r.length), buckets = vec(d.buckets, 2);
same(r, buckets);
if (buckets.some(v => !Number.isInteger(v) || v < 1))
fail('INVALID_PARAMETER');
const lo = Math.min(...buckets), hi = Math.max(...buckets);
if (lo === hi)
fail('EMPTY_LEG');
if (Math.min(...r) < -1)
fail('INVALID_RETURN');
const rh = mean(r.filter((_, i) => buckets[i] === hi)), rl = mean(r.filter((_, i) => buckets[i] === lo)), cost = num(d.cost_bps ?? 0), th = num(d.turnover_high ?? 0), tl = num(d.turnover_low ?? 0);
if (Math.min(cost, th, tl) < 0)
fail('INVALID_PARAMETER');
const drag = cost / 10000 * (th + tl);
return { ids: names, high_return: rh, low_return: rl, gross_spread: rh - rl, cost: drag, net_spread: rh - rl - drag, gross_exposure: 2, primary: rh - rl - drag };
}
required(d, ['scores']);
const s = vec(d.scores, 3), names = ids(d, s.length);
if (op === 'decay') {
required(d, ['forward_path']);
const paths = mat(d.forward_path, 3);
same(s, paths);
const h = integer(d.max_horizon ?? paths[0].length, 1, paths[0].length), labels = Array.from({ length: h }, (_, j) => paths.map(row => compound(row.slice(0, j + 1)))), curve = labels.map(row => corr(s, row));
return { ids: names, horizons: Array.from({ length: h }, (_, j) => j + 1), correlations: curve, labels_by_horizon: labels, n: s.length, primary: curve[h - 1], undefined_horizons: curve.map((v, i) => v === null ? i + 1 : 0).filter(Boolean) };
}
required(d, ['forward_returns']);
const r = vec(d.forward_returns, 3);
same(s, r);
if (Math.min(...r) < -1)
fail('INVALID_RETURN');
const a = op === 'rank_ic' ? ranks(s) : s, b = op === 'rank_ic' ? ranks(r) : r, c = corr(a, b);
return { ids: names, correlation: c, score_coordinates: a, return_coordinates: b, n: s.length, primary: c, defined: c !== null };
}
/** D17-F04-A04 public boundary. No mutation, implicit imputation or silent failure. */
export function compute(input: unknown): Result {
const op = "spread";
try {
if (!input || typeof input !== 'object' || Array.isArray(input))
fail('INVALID_SHAPE');
const d = input as Data;
if (Object.values(d).some(v => v === null))
fail('INVALID_NUMBER');
context(d, op);
const result = evaluate(d, op);
const check = (v: unknown): void => { if (typeof v === 'number' && !Number.isFinite(v))
fail('NUMERICAL_FAILURE'); if (Array.isArray(v))
v.forEach(check);
else if (v && typeof v === 'object')
Object.values(v).forEach(check); };
check(result);
return { status: 'ok', method: op, ...result };
}
catch (error) {
if (error instanceof ContractError)
return { status: 'error', method: op, code: error.message };
throw error;
}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
