Chapter 4
What the @wia/bio-004 TypeScript SDK, the wia-bio-004 command-line tool and the specified REST registry actually do in version 1.0, how to call them correctly, and where their behaviour departs from the text of the standard.
Chapters 2 and 3 defined what a biomarker performance claim means and how a result is recorded. This chapter turns to the software that ships with WIA-BIO-004. The repository contains three pieces: a TypeScript library (api/typescript), a Bash command-line tool (cli/wia-bio-004.sh) and, in the second phase document, a REST and Python interface for a biomarker registry. They are not equally mature. The TypeScript SDK performs real calculations and is the piece to build on. The command-line tool is, in v1.0, a demonstration shell whose numbers are fixed in the script. The REST interface is a specification with no reference server behind it.
The chapter reads the code rather than the brochure. Every behaviour described below was checked against the v1.0 source, and every number in the worked example was produced by running that source on the data shown. Where the SDK disagrees with the specification (§8.2–§8.3) or with the README, the disagreement is named and a safe way of working is proposed. Those proposals are this book's recommendations, not normative requirements of the standard.
The package is named @wia/bio-004, version 1.0.0, licensed MIT, and declares no runtime dependencies. Its package.json targets Node.js 16 or later and builds with tsup into two module formats: CommonJS (dist/index.js) and ES modules (dist/index.mjs), with declaration files alongside. A second entry point, @wia/bio-004/types, exposes the type definitions, the constants object and the error class on their own, which is convenient for a front end that only needs the shapes of the records. The package.json is configured for publication to a public registry; whether a given version is actually available there should be checked at install time rather than assumed. Installing from a clone of the repository is also possible: install.sh builds the SDK in place.
The public surface is small. There is one class, BiomarkerDataSDK, which is also the default export, and four standalone functions that each create a fresh instance and delegate to it: calculateROC, validateBiomarker, analyzeSensitivity and predictOutcome. The class additionally offers generateReport and getVersion(), which returns the string '1.0.0'; neither has a standalone wrapper. Everything in types.ts is re-exported from the main entry point, so interfaces such as ROCRequest and values such as BIOMARKER_CONSTANTS can be imported from either path.
// ES modules: named functions, the class (also the default export) and the error type
import BiomarkerDataSDK, { calculateROC, validateBiomarker, BiomarkerError } from '@wia/bio-004';
// type-only sub-export
import type { ROCResult, ValidationResult } from '@wia/bio-004/types';
// or, in a CommonJS file (resolves to dist/index.js):
const { analyzeSensitivity, BIOMARKER_CONSTANTS } = require('@wia/bio-004');
const sdk = new BiomarkerDataSDK();
sdk.getVersion(); // '1.0.0'
The v1.0 sources do not compile as shipped. Both files end with a redundant export list that re-exports names already declared with export: index.ts repeats export { BiomarkerDataSDK }, and types.ts ends with an export type { … } block and an export { BIOMARKER_CONSTANTS, BiomarkerErrorCode, BiomarkerError } line. The TypeScript compiler (the package declares ^5.3.3; the errors were reproduced with 5.9, see Chapter 8) rejects these with errors TS2323 ("Cannot redeclare exported variable") and TS2484 ("Export declaration conflicts with exported declaration"), and Node.js refuses to load the module with "Duplicate export". Deleting the trailing export lists is sufficient: after that change the library type-checks under --strict and every example in this chapter runs. A team adopting the SDK should carry that patch, or wait for a corrected release, and should add a npm run typecheck step to its build so that a regression of this kind is caught immediately.
| Method | Input type | Output type | Throws |
|---|---|---|---|
calculateROC | ROCRequest | ROCResult | B002 length mismatch or one class only; B001 fewer than 100 samples |
validateBiomarker | BiomarkerValidation | ValidationResult | Whatever the internal calculateROC throws (always called at least once) |
analyzeSensitivity | SensitivityAnalysisParams | SensitivityAnalysisResult | Same as calculateROC (always called internally) |
predictOutcome | OutcomePrediction | PredictionResult | Nothing; missing inputs become 0 |
generateReport (class only) | Biomarker, ValidationResult, optional clinical data | BiomarkerReport | Nothing |
getVersion (class only) | none | string | Nothing |
calculateROC: from Labels and Values to a Curve
A ROCRequest carries two parallel arrays and three options. trueLabels holds the reference-standard classification of each subject, 1 for diseased and 0 for healthy; predictions holds the biomarker value or model score for the same subject in the same position. The optional thresholds array restricts evaluation to caller-chosen cutoffs; calculateCI (default true) requests a confidence interval for the AUC; confidenceLevel defaults to 0.95. The request type matches the one in §8.2.1 of the specification, extended by the two confidence options.
Before any calculation the function applies three guards in a fixed order. If the arrays differ in length it throws BiomarkerError with code B002. If there are fewer than 100 entries it throws B001. If either class is absent it throws B002 again. Note that the class counts use strict comparison with 1 and 0: a label of 2 or true is counted in neither group and is skipped by the confusion matrix, yet it still counts toward the 100-sample minimum. Labels should therefore be normalised to the integers 0 and 1 before the call.
When no thresholds are supplied, the SDK builds a grid from the observed values. It sorts the unique prediction values and keeps every k-th one, where k = max(1, ⌊U/100⌋) and U is the number of unique values. With fewer than 200 unique values every observed value becomes a threshold; above that, the grid thins to roughly one hundred to two hundred points. The smallest value is always kept. If thinning drops the largest value, the grid is closed with that value plus 0.001, so the curve reaches the point (0, 0). The comment in the source says both ends are padded by ±0.001, but only the upper end ever is.
At each threshold t a subject is classed positive when value ≥ t. This fixes the direction of the test: higher values must indicate disease. For markers where low values are abnormal, such as a protein that falls in disease, the caller must negate the values (or use their reciprocal) before calling, and must remember to translate the reported cutoff back.
Each threshold yields a ROCPoint with threshold, sensitivity, specificity, fpr, tpr and youdenIndex. These values are fractions between 0 and 1, unlike the percentages returned by validateBiomarker (section 4.3). Mixing the two conventions in one dashboard is the most common presentation error with this SDK.
The AUC is the trapezoidal area under the points after sorting them by false-positive rate. The optimalThreshold is the point with the largest Youden index J = sensitivity + specificity − 1; if several points tie, the first one in ascending threshold order wins, because the comparison is strictly greater-than. Chapter 2 explains when Youden's rule is appropriate; the SDK offers no alternative rule in this function, but analyzeSensitivity (section 4.4) reports several.
When calculateCI is true the result carries aucCI95, computed from the Hanley and McNeil standard error with Q1 = AUC/(2 − AUC) and Q2 = 2·AUC²/(1 + AUC), and clipped to [0, 1]. A comment in the code calls this a "DeLong method approximation"; it is not. DeLong's method is a non-parametric estimator based on placement values and is the right tool for comparing two correlated AUCs (Chapter 2); the SDK does not implement it.
Tied false-positive rates bias the AUC. The sort that precedes the trapezoid rule orders points by FPR only. When several thresholds share an FPR, which happens whenever a run of cases lies between two control values, the points keep their threshold order, so within a tie the true-positive rate decreases instead of increasing. The trapezoid then joins the wrong corners of each vertical step. On the 120-subject data set of section 4.3 the SDK reports AUC = 0.8904, whereas the exact Mann–Whitney AUC of the same data, and the trapezoid over the same points sorted by FPR and then TPR, are both 0.8951. The error is small here but depends on the data; for a published figure, compute the exact AUC independently or sort the returned rocCurve by (FPR, TPR) and integrate it yourself.
User thresholds truncate the curve. When thresholds is supplied, the curve contains only those points. No (0, 0) or (1, 1) anchor is added, so any area outside the span of the chosen cutoffs is simply lost. On the same data, thresholds [20, 30, 40, 50] return AUC = 0.500 against 0.890 from the automatic grid: the lowest threshold still leaves a third of the controls negative, and two cutoffs share FPR = 0 in the wrong order. Use supplied thresholds to inspect operating points, never to report an AUC.
The confidence level has two values. The z-value is 1.96 when confidenceLevel is exactly 0.95 and 2.576 for anything else. Asking for a 90 % interval therefore returns a 99 % interval ([0.812, 0.969] instead of the narrower 95 % [0.830, 0.950] on the example data), without warning. Only 0.95 and 0.99 are safe inputs. The result field is also named aucCI95 whatever level was requested.
The SDK's ROCResult matches the specification's ROCResponse but adds youdenIndex per point plus nDiseased and nHealthy, and makes aucCI95 optional. Consumers should accept the extra fields and not assume the interval exists when calculateCI: false was sent.
validateBiomarker: a Validation Verdict
validateBiomarker takes a BiomarkerValidation: the biomarkerType, an array of measurements each with value, unit, a boolean diseaseStatus and an optional patientID, a referenceRange, an optional cutoffThreshold and an optional prevalence (default 0.5). It returns a ValidationResult with the verdict isValid, a performance block, the full rocAnalysis, the recommendedCutoff, a clinicalUtility grade, and warnings, errors and sampleSizeAdequate.
The cutoff is the caller's cutoffThreshold when given; otherwise it is the Youden optimum of an internal ROC analysis, and a warning records that choice. The performance block is a DiagnosticPerformance computed at that cutoff: sensitivity, specificity and accuracy, the confusion matrix, PPV and NPV, and both likelihood ratios. The clinical-utility grade comes from the AUC and the table CLINICAL_UTILITY_THRESHOLDS: 0.90 or more is excellent, 0.80 good, 0.70 fair, 0.60 poor, anything lower insufficient. Warnings are added when sensitivity or specificity falls below 80 % and when the AUC is below MIN_AUC_CLINICAL (0.70). The result is valid when there are no errors and the grade is not insufficient; a poor marker is therefore reported as valid.
Two fields of the input are accepted but not used. biomarkerType does not influence any calculation, and referenceRange is neither compared with the values nor checked against their unit. The units themselves are not compared either; the only use of unit is to decorate the cutoff warning with the unit of the first measurement. Unit harmonisation (Chapter 3, section 3.8) is the caller's job.
All rates in performance are percentages (65 means 65 %), while the likelihood ratios are plain ratios and prevalence is a fraction. More consequential is how the predictive values are produced. The SDK does not compute PPV as TP/(TP + FP). It applies Bayes' rule with the prevalence argument:
PPV = Sens·p / (Sens·p + (1 − Spec)·(1 − p)), NPV = Spec·(1 − p) / (Spec·(1 − p) + (1 − Sens)·p)
With the default p = 0.5 the result equals the count-based value only when the study itself is balanced between cases and controls. For an unbalanced study, a caller who leaves the default will receive predictive values that describe neither the study nor any real clinical setting. The rule is simple: always pass the prevalence of the intended-use population, state it next to the PPV and NPV in every report, and if the study's own count-based values are wanted, compute them from confusionMatrix.
The function is designed to collect problems into errors and return them, and it does add an error when fewer than 100 measurements are supplied or when one class is missing. In practice that array is never seen for those cases. The function always calls calculateROC internally, once to find the cutoff (if none was given) and once more for rocAnalysis, and calculateROC throws B001 or B002 first. Even with an explicit cutoffThreshold, 50 measurements produce an exception, not a result with errors filled. This is why the README's seven-sample example cannot run: it throws B001. It is also why sampleSizeAdequate is, in v1.0, always true in any result that is actually returned.
The floor itself deserves a precise statement. The SDK constant MIN_VALIDATION_SAMPLES = 100 counts all measurements, so 90 cases and 10 controls pass it. The README's validation requirements ask for at least 100 cases and 100 controls for an initial validation. The two are different rules; a study plan should meet the README's rule, and Chapter 6 discusses how to size a study properly. A small pre-check that gathers all problems before the SDK can throw makes the behaviour predictable:
import { BIOMARKER_CONSTANTS, BiomarkerValidation } from '@wia/bio-004';
/** Collects every problem before the SDK gets a chance to throw. */
export function precheck(input: BiomarkerValidation): string[] {
const problems: string[] = [];
const ms = input.measurements;
if (ms.length < BIOMARKER_CONSTANTS.MIN_VALIDATION_SAMPLES) {
problems.push(`B001: ${ms.length} measurements, need >= ${BIOMARKER_CONSTANTS.MIN_VALIDATION_SAMPLES}`);
}
if (ms.some((m) => typeof m.diseaseStatus !== 'boolean')) {
problems.push('B002: diseaseStatus must be a boolean');
}
const cases = ms.filter((m) => m.diseaseStatus === true).length;
if (cases === 0 || cases === ms.length) problems.push('B002: need both cases and controls');
const units = new Set(ms.map((m) => m.unit));
if (units.size !== 1 || !units.has(input.referenceRange.unit)) {
problems.push(`B008: mixed or mismatched units: ${[...units].join(', ')}`);
}
if (ms.some((m) => !Number.isFinite(m.value))) problems.push('B002: non-finite value');
return problems;
}
The following program uses 120 synthetic measurements of a fictional serum marker. The values are generated by a deterministic formula rather than a random generator so that the reader can reproduce the output exactly; they do not describe any real analyte. The program runs the three analysis functions in turn.
import {
calculateROC,
validateBiomarker,
analyzeSensitivity,
BiomarkerError,
BiomarkerMeasurement,
} from '@wia/bio-004';
// 120 synthetic results for a fictional serum marker "MARKER-X" (ng/mL):
// 60 controls spread over 10.0-39.5 and 60 cases over 25.0-60.4.
// The generator is deterministic, so the output below is reproducible.
const measurements: BiomarkerMeasurement[] = [];
for (let i = 0; i < 60; i++) {
measurements.push({ id: `C${i}`, biomarkerID: 'MARKER-X', unit: 'ng/mL',
value: 10 + ((i * 37) % 60) * 0.5, diseaseStatus: false });
measurements.push({ id: `D${i}`, biomarkerID: 'MARKER-X', unit: 'ng/mL',
value: 25 + ((i * 23) % 60) * 0.6, diseaseStatus: true });
}
const trueLabels = measurements.map((m) => (m.diseaseStatus === true ? 1 : 0));
const predictions = measurements.map((m) => m.value);
try {
// 1. ROC with the automatic threshold grid and a 95 % CI
const roc = calculateROC({ trueLabels, predictions });
console.log(roc.auc, roc.aucCI95, roc.optimalThreshold);
// 2. Validation at the Youden cutoff; PPV/NPV at an assumed prevalence of 0.5
const v = validateBiomarker({
biomarkerType: 'protein',
measurements: measurements.map((m) => ({
value: m.value, unit: m.unit, diseaseStatus: m.diseaseStatus === true,
})),
referenceRange: { min: 0, max: 30, unit: 'ng/mL' },
prevalence: 0.5,
});
console.log(v.clinicalUtility, v.recommendedCutoff, v.performance, v.warnings);
// 3. Threshold sweep and prevalence scenarios
const s = analyzeSensitivity({ measurements });
console.log(s.optimalThresholds, s.prevalenceImpact);
} catch (e) {
if (e instanceof BiomarkerError) console.error(e.code, e.message);
else throw e;
}
Running it with the v1.0 SDK (with the export fix from section 4.1) gives the following, rounded here for readability:
calculateROC
auc 0.890 aucCI95 [0.830, 0.950]
optimalThreshold value 37.6 sensitivity 0.650 specificity 0.933 youdenIndex 0.583
rocCurve 115 points, thresholds 10.0 ... 60.4
validateBiomarker (prevalence 0.5)
clinicalUtility 'good' recommendedCutoff 37.6
sensitivity 65.0 specificity 93.3 accuracy 79.2 (percent)
ppv 90.7 npv 72.7 lrPositive 9.75 lrNegative 0.375
confusionMatrix tp 39 fn 21 tn 56 fp 4
warnings 'Using optimal threshold from ROC: 37.60 ng/mL'
'Low sensitivity: 65.0% (target: ≥80%)'
analyzeSensitivity
optimalThresholds maxYouden 37.6 maxAccuracy 37.6 sensitivity90 28.6 specificity90 37
prevalenceImpact 0.1: ppv 52.0 / npv 96.0 0.2: ppv 70.9 / npv 91.4
0.5: ppv 90.7 / npv 72.7 0.7: ppv 95.8 / npv 53.3
0.9: ppv 98.9 / npv 22.9
The Youden optimum at 37.6 ng/mL favours specificity (93.3 %) over sensitivity (65.0 %); the SDK duly warns that sensitivity is below its 80 % target, yet grades the marker good because the grade depends only on the AUC. The PPV of 90.7 % happens to equal the count-based value 39/43 only because this study has exactly as many cases as controls. At an intended-use prevalence of 10 % the same cutoff gives a PPV of 52 %, which is the figure a clinician in a low-prevalence setting would experience. And the AUC of 0.890 is the SDK's figure; the exact value for these data is 0.895, for the reason explained in section 4.2.
The specification sketches a flat ValidationResult with sensitivity, specificity, ppv, npv, accuracy and confusionMatrix at the top level, and a four-level clinicalUtility without insufficient. The SDK nests these metrics under performance and adds isValid, rocAnalysis, warnings, errors, sampleSizeAdequate and the fifth grade. Code that reads validation.sensitivity, as the README does, receives undefined; the correct path is validation.performance.sensitivity. When exchanging results with a system written to the specification, flatten the SDK result explicitly and map insufficient to poor or to a rejection, by agreement.
analyzeSensitivity and predictOutcome
analyzeSensitivity accepts full BiomarkerMeasurement records (the Chapter 3 type, with id and biomarkerID), an optional thresholdRange of {min, max, step}, and optional prevalenceScenarios, defaulting to 0.1, 0.2, 0.5, 0.7 and 0.9. It returns three things. thresholdAnalysis lists sensitivity, specificity, PPV, NPV and accuracy (all percentages) at every threshold, either the stepped range or, by default, the ROC grid. prevalenceImpact gives PPV and NPV at the Youden cutoff for each prevalence scenario. optimalThresholds reports four cutoffs: maxYouden, maxAccuracy, sensitivity90 (the most specific threshold that still keeps sensitivity at 90 % or above) and specificity90 (the most sensitive threshold that keeps specificity at 90 % or above).
These four cutoffs are the practical answer to the question of Chapter 2, section 2.5: which rule to use. In the example, a screening application would choose the sensitivity90 cutoff of 28.6 ng/mL (sensitivity 90.0 %, specificity 63.3 %), a rule-in application the specificity90 cutoff of 37.0 ng/mL (sensitivity 66.7 %, specificity 90.0 %), and Youden's balanced rule lands at 37.6 ng/mL. Recording which rule produced the cutoff is part of the data (Chapter 3).
Three details matter. The PPV and NPV in thresholdAnalysis are always computed at a prevalence of 0.5, regardless of the scenarios; only prevalenceImpact varies prevalence. If no threshold meets a 90 % target, the corresponding field silently falls back to the Youden cutoff, and so does a legitimate cutoff of exactly 0 because the fallback uses a falsy test; compare the returned value with maxYouden before trusting it. And a stepped range is generated by repeated floating-point addition, so thresholds such as 0.30000000000000004 appear, {min: 0, max: 0.3, step: 0.1} stops at 0.2 because the last sum exceeds 0.3, and a step of zero or less never terminates. Generating the list from an integer index (min + i*step) and rounding avoids both problems.
BiomarkerMeasurement.diseaseStatus is typed as boolean | DiseaseStatus, so records carrying 'diseased', 'healthy', 'at_risk' or 'unknown' are type-correct. But analyzeSensitivity converts the status to a label with a truthiness test, and every non-empty string is truthy. A record marked 'healthy' is therefore counted as diseased. If all records use strings, every label becomes 1 and the call fails with B002 ("Must have both diseased and healthy samples"), which at least is visible. If strings and booleans are mixed, the controls marked with strings silently become cases and the results are wrong without any error. Map the status to a strict boolean first, and exclude at_risk and unknown from a diagnostic-accuracy analysis altogether.
predictOutcome applies a stored PredictionModel to one subject's marker values. For logistic_regression it forms the linear predictor β₀ + Σβᵢxᵢ from parameters.intercept and one coefficient per marker named in model.biomarkers, and returns p = 1/(1 + e^−(β₀+Σβᵢxᵢ)). For every other model type (random_forest, neural_network, svm, other) it cannot run the model, since only coefficients are stored; it falls back to the average of each present value multiplied by its coefficient, clipped to [0, 1], which is not a probability in any meaningful sense. Such models should be scored by their own runtime and only the result recorded.
The classification is fixed: positive when p ≥ 0.5, with risk bands low below 0.3, medium below 0.7 and high from 0.7. contributingFactors lists βᵢxᵢ per marker, sorted by magnitude; the intercept is excluded, so the contributions do not add up to the linear predictor. clinicalVariables is accepted but ignored, and confidenceInterval is never filled.
import { predictOutcome, PredictionModel } from '@wia/bio-004';
// Illustrative two-marker model; the coefficients are invented for the example.
const model: PredictionModel = {
id: 'MODEL-DEMO-1', name: 'Two-marker demo', type: 'logistic_regression',
biomarkers: ['markerX', 'markerY'],
parameters: { intercept: -4.0, markerX: 0.08, markerY: 0.5 },
performance: { trainingAUC: 0, validationAUC: 0 },
};
predictOutcome({ model, biomarkerValues: { markerX: 40, markerY: 1.2 } });
// linear predictor -4.0 + 0.08*40 + 0.5*1.2 = -0.2
// => { prediction: 'negative', probability: 0.450, riskCategory: 'medium',
// contributingFactors: [ { factor: 'markerX', contribution: 3.2 },
// { factor: 'markerY', contribution: 0.6 } ] }
predictOutcome({ model, biomarkerValues: { markerX: 40 } });
// markerY missing is silently treated as 0 => probability 0.310, riskCategory 'medium'
The second call shows the most dangerous behaviour: a missing marker is replaced by 0 without warning, and the probability changes. Check that every marker in model.biomarkers is present before calling. The 0.5 threshold and the risk bands are defaults of the SDK, not clinically validated decision limits; a deployed model must use the thresholds established in its own validation (Chapter 6).
generateReport and the Constants
generateReport(biomarker, validation, clinicalData?) is available only on the class. It returns a BiomarkerReport with an id of the form REPORT- followed by the current time in milliseconds, type fixed to 'validation', the supplied Biomarker, a population block whose totals are recomputed from the confusion matrix (cases = TP + FN, controls = TN + FP), the diagnosticPerformance and rocAnalysis from the validation, optional clinicalCorrelations and prognosticAnalysis, a list of recommendations, and metadata with dateGenerated and version.
The recommendations follow simple rules. Sensitivity below 80 % adds "Consider lowering threshold"; specificity below 80 % adds "Consider raising threshold". The grade then adds one sentence: excellent is described as ready for clinical implementation, good as a candidate for multi-centre validation, fair as a candidate for combination with other markers, and both poor and insufficient as not recommended for clinical use. The phrase "ready for clinical implementation" is generated from the AUC alone and ignores analytical validation (Chapter 5), external cohorts and clinical utility (Chapter 6). It should be edited, not forwarded. The time-based id is not unique under concurrency and should be replaced; dateGenerated serialises to ISO 8601 UTC, as Chapter 3 requires.
BIOMARKER_CONSTANTS is a frozen (as const) object that gathers the numeric policy of the SDK. Only some of its entries are used by the code; the others document intent. Table 4-2 lists them all.
| Constant | Value | Used by the code? | Note |
|---|---|---|---|
MIN_VALIDATION_SAMPLES | 100 | Yes, calculateROC and validateBiomarker | Total count, not per group |
MIN_AUC_CLINICAL | 0.70 | Yes, warning in validateBiomarker | Same as the fair boundary |
CLINICAL_UTILITY_THRESHOLDS | 0.90 / 0.80 / 0.70 / 0.60 | Yes, grading | The specification's AUC bands differ at the low end (Chapter 2) |
TARGET_SENSITIVITY_SCREENING | 0.90 | No | Fraction, although results are in percent |
TARGET_SPECIFICITY_CONFIRMATORY | 0.95 | No | Fraction |
MAX_CV_DIAGNOSTIC | 15.0 | No | Percent; other documents give other limits (Chapter 5) |
MAX_CV_SCREENING | 20.0 | No | Percent |
MIN_STATISTICAL_POWER | 0.80 | No | No power calculation exists in v1.0 |
DEFAULT_CONFIDENCE_LEVEL | 0.95 | No | The default is hard-coded separately in calculateROC |
ALPHA | 0.05 | No | No hypothesis test exists in v1.0 |
The 80 % limits behind the sensitivity and specificity warnings are hard-coded, not taken from this object. An implementation with different screening and confirmatory targets should check the returned performance itself.
Errors are instances of BiomarkerError, a subclass of Error with a name of 'BiomarkerError', a code from the BiomarkerErrorCode enumeration, a human-readable message and an optional details object. The enumeration defines ten codes; §8.3 of the specification defines the first six, with the same numbers and compatible meanings. Only two codes are ever thrown by v1.0.
| Code | SDK name | Specification §8.3 meaning / action | Raised in v1.0 by |
|---|---|---|---|
B001 | INSUFFICIENT_SAMPLES | Insufficient sample size / collect more samples | calculateROC, fewer than 100 entries |
B002 | INVALID_MEASUREMENTS | Invalid measurement values / check data quality | calculateROC, length mismatch or one class only |
B003 | MISSING_REFERENCE | Missing reference standard / define gold standard | Not raised |
B004 | POOR_PERFORMANCE | Poor analytical performance / improve assay | Not raised |
B005 | FAILED_VALIDATION | Failed validation / re-validate biomarker | Not raised |
B006 | LOW_STATISTICAL_POWER | Low statistical power / increase sample size | Not raised |
B007 | INVALID_THRESHOLD | Not in the specification | Not raised |
B008 | DATA_QUALITY_ISSUE | Not in the specification | Not raised |
B009 | MISSING_CLINICAL_DATA | Not in the specification | Not raised |
B010 | CALCULATION_ERROR | Not in the specification | Not raised |
The unused codes are still useful. An application that wraps the SDK can raise them from its own checks: B003 when a validation study lacks a referenceStandard, B007 for a cutoff outside the measuring range, B008 for flagged samples (hemolyzed, lipemic, icteric) that were not excluded, B009 for missing outcome data, B010 when a calculation returns a non-finite number (sensitivity is 0/0 if a class is empty at a threshold). Codes B007–B010 are SDK extensions; a system that must interoperate strictly with the specification should expect only B001–B006 from a peer.
types.ts also defines a Result<T, E> union, { success: true, data } or { success: false, error }, with an asynchronous variant, but no SDK function returns it; all of them throw. When a pipeline prefers values to exceptions, a thin wrapper gives the intended style without changing the library:
import { calculateROC, BiomarkerError, ROCRequest, ROCResult, Result } from '@wia/bio-004';
export function safeROC(req: ROCRequest): Result<ROCResult, BiomarkerError> {
try {
return { success: true, data: calculateROC(req) };
} catch (e) {
if (e instanceof BiomarkerError) return { success: false, error: e };
throw e; // programming errors are not converted
}
}
const r = safeROC({ trueLabels: [1, 0, 1], predictions: [0.9, 0.2, 0.7] });
if (!r.success) console.log(r.error.code); // 'B001' (fewer than 100 samples)
wia-bio-004 is a Bash script, installed by install.sh into /usr/local/bin (using sudo if needed) and relying on bc for arithmetic. It has six commands, summarised in Table 4-4.
| Command | Options | Declared purpose | v1.0 behaviour |
|---|---|---|---|
calc-roc | --data <csv>, --threshold <t> (default 0.5) | ROC curve and AUC | Checks the file exists and counts its lines; prints fixed TP 75 / TN 85 / FP 15 / FN 25 and AUC 0.85. The threshold is only echoed. |
validate | --type <type>, --data <json> | Validate performance | Does not open the file; prints fixed counts, metrics and "PASS" checks. |
analyze | --data <csv>, --sensitivity, --specificity | Sensitivity and prevalence analysis | Does not open the file; prints a fixed table. |
report | --input <file>, --output <file> (default report.txt) | Validation report | Writes a fixed text template to the output path, overwriting any existing file. |
predict | --model <json>, --data <csv> | Outcome prediction | Does not open either file; prints a fixed probability and risk band. |
version, help | none (help also accepts --help, -h) | Information | As described. There is no --version flag: it is rejected as an unknown command with exit status 1. |
The v1.0 CLI must be read as a demonstration of the intended command shape. Apart from checking that the calc-roc data file exists and counting its rows, it reads no data. Its printed metrics are fixed in the script and are identical for every input:
# abridged; colour codes removed
$ printf 'label,value\n1,0.9\n' > one-row.csv
$ wia-bio-004 calc-roc --data one-row.csv --threshold 0.7
Total samples: 1
✓ AUC: 0.85
Sensitivity: 75.00%
PPV: 83.00%
NPV: 77.00%
✓ Clinical Utility: GOOD (AUC ≥ 0.80)
A one-row file receives an AUC of 0.85 and a "GOOD" grade. The printed PPV and NPV are also truncated rather than rounded, because bc divides at two decimal places before multiplying by 100 (75/90 is shown as 83.00 % instead of 83.3 %). The prevalence table of the analyze command contains NPVs that are inconsistent with its own sensitivity and specificity (Chapter 2 gives the correct values), and the report template claims "ADEQUATE (≥100 cases and controls)" and "Statistical Power: SUFFICIENT" whatever the input. None of these numbers may be quoted as a result, pasted into a validation file or used as a test oracle. The README's suggestion of --output report.pdf produces a plain-text file with a .pdf name, and its wia-bio-004 --version check fails; use wia-bio-004 version.
For real command-line work, write a short Node.js script that reads a CSV or the Chapter 3 JSON, calls the SDK, keeps the CLI's command names and exits non-zero with the BiomarkerError code when the SDK throws. Chapter 8 lists "make the CLI read data" among the open issues.
The second phase document specifies an HTTP interface for a biomarker registry, with a base path ending in /biomarker/v1 and resource paths prefixed /v1. It is a specified interface: the repository contains no server that implements it, and the example host name in the document should not be assumed to be a live service. The document gives four operations.
| Operation | Purpose | Main payload fields | Relation to the data formats |
|---|---|---|---|
POST /v1/biomarkers | Register a biomarker definition | name, type, disease, analytes[], algorithm, cutoff, performance | A reduced form of the biomarker definition (section 3.4); type is the clinical role, not the modality |
GET /v1/biomarkers?disease=&type= | Search the registry | Returns biomarkers[] with id, name, type, disease, therapy, validationStatus | Query by clinical role (for example predictive) |
POST /v1/clinical/results | Submit a patient result | patientId, biomarkerId, specimenId, result{value, unit, interpretation}, performedBy, performedDate | A subset of the measurement record (section 3.1) |
POST /v1/validation/studies | Register a validation study | biomarkerId, studyType, design, sampleSize, endpoints[], expectedCompletion | Registration of a planned study; the results use the section 3.5 record |
A result submission, with illustrative identifiers and values, looks like this:
POST /v1/clinical/results
Content-Type: application/json
{
"patientId": "PT-000123-CODED",
"biomarkerId": "BM_EXAMPLE_01",
"specimenId": "SPEC-2026-000045",
"result": { "value": 42.5, "unit": "ng/mL", "interpretation": "above cutoff" },
"performedBy": "LAB-EXAMPLE-01",
"performedDate": "2026-03-02"
}
Implementing this interface means settling several points the document leaves open. First, naming style: the REST payloads use patientId and biomarkerId, while the measurement record and SDK use biomarkerID and patientID. A server should accept the REST names on the wire and map them explicitly, not guess. Second, the type of a registry entry is a clinical role (diagnostic, predictive), whereas the SDK's Biomarker.type is a modality (protein, genetic, …); Chapter 1 explained why both axes are needed, and a registry should store both in separate fields. Third, the registry expresses performance as fractions (0.91) while the SDK's DiagnosticPerformance uses percentages; one convention must be chosen for storage and the conversion made at the boundary. Fourth, the example studyType: "clinical_validation" is one of three incompatible enumerations for study type (Chapter 3, section 3.5); this book recommends the SDK enumeration (discovery, validation, clinical_utility, multi_center). Finally, result in the submission carries only value, unit and a free-text interpretation; the specimen type, method, collection and measurement times and quality flags of section 3.1 are missing, and a server that accepts results for later validation analysis should require them.
The document's payload examples also contain performance figures for named markers; they are illustrative and unverified. A registry must never be seeded with example numbers, and every stored performance block should point to the validation study that produced it.
The same document sketches a Python client, wia_bio.BiomarkerClient(api_key=…), with three groups of methods: client.biomarkers.create(name, type, disease, analytes), client.validation.analytical(biomarker_id, LOD_samples, precision_replicates) and client.clinical.submit_result(patient_id, biomarker_id, value, interpretation). No such package ships in the repository; the sketch is useful only as the intended client shape, whose analytical-validation parameters correspond to the LOD and precision protocols of Chapter 5.
A team that builds the registry should write an OpenAPI description of its own implementation with the mapping decisions above made explicit. Clinical results are personal health data, so authentication should follow the access-control model of Chapter 7 rather than a single API key. Result submission should be idempotent on a client-supplied identifier (for example specimen identifier plus analyte), error bodies should reuse the B codes of Table 4-3, and validation endpoints should run the SDK on stored measurements rather than accept pre-computed metrics.
The v1.0 software of WIA-BIO-004 is usable when its limits are known. The TypeScript SDK computes real results once its export lists are fixed; the command-line tool prints fixed demonstration numbers; the REST registry and Python client are specified interfaces without a reference implementation. Table 4-6 collects the working rules that follow from this chapter.
| Situation | Rule |
|---|---|
| Reporting an AUC | Use the automatic grid; cross-check with an exact Mann–Whitney AUC; never report an AUC from user-supplied thresholds |
| Confidence interval | Request only 0.95 or 0.99; call it Hanley–McNeil, not DeLong |
| Predictive values | Always pass the intended-use prevalence and print it beside PPV/NPV |
| Input preparation | Labels as 0/1, status as strict boolean, one unit, high values mean disease; run a pre-check before the SDK |
| Choosing a cutoff | Pick sensitivity90, specificity90 or Youden by intended use and record the rule |
| Predictions | Verify all model inputs are present; use the model's validated thresholds, not 0.5 |
| Reports | Replace the time-based ID; review generated recommendations before release |
| CLI | Treat output as demonstration only; use an SDK-based script for real data |
| REST registry | Write an OpenAPI description, map names and units explicitly, make submissions idempotent |
Chapter 5 turns from the software to the laboratory: the analytical-validation protocol and quality control that must stand behind every number these tools compute.