Assay
Historical reader dependency boundaries
The single strict saved-record reader now lives in
src/components/tools/assay-analyser/saved-record.ts; model.ts re-exports
its original public functions and reader types. The Study material summary
imports that reader directly, without the import parser, curve derivation or
result summarisation in model.ts. Supporting source/identity guards are
unchanged. Mode sub-records still load through mode-readers.ts, remain
pending until their readers load, and retain the same unknown-field and
version refusals. Reading summary facts does not run an analysis.
Retained Assay research/import context uses the shared strict functions in
research-record-readers.ts; the public research-record.ts module re-exports
those same functions for existing callers. Historical readers load neither the
context presentation copy nor the table-decision summary renderer. Stored cell
ranges use range-reader.ts and shared workbook limits in xlsx-limits.ts,
without loading CSV/XLSX grid parsing; range.ts and xlsx.ts retain their
public re-exports. Validation branches, numeric limits and recorded scientific
values are unchanged. The production size comparison includes these shared
modules and the affected Study evidence/report rows; no extra lazy-reader
boundary or budget allowance is introduced.
The historical repeat-source receipt reader lives in repeat-reader.ts, with
its original exports retained by repeat.ts. Assay record validation reads
that leaf, removing the Repeat setup operation and its mode-factory imports
from the receipt-reader boundary. The receipt validation and Repeat setup
behaviour are unchanged.
Historical calibration result/settings readers and their types/constants live
in src/lib/fit/calibration-result-reader.ts; calibration-model.ts re-exports
the same function objects. Notebook formatting imports that pure leaf so a
retained calibration figure does not load current Assay file-import, CSV or
XLSX decision code. Validation and scientific calculations are unchanged.
Assay (registry slug assay-analyser) fits four-parameter logistic dose-response curves to plate-reader exports and tables, interpolates unknowns from linear or 4PL standard curves, calculates qPCR relative expression, fits initial-rate and inhibition enzyme kinetics, estimates a growth rate in a chosen window and an apparent Tm from a thermal-shift melt, all in the browser. This page describes its code, its science with exact equations and constants, how its results are stored, recorded and exported, how it is verified, and where the code and the documents disagree.
Purpose and users
Assay is the suite's analysis bench for plate-based assays. It has ten analysis modes, chosen with the searchable analysis chooser (below) from a compact trigger on the empty start or in the workspace header (analysisMode in the assay record, ANALYSIS_MODES in model.ts). The start shows one data-entry method at a time: Paste, File or Bench input. Examples and recent work remain opt-in routes beneath the input. In a loaded view, Save work and ⋯ sit at the right even for modes without tabs:
Mode (analysisMode) |
What it computes | Main engine |
|---|---|---|
Dose response (dose-response, default) |
Per compound: a 4PL fit in log concentration, the relative or absolute C50 (IC50 or EC50 by declared intent) or the reason it is withheld, a 95 percent interval on log10 C50 where estimable, Z′ per plate, model comparison | src/lib/fit/ |
Standard curve (calibration) |
A linear or 4PL curve fitted to standard means, unknown concentrations by inverse prediction inside the tested standard range, dilution correction; optional ELISA, Bradford or BCA protocol notes | src/lib/fit/calibration.ts |
qPCR relative expression (qpcr) |
Efficiency-corrected relative quantities from reviewed Cq tables, normalised to one or more reference assays and to run-local calibrator samples | src/lib/assay/qpcr/ |
Enzyme kinetics: initial rates (enzyme) |
Michaelis–Menten Km and Vmax by least squares on the rates, 95 percent profile intervals, kcat when units allow; the rates typed, or taken from progress curves over a chosen window (A6) | src/lib/kinetics/michaelis-menten.ts, src/lib/fit/profile.ts, src/lib/kinetics/progress-rates.ts |
Enzyme kinetics: inhibition (inhibition) |
A global fit of the competitive, uncompetitive, noncompetitive or mixed model the person chooses: shared Vmax and Km, Ki and/or Ki′ | src/lib/kinetics/inhibition.ts (the models, their parameters and inhibitionRate in inhibition-rate.ts, which it re-exports, so the panel and the CSV writers carry no fitting code) |
Growth: selected window and full curve (growth) |
The specific growth rate μ in a typed time window after blank subtraction, and the doubling time; optionally the modified Gompertz full fit (lag, μm and A as a log increase, each withheld when the readings lack its phase; A6) | src/lib/growth/window.ts, gompertz.ts |
Thermal shift: apparent Tm (thermal) |
Per condition, two apparent Tms (sigmoid midpoint and derivative extremum) in a typed temperature window, and ΔTm against a reference condition | src/lib/thermal/apparent-tm.ts |
Column statistics (columns) |
Descriptives, Shapiro–Wilk, intervals on the mean and median, one-sample, two-sample and paired t and rank tests, effect sizes (A-2 family 1) | src/lib/stats/column-tests.ts |
Several groups (groups) |
One-way (Welch or classical) and two-way ANOVA, repeated measures with a mixed model for missing levels, Tukey, Games–Howell, Dunnett, Holm, Šidák, Bonferroni, Kruskal–Wallis with Dunn, Friedman (A-2 family 2) | src/lib/stats/groups.ts, models.ts, multiple.ts |
False discovery rate (many-tests) |
Adjusted p-values by BH, BY, Holm or Bonferroni and the count at a level (A-2 family 6) | src/lib/stats/p-adjust.ts, fdr.ts |
Combination responses (combination) |
HSA and Bliss independence excess on a concentration matrix, evaluated without curve fitting | src/lib/assay/combination/ |
A separate task page computes Ki from a typed IC50 by the Cheng–Prusoff relation (src/lib/kinetics/ki.ts); it is deliberately not linked from any result.
The design brief (docs/design/brief-assay-analyser.md §2, design intent) names screening scientists, academic labs and students who fit dose-response curves without a GraphPad Prism licence. The registry entry's status is preview, version 0.2.0, updated 2026-10-10 (content/apps/assay-analyser.ts). All scientific computation runs locally; the app declares no external endpoints (dataPolicy.endpoints: []), and dataPolicy.wasm: true admits the shared RDKit WebAssembly engine, which loads only when a compound carries a SMILES string (see Security and network).
The registry and the copy describe the app as research and education software. Nothing in the code establishes suitability for clinical or regulated use, and the copy says so.
Addresses and entry points
| Address | What it serves | Source |
|---|---|---|
/assay |
Product page | content/apps/assay-analyser.ts (entry, page) |
/assay/app |
The workspace (never indexed) | src/apps/assay-analyser/ |
/assay/ic50-calculator |
Task page with a server-rendered island that fits a pasted table | src/apps/assay-analyser/islands/ic50.tsx, src/components/tools/assay-analyser/paste-fit.ts |
/assay/z-prime |
Task page computing Z′ from pasted control wells | src/apps/assay-analyser/islands/zprime.tsx |
/assay/growth-rate-window, /assay/michaelis-menten-initial-rates, /assay/enzyme-inhibition-ki, /assay/ki-from-ic50, /assay/thermal-shift-apparent-tm |
Task pages, each with its own island (the panel of that mode, or the Ki form) | src/apps/assay-analyser/islands/{growth,kinetics,inhibition,ki,thermal}.tsx |
/assay/elisa-standard-curve, /assay/protein-assay-standard-curve |
Server-rendered standard-curve pages; the worked examples are the published fixture cases fitted when the page is built; no island | tasks.marketing.tsx |
/assay/mtt-example, /assay/how-to-report-an-ic50, /assay/limits |
Plain task pages; the MTT page links into the plate walk | src/apps/assay-analyser/tasks.marketing.tsx, content/apps/assay-analyser.answers.ts |
/docs/assay |
Product documentation with the methods and verification tables | content/apps/assay-analyser.docs.ts, its definitions in content/apps/assay-analyser.docs-entries.ts (moved verbatim, E6, 17 October 2026), src/apps/assay-analyser/verification-table.marketing.tsx |
/changelog#assay-analyser |
Changelog entries | content/apps/changelog/assay-analyser.ts |
/verification/assay-analyser/<version>/<file> |
Published fixture files (0.1.0 to 0.8.0, 0.10.0 and 0.11.0; a set's version field names its directory, otherwise the registry version 0.2.0) |
public/verification/assay-analyser/ |
/o/mine/assay/<uuid> |
Object page of one assay; Assay also contributes tabs to target and compound pages | src/apps/assay-analyser/object-tab.tsx |
The URL segment assay comes from APP_SEGMENTS in content/apps/routes.ts; the slug assay-analyser remains the machine id for storage keys, object refs, accents and changelog anchors (see Registry and content). The app has no static sub-pages of its own under /assay/app/<id> (PAGE_IDS is empty in src/apps/assay-analyser/page-ids.ts).
File map
Registry and copy (content/)
| File | Role |
|---|---|
content/apps/assay-analyser.ts |
Registry entry content: tagline, capabilities, limits, sources and sourceMeta licence facts, excluded sources, data policy, objects, tasks, oracles, verificationSets, FAQ |
content/apps/assay-analyser.entry.ts |
The full entry with identity (withIdentity), imported by the app chunk |
content/tools/assay-analyser.ts |
The write-up, sources and limits that the entry, docs page and Limits panel read |
content/apps/assay-analyser.methods.ts |
METHODS, the union of every method record (MethodRef joined with its verification state), for the methods page, the task pages, the chip generator, the pins and the tests; MODE_METHOD_FILES; re-exports EQUATIONS (assay-analyser.equations.ts). The workspace never imports it (the CTO's ruling of 13 October 2026 on the workspace chunk, 3) |
content/apps/assay-analyser.methods-core.ts, content/apps/assay-analyser.methods-<mode>.ts, content/apps/assay-analyser.method-record.ts (assay-analyser.methods-in.ts is not one: it is the methods-paragraph reader's copy) |
The dose-response methods (CORE_METHODS, always in the workspace), each mode's (calibration, qpcr, growth, enzyme with Hill and the two-substrate models, inhibition with Ki, Morrison and slow binding, thermal, screening, columns, power, groups, many-tests, survival, counts, relationships, gr, binding), and the method() helper they share. A key is in exactly one file (mode-methods.test.ts). src/components/tools/assay-analyser/mode-methods.ts loads a mode's file with the mode (loadModeMethods, started when the mode opens and awaited by the project export and by withModeMethods around each Bench capture); methodRecord throws rather than answer a mode's record before it loads, so no method is left out of a provenance block; a chip for a mode's method shows its plain label until then |
content/apps/assay-analyser.verification.ts |
Fixture date, oracle versions, FIXTURE_COUNTS, TOLERANCES, REVIEW_STATUS, verification notes |
src/apps/assay-analyser/repro/ |
A-4 item 4, the reproducibility package: package.ts (the HTML builder, its own chunk drybench-export:assay-repro-package), page-script.ts (the page's own script, serialised into the file), engine.generated.ts (the fit engine as one script, generated by scripts/prebuild/assay-analyser-repro-engine.mjs); copy in content/apps/assay-analyser.package.ts (the Report view's key) and assay-analyser.package-page.ts (the page) |
content/apps/assay-analyser.static-methods/<island>.ts |
Generated by scripts/prebuild/assay-analyser-static-methods.mjs: one per task island, the chip labels and titles of the method keys its static import closure names as literals; the island lends them to its panels through StaticChipsContext (panels/static-chips.ts, a module of its own so a chipless island does not take the panel parts) by IslandFrame's chips. The workspace lends its own chip and carries none of them (the CTO's ruling of 13 October 2026 on the workspace chunk, 2); the curve inspector, which the shell renders outside the workspace's tree, takes the same chip through the store (AssaySnapshot.panel). scripts/assay-static-methods.test.mjs checks staleness and that every MethodChip key is a literal or a conditional between literals |
content/apps/assay-analyser.limit-figures.ts |
Generated by scripts/prebuild/assay-analyser-limit-figures.mjs: the import and project-file caps as formatBytes(…, "down") writes a limit ("4.1 MB", "50.0 MB"), for the Limits line in APP_LIMITS, so the registry entry takes two strings and not the formatter or the constants' modules (the CTO's ruling of 12 October 2026); scripts/assay-limit-figures.test.mjs fails when they drift |
content/apps/assay-analyser.workspace.ts |
Workspace copy (views, fit settings, report, exports, errors) |
content/apps/assay-analyser.frame.ts |
The frame's words (loading line, boundary name, view names, inspector title, share words, commands, examples), the only copy the definition chunk imports; the workspace copy spreads them in, so each is written once and the rest of the workspace copy loads with the workspace (13 October 2026: definition chunk 34.5 to 22.5 KB, workspace chunk 230.3 to 242.6 KB) |
content/apps/assay-analyser.endpoint.ts |
ASSAY_INTENTS, endpointName, endpointText, reportingCopy (D-0028 wording) |
content/apps/assay-analyser.calibration.ts, .qpcr.ts |
All calibration and qPCR copy, including refusal messages and qualifications |
content/apps/assay-analyser.outliers.ts |
The outlier-candidate flags (A5): candidate lines in the CSO's words, withheld reasons, the control-group lines |
content/apps/assay-analyser.curve-shape.ts |
The curve-shape section's copy (bell-shaped and biphasic fits): equations, the refusal and withheld sentences, the comparison line, the limits line, the method paragraph, the CSV block's headers |
content/apps/assay-analyser.antagonism.ts |
The agonist and antagonist panel's copy: the two analyses, the refusal, withheld and flag sentences, the test lines, the Schild plot's words, the three limits lines, the CSV headers |
content/apps/assay-analyser.gr.ts |
The GR metrics panel's copy: the table format, the row reasons, the refusals, the withheld reasons, the controls line, the limits line, the method paragraph, the CSV headers |
content/apps/assay-analyser.kinetics.ts, .inhibition.ts, .ki.ts, .growth.ts, .thermal.ts |
Copy of the five Wave 1 analyses: labels, refusal and withheld sentences, qualifications, CSV headers, method paragraphs, limits. .progress.ts holds the progress-curve source panel's own words (A6), loaded with that panel; what the kinetics panel, exports and record say about progress-curve rates is kineticsCopy.progress. .inhibition.test.ts and .thermal.test.ts check wording rules |
content/apps/assay-analyser.kinetics-record.ts |
kineticsRecordCopy: the initial-rates words the CSV writers, the interval lines and the recorded result read; kineticsCopy spreads it, so the CSV chunks carry only these |
content/apps/assay-analyser.columns.ts |
Column statistics' copy (A-2 family 1): the input, the declaration, the comparison controls, table headers, the CSO's withholding sentences, Shapiro–Wilk sentence and limits lines, the method paragraph, formatP (two significant figures, "< 0.0001" below) and pClause ("p = 0.043", or "p < 0.0001" for the bound; the columns, groups and relationships sentences use it) |
content/apps/assay-analyser.outliers.ts, content/apps/assay-analyser.power.ts |
Outlier candidates' and power and sample size's copy (with their CSV words) (A-2 family 7): the frames, table headers, the CSO's criterion, withholding sentences and limits lines, the planning note's wording (power ruling 8), the method paragraphs |
content/apps/assay-analyser.survival.ts, .counts.ts |
Time to event's and counts' copy (A-2 families 4 and 5): the input and its status codes or blank-cell line, the declarations, the controls, table headers, the CSO's withholding sentences and limits lines, the CSV words, the method paragraphs. formatP comes from the columns copy |
content/apps/assay-analyser.modes.ts |
modeWords: the words the workspace frame and the export menu need before a mode's panel loads; inRunningText (a title inside a sentence) |
content/apps/assay-analyser.island-mode-words.ts |
The words of the four modes with a task island (enzymeWords, inhibitionWords, growthWords, thermalWords) and unresolvedWords; modeWords and reportingCopy read them, and those modes' copy files import only their own, so an island carries neither the other modes' words nor the reporting copy |
content/apps/assay-analyser.chooser.ts |
The analysis chooser's content: ANALYSIS_GROUPS (ten groups by what the data are), ANALYSES (only the shipped entries: one per mode, plus column statistics' three panel: presets (One sample against a value, Two groups, Paired samples), all named from modeWords; each family's entry joins in the commit that ships it, and the design document holds the catalogue of 45) and chooserWords; inRunningText comes from assay-analyser.modes.ts (the start re-exports it, so the chooser's chunk carries no start copy; the CTO's chooser-budget ruling of 13 October 2026), and the surface's title ("Choose an analysis") from modeWords.choose. Loaded only with the chooser's on-demand chunk, never in the workspace's first load |
content/apps/assay-analyser.chooser-keywords.ts |
The chooser's search keywords by entry id (ANALYSIS_KEYWORDS) and withKeywords, loaded by chooser-list.tsx on the search's first focus or input, not with the list (the CTO's ruling of 13 October 2026 on the workspace chunk, 4); until they arrive the search matches names, groups and descriptions, and it re-ranks when they do. Each new entry adds its keywords here; chooser.test.ts checks one set per listed entry and none in the list |
content/apps/assay-analyser.plate-sheet.ts |
Copy for a received plate preparation sheet (assayPlateSheetText) |
content/apps/assay-analyser.examples.ts |
Shipped examples (mtt@1, prism-erlotinib-hcc827@1, and the synthetic initial-rates@1, inhibition@1, thermal-shift@1, growth-window@1, which carry their mode), their conditions, changes and series wells |
content/apps/assay-analyser.table-rows.ts, .import-review.ts, .placement.ts, .research.ts, .workflow.ts, .productivity.ts, .answers.ts, .docs.ts, .guide.ts, .views.ts |
Copy for table decisions, import review, plan placement, research context, workflow, repeat/presets, task pages, docs, guide and view ids |
App glue (src/apps/assay-analyser/)
| File | Role |
|---|---|
definition.ts, entry.tsx, workspace.tsx |
AppDefinition (Main, inspector, shortcuts 1 to 4 and g, palette commands, share link); the entry chunk; the dynamically loaded workspace with skeleton |
views/assay-workspace.tsx |
The workspace component (about 1,540 lines): state, import, examples, autosave, fit orchestration, object writes, hand-offs, exports, rendering |
views/plate-view.tsx, views/fit-results.tsx, views/report-view.tsx |
The dose-response views loaded with next/dynamic when they first render (CTO step 6, 12 October 2026), each with a drybench-view:assay-analyser/<view> displayName as its size marker: the Plate map's panels (the plate, the assignment card, "Import and recognition", "Quick quality checks", the templates and the decision history); the Fit view's results (the chart panel with the figure controls and the small multiples, the headline card, "Review and reproduce" with the readouts, "Confidence and interpretation", the results table with Compare, and with them the charts, the figure code and the data table; each panel in a named grid area the workspace lays out beside the fit settings and the context); the Report view's body (Print button, method block, figure caption with "Copy caption", printable report), which re-exports printNow. Once there are dose-response curves to fit, the workspace starts loading the Fit view's results module, its Compare, agonist and antagonist and figure-settings panels (preloadResultPanels in fit-results.tsx) and the potency panel, each in its own chunk, so the first fit is not followed by more chunk requests (REL-230). The figure export draws from the stored results through the scene (A-0 commits 4 and 5), so it needs no view mounted (REL-38's cause is gone); the Print export opens the Report view and prints 350 ms after its module has loaded. No loading placeholder, as for the table-rows panel. The open path, the strict readers, the fit settings and context, and the recovery and withheld notices stay in the workspace chunk |
views/figure-view.tsx |
The Figure view (the fifth view, Import · Plate · Fit · Figure · Report, shortcut 4, Report now 5; the CPO's figure-editor memo of 13 October 2026), loaded with next/dynamic on first visit and kept mounted while hidden (drybench-view:assay-analyser/figure). Before a fit it says so with a key to Fit. The style row: five size presets named by physical size (journal names held for the General Counsel), each an action key whose description lists what it sets; Apply saved style, Save style or Replace saved style (the device preference, kept in this browser); Undo and Reset figure. The canvas is the export at scale (figureDocument with the axis facts, layoutFigure with FIGURE_EXTRAS, the light chart colours on white or a checker for transparent, no on-screen text floor: SceneNodes floor={false}), a non-focusable role="img" described by the elements list; a click selects the element under it and focuses its option; the selection outline is a two-tone rectangle (the accent line and a dark dashed inner line, 3:1 on white and on the sunken surface in both themes). The elements list is the ARIA single-select listbox (arrows, Home, End, Escape to the figure, Enter to the property panel, Ctrl/⌘+Z for Undo); at 375 px an Element select with Previous and Next. The property panel follows the selection: the figure (size, text, points, lines, layout, columns, letters, shared axes or legend, background, frame, grid, replicates), the x axis (title, range in mol/L, decade step, minors), the y axis (title, range, linear or log with the CSO's refusal line, step, minors, break with its refusal line), a series (display label, colour, symbol, line, order, hide), the legend, an annotation (its fields and Delete). Selects, segmented controls and checkboxes write on change, text and number fields on commit with their field errors; every write names itself ("Last change: …", not announced); actions are announced once. Undo is held by the workspace: twenty steps per piece of work (cleared when another opens), recording the view's changes, Fit's Apply, presets, the saved style and Reset. Fit's Figure settings keep their four sections, apply only the fields changed since the form was in step with the record (so an Apply never overwrites the editor's writes), follow the record while nothing is pending, and end with "Edit in the Figure view" (disabled with its reason while edits are unapplied). Fit's charts draw the series' styles, ranges, ticks, frame and grid, not log y, a break or annotations, which the Figure view and every export draw |
figure/heatmaps.ts, figure/compose.ts, figure/chart-caption.ts |
The figure editor's items 4 and 5 in Assay. A figure's panels.heatmaps (up to four plate indices; the Figure view's "Plate heatmaps" checkboxes, shown for a plate import) draws each named plate's raw readings as a heatmap in one row beneath the curves (heatmaps.ts plateHeatmap: A at the top, column 1 at the left, a well excluded in the record crossed, a blank or non-numeric cell outlined as "No reading"), assembled by compose.ts composeFigure on a grid within the figure's size (the curves' rows and one heatmap row share the height; a default size gains a 61 mm row, as the export dialog's size line says), the panel letters continuing after the curves', the caption once at the foot. The export (figureScene, now async) and the Figure view load compose.ts only when a named plate exists. The Report view adds the CSO's sentence for each heatmap to the figure caption (heatmapSentences, from the same scale: the kept wells' range, the excluded and empty counts). chart-caption.ts holds the category charts' caption sentences (the CSO's rulings 1 to 6, from the layout's report, fallbacks named) and the brackets (bracketsFor: only on a unit declared independent, never from a withheld result, one per panel unless the tests are a family adjusted for multiplicity, then every adjusted pair up to six, the method and family size named in the caption; more than six are left to the comparisons table; labelled p = 0.012 to two significant figures or p < 0.0001, stars only when asked and keyed) |
views/plate-view.tsx, views/fit-results.tsx, views/report-view.tsx |
The dose-response views loaded with next/dynamic when they first render (CTO step 6, 12 October 2026), each with a drybench-view:assay-analyser/<view> displayName as its size marker: the Plate map's panel; the Fit view's results block (readouts, small multiples, results table, and with them the charts, the figure code and the data table; the figure controls load in a chunk of their own behind a placeholder of the closed disclosure's size, REL-140); the Report view's body (Print button, method block, figure caption with "Copy caption", printable report), which re-exports printNow. Once there are dose-response curves to fit, the workspace starts loading the Fit view's results module, its Compare, agonist and antagonist and figure-settings panels (preloadResultPanels in fit-results.tsx) and the potency panel, each in its own chunk, so the first fit is not followed by more chunk requests (REL-230). The figure export draws from the stored results through the scene (A-0 commits 4 and 5), so it needs no view mounted (REL-38's cause is gone); the Print export opens the Report view and prints 350 ms after its module has loaded. No loading placeholder, as for the table-rows panel. The open path, the strict readers, the fit settings and context, and the recovery and withheld notices stay in the workspace chunk |
views/figure-view.tsx |
The Figure view (the fifth view, Import · Plate · Fit · Figure · Report, shortcut 4, Report now 5; the CPO's figure-editor memo of 13 October 2026), loaded with next/dynamic on first visit and kept mounted while hidden (drybench-view:assay-analyser/figure). Before a fit it says so with a key to Fit. The style row: five size presets named by physical size (journal names held for the General Counsel), each an action key whose description lists what it sets; Apply saved style, Save style or Replace saved style (the device preference, kept in this browser); Undo and Reset figure. The canvas is the export at scale (figureDocument with the axis facts, layoutFigure with FIGURE_EXTRAS, the light chart colours on white or a checker for transparent, no on-screen text floor: SceneNodes floor={false}), a non-focusable role="img" described by the elements list; a click selects the element under it and focuses its option; the selection outline is a two-tone rectangle (the accent line and a dark dashed inner line, 3:1 on white and on the sunken surface in both themes). The elements list is the ARIA single-select listbox (arrows, Home, End, Escape to the figure, Enter to the property panel, Ctrl/⌘+Z for Undo; each key steps from the selection as it stands, so arrows pressed faster than the list re-renders are none of them lost: before 14 October 2026 four arrows in one task moved one element, which failed figure-editor.spec.ts on CI's 2-vCPU runners); at 375 px an Element select with Previous and Next. The property panel follows the selection: the figure (size, text, points, lines, layout, columns, letters, shared axes or legend, background, frame, grid, replicates), the x axis (title, range in mol/L, decade step, minors), the y axis (title, range, linear or log with the CSO's refusal line, step, minors, break with its refusal line), a series (display label, colour, symbol, line, order, hide), the legend, an annotation (its fields and Delete). Selects, segmented controls and checkboxes write on change, text and number fields on commit with their field errors; every write names itself ("Last change: …", not announced); actions are announced once. Undo is held by the workspace: twenty steps per piece of work (cleared when another opens), recording the view's changes, Fit's Apply, presets, the saved style and Reset. Fit's Figure settings keep their four sections, apply only the fields changed since the form was in step with the record (so an Apply never overwrites the editor's writes), follow the record while nothing is pending, and end with "Edit in the Figure view" (disabled with its reason while edits are unapplied). Fit's charts draw the series' styles, ranges, ticks, frame and grid, not log y, a break or annotations, which the Figure view and every export draw |
figure/heatmaps.ts, figure/compose.ts, figure/chart-caption.ts |
The figure editor's items 4 and 5 in Assay. A figure's panels.heatmaps (up to four plate indices; the Figure view's "Plate heatmaps" checkboxes, shown for a plate import) draws each named plate's raw readings as a heatmap in one row beneath the curves (heatmaps.ts plateHeatmap: A at the top, column 1 at the left, a well excluded in the record crossed, a blank or non-numeric cell outlined as "No reading"), assembled by compose.ts composeFigure on a grid within the figure's size (the curves' rows and one heatmap row share the height; a default size gains a 61 mm row, as the export dialog's size line says), the panel letters continuing after the curves', the caption once at the foot. The export (figureScene, now async) and the Figure view load compose.ts only when a named plate exists. The Report view adds the CSO's sentence for each heatmap to the figure caption (heatmapSentences, from the same scale: the kept wells' range, the excluded and empty counts); it loads heatmaps.ts only for a figure naming a plate the source holds, and shows the caption once the sentences are ready. chart-caption.ts holds the category charts' caption sentences (the CSO's rulings 1 to 6, from the layout's report, fallbacks named) and the brackets (bracketsFor: only on a unit declared independent, never from a withheld result, one per panel unless the tests are a family adjusted for multiplicity, then every adjusted pair up to six, the method and family size named in the caption; more than six are left to the comparisons table; labelled p = 0.012 to two significant figures or p < 0.0001, stars only when asked and keyed) |
figure/stats-figures.ts, panels/category-figure.tsx |
The category charts in column statistics and several groups. stats-figures.ts turns an analysis already computed into a chart's input: columnsFigure(read, analysis) gives one group per column with each cell by row (blank, text and excluded as gaps), the two-sample or paired comparison's t test as the one bracket test (paired rows add before–after), and a one-sample test's μ0 as a reference line with no bracket. groupsFigure(result) gives the groups read: one-way with its post-hoc family, or Kruskal–Wallis with Dunn, whose adjusted pairs are the brackets with the family's method and size; repeated measures and Friedman, paired by row with their Holm pairs; two factors as grouped bars, one series per level of B, with no brackets. Stacked bars are never offered, because nothing here is declared compositional. CategoryFigure is loaded by next/dynamic in its own chunk under the comparison (column statistics) or the result (several groups). It offers the chart, the error bars (SD or none), the centre (dot plots), the bracket (p, stars or none; shown only when a test exists) and the width (85, 114 or 174 mm by 70 mm). It draws through layoutCategory exactly as it downloads, with the caption from chartCaption plus the bracket captions, and a Copy button. Refusals and the reasons a bracket is not drawn are listed. SVG, PNG and TIFF at 300 dpi, and PDF (re-laid out on Helvetica metrics) download with the provenance block and source line from the workspace's provenanceFor. The chart's choices are not stored in the record. Browser spec: e2e/assay-analyser/category-figure.spec.ts; unit: figure/stats-figures.test.ts |
store.ts |
A module-level snapshot store (publishAssay, readAssay, useAssay) through which shell-rendered parts (commands, inspector, share) read the workspace |
inspector.tsx |
Curve inspector, rendering CurveDetail for the selected curve |
objects.ts |
Object inbound mapping, assayLinks, ic50Numbers, zPrimeNumbers, compound inputs |
object-tab.tsx, object-tabs.ts |
Tabs on assay, target and compound object pages; the assay tab shows only the results upgradeToV6 would carry (useCheckedResults: verified by their hash, or from before v6 and consistent with the readings), else the CSO's withheld sentence (SCI-36) |
workspace-adapter.ts |
Capture of recorded Workspace results for dose response, calibration and qPCR |
exports.ts, report.ts, provenance.ts |
Export menu items, method block, method paragraph, print report, provenance chips and settings hash; the cited methods' sources are written as computed in the chips and the export provenance blocks alike (the example's data stay shipped; the closing walk's W4) |
example-export.ts, example-slots.ts |
Labels, statements and context columns for example data in exports |
address-ref.ts, provenance-links.ts |
Whether the address may name the kept assay; retirement of legacy plan links |
islands/, task-islands.tsx, tasks.marketing.tsx, verification-table.marketing.tsx |
Task-page islands and marketing pages |
Domain code and panels (src/components/tools/assay-analyser/)
| File | Role |
|---|---|
model.ts |
AssayData and its reader readAssayData, readImport, deriveCurves, fixedFor, summarise (CurveAnalysis to StoredCurve), differences, mayWriteOn, markEdited |
address.ts, ids.ts |
URL schema assaySchema, FitSettingsState, defaults, SHARE_KEYS; the slug |
table-decisions.ts |
Row roles and exclusion decisions with reasons and history; strict readers for decision-bearing sources |
fit-protocol.ts, fit-client.ts, fit.worker.ts |
Batch fitting worker protocol (FIT_CHUNK = 25, FIT_CAP = 400), client, worker |
experiments-record.ts, experiments.ts, panels/experiment-summary-list.tsx, panels/across-plates.tsx, panels/across-assays.tsx |
A4's experiment-level summary: the declaration on AssayData.experiments and its reader (the model loads only experiments-record.ts), the per-experiment inputs and members, the shared summary rows, the Report's "Across plates" and the Study's "across saved assays"; copy in content/apps/assay-analyser.experiments.ts |
negative-result.ts |
A-4 item 12's negative result: eligibility (rule (ii) or an admitted fitter refusal, p ≥ 0.05, the feasibility check), its fields and the statement; read by the curve panel, the results CSV and the recorded-result projection; copy in negativeCopy (content/apps/assay-analyser.negative.ts, its own file so the task islands that read reportingCopy do not carry it) |
transformation-history.ts |
A-4 item 14's transformation history: every step from the imported readings to one curve's reported number, before and after; read by the curve inspector's disclosure and src/apps/assay-analyser/history-export.ts; copy in content/apps/assay-analyser.history.ts |
pre-submission.ts, panels/pre-submission-panel.tsx |
A-4.1's pre-submission check: the pure rules (checkDoseResponse and one check* per other mode) and the section with its fix links; copy in content/apps/assay-analyser.presubmission.ts, the Export-menu entry's words in workspaceCopy.check; see Pre-submission check |
explain.ts, residual-steps.ts, tighten.ts, panels/curve-diagnostics.tsx |
A-4 items 17, 5 and 3(b): "Explain this number" (deterministic), the residuals by dilution step, and the standard curve's "What would tighten this?"; copy in content/apps/assay-analyser.explain.ts; see A-4 diagnostics |
divergence.ts, panels/divergence-panel.tsx, panels/divergence-notice.tsx |
A-4 item 16's duplicate and divergence detection: identityOf (file hashes from every import receipt, the readings' SHA-256), findOverlaps, differencesOf, overlapStatement; the Study section and the workspace line; copy in content/apps/assay-analyser.divergence.ts; see Duplicate and divergence detection |
mass-conversion.ts, mass-conversion-record.ts, panels/mass-conversion-panel.tsx, panels/enzyme-from-protein.tsx |
A-4 item 10, units that know the molecule: a mass-unit table converted at import through each compound's molar mass (massTable, convertMassImport, basisLine), the conversion record on importRecord.massConversion and its strict reader, the import section (drybench-view:assay-analyser/mass-conversion) and the workspace's enzyme-from-a-protein step for kcat (drybench-view:assay-analyser/enzyme-from-protein); copy in content/apps/assay-analyser.units.ts; see Units that know the molecule |
methods-parse.ts, panels/methods-in-panel.tsx |
A-4 item 13, a methods paragraph in: fixed phrase rules (parseMethods) into the dose-response settings with the words each rests on, and proposal against the current settings; the disclosure at the end of Fit settings (drybench-view:assay-analyser/methods-in); copy in content/apps/assay-analyser.methods-in.ts; see Methods paragraph in |
efficiency.ts, efficiency-method.ts, panels/potency-panel.tsx |
A-4 item 11, potency with structure beside it: pIC50, LE and LLE from the shown relative IC50 and Molecule's descriptors (efficiencyOf, describedOf, lowerPlateauUnreached), the stored assayFormat, the section under the results table (drybench-view:assay-analyser/potency) with the efficiency CSV and the one-step SAR series link; copy in content/apps/assay-analyser.efficiency.ts; see Potency and efficiency |
drift.ts, drift-method.ts, panels/drift-panel.tsx |
A-4 item 8, control runs over time: the individuals chart and Pettitt change-point (driftChart), the series (c50Series, zPrimeSeries, blankSeries, referenceLabels) and the Study Evidence section (drybench-view:assay-analyser/drift); copy in content/apps/assay-analyser.drift.ts; see Control runs over time |
layout-suggestion.ts, layout-suggestion-method.ts, suggested-from.ts, panels/layout-suggestion-panel.tsx |
A-4 item 6, a layout suggested from the plate diagnostics: the trigger and the rules (testsOf, triggers, suggestLayout, build), the v6 suggestedFrom record on a saved template (readSuggestedFrom) and the section under the diagnostics in Fit settings (drybench-view:assay-analyser/layout-suggestion); copy in content/apps/assay-analyser.layout-suggestion.ts; see A layout suggested from the diagnostics |
comparison-record.ts, comparison.ts, compare-client.ts, compare.worker.ts, panels/compare-panel.tsx |
A4's curve comparison: the record on AssayData.comparisons and its strict reader (the model loads only comparison-record.ts), the input from the stored results, the plates line, the stale check), the one-job worker (drybench-worker:assay-compare) and its client, the Compare panel in Fit (drybench-view:assay-analyser/compare); copy in content/apps/assay-analyser.compare.ts |
shape.worker.ts, shape-client.ts, shape-export.ts, panels/curve-shape-section.tsx |
Bell-shaped and biphasic fits in a curve's detail: the worker (drybench-worker:assay-shape, one job each), its client, the CSV block (shapeRows, shapeCsv) and the section, loaded with React.lazy from curve-detail.tsx |
antagonism.worker.ts, antagonism-client.ts, antagonism-export.ts, panels/antagonist-panel.tsx |
Agonist and antagonist analyses below Compare: the worker (drybench-worker:assay-antagonism), its client, the CSV block (antagonismRows, antagonismCsv) and the panel (drybench-view:assay-analyser/antagonism, loaded with next/dynamic from views/fit-results.tsx) |
gr.worker.ts, gr-client.ts, gr-export.ts, panels/gr-panel.tsx, examples/gr-metrics.data.ts |
The GR metrics mode: the worker (drybench-worker:assay-gr-metrics; assay-gr alone was a prefix of the groups and growth workers' markers, so the size check counted them as GR's), its client, the CSV (grRows, grCsv), the panel (loaded with next/dynamic by the workspace when the mode is chosen) and the synthetic example |
calibration.worker.ts, qpcr.worker.ts, gompertz.worker.ts |
Workers for calibration, qPCR and the growth full fit (one job each; the Gompertz worker carries drybench-worker:assay-growth) |
calibration-import.ts, calibration-export.ts, calibration-integrity.ts |
Calibration CSV parser and example, CSV/HTML writers, input-hash verification |
exports.ts |
resultRows, prismXyCsv, drcSnippet, replicatesOf |
shown.ts, words.ts, format.ts, chart.ts |
What a stored curve shows; sentences; number formatting; chart geometry |
import-mapping.ts, import-presets.ts, imported-rows.ts, original-import.ts |
Explicit long-table column mapping, saved presets, imported-readings rows, original-file receipts |
plan-sources.ts, preparation-plan.ts, preparation-context.ts, plate-placement.ts |
Dilution-plan and master-mix provenance, dose comparison, atomic placement commit |
research-record.ts, repeat.ts |
Research context and import record readers; repeat setup (written through raiseOnWrite, as v6 or v7, with no results, so the v2 figure it copies is readable) |
figure/settings.ts, figure/document.ts, figure/react.tsx |
Stage A figure system (A-0): FigureSettings v2 (millimetres and points; size the whole figure or null, null meaning defaultFigureSize at render: 85 mm wide for an overlay or one column, else 170, by 58 mm a row with 3 mm between, the CPO's Q2; Okabe and Ito's eight colours by index; a figure-wide defaults colour and symbol, each concrete or "cycle"; optional per-series colour, symbol, display label and hidden flag keyed by curve id; white by default; the CTO's addendum of 12 October 2026), its strict reader readFigureV2 and seriesStyle (the series' own fields, else the defaults, "cycle" taking the series' position with the symbol cycling with the colour); figureDocument turns stored results and v2 settings into the suite's FigureDocument as the curve chart draws them (kept and excluded points, the 61-sample curve, dashed when descriptive, the shown estimate and its interval, mean ± SD bars; zero-dose rows by curve label (ZeroDoseRows, derived by the caller from the assay's data, since they are not in the stored curve) as the series' zeroDose; an overlay of more than one curve whose record has no legend takes one beneath, since the controls offer no legend choice and an overlay must name its compounds); panelDocument is one curve as the screen draws it (85 by 58 mm, the curve's style at its position in the results, excluded wells always drawn hollow because the screen is where a well is excluded by pointing at it). readStoredFigure is the record reader's figure step: v1 (readFigureV1, Wave 1's reader) on any record version, migrated in memory by migrateFigure; v2 only on v6, else figure-malformed at figure.v; a v2 figure whose replicates is anything but points or mean-sd, figure-malformed at figure.replicates (SEM and the CI of the mean arrive as v3 with the declared-experiments unit); above v2, figure-newer. figure/react.tsx draws a scene on screen (a series group carries data-series, a replicate bar data-replicate-bar with its n, the zero-dose group data-zero-dose with its count, for the browser tests): a group scaled to CSS pixels at 96 to the inch, colour roles as the chart theme's literal colours (Okabe and Ito's black as the theme's ink, so it shows on the dark theme; the export keeps the palette), text measured by a canvas in the figure's font after hydration and by the fixed-width stub before; no text below 11 CSS pixels on screen (UI-22: SCREEN_TEXT_FLOOR_PX, every text node clamped; the curve chart also lays its panel out at SCREEN_TEXT_FLOOR_PT, 8.25 pt, when the figure's size is smaller, so labels have room), while the export keeps the figure's own size in points |
figure/settings.ts (v3), figure/read.ts, figure/axes.ts, figure/editor.ts, figure/colours.ts |
The figure editor (13 October 2026; docs/design/assay-stage/figure-editor.md, the CPO's figure-editor memo and the CSO's figure-axes and chart-types memos of that day). FigureSettings v3 is v2 with axes.yScale (linear · log10), axes.xTicks and axes.yTicks (step, decades on a log axis, the spacing on a linear one, or null; minor), axes.yBreak ({ from, to } or null), axes.grid (x, y), axes.frame (open · box · none), per-series line (solid · dashed · dotted · none) and order, annotations (at most 50, anchored in data units: text, reference line, shaded range, arrow; x in mol/L; panel a curve id or null for every panel; words never a significance claim, SIGNIFICANCE_TEXT in src/lib/figure/bounds.ts) and panels (letters, sharedX, sharedY); migrateFigureV2 (src/lib/assay/migrations.ts) fills the added fields at the values that draw the v2 figure unchanged. DEFAULT_FIGURE is the current default; DEFAULT_FIGURE_V2 and readFigureV2 stay for v2. currentFigure reads either shape as v3, so callers holding a v2 default still draw. read.ts holds the record's readers (readStoredFigure: v1 on any record version, v2 and v3 only on v6, both migrated in memory to v3; figure-newer above 3; readFigure, readAnnotation), apart from settings.ts so a task page's chart does not load them. axes.ts axisFacts(results, figure) is the one account of what the axis settings do to these data (the CSO's axes memo): log y refused on a percent scale or when a kept point, a drawn mean or a shown excluded point is at or below zero; on log, the bar arms reaching zero or below and the curves falling to it, cut at the frame; a y break refused when a point, a bar or a shown curve's mid-response lies inside it; the kept points and estimate marks a hand-set range leaves out. The document applies log y and a break only with the facts (figureDocument(..., facts)), the Figure view states each refusal, and the caption counts from the same facts. editor.ts is the editor's model: the elements in the list's order, the size presets, the saved style (suite:assay-analyser:figure-style, style fields only: text, point and line sizes, frame, grid, background, colour and symbol defaults, legend position), moving a series, a new anchored annotation at the middle of a panel's data, the elements' boxes on the scene and the pointer's hit (an annotation or the legend under it, else a series vertex within 2 mm, else an axis strip, else the figure). colours.ts holds exportColour, so the view does not load the export writers |
mtt-example.ts, examples/ |
The worked MTT plate; the on-demand PRISM example data module (generated) |
paste-fit.ts |
The IC50 calculator page's one-call read-fit-summarise |
kinetics-import.ts, kinetics-intervals.ts, kinetics-export.ts, progress-words.ts |
Rate paste reader; the interval lines (intervalView, "not resolved within the search", "Interval not available"); result and rates CSV; the one "rates from progress curves" line (window, unit, direction, ε, l) shown under the rates and appended to both CSVs and the record |
series-export.ts |
assaySeries: a saved assay's verified results as a Molecule series (H22): the TSV table, the explicit mapping (C50 as <endpoint> (<kind>) in M, withheld empty) and the 200-row refusal with the count |
inhibition-import.ts, inhibition-export.ts, inhibition.worker.ts |
(substrate, inhibitor, rate) paste reader (500 rows, 12 inhibitor levels); CSV writers (inhibitionResultCsv, inhibitionRatesCsv, inhibitionComparisonCsv), inhibitionReason, designLine; the worker (marker drybench-worker:assay-inhibition) that runs computeInhibition, or with models: "all" computeInhibitionWithComparison, off the main thread |
growth-import.ts, growth-export.ts |
Growth paste reader; result and readings CSV |
thermal-import.ts, thermal-export.ts |
Melt paste reader (condition, temperature, fluorescence); result and readings CSV, thermalReason, disagreeLimit |
examples/{initial-rates,inhibition,growth-window,thermal-shift}.data.ts |
The synthetic example readings of the four Wave 1 examples |
examples/groups.data.ts, groups.worker.ts, groups-export.ts, panels/groups-panel.tsx, panels/many-tests-panel.tsx |
Several groups and the false discovery rate (A-2 families 2 and 6): the synthetic examples (one factor, two factors, repeated measures with two missing days, twelve named p-values); the groups worker; the two CSV writers (groupsResultCsv, manyTestsCsv, with groupsSettings building the engine's settings from the stored choice); the two panels |
chooser/trigger.tsx, chooser/chooser-list.tsx |
The analysis chooser's surface (design §5 to §7, CPO memo of 12 October 2026, A; DS §6.33). trigger.tsx is all the workspace frame carries: AnalysisChooser, a static channel holding the mode's title until the chooser's chunk arrives (a press on it opens the surface then), and DockedChooser; both request chooser-list.tsx by import() on mount. That chunk (marker drybench-chooser:assay, budget assay-chooser-chunk) holds the content, the ranking, ChooserTrigger (the channel naming the entry and its group, "Analysis: {name}, {group}"; Enter, Space or ↓ open a Popover titled "Choose an analysis" from sm, a bottom Drawer of at most 70 dvh below) and ChooserList (the type="search" combobox, a visible "Analysis" label when docked and aria-label "Search analyses" in the surface; the grouped listbox with sticky headings and data lines; ↓ ↑ wrap, Home, End, Page keys by ten; Enter or a press chooses; Escape clears the query before the surface closes; the count after 500 ms of silence through announce(); no match gives one line, its help and "Clear the search" outside the listbox; the selected row shows "current", the active one bg-sunken; when more than one mode holds data each row shows its wells or rows). |
chooser/suggestion.ts |
The import's suggestion as words (importSuggestion): suggestFor over the shipped entries while dose response is the analysis, giving the notice line, the key ("Analyse as {name}", or for a family-level suggestion "Choose from {group}"), the dismissal ("Keep dose response") and the one announcement; loaded by import() when the Import panel reads text, with the parsers shape.ts asks. A family-level key (design §4.3) calls the workspace's takeFamilySuggestion, which opens the chooser at the group (ChooserAt: the docked list when it shows, else the trigger's surface), the query cleared and the group's first entry active, nothing chosen; the entry the person then chooses in that group gets the text in its paste box (chooseAnalysis through takeSuggestion). The pending group and text are let go when the trigger's surface closes without a choice (onDismiss), when Import reads new text (onRead) and when a start example is chosen, so a later open shows the current analysis active and a later choice carries nothing |
chooser/rank.ts, chooser/preset.ts, chooser/shape.ts |
The analysis chooser's pure parts: the filter and ranking (every query word must match; the active option is a name prefix, then a name, a keyword, the group, the description, ties to list order; the current analysis when the query is empty) and the arrow, Home, End and Page keys; the preset mechanism (an entry's mode and the one model, source or panel setting a press applies; the trigger's name derived from the mode's settings at render); the table-shape detector, which asks the shipped parsers and returns at most one suggestion, none when the chosen analysis reads the text. The statistics families' rules (design §4.2 rows 413 to 415, and 417 at analysis level; REL-169) read the paste with the families' grid reader (readPastedTable) and, for time to event, survivalFromGrid: readsAsSurvival (a time column by its header, "time" or a unit such as "Days", with marks of 0 and 1 only), readsAsPairs (one Y column, replicates allowed), readsAsColumns (three or more), readsAsPValues (one column headed p, p value or padj, every value from 0 to 1), each needing three complete rows (MIN_ROWS), text headers, no text cells and, for pairs and columns, no row labels; detection thresholds, not scientific ones. The counts matrix (row 416) is not recognised yet. Tests in chooser/chooser.test.ts |
panels/ |
Import, plate map (grid and well table), fit settings (with plate-diagnostics.tsx, the plate diagnostics beside Z′, and control-candidates.tsx, the Grubbs candidates in the control groups; both load with the plate summary as their own chunks, outside the workspace chunk), results table, calibration-limits.tsx (limits from blanks and dilution series in Standard curve), small multiples, curve chart (drawn through the figure scene: panelDocument, layoutFigure, figure/react.tsx; mouse hit areas mapped through the panel geometry layoutFigure reports) and detail, context, method block, calibration, qPCR, research and plan panels; kinetics-panel.tsx with progress-source.tsx (the progress-curve source inside it), inhibition-panel.tsx and the shared kinetics-shared.tsx; growth-panel.tsx; thermal-panel.tsx; planned-sheet-panel.tsx (a received plate preparation sheet, loaded only when the assay keeps one) |
Shared libraries
| Path | Role |
|---|---|
src/lib/fit/ |
outliers.ts and outliers-four-pl.ts (outlier candidates, A5), lm.ts, linalg.ts, curve.ts, four-pl.ts, ci.ts, stats.ts, compare.ts, zprime.ts, normalise.ts, response-evidence.ts, rules.ts, analyse.ts, methods.ts, calibration-model.ts, calibration.ts, profile.ts (generic profile bounds, used by the kinetics intervals and the comparison's δ), global.ts and compare-constants.ts (the curve comparison, A4), experiments.ts (the experiment-level summary, A4), index.ts (FIT_VERSION = "2.0.0"), plate-diagnostics.ts (signal window, SSMD, Z and plate patterns), calibration-limits.ts (limits from blanks and dilution series), relative-potency.ts (relative potency and parallelism intervals from a stored comparison), reporter.ts (dual-luciferase ratios), two-phase.ts (bell-shaped and biphasic fits and their reporting rule), antagonism.ts (Gaddum/Schild, Gaddum non-competitive, the operational model). ode.ts in the same directory belongs to Bioreactor |
src/lib/kinetics/ |
michaelis-menten.ts (fit, profile, kcatFrom), substrate-inhibition.ts (the Haldane form), ki.ts, inhibition.ts, inhibition-compare.ts (the four-model comparison), progress-rates.ts (window rates from progress curves), model.ts (re-exporting model-core.ts: shapes, constants, kineticsInput and the result reader, which src/lib/assay/result-integrity.ts imports directly so the task islands carry no timecourse reader), inhibition-model.ts and progress-source.ts (stored shapes and strict readers, no engine code), compute.ts and inhibition-compute.ts (result from stored data), methods.ts (method records with no imports), hill.ts, bisubstrate.ts, morrison.ts and slow-binding.ts (A-1 second-pass engines; panel choices to follow) |
src/lib/growth/ |
gr.ts (GR metrics: grMetrics, grValue, grAoc, fitGr, rankTrend), gr-model.ts (the GR record GrData, readGrData, parseGrTable, grReadings), gr-base.ts (emptyGr), window.ts, gompertz.ts (the A6 full fit), gompertz-curve.ts, gompertz-method.ts, gompertz-record.ts (the stored fit and its strict reader), gompertz-integrity.ts (verifyGompertzFit), result.ts (the settings, the window result reader and growthInput, split from model.ts so the readers every island loads stay light), model.ts, compute.ts, methods.ts |
src/lib/binding/ |
binding.ts (saturation and competition, one and two sites, NS subtracted or fitted; A-1 second pass), ki.ts (Cheng–Prusoff and Munson–Rodbard for binding, apart from the enzyme ki.ts), record.ts (stored shapes, bindingInput and the strict readers, no engine), compute.ts (computeBinding, loaded with the panel), methods.ts |
src/lib/fit/multistart.ts |
The multi-start bounded LM fit with the bound, failed and estimated states and the correlation matrix, shared by the A-1 second-pass engines (binding, Hill, two-substrate, Morrison, slow binding) |
src/lib/thermal/ |
apparent-tm.ts, model.ts, compute.ts (computeThermal; re-exports thermalDeltas), deltas.ts (thermalDeltas, apart from the engine so the CSV writers and the recording carry no fit or interval code), methods.ts |
src/lib/table/ |
delimited.ts (decoding, delimiter detection, RFC 4180 parsing, numbers), plate.ts (plate-export profiles), tables.ts (long and wide tables), kinetic.ts (kinetic read blocks and the endpoint read), xlsx.ts (Assay's own .xlsx reader: zip, the worksheet XML, cells by the CSO's rules), response-template.ts (Calc's response template: the rows Calc writes and the reader that brings a filled one back as a plate), plate-map.ts (layout model and plan placement), sheet-placement.ts and well-ranges.ts (a Calc plate preparation sheet placed on a layout), import-review.ts, methods.ts, index.ts (TABLE_VERSION = "1.0.0") |
src/lib/assay/qpcr/ |
model.ts (types and strict readers), quantity.ts (calculateQpcr), interval.ts and interval-method.ts (the Welch fold-change interval, A6), import.ts (with QPCR_FOLD_EXAMPLE), import-state.ts, integrity.ts, report.ts, oracle.py (test-only) |
src/lib/stats/outliers.ts, noncentral.ts, power.ts, power-record.ts |
Outliers and planning (A-2 family 7; see Outlier candidates and power): Grubbs per column and studentised residuals across groups (analyseOutliers over the Columns surface's read table); the noncentral t (AS 243) and F (AS 226 with AS R84) and the central F quantile; power for a planned n or the smallest integer n for a target power (solvePower, rCallOf); the power sub-record and its strict reader |
src/lib/stats/ |
Column statistics (A-2 family 1; see Column statistics): distributions.ts (Φ and its quantile, the exact rank-sum and signed-rank null counts, the binomial at ½; Student's t from src/lib/fit/stats.ts), descriptives.ts (descriptives with the CSO's floors and withholding codes), shapiro-wilk.ts (Royston's AS R94), column-tests.ts (intervals on the mean and the median, the t-tests, the rank tests with Hodges–Lehmann, effect sizes, normality with Q-Q positions), columns.ts (columnsFromGrid, the grid's cell states as columns, and analyseColumns), columns-record.ts (the stored sub-record and its strict reader); for families 2 and 6, multiple.ts (the studentized range and the many-to-one t by deterministic Gauss–Legendre quadrature, with their quantiles by an Illinois root-find), p-adjust.ts (R's p.adjust BH, BY, Holm and Bonferroni step for step, and single-step Šidák) and fdr.ts (the false discovery rate over a grid column of p-values, the CSO's withholding rules), methods.ts (method records with no imports). Pure, with no app dependency |
src/lib/stats/ (families 4 and 5) |
Time to event and counts (see Time to event and Counts and proportions): chi-squared.ts (the chi-squared upper tail by the incomplete gamma, exact binomial sums, bisection), survival.ts (survivalFromGrid, kaplanMeier, quantileTime, weightedLogRank, analyseSurvival), survival-record.ts, contingency.ts (Pearson and Yates, Fisher 2 × 2 and r × c, Woolf, Katz, Newcombe, Wilson, Clopper–Pearson, McNemar, the trend test), counts.ts (tableFromGrid, analyseCounts with the withholding policy), counts-record.ts, survival-counts-methods.ts (method records with no imports). Pure, with no app dependency |
src/lib/relate/ |
Relationships (A-2 family 3; see Relationships): points.ts (an xy grid as points by the declaration, replicates as points or row means, every cell left out listed; columns rows paired complete-case), ols.ts (Householder QR, the unscaled covariance, quadraticByR for hat values, bands and the intercept SE without squaring the condition number), correlation.ts and spearman-p.ts (cor.test's Pearson and Spearman, AS 89 for the exact and Edgeworth path), regression.ts (simple, through the origin and multiple regression; pointwise bands; lack of fit; VIF; diagnostics), nonlinear.ts (growth, one- and two-phase, power, Boltzmann on src/lib/fit/lm.ts with profiles from src/lib/fit/profile.ts; the polynomials by QR), compare.ts (ΔAICc and the nested F), method-comparison.ts (Deming with its jackknife, Bland–Altman), codes.ts, methods.ts, record.ts (the stored sub-record, no engine imports), analyse.ts (one analysis from the record). Pure, with no app dependency |
src/lib/figure/ |
The suite-level figure core (CTO's architecture ruling 9): scene.ts (the scene in millimetres: groups, paths, text runs, colour roles; the FigureDocument type; marker paths), layout.ts (layoutFigure(doc, { measure, formatX, formatY, onPanel }): zero-dose responses (FigureSeries.zeroDose) in a band left of the log axis, 8 percent of the plot and at least 2 em, with a "0" tick, the axis line broken by two short strokes and the points hollow, a log tick that would reach the "0" left out, PanelGeometry.xRange giving the span the log axis maps onto; small multiples or an overlay with a legend (at the right, its width the swatch plus the widest label with a tenth more for a substituted font, one em clear of the edge), tick labels thinned so they never overlap, titles set smaller to fit, each replicate bar its own group, the caption wrapped beneath; onPanel reports each panel's plot box, domains and labelled tick counts without changing the scene; defaultFigureSize, the CPO's default size for a figure whose stored size is null), bounds.ts (FIGURE_SIZE_BOUNDS, no imports, so the record's figure reader takes the bounds without the layout), svg.ts (sceneToSvg: millimetre units, provenance metadata, series-<id> groups, real text, no CSS), tiff.ts (imageToTiff: baseline uncompressed TIFF in strips, the dpi, the provenance; refused at 100 MB), pdf.ts (sceneToPdf: one page of PDF 1.4, Helvetica and Symbol not embedded, substitutions listed, the provenance embedded as provenance.json and written in full in the document information as /Provenance, with the source line as /Source, as PNG writes them (the closing walk's W1, 14 October 2026); helveticaMeasure) and afm-widths.ts (generated from Adobe's AFM files with their notice). Pure, with no app dependency. The figure editor adds axis.ts (axisScale: linear or log10, one break removing an interval at the same mm per unit either side with a 1.6 mm gap, NaN for a value with no place; axisTicks: whole decades by a step of 1 to 3 with minors at 2 to 9 times, 2× and 5× labelled under a decade; multiples of a linear step anchored at zero, 2 to 20 of them, minors dividing it evenly; autoLogDomain), and the document's optional fields (xAxis, yAxis, y2, grid, frame, annotations, letters, shared, a series' line and axis): with all absent the scene is unchanged. The layout draws log y (a lower bar arm or a curve at zero or below cut at the frame, counted in PanelGeometry.cut), a break with the zero-dose band's two strokes (and on a box's far side), the open, box or no frame, grid lines at x and y ticks, panel letters and shared axes on small multiples (one domain, tick labels and titles on the left column and bottom row only). layout-extras.ts (FIGURE_EXTRAS, passed as LayoutOptions.extras by the Figure view and the exports only) draws the second y axis at the right and the annotations: shaded ranges behind the data with both ends in the axis' words, reference lines with their value, text by position, arrows by compass direction with the head at the anchor; one anchored outside the axes is listed in PanelGeometry.outside, not drawn. Colour roles gain literal #rrggbb and tint:<role> (a quarter strength over the background), resolved by resolveColours in every backend (SVG, PDF, canvas, screen). Item 4 adds the chart types, each its own module so the dose-response figure never loads them: summaries.ts (type 7 quartiles, boxSummary with whiskers within 1.5 IQR, meanSd, bandwidthNrd0, kernelDensity, dodge: a deterministic sideways arrangement, never random jitter), category.ts (layoutCategory: grouped bars of the mean from 0 with every point drawn and mean ± SD with n above; stacked bars of means without bars or points, a negative mean refused; boxes, below n = 5 the points with a median line; violins on bw.nrd0 trimmed to the data at one maximum width with median and quartile lines, below n = 10 a box; dot plots by mean (± SD) or median; before–after rows joined in group order, a row with a missing value hollow and unjoined; brackets the caller words from a test result, stacked above both groups and their n labels; a CategoryReport of fallbacks, refusals, pairs, bandwidths and n), heatmap.ts and heatmap-scale.ts (layoutHeatmap: viridis, the kept cells' range or a fixed one clipping with ≤ and ≥ on the always-drawn colour bar, excluded cells filled at their clipped reading and crossed, empty cells outlined, a key naming both; an assembled heatmap's panel letter drawn before its heading, not over it), viridis.ts (generated, CC0) and assemble.ts (assemble: scenes moved into grid cells, ids kept unique, letters continued, one caption; captionHeightOf) |
src/lib/assay/project-file.ts |
The Assay project file (rulings 2 and 3; D-0068): kind assay-project at schema 1 with exactly one assay, originals as base64, the integrity hash; readProjectFile (also reads the session file as a one-entry project), sealProject, the recent-files list; ASSAY_KIND_ADAPTERS, the session and Assay project kinds as adapters of the suite's project@1 reader (src/lib/project/file.ts, see Storage and state). Not yet written or opened by the workspace |
src/lib/assay/migrations.ts |
The record parts' pure migrations: the FigureV1 and FigureV2 shapes and migrateFigure(v1), a function of the v1 figure alone (v1 pixels to v2 points and millimetres, as architecture §3.3 gives it, the background white per the CPO's §D.21; v1's colour and symbol become defaults with series empty, so curves fitted after the migration draw in them; v1's width and height were one panel's screen pixels and are not carried, size is null); migrateFigureV2(v2), the FigureV3 shape and its annotation types (the figure editor) |
src/lib/workspace/assay-projection.ts, qpcr-projection.ts, repeat-assay.ts |
Recorded-result projection, qPCR facts, repeat of saved assay work |
Pipeline and tests
| Path | Role |
|---|---|
pipeline/assay-analyser/generate_curves.py |
Seeded generator for the 33 synthetic curves, the MTT plate and the Z′ control sets (NumPy 2.4.6) |
pipeline/assay-analyser/reference.R |
The R oracle (R 4.3.3, drc 4.0-0, MASS 7.3-60.0.1); writes drc-reference.json and manifest.json |
pipeline/assay-analyser/relative-irls-reference.py |
Independent SciPy fixed-weight IRLS oracle (scipy==1.17.0, numpy==2.3.5) |
pipeline/assay-analyser/oracles/ |
The Wave 1 oracles (SciPy 1.13.1 and NumPy 2.0.2 from requirements-scipy.txt, or the Python standard library; one R script), their fixtures and publish.py; see Oracles and verification |
pipeline/assay-analyser/walk_plate_reference.R, walk/ |
The plate walk's refit row (R 4.3.3, drc 4.0-0), published under 0.3.0 |
pipeline/assay-analyser/make-plate-fixtures.mts, make-prism-example.mts |
Synthetic plate-export files; the PRISM example fixture and data module |
pipeline/assay-analyser/fixtures/, plates/ |
Fixture files read in place by tests |
pipeline/assay-analyser/make-project-file-fixtures.mts, make-record-v6-fixture.mts, make-record-v6-figure-v2-fixture.mts, fixtures/project-files/ |
Reader regression fixtures written by the code, not an oracle: the MTT session file as sessionFile wrote it, one record per version 1 to 5 (checked by pipeline/assay-analyser/project-files.test.ts), record-v6-figure-v1.json, a v6 record with a v1 figure, and record-v6-figure-v2.json, a v6 record with a v2 figure away from every default (upgrade.test.ts: a v1 figure reads as migrateFigure(v1), the v2 figure reads whole, and upgradeToV6 leaves every fixture's figure as v2) |
pipeline/assay-analyser/figure/tiff_expected.py, pdf_expected.py, fixtures/figure/tiff-expected.json, pdf-expected.json |
The TIFF and PDF writers' oracles (standard-library Python from the TIFF 6.0 and PDF 1.4 specifications and RFC 1950, each before its writer) |
pipeline/assay-analyser/figure/write-svg-samples.mts |
Writes the Fit view's figure export as SVG (small multiples, overlay, both legends, white and transparent) from the MTT record, for the stage's exit criterion 5 (opened in Inkscape, librsvg and xmllint by hand; docs/progress/assay-acceptance.md) |
pipeline/assay-analyser/figure/write-readback-samples.mts, readback.py |
Third-party read-back run by hand: Pillow (every TIFF pixel) and pypdf (PDF page, texts, attachment) |
pipeline/assay-analyser/figure/afm/, afm_widths.py |
Adobe's Core 14 AFM files with MustRead.html, unmodified; the generator of src/lib/figure/afm-widths.ts |
pipeline/assay-analyser/make-figure-scene-fixture.mts, figure-scene-input.ts, fixtures/figure/mtt-scene-v1.json |
The MTT figure as a scene (170 by 58 mm, three small multiples, the fixed-width text stub), written by the engine, not an oracle; src/lib/figure/figure.test.ts re-lays it |
src/lib/fit/fixtures/calibration-scipy.json |
The original (9 October) SciPy 1.13.1 4PL calibration fixture, kept write-once; its generator moved to pipeline/assay-analyser/oracles/calibration_4pl_scipy.py, which also writes the published calibration-4pl-scipy.json |
e2e/assay-analyser/, e2e/assay-return-journey.spec.ts, e2e/qpcr.spec.ts, e2e/perf/assay-two-second.spec.ts, e2e/perf/assay-workspace-fit.spec.ts |
Browser tests, the two-second performance gate and the workspace fit timing |
Architecture and data flow
The workspace holds one AssayData value in React state. Everything shown is derived from it:
pasted text / file / example / session JSON
-> readImport (src/lib/table) plates or table + ImportReview
-> AssayData.source, layout, excluded, tableDecisions, settings
-> deriveCurves (model.ts) per-plate normalisation, pooled wells per compound, CurveInput[]
-> FitClient: 1 curve inline, >1 in fit.worker.ts in chunks of 25
-> analyseCurve (src/lib/fit/analyse.ts) per curve
-> summarise -> StoredResults stored in AssayData.results with fitVersion
-> writeNumbers C50 and Z' numbers on objects
-> exports, report, recorded Workspace results
Fit settings live in the address (#s= fragment), not only in the record; a run copies the current settings into StoredResults.settings, and everything downstream (exports, recorded results, object numbers) reads the stored settings, so a later change of a control does not relabel an earlier fit (workspace-adapter.ts keeps results.settings explicitly).
Shell-rendered parts (palette commands, the g shortcut, the inspector, the share link) cannot see the workspace's React state. The workspace publishes an AssaySnapshot on every render to store.ts, and those parts read it through readAssay() or useAssay().
Workspace structure and state
Views and modes
The dose-response mode has four views in one tab row, ids import, plate, fit, report (content/apps/assay-analyser.views.ts). Visited Import, Plate map and Fit views stay mounted but hidden, so unparsed paste text and drafts survive tab switches; Report renders only with results. With no assay open (no readings, plan, preparation, repeat or saved work, in dose-response mode), the page leads with the shared discovery start (views/assay-start.tsx, loaded on demand; copy content/apps/assay-analyser.start.ts; UI-24 and UI-16, CPO memo of 11 October 2026, Q7): the app mark, the title "Analyse an assay" as the H1 (never "No assay open"), one sentence, the analysis chooser (docked in the right column at 56 rem and above, its trigger above the input below that) and the Import panel's own input and review (ImportPanel with variant="start", so detection and review are the same) with "Use a Bench input" beside the file input, eight labelled examples, the data first and the analysis in brackets as the chooser names it (assayStartText.example, through inRunningText; CPO memo of 12 October 2026 on the start's words: the worked MTT plate, the published erlotinib readings, standards and unknowns, a Cq table, initial rates, rates with an inhibitor, a growth course, melt curves; the standard-curve and qPCR examples load through withCalibrationExample and withQpcrExample, shared with their panels), recent assays and Assay's own privacy line; the sentence, privacy line and example() exist once, in the start's copy, and inRunningText in assay-analyser.modes.ts (the start re-exports it; the chooser's chunk reads it there); states.empty and states.emptyBody are retired ("No assay open" stays only as the shell's orientation crumb); the right column is empty (aside={null}) until the analysis chooser fills it. Choosing another mode from the start moves focus to the chooser's trigger in the subject bar. Until the assay is named, a mode other than dose response shows the chosen entry's name as its placeholder heading ("Two groups", "Linear regression": the preset entries' modeWords names, else the mode's; the closing walk's S1, 14 October 2026). In the columns, groups, survival, counts and relationships panels, loading the example also declares its units as it was made (independent units; for counts, the design too) and says so beside the example note in the CSO's words (exampleDeclared, exampleDeclaredRepeated for repeated measures; S7); a paste or an edit after the example sets the declaration back to "Not declared" (counts: the design to what choosing the analysis gives), so the example's declaration never carries over to the person's own data (the CSO's and the CPO's red line, 14 October 2026). Each counts analysis has its own example in its own layout (examples/counts.data.ts: independent groups, pairs before and after, five ordered doses, loaded with the event column chosen, since the trend reads only that; S3 and S4), and Paired samples load COLUMNS_PAIRED_EXAMPLE (one culture per row, before and after). The qPCR examples arrive with their sample identities confirmed and say so, from the panel, the start or an ex= link (S19; withQpcrExample and withQpcrFoldExample set the confirmation). The statistics families share one synthetic note (syntheticNote in assay-analyser.modes.ts; S21); the lab modes' notes say how their data were generated and stay their own. Each family's result CSV opens with the declaration in the control's own words (relationships: its code with the words in the note). Once there is a subject, the subject bar appears and "Use a Bench input" is in its ⋯ menu (UI-16); other modes with nothing in them head the bar with the mode's name. Keys 1 to 4 switch views and focus the view heading; g fits when canFit is true. A Limits banner, a published-example note, a refit notice and the preparation-plan panel sit above the views.
The analysis chooser is the one mode control (D-0062; CPO memo of 12 October 2026, A; the native select is gone). On the start it is docked in DiscoveryStart's right column at 56 rem and above and is a trigger in the input column below that (CSS container queries, so only one form is ever shown and a resize keeps the chosen analysis); in the loaded workspace the trigger sits in the subject bar's settings strip. Choosing calls chooseMode, which creates the mode's empty sub-record if needed, announces modeWords.switched once and, when the choice leaves the start, moves focus to the subject bar's trigger. The import suggests a mode and never switches it (roadmap A1; design §4): when dose response's Import panel (on the start or in the Import view) reads text, it loads chooser/suggestion.ts and, when another shipped analysis's parser reads the text and dose response's does not, shows one notice line ([data-import-suggestion], role="status", aria-live="off", a 2 px info rule) above the recognised layout or the refusal, with a key and a quiet "Keep dose response"; the line is announced once and focus stays where it was. Editing or clearing the text, or reading new text, removes it. The key calls takeSuggestion: the workspace keeps the text (suggestionCarry), switches the mode with chooseMode, remounts that mode's panel with the text in its own paste box (initialText, every Wave 1 panel and calibration and qPCR) for its own review, and moves focus to the panel's heading. Only dose response's Import suggests today; the other panels' paste boxes do not, and no family-level suggestion is shown until a statistics family ships. Browser specs choose analyses through e2e/support/assay-chooser.ts (by keyboard: the name typed into the search, then Enter); e2e/assay-analyser/chooser.spec.ts covers the two forms, the ranking, Escape, no match, focus and counts. The manual NVDA and VoiceOver pass the CPO asked for (A.1 Q4a) has not been done.
The free/paid line (roadmap-v0-depth Stage A, exit criterion 7; 13 October 2026). Every analysis, export, the project file and share links work with no account. When the workspace access says saving is unavailable (signed out of an account workspace, or no entitlement), WorkCaptureControls shows the caller's freeLine in place of the suite's paid link: Assay passes workspaceCopy.freePath ("Saving to a Study needs a Drybench account. Downloads, the project file and share links are free.") with "Download project file" beside it. In today's browser-local access saving works on this device, so the line is not shown. It is never on results, never a modal and never in an export: the app's limits line, which travels in every export's provenance, carries no account text, and the in-app guide and FAQ say "no account is needed to analyse and export; saving needs one". e2e/assay-analyser/free-path.spec.ts checks the line, the download and the project file's text.
The Calibration, qPCR, enzyme kinetics, inhibition, growth and thermal-shift panels (panels/calibration-panel.tsx, qpcr-panel.tsx, kinetics-panel.tsx, inhibition-panel.tsx, growth-panel.tsx, thermal-panel.tsx, each loaded with next/dynamic) replace the four views when their mode is selected. Switching mode sets v: 5 and creates an empty sub-record (emptyKinetics, emptyInhibition, emptyGrowth, emptyThermal and the calibration and qPCR equivalents) if none exists; the switch is announced once. Each mode keeps its own record, so switching back and forth loses nothing. Loading one of the four synthetic Wave 1 examples (ex=initial-rates@1, inhibition@1, growth-window@1, thermal-shift@1) opens its mode, because the example entry carries mode; no fragment key was added. Each Wave 1 panel follows the same order: paste and review naming rejected rows, the example, units and conditions, the typed window or model, the result or its withheld reason with the CSO's qualification, one chart, the readings table with exclude and restore (a reason is required and kept in history), the method paragraph and a limits block.
Column statistics (columns mode, A-2 family 1; Column statistics) is a v6 mode: chooseMode("columns", preset) raises the record to v6, creates emptyColumns if needed and sets the comparison from the chooser entry's panel: preset (one-sample, two-sample, paired); the trigger's name is derived from the stored comparison (presetSettings). panels/columns-panel.tsx (on demand, with src/lib/stats/ and its copy) shows the data grid (typed, or a pasted table read into it, with exclusions) and the text cells listed by column and row, the declaration radio group (not declared, independent, technical), the comparison, columns A and B or the column and the typed reference value, Welch or Student, the level, then the descriptives table in its own scrolling region, Shapiro–Wilk with up to six Q-Q plots, the comparison (the estimate and its interval first, then the t-test and the rank test side by side, the effect sizes marked "descriptive; no interval") and the limits. The record stores only the applied text and the settings (src/lib/stats/columns-record.ts, strict reader, v6 only); every number is recomputed on render. Recording to a Study (captureColumnsWorkspaceResult in columns-capture.ts, loaded on the Record press; schema assay-analyser.columns-result v1, a presentation schema in result-projection-core.ts) recomputes the analysis from the saved table and settings and records one row per column (n, mean, SD, the interval on the mean or its withheld sentence) and one for the comparison (the estimate and its interval, the t-test and rank test lines), the full long rows as output, the six method pins, and the declaration and limits lines as qualifications; a repeat keeps the settings and clears the table. The exports (columns-export.ts, on demand) are "Column statistics CSV" (one row per statistic at full precision with n, level, R's method name, and a withheld code and sentence in place of any withheld number) and "Columns as read CSV" (each cell's state by column and row). The methods block lists the column-statistics methods; e2e/assay-analyser/column-statistics.spec.ts covers the chooser entry, the declaration gate, Welch and Student, refused cells, at desktop and 375 px.
Outlier candidates (A-2 family 7) are a section of the Columns surface, panels/outliers-section.tsx, loaded in its own chunk by columns-panel.tsx and collapsed until opened; the chooser's "Outlier candidates" entry (panel:outliers) opens it through the workspace's columnsScreen state, which is not stored (the trigger's name is derived from it while the mode is open). Power and sample size (power mode) is a v6 mode with no data: chooseMode("power") raises the record to v6 and creates emptyPower; panels/power-panel.tsx (on demand) holds the design (test, what to work out, α, target or n, the assumed effect) and the answer with the planning note to copy or to add to the current Study's notes (study-note-append.ts, on the press: the Study store's update against the revision just read, as the Bench note, so a newer revision is never replaced); the record stores the inputs as typed. The outlier frame is stored on the Columns record as outliers: { frame } once the person screens (absent before), so the export and the Study record use the frame they saw. Recording: a screened Columns result adds one "Outlier candidates" row naming each candidate in the chosen frame, the criterion, the exclusion line and the Grubbs or studentised-residuals method pin (columns-capture.ts); a power plan records through power-capture.ts (schema assay-analyser.power-result v1, a presentation schema; evidence a planned manual assertion, calculated) with one row carrying the answer and the planning note, the full rows as output, the power method pin and the limits as qualifications; a repeat keeps the plan's inputs. Exports: "Outlier candidates CSV" (outliers-export.ts, the chosen frame only, full precision, method and codes; offered once screened) and "Power and sample size CSV" (power-export.ts: the design, the answer at full precision, the flags, the R equivalent, or the withheld code).
Several groups (groups mode, A-2 family 2) and the false discovery rate (many-tests mode, family 6; the section below) are v6 modes. The chooser carries four entries: "Several groups: one factor" (the mode's own entry), "Several groups: two factors" and "Repeated measures" (panel:two-way and panel:repeated, which chooseMode turns into the stored choice; the trigger's name follows the stored design) in "Columns of values", and "False discovery rate" in "Many tests, outliers and planning". panels/groups-panel.tsx (on demand) shows the data grid (the columns grid, with "Rows are matched" for repeated measures, or the grouped grid for two factors; both are kept, so changing the design never discards a table), the paste disclosure and the example, the declaration radio group shared with Columns, the design, level, test, comparisons, correction and control, then the result from groups.worker.ts (drybench-worker:assay-groups, one job, debounced; the studentized-range and Dunnett quantiles take up to a second): the omnibus line, η² and ω² marked descriptive, the comparisons table with m and the method named, the described assumptions and the limits. panels/many-tests-panel.tsx computes on the page (a sort and a running minimum): the grid of named p-values, method, level and order, the count in the CSO's words, m and the rows left out, the method's assumption line, and the adjusted p column named "adjusted p (BH)" and so on. The exports (groups-export.ts, on demand) are "Several groups CSV" (long rows: section, item, statistic, value at full precision, method, withheld code; comparisons name their groups) and "Adjusted p-values CSV" (m, the level, the count, then p and adjusted p per test in input order, or the withheld code and rows). Recording to a Study (canRecordGroupsWorkspaceResult and canRecordManyTestsWorkspaceResult in workspace-adapter.ts; the capture in groups-capture.ts, loaded on the Record press; schemas assay-analyser.groups-result v1 and assay-analyser.many-tests-result v1, presentation schemas in result-projection-core.ts) recomputes from the saved grid and settings and records bounded rows within the projection's 50: the group sizes for up to ten groups, the overall test (or its withheld sentence), up to 30 comparisons named by their pair with the method and m, and a row counting the rest; or the count in the CSO's words and up to 45 tests' adjusted p. The full long rows are the output; the method pins follow the analysis; the declaration, assumption and limits lines are qualifications. A repeat keeps the settings and clears the tables. src/apps/assay-analyser/groups-persistence.test.ts covers both. e2e/assay-analyser/groups.spec.ts covers the chooser entries, the declaration gate, Welch with Games–Howell, the mixed model's missing days and the false discovery rate's count, at desktop and 375 px.
Time to event (survival) and counts and proportions (counts) are v6 modes in the same pattern. chooseMode raises the record to v6 and creates emptySurvival or emptyCounts; the counts presets set the analysis (panel:mcnemar also records the matched-pairs design, panel:trend), and "Compare survival" (panel:compare) stores nothing: the workspace holds the choice for the session (survivalScreen, as the outliers screen is), so the trigger and the heading name the entry chosen, not the number of groups (the closing walk's S2). panels/survival-panel.tsx shows the grid as an xy table (time, then one status column per group), refused rows by reason, the declaration, test, level, reference group and band, the chart from src/lib/figure/survival.ts (rendered with the scene backend) with its risk table and a Kaplan–Meier table disclosure, the groups with their medians, the comparison and the limits. panels/counts-panel.tsx shows the grid as a columns table with row labels (no exclusions), refused cells, the two declarations, the analysis, test, roles and scores, the counts with totals, expected counts and row proportions, the result (the r × c Fisher p only on "Calculate the exact p", REL-51) and the limits. Exports (survival-counts-export.ts, on demand): "Survival results CSV" and "Kaplan–Meier table CSV"; "Counts results CSV" (McNemar's continuity-corrected test in it only) and "Table of counts CSV". Not yet recordable to a Study (REL-100). A repeat keeps the settings and clears the table. e2e/assay-analyser/survival.spec.ts and counts.spec.ts cover the chooser entries, the declaration gates, the results on the examples and refused input at desktop and 375 px.
Address state
assaySchema in address.ts:
| Key | Values | Meaning |
|---|---|---|
view |
import, plate, fit, report |
The subject; changing it pushes history, other keys replace |
norm |
percent, raw, percent-control, percent-inhibition |
Normalisation (default percent) |
fix |
list of top, bottom, hill (max 3) |
Fixed parameters |
w |
none, 1y2 |
Weighting |
ci |
profile, wald |
Interval shown (default profile) |
ic |
rel, abs |
Relative or absolute C50 (default rel) |
intent |
unspecified, inhibition, activation |
Endpoint name C50, IC50 or EC50 (D-0028) |
ex, recipe, id, name, smiles, mine |
Hand-off dictionary validators (src/lib/handoff/params.ts) |
Arrivals: example id, dilution plan payload, UniProt accession, name, structure, the person's own object |
Defaults are omitted from the fragment (settingsToState). A share link carries only SHARE_KEYS = view, norm, fix, w, ci, ic, intent: never a response, well assignment, compound name or browser-local ref. addressTakesAssay (address-ref.ts) decides when the address's mine may name the kept assay so a reload reopens it: when mine is empty, or when it names the mine:recipe/ that arrived and the assay now holds that recipe. See Storage and state for the fragment mechanism.
Copy link (contracts §3.5; the CPO's Bench memo of 12 October 2026, ruling 5): share.notInLink in definition.ts returns workspaceCopy.share.notInLink when and only when the assay holds readings (the CPO's Copy link memo of 12 October 2026, Q1; readAssay()?.hasReadings, true when dose response has a source or any of the calibration, qPCR, kinetics, inhibition, growth and thermal sub-records holds readings, since a link carries none of them), so Copy link opens the shared share dialog instead of copying: "Not in this link", that line and "A session file carries what the link leaves out.", with "Share a session file" (exportSession) focused and "Copy link anyway" beside the link. An empty workspace copies at once with the usual "Link copied" toast. When the address the workspace first reads carries a fit-settings key (norm, fix, w, ci, ic or intent) and no mine, ex or recipe, the Fit view's empty line ([data-fit-empty]) is preceded by fit.settingsOnly until the assay holds data. The settings live in the address, so a table imported afterwards is fitted under them (e2e/assay-analyser/share.spec.ts checks this); opening a session file applies the file's own settings.
The assay record (AssayData)
AssayData (model.ts) is the data of the mine:assay/<uuid> object and of the session file:
| Field | Content |
|---|---|
v |
Record version 1 to 8 (below) |
analysisMode, calibration, qpcr, preparation |
Mode and the calibration, qPCR and master-mix preparation sub-records (v4, v5) |
combination |
Combination responses (contract 13; CombinationDataV1, src/lib/assay/combination/types.ts, strict reader record.ts), v8 only: v: 1, its own id and sourceId, the two agents (id, name, unit), the dose levels of each axis, the applied observations (id, doses, response, excluded, optional well, biological-replicate id and source row), on the raw path the endpoint control readings (controls.noEffect, controls.fullEffect, values only), the settings (input path mode, scale, direction, blissInterpretationDeclared, optional endpointsRepresentScale) and the stored result with its inputHash |
enzyme, inhibition, growth, thermal |
The four Wave 1 analyses (KineticsData, InhibitionData, GrowthData, ThermalData), v5 only; each holds v: 1, its own sourceId, the reviewed readings, settings, decision history, the source (paste or synthetic), the example id and the last result with the SHA-256 inputHash of its inputs |
columns |
Column statistics (A-2 family 1; ColumnsData, src/lib/stats/columns-record.ts), v6 only: v: 1, its own sourceId, the grid (a columns data grid, read by the grid's strict readGrid, or null), the source (paste, typed or example) and the settings (the declaration, the level, the comparison); no number is stored, every one is recomputed from these |
power |
Power and sample size (A-2 family 7; PowerData, src/lib/stats/power-record.ts), v6 only: v: 1, its own sourceId, and the settings (test, solve for n or power, α, target power, and the fields n, delta, sd, units, means, p1, p2 as typed strings of at most 200 characters); no number is stored |
groups |
Several groups (A-2 family 2; GroupsData, src/lib/stats/groups-record.ts), v6 only: v: 1, sourceId, two grids (columns and grouped, each read by readGrid and its shape checked, or null), source, the declaration (unit), the level and the choice (one-way: test, post-hoc, family with an optional control; two-way: Type 2 or 3, post-hoc, family; repeated: ANOVA or Friedman, mixed-model rule, GG or HF); no number stored |
manyTests |
The false discovery rate (A-2 family 6; ManyTestsData, same file), v6 only: v: 1, sourceId, a columns grid or null, source, the p column, method (BH, BY, Holm, Bonferroni) and level (0.01, 0.05, 0.10); no number stored |
survival, counts |
Time to event and counts (A-2 families 4 and 5; SurvivalData in src/lib/stats/survival-record.ts, CountsData in counts-record.ts), v6 only: v: 1, a sourceId, the grid (an xy grid for survival, a columns grid for counts, or null), the source and the settings (survival: the declaration, the level, the test, the reference group, the band; counts: the unit and design declarations, the level, the analysis, the selected test or null for the pre-selection, the roles and the trend scores); no number is stored |
blind |
v6 (A-4 item 9): blind analysis, { v: 1, key (code → compound name), hiddenAt, locked?: { at, state }, revealedAt? } (blind-record.ts, strict: unknown keys, an empty or repeated key, or a reveal without a lock refuse the record); see Blind analysis |
plannedSheet |
A plate preparation sheet received from Calc (PlateSheetEnvelope: the serial-dilution recipe with its plateSheet, capturedAt, the sender and contentHash), v5 only, read-only (see Plate map) |
source |
{ kind: "plates", profile, discardedLines, plates: PlateBlock[] } or { kind: "table", shape, unit, skipped, compounds }; table concentrations are stored in mol/L |
sourceId, importReview, importRecord |
Identity of the accepted import, its review accounting, its file/paste/example receipt (with SHA-256 and optional retained original) |
layout |
PlateLayout: format, unit (one of M, mM, µM, nM, pM), wells map of { role, compound?, concentration? } |
excluded |
Observation keys "<plate index>:<well>" or "t:<compound>:<point index>" |
tableDecisions |
{ v: 1, roles, reasons, history } for table rows |
compounds |
Per compound label: smiles?, ref? (inchikey: or mine:compound/) |
target, construct |
Linked objects { ref, label } |
plan |
The dilution plan from Calc: ref, label, TablePayload, dmsoMax, compressed payload, compound, placedAs, placedOn, tableAssociation |
settings, results |
FitSettingsState and StoredResults (inputHash from v6, methods from v6 and optional, fitVersion, tableVersion, at, settings, scale, curves, zPrimes, cancelled) |
refitFromVersion |
Engine version of results withheld on load |
curveFixes |
v6: per curve label, the parameters fixed for that curve alone (top, bottom, hill), added to the all-curve settings.fix by curveFixKeys before fixedFor (routine-week criterion 4); read by readCurveFixes, a malformed entry refused as malformed at curveFixes |
withheldResults |
v6: { reason: "input-changed" or "inconsistent", results }, results withheld when the record was opened, kept unread until a refit succeeds |
experiments |
v6 (A4): the "Across plates" declaration { v: 1, ids: { [plate]: experiment id }, confirmed } (experiments-record.ts, at most 64 plates, ids up to 120 characters); only the declaration is stored, the summary is recomputed when asked; see Experiment-level summary |
comparisons |
v6 (A4): up to 20 curve comparisons, newest first (CurveComparison, comparison-record.ts): { v: 1, version, inputHash, resultsInputHash, resultsAt, at, settings: { shared, reference }, curves, plates, outcome }; read by readCurveComparisons (an unknown key refuses the record at comparisons); see Curve comparison |
dataset |
Example mark { id, kind: "synthetic" or "published", edited? } |
research, workspace, figure, repeatedFrom, template |
Research context, Workspace bindings, figure presentation, repeat source, layout-template flag |
Record versions, as readAssayData enforces them:
v |
Introduced | Reader rule |
|---|---|---|
| 1 | Original | Lenient readSource; no tableDecisions, sourceId or importReview allowed |
| 2 | Table with explicit control roles | Source must be a table; tableDecisions required and strictly read |
| 3 | sourceId and importReview |
Strict readDecisionSource; sourceId must be a UUID; decisions required |
| 4 | analysisMode, calibration, preparation |
As v3 when sourceId or decisions are present; source may be null |
| 5 | qpcr; later enzyme, growth, inhibition, thermal and plannedSheet (Wave 1 and Wave 3, without a new version) |
As v4; these fields allowed from v5, each read by its own strict reader, and a mode named in analysisMode must have its sub-record |
| 6 | Stage A (CTO memo of 12 October 2026, ruling 6), first for the response template: importRecord.templateControls and the template plate profile; then kinetic and the kinetic plate profile; completed by A-0 (the architecture rulings, rulings 1 and 4): results.inputHash |
As v5, and every Stage A field is allowed only from v6; a v6 record with a top-level key outside V6_KEYS (model.ts) is refused, so no field is dropped silently; results, when present, must carry inputHash (sha256: and 64 hex digits) and no key outside its allow-list (V6_RESULTS_KEYS; unknown-key at results.<key>); results.methods, the method pins, is allowed only on v6; a calibration sub-record at v: 2 (the 5PL, A5) or v: 3 (spike declarations) only from v6 |
| 7 | REL-53 (the CTO's memo of 13 October 2026, docs/board/memos/2026-10-13-cto-assay-record-v7.md): the top-level fields added after v6 reached main, ASSAY_V7_KEYS (assayFormat, binding, blind, columns, counts, gr, groups, manyTests, power, relationships, screening, suggestedFrom (A-4 item 6's saved template, joined at batch 8), survival), and the nested ones a build at main's v6 refuses or drops (needsV7: a figure's panels.heatmaps, a calibration sub-record at v: 3, an enzyme model of Hill or two substrates, a comparison's margins, importRecord.massConversion, the percent-vehicle-bg normalisation or a reporter in the fit settings) |
As v6. V6_KEYS is main's set again, and v6 and v7 both accept V6_KEYS with ASSAY_V7_KEYS, because batch builds wrote v6 records carrying them. A build that knows only v6 refuses a v7 record as made by a newer version (version-newer), not as an unknown key. A new top-level key after this change needs v8 |
| 8 | Combination responses (combination mode, contract 13): top-level combination sub-record (ASSAY_V8_KEYS). A build that knows only v7 refuses a v8 record as made by a newer version (version-newer). |
As v7. Allowed only on v8 |
The versions live in one list, ASSAY_RECORD_VERSIONS in src/lib/assay/record-versions.ts (no imports, so the Bench readers in src/lib/workspace/ and the record preview share it without importing the app; record-version.ts in the app re-exports it), with atLeast(v, n) for the reader's checks and raiseTo(v, n) for writers, which move a record up to the version a field needs and never down. New imports write v3 (or keep a higher version); a filled template writes v6. A reader that sees calibration, analysisMode or preparation on v1 to v3, or qpcr, enzyme, growth, inhibition, thermal or plannedSheet below v5, refuses the record rather than dropping fields. Because the Wave 1 modes and the planned sheet were added to v5 without a new version, a v5 reader from before them refuses an assay in one of the new modes (an unknown analysisMode); a dose-response assay that only carries plannedSheet would be read by such a reader without the sheet, because readAssayData builds the record field by field. v6 closes that hazard from Stage A on. ASSAY_SCHEMA_VERSION is still 1, the version emptyAssay starts from; ASSAY_CURRENT_VERSION is 8.
Every record the app writes is v6 (A-0, the architecture rulings' ruling 1). The workspace's current, the one record every write path takes (autosave, the first createMine, the numbers a fit writes, named Workspace work, the session file), goes through raiseOnWrite (src/lib/assay/migrations.ts; raiseToV6 is its earlier name, kept): a record with no results, or with hashed results, becomes v6, and then v7 when it carries a field needsV7 names; nothing else changes, and no step lowers a version (migrateAssayShape keeps a v7 record at 7, so the open-time upgradeToV6 never lowers one). A v6 record carrying v7 fields reads as v6 and becomes v7 at its next write, never on open; a saved task asks to be saved once after that raise, as v5 to v6 did. A v3 figure written at main has no panels.heatmaps and reads as none. Fixtures: record-v6-batch.json and record-v7.json (make-record-v7-fixtures.mts); tests in record-v7.test.ts. From A-0 commit 3 the record's figure is read as v2 (a v1 figure migrated in memory), and from the figure editor (13 October 2026) as v3 (a v1 or v2 figure migrated in memory by migrateFigureV2), so every record written carries a v3 figure. Where a record is opened (an assay from the address, a saved assay from the rail, a session file), upgradeToV6 runs first, in memory: results with a hash are verified; results from before v6 are carried only when they agree with the readings value for value (the CTO's ruling 4 and the CSO's ruling of 12 October 2026: their settings are the assay's, their scale and engine version today's, their curves have the same ids with the same n and excluded counts, and each curve's stored wells, scale, fixed and weighting canonically equal the derived curve's; a curve stored without its wells is not carried), and the hash a carried result then holds was written at upgrade, of an input the result itself attests. Anything else is withheld: the block moves to withheldResults: { reason, results } (v6 only), kept in the record and never read for a number, results is null, and the CSO's notice (copy.fit.resultsWithheld, "Withheld. These results were fitted to readings or settings that differ from this record's…" or "…were saved before the app checked results against their readings…") shows above the results on every open until a refit succeeds, which clears it; the engine-version notice (refitFromVersion) shows alone when it applies. Never through refitFromVersion, and nothing is refitted silently. The migrations are registered at src/apps/assay-analyser/schemas/migrations.ts (contracts §4.7 rule 2) and live in src/lib/assay/migrations.ts (migrateAssayShape, migrateFigure).
Results and their readings (ruling 4). doseResponseInput(data, settings) (model.ts) is what a fit consumes as a pure value: per curve its id, label, scale, wells and fixed parameters and weighting, from deriveCurves under the fit's settings, and the settings. runFit writes inputHash: contentHashLight(doseResponseInput(current, settings)) into the stored results. verifyDoseResponseResults (src/lib/assay/result-integrity.ts) compares it against the readings under results.settings, so changing a setting after a fit does not void the fit, while a changed reading does. The workspace shows, exports and captures only verified results (useVerifiedResults: the results every panel, the method block, the paragraph, the figure, the print report and the export items read); writeNumbers and captureAssayWorkspaceResult verify again and refuse otherwise.
Motion after a fit and a record (the motion pass of 14 October 2026; motion/effects.ts). Only an explicit fit (the Fit key, g, or the definition's fit action: runFit(true)) queues its curves in fit-reveal.ts; CurveChart takes its own id once and draws the fitted line along its path in 400 ms (drawPath, B4, expressive("inkCurve")). A refit on load, a settings change or a restore draws nothing; the points, axes and numbers are never animated, and the chart has no confidence band to draw. The figure's groups carry data-role (figure/react.tsx). After Record result, WorkCaptureControls shows the recorded stamp (B5) beside the receipt; see the shared shell. The Fit key shows PendingLabel while a fit runs.
Method pins (the CTO's ruling of 12 October 2026 on method pins; the CSO's method-line names). runFit writes results.methods: the { id, name, version } of each method methodsUsed lists, from METHODS, pinned when the fit runs and copied out verbatim, never looked up again. src/lib/methods/pin.ts (no imports) holds MethodPin, pinMethod, the strict readMethodPins (1 to 16 entries, exactly three keys) and methodLines, which writes "Method: {name}, version {version}" from the pins, or "Method: {id}, version {version}" for results stored without them. Optional on v6, because an upgrade cannot write names honestly; refused below v6 (malformed at results.methods). Fixture: pipeline/assay-analyser/fixtures/project-files/record-v6-methods.json (written by make-record-v6-methods-fixture.mts).
Strict reading. readAssayDataStrict(input) returns { data } or { problem: { code, path? } } with the codes not-an-assay, version-unknown, version-newer, unknown-key, figure-newer (a figure version above the one this build reads, today 3), figure-malformed (a v2 or v3 figure below v6 is figure-malformed at figure.v), mode-record, source, decisions, review and malformed (another known section, named by path); The record is never migrated by any of them. path is always given for malformed, unknown-key and figure-malformed (the CTO's ruling of 12 October on A-0 commit 1). refusal.ts turns a problem into the CPO's sentence (workspaceCopy.open.refused: not an assay; made with a newer version; could not be read at {path}; an entry at {path} this version does not read; the last three typed once in projectFileText (content/apps/project-file.ts) and re-exported, because no app owns them, the CPO's Sequence ruling, point 9), the path to be set in code type; the project-file open uses it (commit 2).
Mode readers load with their mode (the CTO's ruling of 13 October 2026 on the workspace chunk, docs/board/memos/2026-10-13-cto-assay-workspace-chunk.md). Each analysis mode's sub-record (qpcr, calibration, growth, enzyme, thermal, inhibition, screening, columns, power, groups, manyTests, survival, counts, relationships, gr, binding) is read only by its own strict reader, which mode-readers.ts loads through a literal import() (loadModeReaders, modeReader, prefetchModeReader; modeSubrecordsOf(record) names the readers a record needs). readAssayDataStrict(input, readers?) takes the loaded readers by default and returns a third outcome, { status: "pending", keys }, for a record whose mode reader has not loaded: never a refusal and never "no record". The version gate and the unknown-key refusal come before it. readAssayDataStrictAsync / readAssayDataAsync load the readers and read, and every path that admits a record uses them: the open from the object store and the draft beside it, the undo read, the project-file open (open-project.ts loads every reader first), repeat, source impact, the series export and Across assays. Where a render must read at once (the template list, the object tab), readAssayDataOrPending gives "pending" and use-mode-readers.ts (useModeReadersFor) loads the readers and re-renders; the object tab shows a loading state meanwhile. readAssayData (data or null) and readAssayDataStrictLoaded throw ModeReadersPendingError rather than answer pending, for Node code with every reader registered by mode-readers-all.ts (the tests). The workspace opens a mode's reader early (prefetchModeReader on the analysis mode). Each mode's version and empty record are in a small base module beside its model (src/lib/assay/qpcr/base.ts, src/lib/fit/calibration-base.ts, src/lib/growth/base.ts, src/lib/kinetics/base.ts, src/lib/kinetics/inhibition-base.ts, src/lib/thermal/base.ts, src/lib/screening/base.ts, src/lib/stats/columns-base.ts, src/lib/stats/power-base.ts, src/lib/stats/groups-base.ts, src/lib/stats/survival-base.ts, src/lib/stats/counts-base.ts, src/lib/relate/base.ts, src/lib/growth/gr-base.ts, src/lib/binding/base.ts; the models and records re-export them); the Bench adapter's captures load their reader and integrity guard on recording; the dose-response guard is src/lib/assay/dose-response-integrity.ts. Tests: mode-readers.test.ts (pending before the reader, the core's refusals first, the async read strict, every mode in the registry) and scripts/assay-chunk-imports.test.mjs (the workspace's static closure holds no reader; model.ts, the workspace, its adapter and its exports import no mode model or record from src/lib statically). A new mode adds its key to mode-readers.ts and V6_KEYS, and its base module. path is always given for malformed, unknown-key and figure-malformed (the CTO's ruling of 12 October on A-0 commit 1). refusal.ts turns a problem into the CPO's sentence (workspaceCopy.open.refused: not an assay; made with a newer version; could not be read at {path}; an entry at {path} this version does not read; the last three typed once in projectFileText (content/apps/project-file.ts) and re-exported, because no app owns them, the CPO's Sequence ruling, point 9), the path to be set in code type; the project-file open uses it (commit 2).
Persistence
- Autosave. The first import creates the object with
objectStore().createMine("assay", …, { as: "carry" }); a single in-flight creation promise (pendingCreate) prevents two records from one import. Later changes are written bysetDataafter a 1,200 ms debounce (AUTOSAVE_MS). Under D-0045 a carried draft is not listed as saved work until the person saves it. Named Workspace work uses the guarded Workspace editor instead of this autosave. Its edits are kept as the task's draft beside the record (D-0068 rule 8; Storage and state); both open sites (state.mineandopenSavedAssay) read the draft throughkeptOrSavedand show it whenreadAssayDataaccepts it, settingappliedDraftin the same update (a refused draft is offered as unshown), andreloadOpenAssayservesonDraftResolvedfor Discard and its Undo, checking the scope before it sets data.keptOrSavedalso returns the live task that owns the assay (readOpenWork): thestate.minepath opens that task with the assay, so an address alone shows the offer, andownedRefkeeps this autosave off for an assay a task owns. A saved record put on screen (no draft applied, or reloaded on Discard) is held insavedShownuntil its data and settings show, thensavedOnScreenis bumped so the control takes the upgraded v6 record as the task's baseline rather than an edit (the PR 21 review, findings 2 and 7). - Templates. "Save the layout as a template" creates a separate
assaywithtemplate: trueand the layout only (layoutTemplate) in one action, named "{assay} layout" and announced; "Save with a name…" opens the naming dialog first (the routine week, criterion 10). Templates are offered on the Plate map view ("Layout templates") and applied only to a layout of the same format. - Project file (A-0 commit 2b; the CTO's rulings 2 and 3; D-0068: the free, portable form of one piece of work, which knows nothing of Studies). The cluster's ⋯ menu opens with "This assay" (unit-of-work design §3.9): Open a file… (a native picker made for the one act, so the page keeps one file input; a project or session file opens as the current work; anything else is handed to Import, the import panel reading it once through
handedFileand reporting throughonHandedFileonce it has read or refused it, so the line,projectFileText.handedNotProject"That was not a project file, so Import read it." orhandedNotProjectRefusedwith Import's own reason, is said after the fact; the CPO's ruling of 12 October 2026), Download project file, for saved work Repeat, and New assay last (the CEO, 14 October 2026: a plan from Calc could not otherwise be cleared). New assay runs Next plate's path (nextPlate: the independent-assay guard flushes saved work, then an empty assay and nomine,recipe,id,name,smilesorexin the address, so the discovery start shows); unsaved work (sessionUnsaved) asks first in a dialog (open.newTitle), an example does not (e2e/assay-analyser/new-assay.spec.ts); an unnamed session shows the same group in its own More menu. Unnamed work (no saved task; the record kept in this browser does not count) that holds the person's own inputs and changed since its last project-file write or open shows Unsaved changes with "Download project file" in the save control's status slot (sessionUnsaved), quiet and not announced, with the browser's ownbeforeunloadwarning while it shows; an untouched example never does, and opening saved work starts clean. A project file whose record id (ProjectRecord.id) is a live task's record in this scope opens the §3.10 dialog first (panels/open-conflict.tsx, loaded on demand;findOpenConflictandreplaceSavedInputsinopen-project.ts): the saved work and the file side by side, a note when the file's integrity hash does not match, Open as new work (primary, focused) taking the file as unnamed work, Replace the saved inputs (an explicit save of the file's inputs as the task's next revision, checked against the revision read; refused while the task holds a kept draft, with the reason) and Cancel (Escape). Export "Project file (JSON)" and the shell's share-file action writeproject@1(see Storage and state) throughprojectFile(exports.ts), which loadsproject-export.tson intent (chunkdrybench-export:assay-project): one assay record (schemaVersionthe record'sv,enginesthe fit and table versions, the provenance), the retained originals that verify in this browser as base64, the saved object's versions, and any work the opened file carried for other apps written back in its place; sealed, and refused above 50,000,000 bytes with the CPO'scapOversentence. The session file (assay-session@1,sessionFile) is no longer offered and is read forever. Opening: the Import panel sniffs a dropped file's first 16 KiB and hands a project file (known by its envelope, never its name, so a renamed one opens and a plate export named.jsonimports) toreadSessionFilewith the 50 MB cap ahead of the text imports' 4 MB, checked by kind in the handler (the drop has no limit of its own); a file whose kind is not certain (a.jsonname without the project envelope: the project detector's hint, "possible") is not read until the person decides: the CPO'sdetectedUnsureline names the top reading (the plate reading when the text reads as one, else the project hint) and a native Read as select offers Assay's two kinds (import.kinds: Plate export or table, Project file); "Read the text" then reads it as a plate or hands it to Open (contracts §4 rule (2); a certain project file still opens at once, §3.8) and refused with the CPO'sprojectFileText.tooLarge, its size and cap filled byformatBytes(the same sentence for the reader'stoo-large); the calibration and qPCR panels' session buttons use the same path.readSessionFileloadsopen-project.ts(chunkdrybench-import:assay-project): the suite reader with Assay's reader, its count of one and the two older kinds as adapters; a refusal shows the CPO's sentence (open.refused: not an assay; made with a newer version; could not be read at{path}; an entry at{path}this version does not read); a retained original this browser cannot verify is restored from the file when its bytes match the receipt (retainWorkspaceOriginalFile), else the receipt is downgraded as before; the record is raised byupgradeToV6; the hand-off line (from Import), the work kept for other apps and what a reduced file left out are announced once; the file's name, size, hash, time and label join the scope's recent list (assay-analyser:recent-files:v1, at most 20). Not yet built (surfaces the design agent and the CPO place): the open control in ⋯, the recent list's display, the conflict with a saved record of the same subject (opening is always a new draft today), the file-changed-outside-the-app line forintegrity: "mismatch", and the export dialog's choice to leave originals or versions out. - Import presets. Long-table column mappings are stored in the IndexedDB
metastore underassay-analyser:import-presets:v1(at most 50), and fail closed when headers change order or content (import-presets.ts).
See Storage and state for the object store, versions and Workspace stores.
Import
The Table view (the DataGrid)
The Import panel's data input has a Text | Table switch ([data-data-view], a segmentGroup of two aria-pressed buttons labelled by the panel heading; words content/apps/data-grid-view.ts; DS §6.34 and the CPO's memo of 13 October 2026, ruling 5 and amendment C). Table loads panels/import-table-view.tsx with React.lazy on the first press, so the grid, the paste reader and the plate check stay out of the workspace chunk (+0.5 KB there for the switch; the lazy chunk measured 11.9 KB gzip on 13 October 2026). The view reads the current text with readPastedTable (src/lib/grid/paste.ts) and shows the suite DataGrid (src/components/suite/data/data-grid/) with the statement line; opening it never changes the text. An edit in the grid writes the grid back with gridText (tab-separated, header words from content/apps/data-grid-ui.ts) into the same text the Read key parses, so the record, the parsers and the analyses are unchanged; the panel then shows "Edited in the table; the original text is replaced." with Undo ([data-table-edited]) until the next act (typing, a paste, a file, Read, Clear or Undo). Text that parsePlates or parseKinetic reads opens read-only with "A plate export is edited as text." A comma inside digits is read by the CSO's evidence rule (see the grid model in the shared shell); the toolbar's "Decimal comma (1,5 means 1.5)" checkbox ([data-decimal-comma]) is kept by the panel with the text for as long as the text and re-reads it when changed; the record never depends on it, since an edit writes resolved numbers into the text. The grid offers no exclusions here (exclusions={false}): the analyses keep their own (wells, points). The shape (columns, grouped, xy) is a view choice in the Table view, never stored nor in the #s= link. The statistics families (A-2) are to read the grid from this place. Browser spec: e2e/assay-analyser/data-grid.spec.ts (the edit-to-Read hand-off against the same text typed directly, Undo, the keys, a plate export read-only; desktop and phone).
The .pzfx project file
A file whose name ends in .pzfx, or whose first 16 KiB hold a <GraphPadPrismFile root, chosen or dropped on Import, is read by src/lib/grid/pzfx.ts (readPzfx) in panels/pzfx-tables.tsx, both loaded on demand (A-0's import layer; copy content/apps/pzfx-import.ts, which names the format only as "the .pzfx project file", roadmap A-3, held for the General Counsel). The reader refuses a file over 20 MB, any <!DOCTYPE or <!ENTITY, a file without the root element and XML that does not validate (XMLValidator), each with its sentence; it parses with fast-xml-parser (preserveOrder, values not coerced) and decodes numeric character references itself. Tables follow the file's TableSequence. OneWay tables become columns grids, TwoWay tables grouped grids (row titles as row labels, Replicates sub-columns), XY tables xy grids; every d cell is read with the CSO's number rule with no comma evidence (the format writes plain numbers), text kept as text, Excluded="1" kept as excluded, shorter columns padded with blanks. A table whose YFormat is a summary (mean with SD, SEM or N; ranges) is named and not read; survival, contingency, parts-of-whole, multiple-variables and nested tables are named and not read yet. Elements whose names end in Analysis are named from their titles and never run; when none are found the panel says so. The panel lists every table ([data-pzfx-table] read or named) and the analyses, and Use this table (with a select when several are readable) writes the chosen grid into the data input as text (pzfxTableText: the file's excluded values left blank and counted, [data-pzfx-excluded], since the text cannot carry an exclusion) and switches to the Table view; the file is not retained as an original. The file input accepts .pzfx; the visible types hint is the CPO's ruled sentence and does not mention it (held). Tests: src/lib/grid/pzfx.test.ts against pipeline/assay-analyser/pzfx/synthetic-tables.pzfx, written by hand from the XML structure; the browser case in e2e/assay-analyser/data-grid.spec.ts.
1,536-well plates
parsePlates(text, { formats: [96, 384, 1536] }) and parsePlateRows(..., { formats }) read 32 × 48 lettered grids and SoftMax blocks (rows A–Z, then AA–AF; rowLabel, wellId, parseWellIdIn); the default stays 96 and 384, so dose response and its stored import review (whose well pattern is A–P, 1–24) are unchanged. parseWellRanges reads AA–AF on a 1,536-well plate. Only the screening campaign asks for 1,536 wells today.
Text and numbers
src/lib/table/delimited.ts:
decodeTexthonours byte-order marks for UTF-8, UTF-16LE and UTF-16BE, and recognises unmarked UTF-16 when more than 60 percent of odd (or even) bytes in the first 4,096 are zero and fewer than 10 percent of the others are.detectDelimiterscores tab, comma and semicolon over the first 50 non-empty lines: lines agreeing on the median field count × 10, plus min(count, 30); tab wins ties.parseDelimitedfollows RFC 4180 quoting with any single-character delimiter.parseNumberreads by the CSO's comma rule (SCI-45; the addendum of 13 October 2026 todocs/board/memos/2026-10-13-cso-column-statistics.md, item 3), throughclassifyNumberandreadCellWithinsrc/lib/grid/model.ts: plain decimals and exponents;1,234.5and1,234,567as thousands; a comma not followed by exactly three digits (0,25) as a decimal comma, refused in a comma-delimited file; spaces only between groups of three digits; the Unicode minus. The ambiguous1,234is read only on the file's evidence,fileComma(rows, delimiter)over every cell before any is read: thousands when another cell is an unambiguous thousands form, a decimal comma when another is an unambiguous decimal comma or the file is semicolon-delimited; with no evidence, or both kinds, every such cell is null, so the reader refuses it and lists it in the import review, whereissueReasonText(research-record.ts) names it "Comma is ambiguous". Every text reader computes the evidence once per file: plates (parsePlateRows), kinetic reads, the curve table, the response template, the qPCR, growth, inhibition, calibration, thermal and kinetics tables, and the column mapping.decimalComma: truewithout evidence is an explicit decimal-comma setting (Molecule's series reader). Anything else (OVRFLW,#SAT, blank) is null. Tests:src/lib/table/comma.test.tsand the template case inresponse-template.test.ts.
The workspace's import limit is 4 MB of text (IMPORT_MAX_BYTES); the import review record is limited to 4 MB serialised (IMPORT_REVIEW_MAX_BYTES).
Plate-export profiles
parsePlates (src/lib/table/plate.ts) reads only plate blocks and counts the other lines as discardedLines; header metadata is never stored. Profiles:
| Profile | Recognised by |
|---|---|
softmax-pro |
A Plate: line, a header row containing "Temperature" within three lines followed by column numbers, then unlettered rows (until ~End) |
grid |
A row of column numbers 1 to 12 or 1 to 24 (starting in one of the first three cells), then rows led by consecutive letters A to H or A to P |
gen5 |
A lettered grid in a file that also has a Results line or a line starting Software Version |
template |
Calc's response template: a header row of exactly its twelve columns (any order or case, the unit only on concentration); tried before every other profile, and only on unmapped text |
SoftMax blocks are tried first. A single short cell on the line above a grid header becomes the block's label. Each cell becomes an ImportReview candidate: a non-numeric cell is rejected as response, an empty one as blank. The profiles were written from synthetic files (pipeline/assay-analyser/plates/), not vendor samples; PROFILE_WORDS gives the names shown.
The value the parser took is always stated (roadmap A1; bench-workflows Milestone A, 19 October 2026). readerMarker names a well's saturation marker ("OVRFLW", "OVER", "Sat" or "#SAT" (the case-insensitive pattern #?sat, plate.ts), "****", "> 4.000") as the review reason saturated and an error marker ("Err", "?????", "Range?", "#N/A") as reader-error; both stay rejected candidates in the review's issues, never numbers. DetectedPlateMap (panels/detected-plate-map.tsx) shows both the plate coordinates (A..H, 1..12) and the 1-based source row/column coordinates (R1.., C1.. or Excel column letters A..Z when from .xlsx), and renders non-colour-only badges (SAT, ERR, DATE, REJ, —) with accessible aria-label descriptions for every well. readKind reads a block's label only: a wavelength read ("Read 1:570", "570 nm"), a value the reader's software already corrected ("570-620", "Blank 570", "Corrected", "Delta", "Ratio") or unnamed. In the Import review (import-panel.tsx) a file with several blocks lists each read, never pooled silently: for two wavelength reads of the same format it offers bidirectional subtraction (d:i:j and d:j:i via differenceImportPlate in model.ts: measure minus reference, well by well; a well missing from either read is left out and both reads' issues stay in the review; the plate's label becomes "{measure} − {reference}"), and when all blocks share the same plate format and are not distinct wavelengths it offers an explicit "all" ("All N plates (multi-plate run)") choice. A line ([data-import-taken]) states the value taken: the read as the file gives it, the subtraction by this app, or all plates kept as separate plates. A read labelled as already corrected is used only after the person ticks "Use the reader's corrected values as they are"; until then "Use this import" stays disabled. When applied, importRecord (research-record.ts) records readBasis, correctedConfirmed, and any manual selectedRange. Measure and reference columns in a long or wide table are not yet read as a pair (tables take one response column, as before).
The detected preview renders one complete plate map at a time (24 October 2026, REL-500; CPO/CDO core-workflow memo). When several staged plates are available, “Plate map to preview” changes only the displayed map: the reader-block import selection, aggregate review counts and complete issue list stay intact. A single selected block shows that block; subtraction shows the computed difference with its existing taken-read explanation. The original plate index identifies the source coordinates and rejected wells, including a later block selected for import: selectImportPlate rewrites the observation keys but keeps original plate indices in source locations and issues. A replacement candidate resets the displayed map. e2e/improvements/plate-review.spec.ts checks every fixture block is reachable, later-block source rows and saturation badges, all-plate/channel selection independence, subtraction and replacement. The unchanged 115 × 384-well probe keeps its selection, import and Plate-map assertions and timing bounds; the baseline diagnostic found a correctly labelled 117-option select alongside 115 maps / 44,160 well cells and long tasks of roughly 10, 17 and 8 seconds at CPU 4×. This is functional/rendering evidence, not a new performance waiver.
Kinetic read blocks (roadmap-v0-depth A1; CSO memo of 11 October 2026, item 11; 12 October 2026; updated 19 October 2026). parseKinetic and parseKineticRows (src/lib/table/kinetic.ts, method kinetic-read-blocks 1.0.0) read a header naming the axis with its unit ("Time [min]", "Time (s)", "Temperature [°C]"; a bare "Time" over h:mm:ss values in seconds; a unit-less time over plain numbers is refused with the reason, as are reads that do not increase), an optional temperature column kept per read as metadata, then one column per well. Three layouts: a plain table, a SoftMax Pro-style block (a Plate: line naming Kinetic above the header) and a Gen5-style block (Results and a read label above it); header lines are discarded. One series per well; a blank is a missing read, never interpolated; a cell that is neither a number nor blank (OVRFLW, #SAT, Range?) is a marker, reported with its text and classified by readerMarker(mark.raw) ?? "response" in kineticEndpoint (saturated or reader-error) and never dropped. The import panel loads the reader on demand (kinetic-import.ts) when a header names a time or temperature column beside A1 (in pasted text or an .xlsx sheet via readKineticRowsImport), and embeds KineticEndpoint (panels/kinetic-endpoint.tsx, loaded on demand) directly in the import review so the person can inspect and choose either the final read or any specific read index/time point before applying the import, as well as later in Fit settings. The whole block is kept as the v6 field kinetic (kinetic-record.ts, strict reader: the block and its endpoint, last or at an existing axis value). Fit settings also carries "Endpoint read" (panels/kinetic-endpoint.tsx): choosing one rebuilds source.plates[0] and the import review from the kept block, keeps the source identity and the exclusions, and clears the fit; the line it announces names the excluded wells kept, when there are any ("3 excluded wells kept."). The method block's "Endpoint read" item and the paragraph's first sentence name the read; the limits line says one read is fitted, never averaged or interpolated. The thermal-shift mode reads a melt block pasted into its own table box (thermal-kinetic.ts, loaded on demand from the panel's Review when a temperature column sits beside well columns; copy assay-analyser.thermal-kinetic.ts): each well becomes a condition named by its well, one row per read as the reader wrote it; a missing read is left out and counted, a reader marker is a row issue naming the well and its text (which, as every issue there, must be corrected before Apply), a block over time is refused, and the usual limits hold (5,000 rows, 24 conditions). The growth mode reads a time-course block the same way, with the choice the code cannot make given to the person. The reader is growth-kinetic.ts, loaded with panels/growth-block.tsx from the panel's Review when a time or temperature column sits beside well columns; its copy is assay-analyser.growth-kinetic.ts.
- The person types one culture's replicate sample wells and the blank wells, as lists or ranges in one row or one column (
A1-A3,A1-C1). A well in both lists, an unknown well or a diagonal range is named as the issue. Unnamed wells are left out and counted. - Each read of a named well becomes a growth row with its role. The growth mode's own rules then apply unchanged: every sample read is a point, and the blank reads are averaged into one blank. Nothing is averaged or time-matched in the reader.
- A missing read is left out and counted. A marker is a row issue. A melt is refused. The 2,000-reading limit holds.
- A block in seconds is shown in minutes (seconds/60), and the review line says so. Applying sets the time unit from the block and clears the typed window.
The progress-curve source does not read blocks yet.
Spreadsheet workbooks (.xlsx) and manual cell-range selection (CTO ruling 4 of 12 October 2026; CSO memo of 11 October 2026, item 11; 19 October 2026). readXlsx (src/lib/table/xlsx.ts) reads an Office Open XML workbook with no dependency: the zip's central directory and local headers, stored and deflated entries inflated by the platform's DecompressionStream("deflate-raw"), and the subset of XML a worksheet needs tokenised without DOMParser. Refused with the reason: an OLE file or an entry flagged encrypted (encrypted), ZIP64 or an unknown compression method (container), Strict Open XML (strict), anything without xl/workbook.xml and its relationships (not-workbook), a part over 32 MB inflated (too-large), a sheet over 500,000 cells (too-many-cells, detail the count) or whose used extent from A1 passes XLSX_MAX_ROWS = 5,000 rows or XLSX_MAX_COLS = 256 columns (too-many-cells, detail an XlsxExtent: the sheet, its last used row and column, and the two caps; the words are assayXlsxText.refused.tooManyRowsOrColumns), and a browser without DecompressionStream (unsupported-browser). The extent check runs after a sheet's cells are read and before its grid is built, so no grid is allocated for a refused sheet and nothing past the caps is ever dropped unsaid (CTO memo of 20 October 2026, item 5; until 20 October the reader clamped such a sheet to 5,000 × 256 and returned it as read, REL-386). A long-format kinetic export over 5,000 rows, which read in full before 19 October, is now refused; the bounds themselves await review. Cells: a number's stored text is passed verbatim to the same number parser as pasted text, never through a float round trip; shared, inline and formula-string text as text; a boolean as TRUE or FALSE; an error cell keeps its text (#N/A) and is listed; a formula takes its cached value and is counted; a cell typed d or styled with a built-in or custom date or time format is refused as a value, left empty and listed. Each sheet becomes rows of cell text from A1, which parsePlateRows, parseCurveRows and (when readKineticRowsImport is passed to readXlsxSheet) parseKineticRows read by the same detection as pasted text. When automatic detection fails on an .xlsx sheet or delimited text, or when the person clicks "Select a cell range manually" on the detected plate preview, RangeSelectorPanel (panels/range-selector-panel.tsx, loaded on demand with a "Loading the range selector…" status line in its place, [data-range-selector-loading], backed by src/lib/table/range.ts extractRangeFromGrid) presents a bounded preview (60 rows × 30 columns) with both interactive click/shift-click cell selection and non-drag Start cell / End cell inputs (B4, M11 or R4C2) plus an interpretation mode (plate-96, plate-384 or table), preserving 1-based sheet coordinates, whole-file decimal-comma evidence, and all cell-error/date/saturation markers. The default sheet is the first holding a number. The import panel reads a chosen .xlsx file through xlsx-import.ts (on demand, marker drybench-import:assay-xlsx, budget assay-xlsx-import-family; copy content/apps/assay-analyser.xlsx.ts, the CPO's §C words): .xls and .xlsm are refused by extension before the chunk loads (workspaceCopy.import.xlsxRefused); the workbook's refusals say what to do next; the first sheet with a number is read, and with more than one sheet a "Sheet" select in "What was recognised" names each sheet with its count of numeric cells and reads another through the same detection when chosen, announced once. The detection words say the sheet, its rows and columns, the formula cells read by cached value, and the date and error cells the review lists; review issues at a date or error cell carry the reasons date and cell-error (import-review.ts). The sheet's name is kept as the v6 field importRecord.sheet (refused on an older record) and written as "Sheet" in the export context.
The response template (roadmap-v0-depth A2, 12 October 2026). readResponseTemplate (src/lib/table/response-template.ts) reads a filled Calc template whose rows carry wells as one PlateBlock per plate value (labelled by that value unless the only plate is 1), 384 wells when any well lies beyond H or 12, else 96, and returns the layout with it: sample rows as samples with their compound and concentration in the header's unit (one of M, mM, µM, nM, pM), blank rows as blanks. vehicle and positive-control rows (the sheet's words, CPO §D.23) are declared controls: never fitted, never given a role, because which is the 100 and which the 0 percent reference depends on the readout (the C5 rulings of 10 October 2026); they are returned as declared and kept on the import record as templateControls. Every row is a candidate (candidateKind rows): a numeric response is accepted at its well (sourceMap 0:<well> → the response cell), a blank or non-numeric one is rejected and named, never zero. Role words are read with case and whitespace ignored, and a space or underscore in "positive control" reads as the hyphen; nothing else is forgiven, and "positive" alone is never a role (templateRoleOf; the CSO's ruling of 12 October 2026 on the role words). A role cell holding any other word, or empty, leaves its well unassigned with its reading kept and is listed on "What was recognised" ("Role “{word}” at {well} is not a template role; left unassigned."). Rows that cannot be placed are unresolved and block the import: a bad or repeated well (well), a control row with a concentration (role), a sample without a compound or a positive concentration (sample), a well planned differently on another plate (layout). A non-molar or unknown unit refuses the import with the table reader's unit problem. Sample rows with a blank compound cell (a plan sent without a compound, CPO §B.1) are counted (unnamed); readTemplateImport returns them unnamed, and the import panel asks for the compound on "What was recognised", prefilled from the plan's compound when an assay holds a plan, applying it to every blank row before the preview; with no plan the import is refused with "Name the compound for these rows before importing them."; a template with no wells is named the same way. A template with no wells whose rows are all samples (planned without a sheet) is read by the long-table reader, with its other template columns counted as recognised, not ignored. The import panel shows the profile, a line naming the declared controls, and does not ask for one block, since the plates share the template's one layout. On apply, templateLayout (model.ts) lays the template over the assay's layout of the same format, keeping a positive or negative role the person already gave a declared well. The Plate map then shows "Controls declared in the response template" with buttons that select the still-unassigned vehicle or positive-control wells and move focus to the Role select, as the kept sheet does. PROFILE_IDS gained template, allowed only on a v6 record, and the declared controls are a v6 field, so a reader from before 12 October refuses such an assay rather than reading it without them.
In the reviewed import UI, a file with several blocks (other than a template's plates) requires an explicit choice before it can be applied (selectImportPlate; panels/import-panel.tsx), because separate blocks may be different reads, wavelengths or channels unless the person explicitly chooses "all" for a matching-format multi-plate run.
Long and wide tables
parseCurveTable (src/lib/table/tables.ts), used when no plate block is found:
- Long when a concentration column (header words
concentration,conc,dose,c,x) and a response column (response,signal,value,y,od,absorbance,a570,a450,viability,inhibition,percent,activity,rlu,rfu,counts,luminescence,fluorescence) are found. Optional compound, well, plate, replicate and unit columns. The unit comes from the concentration header's parentheses or brackets, else from a unit column; a per-row unit that conflicts is recorded asunresolved. - Wide otherwise: the first column is the concentration with its unit in the header; other columns are compounds, and repeated headers or replicate suffixes (
A_1,A (2),A rep 3) group into one compound. Columns named like temperature, time, well, plate, read or notes are ignored and listed.
Units resolve through src/lib/units/quantity.ts; only molarity units are accepted (a mass concentration is refused, since it would need a molar mass). Points are stored in mol/L.
import-mapping.ts lets the person map long-table columns explicitly; it rewrites the table to canonical headers and passes source coordinates through, so review issues and the sourceMap keep original row and column numbers. A recognised unit column stays authoritative even under a mapping.
Import review and observation decisions
ImportReview (src/lib/table/import-review.ts) records the delimiter, decimal policy, candidate counts (candidates = accepted + rejected + unresolved), ignored rows and columns, each issue's coordinates and raw text (first 200 characters) and a sourceMap from observation key to source cell. An import with unresolved issues or no accepted readings cannot be applied. Applying an import gives it a fresh sourceId, empties excluded, starts an empty decision log and clears results.
Exclusion and restoration of wells or table rows go through applyObservationDecision (table-decisions.ts): a reason is required, raw values stay, the decision is appended to history (at most 500 events and 50,000 keys, 2 MB), results are cleared, and an example's mark becomes edited. Table rows also take roles sample, positive, negative, blank; non-sample rows leave the fit but do not normalise responses or feed Z′ (copy in content/apps/assay-analyser.table-rows.ts says so). The decision log is editable local state; the copy states that it is not an audit trail.
Original-file retention
For a dropped file, prepareOriginalImport records name, size, media type, modification time and SHA-256, and, if the person opts in, stores the bytes through retainWorkspaceOriginalFile; the receipt states originalRetained and, when not retained, a reason (size-limit, storage-unavailable, source-unavailable).
Plate map
src/lib/table/plate-map.ts holds the model; panels/plate-grid.tsx, panels/well-table.tsx and panels/plate-panel.tsx the UI. After an exclusion decision the changed wells are tinted once (trace, danger for an exclusion, the accent for an inclusion); the decision and its reason are unchanged.
Composition (the UI redesign of 13 October 2026, D-0082):
PlatePanelrenders named grid areas, two columns at 60 rem of the main container and one column, in the same order, below it. The plate panel ("Plate map" as the H2, the format as its eyebrow, the mapped-well count and the plate count as pills, the Grid or Well table switch in its header, the counts line, the grid or the table, the legend with one swatch per compound in its hue and the role glyphs in words, and the quiet "Go to Fit" key) stands beside the "Assign the selection" card (the selection as a pill, the Role, Compound and Concentration fields, Assign as the view's one primary key with Fill a series under it, the observation decisions with their reason and keys, and the keyboard hints; a kept plate sheet's or a template's declared-control buttons follow the card). A dilution plan's card sits under the plate when one is held. Beneath: "Import and recognition" (ImportFacts: the profile the plate was read by (PROFILE_WORDS), where it came from and when (importRecord), the review's accepted, rejected and unresolved counts (importReview) with the discarded header lines and the block count, and what became of the original bytes (originalRetentionText); every line as the Import view stated it, with "Change the import" opening that view) and "Quick quality checks" (the layout's counts as rows: control, sample, blank and excluded wells, and readings without a role on a published plate; facts, never verdicts; the pill says "Every well assigned" or how many are not). Last, "Layout templates" and the observation decision history. The grid scrolls inside its own frame where the wells' names need more width than the column gives (each well carries its compound's name, UI-21).Wells (UI-21): each well shows its id and role glyph at 11 px and, for a sample, its compound at 12 px, the well tinted and barred in the compound's hue (
compoundHue, a second cue: the name is in the well); a sample's concentration is in its hover title and its spoken label, not its text; a well without a compound shows its reading at 11 px.e2e/assay-analyser/type-and-live.spec.tschecks the smallest text in the wells and in the curve charts.Roles:
sample(compound and concentration in the layout's unit),positive,negative,blank,empty. In this apppositiveis the 100 percent control (vehicle) andnegativethe 0 percent control (kill); the PRISM example notes that PRISM's own files use the opposite names.Keyboard (grid,
role="grid"): arrows move; Shift+arrow extends from the anchor; Space toggles, Shift+Space selects the rectangle; Enter assigns;Eexcludes or includes; Escape clears; Ctrl/Cmd+A selects all; Home/End move within the row (with Ctrl/Cmd, to the corners). Click, Shift+click and Ctrl/Cmd+click select. The well table is the same data for small screens.Ranges: a mouse drag selects a rectangle (
pointerType === "mouse"only, so touch keeps the frame's scroll) and Ctrl/Cmd+drag adds one; Ctrl/Cmd+Shift+arrow adds a rectangle by keyboard; row and column headers select a row or column (Shift adds). Chart points, plate wells and table rows share one exclusion path (decidein the workspace,decisionKeyOfinmodel.ts), and every exclusion asks for a reason.Fill series:
fillSeriesgives ordered wellstop / fold^floor(i / replicates)along rows or columns.Dilution plan placement (H20):
proposePlacementpreviews a plan from a start well (every dose and replicate must fit; any non-identical occupied well blocks it; units must both be molar).applyPlacementrecomputes the proposal at apply time and refuses a stale or altered one.fittingDirectionflips the plan's own direction when only the other fits.commitPlatePlacementapplies only if the assay is still the previewed state.Plate preparation sheet (Wave 3, C5): when a serial-dilution recipe from Calc carries a
plateSheet, the plate panel proposes it withproposeSheetPlacement(src/lib/table/sheet-placement.ts). Dose wells come from the sameproposePlacementcall Calc uses, so the two apps cannot disagree. The sheet's control wells are read byparseWellRanges(well-ranges.ts: tokens such asA1:A3, H12orA1-A3; H12, each a well or a rectangle, duplicates collapsed, a bad or outside token named). Blank wells are written asblank. Vehicle and positive-control wells are never written and never given a role, because which of them is Assay's 100 or 0 percent reference depends on the readout (CSO and CPO rulings of 10 October 2026); they are listed for the person to assign. Any occupied well a dose or any sheet control would land on blocks the whole placement; a control on a dose well iscontrol-overlaps-dose; an unreadable range iscontrol-range; a 96/384 format mismatch isplan.applySheetPlacementrecomputes the proposal and refuses a stale one, then writes doses and blanks in one commit. Apply also stores the recipe asplannedSheet(capturePlateSheetinpreparation-context.ts, with a SHA-256contentHash), separate from a master-mixpreparation; it is never edited, and later edits in Calc do not reach it.panels/planned-sheet-panel.tsxshows it as "Planned preparation", with the pinnedplate-sheetanddilutionmethod versions (and a line when the currentPLATE_SHEET_METHODversion differs), and buttons that select the unassigned vehicle or positive wells and move focus to the Role select, which is not prefilled. When an edited sheet arrives (its recipe address differs from the plan the assay keeps), the snapshot states that it was not taken (assayPlateSheetText.snapshot.keptArrival, thekeptrefusal made visible), beside the arrival strip'srecipeKeptline.Published wells:
placeOnWellsplaces a plan on an example's series wells without moving readings; each planned concentration must be withinPLAN_DOSE_TOLERANCE = 0.001(0.1 percent, relative) of the published dose, the counts must match, and the wells must already hold the same compound.A plate's cell line (E10, 17 October 2026; the CPO's ruling 7): under the plate selector, a "Cell line" row with the @Object picker's
cell-linetarget (ObjectPickerPill; the dialog's body issrc/components/suite/objects/cell-line-picker.tsx, which resolves a name or aCVCL_accession against the Cellosaurus snapshot and lists every match in the sharedCandidateList, choosing none; "Keep as typed text" saves amine:cell-linerecord). One per plate: the choice is a workspace input binding with the rolecell-line:<plate index>(src/apps/assay-analyser/cell-line-binding.ts,cell-line-roles.ts), the record's passport kept by value with its content hash, soAssayDatagains no top-level key and the record version is unchanged;captureBoundInputscarries it into recorded results.assayLinkswrites ameasured-inlink from the assay to each distinct line; the workspace also links on attach and on autosave and unlinks a line no plate names. A label only: no number reads it, and it is not shown in a preview.src/apps/assay-analyser/cell-line-binding.test.ts;e2e/cell-lines/cell-line.spec.ts.Reading curves:
readLayoutconverts concentrations to mol/L with the layout unit, makes one curve per compound per plate, and collects non-excluded control wells. Blank wells are read but no dose-response calculation uses them.
Dose-response science
From data to curves
deriveCurves (model.ts):
- For each plate, positive and negative control values (excluded wells removed) give Z′ when either list is non-empty, and a normaliser for the chosen mode.
- Sample wells are normalised with their own plate's controls and pooled across plates by compound label; each well keeps its plate label (used as the permutation block).
- If any plate cannot be normalised (missing controls or zero separation), every curve falls back to raw responses, so one assay has one scale.
- A table source is not normalised. With
norm=rawits scale israw; with any other mode it is treated as already percent, as pasted (the Fit view offers only "already percent" or "raw" for tables). - Fixed parameters come from
fixedFor, over the union of the all-curve settings (fix) and the curve's own entry incurveFixes(curveFixKeys; set from the curve inspector's "Fix for this curve", which refits at once): Top = 100 and Bottom = 0 only on the percent scale; Hill = −1 or +1 by the observed direction, judged by comparing the mean response of the lowest and highest quarter of kept wells (direction). The direction never determines the endpoint name.
Every kept well is a separate observation; replicates are not averaged before fitting. Wells with a concentration of zero or below are refused by rule (i) rather than dropped.
Normalisation
src/lib/fit/normalise.ts, with per-plate control means μpos and μneg:
percent = 100 · (y − μneg) / (μpos − μneg)
percent-control = 100 · y / μpos
percent-inhibition = 100 − 100 · (y − μneg) / (μpos − μneg)
percent-vehicle-bg = 100 · (y − μblank) / (μpos − μblank) (A-1 second pass)
raw = y
Background-subtracted percent of the positive control (percent-vehicle-bg; CSO ruling of 13 October 2026, docs/board/memos/2026-10-13-cso-viability-normalisations.md): μblank is the mean of the plate's own kept blank (medium-only) wells, read by readLayout and until now unused; never borrowed from another plate. Withheld per plate with its reason (needs-blanks under 2 blanks, needs-controls under 2 positive-control wells, vehicle-not-above-blank when μpos − μblank is not above rounding, as for a readout that rises with death), the plate then following the existing raw path. Its own method record (viabilityBackground) so the normalisation method's version, which stored results pin, is unchanged. The mode ids are append-only; the labels now say what each subtracts ("Percent of control span (positive 100, negative 0)", "Percent of positive control, no background subtracted", "Percent inhibition (100 − percent of control span)", "Percent of positive control, background-subtracted"). Under percent-control a plate with at least 2 blanks shows "Blank wells on this plate read x% of the positive control; this option does not subtract them" (blankShare). On every normalised plate the sample wells below 0 and above 100 percent are counted and shown, never clipped (outOfRange; PlateSummary.outOfRange). No default changed.
Reporter ratio (src/lib/fit/reporter.ts; CSO ruling of 13 October 2026, docs/board/memos/2026-10-13-cso-reporter-ratios.md): an import of exactly two blocks of one format can be kept as "Both, as two channels for a reporter ratio" in the import review (import-panel.tsx, channelsOption; never pooled). In Fit, the "Reporter ratio" section (panels/reporter-ratio.tsx, shown when the import holds two or more blocks) names the firefly and Renilla blocks (never inferred; unassigned by default) and background (off by default). The pairing is FitSettingsState.reporter, in the address as rep=<firefly>,<renilla>[,bg], so a share link carries it. deriveCurves then reads one ratio block in place of the blocks (reporterRatios): R = (F − F_bg)/(Rn − Rn_bg), backgrounds the means of each block's own kept blanks (at least 2 per channel, else refused); an exclusion in either block removes the well from both. A well's ratio is withheld at net Renilla ≤ 0, or, with at least 6 kept Renilla blanks that vary, below their mean + 3 SD (else that check is "not run"); firefly at or near background is flagged and kept; net Renilla outside 0.5× to 2× the plate median (at least 8 wells with a ratio) is flagged and kept. Normalisation, Z′, the plate diagnostics and the fit read the ratios unchanged; on ratios a control mean needs at least 3 positive-control wells with a ratio, else the plate is withheld (needs-controls). The per-condition view (reporterConditions) lists fold over control (mean ± SD, n) and mean net Renilla as percent of the controls'. Refused pairings name their reason (unassigned, same-block, format-mismatch when the blocks differ in format or in which wells hold a reading, background-needs-blanks). This pass pairs one firefly block with one Renilla block (one plate).
The Fit settings name each plate's control wells by id and role, in ranges (formatWellRanges in src/lib/table/well-ranges.ts, which parseWellRanges reads back: "Controls: A1–H1 (positive control, 8 wells) and A12–D12 (negative control, 4 wells)."), under the formula of the chosen mode; PlateSummary.controls carries the wells and StoredResults.zPrimes[].controls stores them with the fit, so the method block and paragraph name them too (routine-week criterion 3; results fitted before this name the counts only).
percent and percent-inhibition need at least one well of each control and means that separate beyond rounding (noSeparation, else no-separation); percent-control needs a positive control whose |μpos| is above rounding (notAbove(|μpos|, npos, M), else no-separation); percent-vehicle-bg refuses with vehicle-not-above-blank when μpos − μblank is not above rounding (notAbove(·, npos + nblank, M)). The bound is relative (the CSO's audit-fix memo of 20 October 2026, item 1; Science overview), so 10⁻¹⁴ M controls normalise. Values are never clipped.
The model
src/lib/fit/curve.ts and four-pl.ts, with x = log10(concentration in mol/L):
f(x) = Bottom + (Top − Bottom) / (1 + 10^((logC50 − x) · Hill))
Hill < 0 gives a falling curve. The drc LL.4 mapping, recorded in every fixture, is e = C50 = 10^logC50, b = −Hill, c = Bottom, d = Top; LL2.4 uses e = ln(C50). The analytic gradient (fourPLGradient), with u = 10^((L − x)H) and d = 1 + u:
∂f/∂Top = 1/d
∂f/∂Bottom = 1 − 1/d
∂f/∂logC50 = −(Top − Bottom) · u/d² · H · ln 10
∂f/∂Hill = −(Top − Bottom) · u/d² · (L − x) · ln 10 (u/d² taken as 0 when u overflows)
When both plateaus and Hill are free, a solution with Top < Bottom is rewritten as the same curve with Top and Bottom swapped and Hill negated (canonical), so Top is the higher plateau; with any of these fixed the person's names are kept. Internal names (logIC50, ic50, pic50) are compatibility aliases; display names follow intent.
The optimiser
levenbergMarquardt (src/lib/fit/lm.ts) minimises Σrᵢ² over a box, written from Levenberg 1944 and Marquardt 1963 without third-party code:
A = JᵀJ, g = Jᵀr
step: (A + λ · diag(max(Aᵢᵢ, floor))) δ = −g, floor = max(1e−300, 1e−12 · max Aᵢᵢ)
trial = clamp(p + δ, lower, upper); accept only if Σr² falls
| Setting | Value |
|---|---|
| Starting damping λ | 1e−3 |
| On acceptance | λ ← max(λ/3, 1e−15); if the fall ≤ 1e−15·objective or every relative step ≤ 1e−12, λ ← max(λ, 1e−12) |
| On rejection or singular system | λ ← 4λ; at most 40 attempts per iteration; stop if λ > 1e18 |
| Maximum iterations | 1,000 |
| Convergence | objective ≤ objectiveTolerance, or the scaled projected gradient ≤ gradientTolerance |
| Scaled projected gradient | maxᵢ |gᵢ| / sqrt(Aᵢᵢ · objective), with components at an active bound pointing outward set to 0 |
4PL settings (runFrom) |
gradientTolerance 1e−6; objectiveTolerance (64ε)² · Σ wᵢyᵢ² |
A small step or objective change is not treated as convergence; a run that cannot accept a step ends unconverged. A non-finite residual marks a point infeasible. Linear algebra is Gaussian elimination with partial pivoting on at most 4 × 4 systems (linalg.ts).
Starts, search box and limits
D-0060 (11 October 2026): the shell renders one limits notice, below the workspace since the CEO's feedback of 14 October 2026. AssayLimitsNotice reads the published doseResponse flag and shows the unchanged C50 summary only in dose-response mode. Other modes retain the standing statement and the complete limits drawer and count, without that summary. The curve inspector starts hidden.
fitFourPL (four-pl.ts) is deterministic: no random restarts.
- Data start (
startingValues): means per distinct x; Bottom = lowest mean, Top = highest mean (unless fixed); logC50 interpolated where consecutive means cross the midpoint (else the midpoint of the x range); Hill = −1 if the mean of the right half of the means is ≤ the left half, else +1. - Alternate starts (Hill free): Hill = ±{0.5, 1, 2, 4} in both signs, skipping the data start's own value; up to eight runs. The best run is the converged one with the lowest objective.
- Search box (
searchBox), with span = max(y) − min(y) (or max(|max y|, 1) when zero): Top and Bottom in [min y − 20·span, max y + 20·span]; logC50 in [min x − 5, max x + 5]; Hill in [−50, 50]. - Step competitor (
bestStep): for every split between adjacent distinct concentrations and both directions, each side at its best constant (mean; Σy²/Σy under the archived direct relative loss; weighted mean under frozen IRLS weights), with fixed plateaus used as given; skipped when Hill or logC50 is fixed. If the best step beats the best finite fit by more than a relative 1e−9 (RELATIVE_FALL), a run starts in the gap with Hill = ±20. - Limit classification: Hill on its ±50 face, or a step still lower, gives
no-finite-minimumwith limitstep. A parameter on another face triggers a rerun in a box widened by four times that parameter's width each side; if the objective keeps falling or the parameter reaches the wider face, the result isno-finite-minimumwith limitbeyond-range(logC50 on a face) orplateau-runaway. Such a fit keeps an internalbestPointthat is never shown or exported. The UI and exports call it "numerically unresolved"; it is not a proof that no finite minimum exists. - Refusals before or after fitting: no data; non-finite x, y or fixed value (
invalid-data); numerically flat responses, max(y) − min(y) ≤ 64ε·max|y| (numerically-flat); everything fixed; 1/Y² with any y ≤ 0 or any fitted value ≤ 0 (weighting-nonpositive); best run unconverged (not-converged); |Top − Bottom| ≤ 64ε·max|y| or Hill = 0 (unidentifiable); and a rank guard: the model Jacobian's columns scaled to unit norm, the scaled Gram matrix inverted, refused if any diagonal is non-finite, negative or above 1e10 (unidentifiable).
These numerical tolerances are implementation policies recorded in docs/science/2026-10-scientific-audit-implementation.md, which says changing them requires adversarial fixtures and a versioned justification.
Relative (1/Y²) weighting by IRLS
Version 2.0.0 implements relative weighting as iteratively reweighted least squares (D-0028 §3; audit F2):
fit unweighted -> θ0
repeat at most 100 times:
require every f(θk, xᵢ) finite and > 0
wᵢ = 1 / f(θk, xᵢ)² (fixed during the solve)
θk+1 = argmin Σ wᵢ (yᵢ − f(θ, xᵢ))² (full multistart, residual sqrt(wᵢ)(yᵢ − f))
stop when max relative change < 1e−8
(Top, Bottom scaled by max(max|y|, |previous|); logC50, Hill by max(1, |previous|))
otherwise refuse as not-converged
All observed responses must be positive. The variance model is SD proportional to the positive fitted response. No interval, flat-line diagnostic, model comparison or R² is produced for a weighted fit. The option historicalRelativeLoss reproduces the version 1 objective Σ((y − f)/f)² with its full Jacobian only to check archived fixtures; the UI never selects it.
Confidence intervals
src/lib/fit/ci.ts. Both intervals are on log10 C50 at 95 percent, only for unweighted fits that reached a minimum with logC50 free, residual degrees of freedom df = n − p > 0, and objective > (64ε)²·Σy² (no estimated-variance interval for numerically zero residuals).
Wald:
s² = SS_min / (n − p)
SE = sqrt( [(JᵀJ)⁻¹]_logC50 · s² ), J = model Jacobian at the solution
logC50 ± t(0.975; n − p) · SE
Profile extra-sum-of-squares (default):
threshold = SS_min · (1 + F(0.95; 1, n − p) / (n − p)), F(0.95; 1, ν) = t(0.975; ν)²
bound = the logC50 where SS_profile(logC50) crosses the threshold,
the other free parameters refitted at each trial value
Bracketing walks outward from the estimate with steps starting at 0.05 log10, growing by 1.5× up to 0.5, to at most 10 log10 units (PROFILE_STEP_LOG10, PROFILE_LIMIT_LOG10); the crossing is found by bisection to 1e−7 log10 (at most 60 halvings). Each trial refit warm-starts one LM run from the previous point; if that does not converge it falls back to the full multistart fit with logC50 fixed. Each bound has a status: crossing, search-limit (no crossing within 10 units) or refit-unresolved (a refit failed). Unresolved bounds are stored as null with their status and displayed as "not resolved within the search", never as a number or infinity (D-0028 §5). The lowerUnbounded/upperUnbounded flags are legacy names for a missing bound.
Both intervals assume independent Gaussian errors of constant variance and condition on the normalised responses; the uncertainty of the estimated control means is not propagated (stated in content/tools/assay-analyser.ts limits). The profile interval is always computed for an unweighted minimum, whatever interval the person displays, because rule (vi) reads it.
Distribution functions
src/lib/fit/stats.ts, written without a library: ln Γ by Lanczos (g = 7, nine coefficients, reflection below 0.5); the regularised incomplete beta by continued fraction with the modified Lentz method (500 iterations, 1e−16); F upper tail and Student t CDF from it; tQuantile by bisection on the CDF (200 iterations, relative 1e−14); sampleSd with n − 1.
Response evidence
src/lib/fit/response-evidence.ts replaced the flat-line F-test as the reporting gate (D-0028 §2):
within each block (plate): average ranks of x and of y, centred by (m − 1)/2
T = | Σᵢ rxᵢ · ryᵢ | (absolute Spearman rank covariance)
null: y ranks permuted within each block
n ≤ 8 observations: exact enumeration of all within-block permutations, p = #(T* ≥ T − 1e−12) / #permutations
n > 8: 1,999 within-block Fisher–Yates shuffles, xorshift32 seeded 0x4d595df4, p = (extreme + 1)/(1,999 + 1)
Blocks are the wells' plate labels (analyse.ts), or a single block all for a table without a plate column. The function returns null (no evidence; rule (ii) withholds) when there are fewer than two points, or when no block contains more than one concentration. Constant responses give p = 1. Four unreplicated doses can never pass: the smallest exact two-sided p is 2/24 (reportingCopy.fourUnreplicated). The test assumes independent exchangeable responses within each block; technical wells are not biological replicates.
Reporting rules
applyRules (src/lib/fit/rules.ts), counting distinct concentrations with at least one kept well. The first rule that fires withholds the value with its reason; (vi) only flags.
| Rule | Fires when | Applies to |
|---|---|---|
| (i) | Fewer than MIN_CONCENTRATIONS = 4 distinct concentrations, or any kept concentration ≤ 0 (checked before fitting by refusal) |
Both |
| (ii) | No response evidence, or permutation p ≥ 0.05 | Relative and absolute |
| (iii) | Fewer than two concentrations strictly below, or strictly above, the fitted logC50 | Relative |
| (iv) | A free plateau with fewer than two concentrations at or beyond its bend point | Relative |
| (v) | Percent scale only: fewer than two concentrations where f(x) < 50, or fewer than two where f(x) > 50 | Absolute |
| (vi) | Profile interval wider than 2 log10 units, or a bound unresolved: flag "poorly determined" | Relative (flag) |
Bend points (Sebaugh and McCray 2003, as cited; the constant was checked by arithmetic as the root of (k − 1)ln k = k + 1, and the paper itself is recorded as not yet read):
k = 4.6805 (BEND_CONSTANT), half-width = log10(k) / |Hill|
falling curve: Top bend = logC50 − half-width (low side), Bottom bend = logC50 + half-width
rising curve: sides swapped
each bend lies 1/(1 + k) ≈ 17.6 percent of the span from its plateau
Absolute C50 (percent scale only), where the fitted curve crosses 50:
logC50_abs = logC50 − log10( (Top − Bottom)/(50 − Bottom) − 1 ) / Hill (exists only if the argument of log10 is positive and finite)
The absolute value is not subject to rules (iii) and (iv) and carries no interval. On a no-finite-minimum fit, rules (ii) to (v) are evaluated at the best point; if none fires the value is still withheld (noFiniteMinimum), with "C50 between {c1} and {c2}" for a step and "midpoint unresolved within the numerical search" otherwise. A curve whose response check fails keeps its descriptive estimates and is drawn, with reportingNotice (words.ts) explaining why no midpoint is reported.
The flat-line F-test (flatLineTest) is still computed for unweighted fits and shown as an approximate diagnostic that does not gate reporting:
F = ((SS_flat − SS_full)/(p − 1)) / (SS_full/(n − p)), SS_flat about the mean
Z′
src/lib/fit/zprime.ts (Zhang, Chung and Oldenburg 1999):
Z′ = 1 − 3(σpos + σneg) / |μpos − μneg|, σ = sample SD (n − 1)
Refused when either control has fewer than two wells (too-few-wells) or the means do not separate beyond rounding, |μpos − μneg| ≤ tau(npos + nneg, M) (no-separation; noSeparation in src/lib/stats/degenerate.ts, the CSO's audit-fix memo of 20 October 2026, item 1: three readings of 0.1 against two, whose double means differ by 1.4 × 10⁻¹⁷, refuse, where they gave Z′ = −2.67 before). belowConvention marks Z′ < 0.5 as an observation, never a gate. Z′ is computed per plate from the non-excluded control wells and stored in StoredResults.zPrimes.
The screening campaign (A-1)
The eighth analysis, analysisMode: "screening", chosen as "Screening campaign" in the chooser's Plates and doses group (content/apps/assay-analyser.chooser.ts); the CSO's rules (roadmap A7, ruled in docs/board/memos/2026-10-13-cso-screening.md). The record holds a v6 sub-record screening (src/lib/screening/screening-record.ts, readScreeningData: the applied plate-reader text up to 4 MB, the control wells as ranges, the method, the typed threshold or null, excluded wells as "<plate>:<well>"); a v5 record with the mode is refused; nothing computed is stored. A repeat keeps the settings, never the plates. panels/screening-panel.tsx (loaded on demand; words content/apps/assay-analyser.screening.ts) reads the text with readPlateSet (src/lib/screening/plate-set.ts: parsePlates with 96, 384 and 1,536 wells; the control ranges by parseWellRanges, the same on every plate; every other well a sample well, a blank, text or reader marker a sample well with no value) and screenCampaign (src/lib/screening/screening.ts): per plate, percent inhibition from that plate's own control means (the percent-inhibition normaliser; fewer than two wells in a control, or means that do not separate beyond rounding, withholds the plate's percent inhibition, Z′ and calls with the reason), Z′ (zprime.ts, shown with the 0.5 observation, never a gate), the robust z on the sample wells' percent inhibition, z = (x − median)/(k × MAD) with k = 1/Φ⁻¹(0.75) = 1.482602218505602 and the raw MAD, withheld below 16 sample wells, and when the finite sample percents do not spread beyond rounding (noSpread) or MAD is zero (no-spread); a call when the chosen value is at least the typed threshold at full precision, worded "at or above threshold on this plate" with the method and threshold beside it, never "hit" or "active"; no threshold, no call (no default). Nothing is pooled across plates; no spatial correction. The panel shows a per-plate table (controls, Z′, n, no value, median, raw MAD, the robust-z state, the count at or above threshold) and the wells at or above threshold across plates, ranked by the method's value with ties by plate, row and column (withheld z and withheld plates last), with a wells CSV (screening-export.ts; every sample well with a value at full precision, the call, the method, the plate's Z′, the method and limits lines as # rows). The limits line is the CSO's verbatim. The export menu carries the project file. Recording to a Study: canRecordScreeningWorkspaceResult (v6, applied text and a typed threshold) and screening-capture.ts, loaded on the Record press, which recomputes the campaign from the stored text and settings and records assay-analyser.screening-result v1: one presentation row for the wells at or above threshold (the count, with the call, method and threshold) and one per plate (at most 48; Z′, n and the count, or the withheld plate's sentence; the median, raw MAD and robust-z state as detail), the wells at or above threshold (plate, well, percent inhibition, robust z) as output, the limits and the call rule as qualifications; refused when every plate is withheld. The projection reads it as the other presentation writers' (result-projection-core.ts; the input projects the mode only, assay-projection.ts); a repeat from a recorded screening result is allowed (repeat-assay.ts). Methods screeningPercent and screeningRobustZ (citations from memory, not re-read). Tests: src/lib/screening/screening.test.ts, src/apps/assay-analyser/screening-persistence.test.ts, screening-oracle.test.ts (against oracles/fixtures/screening.json, recorded in the oracle image on 13 October 2026 and published as 0.11.0; all 9 cases pass), e2e/assay-analyser/screening.spec.ts.
Plate diagnostics (A-1 second pass)
src/lib/fit/plate-diagnostics.ts (plateDiagnostics(format, wells), version 1.0.1; CSO rulings of 13 and 25 October 2026, docs/board/memos/2026-10-13-cso-plate-diagnostics.md and 2026-10-25-cso-plate-edge-relative-spread.md). The engine re-exports the dependency-free version authority in plate-diagnostics-version.ts, also used by the method metadata. Computed per plate in deriveCurves (PlateSummary.diagnostics) from the kept positive and negative control wells and the sample wells (excluded ones dropped; blanks and empty wells never enter), and shown under "Plate diagnostics" in the Fit settings, one disclosure per plate (panels/plate-diagnostics.tsx). Never stored, never exported, never a fit gate: nothing feeds a fit, an exclusion or a correction. Successful pattern tests can feed the separate layout suggestion below.
Fit settings renders this existing diagnostics region whenever actual plate summaries are present, including sample-only plates without control wells (REL-702). The Z′ heading, its own list and any published Z′ note retain their separate plates.some(p => p.zPrime) applicability gate. No-controls does not produce an empty Z′ section, invented control values or a control span. The lazy diagnostics and suggestion retain their own existing result/refusal rules; sample effects use reading units when the span is unavailable, and no-span/no-tests still prevents unsupported layout advice. With no plates, such as a table source, the outer diagnostics region remains absent. Existing styles, dynamic imports and scientific calculations are unchanged.
Signal window SW = (|μpos − μneg| − 3(σpos + σneg)) / σpos (AGM form with n = 1; positive = full signal)
SSMD β̂ = (μpos − μneg) / √(σpos² + σneg²) (signed, method of moments)
Z Z = 1 − 3(σs + σneg) / |μs − μneg| (kept sample wells against the negative control)
σ = sample SD (n − 1)
Withheld, with the reason as the line's text: SW under two wells in a control (too-few-wells), means that do not separate beyond rounding (no-separation, Z′'s test) or positives with no spread beyond rounding (no-vehicle-spread, noSpread); SSMD under two wells or with neither control spreading beyond rounding (no-spread; coinciding means give 0); Z when the kept sample wells are at more than one concentration (mixed-concentrations, every dose-response plate), under 16 of them (too-few-samples), under two negative wells or with means that do not separate beyond rounding (noSeparation); Z is not shown on a plate with no sample well.
Plate pattern, within one group at a time (the positive wells, the negative wells, and the sample wells on a single-concentration plate only; never pooled across roles or concentrations). Edge is one ring, the first and last row and column of the format.
- Edge against interior: Welch's two-sample t, two-sided; the difference of means as percent of the control span |μpos − μneg| when Z′ is computed, else in reading units. The four-edge/four-interior floor retains precedence (
too-few-wells). Each side then uses its own relative rounding bound,2*n*Number.EPSILON*max(abs(x)), throughnoSpread, without an absolute magnitude floor. Both sides without spread beyond rounding withhold the comparison (no-spread); exactly one such side contributes zero variance, retaining the other side's sample variance and both real counts. The withheld edge line says "Withheld: both the edge readings and the interior readings have no spread beyond rounding." It carries no t, df or p and leaves all readings and positions intact. Controls in the outer columns are all edge wells, so on most dose-response plates this is withheld. - Rows and columns: the additive model reading ~ row + column, each factor F-tested adjusted for the other (type II, as R's
drop1), by least squares on the dummy design (modified Gram–Schmidt with exact-dependence detection, so a factor aliased with the other has no degrees of freedom). Needs two levels of each factor and 10 residual degrees of freedom (too-few-levels, also when a factor is aliased) and a residual sum of squares above zero (no-spread). The spread is the highest minus the lowest raw level mean. Active compounds add spread to a sample group.
p values are unadjusted and labelled so; there are no bands or thresholds on screen. Thresholds (16, 4, 10, one ring) are the CSO's judgement. The method is verified against the unchanged 17-plate reference published as 0.11.0 and the independent 26-case edge supplement 0.18.0 (below). The supplement uses exact decimal-input fractions and bounded Student-t sine quadrature in CPython 3.12.14's standard library, with 34 analytical and identity checks; it claims no new R or SciPy cross-check. Row/column calculations and their separate no-spread sentence are unchanged.
SCI-199's corrected withheld edge becomes ok: false, p: null, effectPercent: null in layout-suggestion.ts's testsOf; it contributes nothing to the performed-test count m and cannot trigger an edge rule. Valid row/column tests still count and trigger under the existing rules. The layout-suggestion method remains 1.0.0, and old SuggestedFrom receipts retain their original p, m and rule fields. Neither stored fit results nor scientific record schemas change.
Model comparison
src/lib/fit/compare.ts, shown per curve in the inspector (panels/curve-detail.tsx, comparisons) for unweighted fits that reached a minimum. The reduced models fix a parameter that is free: Hill at ±1 (the fit's own sign), and on the percent scale Bottom at 0 and Top at 100.
F = ((RSS_r − RSS_f)/(df_r − df_f)) / (RSS_f/df_f), p from F(df_r − df_f, df_f), α = 0.05
AICc = n·ln(RSS/n) + 2K + 2K(K + 1)/(n − K − 1), K = fitted parameters + 1 (null when n ≤ K + 1)
Akaike weights are reported by compareAicc. The F-test is an approximate nested-model diagnostic; the method record says so.
GR metrics (A-1 second pass)
grMetrics (src/lib/growth/gr.ts), method gr-metrics 1.0.0 (CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-gr-metrics.md; Hafner, Niepel, Chung and Sorger 2016, cited from memory). Its own mode, analysisMode: "gr", chosen in the analysis chooser ("GR metrics: GR50, GRmax and GR_AOC", group "Timecourses and melts"). The record AssayData.gr (GrData: the unit and the readings as pasted, no result) is read only on a v6 record (readGrData; a v5 record carrying it, or the mode without it, is refused); the metrics are computed in gr.worker.ts from the stored readings each time they are shown, so nothing can go stale and nothing is recorded to Bench this pass.
- Input: a table with a header naming well, role, concentration and reading (plate optional); roles treated, untreated (control, vehicle), time zero (t0) and blank; a treated row needs a positive concentration. Every row that does not read is listed with its reason; nothing is guessed.
- Order: the mean blank subtracted from every reading; refused with no number for a non-finite value, a reading not above zero after the blank, beyond rounding (
notAbove(y − blank, n_blank + 1, max(|y|, max|blank|)); named, never dropped), fewer than two time-zero or two untreated wells, k = log2(x_ctrl/x₀) below 0.5, or fewer than four concentrations; warned below one doubling. GR per treated well = 2^(log2(x/x₀)/k) − 1; GRmax the observed mean at the highest concentration with its n; GR_AOC the trapezoid area over (1 − mean GR) on log10 c divided by the range. - The fit: the 4PL with Top fixed at 1 (
fitGr): GRinf in [−1, 1], log10 GEC50 within two log units of the tested range, hGR in [0.1, 5], six deterministic starts. Rule (ii) first (the permutation rank-trend gate ofresponse-evidence.ts, blocked by plate), then a rising trend (rankTrend, the signed form of the same statistic), then the fit: an estimate on the hGR or GEC50 face, or GRinf on +1, is a search limit. Each of these withholds GEC50, hGR, GRinf and GR50. OtherwiseapplyRuleson the raw scale: (iii) then (iv) withhold GEC50, GRinf has its own bend check (two concentrations at or beyond GEC50·4.6805^(1/hGR)), (vi) flags. GR50 = GEC50·((1 − GRinf)/(0.5 − GRinf) − 1)^(1/hGR), withheld at GRinf ≥ 0.5 and without two tested concentrations of fitted GR on each side of 0.5. The profile interval on log10 GEC50 (ci.ts) only; no interval on GR50, GRinf or hGR. The flat-line F p is kept on the result for the package comparison and not shown. - Only GEC50 has a profile interval in this version; GR50, GRinf and hGR say "Interval not available: this version computes a profile interval for GEC50 only." (the closing walk's S11, 14 October 2026).
- The method stays "unit-tested", described as "checked against the published definition", until the GRmetrics comparison (SCI-120).
Curve shape: bell-shaped and biphasic (A-1 second pass)
fitTwoPhase (src/lib/fit/two-phase.ts), method two-phase-dose-response 1.0.0 (CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-bell-and-biphasic.md). Offered in a curve's detail ("Curve shape", panels/curve-shape-section.tsx, its own chunk) whenever the curve has at least four concentrations; the person chooses "bell-shaped" or "biphasic" and the fit runs in shape.worker.ts. It never replaces the 4PL, which stays the curve's reported IC50/EC50, and nothing is stored on the record: the result lives in the panel until the curve's points change (the section is keyed by them), and its CSV (shape-export.ts) is its own labelled block.
L(z) = 1/(1 + 10^−z), x = log10 M, m₂ = m₁ + Δ, Δ ≥ 0, h₁, h₂ > 0
biphasic y = start + span·[f·L(h₁(x − m₁)) + (1 − f)·L(h₂(x − m₂))] span's sign = the free 4PL's direction
bell y = dip + (plateau₁ − dip)·(1 − L(h₁(x − m₁))) + (plateau₂ − dip)·L(h₂(x − m₂))
- Unweighted least squares, every parameter free, whatever the curve's own weighting or fixed parameters. Box: the 4PL's response box for the levels (the biphasic span in [0, 41·span] or its negative, f in [0, 1]), m₁ within three log units of the tested range, Δ in [0, width + 5], h in [0.01, 50].
- Starts (deterministic, no random restarts): every pair of gaps between adjacent tested concentrations leaving two concentrations below, between and above; the midpoints at the gaps, the levels from the three segments' means, |h| in {1, 2}, the bell from the middle segment's highest and lowest mean. Each start runs 200 iterations; the four lowest are polished for up to 2,000; the lowest RSS is kept, a converged run preferred at a tie (relative 1e-6). About 0.05 to 0.5 s per curve in the worker.
- Refused, no numbers: fewer than 8 distinct concentrations, or n − 7 < 3. A search that converges from no start is "not shown".
- The rule, in order; the first that fails withholds every number with its reason (
TWO_PHASE_RULE): (a) a parameter within 1e-4 of its box (as a fraction of the box width; the biphasic f excepted), f outside [0.10, 0.90], the bell's dip not beyond both plateaus or its smaller arm under 10 percent of the larger, an h above 5 or below 0.3, JᵀJ singular; (b) a midpoint outside the tested range, or Δ below 1.0; (c) fewer than two tested concentrations below m₁, between, or above m₂, or fewer than two at or beyond m₁ − log10(4.6805)/h₁ or m₂ + log10(4.6805)/h₂ (the bend points,BEND_CONSTANT); (d) not preferred over the free unweighted 4PL by both F(3, n − 7) p < 0.05 and ΔAICc ≥ 2 (K = 8 against 5; the 4PL's best point when it has no finite minimum, said so); (e) for the bell, fewer than two tested concentrations either side of the fitted extreme. - Reported: both midpoints (concentration and log10), signed Hill slopes (negative for a falling phase), the biphasic shares, the plateaus (the bell's dip labelled a model parameter, not an observed response), the bell's peak or trough and its concentration, RSS, df, the largest correlation between estimates (the matrix from (JᵀJ)⁻¹), a single-start flag; "Interval not available" for every parameter. The F and AICc are shown as information whenever computable, withheld at an exact fit.
- The null-simulation release gate (memo point 4(g);
pipeline/assay-analyser/two-phase-null-rate.mts, seeded): plain 4PL data, 10 concentrations at half-log spacing in duplicate, Hill 1, noise at 5 and 10 percent of the span, 1,000 replicates each. Measured 13 October 2026: bell 0 of 1,000 at both levels; biphasic 2 of 1,000 (0.2 percent) at 5 percent and 0 at 10 percent. The gate is 5 percent.
Curve comparison (Compare, A4)
src/lib/fit/global.ts (compareCurves), the method record CURVE_COMPARISON_METHOD (methods.ts, curve-comparison 1.0.0), the CSO memo of 11 October 2026, item 2, and the CTO's architecture rulings of 12 October 2026, rulings 10 and 16. The Compare panel (panels/compare-panel.tsx, its own chunk drybench-view:assay-analyser/compare, loaded by views/fit-results.tsx under the results table) takes two to eight curves whose relative C50 is shown from an unweighted fit (ineligibility in comparison.ts), a reference curve and any named shared constraints, and runs compareCurves in compare.worker.ts (drybench-worker:assay-compare, one job per worker, compare-client.ts). The input is read from the stored results alone (each curve's kept points and fixed; compareInputFrom), so verifying a comparison never refits.
Three global models over the chosen curves, residuals concatenated, unweighted, one parameter vector (GlobalProblem): separate (every free parameter per curve), constrained (the named constraints, any of Top, Bottom and Hill, one value for all curves; log C50 per curve) and shared (the constrained model with one log C50).
midpoint test: F = ((RSS_shared − RSS_constrained)/(df_shared − df_constrained)) / (RSS_constrained/df_constrained)
each constraint: the separate model against the separate model with that one parameter shared
potency ratio: δ = log10 C50_B − log10 C50_A, an explicit slot of the constrained problem (logC50_B = logC50_A + δ)
profile on δ: RSS(δ) ≤ RSS_min × (1 + F(0.95; 1, df)/df), the other slots refitted (profile.ts, unchanged)
Wald on δ: δ ± t(0.975, df) × SE, SE from (JᵀJ)⁻¹ × RSS_min/df at the solution
p < 0.05 reads "The data do not support one midpoint for both curves (F = …, p = …; df … and …)"; otherwise "Consistent with one midpoint". Never "significantly more potent". A comparison across plates always carries "Plate effects are confounded with the comparison"; a same-plate one says "Same plate."; a table without plates says plates are not recorded (platesOf). The C50 ratio B / A is 10^δ with the profile bounds transformed the same way.
- Starts and box. Deterministic: every curve's own estimates (
fitFourPL), a shared parameter at their mean, then the shared parameter at each curve's own value; the lowest objective among converged runs. The box is the union of the curves'searchBoxes (Top and Bottom ±20 spans, log C50 ±5 log10 beyond the tested range, Hill ±50) and δ within ±(the tested range + 10). LM's projected-gradient tolerance is 1e-6, the objective floor the responses' numeric resolution. - Refusals (
CompareRefusal): fewer than two or more than eight curves, a duplicate id, a reference outside the chosen curves, a shared parameter the user fixed, a fixed midpoint, a curve whose own fit has no finite minimum (own-fit-at-box), a constrained or shared solution within 1e-4 of its box (global-at-box), a global fit that did not converge. - The ratio withheld (
ratio.withheld):unbounded-intervalwhen either curve's own profile interval on log C50 (profileLogIC50,ci.ts) is not bounded within its search,poorly-determinedwhen it is wider than two log10 units (rule (vi)). The F test is still shown. - On the record (ruling 16):
comparisonRecordstores the outcome withresultsAt,resultsInputHashand the hash of its own input (comparisonInput: the engine version, the settings and every point).comparisonStateshows a stored comparison only whileresultsAtandresultsInputHashequal the current results', the engine version is the same and the input hash recomputes; otherwise "Refitted since this comparison was made; its numbers are withheld. Compare again." and no number. Nothing is recomputed silently. - Limits line (CSO): "An extra-sum-of-squares comparison of two fitted midpoints under the same assay and normalisation; approximate for a nonlinear model; constant-variance unweighted errors assumed."
Relative potency and parallelism (A-1 second pass)
src/lib/fit/relative-potency.ts (version 1.0.0; CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-parallelism-relative-potency.md), read inside the Compare panel (panels/relative-potency.tsx) when a stored comparison has exactly two curves and Top, Bottom and Hill all shared. It reads the stored outcome and the stored results' points; nothing is refitted and COMPARE_VERSION is unchanged.
- ρ (reference over test): log10 ρ = logC50(reference) − logC50(test) = −δ of the constrained model; its 95 percent profile bounds are δ's negated and swapped, Wald beside it labelled approximate. Withheld as the comparison's ratio is (
unbounded-interval,poorly-determined), orunresolvedwhen neither profile bound is reached. Always followed by "Relative potency assumes the test behaves as a dilution of the reference; read it with the parallelism intervals." - Joint F: separate against constrained (3 numerator df) by
extraSumOfSquaresF, information only, always with the CSO's note that it detects resolvable, not important, differences. - Parallelism intervals: 90 percent Wald from the separate fits, pooled s² = RSS_sep/(N − 8), t(0.95, N − 8), each curve's (JᵀJ)⁻¹ at its own estimates (
fourPLGradient): the Hill ratio test/reference on the log scale, and the Top and Bottom differences. A difference is withheld (plateau-extrapolated) when that plateau has fewer than two distinct concentrations at or beyond its bend point on either curve (plateauChecks);singularwhen a covariance cannot be computed. - Margins: typed by the person, none by default; a pair must be ordered, bracket 1 (ratio, and positive) or 0 (differences), else it is refused with its reason and the run waits. Valid margins are stored on the comparison as
settings.margins(an optional key the strict reader accepts; a record without it reads as before) and travel in the project file. Each interval is classed within (closed bounds) or beyond; ρ is never hidden or altered, and an interval beyond its margins adds "Outside your margins on …: the curves may not differ by a shift alone, and ρ may not describe this pair." - Refusals: fewer than four distinct concentrations on either curve (
too-few-concentrations, naming it), opposite directions in the curves' own fits (opposite-directions), and the comparison's own refusals. - Limits line (CSO): "Relative potency of one test against one reference from a parallel four-parameter logistic fit on the same response scale; meaningful only if the test behaves as a dilution of the reference. …" No verdict word ("parallel" as a verdict, "pass", "fail", "equivalent", "similar", "significant", "acceptable", "suitable") and no USP conformance (
content/apps/assay-analyser.relative-potency.test.ts).
Agonist and antagonist analyses (A-1 second pass)
schildAnalysis and operationalModel (src/lib/fit/antagonism.ts), methods gaddum-schild-global, gaddum-noncompetitive and operational-model-furchgott 1.0.0 (CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-agonist-antagonist.md). A panel below Compare in Fit (panels/antagonist-panel.tsx, its own chunk and worker): the person chooses curves whose C50 is shown (ineligibility from comparison.ts), gives each an antagonist concentration in one unit (0 for the control) or names the control for the operational model, and confirms the curves come from one agonist, antagonist and system. Nothing is stored on the record: the result stays in the panel while the results are the same object, and its CSV (antagonism-export.ts) is its own block.
- Input refusals: fewer than two or more than eight curves; [B] missing or below zero; not exactly one control; two curves at one [B]; mixed response scales; the control's own C50 withheld; a falling curve (these analyses take rising agonist curves); a global fit that does not converge or runs to its box.
- Inclusion: each curve's own free unweighted 4PL through
analyseCurve; a curve whose relative C50 is not shown is left out and named; a (vi) flag is carried. - Gaddum/Schild:
SchildProblem, θ = [Top, Bottom, Hill, log EC50₀, pA2, s] (and s = 1 for pKB),log EC50([B]) = log EC50₀ + log10(1 + 10^(s(log[B] + pA2))); box Hill [0.01, 50], log EC50₀ within five log units of the agonist range, pA2 in [−max log[B] − 6, −min log[B] + 6], s in [0.1, 5]; starts from the own fits (pA2 the median of log(DR − 1) − log[B]; s 1, 0.7, 1.4; pA2 ± 1). Profile intervals byprofile.ts(RSS_min·(1 + F(0.95; 1, df)/df)). Withheld as a whole: fewer than three antagonist levels, a span under one log unit, the maximum not assessable, or insurmountable (the shared-Top F p < 0.05 and the highest [B]'s Top more than 20 percent of the control's span below the control's; at 20 percent or less the "slightly reduced" flag). pKB is shown only when F(s = 1 within free s) p ≥ 0.05, the interval on s is bounded and at most 1.0 wide, and the shared-Hill F p ≥ 0.05; otherwise pA2 stays with the named reason. - The Schild plot: log(DR − 1) against log[B] with DR from the separate fits (DR ≤ 1 named and not plotted), the line s·(log[B] + pA2) from the global fit; the plot's own least-squares line is never computed in the app.
- Gaddum (non-competitive), only when insurmountable:
gaddumPairper antagonist curve against the control; withheld when its Top is below 25 percent of the control's span above the control's Bottom, with fewer than five of the ten equiactive levels (20 to 80 percent of the depressed span) inside both tested ranges, or a slope ≤ 1; KB = [B]/(slope − 1), no interval, no R². - Operational model:
OperationalProblem, θ = [Em, basal, n, log KA, log τ₁…], computed as E = basal + (Em − basal)·L(n(log τ + log A − log10(A + KA))); box n [0.1, 10], log KA within five log units of the agonist range, log τ [−4, 4]; starts with log KA at the control's log EC50 + 0, 1, 2 and τ from each curve's EC50 and Top; log(τ/KA) profiled in the reparameterisation ρ = log τ − log KA. Withheld unless the shared-Top F p < 0.05, the most depleted Top at least 20 percent of the control's span below the control's, no parameter at its box (1e-4) and log KA's profile bounded; flagged when that interval is wider than two log units. - On synthetic data the engine recovered pA2 8.000, s 1.000 and pKB 8.001 (truth 8, 1), the operational log KA −5.997 and log τ 1.481, 0.478, −0.222 (truth −6, 1.477, 0.477, −0.222), and a noiseless receptor-removal Gaddum KB to 1e-9. About 0.05 to 0.15 s per analysis in the worker.
Experiment-level summary (A4)
src/lib/fit/experiments.ts (summariseExperiments), the method record EXPERIMENT_SUMMARY_METHOD (experiment-summary-log-c50 1.0.0), the CSO memo of 11 October 2026, item 1. The unit of replication is an experiment the person declares and confirms as biological (the qPCR biologicalIdsConfirmed pattern); wells and plates are never n. Each experiment is fitted alone under the reporting rules, and a member enters only when its C50 is shown under the results' ic (memberOf, experiments.ts); the rest are listed with their reasons.
k ≥ 3: mean m of log10 C50, geometric mean C50 = 10^m; SD = √(Σ(x − m)²/(k − 1)); m ± t(0.975, k − 1)·SD/√k
k = 2: the two values and their difference (second declared minus first); no SD, no interval
k = 1: the single fit, labelled; k = 0: no summary ("none of n experiments gave a C50")
Relative and absolute C50s in one compound's members refuse that compound's summary ("relative and absolute C50s are never mixed"). The poorly-determined flag on any member is carried to the row.
- Across plates (
panels/across-plates.tsx,drybench-view:assay-analyser/across-plates, loaded byviews/report-view.tsx): needs two or more plates among the results' curves (platesOfResults). Each plate gets an experiment id (default its label); plates with the same id are pooled as one experiment (technical repeats;experimentsOf). No number shows until the person ticks "These are independent biological experiments…". "Summarise" fits every compound once per experiment (experimentInputs: the curve's wells on the experiment's plates, with itsfixedandweighting) in the fitting worker (FitClient), each throughanalyseCurveandsummarise, then groups by compound (byCompound). The declaration is stored asAssayData.experiments; a changed declaration or a refit clears the shown summary until it is summarised again. - Across saved assays (
panels/across-assays.tsx,drybench-view:assay-analyser/across-assays, loaded by the Study page's Evidence tab,src/components/suite/objects/study-page.tsx, when the Study holds two or more saved assays): each saved dose-response assay is one experiment; its stored results are used only whenverifyDoseResponseResultspasses against its readings (ruling 4), otherwise the assay is listed as left out; assays without results are listed too. The biological confirmation is asked each time and not stored. Runs that are the same experiment leave the summary as a whole group (the CSO's rule of 14 October 2026,docs/board/memos/2026-10-14-cso-across-assays-identical.md):partitionIdenticalindivergence.tsapplies the divergence section's own test (sameIdentity: a shared reader-file hash or identical readings, transitively) to the verified assays, drops each group from the summary and from the "Assays in this Study" line, and states it in one warning line (acrossAssays.sameReadings); once only one of a group remains in the Study, it is counted, so n counts distinct runs only.summariseExperimentsis unchanged (EXPERIMENTS_VERSION1.0.0). In the summary rows an SD or a pair's difference below 0.0005 log10 units reads "< 0.001", otherwiseformatLog(panels/experiment-summary-list.tsx). A re-read of the same plate on the reader is a new file and is not caught (REL-195). Tests:divergence.test.ts(three identical runs plus one distinct give one group and k = 1; a shared file groups differing readings, transitively);e2e/assay-analyser/study-evidence.spec.ts. - Limits line (CSO): "n counts experiments you declared independent; wells and plates are not n. The interval describes run-to-run scatter under these conditions; it is not an interval on a binding constant."
Units that know the molecule (A-4 item 10)
The CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-units-that-know-the-molecule.md), the design docs/design/assay-stage/units-that-know-the-molecule.md, the engine src/lib/units/molar-from-mass.ts (method molar-from-mass 1.0.0).
c (mol/L) = c (g/L) / M
compound: M = Σ count × CIAAW 2021 abridged weight, over the formula of the stated form
(a multi-part formula: the whole input, or its heaviest part as the parent; no default)
protein: M = Σ residue masses + one water, per polypeptide chain (ProtParam by default; CIAAW 2021 residue formulas)
kcat = Vmax / ([E]total × n × f) n: active sites per chain (≥ 1, stated), f: active fraction in (0, 1]
- Dose response, a table in a mass unit (pg/mL, ng/mL, µg/mL, mg/mL, g/L):
molarUnitstill refuses it ("not molar"); under the refusal the import offers the conversion. Each compound takes a saved Molecule record with a formula (its RDKit block, written only when a public source holds the structure; that source named) or a typed formula; a bare typed molar mass is refused, a multi-part formula needs the form stated. The table reader'sMassConversionoption (src/lib/table/tables.ts) converts once (molarMass: nullreads the typed values unconverted, which are kept); the source's unit becomes nM. The record (v6) carriesimportRecord.massConversion: the unit, the method, and per compound the typed values and the basis (formula, used formula, form, origin, record ref and label, source, mass and its exact decimal). It appears in the context rows (JSON, as the mapping) and the method paragraph adds "Concentrations were typed in … and converted to molar once at import …" with each mass rounded once from its exact decimal. Plate exports are not converted. - Initial rates, kcat: the workspace's "Enzyme from a protein" step (not on task pages, so the islands carry no object store) takes the total (molar, or a mass concentration through a Protein record's chain mass: the whole precursor sequence, or a residue range the person gives), the sites per chain (required, no prefill; UniProt's active-site features are never used) and the active fraction with its basis ("assumed fully active" means 1.00). Applying sets
settings.enzyme(nM) and keepssettings.enzymeFrom(src/lib/kinetics/enzyme-from.ts, strict reader; refused without an enzyme); typing the enzyme drops it. kcat prints the derivation and the CSO's limits line beside it. - Limits lines (CSO, verbatim): the formula and form; the protein's sequence mass; the kcat interval and the stated sites (
assayUnitsCopy.limits). The immunoassay line waits for the calibration path, which is not built this pass (ruling B, deferred). - Oracle:
pipeline/assay-analyser/oracles/units_molecule.py(standard library; CIAAW 2021 abridged weights and ExPASy ProtParam residue masses typed independently; the precursor checked against ExPASy's printed weight). 20 cases, recorded in the oracle image, fixtures 0.11.0, 13 October 2026;src/lib/units/molar-from-mass.test.tscompares the engine with it within the CSO's tolerances. - Tests:
molar-from-mass.test.ts,mass-conversion.test.ts,src/lib/kinetics/enzyme-from.test.ts;e2e/assay-analyser/units-molecule.spec.ts(an ng/mL table through a typed salt formula with the form demanded; kcat with sites required and the basis printed).
Methods paragraph in (A-4 item 13)
src/components/tools/assay-analyser/methods-parse.ts, the design docs/design/assay-stage/methods-paragraph-in.md. A paper's methods text (up to 8,000 characters) is read by an ordered list of regular-expression rules, no AI. Each match is a finding with the phrase it rests on: set (normalisation, Top/Bottom/Hill fixed or fitted, weighting, interval, relative or absolute C50, direction), unsupported (five-parameter, robust fitting or ROUT, outliers removed, Top other than 100, Bottom other than 0, Hill other than ±1, shared or global parameters, log-transformed responses), conflict (two phrases disagreeing on one setting, such as IC50 and EC50 both named: nothing set) or noted (software, the 4PL, least squares). proposal lists only real changes against the current settings; on a table source a control-based normalisation is not applied (percent or raw only), and a Top or Bottom fix needs a percent scale. Apply changes the settings (the address and the record, as the controls do); nothing refits on its own. No new number, so no methods entry or oracle. Tests: methods-parse.test.ts; e2e/assay-analyser/methods-in.spec.ts.
Potency and efficiency (A-4 item 11)
The CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-ligand-efficiency.md), the design docs/design/assay-stage/potency-and-efficiency.md, src/components/tools/assay-analyser/efficiency.ts (method ligand-efficiency 1.0.0).
pIC50 = −log10(relative IC50 / M)
LE = ln(10)·R·T·pIC50 / HA R = 1.98720 cal/(mol·K), T = 298.15 K (1.3642 kcal/mol, unrounded)
LLE = pIC50 − Crippen cLogP (RDKit)
- Descriptors: the shared RDKit worker's
analyse(Molecule's path,engine.ts): HA isNumHeavyAtomsand cLogPCrippenClogPof the described structure (the parent of a salt); a metal complex or inorganic salt Molecule keeps whole is withheld (describedOf: more than one fragment and nolargest). - Given only for an inhibition IC50 (intent "inhibition"), an assay stated as biochemical (
AssayData.assayFormat, v6, absent is "Not stated"; the control is disabled on a record the write path cannot raise to v6), the relative IC50 shown and not poorly determined, the percent scale, the lower plateau reached (free and no replicate mean below 30 percent, the pre-submission check's rule), a structure RDKit reads, only elements Crippen types (C, H, N, O, S, P, F, Cl, Br, I, B, Si, Se) and at least six heavy atoms. Otherwise the first reason in that order, and no number. LLE alone is withheld for a non-zero net formal charge. - Display: pIC50 to 2 decimals, LE to 2 ("kcal/mol per heavy atom"), LLE to 1, rounded at display only. The efficiency CSV carries
compound,pIC50,heavy_atoms,clogp_crippen,LE_kcal_per_mol_per_heavy_atom,LLE_crippen,structure_described,assay_format,efficiency_withheld_reason. The method line, limits line and methods entry are the CSO's verbatim. The results CSV and the report are unchanged this pass. - SAR series: the existing H22 hand-off (
series-export.ts, Molecule'sassayinbound rule) offered as one link in the section (SendToLink). Molecule computes no LE, so rows arrive without one (ruling 13). - Oracle:
pipeline/assay-analyser/oracles/ligand_efficiency.py: heavy atoms counted by hand for every structure (exact), the arithmetic in decimal, and Python RDKit 2026.03.6 for cLogP and charge as a drift check against RDKit.js (1e-3), not as independent truth (ruling 11). 13 cases including an HCl salt, a mesylate and calcium acetate given to the app as drawn (the app's parent choice checked), a mixture, a quaternary ammonium and a written-in protonated amine (LLE withheld), carboplatin (kept whole), five heavy atoms, about sixty heavy atoms and boron. Recorded in the oracle image (RDKit from its "calc" environment,run-oracle.sh), fixtures 0.11.0, 13 October 2026;efficiency.test.tscompares the engine with it through RDKit.js. Open: the Crippen hand summation for ethanol and toluene (Wildman and Crippen 1999, Table 1, to be read). - Tests:
efficiency.test.ts(each withholding rule, the factor, the stored format on v6 only);e2e/assay-analyser/potency.spec.ts.
Control runs over time (A-4 item 8)
The CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-assay-drift.md), the design docs/design/assay-stage/assay-drift.md, src/components/tools/assay-analyser/drift.ts (method assay-drift 1.0.0).
centre = mean of the charted runs; σ̂ = mean |xᵢ − xᵢ₋₁| / 1.128 (consecutive charted runs, gaps skipped)
trial limits = centre ± 3σ̂ (drawn from 8 charted runs; none when σ̂ = 0)
Pettitt: U_t = Σ_{i≤t} Σ_{j>t} sign(xᵢ − xⱼ), K = max |U_t| (first t), p ≈ 2·exp(−6K²/(n³ + n²)) capped at 1; from 10 runs; flagged at p < 0.05
- Runs (
panels/drift-panel.tsx, on the Study's Evidence tab when it holds two or more saved assays): saved dose-response assays whose results passverifyDoseResponseResults, ordered byimportRecord.importedAtthen ref. Left out and listed: templates, other modes, example datasets (datasetor an example import), no import record, no results, a cancelled fit, unverified results. - Series: the reference compound (exact stored label, offered when in two or more runs, not stored) as its shown log10 C50, a withheld one a gap with its reason; the first run fixes the kind, scale, normalisation and weighting, and differing runs are left out; differing fixed parameters are marked. Z′ is the mean of the run's valid plates (each plate drawn behind it; the 0.5 line labelled as a convention). The blank is the mean raw value of blank-role wells per plate, averaged over plates (tables: rows with role blank; a table already in percent is left out).
- Each chart is drawn at its container's measured width (240 to 640 units), read before the first paint and kept current by a
ResizeObserver, so its 11-unit text is 11 px on screen at 375 px (UI-22) from the first frame (REL-232); the dashed limit lines are labelled "upper trial limit" and "lower trial limit" at their right end (copy incontent/apps/assay-analyser.drift.ts). - Points shown from 3 runs; "outside the trial limits", never a verdict; one flag per series at most; three series tested at 0.05 each, uncorrected.
- Oracle:
pipeline/assay-analyser/oracles/assay_drift.py(standard library: fractions for means and moving ranges, Pettitt by enumeration), 11 cases (stable, a step at run 6 of 14, a single outlier, a trend, 7 and 9 runs, all withheld, constant values, gaps, mixed relative and absolute, Z′ with a failed plate). Recorded in the oracle image, fixtures 0.11.0, 13 October 2026;drift.test.tscompares the engine with it. The Rqccandtrendcross-checks the CSO named are not yet in the script. - Tests:
drift.test.ts(with the blank and label rules);e2e/assay-analyser/study-evidence.spec.ts(eight runs scaled apart: the blank's limits drawn and labelled, every chart text at least 11 px on screen; and three fitted runs of one export, each renamed and saved as work at once, filed into a Study under those names: the section with the Z′ and blank series, "trial limits are drawn from eight runs", the limits line; one live region, no sideways scroll, axe), desktop and phone.
A layout suggested from the diagnostics (A-4 item 6)
The CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-layout-suggestion.md), the design docs/design/assay-stage/layout-suggestion.md, src/components/tools/assay-analyser/layout-suggestion.ts (method layout-suggestion 1.0.0). Rule-based advice; no new number. The p values and effects shown are the plate diagnostics' own.
m = pattern tests that ran (not withheld) over every plate of the assay
a test triggers: p <= 0.05/m (binary64) and |effect| >= 10 % of the control span; a sample-group row or column test never triggers
edge trigger -> (a) outer ring empty, every occupied well moved in reading order onto the interior in reading order
row or column trigger -> (b) positives and negatives alternating down columns 2 and C-1 (2 and 23 on 384) in opposite phase, further pairs inward when needed;
each compound's series laid contiguously, replicate series k in the top half (k even) or bottom half (k odd)
both -> (a) then (b)
- Refused, nothing shown: a format other than 96 or 384, no test ran, the span withheld everywhere, no trigger. Refused, one line: the wells do not fit the interior (or (b) runs out of room). Note: every control well on the outer ring and nothing triggered: "Edge effects could not be checked", offering layout (a) (ruling 11).
- Panel (
panels/layout-suggestion-panel.tsx, under the diagnostics in Fit settings, its own chunk loaded bynext/dynamic): each finding with its p (unadjusted, named against the 0.05/m allowance), the layout in words, one action "Save as a template", the template line and the limits line. The plate on screen and its analysis are never changed. - Saved:
saveTemplate(name, { layout, suggestedFrom })in the workspace writes a v6 template carryingsuggestedFrom(suggested-from.ts: the source assay's label and ref, time,ruleaslayout-suggestion@<version>, rules, m and the triggers with their p and effect); the strict reader refuses it below v6 and refuses an unknown rule or a non-numeric p. - Oracle:
pipeline/assay-analyser/oracles/layout_suggestion.py(standard library; trigger arithmetic in fractions, p compared in binary64 at the Bonferroni boundary), 16 cases: a quiet plate, an edge, a column and an edge-plus-rows trigger on 96, both rules on 384, p at and just above 0.05/m, the effect just below and at the 10 percent floor, a sample-group row pattern, two plates (m = 18) with nothing triggered, a full interior, every control on the ring, and the refusals (no test ran, span withheld, a 1536 format). It states decisions and counts; the engine test checks the layout's properties (ruling 12). Recorded in the oracle image, Oracles run 38458144391, SHA-256 63b55056…, fixtures 0.12.0, 13 October 2026. - Tests:
layout-suggestion.test.ts(16 oracle cases, the given layout unchanged, the provenance round-trip on v6 only);e2e/assay-analyser/layout-suggestion.spec.ts(the MTT example's edge-unchecked note, and Save creating the template).
At the reader (A-4 item 15)
The CPO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cpo-phone-at-the-reader.md), the design docs/design/assay-stage/phone-at-the-reader.md, copy in content/apps/assay-analyser.reader-loop.ts.
- Paste from clipboard (
panels/import-panel.tsx): a button above the paste box wherevernavigator.clipboard.readTextexists (read withuseSyncExternalStore, never by pointer type); the text goes through the box's own paste path (stagePasted, thenstage). A refusal or an empty clipboard is said under the button. - The plate map used last is kept in this browser (
suite:assay-analyser:last-plate-map, never in a URL), listed first in the review as "… (used last)" and chosen for the person to see before "Use this import"; written when an import is applied with a map. - Run on arrival on a reference page (REL-228; the CPO's ruling of 17 October 2026 and the CSO's, CTO's and CMO's memos):
assay-workspace.tsxpassesautoRunto the kinetics, inhibition, thermal, growth and qPCR panels when the page is a reference (reference.enabled), its seed is on screen (url.referenceSeeded, false for a#s=fragment, saved state or any address change), the address is ready, and no saved assay, named task or preview is open, and only whencontent/apps/assay-analyser.reference-runs.tshas a line for the seed's worked example. Each panel'suseExampleCalculationruns its own button's calculation once, with the example's own model or window;consumeAutoRunsetsreferenceRunDone, so a remount after an edit never runs it again. The first result after it is held by identity (arrivalResult) and the line shows beside that result only; an edit clears it as usual, and a later run is the visitor's. Calibration'sautoFitis the older form of the same rule. Nothing is saved: the autosave returns early for an unsaved reference. - Fit on "Use this import":
onApplyreceives the chosen map; the workspace (templateFitinviews/assay-workspace.tsx) opens the Fit view and fits at once when the curves can be fitted and every plate's normalisation has its controls; otherwise it opens the Fit view with the "does not assign controls" line. The note above Fit settings names the map and offers "Change plate map". The worked example's own auto-fit (autoFit) is separate. Unless another control is focused meanwhile, focus goes to the Fit heading and stays there through the fit: the settings' size container (@container/assay-fit) wraps the settings alone, since Chromium dropped the boxes of the container's leading children that do not query it (the heading, or the lede and the plate-map note) when the first results narrowed the column (REL-162). - Deferred view focus (24 October 2026, REL-507): a view change invalidates older heading-focus requests, and a pending request yields when another control has gained focus. The ownership check also runs inside
focusHeading's deferred target getter. An instrumented baseline captured “Change plate map” gaining focus, the import's delayed Fit-heading callback taking it away, then Enter reaching the heading rather than the button. The ordinary reader-loop case stays unchanged; an additional deterministic case holds import paint callbacks until the repair button is focused, releases them, checks focus is retained and then uses the same Enter/grid assertion. No navigation timeout or fit eligibility changes. - Next plate (
FitResults'safterslot, plate sources only):nextPlaterunsprepareIndependentAssay(the plate on screen is saved as its own assay), resets to an empty assay and focuses the Import heading. - The share sheet is not used: a manifest
share_targetis held for a suite decision (open question Q-160; GET rejected by the CPO). - Tests:
e2e/assay-analyser/reader-loop.spec.ts(clipboard in, map chosen, fitted, Next plate, the map offered first, one live region, axe; a refused clipboard), desktop and phone.; a map that leaves a plate without its controls (the incomplete note, no fit, "Change plate map" to the map).
Duplicate and divergence detection (A-4 item 16)
src/components/tools/assay-analyser/divergence.ts, the design docs/design/assay-stage/divergence.md. Two saved assays overlap when they share a file hash (the dose-response import record's file.sha256, or any mode's source.sha256 or import receipt) or, for dose response, identical readings (contentHashLight of the parsed source, so a paste of the same plate or a copied record is caught). Templates never enter. For a pair with identical readings, differencesOf lists exclusions present in only one (with the reason in tableDecisions.reasons), control roles (tableDecisions.roles for a table, layout.wells for plates), the settings (norm, fix, weighting, ci, ic, intent), the per-curve constraints, and, only when both assays' stored results pass verifyDoseResponseResults, each shared compound's shown C50 (stated with the gap in log10, ignored below 0.0005) or that it is shown in one and withheld in the other. No new number: the C50s are the stored ones. Flag, never act.
- Study, Evidence tab (
AssayDivergence,drybench-view:assay-analyser/study-divergence, before "across saved assays" when the Study holds two or more saved assays): every overlapping pair, its headline, the file line and the differences, then the limits line. - Workspace (
panels/divergence-notice.tsx,drybench-view:assay-analyser/divergence-notice, mounted when the assay holds readings or a file receipt): one status line naming and linking the other saved assays (of the forty most recent the workspace lists) that share its file or readings. It compares against those records only, not against the whole store. - Limits line: "identical means the same file hash or the same parsed readings; a re-read of the same plate on the reader is a new file and is not detected."
- Tests:
divergence.test.ts(same file across modes, same readings unchanged, each difference kind and its words, the verification gate, templates and different readings);e2e/assay-analyser/divergence.spec.ts(the same plate pasted into a second assay names and links the first). The Study section:e2e/assay-analyser/study-evidence.spec.ts(three runs of one export, so three pairs, each stated, with the limits line), desktop and phone.
Negative result (A-4 item 12)
src/components/tools/assay-analyser/negative-result.ts, the method record NEGATIVE_RESULT_METHOD (negative-result 1.0.0, "verified" against negative-result.json, 0.11.0), the CSO's memo of 13 October 2026 with its addendum (docs/board/memos/2026-10-13-cso-negative-result.md). A withheld C50 is stated as a negative result, with the same provenance as a shown one (input SHA-256, fit engine, method pins, dataset mark), when all of these hold:
- the curve was withheld by rule (ii), or refused by the fitter as
unidentifiable,not-convergedornumerically-flatafter rule (i) passed (the addendum): for those three refusals only,analyseCurvestoresrefusedEvidence, the sameresponseEvidencecall on the same points and blocks rule (ii) reads (responseBlocksOf), andsummarisewrites it toStoredCurve.responseEvidence; the curve stays refused, its reason and wording unchanged (reportingNoticeignores a refused curve's evidence); - the stored permutation p is at least 0.05, the fit was not cancelled, and the results passed their integrity check (callers pass only verified results);
- feasibility: the same test on the same kept concentrations and blocks with y = x gives p < 0.05, so the design could have detected a trend. Four unreplicated doses (2/24) and five plates of two doses (2/32) fail it, and the curve panel says "This design cannot reach p < 0.05 on the response-ordering check, so no negative result is stated."
The fields are recomputed from the stored curve and the experiment declaration, never stored a second time; the sentence is rendered and never stored:
No monotonic concentration-response trend detected from {lo} to {hi} ({c} concentrations; mean response {ylo} {unit} at {lo},
{yhi} {unit} at {hi}; two-sided rank-permutation p = {p}, {exact | 1999 sampled permutations}, {blocks} plate/block(s); {experiments}).
experiments: "{n} independent experiments" only when every plate is in a declaration confirmed as biological;
"{k} experiments declared; not confirmed as biological"; "experiments not declared; {k} plates"; "one table; experiments not declared"
lo and hi are the lowest and highest kept positive concentrations; the means are of the kept wells there (the replicateSummaries rule). The limits line goes with every statement: it states no monotonic ordering at p < 0.05, not inactivity; a response already complete at the lowest concentration, a bell-shaped response or a small effect give the same result; no power is calculated. Nothing enters the C50 columns. It shows in the curve panel after the rules (NegativeResultBlock, labelled "Negative result" in text), in the results CSV's "Negative result" column (empty when not eligible; the CSV's permutation-p columns and the panel's response-ordering line still skip a refused curve's evidence, so a refusal reads as before), and as a negativeResult fact on a recorded result's curve (assay-projection.ts, reading the exact input's experiments). When any curve states one, negative-result is added to the export's provenance methods (provenanceFor); the fit's own stored method pins are unchanged. Results recorded before this change carry no evidence on refused curves and state nothing until refitted; the fit engine's version was not raised (REL-50). The oracle is pipeline/assay-analyser/oracles/negative_result.py (below); its fixture waits on the Oracles workflow.
Reproducibility package (A-4 item 4)
The design docs/design/assay-stage/reproducibility-package.md. The Report view's quiet key "Download reproducibility package", beside Print and offered only with verified results (the workspace passes pkg only then; not in the Export menu, which is full for dose response), loads repro/package.ts and saves one HTML file. The file holds:
- the assay as saved (
record: readings, layout, exclusions with reasons, settings, stored results), offered back as JSON by the page; - the inputs the fit consumed,
doseResponseInput(data, results.settings), whose hash the results carry asinputHash; - the stored results, the methods paragraph, the figure caption, the provenance block's methods (with versions, verification and references), limits and statements, and the settings;
- the fit engine:
engine.generated.ts,src/lib/fit/analyse.tsand its eight imported modules transpiled by TypeScript with types removed and nothing else changed, in a module table, with the engine's SHA-256, each source's SHA-256 and the fit version;repro/engine.test.tschecks that it reproduces the source engine exactly on the MTT plate.
Opened in any browser, the page's script (page-script.ts, serialised with toString, so it references only browser globals and the embedded data) recomputes the inputs' SHA-256 by the app's canonical JSON and says whether it matches the stored hash ("not computed" where the browser offers no SHA-256 to a local file), fits every curve again with the embedded engine, eight at a time, and lists stored against re-run: agreement is the same report, the same shown or withheld outcome and every estimate within 1e-6 relative (floating-point last digits can differ between browsers). The figure draws the chosen curve: kept points (excluded hollow), the re-run 4PL line on a log10 axis, each point's values in its tooltip. The file's own Content-Security-Policy allows nothing remote (default-src 'none'; inline script and style only), and the embedded JSON and script are escaped for a <script> element. Light and dark follow the system. e2e/assay-analyser/reproducibility-package.spec.ts downloads the MTT example's package, opens it from disk and checks 3 of 3 agree, the hash matches, the figure, no remote request and axe, desktop and phone.
Pre-registered analysis (A-4 item 2)
src/components/tools/assay-analyser/planned-analysis.ts, copy in content/apps/assay-analyser.plan.ts, the design docs/design/assay-stage/pre-registered-analysis.md. A plate sheet from Calc may carry the analysis settled with the plan (analysis on the sheet; see Calc). planOf(plannedSheet) reads it from the captured, hashed envelope only. When the plan is placed and the sheet captured in that act (decided inside the state update), the workspace loads planned-analysis.ts and writes planSettings(plan, settings) to the fit settings (normalisation, fixed parameters, weighting, interval, endpoint; the assay intent is kept): the data arrive and run as planned. The write is lazy and best effort: it happens only if the same sheet is still kept and the settings are unchanged since the act, and a failed load leaves the settings as they were, which the report then lists as changes. Because the plan is part of the hashed sheet, any edit in Calc, even settling the same choices again on a later day, makes a re-sent sheet a different one, which the assay refuses as it refuses any other different sheet (kept). The fit date in the sentence is the person's local date, as the settled date is. The placement's own confirmation stays the one announcement. The kept snapshot (planned-sheet-panel.tsx) lists the plan with its settled date.
After a fit, compareToPlan(plan, results, data) compares the stored results' settings with the plan, and the exclusions with the rule: any exclusion when no rule was planned is a change; otherwise the count is stated under the planned rule (whether each exclusion follows the rule is the person's call, not checked). planSentence adds one sentence to the methods paragraph (methodParagraph in src/apps/assay-analyser/report.ts, so the Report view, the paragraph export and the clipboard carry it): "Analysed as planned on {fit date}: the model, … were settled with the plan in Calc on {settled date}." or "Analysed on {fit date} with {n} changes from the analysis settled with the plan in Calc on {date}: {what} {planned} in the plan, {used} in the analysis; … Reason recorded: {the research record's Deviations}" ("No reason recorded; …" when empty). No record field is added: the plan lives inside plannedSheet, and the reason in research.deviations. e2e/assay-analyser/planned-analysis.spec.ts covers Calc by keyboard and Assay's arrival, settings and paragraph, desktop and phone.
Blind analysis (A-4 item 9)
src/components/tools/assay-analyser/blind.ts (the acts, loaded with the panel), blind-record.ts (the record and lockStateOf, loaded with the model), blind-sentence.ts (the paragraph), panels/blind-panel.tsx (drybench-view:assay-analyser/blind, its own chunk, in the Fit view's column beside the Fit settings), copy in content/apps/assay-analyser.blind.ts and assay-analyser.blind-sentence.ts, the design docs/design/assay-stage/blind-analysis.md.
- Hide (
hideNames), allowed only for a dose-response assay with readings, while no results, comparisons or withheld results are stored (they carry the names), with no dilution plan or plate sheet and no compound structure or link (blindable): every compound name becomes a code, "Sample 01" onwards in a random order (crypto.getRandomValues; "Blind sample" when a code would equal a name), throughrelabel: a table's compound labels, the layout's compound per well, the compound notes,curveFixes, the table exclusion and decision keyst:<compound>:<row>(with the decision history), the import review's source map, the figure's per-series styles and the stored results' curves. The key is kept inblindand the record raised to v6. - Lock (
lockAnalysis), only while blind, with stored results (the panel offers it only when the workspace's verified results exist), once:locked.stateislockStateOf(canonical JSON of the stored fit's settings, the exclusions, the exclusion reasons and the per-curve fixes). - Show the names (
revealNames), only after the lock, and refused ("clash") when a compound named while blind carries a hidden name, which would merge two compounds; the stored results must verify against their readings (verifyDoseResponseResults), thenrelabelmaps every code back, the results'inputHashis recomputed over the same readings under the real names, and the lock state is rewritten under the names. The write is guarded: it lands only if the stored assay is still the one verified (applyIfin the workspace), otherwise "The assay changed …". - The paragraph (
blindSentence, appended bymethodParagraph): when the names were hidden, locked and shown (local dates), "still hidden" before the reveal, and "The settings, exclusions or fixes were changed after the analysis was locked." when the current lock state differs from the lock's.
Not coded, and named in the panel's limits line: the key, the retained original file (its download is held while blind, but the project file carries it), the file name, plate labels, the figure's series label text, rejected cells' raw text in the import review and the experiment context's free text; so this is a discipline, not concealment. The Study's summary across saved assays lists blind assays apart and never pools them by code (across-assays.tsx; SCI-51). Comparisons made while blind keep their codes and read as made from other results after the names are shown. Older builds refuse a record with blind (REL-53); a draft from before the hide can undo it (REL-54). blind.test.ts and e2e/assay-analyser/blind-analysis.spec.ts (desktop and phone).
Transformation history (A-4 item 14)
src/components/tools/assay-analyser/transformation-history.ts, copy in content/apps/assay-analyser.history.ts, the design docs/design/assay-stage/transformation-history.md. For one dose-response curve, every step from the imported readings to the reported number, each row { step, item, before, after, note? }:
| Step | Rows |
|---|---|
file |
the import (file name, bytes and SHA-256, or pasted text, or the shipped example) and when; for a file, whether its bytes are kept on this device, or why not (the existing opt-in retention) |
read |
the reader profile; lines outside the plate blocks (counted, never kept); cells (or table rows) read against accepted, rejected and unresolved |
map or units |
plates: the compound's wells and each plate's control counts; a table: each concentration as typed against mol/L |
normalise |
per plate, the control wells' readings against their mean (the formula as the note); then every well's raw reading against its normalised response; raw mode and the raw fallback say so |
exclude |
each excluded well with its reason (tableDecisions.reasons, through decisionKeyOf); the observations before and after |
log |
each kept concentration against its log10 |
fit |
Top, Bottom, log10 C50 and Hill, free or fixed, against the estimate; the engine version and fit time |
rules |
the stored log10 C50 against shown or withheld, with the reason |
report |
the stored C50 against the displayed one (three significant figures with its prefix), and pC50 |
It is recomputed each time from the assay as saved and the verified stored results (the workspace passes only verifiedResults), so the raw readings are the ones the stored fit consumed. It lists the app's own steps on editable work; it is never called or claimed as a regulatory record (the compliance rule), and transformation-history.test.ts runs the forbidden-phrase check over its copy. It shows in the curve inspector as a closed <details> after the rules (HistoryDisclosure in panels/curve-detail.tsx); the engine loads on opening, and the CSV (every curve, with the provenance columns and README) is built by src/apps/assay-analyser/history-export.ts, loaded on its button. It is not in the Export menu: dose response already registers nine items, and a tenth would push the pre-submission check's entry out. Dose response only in this version. e2e/assay-analyser/transformation-history.spec.ts covers the MTT example on desktop and phone.
Pre-submission check (A-4.1)
src/components/tools/assay-analyser/pre-submission.ts, the CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-pre-submission-check.md), the design docs/design/assay-stage/pre-submission-check.md. Before export, the analysis is read the way a careful reviewer reads it and the findings are listed, each with the fact it rests on ("Rests on: …") and a fix that is a place. Flag, never act: a rule reads verified stored results, settings and the figure settings and changes nothing; it computes nothing new beyond comparisons with thresholds and the per-concentration means replicateSummaries already draws, so there is no methods entry or oracle (the CSO, §1). Dose response reads the results useVerifiedResults passes; every other mode's sub-record is read only after its verify*Result (the same gate as export), otherwise the section says "Results withheld until refit, or not yet calculated; nothing to read".
Rule (rule) |
Kind | Fires when | Fix |
|---|---|---|---|
replication |
replication | Any C50 shown, unless the "Across plates" declaration is confirmed with two or more experiment ids over the results' plates; a declaration of two or more not confirmed names that instead. research.replicateKind = "independent" is named in the fact, never a silencer |
Replicate interpretation (one plate or a table); Across plates (two or more plates); its confirmation |
spread-two-wells |
spread | Figure replicates "mean ± SD" and a drawn (not hidden) series has a concentration with exactly two kept wells | Figure: replicates |
range-c50 |
range | A shown C50 (relative or absolute per ic) outside the curve's kept concentrations: a guard, since rules (iii) and (v) should keep it silent |
Curve detail |
range-interval |
range | A resolved bound (status crossing) of the shown interval beyond the kept concentrations; unresolved bounds are rule (vi)'s | Curve detail |
axis-range |
axis | A set x or y range leaves kept points of a drawn series, or a shown C50's mark, outside it | Figure: axis range |
axis-log-hidden |
axis | A log y axis cuts bar arms or curves, or leaves excluded points at or below zero or zero-dose rows undrawn (the CSO's figure-axes memo of 13 October 2026, ruling 1(f); REL-141). Reads logAxis counts from the figure system's shared count, the caption's own; absent until the figure has log y, so silent today (SCI-71) |
Figure: axis range |
plateau-lower, plateau-upper |
plateau | Percent scale, relative C50 shown, the lower (upper) plateau's parameter free, and no concentration mean of kept wells below 30 (above 70) percent (PLATEAU_LOWER, PLATEAU_UPPER; the roadmap's convention and the CSO's judgement, attributed to no source). The plateau is named by its fitted value, so a rising curve's lower plateau is Bottom at low concentrations |
Bottom or Top constraint |
reporting-withheld |
reporting | Curves refused, at no finite minimum, or withheld by a rule, grouped by rule with the curves named and the first one's stored reason (flagText) |
Curve detail |
reporting-poorly-determined |
reporting | Rule (vi) on a shown C50 | Curve detail |
reporting-weighted, reporting-cancelled, reporting-example, reporting-z-prime |
reporting | 1/Y² weighting; a cancelled fit; example readings; a plate's Z′ below 0.5 (an observation, not a gate) | Weighting; the Fit; Plate map |
compare-stale, compare-plates |
reporting | The newest stored comparison was made from other results (results time, results hash, curves; the Compare panel re-verifies the input hash); or it compares curves on different plates | Compare curves |
range-unknowns (calibration), qpcr-diagnostic, qpcr-unresolved, qpcr-interval-withheld, qpcr-efficiency-assumed, range-km, kinetics-km-poorly, kinetics-no-intervals, range-km/range-ki/range-kiPrime (inhibition), thermal-refused, thermal-disagree |
range or reporting | As named, from the stored outcome and its flags; facts quote the mode's stored reason sentence | The mode's result or readings |
Findings are ordered by kind (replication, spread, range, axis, plateau, reporting), then by the subject's display order. Each Finding carries family, rule, kind, subject (kind and id), says, fact, basis (the record paths it read), fix and order (the CSO's §5); A-2's families add column-statistics and groups with kinds test-choice, multiplicity and distribution and an input carrying the declared design, the test, the number of comparisons and the correction. A states that leaves nothing to export (results withheld, a refused fit) is not a finding. The section ends with what was read and the checks run, listed, and with none applying says "None applies. The checks cover only what is listed; they are not a review of the science."
Where it shows. The Export menu's first group, "Before export", holds "Pre-submission check" whenever there is something to read and the menu has room (MENU_MAX, ten: with a retained original file dose response already registers ten, and the entry yields rather than push the project file and method paragraph under "More formats…"); it opens the section (switching dose response to the Report view) and moves focus to its heading; it is never a modal. Dose response: the Report view, after Print (ReportView's check). Other modes: under the mode's panel. The section is its own chunk (drybench-view:assay-analyser/presubmission). A fix switches the view (goFix in the workspace), or selects a curve and opens the inspector, then moves focus to the control (opening its disclosure), which is left as it was. e2e/assay-analyser/pre-submission.spec.ts covers the MTT plate (replication, the withheld curve, the two-well finding after choosing mean ± SD, fixes by keyboard) and unsaturated initial rates (Km beyond the substrate range), desktop and phone; the Export-menu entry on desktop.
Not yet applied (SCI-44): the experiment-summary findings, growth's withheld-parameter finding.
A-4 diagnostics (items 17, 5, 3 and 7)
The CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-a4-diagnostics.md). Flag, never act: nothing here excludes, refits the reported result or changes a setting. Built and shown: items 17, 5, 3(b), and, since their fixtures were recorded in the pinned image and published as 0.11.0 on 13 October 2026, items 7 (influence halos) and 3(a) (the design calculation), each listed in the app's METHODS (influence, designNarrowing) with its limits line. 3(c), the CPO's ruling of 13 October 2026 on the power plan's note applied here: the suggestion's design note (date, curve, wells, concentration, "suggested by a design calculation", the predicted narrowing) has Copy and "Add to Study notes", which appends it as a new paragraph through study-note-append.ts (the power panel's module, revision-safe); disabled with the reason beside it without a Study or write access. The push into a Calc plan is deferred (docs/NEXT.md).
- Explain this number (
explain.ts, item 17). Under a curve's detail (the inspector), in a disclosure loaded withpanels/curve-diagnostics.tsx(its own chunk,drybench-view:assay-analyser/curve-diagnostics). For a shown C50: the kept wells and concentrations and excluded count; the slope count, the distinct kept concentrations strictly between the two bend points; each plateau set by its count at or beyond its bend point, or fixed by a setting; the response-evidence p. For the interval: its bounds, the fold range 10^W, "wide" above 1 log10 and "poorly determined" above 2 or with a bound unresolved (WIDE_LOG10, the CSO's judgement); "Intervals are wider when fewer concentrations lie on the slope"; fixed parameters named as treated as known. For a withheld C50: the stored reason, the count that withheld it under rule (iii) or (iv), "Least squares found no single best curve" for no finite minimum, and that the drawn midpoint is not an estimate. Facts side by side, never "because".StoredCurvedoes not store rule (iv)'s checks, so the bend points are recomputed from the stored estimates withplateauChecksandBEND_CONSTANT, the arithmetic rule (iv) ran. - Residuals by dilution step (
residual-steps.ts, item 5). In the same chunk: residuals y − f(x) at the stored estimates against step k (0 = the top concentration), one series per plate row (or column) when each runs across the concentrations, as a chart (each series its own dash and marker) and a table. Shown only for an unweighted fit at a minimum with its relative C50 shown, at least six distinct concentrations (MIN_STEPS) spaced equally in log10 within 0.01 (SPACING_TOLERANCE). No test and no "percent per step": a constant error per transfer is an affine map of log concentration that the 4PL absorbs exactly (Hill × (1 + δ/s)), whichexplain.test.tsconfirms numerically; the view shows only uneven errors and says a pattern can also come from plate position or curve shape. With a placed Calc plan, its planned fold, points and top concentration are shown beside it, labelled planned; nothing from the plan enters a computation. Limits line: "Residual display only; no test. A constant per-step error cannot be detected from the responses." - Influence halos, the engine (
src/lib/fit/influence.ts, item 7;INFLUENCE_METHOD,influence-leave-one-out1.0.0). For an unweighted curve whose relative C50 is shown: each kept well left out, the same model refitted (fixed parameters kept;fitFourPL's starts plus the full estimates as one more, the lowest converged objective), rules (i) to (v) applied to the refit (not (vi)), Δ = log10 C50 without the well − log10 C50, or the rule that withheld it and no number.largestShiftnames the largest |Δ|, the first well on a tie within 1e-9. About 120 ms a curve on the MTT plate, so it runs ininfluence.worker.ts(drybench-worker:assay-influence, one job per curve,influence-client.ts) when a curve's detail opens. Shown in the curve detail: the "Influence of each well" disclosure (the largest shift named with the CSO's sentence and set against the shown interval, or "the interval is not fully resolved"; each well without which no C50 is reported, with the refit's reason and no number; a table of every well's shift; the legend and limits line) and halos on the detail's chart (CurveChart'shalos, screen only, never in an exported figure): radius linear in |Δ| up to 2.5 mm at the 0.5 log10 cap, a second ring at the cap, a dashed ring where the C50 is lost. Unweighted curves with the relative C50 shown and chosen only. Oracle:pipeline/assay-analyser/oracles/influence_loo.py(SciPyleast_squares, the rank-trend permutation from its stated seed; cross-checkr/influence_loo_drc.R, drc LL.4); MTT A and B, Compound C (no halos), an outlier on the slope, losing the bracket (rule iii), four concentrations (rule i), Bottom fixed, tied shifts; tolerance 0.00086 log10 on Δ, outcomes exact. Published asinfluence-loo.json0.11.0 (run 38420124556);src/lib/fit/diagnostics.test.tsholds the engine to it. - What would tighten this? (dose response), the engine (
src/lib/fit/design.ts, item 3(a);DESIGN_METHOD,design-narrowing1.0.0). V_c = [(JᵀJ + r·j_c j_cᵀ)⁻¹]_logC50·s² at the stored estimates for the tested concentrations, the midpoints and one step beyond each end, r the modal replicate count (the smaller on a tie); the predicted half-width t(0.975, df + r)·√V_c against the current Wald half-width (never the profile); q rounded to the nearest 5 (halves up) and shown at 10 or more (NARROWING_FLOOR); ties to the candidate nearer the C50, then the lower concentration. On the MTT plate: Compound A, a midpoint beside the C50, 15 percent; Compound B, a tested concentration, 15 percent. Shown in the curve detail's "What would tighten this?" disclosure in the CSO's words, with its limits line; a rule (vi) curve gets only "The interval is too poorly determined for a local design calculation; {m} tested concentrations lie on the slope". Oracle:pipeline/assay-analyser/oracles/design_narrowing.py(NumPy analytic gradient; cross-checkr/design_narrowing_fd.R, a finite-difference Jacobian at drc's estimates); MTT A and B, one concentration on the slope (a midpoint wins), a thin plateau (the step beyond wins), a dense series (below the floor); tolerance 1e-6 relative on V_c, the choice exact. Published asdesign-narrowing.json0.11.0 (run 38420129247);diagnostics.test.tsholds the engine to it. - What would tighten this? (standard curve) (
tighten.ts, item 3(b)). Under the pre-submission check in calibration mode: "{k} unknown(s) read below the lowest standard ({lowest} {unit}); add standards below it, or concentrate the samples" and the above-range counterpart. No concentration is suggested (an extrapolated estimate is refused).
Outlier candidates (A5)
src/lib/fit/outliers.ts (studentisedResiduals, grubbs) and src/lib/fit/outliers-four-pl.ts (fourPLOutliers, apart so a page testing only control groups does not load the 4PL fitter), method outlier-candidates 1.0.0; CSO memo of 11 October 2026, item 7. Flags for review, computed on demand from the current fit and never stored, so they recompute after any exclusion; the copy (content/apps/assay-analyser.outliers.ts) says "candidate", never "outlier" or "exclude", and the exclusion route stays the reason-required one.
h_i = [J (JᵀJ)⁻¹ Jᵀ]_ii, J the Jacobian at the stored estimates over the free parameters (fourPLGradient)
s² = RSS/(n − p); r_i = e_i / (s √(1 − h_i)); s_(i)² = (RSS − e_i²/(1 − h_i)) / (n − p − 1)
t_i = e_i / (s_(i) √(1 − h_i)); p_i = 2 P(T > |t_i|) on n − p − 1 df; adjusted min(1, n p_i)
candidate when adjusted p < 0.05 ⇔ |t_i| > t(1 − 0.05/(2n), n − p − 1)
Grubbs (a control group): G = max|x_i − x̄|/s, t_G² = n(n − 2)G² / ((n − 1)² − nG²), p = min(1, 2n P(T > t_G)) on n − 2 df
- Curves (inspector,
panels/curve-detail.tsx, "Candidates for review"): tested only for an unweighted fit with a minimum (weighted,no-fitotherwise), n − p ≥ 6 (degrees-of-freedom) and a residual sum of squares above 1e−20 of Σy² (zero-residual); an observation with 1 − h below 1e−10 is not studentised. The candidate line is the CSO's: "Candidate: studentised residual 4.1 against the current fit (adjusted p 0.01). Review the well; exclusion needs a reason." - Control wells (fit settings,
panels/fit-settings.tsx, beside Z′): Grubbs within each positive, negative and blank group of six or more kept wells on each plate (PlateSummary.controlValues, the wells and valuesreadLayoutread, excluded ones left out); one test per group, the value furthest from the mean; groups of two to five are not shown. - ROUT is not used (it needs a robust fitter; refused this stage by the CSO). The p uses the incomplete beta directly (
tTwoSided), so a small p keeps its precision.
Other quantities
- R² = 1 − RSS/Σ(y − ȳ)², unweighted minima only, shown as "not a goodness-of-fit gate".
- Hill observation (
hillObservation): |Hill| > 1.5 "steeper", < 0.67 "shallower", otherwise about one; worded as observations. - pC50 = −logC50, derived for display, never stored.
- Drawn curve: 61 points from min x − 0.5 to max x + 0.5 (
analyse.ts).
Batch fitting
FitClient (fit-client.ts) fits one curve inline and larger batches in fit.worker.ts, which runs analyseCurve in chunks of 25, posts each chunk and the progress, and yields between chunks so a cancel message is read; cancel keeps the curves already fitted (StoredResults.cancelled = true). A batch above 400 curves is refused before anything runs (FitTooLarge; the workspace shows "Split the import, or exclude compounds"). A worker error terminates the worker and rejects the batch. Progress is announced through the shell's one live region.
Data in without friction (the routine week, criterion 1)
A pasted table whose concentration header names no unit, with no unit column, is no longer refused: the review asks "Unit of the concentrations in this table" with the molar units and "Read with this unit", reads the table with that unit (readImport(text, undefined, unit), parseCurveTable's givenUnit, used only when the file states none) and says "The header named no unit; concentrations read in {unit}, as you chose." A reader export (a plate grid or a reader's block) has readings and no doses; when a saved layout template of its plate size exists, the review offers "Plate map" with those templates (default "None: assign on the Plate map"), and the chosen layout goes into the import as its layout, said in one line, so the readings reach a curve without the Plate map step. Nothing is applied unasked. routine-week.spec.ts times each from the paste to the first fitted curve (under ten seconds).
Zero doses and units (the routine week, criterion 2)
In a pasted table, a row whose dose is exactly 0 and that has no role is read as zero dose (isZeroDose in table-decisions.ts; the CSO's ruling of 13 October 2026): a status derived from its concentration, never stored, so no record field changes. It keeps its reading, is left out of the log-dose fit and of n (log(0) is undefined), is never a 100 or 0 percent reference, and the curve fits its positive doses instead of being refused. The table-rows panel says "{n} zero-dose rows not fitted: log(0) is undefined…" with a filter to show them; assigning a control role or excluding with a reason each take one step, as before. A negative dose is a data error: it stays a sample and refuses the curve under rule (i), said in its own line. The method block's controls item adds "Zero-dose rows: {n}, not fitted (log(0) undefined), not used for normalisation unless assigned."; the imported-rows CSV gives such rows an empty role and the fit use "Zero dose, not fitted", with their readings exactly as read. On the plate map a sample well at 0 is still refused ("A sample well needs a concentration above zero…"). Changing "Concentration unit for the plate map" is announced and shown ("Unit set to {unit}. Applies to the next import only. No stored concentration has changed."), and says so when it clears a previewed import; it never relabels a stored value. zeroDoseRows(data) in the same file gives each compound's zero-dose rows (name and raw response, excluded rows left out) for the figure, which draws them hollow at a "0" position left of the log axis on a broken axis (the figure side is the A-0 figure system's). The workspace computes it once from the source, the row decisions and the exclusions, and passes it to the charts (FitResults → SmallMultiples → CurveChart), to the figure exports (ExportInputs.zeroDose) and to the figure caption, which names the rows.
Excluding a point from the chart
A point's hit area on the curve chart (or, by keyboard, the chart's data and the well's "Exclude well {id}" button) opens the reason dialog; a reason is required. "Exclude and refit" (or "Reinstate and refit") records the decision and refits in the same action: the workspace queues the refit and runs it once the decision has rendered (refitQueued), announcing "Well {id} excluded; refitting." An exclusion's notice carries Undo, which reinstates the well with the logged reason "Exclusion undone" and refits; the decision history keeps both entries. The exclusion is listed in the chart's description, the method block ("1 well excluded: A9"), the method paragraph, the CSV columns and the well table (the routine week, criterion 6; routine-week.spec.ts, chart-plate.spec.ts).
The Fit view
With results, views/fit-results.tsx renders five panels in named grid areas, and the workspace lays them out with the fit settings and the context (charts summary / charts interpretation / review settings / table settings / table context at 60 rem of the main container; one column, in that order, below it; the UI redesign of 13 October 2026, D-0082). The chart panel ("Fitted response", with "Small multiples" as its eyebrow, the model and weighting as a pill, the curve count, the response scale's axis words, the figure controls and the small multiples). The headline card ("Lowest C50 shown", the endpoint named by intent, with "Fit summary" as its eyebrow): the lowest shown C50 in the serif answer face with its unit, the compound and pC50, the example chip (a published or synthetic dataset), the interval with its method, Hill, Bottom / top, R², Points with the replicates, the fixed parameters and the plate, every value read from resultsRows for that curve, its flags beneath, and "Open the curve detail" (the inspector); when no C50 is shown it says so and counts the withheld curves. "Review and reproduce": the ReadoutStrip labelled "Fit summary" (curves fitted, C50s shown, withheld, exclusions as wells or rows, the lowest Z′), "Copy the method paragraph" (the Report's paragraph) and "Open the report". "Confidence and interpretation": the reporting rule in one paragraph (fit.interpretation.body, the C50 shown only when the rules pass, with the rules' reasons travelling with every result), the refit differences banner, the line saying what the fit wrote and the structure engine's note, and a link to the methods page. "Results": the results table (the data-results-ready stamp on its wrapper) and the Compare panel. Before a fit, the settings panel ("Fit settings", carrying the Fit key and the published example's chip) and the context share the row as before. The Fit key's focus target is the chart panel's heading once there are results and the settings panel's before.
The results table
panels/results-table.tsx: one row per curve with C50 (or its withheld state), pC50, the 95 percent interval with its method, Hill, R², Points and Replicates (kept wells per concentration, replicatesOf in model.ts, the same as the results CSV's column), Plate when there are plates, the flags as a sentence, and Top, Bottom and Excluded in the Columns menu. Its toolbar's "Copy table" (data-copy-table) writes every exportable column in its shown form to the clipboard as tab-separated text and as an HTML table, so it pastes as a table into a spreadsheet or a document, and announces the row count once (the routine week, criterion 5; routine-week.spec.ts).
Constants in one place
| Constant | Value | File |
|---|---|---|
FIT_VERSION, method record versions |
2.0.0 | src/lib/fit/index.ts, methods.ts |
TABLE_VERSION |
1.0.0 | src/lib/table/index.ts |
CONFIDENCE_LEVEL |
0.95 | ci.ts |
PROFILE_LIMIT_LOG10, PROFILE_STEP_LOG10 |
10, 0.05 | ci.ts |
F_TEST_ALPHA, FLAT_LINE_ALPHA |
0.05 | compare.ts, rules.ts |
RESPONSE_PERMUTATIONS |
1,999 | response-evidence.ts |
BEND_CONSTANT |
4.6805 | rules.ts |
POORLY_DETERMINED_WIDTH_LOG10 |
2 | rules.ts |
Z_PRIME_CONVENTION |
0.5 | zprime.ts |
ALTERNATE_HILL_STARTS |
0.5, 1, 2, 4 | four-pl.ts |
FIT_CHUNK, FIT_CAP |
25, 400 | fit-protocol.ts |
COMPARE_VERSION, COMPARE_MAX_CURVES |
1.0.0, 8 | compare-constants.ts |
COMPARE_ALPHA, COMPARE_LEVEL, COMPARE_BOX_TOLERANCE |
0.05, 0.95, 1e-4 | global.ts |
COMPARISONS_KEPT |
20 | comparison.ts |
PLAN_DOSE_TOLERANCE |
0.001 relative | plate-map.ts |
Standard-curve calibration
Reviewed spreadsheet, table and plate intake
The existing formatted CSV/TSV reader is preserved. The Readings disclosure also
loads panels/calibration-intake-panel.tsx on request (marker
drybench-island:calibration-import). Its calibration-intake.ts adapter reuses
shared delimited decoding, A1 coordinates, XLSX stored cell strings and
ImportReview. Select one worksheet and an explicit range: a table includes its
header, while a plate range contains exactly 8 × 12 or 16 × 24 response cells.
The researcher declares raw or reader-corrected responses. Corrected imports
require confirmation and keep app blank subtraction off, including on strict
restoration. Plate orientation is explicitly confirmed; every populated well is assigned a
standard, unknown, measured blank, or an ignore decision with a reason. Empty
cells are missing measurements; typed spreadsheet dates/errors remain candidates
even when their stored text is withheld. Nothing infers concentrations from
labels, pools worksheets, converts units, or fits during review.
Column mappings use indices and preserve original headers, physical response-cell coordinates, ignored columns, and rows outside the selected rectangle. Optional row overrides support exports without role/standard-concentration columns. An unmapped dilution column uses the visibly declared default; a mapped blank dilution cell is unresolved. Standards and blanks still require factor 1. The selected unit must match a mapped unit column. Shared number rules retain measured zero and negative finite responses while refusing missing, ambiguous and invalid values. Review errors name their actual source cell and retain typed date/error and reader saturation/error classifications, including through explicit ignores. Very long preview values are shortened visibly; validation reads complete values. XLSX numerics use the workbook's decimal-point convention. Formula caches are read without recalculation and their worksheet count is disclosed.
Review is invalidated by source/layout/mapping/unit/assignment changes; Apply checks the reviewed data identity and workspace scope again. File parsing, hash creation and optional original retention are guarded against stale async results. Failed original retention requires explicit continuation. Files are capped at 10 MiB, delimited grids at 5,000 rows × 256 columns, and imports at 768 candidate measurements. The shared XLSX limits still apply without clipping.
AssayImportRecord.calibrationImport is a versioned, calibration-specific
provenance record, read by src/lib/fit/calibration-intake-record.ts. It retains
the selected unit, mappings, explicit assignments/ignore reasons, formula count
and complete review/source map; existing sheet and selectedRange fields retain
its origin. The reader checks rectangle bounds, well geometry, candidate counts,
ignored decisions and exact coverage of calibration reading IDs. Mapped paste
receipts intentionally accept matching paste sources; file receipts still require
matching digest/retention. Session/captured inputs retain this record and the HTML
report includes it. The scientific input hash is unchanged: the resulting roles,
labels, concentrations, responses and dilutions are already hashed as readings;
the hash does not authenticate the separate review metadata. Historical hashes
and numerical method versions are unchanged.
Calibration mappings use a distinct local preset collection and strict reader
(calibration-presets.ts) through the generic transactional helper shared with
import-presets.ts. Existing dose-response keys/readers and molar-unit rules are
unchanged. Loading requires identical unique headers in identical order and
stages settings for a fresh review; changed headers require explicit remapping.
Plate assignments are not automatically saved or reused as templates.
Independent inputs/expected readings and a two-worksheet XLSX live in
pipeline/assay-analyser/fixtures/calibration-intake-v1/, committed before the
adapter and generated with Python CSV/Decimal/OOXML by
calibration_intake_reference.py. Tests cover physical coordinates, both plate
geometries, missing/zero/error handling, persistence and the existing exact linear
calibration → original-sample concentration → Calc hand-off. These synthetic
layouts do not establish compatibility with a named instrument/software version;
actual export observation remains an acceptance follow-up.
Existing calibration model and numerical contract
Implemented by src/lib/fit/calibration-model.ts (record types and strict readers, CALIBRATION_VERSION = "1.3.0" (1.1.0 the 5PL, 1.2.0 the inverse-prediction intervals, 1.3.0 their method 2.0.0, the reading variance pooled from replicated standards, SCI-123; stored results of an earlier version turn stale and ask for a re-run); the sub-record is v: 1, or v: 2 when it carries the 5PL, or v: 3 when it carries spike declarations (spikes, the CTO's ruling of 13 October 2026), and a v: 2 or v: 3 sub-record is read only on a v6 assay record, the CTO's ruling 1 of 12 October 2026) and src/lib/fit/calibration.ts (fitCalibration); the scientific contract is docs/design/assay-calibration-increment.md.
Input. One table with columns role, label, concentration, response, dilution (optional unit, source_row, excluded, decision_reason), parsed by calibration-import.ts: roles standard, unknown, blank; standards need a concentration ≥ 0, unknowns and blanks none; dilution ≥ 1 for unknowns and exactly 1 otherwise; at most 768 readings and 1 MB. One concentration unit per source, from pg/mL, ng/mL, µg/mL, mg/mL, g/L, pM, nM, µM, mM, M; no mass/molar conversion.
Calculation:
- Optional blank correction subtracts the mean of retained blanks from every standard and unknown response; refused without a retained blank.
- Standards are grouped by concentration and averaged (one mean per level, equal weight). Unknowns are grouped by exact label and dilution factor and averaged. Mean, SD (n − 1) and CV (only for a positive mean) are descriptive.
- Linear: ordinary least squares y = a + b·c on the standard means, computed on scaled values; needs ≥ 3 distinct levels; refused if the scaled slope is within 64ε of zero. Linear is always unweighted (the reader rejects
1/y2for linear). - 4PL:
fitFourPLon (log10 c, mean response) with weightingnoneor1/y2(IRLS as above); needs ≥ 5 distinct levels, all positive (zero readings must be assigned as blanks). Any outcome other than a minimum is refused with one generic message. No reporting rules or intervals are applied. 4a. 5PL (standard curves only; CSO memo of 11 October 2026, item 3; refused for dose response):fitFivePLby bounded Levenberg–Marquardt with the analytic gradientfivePLGradienton (log10 c, mean response), unweighted only (the reader rejects1/y2for the 5PL); parameters [Bottom, Top, logMidpoint, Hill, ln s], the first four free and ln s boxed to [ln 0.1, ln 10]; ten starts (ln s ∈ {ln 0.5, 0, ln 2} × three midpoints at the centre of the standards and ± one eighth of their span, plus the 4PL's estimates at s = 1), the lowest objective kept. Needs ≥ 7 distinct positive levels (CALIBRATION_MIN_STANDARDS); ln s on its box (within 1e−9) refuses the 5PL witherrors.sAtBoxand no fallback; a fit with Top ≤ Bottom is refused. Beside it, as information only (result.comparison): the unweighted 4PL of the same means, the extra-sum-of-squares F of 4PL within 5PL (nested at s = 1; 1 and n − 5 df) and AICc for both (K = parameters + 1); withheld when the 5PL residual sum of squares is at most 1e−20 times the responses' sum of squares, or when the 4PL found no minimum. The panel never chooses the model, and every 5PL result carries the CSO's limits line (calibrationCopy.fivePLLimit): the midpoint is not a C50. - Tested range = [lowest, highest] retained standard concentration. Each standard gets fitted response, residual, back-calculated concentration and recovery = 100·back-calculated/nominal; R² is computed on scaled means and is descriptive.
- Unknowns: below or above range is judged in response space against the fitted responses at the range ends (tolerance 64ε); otherwise the inverse below is taken, checked to lie in range (tolerance 256ε times the larger of the top standard and the inverse) and clamped; the original-sample concentration is the interpolated value times the dilution factor.
linear inverse: c = (y − a) / b
4PL inverse: c = 10^( logMid − log10( (Top − y)/(y − Bottom) ) / Hill ), only for Bottom < y < Top
5PL: y = Bottom + (Top − Bottom) / (1 + 10^((logMid − x)·Hill))^s, x = log10 c, s = exp(ln s)
5PL inverse: c = 10^( logMid − log10( ((Top − Bottom)/(y − Bottom))^(1/s) − 1 ) / Hill ), only for Bottom < y < Top
drc LL.5: b = −Hill, c = Bottom, d = Top, e = 10^logMid, f = s
- Inverse-prediction intervals (A5; CSO memo of 11 October 2026, item 4;
src/lib/fit/inverse-prediction.ts), on every interpolated unknown of av: 2result (a fit from the panel raises the sub-record tov: 2before hashing; av: 1record keeps its shape, with no interval). The fit is on the standard means, n of them, each the mean of r kept readings, p parameters, N = n·r readings in all. Method 2.0.0 (14 October 2026; the CSO's ruling on SCI-123 in the exit memo of 13 October,docs/board/memos/2026-10-13-cso-stage-a-exit.md): the reading variance is pooled from the individual readings, s² = (Σ r·(ȳᵢ − f(xᵢ))² + Σ (r − 1)·sdᵢ²)/(N − p), t = t(0.975, N − p), the unknown the mean of its m readings. Method 1.0.0 took s² = RSS/(n − p) from the means alone, which estimates σ²/r, so with replicated standards its intervals were too narrow (simulated coverage 83 to 91 percent at a nominal 95, against 94.4 to 95.5 percent for method 2.0.0); with standards read once the two are identical. Linear: the classical inverse-regression (Fieller) interval in concentration, g = t²(s²/r)/(b²·Sxx), bounds c̄ + ((c0 − c̄) ± (t·√(s²/r)/|b|)·√((1 − g)(r/m + 1/n) + (c0 − c̄)²/Sxx))/(1 − g), which is the textbook interval over the N individual readings. 4PL and 5PL: the delta method on x = log10 c by the implicit function theorem, var(x0) = (∇θf(x0)ᵀ·(s²/r)(JᵀJ)⁻¹·∇θf(x0) + s²/m)/f′(x0)², bounds 10^(x0 ± t·√var), labelled "approximate (delta method)". Withheld with a named reason, in order: weighted fit; standards with unequal numbers of kept readings (unequal-replicates); residual df on the concentration levels, n − p, below 3 (five standards for linear, as the CSO set; seven for the 4PL and eight for the 5PL, the builder's judgement); logistic, the slope at the unknown below 10 percent of the slope at the midpoint parameter (the CSO's judgement); linear, g ≥ 1; a bound outside the standards (lower-bound-outside,upper-bound-outside,both-bounds-outside). The interval and the original-sample interval (× dilution) are shown, exported in the results CSV and projected to Bench; the v2 qualifications replace "point estimates only" with the CSO's limits line on constant variance.
No LLOQ/ULOQ, automatic model selection or extrapolation is produced; the qualifications in calibrationCopy.qualifications (v1) or qualificationsV2 (v2) travel with every result. A recorded result's report (assay-projection.ts) adds "The tested standard range is not a validated quantification range." and, for a v: 1 result only, which carries no interval, "No interpolated-concentration confidence interval is provided." (the CSO's Stage A exit memo of 13 October 2026, section 3). The result stores inputHash (SHA-256 of calibrationInput: version, source id, settings, readings, source, example flag, history) and fitVersion; verifyCalibrationResult re-hashes before a stored result is shown, exported or recorded, and a mismatch leaves the old bytes in place but withholds them. The fit runs in calibration.worker.ts; cancel terminates the worker. A synthetic linear example is in CALIBRATION_EXAMPLE.
REL-167 (25 October 2026): the current calibration panel supplements an actual
v2 unknown interval withheld for unequal-replicates with one muted paragraph
before the chart and unknown table. src/lib/fit/calibration-kept-counts.ts
reads only the existing trusted result: the comparator is the largest saved
retained standard-group n, and every group below it is named by its saved
concentration (String, without rounding), saved result unit and kept count.
A numerically sorted copy leaves stored arrays and row IDs unchanged; fully
excluded levels remain absent. This observed maximum is not a required count
or advice to change readings. Each unknown's original generic refusal remains.
Equal counts, v1 results, absent intervals and other reasons such as weighted
produce no paragraph. The helper has only a type dependency on the historical
result reader; it never fits, recounts editable readings or alters trust,
methods, hashes, capture, history or exports. The unequal-count interval method
remains open as REL-168.
Limits from blanks and dilution series (A-1 second pass)
src/lib/fit/calibration-limits.ts (version 1.0.0; CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-lod-loq-linearity-recovery.md), computed in the panel from the trusted fitted result at display time and shown under the chart (panels/calibration-limits.tsx). Never stored, hashed or exported; nothing changes an unknown's value or status.
blankLimits(data, result): the kept (not excluded) blanks, n, mean and sample SD. Thresholds in the curve's own response space, y = mean − subtracted ± k·SD (k = 3 for the limit of detection, 10 for the limit of quantification; + for a rising curve, − for a falling one, the direction from the fitted responses at the lowest and highest standards), so blank subtraction does not change the result. Withheld under 6 blanks (too-few-blanks), when the blanks do not vary (no-spread, also for identical values whose floating-point SD is not exactly 0) and without a fitted curve (no-fit); a 4PL or 5PL threshold not strictly between the plateaus has no concentration (beyond-plateau); beyond the lowest standard's fitted response the limit isbelow-lowestand shows the lowest standard, never a number below it; beyond the highest standard's it is withheld (above-highest); otherwise the concentration byinvertCalibration. Not CLSI EP17's limit of blank; no interval this pass (n and the SD are shown).limitNote(unknown, limits): an interpolated unknown whose assay concentration lies below a computed limit gets a neutral note in its status cell, "lod" before "loq".dilutionSeries(result): each label read at two or more dilution factors. Each in-range dilution's original concentration and its percent of the series mean (the mean of the in-range dilutions, not the least diluted); out-of-range dilutions are listed with their status and left out. With 2 or more in range the mean and CV (sample SD / mean); with 3 or more the least-squares slope of log10 original concentration on log10 dilution factor with a 95 percent t interval on k − 2 df, 0 meaning no trend, an observation. No acceptance band.
The method is unit-tested until its fixture (calibration-limits.json, 14 cases) is published.
Spike recovery (A-1 third pass)
Ruling 9 of the same CSO memo and the CTO's ruling of 13 October 2026 on the record. The person declares, in the settings panel (panels/calibration-spikes.tsx, SpikeDeclarations), a spiked unknown group and its unspiked partner, each by its label and dilution factor as the fit groups unknowns (never by a row id, never guessed from a name), and the amount added in the curve's unit on the original-sample basis. The declarations are CalibrationData.spikes (CalibrationSpike: spiked, unspiked, added, unit) on a v: 3 sub-record, read strictly by readCalibrationSpikes (at most 384; no extra keys; both groups present in the readings, excluded rows included, and different; added finite and above 0; unit the curve's; one declaration per spiked group). They are scientific inputs: calibrationInput includes them when present (v1 and v2 hashes are unchanged), so declaring or removing one clears the result until the curve is fitted again. Choosing the 5PL raises v1 to v2 and never lowers v3; a fit raises only v1; a new import and loading the example (withCalibrationExample) drop the declarations, as they reset history; a new concentration unit drops them too. spikeRecovery in calibration-limits.ts works each out at display time from the trusted result, never stored: (C spiked − C unspiked) ÷ C added × 100 on the original sample, withheld (SpikeWithheld) when the amount is not above 0, either group has no kept readings (a wholly excluded group is not among the fitted unknowns), the dilution factors differ, or either is out of range. SpikeRecoveries lists them under the limits as "spike recovery", never bare "recovery", beside the standards' back-calculated recoveryPercent, with its limits line. The Workspace projection names each declaration as an input fact. Tests: src/lib/fit/spike-recovery.test.ts (the CSO's case 14 by hand, every withheld reason, the reader, the hash) and e2e/assay-analyser/spike-recovery.spec.ts (declare, refit, a withheld partner, a reload, desktop and 375 px). Oracle oracles/spike_recovery.py with r/spike_recovery.R (8 cases); spike-recovery.json was recorded in the oracle image (Oracles run 38420985979, R 4.4.3 agreeing within 1e-9) and published as 0.11.0, and the engine is checked against it.
Spike recovery is held to a later pass by the same memo: it needs a declared added concentration and an explicit unspiked partner on each spiked unknown, a change to the stored reading (and a CTO ruling on the record version); the formula is ruled there. The method is verified against its fixture (calibration-limits.json, 14 cases, published as 0.11.0).
qPCR relative expression
Implemented in src/lib/assay/qpcr/ (QPCR_VERSION = "1.1.0"; 1.0.0 results, without intervals, stay readable as v1 results); design in docs/design/pre-release-app-depth/assay.md §A1 and qpcr-implementation.md.
Input. One row per well and assay with fields run, well, sample, condition, calibrator (true/false), technical replicate id, assay, role (target, reference, ntc, no-rt), Cq and optional status (measured, undetermined, missing, excluded) and reason. parseQpcrTable maps headers by alias (Cq or Ct, and so on), keeps the raw Cq and status text, never turns "Undetermined" into a number, and refuses duplicate run/well/assay locations and duplicate technical ids within a run, sample and assay. Limits: 4,096 rows and 2 MB of text; at most 16,384 sample–target combinations. The person must confirm that sample ids identify independent biological samples before calculating.
Calculation (calculateQpcr), with E the fold amplification per cycle (E = 2 for every assay under ΔΔCq; otherwise the entered value, fold or 1 + percent/100, accepted only for 1 < E ≤ 2 and with a stated source):
per (run, sample, assay): mean Cq over accepted measured wells
log2 Q = −meanCq · log2(E)
normalised = log2 Q_target − mean over reference assays (log2 Q_ref)
calibrator mean = mean normalised value of all declared calibrator samples in the same run and target
log2 fold change = normalised − calibrator mean; fold change = 2^(log2 fold change)
group (run, condition, target): mean log2 FC, geometric fold = 2^mean, SD of log2 FC (n − 1)
This is the Pfaffl efficiency-corrected ratio with a geometric mean over reference assays (Hellemans et al. 2007), computed in log space. A sample is unresolved, with reasons, if a required assay has no accepted measured well, any retained well of a required assay is missing or undetermined, or the run's calibrator set is incomplete. Runs are never pooled. Diagnostics (never auto-exclusions): retained non-measured wells, technical Cq spread (max − min) above the person's threshold (default 0.5 cycles), single technical wells, measured NTC or no-RT controls. Refusals: malformed data, unconfirmed biological ids, missing spread threshold, no target or reference, unsupported efficiencies, role conflicts, duplicate technical ids, inconsistent condition or calibrator within a sample, too many combinations. No hypothesis tests or efficiency-uncertainty propagation are computed. The separate geNorm comparison describes consistency among the declared reference assays; it does not establish their suitability.
The fold-change interval (Stage A, A6; CSO memo of 11 October 2026, item 9; interval.ts, method qpcr-fold-change-welch-interval 1.0.0). Every group of a v2 result carries interval, from welchFoldInterval(treated, calibrators, holdsCalibrator):
treated = normalised log2 quantities of the group's quantified samples (n1)
calibrators = normalised log2 quantities of the run's calibrator samples for the target (n2)
d = mean(treated) − mean(calibrators) (equals the group's mean log2 FC)
v1 = s1²/n1, v2 = s2²/n2; SE = √(v1 + v2)
df = (v1 + v2)² / (v1²/(n1 − 1) + v2²/(n2 − 1)) (Welch–Satterthwaite)
95% bounds = d ± t(0.975, df) · SE; fold bounds = 2^bounds
The calibrators' own scatter is inside the interval, which a one-sample interval around the calibrator mean treated as fixed would leave out. Biological samples are n: technical wells are averaged before this point and never counted. Withheld states, checked in this order, carry no number: calibrator-group (the group contains a declared calibrator sample), calibrator-unresolved, calibrator-n and treated-n (fewer than three biological samples), no-scatter (neither group spreads beyond rounding, noSpread with the largest log₂ quantity a group's normalised quantities were computed from as the scale; df undefined), numerical. One group with no spread has its variance set to exactly 0 (its SD shows 0) and the interval is the other group's, df = n − 1. tQuantile (src/lib/fit/stats.ts) takes non-integer df. The panel's group table is open by default and shows the fold-change interval, the log₂ interval and the df, or the withheld sentence. The method paragraph says "No inferential interval" only for a v1 result, where that is true; the report's group table carries the same columns.
Record. QpcrResult.v is 2 when the groups carry interval. The v2 branch of readQpcrResult whitelists the result's and each group's keys, and checks each interval with readQpcrInterval: a withheld interval holds no number, nTreated equals the group's n, and the bounds bracket the difference. A v1 group carrying interval is refused. A v2 result is accepted only on a v6 assay record (readAssayDataStrict, mode-record at qpcr.result.v), under the CTO's architecture ruling 1 on sub-records. QpcrData.v stays 1, because the new field is inside the result, which versions itself.
verifyQpcrResult checks that the result's input hash and settings hash match the current data before display, export or recording. The calculation runs in qpcr.worker.ts. A repeated import reuses the previous target/reference selection, efficiencies and threshold only when assay identities and roles match, and always requires a new biological-id confirmation (qpcrImportSettings).
Reading the captured qPCR result (16 October 2026)
The results panel keeps counts, refusal and historical-version warnings first. A compact “Reading this result” block then explains the captured quantity model and the sample–target–run identity of each point, before the sample table. The existing single “Review qualifications” disclosure now follows that guide, closed initially; it retains the original messages, affected-row filter and explicit reasoned exclude/restore actions. Its threshold explanation reads the captured result settings and describes max-minus-min among retained measured wells, with a strict range-exceeds flag. Counts still mean diagnostic entries, not distinct wells or biological samples.
The guide, captured-threshold explanation and conditional interval-scale explanation beside intervalHelp require a trusted calculated result with the literal pair v: 2 and method version 1.1.0. They do not inherit future QPCR_VERSION changes. Old, unsupported, refused or hash-mismatched results receive no new interpretation; existing historical tables and notices remain. Settings edits clear the result and explanations. The point plot remains limited to its first 60 values, with its existing notice, and gains no error bars. Group rows retain local biological n, exact withholding reasons, log bounds and unavailable fold endpoints. Efficiencies are treated as fixed; their uncertainty is not propagated. No schema, method pin, hash input, calculation or export of the new guide is added.
The shared calibrator-group label now reads “Withheld: this group contains a declared calibrator sample.” in the panel and existing HTML report. Calibrators are pooled by run and target, but groups are partitioned by condition as well: split-condition calibrators need not have zero group log₂ fold change. The withholding rule and numeric outputs are unchanged. A literal hand-derived regression in src/lib/assay/qpcr/qpcr.test.ts covers quantities 0 and 2 in two conditions, group log₂ differences −1 and +1, and fold changes 0.5 and 2, both still withheld. Browser coverage is e2e/assay-analyser/qpcr-interpretation.spec.ts; scientific and product rulings are in the 16 October CSO qPCR-result-reading memo and CPO methods-and-qpcr-reading memo, including the CSO calibrator amendment.
Efficiency from a dilution series and geNorm M (A-1 second pass)
src/lib/assay/qpcr/efficiency.ts (version 1.0.0; CSO ruling of 13 October 2026, docs/board/memos/2026-10-13-cso-qpcr-efficiency-reference-stability.md), shown in the qPCR mode by panels/qpcr-efficiency.tsx: a panel "Efficiency from a dilution series" after the settings, and a "geNorm M" disclosure in the results.
- Input (
parseDilutionTable): a pasted table with assay, quantity, technical and cq (optional status, excluded); "Undetermined" or an empty Cq reads as undetermined unless the status says missing; unreadable rows are listed by number. The person chooses whether the quantity column holds relative quantities or fold dilutions (d means 1/d); nothing is inferred. The series is not stored: it lives in the panel until the page is left. - Fit (
fitEfficiency, per assay): each level's Cq is the mean of its kept wells (measured, not excluded), one point per level; undetermined and missing wells are counted per level and a level with none kept is dropped and listed. Cq = a + b·log10(quantity) by least squares on the level means; E = 10^(−1/b); efficiency % = (E − 1)·100; R² descriptive. From four levels the slope's 95 percent t interval on k − 2 df, mapped to E while its upper bound is below 0 (else "upper limit not determined"); at three levels withheld. Refused: fewer than three levels, a slope at or above 0, a quantity at or below 0. No Cq cut-off and no 90–110 percent band. - E above 2 is flagged ("Above doubling per cycle …") and cannot be carried; E within 1e-9 of 2 counts as 2 (
DOUBLING_RESOLUTION: a perfect doubling series fits E = 2.0000000000000004 in binary64) and is carried as min(E, 2), soefficiencyFold's 1 < E ≤ 2 guard is unchanged. - Carry: "Use E for … in the efficiency-corrected calculation" fills that assay's existing
settings.efficienciesentry (fold convention,measured, and the source line "Dilution series: k levels, slope b, Cq range …, fitted <date>, input <hash>"), asks first when the entry already holds a value, never changes the method, and clears the result like any settings change. Only the point estimate is carried; its interval does not enter the fold-change intervals. Once carried, sample well means for that assay outside the series' Cq range are flagged, while the fitted series is still in the panel. - geNorm M (
genorm): over the declared reference assays, from the result's well summaries (log2Q = −meanCq·log2E with the E the settings used, stated), complete samples only (every reference with a kept, measured summary; others dropped and listed); V_jk = sample SD of log2Q_j − log2Q_k, M_j = mean over k ≠ j, ascending, no label, no stepwise exclusion and no cut-off. Two references show the one pairwise SD; fewer than three complete samples withhold M; one reference shows nothing. NormFinder is deferred. - Limits lines (CSO): "Fitted on mean Cq per dilution level; assumes the series behaves like your samples …" and "Ranks candidates by how constant their ratios are across these samples only …".
Binding (A-1 second pass)
The binding mode (record v6 only; CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-binding.md; the screen in docs/design/assay-stage/binding.md). Chosen from the analysis chooser ("Competition and saturation binding", group Plates and doses). The panel is panels/binding-panel.tsx (loaded on demand), copy in content/apps/assay-analyser.binding.ts.
- Input. A pasted table read by
binding-import.ts: a header row withconcentrationfirst, thentotaland optionallynonspecific(orNS) columns, numbered for replicates; every cell is one observation (BindingRowRecord: x, kind, y). A blank cell is no reading; a row with a bad value is named and not read. Up to 2,000 readings. - Saturation (
fitSaturation): specific = Bmax·L/(Kd + L), one site, and two sites beside it when asked. Non-specific binding is fitted jointly as ns·L (+ a constant held at 0 unless freed; three NS concentrations, four with the constant) or subtracted by the mean NS at each concentration (refused when any total concentration has none; never interpolated). Free radioligand is taken as the concentration added. Six concentrations for one site; two sites withheld below ten concentrations or three decades, with a Kd outside the range, or the two under tenfold apart. Flags: the highest concentration below Kd (extrapolation); specific bound over 10 percent of added when the reading and concentration units are both molar (depletion). - Competition (
fitCompetition): y = Bottom + (Top − Bottom)/(1 + (x/IC50)^nH), nH = 1 unless freed, x = 0 allowed; Bottom fitted, fixed to the NS mean or at 0; IC50 outside the tested range withheld. Two sites with F (a fraction of radioligand binding) on the logit scale in [0.01, 0.99]; no Ki for two sites or a Hill-free fit. - Ki (
src/lib/binding/ki.ts, throughcomputeBinding): Cheng–Prusoff with L and Kd typed in their own molar unit (converted to the concentration unit); Munson–Rodbard beside it when y0 or B0 is entered (y0 = B0/(L − B0)), withheld at or below zero, with the warning above y0 = 0.1. When the IC50's profile interval resolved, both Ki values carry the IC50's bounds through the equation, labelled. - Intervals. Profile 95 percent intervals on Kd and Bmax (non-specific fitted) and on IC50 (nH = 1), by
profile.ts. On the subtracted route Kd and Bmax have none ("ns-subtracted"): those intervals treat the NS mean as exact and undercovered (about 90 percent) in the oracle's simulation, so ruling 6 withholds them. One site against two by AICc and the approximate F (2 df; one site is a boundary of two), as information. - States. Every fit is estimated, withheld, bound, failed or refused; only an estimate carries constants, and
readBindingResultrefuses one that does otherwise.binding-view.tsturns a result into lines that the panel,bindingResultCsvand the Bench recording (src/apps/assay-analyser/binding-capture.ts, loaded on the Record press; schemaassay-analyser.binding-result) all read.verifyBindingResult(src/lib/assay/binding-integrity.ts) checks the result against its inputs before display, export and recording. Repeat keeps the settings, never the readings. - Verification. Methods
binding-equilibriumandbinding-ki-cheng-prusoff-munson-rodbard1.0.0, "verified" sincebinding.json(0.11.0) passed: 24 cases, R nls and drc agreeing on all 20 fits, the coverage runs as in the oracle table below. Unit tests:src/lib/binding/binding.test.ts,record.test.ts; browser:e2e/assay-analyser/binding.spec.ts.
Enzyme kinetics, growth and thermal shift
Four modes and one task page added by Wave 1 and its continuation (docs/progress/wave1-assay.md items 6 to 8, docs/progress/wave1b-assay.md items 10 to 12; specifications in the CSO's and CPO's memos of 10 October 2026). They share one pattern:
- Engine and stored model apart. Each engine is a pure module (
window.ts,michaelis-menten.ts,inhibition.ts,apparent-tm.ts); the stored shape and its strict reader (model.ts,inhibition-model.ts) carry no engine code, andcompute.tsjoins them, so the workspace frame and the Bench projections load no engine. Method records live inmethods.tsfiles with no imports. The inhibition result and comparison readers, with the settings, rows and design, are ininhibition-result.ts, whichinhibition-model.ts(the record reader, with the tight- and slow-binding reader) re-exports; the Ki task page, the CSV writers and the integrity guard import the result side only, and the kinetics units and condition keys are inkinetics/base.ts. - Inputs hashed. A result stores
inputHash, the SHA-256 of the mode's input function (kineticsInput,inhibitionInput,growthInput,thermalInput: version, source id, settings, readings, source, example and decision history; never the result). Client panels hash withsrc/lib/workspace/content-hash.ts(contentHashLight, equal tocontracts.contentHashfor valid JSON, tested bycontent-hash.test.ts), becausecontracts.tswould pull the object references and search catalogues into a task-page island. Display, export and recording first re-hash and refuse a mismatch (verifyKineticsResultand its siblings in each panel). - States, not numbers. A refused, bound, failed or withheld result stores its state and reason and carries no estimate (D-0028 item 5); readers reject an estimate field on a non-estimated state. Historical results are validated structurally and never recomputed.
- No intervals except the Michaelis–Menten profile intervals; every other result says "Interval not available" with its reason.
- Caps (readers and paste importers): initial rates 500 (
KINETICS_MAX_RATES); inhibition 500 rates and 12 inhibitor levels; growth 2,000 readings; thermal 5,000 readings and 24 conditions.
Initial-rate Michaelis–Menten
fitMichaelisMenten and profileMichaelisMenten (src/lib/kinetics/michaelis-menten.ts), method michaelis-menten-initial-rate 1.2.0 and michaelis-menten-profile-interval 1.1.0 (src/lib/kinetics/methods.ts; KINETICS_VERSION is the former).
v = Vmax · S / (Km + S)
OLS on the rates (each replicate an observation), parameters ln Vmax and ln Km on lm.ts
box: Km in [min positive S / 1000, 1000 · max S]; Vmax in [max|v| / 10⁶, 1000 · max|v|]
starts: Vmax₀ = max|v|; Km₀ = lower-median level, lowest level, highest level; lowest RSS kept
LM: 2,000 iterations, scaled-gradient tolerance 1e-8, objective tolerance (64ε)² · Σv²
correlation(Km, Vmax) from (JᵀJ)⁻¹ with the analytic Jacobian
kcat = Vmax / [E] only for a rate in M, mM, µM or nM per s or min and an enzyme in µM, nM or pM
- Refused with no numbers: a negative or non-finite substrate, a non-finite rate (
invalid); fewer than four distinct positive substrate levels (substrate-levels). - Withheld as states: a solution within 1e-4 ln units of the box (
bound, checked first); a best run that did not converge (failed). Km outside [lowest, highest] positive S keeps the estimate and is flagged (kmOutsideRange). kcat refusals:needs-volume(an amount per time orsignal/min),enzyme-unit,range. The active enzyme may be derived from a protein, sites and active fraction (Units that know the molecule). - Units: substrate M, mM, µM, nM, mg/mL, µg/mL; rate
MM_RATE_UNITS(molar or amount per s or min, andsignal/min); conditions are STRENDA's list (enzyme, substrate, temperature, pH, buffer, detection), shown as "Not stated" when empty.
Profile intervals (one at a time, on ln Km and ln Vmax, through src/lib/fit/profile.ts; ci.ts is not used and no Wald interval is formed):
threshold = RSS_min · (1 + F(0.95; 1, n − 2) / (n − 2)) F from fQuantileOneNumerator
search: outward steps 0.02, 0.04, 0.08, … ln units, the last clamped to the box;
the first step at or above the threshold brackets the crossing, found by Illinois to 1e-10
Km held: Vmax = Σ v·x / Σ x², x = S/(Km + S), clamped to its box (closed form)
Vmax held: Km refitted by lm.ts from a warm start, scaled-gradient tolerance 1e-6
Each side is a crossing (a number), search-limit (refitted-at-box: a refitted parameter reached its box first; profiled-at-box: the profiled parameter reached its box below the threshold) or refit-unresolved; an unresolved side carries no number. "Not available", with no search, for no estimate, residual variance numerically zero (RSS ≤ 1e-20·Σv²) or df < 1. Km is "poorly determined" when its interval spans more than two log10 units or a side is unresolved; the estimate stays. The kcat interval is the Vmax interval times kcat/Vmax, the enzyme concentration taken as exact. A result stored under method 1.0.0 has no intervals and reads "Interval not available: fitted before intervals were reported". Coverage was simulated by the builder (2,000 replicates per design; saturating design 0.9525 for Km and 0.950 for Vmax), so the copy says "approximate 95 percent".
Substrate inhibition, the Haldane form (A5)
fitSubstrateInhibition (src/lib/kinetics/substrate-inhibition.ts), method substrate-inhibition-haldane 1.0.0 (roadmap A5; CSO memo of 11 October 2026, item 6; architecture §6.8). Chosen by the person in the initial-rate panel's "Rate model" select (KineticsSettings.model: "michaelis-menten" | "substrate-inhibition", optional; absent is Michaelis–Menten, so older records and their input hashes are unchanged), never automatically. Inline, like the Michaelis–Menten fit: the panel calls computeKineticsWith (compute-core.ts) and imports the Haldane engine only for that model; computeKinetics (compute.ts) passes it statically for the task page and the tests.
v = Vmax·S / (Km + S·(1 + S/Ksi)), optimum S* = √(Km·Ksi)
- Ordinary least squares in (ln Vmax, ln Km, ln Ksi) on
lm.tswith an analytic Jacobian, in dimensionless coordinates; three starts (Km₀ the lower-median, lowest and highest positive S; Ksi₀ the highest S; Vmax₀ max |v|); boxes Vmax [max|v|/1e6, 1000·max|v|], Km and Ksi [min S/1000, 1000·max S]. - Refused:
invalid(a negative or non-finite value, or every rate zero);substrate-levels(fewer than six distinct positive S). - Withheld, with no constant: Vmax or Km within 1e-4 of its box (
bound, named); Ksi within 1e-4 of its box (withheld,ksi-bound); fewer than two distinct positive S above S* (withheld,no-decline, "no decline evident beyond the optimum", the count recorded); no verified convergence (failed). The panel then offers "Fit Michaelis–Menten instead", which sets the model and fits. - Reported: Vmax, Km, Ksi and S* each with "Interval not available", the count beyond S*, RSS, df = n − 3, the correlation matrix (the Km–Ksi correlation shown), residuals; and, as information only, AICc against Michaelis–Menten on the same rates (ΔAICc = AICc_MM − AICc_Haldane, K = parameters + 1) with the F of Michaelis–Menten within Haldane (F(1, n − 3), approximate: Michaelis–Menten is the boundary Ksi → ∞), withheld at an exact fit. No interval and no kcat this stage.
- Stored:
KineticsResult.substrateInhibition(present exactly when the settings chose the model;readKineticsResultrefuses either without the other, and a withheld state with a constant) beside the Michaelis–Mentenoutcomeof the same rows, which is the AICc reference and the offer. The panel's Haldane result ispanels/substrate-inhibition-section.tsx, loaded withnext/dynamiconly for that model; copy incontent/apps/assay-analyser.substrate-inhibition.ts.substrateInhibitionResultCsv(substrate-inhibition-export.ts, loaded on download) writes the Haldane row (model, Vmax, Km, Ksi, S*, state and reason, the count beyond, the Km–Ksi correlation, RSS, df, ΔAICc, F, p) when the model was chosen, never the Michaelis–Menten numbers (kineticsResultCsvrefuses such a result); the rates CSV's fitted values and residuals follow the chosen model. Recorded to Bench, a Haldane result listssubstrate-inhibition-haldaneas its method, preceded byprogress-curve-initial-rateswith the window line among the reasons when the rates came from progress curves (A6), as the Michaelis–Menten record does.
Hill and two-substrate models (A-1 second pass)
Two more choices in the initial-rate panel's "Rate model" select (KINETICS_MODELS: hill, ping-pong, sequential; CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-hill-and-bisubstrate.md), never automatic. As Haldane, the chosen fit is stored beside the Michaelis–Menten outcome of the same rows (KineticsResult.hill or .bisubstrate, present exactly when the settings chose the model; readKineticsResult refuses either way round and a non-estimated state carrying a constant). The panel loads src/lib/kinetics/hill.ts or bisubstrate.ts only for that model (computeKineticsWith's engines), and the result section panels/kinetics-models-section.tsx on demand; copy in content/apps/assay-analyser.kinetics-models.ts.
- Hill (
fitHill, methodhill-initial-rate1.0.0): v = Vmax·S^h/(S0.5^h + S^h), ln parameters, h in [0.1, 10], three starts (h₀ = 1). Refused below six substrate levels; the whole fit withheld when fewer than two levels lie below or above S0.5 (not-bracketed); any parameter at its boxbound. "S0.5", never Km. The 95 percent profile interval on h is computed and stored but shown only whenHILL_INTERVAL_SHOWNis set, in the commit that publisheshill-kinetics.jsonwith its coverage run passing (ruling H2); until then the tile reads that the interval is shown: hill-kinetics.json (0.11.0) passed its coverage run (94.6 and 95.5 percent of 2,000 replicates at h = 1 and 2.5), andHILL_INTERVAL_SHOWNis true. AICc and the ordinary F against Michaelis–Menten (h = 1 is interior) as information; kcat as Michaelis–Menten has it; no kcat/S0.5. - Two substrates (
fitBisubstrate, methodbisubstrate-initial-rate1.0.0): asubstrate bcolumn in the paste (RateRow.substrateB;substrate areads as substrate, which is A). Both ping-pong and sequential are fitted whenever either is chosen; the choice is the one reported. Refused without 12 rows with both positive, four levels of one substrate and a three-by-three grid; sequential withheld when KiA reaches its lower bound (the memo's line), judged before convergence. AICc and the approximate F of ping-pong within sequential as information; the ordered-or-random line; no intervals. The section draws rates against A, one series per level of B. - Lines, exports, recording.
kinetics-models-view.tsturns the chosen fit into lines that the section,kineticsModelsResultCsvand the Bench recording (the Hill or two-substrate branch ofcaptureKineticsWorkspaceResult, methodhill-initial-rateorbisubstrate-initial-rate) read alike;kineticsResultCsvrefuses such a result; the rates CSV's fitted values and residuals follow the chosen model and gain a Substrate B column when present. Tests:hill.test.ts,bisubstrate.test.ts,kinetics-models-record.test.ts; browsere2e/assay-analyser/kinetics-models.spec.ts.
Progress curves to initial rates (A6)
progressWellRate, progressRates and progressReadings (src/lib/kinetics/progress-rates.ts), method progress-curve-initial-rates 1.0.0 (PROGRESS_RATES_METHOD). A source inside Initial rates, not an eighth analysis (roadmap-v0-depth A6 and its information architecture; CSO memo of 11 October 2026, item 9). The panel (panels/progress-source.tsx, a disclosure "Progress curves" beside "Rates") reads a pasted timecourse with the kinetic-block reader of A1 (parseKinetic, src/lib/table/kinetic.ts; a temperature axis is refused), takes one window, the signal direction, optional ε and path length and [S]0 per well, shows each well's rate for review with a chart of the curves, the shaded window and each fitted line, and on "Use these rates for the fit" writes them as ordinary rate rows, each naming its well, so the Michaelis–Menten fit, the decisions and the exports are the same code as typed rates. Computed inline (CTO constraints memo of 11 October 2026, (d)).
window [t0, t1], inclusive, in the timecourse's unit, one for every well; nothing chooses it
reads used: those with a value inside the window (a missing read skipped, never interpolated)
withheld: a reader marker inside the window (`marker`, the reads named); fewer than 3 distinct times (`too-few-points`)
signal = a + b·t, ordinary least squares (centred sums)
rate = d · b · f, d = +1 rises, −1 falls; f = 60 (s), 1 (min), 1/60 (h) → signal/min
= d · b · f / (ε · l) · 10⁶ with ε (M⁻¹ cm⁻¹) and l (cm) both declared → µM/min
conversion % = 100 · d · b · (t_last − t_first) / (ε · l) / [S]0 (M)
only with ε, l, a molar [S]0 > 0 and a rate > 0; strained above 10 percent (a flag, not a refusal)
rows to the fit: estimated wells with a typed [S]0, in the block's order; id `well-<well>`, `well` kept
Stored as KineticsData.progress (ProgressSource: { v: 1, block, settings: { window, direction, epsilon, pathLength }, substrate }), present exactly when source.kind is "timecourse", read by readProgressSource (the block through readKineticRecord, so the same caps: up to 2,000 reads and 384 wells; a backwards window, a non-positive ε or l, or a substrate for a well the block lacks is refused). kineticsInput includes progress when present, so the result's hash covers the timecourse; a record without it hashes as before. The rate unit select is fixed while rates come from progress curves. The rates enter the fit as observations without their own uncertainty (stated in the method paragraph); there is no background (no-enzyme) slope subtraction, no integrated-rate fit and no per-well window (limits lines).
Enzyme inhibition (global fit)
fitInhibition (src/lib/kinetics/inhibition.ts), method inhibition-global-fit 1.1.0, run in inhibition.worker.ts. The person chooses the model and it stays the reported one; nothing selects a model. The four-model comparison below sets the models side by side as information only.
competitive v = Vmax·S / [Km(1 + I/Ki) + S]
uncompetitive v = Vmax·S / [Km + S(1 + I/Ki′)]
noncompetitive v = [Vmax/(1 + I/Ki)]·S / (Km + S) (pure)
mixed v = Vmax·S / [Km(1 + I/Ki) + S(1 + I/Ki′)], Ki′/Ki derived and descriptive
- Vmax and Km are shared across inhibitor levels (
shared: ["vmax", "km"]in the result). All parameters are searched as logs onlm.tswith an analytic Jacobian. Boxes: Vmax and Km as the initial-rate fit, from the inhibitor-free series; Ki and Ki′ in [lowest positive I / 1000, 1000 · highest I]. Nine starts (Km₀ and Ki₀ each the lower-median, lowest and highest level); lowest RSS kept; 500 iterations, scaled-gradient tolerance 1e-8. - Refused, with no numbers: any negative or non-finite value (
invalid); no inhibitor-free series with four positive substrate levels (no-control); fewer than two positive inhibitor levels (levels); a level with fewer than four substrate levels (level-substrates, the level named). - Withheld: any parameter within 1e-4 of its box (
bound, the parameters named, checked before termination because a constant running to its box leaves the others crawling along a flat valley); a best run without verified convergence, including a stall below the 500-iteration cap (failed). A withheld fit's RSS is not exported. - Reported: the constants of the chosen model (the mixed model always both Ki and Ki′), RSS and df = n − p, the full correlation matrix from (JᵀJ)⁻¹, residuals, and range flags for Km (substrate range) and Ki, Ki′ (inhibitor range). Ki and Ki′ carry profile intervals (below); Vmax and Km read "Interval not available".
- Inhibitor units M, mM, µM, nM, pM; conditions add
inhibitorName. The model is named on every tile, chart caption, export row and record row. - Ki and Ki′ profile intervals (A5).
profileInhibition(inhibition.ts), methodinhibition-profile-interval1.0.0 (CSO memo of 11 October 2026, item 8; architecture §6.3): one-at-a-time 95 percent profile extra-sum-of-squares bounds on ln Ki and ln Ki′ by the generic search ofsrc/lib/fit/profile.tsoninhibitionProblem(exported for it, CTO ruling 10) in the fit's dimensionless coordinates, threshold RSS_min·[1 + F(0.95; 1, n − p)/(n − p)], the other parameters refitted bylm.tsfrom a warm start (scaled-gradient tolerance 1e-6). Each side is a crossing,search-limit(refitted-at-box,profiled-at-box) orrefit-unresolved; an unresolved side carries no number. Not available, with no search, for no estimate, zero residual (RSS ≤ 1e-20·Σv²) or df < 1. "Poorly determined" when any constant's interval spans more than two log10 units or a side is unresolved; the estimate stays. Stored asInhibitionResult.intervals(additive; absent on a result stored before them, which reads "fitted before intervals were reported"), computed in the worker for the chosen model whether one model or four were fitted. Shown on the Ki and Ki′ tiles throughinhibitionIntervalView(inhibition-intervals.ts), exported in the existing interval columns with the method, df, the poorly-determined flag and the qualification appended, and recorded with the method in the Bench result. Coverage, simulated in the oracle (1,000 replicates per design): competitive Ki 93.8 percent, mixed Ki and Ki′ 94.9 percent each. No bootstrap.
Inhibition model comparison (A5)
compareInhibitionModels and compareFits (src/lib/kinetics/inhibition-compare.ts), method inhibition-model-comparison 1.0.0 (roadmap A5; CSO memo of 11 October 2026, item 5; architecture §6.2). "Compare the four models" in the result panel posts models: "all" to inhibition.worker.ts (the request field of the CTO's architecture ruling 10; "chosen", the default, is the single fit as before). The worker answers { jobId, marker, result, comparison }; computeInhibitionWithComparison (inhibition-compute.ts) fits the four models once on the kept rows, so the chosen model's result is exactly the one computeInhibition gives and the comparison never changes the reported constants.
AICc = n·ln(RSS/n) + 2K + 2K(K + 1)/(n − K − 1), K = fitted parameters + 1 (compare.ts aicc)
ΔAICc = AICc − lowest AICc among the estimated fits
F = (RSS_r − RSS_mixed) / (RSS_mixed/(n − 4)), F(1, n − 4), for r in {competitive, uncompetitive, noncompetitive}
- Ranked: the
estimatedfits by AICc, ties in the model order. Outside the ranking, with their state and no number:bound(the constants at the box named),failedandrefused; the panel states the count. - F only where nested and only when both fits are estimated. Competitive and uncompetitive are mixed with one constant at infinity, a boundary of the parameter space, so their p carries
boundary: trueand the copy says "approximate"; noncompetitive (Ki = Ki′) is a regular constraint. No F between the one-constant models. - Withheld as a state:
zero-residualwhen any estimated fit's RSS ≤ 1e-20·Σv² (an AICc on an exact fit is not information);refusedwith the refusal when every model is. - Stored as
InhibitionData.comparison({ v: 1, version, at, inputHash, settings, rows, outcome }; additive on the v5/v6 record, which an older reader passes through unread, architecture §2.4).readInhibitionComparisonrefuses a number on a model outside the ranking, a ranking that is not the estimated models in AICc order, a non-zero ΔAICc on the first, or an F with the wrong degrees of freedom or boundary flag.verifyInhibitionComparison(src/lib/assay/inhibition-integrity.ts, re-exported byresult-integrity.ts) re-hashes the inputs and checks the kept rows and settings before the table is shown or exported; any change to the rates, decisions or settings clears it with the result. - Copy (
content/apps/assay-analyser.inhibition-compare.ts, apart from the inhibition copy whose wording test forbids comparison words): the CSO's sentence "Lowest AICc among the fitted models on these data; a difference under 2 does not discriminate." beside "Your chosen model, …, stays the reported one."; never "mechanism: …" or "the inhibitor is …". Exportinhibition-comparison.csv(inhibitionComparisonCsv): one row per model, ranked then outside, numbers only for estimated models, the F's approximate flag, the sentence, the method version and the input hash.
Tight binding and slow binding (A-1 second pass)
Two choices added to the inhibition panel's model select (CSO memo of 13 October 2026, docs/board/memos/2026-10-13-cso-morrison-and-slow-binding.md), never automatic. They do not touch the four-model path: choosing one stores InhibitionData.special (src/lib/kinetics/inhibition-special.ts: the model, its own settings, and its own result with the hash of specialInput, the four-model inputs plus the special settings), additive on the v5 inhibition record and read strictly by readSpecial from readInhibitionData. Choosing one of the four models again removes it. The section panels/inhibition-special-section.tsx (on demand) holds the model's settings, Fit, the result and the export; it replaces the four-model run and result while chosen. Copy in content/apps/assay-analyser.inhibition-special.ts.
- Morrison (
fitMorrison, methodmorrison-tight-binding1.0.0): the rows at one substrate concentration with inhibitor-free controls; [E]T typed with its unit (converted to the inhibitor's unit) and optionally fitted in [E/100, 100 E]. The rationalised quadratic; v0 and Ki_app by bounded LM. Refusals named (substrate levels with the count, controls, eight levels or ten with [E] freed with the count, two levels below [E], the top level at least 2 [E]). Withheld: the titration regime (Ki_app under [E]/100; the fitted [E] still given) and a midpoint the levels do not bracket. Information: Ki_app over 10 [E]T; with [E] freed, the fold from the typed value (shown beyond 2-fold), the [E]–Ki_app correlation, and the fitted [E] withheld when Ki_app exceeds ten times it. Ki only throughmorrisonKion the mechanism the user declares, with Km and [S] typed; mixed refused. No interval. - Slow binding (
fitSlowBinding, methodsslow-binding-one-stepandslow-binding-two-step1.0.1): a paste ofinhibitor, kobs(read as rates with substrate 0;inhibition-import.ts), kobs in s⁻¹ or min⁻¹. Both models fitted; the person chooses which is reported. One step: OLS with exact t-intervals on kon_app and koff; koff reported only when its interval lies above zero, else "koff-zero" or "negative-intercept"; Ki_app = koff/kon_app beside a reported koff. Two step: k5, Ki_app and k6 ≥ 0 with Ki*; withheld as "no curvature evident" (the line offered); k6 at zero withholds k6 and Ki*. SCI-198 (25 October 2026): before the OLS direction test,noSpread(kobs)refuses both models asnot-risingwhen max − min ≤ 2·n·ε·max|kobs|, the existing CSO rounding bound, with no absolute floor. Invalid-input and minimum-level gates retain precedence; the two-step all-zero case remainsinvalid. Falling kobs (slope negative, p < 0.05) refused for both asfalling; any other slope at or below 0 refused for both asnot-rising("kobs does not rise with [I] in these values; no positive kon,app can be estimated.", the CSO's sentence of 20 October 2026), never a negative kon_app or Ki_app. AICc only. With Km and [S] typed, the competition-corrected kon, Ki and Ki*. New slow-binding results are stamped 1.0.1; Morrison remains 1.0.0.specialVersion(model)derives the current stamp from the method records, andverifySpecialResultrequires that stamp as well as matching model, rows and input hash before current display, export or capture. Historical 1.0.0 slow results still read unchanged and remain in immutable evidence; the editable panel asks for a refit instead of relabelling or recomputing them automatically. - Lines, export, recording.
inhibition-special-view.tsgives the lines the section,specialResultCsvand the Bench recording (src/apps/assay-analyser/inhibition-special-capture.ts, loaded on the Record press; schemaassay-analyser.inhibition-special-result) read alike;verifySpecialResult(src/lib/assay/inhibition-special-integrity.ts) checks the result against its inputs first. Repeat keeps the special settings. Tests:morrison-slow.test.ts,inhibition-special.test.ts; browsere2e/assay-analyser/inhibition-special.spec.ts.
Ki from IC50 (Cheng–Prusoff)
kiFromIc50 (src/lib/kinetics/ki.ts), method ki-cheng-prusoff-competitive 1.0.0, on the task page ki-from-ic50 only.
Ki = IC50 / (1 + [S]/Km) competitive inhibition only; Ki in the IC50's unit
- The IC50's bounds through the equation (A5).
kiBounds(ki.ts), methodki-cheng-prusoff-bounds1.0.0 (CSO memo of 11 October 2026, item 8): when the person types the IC50 interval, in the IC50's unit, both ends or neither, each end is divided by the same factor 1 + [S]/Km and shown labelled "The IC50's bounds through the equation; Km and [S] treated as exact." Refused with no bounds, the Ki kept: one end only (bounds-incomplete), an end not a positive number (bounds-range), the IC50 outside the ends (bounds-order). A refused Ki gives no bounds. The interval's level is the IC50 interval's; nothing claims one.
IC50, [S] and Km are typed by the person; units M to pM or mg/mL, µg/mL, ng/mL. Refused with no number: an incomplete set (incomplete), [S] and Km in different dimensions, never converted (units), a non-positive IC50 or Km, a negative [S] or a non-finite result (range). Never fed from a fitted curve; the island has no link into the app and nothing in the app links to the page.
Growth: selected window
growthWindow (src/lib/growth/window.ts), method growth-window-rate 1.0.0.
blank = mean of the kept blank readings (0 when there are none)
corrected = response − blank, subtracted before the log
in [t0, t1] (both ends included), kept sample readings: ln(corrected) = a + μ·t by OLS
doubling time = ln 2 / μ, only when μ > 0
Withheld with the rows named: any kept corrected reading in the window not above zero beyond rounding (nonpositive; notAbove(y − blank, n_blank + 1, max(|y|, max|blank|)), so a sample equal to six blanks of 0.05 refuses, where it gave μ = 0 before); fewer than three distinct times (too-few-points); a non-finite window or reading, or t1 < t0 (invalid). Time unit h or min. Residuals and n are reported; no interval ("possible serial correlation; descriptive only"). It is not μmax, a lag or a carrying capacity; the full-curve fit below reports its own rate and lag beside it, never in place of it.
Growth: full-curve fit (modified Gompertz)
fitGompertz (src/lib/growth/gompertz.ts), method growth-gompertz-fit 1.0.0 (Stage A, A6). The rules are the CSO's ruling of 12 October 2026, "A6 Gompertz", given to this builder in lean mode and recorded here. Settings: GrowthSettings.model "window" (as before) or "gompertz" (the full fit, with the window still calculated beside it when one is typed).
blank = mean of the kept blank readings (the window method's matched blank)
N0 = mean corrected response at the earliest kept time t_min
y = ln((response − blank) / N0) every kept sample reading; the window plays no part
model y = A·exp(−exp(μm·e/A·(λ − t) + 1)) Zwietering et al. 1990; A is a log increase, never a carrying capacity
box A ∈ [1e−9·A_hi, A_hi], A_hi = 3·max y; μm ∈ [1e−9·μ_hi, μ_hi], μ_hi = 10 × the steepest two-point slope of the per-time mean y;
λ ∈ [t_min − span, t_max]; "on its bound" = within 1e−6 of the box width
fit unweighted bounded LM (src/lib/fit/lm.ts) from 20 fixed starts (A ∈ {max y, 1.5 max y} × μm ∈ {s, s/2} × λ at t_min + k·span/4),
lowest RSS; a parameter on its bound is then held and the others polished alone (lm.ts clamps a full step, which bends the free ones)
Withheld, the whole fit: any kept sample reading not above the blank beyond rounding (nonpositive, rows named; the window's test); fewer than six distinct times (too-few-times); no increase, max y ≤ 0 or no positive two-point slope (no-growth); a non-finite reading (invalid); a best run without the solver's convergence flag while any parameter would be reported (not-converged, the suite's rule under "Numerical scale"). Per parameter, with no number:
no-plateauwithholds A, μm and λ. It applies when A is on its upper bound or the inflection λ + A/(e·μm) comes after the last reading, because before the inflection the rate and the lag are extrapolated. It is the CSO's red line, and the reader refuses a fit that reports any of the three under it.plateau-not-reachedwithholds A when the fitted y(t_max) is below 0.95·A. The 0.95 is the CSO's judgement, not a published threshold.no-lagwithholds λ when λ ≤ t_min, λ is on its lower bound, or fewer than two kept times fall at or before λ. The ruling amends architecture §6.4's "at or below zero".rate-on-boundwithholds μm and λ when μm is on its upper bound. This one is the builder's rule.
No interval this stage; residuals, n, RSS, N0 and its time are reported. The panel labels μm "Maximum specific rate from the Gompertz fit (ln units per time unit)" and shows it beside the window μ, never merged; the chart draws the fitted curve only when all three parameters are reported.
Worker. gompertz.worker.ts, one job per instance (marker drybench-worker:assay-growth, the CTO's ruling 10, 15 KB): { jobId, data, inputHash, at } in, { jobId, result } or { jobId, error: true } out. The panel terminates it on the answer, on cancel or on any change to the readings or settings, and keeps a result only when the job id, the input hash, the engine version and readGompertzFit all agree.
Record. GrowthData.v is 2 when it carries settings.model and fit (a GompertzFit: v 1, version, at, inputHash = the hash of growthInput, outcome), under the CTO's architecture ruling 1 on sub-records. A v2 growth record is accepted only on a v6 assay record (mode-record at growth.v). v1 is read as before and may carry neither field. The window result is unchanged, so Bioreactor's measured-growth review, which recomputes it, is unaffected. emptyGrowth writes v2; a repeat of a pre-v6 record keeps v1. verifyGompertzFit (src/lib/growth/gompertz-integrity.ts) checks the hash, the kept sample rows and the engine version before display and export. The fit's section (panels/growth-gompertz.tsx, its words in content/apps/assay-analyser.growth-gompertz.ts, its CSV in gompertz-export.ts) loads with next/dynamic only when the model is "gompertz" or a fit is stored, so the growth task island and Bioreactor's review do not carry it (sizes on 13 October 2026: the growth island 38.1 of 40 KB; Bioreactor's workspace chunk 136.0 of 136 KB, at its ceiling, of which the growth readers are about 0.9 KB and a chunk split the rest). The section's "Full-curve fit CSV" writes one row per parameter: a withheld parameter carries its status and reason and the word "withheld", never a number.
Wave 8 B1 adds a lazy Export reviewed data for Bioreactor action to the
growth panel. It captures the original GrowthData with its source-input
hash, repeats, decisions, units and historical empirical receipt in
drybench.growth-review v1. A matching saved analysis supplies its actual
name, reference and revision; a carried or edited draft without a matching
named checkpoint is labelled as a working draft. The receiver retains the
source receipt as historical and requires explicit recalculation for a new
current window receipt. Missing measurement context remains unknown.
The existing growth kernel, method version and oracle are unchanged.
See Bioreactor's B1 evidence contract.
This implementation has narrow unit evidence; its build/size/browser gates
are pending, so the new action is not yet accepted as a completed wave.
Thermal shift: apparent Tm
apparentTm (src/lib/thermal/apparent-tm.ts), method thermal-shift-apparent-tm 1.1.0, applied per condition by computeThermal (compute.ts) on the kept readings in row order inside that condition's typed window. Two estimates, both always reported:
(a) F(T) = F_lo + (F_hi − F_lo) / (1 + exp((Tm − T)/k)), OLS on lm.ts in (F_lo, F_hi, Tm, ln k)
box: F in [min F − span, max F + span]; Tm across the window's points; k in [median step/100, window span/2]
starts: Tm₀ at 1/6, 1/2, 5/6 of the window, F₀ the end readings, k₀ = span/20; 2,000 iterations
(b) the temperature of the first maximum of the three-point quadratic-interpolant derivative,
signed by the declared direction (rises or falls); no smoothing; resolution half the larger adjacent step
disagreement flag when |Tm(a) − Tm(b)| > max(1 °C, 2 · median step); both values kept
ΔTm = Tm(condition) − Tm(reference), per method, only between two estimated curves (thermalDeltas)
Refused with no Tm, in this order: temperatures not finite or not strictly increasing (temperatures); fewer than 10 points in the window (points); |F_hi − F_lo| < 5 × the residual SD √(RSS/(n − 4)) (no-transition); a search without verified convergence (failed); a second derivative maximum at least 25 percent of the primary, more than two points away, past a dip below half of it (two-transitions); an amplitude against the declared direction (sign); ln k within 1e-4 of its box (k-bound); Tm within half an end step of the window or outside it (edge); fewer than two points beyond Tm ± 2k on either side (unbracketed). Nothing is a thermodynamic quantity: no Kd, no ranking. One melt per condition; replicate wells are entered as separate conditions and their readings are never pooled. Settings: direction, signal label, one window per condition, the reference condition, and conditions (dye, protein, ligand, buffer, scan rate, instrument). The method cites Niesen, Berglund and Vedadi 2007 (record and abstract read; full text not read).
Uncertainty on the sigmoid Tm (A5). src/lib/thermal/tm-intervals.ts, method thermal-shift-tm-intervals 1.0.0 (CSO memo of 11 October 2026, item 8), in order of precedence:
- Replicate wells first.
ThermalSettings.replicates(condition → group label, typed in "Replicate group" beside each window; absent when none, so older settings and their hashes are unchanged) groups conditions as replicate wells.replicateSummariesgives, per group, the mean and sample SD (n − 1) of the sigmoid Tm over the wells with an apparent Tm, n those wells, and the refused wells named and not counted; one well gives its Tm and no SD (one-well); none gives no mean (no-estimate). Stored asThermalResult.replicates, present exactly when groups are declared; shown first in the result; exported per well (group, mean, SD, n, wells not counted) and in each condition's recorded row. No "±" glyph on any thermal surface (the CSO's merge condition of 10 October 2026): "mean … °C, SD … °C, n = … wells". - No single-well interval; a residual diagnostic.
tmProfileprofiles one melt's sigmoid Tm (dimensionless coordinates, threshold RSS_min·[1 + F(0.95; 1, n − 4)/(n − 4)]) and is tested against the oracle, but nothing displays, stores or exports it: the CSO ruled the single-well interval always withheld (13 October 2026, on SCI-39), because its coverage collapses when adjacent readings are correlated and a residual check (withhold when n < 20 or r1 > 1.645/√n) still let undercovering intervals through at ρ 0.5. The coverage table inthermal-tm-intervals.json(independent, AR(1) ρ 0.2, 0.5 and 0.9, and a sloping-baseline misfit, 1,000 melts each: 96.5 percent of independent melts, 89.1 percent at AR(1) ρ 0.2, 73.6 percent at ρ 0.5, 33.6 percent at ρ 0.9 and 0.0 percent with a sloping baseline) is the recorded reason. The panel shows the CSO's sentence in its place ("… Use the spread across replicate wells.", or "… Declare replicate wells to see their spread." with no groups), and under each estimated Tm the residuals' lag-1 autocorrelation (residualCheck: r1 in reading order, the reference 1.645/√n and n;ThermalCurve.residuals), with no pass or fail wording. r1 is null, shown as "not defined: the readings lie on the fitted sigmoid", at a zero residual judged astmProfilejudges it (RSS ≤ 1e−20·Σ on (F − F_min)/(F_max − F_min),zeroResidual): a noiseless melt no longer shows the lag-1 correlation of rounding error (r1 0.72 before). Exports leave the interval bounds empty, never zero, and add r1 and its reference. The derivative extremum keeps its sampling resolution. No bootstrap.
Column statistics (A-2 family 1)
Ruled by the CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-column-statistics.md); the engine is src/lib/stats/.
Input. The shared data grid (src/lib/grid/, DS §6.34), a columns grid: one column per group or condition, typed, or a whole table pasted through the grid's reader (readPastedTable, with its statement line). Numbers are read by the grid's comma rule (src/lib/grid/number.ts, the CSO's addendum of 13 October 2026: unambiguous thousands and decimal-comma forms accepted, "1,234" settled only by evidence in the same paste or kept as text, spaces only between groups of three digits, U+2212 normalised; each reading counted on the statement line). A cell is a value, blank (missing, never zero), text (kept as written, read as missing, listed by column and row with the CSO's line on detection limits) or excluded (kept, never read). n counts values only; blank, text and excluded are shown beside it, blanks counted only to the last row the grid uses (columnsFromGrid). Paired analyses pair by row; a row with one side blank, text or excluded is dropped and listed with each side's reason ("row 7: High dose excluded"); a row blank on both sides is not a row.
The declaration. Inference needs the scientist to declare each value an independent replicate (ruling 1). Undeclared or technical: descriptives only, and SEM, every interval, test and effect size are withheld as UNIT_NOT_INDEPENDENT. n is the count of numeric cells as declared.
Numbers and their floors.
| Statistic | Definition | Floor and withholding |
|---|---|---|
| Mean, median, min, max | Mean with a second pass about the first; median as type 7 | n ≥ 1 |
| SD, CV%, geometric SD factor | n − 1 denominator, two-pass; CV = 100·SD/mean; exp(sd(ln x)) | n ≥ 2; CV and geometric statistics withheld at any value ≤ 0 (NONPOSITIVE_VALUES) |
| Geometric mean | exp(mean(ln x)) | n ≥ 1; values > 0 |
| SEM | SD/√n | n ≥ 3, declared independent |
| Q1, Q3, IQR | R's quantile(type = 7) |
n ≥ 4 |
| Interval on the mean | mean ± t(1 − α/2, n − 1)·SD/√n | n ≥ 3; SD > 0 (ZERO_VARIANCE_GROUP) |
| Interval on the median | [x(k), x(n−k+1)], k the largest with 1 − 2·Bin(k − 1; n, ½) ≥ L; achieved coverage stated | MEDIAN_CI_N when no k reaches L (n < 5 at 90, 6 at 95, 8 at 99 percent) |
| One-sample t | (mean − μ₀)/(SD/√n), df n − 1; μ₀ typed, no default | n ≥ 3; SD > 0 |
| Two-sample t, B − A | Welch (default; Welch–Satterthwaite df) or Student (pooled), chosen by the scientist; no pre-test, no switch | each n ≥ 3; each SD > 0 |
| Paired t | on B − A by row | ≥ 3 pairs; SD of differences > 0 |
| Rank-sum (Mann–Whitney) | R's W = rank sum of B − n_B(n_B + 1)/2, midranks; exact when both n < 50 without ties, else the normal approximation with continuity correction and tie-corrected variance | each n ≥ 3; ALL_TIED |
| Signed-rank | R's V = sum of the positive ranks of x − μ₀ or B − A; zeros dropped and counted; exact when n < 50 without ties or zeros | ≥ 3 non-zero differences; ALL_TIED |
| Hodges–Lehmann | Median of Walsh averages (one-sample, paired) or of pairwise differences (two-sample), with R's exact interval (conf.int = TRUE, exact = TRUE) and achieved coverage |
HL_TIES with ties or zeros; HL_N_LIMIT above n = 1000 (signed-rank) or n_A·n_B = 10,000 (rank-sum; the count table grows as (n_A·n_B)²) |
| Effect sizes | Cohen's d (pooled SD), Hedges' g = J·d with J = Γ(df/2)/(√(df/2)·Γ((df − 1)/2)), d_z, rank-biserial 2W/(n_A n_B) − 1 or 2V/S − 1; point estimates only | as the t-test |
| Shapiro–Wilk | Royston's AS R94 (W, p; exact p at n = 3), Q-Q positions from ppoints(n); a description, never a gate |
3 ≤ n ≤ 5000 (SW_N_RANGE); ZERO_VARIANCE |
All tests are two-sided; one level (90, 95 or 99 percent, default 95) governs every interval. The p-value is shown to two significant figures, "< 0.0001" below that; no stars and no verdict words (ruling 11).
Verification. pipeline/assay-analyser/oracles/column_statistics.py with r/column_statistics.R (base R t.test, wilcox.test, shapiro.test, quantile, pbinom, psignrank, pwilcox; SciPy cross-checks) writes column-statistics.json: 50 cases covering the memo's twenty and the addendum's comma rule (the script reads each paste by the CSO's rule written independently of the grid's code, and the fixture carries the expected statement-line counts), every number from R; the test reads each case through the grid as a person's paste is read. The fixture was recorded in the pinned oracle image on 13 October 2026 (run 38419861435) and published as 0.11.0; src/lib/stats/column-statistics-oracle.test.ts passes on all 50 cases, and the panel's method line reads "Checked against R on 50 published cases (verification set 0.11.0)". column-statistics.test.ts holds the hand cases that need no fixture.
Several groups (A-2 family 2) and the false discovery rate (A-2 family 6)
Ruled by the CSO's memos of 13 October 2026 (docs/board/memos/2026-10-13-cso-groups.md, 2026-10-13-cso-fdr.md); engines in src/lib/stats/ (groups.ts, models.ts, multiple.ts, p-adjust.ts, fdr.ts, groups-analysis.ts). Every family 1 convention carries over (the declaration gate, two-sided, withholding codes, p display).
| Analysis | Grid shape | Definition | Withheld |
|---|---|---|---|
| One-way ANOVA | columns |
Welch (oneway.test(var.equal = FALSE), default) or classical (aov); η², ω² as point estimates |
GROUPS_TOO_FEW (k < 3), N_TOO_SMALL (any n < 3), ZERO_VARIANCE_GROUP |
| Post-hoc | columns |
Tukey–Kramer and Games–Howell (studentized range; p and simultaneous intervals), Dunnett against a chosen control (many-to-one t with λ_i = √(n_i/(n_i + n_0))), Holm, Šidák and Bonferroni on pooled-SD (classical) or Welch t (Bonferroni and Šidák with simultaneous intervals, Holm without); all pairs or against a control; never gated on the omnibus | CONTROL_NOT_CHOSEN |
| Kruskal–Wallis, Dunn | columns |
kruskal.test (tie-corrected χ²); Dunn on the pooled midranks, Holm (default), Bonferroni, Šidák |
ALL_TIED |
| Two-way ANOVA | grouped (rows A, column groups B, sub-columns replicates) |
Interaction always fitted; Type III with sum contrasts (default) or Type II, each SS as the rise in RSS when the term leaves the model (Householder QR); simple effects of B within each A on the full model's error | EMPTY_CELL, CELL_N_TOO_SMALL (n < 2), SINGULAR_DESIGN; ZERO_VARIANCE_GROUP when no cell spreads beyond rounding (noSpread on every cell; one constant cell among varied ones still calculates), for the ANOVA and its simple effects |
| Repeated measures | columns, rows matched |
Complete subjects: classic F with Greenhouse–Geisser (default) or Huynh–Feldt (ε capped at 1), car's formulas; none when the error SSP is singular (fewer complete subjects than levels); paired t with Holm after. Any incomplete subject: the random-intercept REML mixed model (profiled over the variance ratio, golden-section on log γ, boundary at 0 noted), F with the containment df N − S − (k − 1), level means with SE | LEVEL_EMPTY, SUBJECT_TOO_FEW_OBS; the mixed model ZERO_VARIANCE_GROUP when its REML residual q ≤ 1e−20·Σy² (zeroResidual) |
| Friedman | columns, rows matched |
friedman.test on complete rows (incomplete rows dropped and listed), pairwise signed-rank with Holm |
N_TOO_SMALL (< 5 complete rows), ALL_TIED |
| False discovery rate | columns, one p column, row labels as test names |
R's p.adjust BH (default), BY, Holm, Bonferroni step for step; the count at 0.01, 0.05 or 0.10 with ≤ |
P_OUT_OF_RANGE (never clipped), P_TEXT_CELL, M_TOO_SMALL (m < 2) |
The studentized range and the many-to-one t are integrated in the browser by composite Gauss–Legendre quadrature (multiple.ts, QUADRATURE), deterministic, with quantiles by an Illinois root-find; R's ptukey loses accuracy far in the tail (about 1e-6 absolute), so the oracle's primary Tukey and Games–Howell p is R's own integrate() of the same integral, with ptukey and TukeyHSD as a cross-check at 1e-5. Oracles: groups.py with r/groups.R (22 cases; stats, car, multcomp with a recorded seed for the glht cross-check, nlme) and fdr.py with r/fdr.R (10 cases), the scripts write under oracles/fixtures/family-2-groups/ and family-6-fdr/, and the recorded files are committed at the top level (groups.json, fdr.json), where publish.py reads them. Recorded in the pinned image on 13 October 2026 (runs 38419873682 and 38419868024) and published as 0.11.0; the oracle tests pass on all 22 and 10 cases. The groups panel's method line reads "Checked against R on 22 published cases (verification set 0.11.0)".
Outlier candidates and power (A-2 family 7)
Ruled by the CSO's memos of 13 October 2026 (docs/board/memos/2026-10-13-cso-outlier-candidates.md, docs/board/memos/2026-10-13-cso-power-and-sample-size.md); the engines are src/lib/stats/outliers.ts, noncentral.ts and power.ts.
Outlier candidates (columns of values; the fit-based candidates are A5's own). Two frames the person chooses, never shown together:
| Frame | Statistic | Floor and withholding |
|---|---|---|
| Each column alone (default) | Grubbs, two-sided: G = max|x − x̄|/s; t_G² = n(n − 2)G²/((n − 1)² − nG²); p = min(1, 2n·P(T > t_G)) on n − 2 df (the Bonferroni bound, equal to car::outlierTest on the mean model; never the outliers package's folded two-sided p); critical G at 0.05 = ((n − 1)/√n)·√(t²/(n − 2 + t²)), t = t(1 − 0.05/(2n), n − 2). One test per column, never iterated; tied extremes name every row |
n ≥ 6 (GRUBBS_N); NO_SPREAD; n − 1 equal values give p = 0, shown "< 0.0001" |
| Columns as groups | Externally studentised residuals from the one-way model: h = 1/n_g, residual variance without the value, df N − k − 1; Bonferroni over N | k ≥ 2 (GROUP_COUNT); every n_g ≥ 3 (GROUP_N, naming the group); N − k ≥ 6 (RESIDUAL_DF); NO_SPREAD |
The criterion is fixed: a candidate when p (or the adjusted p) < 0.05; there is no α to choose. Nothing is removed and n is as declared; excluding is the grid's own "excluded" state, set by the person, after which the section says the screening is a new test. Screening is not gated by the unit declaration, which is shown beside the result. The words are "Candidate" and "Not a candidate", never "outlier".
Power and sample size (planning; no data). Two-sided tests at α 0.01, 0.05 (default) or 0.10:
| Test | Power at n per group |
|---|---|
| One-sample, two-sample, paired t | P(T′ > t(1 − α/2, ν)), ν = s(n − 1), noncentrality √(n/s)·δ/σ, s = 2 two-sample else 1 (strict = FALSE; paired uses the SD of the differences) |
| One-way ANOVA | P(F′ > F(1 − α; k − 1, k(n − 1))), noncentrality (k − 1)·n·var(means)/σ²; k from 2 to 10 |
| Two proportions | Φ((√n·|p1 − p2| − z(1 − α/2)·√((p1 + p2)(1 − (p1 + p2)/2)))/√(p1(1 − p1) + p2(1 − p2))), no continuity correction |
For a target power (0.80, 0.85, 0.90 or 0.95) n is solved by bisection on [2, 1e7] to 1e-10, and the reported n is the smallest integer whose power reaches the target, checked at n and n − 1, raised to the floor (3 for t and ANOVA, 2 for proportions; atFloor is shown with the computed n). Withheld: INPUT_INVALID, EFFECT_ZERO, POWER_UNREACHABLE (more than 1e7 per group). Flags: the noncentrality above 37.62 (the noncentral t falls back to Abramowitz and Stegun 26.7.10, as the reference does; power reads "> 99.9%") and PROP_APPROX_WEAK (n·min(p1, 1 − p1, p2, 1 − p2) < 5). The noncentral t also uses that approximation above 4e5 df. Observed power is never computed; the difference is always typed, never pre-filled from a result. The answer carries a read-only planning note in the CSO's words, for a Calc plan; the pre-submission check's "What would tighten this?" (A-4 item 3) calls solvePower and the copy's note.text and follows the memo's ruling 6 (no observed difference as the planning value, no top-up).
Verification. pipeline/assay-analyser/oracles/column_outliers.py with r/column_outliers.R (outliers::grubbs.test for G, car::outlierTest and rstudent(lm) for p and the residuals; SciPy and NumPy cross-checks; the package's folded p recorded as a divergence where 2·res > 1) writes column-outliers.json, 21 cases; power_sample_size.py with r/power_sample_size.R (power.t.test, power.anova.test, power.prop.test, n by uniroot at tol = 1e-10, strict = TRUE recorded; SciPy nct, ncf cross-checks) writes power-sample-size.json, 26 cases. src/lib/stats/outliers-power-oracle.test.ts compares the engines at the memos' tolerances; both fixtures were recorded by the Oracles workflow in the pinned image on 13 October 2026 and published as 0.11.0 (verification sets column-outliers and power-sample-size). The outlier cross-check agrees on 80 of 84: car::outlierTest reports an unbounded n·p (1.07, 3.52) where the bound is 1 or the residual infinite, and loses digits on the 1e9-offset case through lm; the engine and fixture follow the bound and grubbs.test's G, which agree. Hand cases in outliers-power.test.ts (R's documented help-page values, the mean-model identity, the floors and codes) and power-record.test.ts run meanwhile.
Time to event (A-2 family 4)
Ruled by the CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-survival.md, with two dated addenda correcting ruling 4's bands and ruling 5's Gehan identity); the engine is src/lib/stats/survival.ts.
Input. An xy data grid: X is time (a unit stated or "time unit not stated"), each Y group one group of subjects, one subject per row with its status in its own group's column: exactly 1 (event) or 0 (censored). Refused and listed by row, never coerced: a status other than 0 or 1 (R's 1/2 coding included), text such as "dead", a status with a blank, text ("120+") or negative time, a time with no status, a status in two groups, an excluded status. Time 0 is allowed.
The declaration. "Each row is one independent subject" opens the band, the median's interval, the test and the hazard ratio (UNIT_NOT_INDEPENDENT otherwise); the curve, counts, numbers at risk and the median point always show.
| Number | Definition | Withheld |
|---|---|---|
| S(t) | Kaplan–Meier, Π(1 − d/n) at each distinct time, events before censorings at a tie | never |
| se | Greenwood's √Σ d/(n(n − d)) on the log S scale (survfit's std.err) |
infinite at n = d |
| Band | log-log: S^exp(±z·se/log S); one level for the panel | undefined at S = 1 or 0 (a gap); gated; n < 3 N_TOO_SMALL |
| Median | the first event time with S ≤ 0.5 (tolerance √ε), the midpoint of a flat stretch at exactly 0.5; "not reached" in words | never (a state) |
| Median interval | where the lower and the upper band reach 0.5 (on a band that is not monotone, the point at or below 0.5 with the largest band value, as R) | gated; bound "not reached" |
| Log-rank | Σ(O − E) with the hypergeometric covariance, first group dropped, groups with E > 0; χ² on k − 1 df | ONE_GROUP, NO_EVENTS_TOTAL, N_TOO_SMALL; NO_COMPARABLE_RISK when fewer than two groups contribute expected events (df = 0, SCI-197), without hiding their curves or changing the hazard ratio's NO_EVENTS_GROUP; ZERO_VARIANCE when the used groups' covariance is not positive definite, decided exactly on the counts (riskSetsJoin: the risk sets of the event times with n > 1 and d < n must join every group with E > 0), e.g. every subject dying at one time ("No subject remained at risk after an event time, so the groups cannot be compared."); few-events line below 10 events |
| Gehan–Breslow–Wilcoxon | the same with weights n_j | as the log-rank |
| Hazard ratio | exp((O − E)/V) for the second group against the reference, interval exp(· ± z/√V) | HR_TWO_GROUPS above two groups, NO_EVENTS_GROUP; ZERO_VARIANCE with the test |
The chart (src/lib/figure/survival.ts, layoutSurvivalFigure) draws steps from (0, 1) to each group's last time, censor ticks, the band as dashed step edges when the person shows it, and numbers at risk at each labelled tick (subjects with time ≥ t). The panel measures its figure (a ResizeObserver) and lays the chart out at its shown width, up to 170 mm, so no text falls under the 11 px screen floor at 375 px; the risk table's group names end half the widest first count plus half an em left of the axis (the CPO's design-check ruling, 14 October 2026). Verification: survival.py with r/survival.R (34 cases) writes survival.json, published at 0.11.0; src/lib/stats/survival-oracle.test.ts passes on all 34 cases, and the four methods (kaplanMeier, medianSurvival, survivalTests, hazardRatio) are verified against it (13 October 2026; their notes in assay-analyser.verification.ts, the panel's method line "Checked against R on 34 published cases"). A band edge need not fall between events; there R's quantile.survfit, and so the engine, takes the point at or below 0.5 with the largest band value (case level-99: R's lower limit 2, not the first time the band reaches 0.5, 1; SCI-100, the CSO's addendum). The fixture also settled the case the memo left to R: where S stays at 0.5 to the end of follow-up, the median is the midpoint with the last observed time. survival.test.ts holds the hand cases and the published survival::aml log-rank; src/lib/figure/survival.test.ts the chart's layout.
SCI-197's comparison admission change is survival-tests 1.0.1; Kaplan–Meier, median survival and hazard ratio stay at 1.0.0. The independent supplemental publication 0.17.0 (survival-slow-degeneracy-v1.json) adds 14 exact-rational survival cases and 15 slow-binding cases. Its Python 3.12.14 standard-library reference computes risk sets and hypergeometric covariance exactly, with finite one-df p-values from erfc; it does not claim a new R or SciPy cross-check. survival-slow-degeneracy-oracle.test.ts compares the new codes, unchanged descriptive outputs, refusal precedence and numerical controls. Existing 0.11.0 and 0.16.0 references are unchanged.
Counts and proportions (A-2 family 5)
Ruled by the CSO's memo of 13 October 2026 (docs/board/memos/2026-10-13-cso-counts.md); the engines are src/lib/stats/contingency.ts and counts.ts.
Input. A columns data grid with row labels: rows are groups (or the first member of a pair, or ordered groups), columns outcomes. Every cell must be a whole number of 0 or more; a blank, text, excluded, non-integer or negative cell is listed and stops the analysis (COUNT_REFUSED). 2 to 10 rows and columns (TABLE_SIZE).
The declarations. The unit (each count a distinct subject counted once) gates every test and interval (UNIT_NOT_INDEPENDENT); the design (cohort or trial, cross-sectional, case-control, matched pairs) is never inferred (DESIGN_UNDECLARED): matched pairs opens McNemar only; relative risk, risk difference and row intervals need a cohort or cross-sectional design (DESIGN_RR).
| Analysis | Numbers | Withheld |
|---|---|---|
| Compare proportions | the selected test only (Fisher pre-selected on 2 × 2, chi-squared above): Pearson X² on (r − 1)(c − 1) df and, on 2 × 2, Yates with min(0.5, |O − E|); or Fisher's two-sided p (tolerance 1e-7), r × c by enumeration on request, with the conditional MLE odds ratio and its exact interval on 2 × 2; the pointer below expected 5 (or 1), never a switch; Woolf odds ratio, Katz relative risk, Newcombe risk difference on 2 × 2 with named roles; Wilson row proportions for a named event column | ZERO_MARGIN, NOT_2X2, ROLES_UNDECLARED, ZERO_CELL, FISHER_TOO_LARGE above 10⁷ tables; an infinite conditional or paired odds ratio shown "not finite" with its lower bound |
| Paired proportions | the exact binomial McNemar on b of b + c (primary), the uncorrected asymptotic test; the corrected test (R's default, applied only when b ≠ c) in the export; the paired odds ratio b/c with Clopper–Pearson limits | NOT_2X2, NO_DISCORDANT |
| Trend | Cochran–Armitage as R's prop.trend.test (variance with N), 1 df, scores equally spaced or typed, the direction in words |
TOO_FEW_GROUPS, NOT_TWO_COLUMNS (the builder's code), ROLES_UNDECLARED, SCORES_INVALID |
Display: p as family 1; every other number to four significant figures; no stars or verdict words. Verification: counts.py with r/counts.R (42 cases) writes counts.json, published at 0.11.0; src/lib/stats/counts-oracle.test.ts passes on every case, and the four methods (contingencyTests, twoByTwoMeasures, mcnemar, trendTest) are verified against it (13 October 2026; their notes in assay-analyser.verification.ts, the panel's method line "Checked against R on 42 published cases"). counts.test.ts holds the hand cases and the R help pages' examples (tea tasting, job satisfaction, smokers).
Reporting policies and qualifications
| Source | Policy in the code |
|---|---|
| D-0028 §1 (assay reporting, 7 October 2026) | Endpoint name from declared intent: inhibition IC50, activation EC50, unspecified C50; never from the Hill sign. The MTT and PRISM examples load as inhibition. Object measurement kinds ic50, ec50, c50 follow intent |
| D-0028 §2 | The within-plate permutation test at p < 0.05 gates reporting; failing fits are drawn as descriptive curves with a plain reason |
| D-0028 §3 | Relative weighting gives point estimates only (IRLS); no interval or comparison, stated in the method block (reportingCopy.relativeOnly) |
| D-0028 §4 | Results from another fit version are dropped on load (readAssayData), readings and settings kept, refitFromVersion set and a refit notice shown; object measurements from other versions are flagged by requiresAssayRefit and their intervals withheld |
| D-0028 §5 | Unresolved profile bounds are "not resolved within the search" everywhere |
| D-0043 | Unread thresholds may ship in preview only if labelled "Unverified: not yet checked against {source}"; the decision names Assay's bend-point constant as the case to check at the next Assay change |
Two decisions share the number D-0028; the assay one is docs/board/decisions/D-0028-assay-reporting-policies-after-the-science-audit.md.
Every result carries its method records (content/apps/assay-analyser.methods.ts) with a verification state (verified meaning checked against a named oracle within a stated tolerance, or unit-tested) and the draft notice REVIEW_STATUS: the wording is draft until the independent reviewer of Q-19 signs the fixture file and the method page. The settings hash (settingsHash in provenance.ts) is a 32-bit FNV-1a fingerprint of normalisation, weighting, fixed parameters, interval method, C50 kind, intent, exclusions and table roles; it identifies identical settings and is not a security measure.
Objects, Studies and hand-offs
Assay owns the assay type; reads assay, target, compound, construct, recipe; writes assay, target, compound; and shows tabs on assay, target and compound pages (content/apps/assay-analyser.ts objects). See Storage and state for the object store.
Inbound (objectInbound in objects.ts): an own assay opens by mine (private); a target by its accession (id, public); a compound by smiles and name (private, by hand-off record) or by name; a construct by mine and name; a recipe only when its kind is serial-dilution or master-mix.
After a fit (writeNumbers in assay-workspace.tsx), unless the work is named Workspace work or a preview:
- Compounds with a SMILES get an
inchikey:ref computed by the shared RDKit worker and are upserted as carried compounds; a plan's compound known only by ref and name is learned by name. ic50Numberswrites one number per shown C50: on the target withaboutthe compound when the assay names a target, else on the compound. Value in mol/L, kind by intent, the shown interval back-transformed (absent bounds omitted) with level 0.95 and method id, and conditions includingfitVersion,assayIntent,endpoint,compound, relative/absolute, bound statuses and plate. Withheld values are not written.zPrimeNumberswrites Z′ per plate on the assay.setNumbersFromSource(on, assayRef, list)replaces every number whose source is this assay, so a refit replaces and an empty list clears; deleting the assay removes them.- Links:
measured-onfrom the assay to its target, compounds and construct;reproduces-doses-offrom a plan to the assay only when the plan was checked against a published example's wells. Legacymade-fromlinks from the assay to recipes, authored by Assay, are retired on save (isLegacyAssayPlanLink). - Example data:
mayWriteOnkeeps a synthetic example's numbers off every public record (onlymine:objects); a published example's numbers never go on a target and carry the dataset mark and its context (CSO rulings of 6 October 2026, as cited inmodel.ts).
Hand-offs: H20 (a dilution plan from Calc, placed on the plate map, optionally with its plate preparation sheet, kept as plannedSheet), H21 (a compound with its structure sent to Molecule by hand-off record, sendCompound), H23 (a target from Protein by id), H24 (the DMSO check returned to Calc by hand-off record, dmsoCheck, after reopening the captured recipe as editable inputs), H25 (a construct label by mine), REL-211 (a total-protein standard curve's sample concentrations to Calc's sample normalisation; see below), H22 (a saved assay's results table to Molecule's series: Send to on the subject row, offered when the assay holds fitted results, which the row's payload hook says as seriesRows; only the assay's ref travels, as mine by hand-off record, and Molecule reads the stored record through series-export.ts's assaySeries: verified results only, the C50 column mapped explicitly with its endpoint, kind and unit M, withheld values empty, more than 200 curves refused with the count). H22 has no crossLinks entry: a cross-link is a plain link and mine travels only after the #, so the registry's tests forbid it; Molecule's objects.reads names assay instead. Responses, potencies and the person's refs never travel in a link.
Sample concentrations to Calc (REL-211, 17–19 October 2026; docs/board/memos/2026-10-17-cso-cpo-assay-calc-normalisation.md, docs/board/memos/2026-10-19-board-workflow-refinements.md). The standard-curve result's action row starts with "Normalise samples in Calc" (copy calibrationCopy.normalise), shown when the assay has an object ref and not in a preview. calibrationNormalisationOffer (src/lib/fit/calibration-normalisation.ts) decides what goes from the trusted result: it refuses no fit, a result fitted by older versions (stale), the synthetic example, a curve whose protocol is not Bradford or BCA (a gel is loaded by total protein, never an ELISA analyte), a molar unit, more than 384 rows, or nothing left to send, and the reason is visible text under the row. Only interpolated unknowns go, at originalConcentration (dilution applied), one row per label and dilution ("S3 (1:4)"), with the inverse-prediction interval when computed, plus any calibration fit qualifications (result.qualifications, all of them; Calc's reader accepts at most 30 non-empty strings of at most 1,000 characters each). Outside the range, unavailable and below the blank-based LOD are withheld and counted; below the LOQ goes with flag: "loq". The block (src/lib/handoff/payloads/normalisation-samples.ts) goes inside a normalisation recipe by sendTo("lab-calculators", <assay ref>, { fields: { recipe, recipeKind: "normalisation" } }): a hand-off record, nothing in the query. The registry declares the link (content/apps/assay-analyser.ts). When a dose-response curve's C50 is withheld or refused, CurveDetail (src/components/tools/assay-analyser/panels/curve-detail.tsx, outside curve-diagnostics.tsx) renders assayRefusalGuidance (content/apps/assay-analyser.refusal-guidance.ts, [data-assay-refusal-guidance="<code>"]; CSO memo of 20 October 2026, §7, and CPO memo of 20 October 2026, §6). The statement is keyed by the code that fired, read from the stored curve: rule (i) split by its reason (REASONS.tooFew, with the count from MIN_CONCENTRATIONS, or REASONS.nonPositive), the fitter's code recovered from the stored reason by FIT_REFUSAL_REASONS (analyse.ts), the outcome's withheldBy for rules (ii) to (v) and noFiniteMinimum, and for an Absolute C50 withheld with no rule either the raw scale (REASONS.notPercent) or no 50 percent crossing (stated with no next step). numerically-flat, not-converged, no-data and the rule (vi) flag have no statement: their reason stands alone and no block shows; a stored reason in another engine version's words gives no guidance rather than a guess. ASSAY_GUIDANCE is exhaustive over these codes, and src/lib/methods/refusal-guidance.test.ts holds it to RuleId, the fitter's union and REASONS, and drives analyseCurve to each code. The links, to /dose-response/app and /binding-inhibition/app, are labelled with the tools' titles from content/education-tools.ts. Browser test: e2e/assay-analyser/calibration-normalisation.spec.ts.
Repeat setup, the Assay side (contract 06, 17 October 2026)
src/apps/assay-analyser/repeat-setup.ts, loaded only after the Study's Repeat setup intent:
readRepeatAssaySourceaccepts a v6 or v7 dose-response record (no other mode block) whose settings tuple is fully explicit (readExplicitSettings;readSettings's defaults are not evidence) and which consumed exactly the selected saved Calc revision: onerecipebinding, its hash intact, naming that recipe at its current revision, whose payload equals the current recipe withoutworkspaceonce read throughreopenPreparationRecipe(so a recovered pair still matches), a plan whose ref and dose table agree, and oneplan-source:*binding for each of the recipe's own bindings. Each failure has its own reason; a changed Calc revision is shown, never chosen.reconcileAnalysiswrites an analysis block only from the reviewed settings, dated the person's review day, and only when the plan format can represent them (never forpercent-vehicle-bg, which is kept as a v7 setting); a difference from the source block needs the explicit "use-reviewed-settings" choice. Checked againstrepeat-setup-1.json.buildRepeatedAssaystarts fromemptyAssay(reviewed settings)at version 6 (7 for background-subtracted normalisation): no source, readings, import record, layout, planned sheet, results, exclusions, curve fixes, comparisons, declarations, blind state, figure, dataset mark or reporter choice; research reset toEMPTY_RESEARCH(replicate kind unspecified) with the method reference and conditions only when kept; target and construct only from their verified captured bindings, kept byte for byte; the plan as the new recipe's ref, label, table and compound; the newrecipebinding (revision 1, the consumed payload's hash) and its plan sources fromcapturePreparationSources;repeatedFromnaming the source revision without a result.
A saved task's pending plan. src/components/tools/assay-analyser/saved-plan.ts resolves the plan from the Assay's own verified recipe binding when no arrival fragment names one and no sheet is captured yet. The plate map then offers that sheet, and its Apply captures it from that exact payload with the binding's ref and revision, through the existing commitPlatePlacement guard; nothing is fetched by ref or stored before Apply. A plan without a sheet is listed too (its nominal doses are placed by the ordinary plan placement). Before a plate is imported, the Import and Plate map views say "Preparation plan available; add new readings to apply it" and list the plan's label, top, fold, number of doses and, with a sheet, the plate and first well.
Workspace tasks and recorded results
workspace-adapter.ts captures immutable input and result snapshots for named Workspace work (see Storage and state for snapshots, content hashes and versioning):
| Mode | Eligible when | Input snapshot schema | Result schema | Methods recorded |
|---|---|---|---|---|
| Dose response | Not calibration/qPCR mode; source and results present; not cancelled; at least one curve; fitVersion current; no refitFromVersion; work state schema version equals data.v |
assay-analyser.assay @ data.v (1 to 6) |
assay-analyser.recorded-result @ 1 |
four-pl @ fitVersion; data version table-parser @ tableVersion |
| Calibration | v4 or later (v6 or later for a v2/v3 calibration sub-record), calibration mode, result version and fitVersion current, input hash re-verified; work stamp equals data.v |
same | assay-analyser.calibration-result @ 1 |
standard-curve-interpolation 1.0.0; four-pl at result.fitVersion for 4PL, or five-pl-calibration 1.1.0 for 5PL; calibration-inverse-prediction 2.0.0 only for a v2 result carrying an interval object |
| qPCR | v5 or later (v6 or later for a v2 result), qPCR mode, result version current (1.1.0), verifyQpcrResult passes; work state schema version equals data.v |
same | assay-analyser.qpcr-result @ 1 |
qpcr-relative-expression 1.1.0, plus qpcr-fold-change-welch-interval 1.0.0 for v2 |
| Initial rates | v5 or later, enzyme mode, result version equals KINETICS_VERSION, input hash re-verified; work state schema version equals data.v |
same, with the result removed from the sub-record | assay-analyser.kinetics-result @ 1 |
michaelis-menten-initial-rate, plus michaelis-menten-profile-interval when intervals were computed and progress-curve-initial-rates first when the rates came from progress curves (whose source line is then a third qualification, kinetics-scope-2) |
| Inhibition | v5 or later, inhibition mode, version INHIBITION_VERSION, hash re-verified; work state schema version equals data.v |
same | assay-analyser.inhibition-result @ 1 |
inhibition-global-fit |
| Growth | v5 or later, growth mode, version GROWTH_VERSION, hash re-verified; work state schema version equals data.v |
same | assay-analyser.growth-result @ 1 |
growth-window-rate |
| Thermal shift | v5 or later, thermal mode, version THERMAL_VERSION, hash re-verified; work state schema version equals data.v |
same | assay-analyser.thermal-result @ 1 |
thermal-shift-apparent-tm |
| Several groups | v6, groups mode, the analysed grid holds anything |
same | assay-analyser.groups-result @ 1 |
one-way-anova, two-way-anova, repeated-measures or rank-groups, plus post-hoc-comparisons when comparisons are chosen |
| False discovery rate | v6, many-tests mode, the grid holds anything |
same | assay-analyser.many-tests-result @ 1 |
p-value-adjustment |
| Combination | v8, combination mode, a current result (verifyCombinationResult at the press) that calculated at least one dose pair under either model; a result that calculated none records only through Record with reasons |
same | assay-analyser.combination-result @ 1 |
combination-hsa, combination-bliss, combination-normalise, combination-technical-mean (all 1.0.0; src/lib/assay/combination/methods.ts) |
Every capture binds the work's stamped state schema version to the record's own data.v, the version the app stamps it with (assayWorkSchema), so stale work is refused and nothing else. The mode's sub-record keeps its own version, read by the mode's reader (mode-readers.ts) whatever the record's version. Until 24 October 2026 the qPCR, initial-rate, inhibition, growth and thermal-shift captures compared the stamp with 5 while every write path saves at v6 or later (raiseOnWrite), so each of their results was refused with "Work saved; the current result could not be saved…" (CW-26, REL-571). src/apps/assay-analyser/capture-record-version.test.ts captures each of the five at v6, v7 and v8 and refuses a work stamped otherwise; e2e/assay-analyser/result-capture.spec.ts saves each shipped example (IC50 as the control) and expects "Work and result saved on this device.". The start's melt and inhibition examples open their readings without calculating, so a save before the run keeps no result ("Work saved. No separate result was recorded."), which is correct. A v2 growth record's Gompertz fit (A, μm, λ) is derived and not verified at capture, so the captured input sets it to null and only the window rate is recorded (the CSO's condition on CW-26, 24 October 2026); a v1 record has no fit key and gets none. REL-575 (25 October 2026): newly captured qPCR v2 results retain the quantity pin (qpcr-relative-expression, the result's existing 1.1.0 version) and append qpcr-fold-change-welch-interval 1.0.0 from the two-field QPCR_INTERVAL_METHOD_PIN in the already eager qpcr/base.ts. The lazy full QPCR_INTERVAL_METHOD record uses that same pin; capture does not import a mode’s full method record into the eager workspace. The interval policy is named even when all intervals are withheld, and for an already eligible whole-result v2 refusal; this records the configured policy, not a claim that an interval was estimated or that its calculator executed before an early refusal. Scientific output, settings, qualifications and versions do not change. Historical v1 results remain readable and fail the existing current-capture gate; earlier immutable snapshots keep their recorded methods and hashes without backfill. qpcr-persistence.test.ts covers exact method pins with available and withheld intervals, whole-result refusals, and an earlier single-method snapshot through storage, projection and Study export/restore. The existing qPCR case in result-capture.spec.ts inspects the actual stored method pins and compares the complete stored snapshot after reload.
REL-185 (25 October 2026): calibration captures use src/lib/fit/calibration-method-pins.ts as the dependency-free authority shared with the existing lazy public method records. CALIBRATION_VERSION 1.3.0 still versions the result envelope; it is never substituted for the interpolation or 5PL method's own version. The 4PL pin retains result.fitVersion. Inverse prediction is named exactly once when result.v === 2 and any unknown carries an interval object, including an explicitly withheld interval. A v1 result, out-of-range-only v2 result or wholly refused fit with no interval object gets no inverse pin. No value, guard, schema or version changes. Earlier snapshots with the wrong method versions remain unchanged through reads, projection, report export and storage roundtrip. calibration-persistence.test.ts covers linear/4PL/5PL inputs from the existing frozen reference sets, calibration data v2 and v3, computed/withheld/no-interval/refused outputs, exact retained values/settings and valid snapshot hashes. result-capture.spec.ts checks the actual saved standard-curve method pins and their persistence after reload on each browser project.
For combination responses, under raw readings with controls (raw-with-controls), normalisation failures (missing controls, fewer than two of either, zero control span, direction contradictions, or non-finite normalisation) refuse the analysis top-level (status: "refused") and emit no cells, rather than returning cells marked withheld.
Combination responses, from 20 October 2026 (the CSO's, CPO's and CTO's rulings of that day; docs/board/memos/2026-10-20-*-audit-fixes.md):
SCI-260's Assay-only metadata repair (25 October 2026) exposes the existing
combination-normalise and combination-technical-mean records, both 1.0.0,
before HSA and Bliss in the lazy COMBINATION_METHODS object. The existing
Combination responses group, current Methods pane and public methods pages
discover them through their ordinary registry path. Canonical names, versions,
capture pins and algorithms are unchanged; no engine is imported through the
metadata. Their separate verified notes describe end-to-end Python-fractions
agreement of observable vehicle/cell/margin means within 1e-9 absolute and exact
statuses/reasons or counts/IDs, with no biological-replicate inference or
uncertainty interval. The same existing 39-case combination-analysis registry
entry associates all four IDs with the unchanged 0.15.0/v3 reference recorded
18 October 2026, retaining the not-run synergy cross-check statement. Public
HTML/Markdown retain the existing contract of names, levels and scrubbed
evidence; implementation versions and detailed reference associations remain in
the current/full method records. The four non-Assay SCI-260 records remain held.
- Input paths. The scale select has three options. The two normalised options set
mode: "already-normalised"withscalefractionorpercentand show the unticked endpoint affirmation. The third, "Raw readings with no-effect and full-effect controls in this block", setsmode: "raw-with-controls"withscale: null(the raw path normalises by its controls, so the engine refusesundeclared-scaleonly on the normalised path, as the oracle reads the scale only there) and adds a required Row role column to the column map. Each distinct value in that column gets its own select ("Dose-pair observation (uses Dose A and Dose B)", "No-effect control", "Full-effect control", "Not used"); nothing is pre-assigned or inferred from a value's text, and Apply is refused until every value has a role. Control rows becomecontrols.noEffect/controls.fullEffect; their dose cells are ignored and counted in the applied line; an excluded control row is refused (excluded-control), because the record keeps control values only (known issues). The engine's existing codes refuse:missing-no-effect-control,too-few-no-effect-controls,missing-full-effect-control,too-few-full-effect-controls,zero-control-span,direction-contradicts-controls,non-finite-normalisation. After Apply the panel shows both endpoint means with their n in the readings' own units and the equation. The direction is declared on both paths. - Import problems (
src/lib/assay/combination/import.ts) are codes with the row and cell (empty-table,missing-columns,short-row,invalid-dose-a,invalid-dose-b,non-finite-response,duplicate-id,no-observations,too-many-observations,role-unassigned,excluded-control); their sentences areimportProblemsincontent/apps/assay-analyser.combination.ts, and a test ties the two sets. A blank dose or response cell, a short row and a repeated observation ID refuse the table (before 20 October a blank read as 0, a short row was skipped, and a repeated ID or an imported exclusion'sexclusionReasonkey made the saved record unreadable). - Stale-result guard. Every evaluation runs in the one-job worker with
inputHash = contentHashLight(combinationInput(next)), wherenextis the exact sub-record the result is stored in (combinationInputinbase.ts: agents, doses, observations with exclusions, controls and settings). An input change stores the sub-record without a result, cancels a pending job and ignores its late answer. The panel shows, exports and counts a result only whilematches(integrity-core.ts) holds on its settings and hash; otherwise it shows the rerun notice. The workspace export menu and the recording module callverifyCombinationResult(combination-integrity.ts, which also re-reads the result strictly). A result stored with an empty hash (written between 18 and 20 October 2026) still reads, fails verification and asks for a rerun; nothing is migrated. The Bench projection reads only the recorded snapshot, nevercombination.result. - Record with reasons.
canRecordCombinationWorkspaceResultholds only for a result that calculated a dose pair with both agents above zero under either model (combinationScored,base.ts). OtherwisecanRecordCombinationWithReasonsholds: the shared Record result key reads "Record with reasons" (WorkCaptureControlsproprecordConfirm), and its press opens the workspace's dialog (title, body, the refusal sentence or each withheld reason with its count fromcombinationRecordReasons; initial focus on Cancel). Confirming records the result withreporting.status: "refused"andoutput.refusedholding the refusal code and sentence, or each withheld reason with model, sentence and count, and the observation count; no expected or excess value exists in it. Ordinary Save work keeps the editable work and records no result. Recorded summaries use sentences from content (content/apps/assay-analyser.combination-record.ts), never reason codes. - Display. The colour key states the sign in words at each end ("−n pp, below the HSA reference", "+n pp, above the HSA reference", likewise Bliss) with no status colour; an all-zero matrix shows the labelled ±1 pp span; the out-of-range disclosure counts retained observations below 0 and above 1 separately; the Cell table's Status column shows words ("Calculated", "Withheld", "Not applicable"). The vehicle qualification sentence is written by the engine (
evaluate.ts) and pinned by both fixtures, so it stays there until a new fixture version moves it. The panel's own Evaluate button is gone: Apply, a settings change, an agent edit and the worked example evaluate in the worker. The worked example ticks neither the endpoint affirmation nor the Bliss declaration.
The dose-response input payload is the assay with settings replaced by the settings the fit ran under and results null. Evidence origin is simulation for a synthetic example, observation for a published one, otherwise unknown; derivation raw, execution recorded. The output holds a summary row per curve (C50 in mol/L or "unavailable" with the reason), the full StoredResults, and table decisions when present. Qualifications: replicate-interpretation, explicit-table-decisions, preparation-plan-only, example-data (info); weighted-inference-unavailable, withheld:<curve>, unresolved-bound:<curve> (warning). Reporting status is qualified when any warning exists, else reportable. Calibration and qPCR results are refused or qualified, never reportable, and carry their scope qualifications. The four Wave 1 results are likewise qualified when a value was estimated (for thermal shift, when any condition was) and refused otherwise; their qualifications are the mode's scope sentences (growth-scope-*, inhibition-scope-*, thermal-scope-* and so on), the withheld reason as a warning, and for thermal shift one warning per refused or disagreeing condition. Their outputs carry bounded presentation rows (summary: label, value, detail; one row per condition for thermal shift, at most 24) beside the stored result. Bench reads those rows as it reads Sequence's, Plasmid's and Bioreactor's (ASSAY_PRESENTATION_SCHEMAS in src/lib/workspace/result-projection-core.ts), and assay-projection.ts projects only the mode and the reading count from their inputs, so the object pages carry no kinetics, growth or thermal code. The copy each recording needs is imported when it records, not with the workspace. Evidence origin is simulation for a synthetic example and unknown otherwise.
Readers elsewhere accept assay-analyser.assay at every version in ASSAY_RECORD_VERSIONS (1 to 8): bundle.ts (retained originals), assay-projection.ts, repeat-assay.ts, source-impact.ts, snapshots.ts, scientific-changes.ts, editable-references.ts and record-preview-model.ts. prepareAssayRepeat accepts the four new result schemas as sources. A Repeat of saved work (repeatAssay) copies setup only, including a v5 assay's plannedSheet (12 October 2026, so plate 2 keeps the sheet's control wells and their buttons) (for each Wave 1 sub-record, its settings into a new empty sub-record: units, conditions, window or windows, model, reference) (layout, compounds, target, construct, plan without placement claims, preparation, settings, figure, method reference and conditions) and never readings, exclusions or results.
Exports
The binding, kinetics, growth (and Gompertz), thermal-shift, inhibition and screening panels name their CSVs with the suite's exportFilename (assay-analyser-<kind>-<yyyymmdd>-<hhmm>.csv, for example assay-analyser-binding-result-20261014-1530.csv) since the polish pass of 14 October 2026 (D-0086), so two downloads never share a generic name; the CSV bodies and their provenance lines are unchanged. Their run keys show "Fitting…" and ignore a second press while the fit runs; the main dose-response Fit key keeps its counted progress bar and shows the same word.
From the shell's Export menu (src/apps/assay-analyser/exports.ts); every file carries the provenance block (provenanceFor: methods with the settings hash, sources at their versions, limits, statements, canonical address).
| Export | Mode | Content |
|---|---|---|
| Results CSV (with provenance JSON sidecar, possibly zipped) | Dose response | One row per curve: endpoint, intent, compound, plate, permutation p and procedure, C50, logC50, pC50, interval bounds, method and bound statuses, Hill, Top, Bottom, fixed parameters ("Hill = -1 fixed"), R², points, replicates, excluded, flags, relative/absolute, status, reason, SMILES, research-context columns, standard provenance columns (source, source_version, retrieved_at_utc, app_version, attribution) |
| Imported readings CSV | Dose response | Every parsed observation with role, fit use, exclusion reason, source row and column |
| Experiment context TXT | Dose response | The research-context text |
| Project file (JSON) | All | The project@1 envelope with the one assay, its retained originals and versions; reopens in Assay (the session file is read, no longer written) |
| Figure caption (Report, "Copy caption") | Dose response | Generated beside the method paragraph (figureCaption in src/apps/assay-analyser/report.ts, the clauses in figure/caption.ts, the words in content/apps/assay-analyser.figure.ts; the CSO's memo of 12 October 2026 on error bars and the caption, ruling (3); the CPO's ruling 22), from the same settings and results and the record's figure settings; never stored. It describes the figure the Export menu draws: the curves shown by display label, with the record's name once in brackets where it differs; the layout; the excluded wells of those curves counted, drawn hollow or not; the bars' measure and the range of n over the bars drawn, or "No summary bars"; the model, weighting, normalisation and constraints in the method paragraph's words (a constraint fixed on one curve only named with its curve, as in the paragraph), except that a plate normalised to percent or percent of control names its vehicle wells ("normalised to the mean of {n} vehicle wells at concentration 0, which set 100 percent and are not drawn on the log axis", the CSO's pre-submission memo 4(a), counted over the plates of the curves shown); the estimate's mark and interval as drawn (none under relative weighting); reported and withheld counts; zero-dose rows drawn at 0 when there are any (the CSO's panel line with the drawing words, provisional); the plates when more than one; the software, fit version and date. Wave 1 modes have no caption yet |
| Figure: SVG, PNG, TIFF, PDF | Dose response | Drawn from the stored results through the scene at the figure's size in millimetres (figure/export.ts, chunk drybench-export:assay-figure-core, loaded on export intent, so the Fit view need not be open): SVG in millimetre units with <metadata id="mbio-provenance">; PNG through figure/raster.ts (loaded only for PNG and TIFF, with figure/canvas.ts and the PNG writer) at 150, 300 or 600 dpi with a pHYs chunk and the provenance text chunks; TIFF (figure/tiff-writer.ts, drybench-export:assay-figure-tiff) baseline uncompressed in strips; PDF (figure/pdf-writer.ts, drybench-export:assay-figure-pdf) laid out with Helvetica's widths, substitutions listed in the dialog before the download. The shared dialog's physical-figure variant (FigureSpec.physical, the CPO's §D.21 and §D.24 words): White and Transparent for SVG, TIFF and PDF, PNG's four; the size in mm read only; the panelTooSmall line without its key; it never writes the record. The caption under the panels: an example's labels, the source line, the serial-plan advisory |
| Print report | Dose response | Results table (up to 400 rows, with a Fixed column), KPIs, method block as parameters, method paragraph, context |
| CSV for GraphPad Prism (XY table) | Dose response, not for example data | Concentration in the layout unit, then each compound's replicate Y columns, a constrained curve's columns titled with its fixed parameters ("Compound B (Hill = -1 fixed): Y1"); excluded and non-positive wells left out |
R snippet (drc::drm) |
Dose response | Data inline; LL.4 per compound with the same fixed parameters; Wald cross-check via LL2.4 converting only the e row by 1/ln 10; for 1/Y², an IRLS loop with LL2.4 passing weights = fitted_y and a stop if drc < 4.0.0 |
| Method paragraph TXT | Dose response | The citable paragraph from methodParagraph |
| Original file | Any, when retained | The verified original bytes |
| Calibration results CSV, readings CSV, report HTML | Calibration | Only after verifyCalibrationResult |
| qPCR sample values CSV, reviewed readings CSV, report HTML | qPCR | Only after verifyQpcrResult; the report includes the original imported text |
kinetics-result.csv, kinetics-rates.csv |
Initial rates | Result only after verifyKineticsResult: Km, Vmax, kcat with unit or refusal, the four interval columns carrying bounds or states, method, level, df, Km poorly determined, kcat low and high, the qualification; conditions ("Not stated" when empty). Rates with fitted values, residuals and decisions. With rates from progress curves, both files append "rates from" (window, unit, direction, ε, l) and the rates file a "well" column |
inhibition-result.csv, inhibition-rates.csv |
Inhibition | Result only after verifyInhibitionResult; the model on every row; Ki′/Ki "derived, descriptive" for the mixed model; no RSS for a withheld fit |
inhibition-comparison.csv |
Inhibition | Only after verifyInhibitionComparison; one row per model, ranked then outside the ranking; AICc, ΔAICc, K, RSS and the nested F only for estimated fits; the chosen model marked; the CSO's ranking sentence |
growth-result.csv, growth-readings.csv |
Growth | Result only after verifyGrowthResult; interval columns "not available" |
thermal-shift-result.csv, thermal-shift-readings.csv |
Thermal shift | Result only after verifyThermalResult; both Tms, ΔTm per method, windows, refusal reasons |
| Combination cells CSV, combination observations CSV | Combination | Cells only after verifyCombinationResult: one row per dose pair (zero-dose positions included) with both doses and units, retained and excluded counts, the observed mean, for HSA and for Bliss the expected fraction, excess in percentage points, status and reason code, the HSA out-of-range qualification, the vehicle qualification, the out-of-range count, the method ids, the contributing observation IDs and the declarations. Observations always (the normalised column only from a verified result, readingsForExport): one row per imported observation (ID, doses, role vehicle / single-agent-a / single-agent-b / combination, original response, normalised effect, included, exclusion) and, on the raw path, one row per endpoint control reading (role no-effect-control or full-effect-control, value; no ID or dose, which the record does not keep), each with the declarations. The declaration columns on every row of both files: Input path, Scale, Direction, Endpoints affirmed (normalised path only), Bliss declared (the CPO's ruling 4.7). No figure export yet |
In the four Wave 1 modes the menu offers only the result CSV, the readings CSV and the session JSON, and the provenance block names the mode's methods without the dose-response settings hash. Example data change the file names (prefix), add a data_origin column and the example's statement and changes, and disable the Prism CSV (example-export.ts). The R snippet states in its header that the app's multistart search, bounds and reporting gates are not reproduced; the follow-up audit records that it was executed for selected curves under R 4.6.1 and drc 4.0.0 (docs/science/evidence/implementation-verification/r-crosscheck-2026-10-07/).
Task pages and examples
The discovery start has nine labelled examples (16 October 2026, live-site request item 13). IC50, ELISA, qPCR relative expression, selected-window growth and enzyme kinetics open filled, editable analyses and request one calculation through their existing engines. A transient request is consumed when the new panel mounts; saved example provenance never triggers a new calculation when work is reopened. Input changes and scope changes retain the panels' existing cancellation and stale-result guards.
The ELISA starter elisa-4pl-synthetic@1 uses the independent SciPy
calibration-4pl-scipy.json increasing case, also shown on the ELISA worked
task page. It explicitly selects 4PL, ng/mL, synthetic absorbance, no blank
subtraction, mean-response replicates and unweighted fitting. There is no
claimed analyte, kit or wavelength. Nine standards and five unknowns retain
their exact fixture values; below/above-range unknowns are not extrapolated.
The optional calibration exampleId travels with input hashing and recorded
snapshot evidence. Old records without it retain their original input hash.
The generic linear starter clears earlier model settings and results. qPCR
uses qpcr-fold-change@1, whose biological sample identities are declared by
the synthetic example; editing/importing other data retains the normal
declaration requirement. These are teaching examples, not experimental data.
- IC50 calculator island (
islands/ic50.tsx): the worked MTT example is fitted at build time and passed in, so the answer is in the server HTML. On focus or edit the island importspaste-fit.ts, which reads, fits under default settings and summarises in one call; a table with rejected or unresolved readings is refused (no review step on this page), and a plate export is recognised and sent to the app. - Z′ island (
islands/zprime.tsx): pasted positive and negative values, preloaded with the MTT controls. - Wave 1 task islands (
islands/growth.tsx,kinetics.tsx,inhibition.tsx,thermal.tsx): the mode's own panel with the synthetic example preloaded, rendered on the server, a link into the app carryingex=<example>@1, and the worked numbers computed when the page is built. On task pages the panels use their own status line; in the workspace they announce through the host (one polite region per page).islands/ki.tsxis the three-field Ki form with no link into the app (IslandFrame link={false}). - Standard-curve pages (
elisa-standard-curve,protein-assay-standard-curve): no island; the worked examples are the published fixture cases (the SciPy 4PL "increasing" case; the exact linear Bradford- and BCA-style cases) fitted by the engine at build. The Standard curve mode's optional "Assay kit or protocol" (CalibrationSettings.protocol: none, ELISA, Bradford, BCA) adds notes and qualifications only; it never chooses the model or changes a number. - The plate walk (walk key
plateversion 1 insrc/lib/walk/key.ts, six Assay steps; words incontent/walks/plate.ts, facts incontent/walks/plate.values.tsgenerated before each build from the MTT fixture byscripts/lib/plate-walk-values.mjs): from a plate export to a reported C50, excluding well A9 of Compound A with a reason. Entered from the MTT example page. Its words load on demand (WALK_DEF_LOADERSinsrc/components/suite/walk/definitions.ts). - qPCR fold-change page (
qpcr-fold-change; A6): no island. Two worked tables, ΔΔCq and entered efficiencies (IL6 1.92, ACTB 1.97), of the exampleqpcr-fold-change@1, calculated bycalculateQpcrwhen the page is built, with the interval or the withheld sentence per condition. A link opens the app on the same example (#s=ex%3Dqpcr-fold-change%401). The workspace'sloadExamplehandles theqpcrmode withwithQpcrFoldExample, which, likewithQpcrExample, loads the example with its biological identities confirmed (made as independent samples; the CSO's ruling of 14 October 2026), so Calculate expression is ready on every path to an example: the panel's button, Assay's start and theex=link. A person's own Cq table is confirmed in its import review (import-state.tsclears the confirmation on every import). The example (QPCR_FOLD_EXAMPLEinsrc/lib/assay/qpcr/import.ts) is the oracle'sddcq-basiccase rounded to 0.01 cycle: three vehicle calibrators and three drug samples, two technical wells per assay. The panel's own "Load synthetic qPCR example" is unchanged; with one calibrator, its intervals are withheld. - Synthetic Wave 1 examples (
content/apps/assay-analyser.examples.ts, data inexamples/*.data.ts):initial-rates@1(duplicate rates, Vmax 10 µM/min, Km 2 µM, 10 nM enzyme),inhibition@1(competitive, Vmax 10, Km 2 µM, Ki 5 µM, four inhibitor levels),growth-window@1(OD600, three blanks, window 3 to 9 h),thermal-shift@1(two melts, apparent Tm 52.0 and 56.3 °C, window 25 to 95 °C). mtt@1(synthetic,mtt-example.ts): one 96-well A570 plate generated from known parameters (seed 20261106); column 1 vehicle (positive, 8 wells), column 12 rows A to D kill (negative), E to H blanks; three compounds on a 3-fold series from 100 µM in columns 2 to 11. Loading it sets intent to inhibition and fits automatically.prism-erlotinib-hcc827@1(published, CC BY 4.0,examples/prism-erlotinib-hcc827.data.ts, loaded on demand): PRISM Repurposing 19Q4 HTS002 readings for HCC827 on three replicate 384-well plates, DMSO as positive, bortezomib as negative, MG-132 unassigned. Its form of the compound is not stated whileform.citableis false; switching it waits on the General Counsel (held for the General Counsel).
See Data sources for licences and Data pipeline for regeneration.
Methods and checks dialog
The methods and checks dialog (D-0085; Shared shell, Registry and content) reads content/apps/assay-analyser.methods-sheet.ts, loaded on first open (the workspace still never imports assay-analyser.methods.ts). One group per analysis mode, named and described by its chooser entry, with the mode's own records plus the core records its result also goes through (MODE_CORE: the parser for screening, the rules for GR metrics, the kinetic reads for thermal shift, growth and enzyme rates). Dose response is its path (DOSE_RESPONSE_PATH: parser, normalisation, 4PL, LM, Wald, profile, rules, replicate summaries) and nine groups beside it (weighted fit, plate quality, outlier candidates and influence, curve shape and model choice, comparisons, agonist and antagonist analyses, reporter, viability and kinetic readings, molar units and ligand efficiency, where to add a concentration), each holding the path so none reads above it (the CSO's review of 14 October 2026). AssaySnapshot.mode (the stored analysisMode, dose response when none) is published for definition.methods.group().
Verification
Oracle fixtures
pipeline/assay-analyser/README.md documents the set (see Oracles and verification). In brief:
| Set | Oracle | Count | Tolerance as tested |
|---|---|---|---|
Synthetic curves synthetic-curves.json |
R nls (best of nls and drc, polished by Nelder–Mead and BFGS), drc LL.4/LL2.4 and MASS confint as cross-checks |
33 curves: 30 with a minimum, C29 refused, C30 and C33 without a finite minimum | logC50 0.00043 log10; Hill 0.1 percent; Top and Bottom 0.1 percent of the span; objective 1e−6 relative (engine never below oracle); Wald bounds 0.0022 and profile bounds 0.0043 log10 |
| MTT plate | R | 3 compounds; Z′ 0.782 | Same; normalisation to 1e−9 |
Z′ zprime-controls.json |
By hand in R | 4 sets plus the MTT plate (Z04 refused) | 1e−9 |
| Nested F-tests | By hand, confirmed by drc anova() |
6 | RSS 1e−6, F 1e−5, p 1e−4 relative |
| PRISM example | R (prism-erlotinib-hcc827.reference.json) |
1 compound on 3 plates | Resolved lower bound to the R tolerance; the upper bound is refit-unresolved in version 2 |
Relative IRLS relative-irls-reference.json |
SciPy least_squares with frozen weights |
C24, C25 | 1e−5 relative on each parameter; frozen-weight score equations to 2e−5 |
Calibration calibration-scipy.json |
SciPy 1.13.1 curve_fit |
increasing and decreasing noisy 4PL | parameters 2e−5; unknowns 5e−5 relative |
outlier-candidates.json (0.11.0) |
NumPy (hat matrix by inversion), SciPy curve_fit and a central-difference Jacobian for the 4PL, scipy.stats.t, recorded in the oracle image; R rstudent, hatvalues and car outlierTest as the cross-check on the straight lines and groups, with outliers grubbs.test for G (its two-sided p compared only where 2·res ≤ 1: it folds above, the CSO's ruling 1(e) of 13 October 2026) (r/outlier_checks.R) |
8 fits (a planted point on a line, on a 4PL and on a 4PL with bottom fixed; clean line and 4PL; an exact line; two below the n − p floor) and 5 control groups (planted, clean, evenly spread with the bound at 1, five wells, no spread) | leverages, residuals, internal and external t, p, adjusted p, G and critical values 1e−6 relative; candidates, indices and withheld states exact. Registry set outlier-candidates (0.11.0); the method verified on 14 October 2026 (the CSO's ruling: the fixture passes in CI through outliers-oracle.test.ts) |
calibration-inverse.json (0.6.0) |
Python fractions for the linear Fieller interval (to the square root and the t quantile); SciPy curve_fit and central finite differences for the 4PL and 5PL delta method, recorded in the oracle image; R investr invest (inversion, Wald) as the cross-check on unknowns read once (r/calibration_invest.R) |
6 curves, 13 unknowns: computed, standard-count, unbounded, asymptote, weighted, upper and lower bound outside | bounds 1e−9 relative (linear), 1e−6 relative (4PL, 5PL); concentrations 5e−5 relative; states exact. Superseded from 0.13.0 by calibration-inverse-v2.json (SCI-123); kept published |
calibration-inverse-v2.json (0.13.0) |
calibration_inverse_v2.py: method 2.0.0, the reading variance pooled from replicated standards (Python fractions for the linear interval, SciPy curve_fit and central finite differences for the 4PL and 5PL), recorded in the oracle image; R investr invest on lm and nls fitted to the individual readings, for unknowns read once (r/calibration_invest_v2.R); a coverage simulation, 2,000 seeded replicates in each of 8 designs (linear and 4PL, standards in duplicate and triplicate, unknowns read once and three times), gates 0.93 (linear) and 0.90 (4PL), with the old method's coverage on the same replicates |
15 curves: the six of v1 and replicated, triplicate, unequal-replicate and singlicate cases for the linear, 4PL and 5PL fits; every state of v1 plus unequal-replicates |
bounds 1e−9 relative (linear), 1e−6 relative (4PL, 5PL); concentrations 5e−5 relative; states exact; read-once cases equal v1. Recorded 14 October 2026 (SHA-256 564e7a13…): R investr agrees on 11 of 11 (linear within about 3e−13, logistic within about 3.5e−9 relative); coverage 94.4 to 95.5 percent against 83.0 to 90.9 for method 1.0.0 |
calibration-5pl-scipy.json (0.6.0) |
SciPy 1.13.1 curve_fit (trf, ln s boxed, nine starts), recorded in the oracle image; R drc LL.5 as the cross-check (r/calibration_ll5.R) |
3 fitted (asymmetric rising s 0.45, asymmetric falling s 2.2, symmetric noiseless where the 5PL recovers s = 1 and the 4PL) and 2 refused (s 25 at the box; six standards) | parameters 2e−5 (ln s absolute); fitted responses 2e−5; concentrations 5e−5 relative; RSS 1e−6, F 1e−5, p 1e−4 relative; AICc 1e−6 absolute; statuses exact. Registry set calibration-5pl (0.11.0); the method verified on 14 October 2026 (the CSO's ruling); its unknowns' intervals are the inverse-prediction method's, whose coverage with the 5PL is not simulated |
calibration-4pl-scipy.json, calibration-linear-exact.json (0.2.0, 0.4.0) |
SciPy 1.13.1 curve_fit; exact rational OLS in Python fractions |
2; 7 (blank, excluded well, falling series, boundary, three refusals) | 2e−5 (log10 midpoint absolute), concentrations 5e−5 relative; linear 1e−9 relative; range flags and refusals exact |
spike-recovery.json (0.11.0; oracles/spike_recovery.py, Oracles run 38420985979) |
Exact rational least squares on the standard means, the inverse and the recovery in Python fractions; R 4.4.3 lm on the same means, inverted through coef() (r/spike_recovery.R), within 1e-9 |
src/lib/fit/spike-recovery.test.ts |
values 1e-9 relative; states and reasons exact |
viability-normalisation.json (written by oracles/viability_normalisation.py in the oracle image, Oracles run 38424173095, SHA-256 7b7e12fb…; published as 0.11.0) |
Python fractions; R 4.4.3 mean as the cross-check (r/viability_normalisation.R) |
11 plates (the CSO's hand plate at exactly 50, percent of control with its blank share, one and two blanks, one positive-control well, positive equal to and below blank, values outside 0–100, an excluded blank, no blanks, kill wells equal to blanks) | 1e−12 relative; reasons and counts exact. Engine test src/lib/fit/viability-normalisation.test.ts |
reporter-ratio.json (written by oracles/reporter_ratio.py in the oracle image, Oracles run 38424169331, SHA-256 4da6fc0c…; published as 0.11.0) |
Python fractions and 60-digit decimal; R 4.4.3 median as the cross-check (r/reporter_ratio.R) |
16 plates (background off and on, Renilla at background, below blank + 3 SD with 6 and 5 blanks, identical blanks, negative net firefly, 7 and 8 wells for the median with the exact 0.5× edge, 2 and 3 control wells, a format mismatch, unassigned, swapped channels, an exclusion, ratio of means against mean of ratios) | 1e−12 relative; states, flags and well lists exact. Engine test src/lib/fit/reporter.test.ts |
qpcr-efficiency.json (written by oracles/qpcr_efficiency.py in the oracle image, Oracles run 38424165179, SHA-256 c09f8576…; published as 0.11.0) |
Least squares on level means in Python fractions and 60-digit decimal, scipy.stats.t; geNorm by hand in decimal; R 4.4.3 lm, confint and sd as cross-checks (r/qpcr_efficiency.R), the script stopping on disagreement |
12 series (a doubling series, a 90 percent series, two and three levels, a rising slope, undetermined wells, a series left with two levels, E above 2, quantities against fold dilutions, an excluded well, a zero quantity) and 5 geNorm tables (the hand case, two genes, an incomplete sample, an entered E, two samples) | 1e−9 relative; states, counts, dropped levels and samples exact. Engine test src/lib/assay/qpcr/efficiency.test.ts |
relative-potency.json (written by oracles/relative_potency.py in the oracle image, Oracles run 38425424281, SHA-256 dc7408c0…; published as 0.11.0; coverage on case 1: ρ profile 94.9 percent, Wald 88.9 / 89.2 / 90.0 percent for the Hill ratio, Top and Bottom; drc's SEs differ by up to 3.7 percent, recorded) |
SciPy least_squares through curve_compare.py (imported unchanged), central-difference covariances, scipy.stats.t; R drc 4.0-0 LL2.4 compParm (b ratio, c and d differences) and anova(constrained, separate) as cross-checks (r/relative_potency_drc.R), the script stopping on disagreement; a 2,000-replicate coverage run of case 1 recorded |
9 (a shift by ρ = 2, slopes differing, tops differing, a precise trivial difference, a weak test, rising curves, three concentrations refused, opposite directions refused, margins just outside the interval and margins not bracketing 0) | RSS 1e−6, F 1e−5, p 1e−4 relative; ρ, ratio and differences 1e−5 relative; profile bounds 1e−3 log10; states and classes exact. Engine test src/lib/fit/relative-potency.test.ts (the noiseless shift and the closed margin bound are hand cases there) |
calibration-limits.json (written by oracles/calibration_limits.py in the oracle image, Oracles run 38424157131, SHA-256 bbf7db9f…; published as 0.11.0) |
Exact rational least squares and limits in Python fractions with 60-digit decimal square roots; noiseless 4PL standards read through the generating curve's closed-form inverse; scipy.stats.t for the trend's quantile; R 4.4.3 mean, sd and confint(lm(...)) as cross-checks (r/calibration_limits.R), the script stopping on disagreement; chemCal's calibration-based lod/loq not run (a different quantity) |
14 (linear limits with and without subtraction, a falling 4PL, one and five blanks, identical blanks, below the lowest standard, the limit of quantification above the highest, a rising 4PL plateau above both thresholds, an excluded outlier blank, four series: one dilution above range, two in range, a matrix trend, an exactly proportional sample) | Linear 1e−9 relative; 4PL limits 1e−6 relative; blank SD 1e−12; states, reasons, notes and counts exact. Engine test src/lib/fit/calibration-limits.test.ts |
plate-diagnostics.json (written by oracles/plate_diagnostics.py in the oracle image, Oracles run 38424153611, SHA-256 a24d3314…; published as 0.11.0) |
Hand formulas in Python fractions and 60-digit decimal (SW, SSMD, Z, Z′, Welch t and df); NumPy least squares for the additive model; scipy.stats for p; R 4.4.3 t.test(var.equal = FALSE) and drop1(lm(y ~ factor(row) + factor(col)), test = "F") as cross-checks (r/plate_diagnostics.R), the script stopping on disagreement |
17 plates (the CSO's fifteen cases: the hand case, one-well controls, no vehicle spread, no spread, coinciding means, inverted roles, a dose-response plate, 15 and 16 samples, controls in the outer columns, a 384 plate with a row gradient, a single-row group, residual df 9 and 10, exclusions, a zero-spread group, Welch with unequal n and variances) | Values 1e−9 relative; p 1e−8 absolute; n, df of F, states and reasons exact. Engine test src/lib/fit/plate-diagnostics.test.ts |
replicate-summaries.json, zprime-degenerate.json (0.2.0, 0.4.0) |
Python fractions/decimal; R 4.3.3 by the definition |
2 (the MTT plate with and without A9); 6 degenerate Z′ sets | 1e−9 relative; n, wells and refusals exact |
growth-window.json (0.2.0) |
Exact rational OLS for whole doublings; SciPy linregress for noisy cases |
9 | μ and doubling time 1e−9 relative; residuals 1e−9; withheld states, rows and n exact; hours-to-minutes pair 1e−12 |
michaelis-menten.json (0.2.0) |
SciPy curve_fit (trf, box as bounds, log parameters, three starts, tolerances 1e−14) |
8 (noiseless, noisy, kcat, kcat refused, unsaturated, bound, two refusals) | Km, Vmax, kcat 1e−5 relative (hand values 1e−9); RSS 1e−6 relative; correlation 1e−4; states and flags exact. Failed convergence is unit-tested only |
michaelis-menten-profile.json (0.4.0); michaelis-menten-profile-v2.json (0.5.0, the engine test's) |
SciPy least_squares (trf) on the nuisance parameter, f.ppf, brentq; in -v2 also a direct profile computed in R 4.4.3 from its own nls fit (7 of 7 cases with an interval; confint recorded beside it, 4 of 7) |
9 | Resolved bounds 1e−3 ln units; threshold 1e−8 relative; side states, reasons, the poorly-determined flag exact; kcat bounds 1e−3 relative; unit-scaling rows 1e−6 |
units-molecule.json (0.11.0) |
Python decimal at 40 digits from typed CIAAW 2021 abridged weights and ExPASy ProtParam residue masses (units_molecule.py, standard library) |
20 (each mass unit, a salt in both forms, a hydrate, S/Cl and Br, insulin precursor and B chain under both tables, per-chain oligomer, kcat with n = 2 and f = 0.8 and through a chain mass, refusals) | Formula weight 1e−4 g/mol; protein 0.02 Da (ProtParam), 0.01 Da (CIAAW); molar, active enzyme and kcat 1e−9 relative; refusals and the used formula exact. Recorded in the oracle image on 13 October 2026 |
layout-suggestion.json (0.12.0; Oracles run 38458144391) |
Python fractions for the trigger arithmetic, p compared in binary64 (layout_suggestion.py, standard library) |
16 (quiet; edge, column and edge-plus-rows on 96; both rules on 384; the 0.05/m boundary from both sides; the effect floor from both sides; sample-group rows; two plates, m = 18; a full interior; every control on the ring; three refusals) | Decisions, m, rules, refusals and counts by role, compound and concentration exact; the layout checked by its properties |
inhibition-global.json (0.4.0); inhibition-global-v2.json (0.5.0, the engine test's) |
SciPy least_squares (trf, nine starts); in -v2 also R 4.4.3 nls on the 11 fits without bound contact (11 of 11) |
18 (analytic reductions, noiseless recoveries, adverse model choices, the noisy matrix by each model, mM and row-order rows, four refusals) | Constants 1e−5 relative (hand values 1e−9); RSS 1e−6 relative; correlations 1e−4; states, named level and parameters at the box exact |
inhibition-compare.json (0.8.0; recorded in the oracle image under the CPU-class pin, 12 October 2026) |
SciPy least_squares (trf, nine starts) for all four models, AICc and the nested F by hand, p from scipy.stats.f.sf; R 4.4.3 nls, AIC and anova on the 6 compared cases (6 of 6) |
8 (one per model, an ambiguous near-noncompetitive set whose two lowest lie within 2 AICc units, mixed at a search bound, an exact fit withheld as zero-residual, a refusal) |
Ranking, outside list, states and refusals exact; constants 1e−5 relative; RSS 1e−6 relative; AICc and ΔAICc 1e−6; F 1e−5 relative; p 1e−4 relative. Engine test src/lib/kinetics/inhibition-compare-oracle.test.ts |
substrate-inhibition.json (0.8.0; recorded in the oracle image under the CPU-class pin, 13 October 2026) |
SciPy curve_fit (trf, log parameters, three starts); Michaelis–Menten by michaelis_menten.py's method for the AICc reference; R 4.4.3 nls on the 4 estimates (4 of 4) |
9 (noiseless hand values, noisy duplicates, mM and row-order rows, no decline, Ksi at its bound, one level beyond the optimum, two refusals) | Constants and S* 1e−5 relative (hand 1e−9); RSS 1e−6 relative; correlations 1e−4; ΔAICc 1e−6; F 1e−5 relative; p 1e−4 relative; states and the beyond count exact. Engine test src/lib/kinetics/substrate-inhibition-oracle.test.ts |
progress-curve-rates.json (0.7.0, first recorded for 0.6.0 on its branch and renumbered at merge; recorded in the oracle image, Oracles run 38323364254) |
Exact rational OLS (Python fractions) on straight lines; SciPy linregress on a six-level plate from the integrated Michaelis–Menten equation (lambertw) with a fixed noise vector, in two windows; SciPy curve_fit for Km and Vmax on the plate's window rates; R 4.4.3 lm on the 25 estimated wells (25 of 25) |
12 (rising and falling, seconds, mM and mass units, wrong direction, a missing read, a marker, too few reads, the plate early and late) | Slope, intercept, rate and conversion 1e−9 relative; residuals 1e−9; withheld wells, reads used and the strained flag exact; Km and Vmax on the window rates 1e−6 relative |
inhibition-profile.json (0.11.0; recorded in the oracle image) |
A SciPy direct profile on ln Ki and ln Ki′ (least_squares trf refits, f.ppf, brentq); coverage on two designs, 1,000 replicates each; R not named |
7 (one per model, a weak inhibitor range, a zero residual, a bound fit) | Bounds 1e−3 ln units; threshold 1e−8 relative; states, reasons and the poorly-determined flag exact. Engine test src/lib/kinetics/inhibition-profile-oracle.test.ts |
thermal-tm-intervals.json (0.11.0; recorded in the oracle image) |
thermal_shift.py's fit, a SciPy direct profile on Tm, statistics.mean and stdev, r1 by hand; coverage under independent, AR(1) and misfit designs, 1,000 melts each; R not named |
7 melts, 4 replicate groups, 2 residual vectors | Replicate mean and SD 1e−3 °C; r1 1e−6; the withheld profile's bounds 1e−3 °C and threshold 1e−8 relative; n, the wells not counted and the states exact; the coverage table recorded with the CSO's decision. Engine test src/lib/thermal/tm-intervals-oracle.test.ts |
ki-bounds.json (0.11.0; recorded in the oracle image) |
Python fractions |
10 | Ki and both bounds 1e−12 relative; the four-figure display exact; refusals exact. Engine test src/lib/kinetics/ki-bounds-oracle.test.ts |
binding.json (0.11.0; binding.py, r/binding_nls.R; R agrees on 20 of 20; coverage Kd 94.6, Bmax 94.0 with NS fitted, IC50 95.0, the subtracted route about 90 and withheld) |
SciPy least_squares (trf, log constants, three starts per model); profiles by refits and brentq; Munson–Rodbard against the exact one-site mass balance (brentq); R nls (port) and drc LL.4 with the slope fixed at 1 |
24 (saturation hand cases by both NS routes, noisy triplicates, pM, counts and row-order rows, a freed constant, the depletion and extrapolation flags, two sites and its three withholdings, competition with Bottom free and fixed, Hill free, IC50 outside the range, two sites, refusals); coverage runs for Kd, Bmax (both routes) and IC50 | Constants 1e−5 relative (hand 1e−9); RSS 1e−6; correlations 1e−4; interval bounds 1e−3 ln; F 1e−5; p 1e−4; Ki 1e−5; states exact. Engine test src/lib/binding/binding-oracle.test.ts |
hill-kinetics.json (0.11.0; hill_kinetics.py, r/hill_nls.R; R agrees on 7 of 7) |
SciPy curve_fit (trf, log parameters, three starts); the h profile by refits and brentq; R nls |
11, and the coverage run on h (2,000 replicates at h = 1 and 2.5: 94.6 and 95.5 percent in the image) | As substrate-inhibition.json, plus h bounds 1e−3 ln. Engine test src/lib/kinetics/hill-oracle.test.ts |
bisubstrate.json (0.11.0; bisubstrate.py, r/bisubstrate_nls.R; R agrees on 15 of 15) |
SciPy least_squares (trf, nine crossed starts, eighteen for sequential); R nls |
11 | Constants 1e−5 relative (hand 1e−9); RSS 1e−6; correlations 1e−4; ΔAICc 1e−6; F 1e−5; p 1e−4; states exact. Engine test src/lib/kinetics/bisubstrate-oracle.test.ts |
morrison.json (0.11.0; morrison.py, r/morrison_nls.R; R agrees on 9 of 9) |
SciPy least_squares on the textbook form; hand rates in 50-digit decimal; R nls (port) |
15 | v0, Ki_app, [E] 1e−5 relative (hand 1e−9); RSS 1e−6; correlations 1e−4; states and counts exact. Engine test src/lib/kinetics/morrison-oracle.test.ts |
slow-binding.json (0.11.0; slow_binding.py, r/slow_binding.R; R agrees on 15 of 15 once the cross-check applied its stated resolution rule to standard errors at an exact line) |
Exact rational least squares (fractions) and scipy.stats.t for the line; SciPy least_squares for the hyperbola (four starts); R lm, confint and nls |
13 | The line 1e−9 relative (standard errors at an exact line 1e−12 of the estimate); hyperbola 1e−5 relative; states exact. Engine test src/lib/kinetics/slow-binding-oracle.test.ts |
ki-cheng-prusoff.json (0.2.0) |
Python fractions |
11 (six values, five refusals) | Ki 1e−12 relative; four-significant-figure display and refusals exact |
thermal-shift.json (0.4.0); thermal-shift-v2.json (0.5.0, the engine test's) |
SciPy curve_fit and hand finite differences; in -v2 also R 4.4.3 nls(SSfpl) on the 7 fitted melts (7 of 7) |
13 synthetic melts | Sigmoid Tm 1e−3 °C; ln k 1e−4; plateaus 1e−4 of the span; RSS 1e−6 relative; derivative values 1e−9 relative and its temperature exact; refusals and the disagreement flag exact |
curve-compare.json (0.10.0, A4); curve-compare-v2.json (0.11.0, the engine test's: the same cases, the drc comparison's two rules stated) |
SciPy least_squares global fits and a hand F, the δ profile by brentq, Wald from a central-difference Jacobian; R 4.4.3 drc 4.0-0 (curveid, pmodels, anova, compParm, EDcomp) as the cross-check on the 11 compared cases: agrees in -v2 (RSS and F about 1e-15, p 3e-7 where above 1e-12, δ 4e-8, Wald SE 0.7 percent at 1e-2); 0.10.0 recorded "differs" on those two definitions |
12 (11 synthetic, the MTT plate's Compounds A and B); coverage 2,000 replicates per design | RSS 1e−6, F 1e−5, p 1e−4 relative; δ and Wald bounds 1e−6 log10; profile bounds 1e−3 log10; withholdings exact (src/lib/fit/global.test.ts) |
negative-result.json (0.11.0, run 38458457419; A-4 item 12) |
Standard-library Python: exact within-block enumeration as fractions, the seeded xorshift32 stream ported, and exact enumeration (block sums convolved) where the orderings number at most 1,000,000 |
16 (flat, sampled, monotonic, four unreplicated, two plates blocked and unconfirmed, full inhibition, bell-shaped, five plates of two doses, a table, an excluded top, the three admitted refusals, a responding not-converged curve, an excluded refusal) | Exact p equal as fractions; sampled p equal to the port, and within 4√(p(1−p)/2000) + 1/2000 of exact where computed; every exact p outside that band around 0.05 except the five-plate design, marked nearThreshold; verdicts, range and experiments exact; means 1e−12 relative (negative-result.oracle.test.ts) |
experiment-summary.json (0.10.0, A4) |
Python fractions and scipy.stats.t.ppf; R 4.4.3 t.test on the 6 cases with an interval |
11 | mean, SD and bounds 1e−9 log10; quantile 1e−8 relative; the rest exact (src/lib/fit/experiments.test.ts) |
qpcr-welch.json (0.7.0; A6) |
Hand Welch computation from the Cq tables (NumPy, scipy.stats.t.ppf), written in the oracle image under the CPU-class pin (Oracles run 38323567398); R 4.4.3 t.test and SciPy ttest_ind(equal_var=False) as cross-checks, 8 of 8 each |
10 cases (ΔΔCq, entered efficiencies, unequal n and variance, two references, two runs, an unresolved sample, a flat calibrator side; treated n 2 with three technical wells each, calibrator n 2, no scatter); 8 intervals, 15 withheld groups | Normalised quantities, differences, SEs and bounds 1e−9; df and t 1e−9 relative; n, statuses and reasons exact (src/lib/assay/qpcr/welch-oracle.test.ts) |
growth-gompertz.json (0.7.0; A6) |
SciPy 1.13.1 curve_fit (trf on the same box, twenty fixed starts), written in the oracle image under the CPU-class pin (Oracles run 38323763752); R 4.4.3 nls(algorithm = "port") from the SciPy estimates, 6 of 6 interior fits within 1e−4 |
9 (noise-free recovering its parameters to 1e−8, noisy, no-lag, no-plateau, plateau-not-reached, A on its bound, nonpositive, too few times, duplicate wells with an exclusion) | A, μm 1e−5 relative; λ 1e−5 of the span; RSS 1e−6 relative; residuals 1e−6; statuses and reasons exact (src/lib/growth/gompertz-oracle.test.ts) |
degenerate-v1.json (0.16.0; degenerate_reference.py; the CSO's audit-fix memo of 20 October 2026, item 1 (c)) |
Refusals decided in exact decimal arithmetic (Python fractions on the decimal inputs; the exact log-rank covariance; 60-digit decimal for the noiseless melt's generating curve); numbers from numpy and scipy. R was not available: the survival and two-way cases the memo names for R are exact fractions and numpy least squares, recorded in about.crossCheck |
60: the memo's 16 cases across Z′, the four normalisers, screening, plate diagnostics, growth window, Gompertz, GR, the qPCR Welch interval, two-way ANOVA, simple effects, the mixed model, survival, slow binding and the thermal residual diagnostic, plus one guard | Statuses, codes, reasons, rows and wells exact; Z′, SDs, percents, signal window and SSMD 1e−9 relative (an exact 0 within 1e−12); robust z 1e−12 relative; growth μ 1e−9 relative; GR arithmetic 1e−9; qPCR bounds 1e−9 (log₂); two-way 1e−9 relative, p 1e−8; survival 1e−9 relative, p 1e−8. Engine test src/lib/stats/degenerate-oracle.test.ts |
plate-edge-spread-v1.json (0.18.0; plate_edge_spread_v1.py, reference-first commit e2e59e312) |
CPython 3.12.14 standard library: exact fractions for per-side admission, means, variances, t² and df; bounded adaptive Simpson integration of the Student-t sine transform for p, absolute integration target 1e−13, hard depth/evaluation limits and failed generation on nonconvergence; 34 analytical/identity checks. No R or SciPy cross-check | 26 physical plates: the original four edge 0.3 versus fifteen interior 0.1, equal constants, rounding bounds and binary ULPs, tiny/large scales, each side flat, unequal n, both varying, equal means, four-well floors, exclusions, 96/384 rings, role/concentration separation and a control span | States, counts and reasons exact; means, Welch t and df 1e−9 relative (exact zero within 1e−12 absolute); p 1e−8 absolute, with no extreme-tail relative-accuracy claim. src/lib/fit/plate-edge-spread-oracle.test.ts also verifies downstream withholding and unchanged layout-rule version. Append-only manifest references the original 0.11.0, 0.16.0 and 0.17.0 paths and hashes without copying their files |
plate-walk-refit.json (0.3.0) |
R 4.3.3 fit_curve from reference.R, drc 4.0-0; permutation p with 100,000 Monte Carlo permutations, set.seed(20261010) |
1 (Compound A without A9) | The estimate and interval tolerances above; Z′ unchanged; the gate's decision and p floor |
The Wave 1 sets are compared by src/lib/fit/oracle-fixtures.test.ts (calibration, replicates, degenerate Z′). Its integrity test exhaustively checks every local fixture through the disjoint frozen 0.16.0 central manifest and the explicit supplemental 0.17.0 and 0.18.0 manifests, verifying each against its own source hash and publication bytes. Legacy embedded source-hash assertions remain unconditional; 0.17.0 has its explicitly different manifest-bound schema. Earlier full-snapshot publications, including 0.7.0's byte-identical copies of A4's and A5's first publications, remain unchanged. Other Wave 1 comparisons are src/lib/growth/window-oracle.test.ts, src/lib/kinetics/michaelis-menten-oracle.test.ts, michaelis-menten-profile-oracle.test.ts, inhibition-oracle.test.ts, ki-oracle.test.ts, progress-rates-oracle.test.ts (also the stored source's reader and refusals) and src/lib/thermal/apparent-tm-oracle.test.ts. The R cross-checks the CSO's memos name (confint for the profile intervals, nls(SSfpl) for thermal shift, nls for the inhibition fit) were not run, because R was not installed in the build container; each fixture records this in about.crossCheck.
src/lib/fit/fixtures.test.ts first checks every fixture hash against manifest.json. The two weighted curves (C24, C25) are checked only under historicalRelativeLoss against the archived version 1 direct-loss oracle; production IRLS is checked by irls.test.ts. Version 1 reporting outcomes from the old F-gate are archival and are not asserted for version 2.
Unit tests
Run with node --test (see Build, test and CI). On 10 October 2026 the Assay-related files (src/lib/fit/*.test.ts, src/lib/table/*.test.ts, src/lib/assay/qpcr/*.test.ts, src/components/tools/assay-analyser/**/*.test.ts, src/apps/assay-analyser/*.test.ts) ran 198 tests, all passing; 11 of those are Bioreactor's ode.test.ts. Coverage by file:
| File | Covers |
|---|---|
src/lib/fit/fit.test.ts |
Distribution functions, LM, gradient against finite differences, canonical naming, starts, determinism, rule (i), absolute C50 and bend constant, normalisation modes, Z′, AICc and F |
src/lib/fit/science.test.ts |
Audit regressions: flat data never identify a midpoint; step respects fixed plateaus; zero-residual fits give a point but no interval; exact permutation p and block confounding; no weighted intervals; exhaustive five-dose flat null; unit-invariant flatness |
src/lib/fit/fixtures.test.ts, irls.test.ts, calibration*.test.ts |
Oracles as above; calibration arithmetic, blanks, grouping, range, readers, hashing, CSV round trip |
src/lib/table/kinetic.test.ts, src/components/tools/assay-analyser/kinetic-import.test.ts |
Every read of every well of the four synthetic kinetic files (plates/kinetic-*, expected.json "kinetic") read back exactly; the axis and unit; markers and missing reads; temperatures as metadata; headers discarded; the endpoint plate; the import's words; the v6 record and its refusals |
src/lib/table/response-template.test.ts |
The template round trip: Calc's rows from a planned sheet, the exact header, a filled template read back with zero unmapped columns and every reading at its well, blanks rejected, unplaceable rows unresolved, two plates sharing a layout, 384 wells, a non-molar unit, a template without wells as a long table |
src/lib/table/table.test.ts, plate-placement.test.ts |
Every synthetic plate file read as written with the header discarded; decoding; tables and units; plate map; placement refusals |
src/lib/assay/qpcr/qpcr.test.ts, import-state.test.ts, welch-oracle.test.ts (the 0.7.0 interval fixture and the strict v2 reader) |
Exact ΔΔCq; Decimal-precision Pfaffl fixtures (oracle.py); references in either order; missing/undetermined handling; calibrators per run; overflow; readers; exports |
src/components/tools/assay-analyser/*.test.ts |
Address round trip and share keys; MTT through the app path; exports text; old results withheld; D-0028 policies; table decisions; placement; presets; repeat; research record; published example placement |
src/apps/assay-analyser/*.test.ts |
Recorded results; calibration and qPCR persistence; example exports; address ref |
src/lib/kinetics/*-oracle.test.ts, src/lib/growth/window-oracle.test.ts, src/lib/thermal/apparent-tm-oracle.test.ts |
The Wave 1 oracles above, metamorphic rows (unit scaling, row order, hours and minutes, a 1 °C shift), the analytic reductions of the inhibition models, the export and record columns; 61, 14 and 18 tests, all passing on this tree (11 October 2026, Node 22.22.0) |
src/apps/assay-analyser/{kinetics,inhibition,growth,thermal}-persistence.test.ts |
Each Wave 1 mode through the stored model, the reader, the recorded result (states with no numbers), the repeat and the exports |
src/lib/figure/figure.test.ts, src/components/tools/assay-analyser/figure/figure.test.ts |
The scene's markers, domains and text helpers; layout geometry (inside the figure, tick labels apart at 40 to 300 mm, unique group names, one clipped group per series, overlay legends, the caption); the SVG writer's contract; the MTT scene against its fixture; the TIFF and PDF writers against their oracles (tiff.test.ts, pdf.test.ts); the v2 reader's refusals, the v1 migration with every v1 field, the series cycle and the MTT document; the caption's clauses on the MTT record (display labels, hidden curves, bars and n, excluded wells drawn or not, relative weighting, rows) |
src/components/tools/assay-analyser/mode-readers.test.ts, scripts/assay-chunk-imports.test.mjs, scripts/assay-static-methods.test.mjs, chooser/chooser.test.ts |
The workspace chunk's structure (the CTO's ruling of 13 October 2026): a mode's record pending until its reader loads, the core's refusals first, the async read strict, every mode in the registry; no reader in the workspace's static closure and no mode model or record imported statically by model.ts, the workspace, its adapter or its exports; each island's chips current and every chip key a literal; one keyword set per chooser entry, none in the list |
src/components/tools/assay-analyser/chart-replicates.test.ts, planned-sheet.test.ts; src/lib/table/sheet-placement.test.ts; src/lib/fit/calibration-protocol.test.ts |
Replicate summaries; the received plate sheet; sheet placement (blanks written, vehicle and positive never written, collisions, stale proposals); kit presets never change a number |
Browser tests
e2e/assay-analyser/workspace.spec.ts (SoftMax paste with header discarded; keyboard plate map and fill series; exports carrying the settings; a 400-curve batch cancelled after a chunk; 375 px layout; H20, H21, H23, H25; numbers replaced on refit and removed on delete; synthetic data writing nothing public; D-0028 intent and four-dose cases), e2e/assay-analyser/tasks.spec.ts (task pages, island chunks, docs verification tables), e2e/assay-return-journey.spec.ts (retained originals, exact historical repeat, mapping repair), e2e/qpcr.spec.ts, and e2e/scientific-continuity.spec.ts (calibration). Wave 1 adds e2e/assay-analyser/chart-plate.spec.ts (exclusion by mouse and keyboard, ranges, replicate bars), standard-curve.spec.ts, growth.spec.ts, kinetics.spec.ts, ki.spec.ts, inhibition.spec.ts, thermal.spec.ts and plate-walk.spec.ts; Wave 3 adds e2e/lab-calculators/plate-sheet-handoff.spec.ts (the sheet reaching Assay's plate map; passed on desktop, phone and dark on 12 October 2026). The depth track adds e2e/assay-analyser/round-trip.spec.ts (the response template and its sidecar, the plate sheet in the bench sheet's CSV and print, a filled template imported as a plate with zero unmapped columns, the declared controls assigned by keyboard, normalised and fitted with Z′, the visible kept refusal) and round-trip-walk.spec.ts (Calc plan → template → Send sheet → arrival → import → Place → assign → fit → Save to Bench and Record → the Study's Work → Repeat → plate 2 recorded, timed; the times are in docs/progress/assay-depth-1.md). Step 6 adds e2e/assay-analyser/views-on-demand.spec.ts (the small-multiples figure exported and Print run on a page where Fit and Report were never opened; desktop and desktop-dark, the export menu's flow being covered on desktop as in workspace.spec.ts). Stage A's A-0 adds e2e/assay-analyser/open-conflict.spec.ts (a file of saved work asks before it opens: Open as new work or Replace), figure-settings.spec.ts (the four sections, a series' colour and label reaching its chart, the too-small line and its key, the Report's caption following the settings and copying) and figure-export.spec.ts (the figure's four formats from the scene). e2e/assay-analyser/routine-week.spec.ts is the routine-week acceptance suite (Stage A, exit criterion 2): one test per criterion whose feature is built (3, normalisation with the control wells named; 4, per-curve and all-curve fixes in the table, method block, Prism CSV and R snippet; 7, Compare's F test and its rule; 8, the figure's layouts, per-series colour and symbol, the mean ± SD bars with n in the chart, SVG and caption, and an SVG that parses; 9, the method paragraph and caption from the same settings, each copied; 11, Z′ per plate; 12, sending to the PI), by keyboard on the three projects; the rest are listed with what is missing and their owners in docs/progress/assay-acceptance.md. e2e/assay-analyser/free-path.spec.ts checks the free/paid line. e2e/perf/assay-two-second.spec.ts times the IC50 calculator against 3,000 ms (D-0076, 14 October 2026; 2,000 ms before) from navigation to answer with a primed cache, 4× CPU and slow 4G; the cold load is reported only. Under D-0065 ruling 4's protocol (the CTO's memo of 14 October 2026 on exit criterion 6) it takes five warm runs and reports the median with the fastest and slowest, attached as assay-two-second.json. Both Assay timing specs share e2e/perf/assay-load.ts: after priming they wait for the one-minute load to fall to the core count (at most 120 s, the wait recorded in settleWaitMs), record the load before and after, and read the result as informative on a 2-vCPU runner (never a pass or a failure), inconclusive above the core count, else within or over; the target is 3,000 ms (D-0076), the pass is judged on a quiet 4-core reference machine, and both report without gating under playwright.perf-report.config.ts until the pre-live pass re-arms them. The audit records show this gate failing intermittently in CI (2,043 to 4,095 ms on several runs between 7 and 10 October) and passing on others. The first fit's cost (CTO ruling 7, 12 October 2026): src/lib/fit/first-fit.bench.test.ts times analyseCurve on that gate's table in Node, median of 20, failing above 60 ms; it read 8.9 ms (the fit 1.4, Wald 0.2, profile 1.6, the rules 4.3 of which the response-evidence permutation test 4.1), so the engine is not where the time goes. e2e/perf/assay-workspace-fit.spec.ts (reporting, playwright.perf-report.config.ts) times Fit pressed to results on the 96-well example at CPU 4×, to the data-results-ready stamp the results region sets to results.at once the fitted rows render; its figures, and the IC50 spec's, are copied by hand into timing in content/apps/assay-analyser.verification.ts (first: median 897 ms on a 4-core build container, 12 October 2026). Each entry names its case (ic50-page or workspace-first-fit), date, commit, runner, cores and load, and ci for a CI runner; CI never writes them. The docs page's Methods section (About › Methods) shows them through the generated timing block (AssayTimingBlock in src/apps/assay-analyser/verification-table.marketing.tsx; content/apps.ts's DocsBlock source timing, rendered for Assay only), a CI row read "2-vCPU" and, above 2,000 ms, "over target"; e2e/assay-analyser/docs-timing.spec.ts checks it. The same spec times a 115-plate paste (0.26 MiB) on the start: read in 2,176 ms and placed in 779 ms at CPU 4× (reported only; Stage B).
What is not verified
- No agreement with GraphPad Prism has been checked; no claim is made.
- The permutation gate has no external oracle fixture; it is checked by exhaustive-null and theory tests only. Calibrated Type I error and power across designs (audit F3) remain outstanding.
- Weighted (IRLS) inference is not implemented; IRLS point estimates are checked against SciPy only.
- qPCR relative quantities stay
unit-tested(exact ΔΔCq and the Decimal Pfaffl cases), with no published fixture. Their Welch interval is published asqpcr-welch-interval(0.7.0) and isverified. (Calibration is published ascalibration-linearandcalibration-4pl; the 5PL ascalibration-5pl(0.11.0),verifiedon 14 October 2026; its inverse-prediction interval, method 2.0.0, ascalibration-inverse(0.13.0),verifiedon 14 October 2026: R investr agrees on 11 of 11 intervals (unknowns read once) and the simulated coverage (linear and 4PL) is 94.4 to 95.5 percent.) - The R cross-checks named for the profile intervals, thermal shift and inhibition fits were not run for the 0.4.0 files (R absent in the build container). The 0.5.0
-v2files, recorded in the oracle image under the CPU-class pin on 12 October 2026, carry them: R 4.4.3nlson 11 of 11 inhibition fits,nls(SSfpl)on 7 of 7 melts and a direct profile from R's own fit on 7 of 7 profile cases with an interval; R'sconfintis recorded beside it and agrees on 4 of 7, its spline interpolation falling inside the region on the unsaturated lower sides. The engine tests and badges use the-v2files. Failed convergence of the initial-rate fit has no fixture case. - The OD600 calibration Calc builds on
fitCalibration(see Calc) is unit-tested only. - The Q-19 independent reviewer has not signed the fixtures or the method page.
- The bend-point paper (Sebaugh and McCray 2003) is recorded as not yet read.
Known gaps, defects and divergences
Code facts found while writing this page; Known issues collects them suite-wide.
Outliers and planning (A-2 family 7), 13 October 2026
- The power plan's note is not pushed into a Calc plan (the CPO's ruling of 13 October 2026;
docs/NEXT.md): it is copied, recorded with the plan, or added to the Study's notes. - Recorded 13 October 2026 (0.11.0) with one finding: R's fixture lists values whose studentised residuals are equal but for rounding in the order of their last bits; the engine orders such ties by column and row, and the oracle test compares a tie's position only within its tie.
Stale comments and copy
src/lib/fit/four-pl.ts:11-14says 1/Y² minimises (y − f)/f directly; version 2 uses IRLS and keeps the direct loss only for archived fixtures. The same header andrules.ts/analyse.tsstill use "no finite minimum" wording that the audit asked to replace.src/lib/fit/rules.ts:10-12describes rule (ii) as the flat-line F-test; the code gates on the permutation test.src/lib/fit/analyse.ts:55says 60 sampled points; the code samples 61.src/lib/fit/rules.ts:188-189computesatAboveand discards it.content/apps/assay-analyser.ts:32says stored results stay until a refit and differences are shown;readAssayData(model.ts:641-644) drops results from another fit version on load (D-0028 §4). Consequently the "keep the old results as a version" branch inrunFit(assay-workspace.tsx:810-815) and the differences banner cannot see old results loaded throughreadAssayData.content/apps/assay-analyser.ts:48andcontent/tools/assay-analyser.ts:17,100say "any parameter fixable"; the UI can fix only Top = 100 and Bottom = 0 (percent scale) and Hill = ±1 (FIX_KEYSinaddress.ts,fixedForinmodel.ts); logC50 cannot be fixed and no other values can be entered. The engine supports any fixed value.content/tools/assay-analyser.ts:100says only the 4PL is fitted and "no ELISA"; the calibration mode fits linear standard curves.content/tools/assay-analyser.ts:107says a larger import "is split before fitting"; the code refuses a batch above 400 and asks the person to split it.- The FAQ and the tools copy say "four control sets for Z′" while
FIXTURE_COUNTS.zPrimeSetsis 5 (the MTT plate counted); flagged by the 10 October audit. - D-0043 asks that the bend-point constant carry "Unverified: not yet checked against {source}"; the copy and method record still say "paper not yet read" (
content/tools/assay-analyser.ts:61,src/lib/fit/methods.ts:100).
Behaviour and robustness
model.ts:593casts storedresultstoStoredResultsafter checking onlycurvesis an array and the version matches; session files are user-supplied, so malformed curves reach the views. The session reader (assay-workspace.tsx:316) checkskindbut notschemaVersion; sessions writebuildHash: "".- A table source defaults to the percent scale ("already percent"), so a raw table left on the default gets percent-scale rules (absolute C50 against 50, fixable plateaus at 0 and 100). Table control roles never normalise.
- Blank wells are read but unused in dose response.
- When a fit is refused after fitting (
unidentifiable,not-converged), no rules run, so the permutation p is never computed and noisy flat data read as a solver failure rather than D-0028 §2's descriptive curve with a reason (10 October audit). - The Monte Carlo permutation p depends on observation order (the same points in another order gave 0.0005 and 0.0010 in the audit).
- Calibration: a sample exactly at the zero-standard response can be reported as not interpolable because the sign check precedes the tolerance (
calibration.ts:26); all 4PL refusals share one generic message; a 4PL with five levels (one residual degree of freedom) is accepted without a qualification. - The results CSV has no weighting, fixed-parameter, normalisation or fit-version column; those are only in the provenance sidecar and the
source_versioncolumn (audit F5). - The recorded summary writes the C50 as an unformatted float string (
workspace-adapter.ts:105).
Wave 1 analyses and the plate sheet (open questions in docs/progress/wave1b-assay.md and docs/NEXT.md, checked in the code)
- R cross-checks for the profile intervals, thermal shift and the inhibition fit: not run for 0.4.0 (R absent); run in the oracle image for the 0.5.0
-v2files and recorded in each one'sabout.crossCheck(CSO sign-off of 12 October 2026,docs/board/memos/2026-10-12-cso-fixtures-0-5-0-sign-off-and-badge-words.md). - The initial-rate result CSV still exports the RSS of a
boundorfailedfit (kinetics-export.ts:47), a point where the search stopped rather than a minimum; the inhibition export leaves it out. The "poorly determined" flag is applied to Km only. - No Ki interval for the inhibition fit (the CSO allows one only with its own profile fixture); the generic
profile.tsis ready for it. - Fixed 12 October 2026 (CPO Q7): the kinetics and growth panels gave their own message line
role="status"while also announcing through the workspace, two polite regions; now, as thermal shift does, the line is a status only when no host announces (the task pages), so every page has one live region. The calibration and qPCR panels did the same (their message lines wererole="status"beside the host's announcement, two polite regions once a result showed); fixed 13 October 2026, their lines are plain text and the host announces, checked bye2e/assay-analyser/type-and-live.spec.ts. - Thermal shift has one melt per condition and no replicate mean or SD; ΔTm is a subtraction of two apparent Tms by the same method, possibly with different windows. Four builder's rules (the k box, the valley condition on a second transition, "failed" only at the iteration cap, a contradicted direction as a refusal) await the CSO.
- The Wave 1 modes and
plannedSheetwere added toAssayDatav5 without a new version (see The assay record). proposeSheetPlacementreports a well named by two sheet control types ascontrol-overlaps-dose, while Calc'splanPlateSheetcalls the same casecontrol-overlap.- Plate sheet follow-ups listed in
docs/NEXT.md: the response template now carries the sheet's wells and roles and imports as a plate (12 October 2026); the browser tests for the template round trip, the visiblekeptrefusal and the assign-then-normalise walk awaitmain's build (docs/progress/assay-depth-1.md); a vehicle or reference role with the direction chosen in Assay is left to a later wave (Q-27).
Layering and duplication
src/lib/fit/calibration-model.ts:1imports fromsrc/components/tools/assay-analyser/research-record.ts, andcalibration.tsandsrc/lib/assay/qpcr/*import content copy, so these "shared" libraries depend on app code and copy.src/lib/fit/calibration.ts:6repeatsFIT_VERSION = "2.0.0"as a literal instead of importing it; a version bump must change both.
Design versus code
- Brief §7 lists URL keys (
v,model,plan,example,handoff) that do not exist; the implemented keys are in the address table above. - Brief §4.1 says profile intervals reaching infinity are reported "unbounded"; D-0028 §5 and the code say "not resolved within the search".
- Brief §4.1 and the pipeline README's CSO rulings specify direct minimisation for 1/Y²; superseded by D-0028 §3 (IRLS).
- Brief §4.3 states relative tolerances; the code and copy use the CSO's absolute log tolerances.
- Brief §4A and
plate.tstreat several grids in one file as several plates; the reviewed import requires one block. docs/design/pre-release-app-depth/architecture.mdproposes 10,000 qPCR wells per analysis; the code allows 4,096 rows.
Verification housekeeping
public/verification/assay-analyser/0.2.0/relative-irls-reference.jsonis published but not listed in that directory'smanifest.json.- The original calibration fixture still lives in
src/lib/fit/fixtures/(write-once); its generator moved topipeline/assay-analyser/oracles/calibration_4pl_scipy.py, which reproduces it to about 1e−10 and writes the publishedcalibration-4pl-scipy.json. publish.pycopies every oracle fixture into each new version directory, so0.4.0holds byte copies of the0.2.0oracle fixtures beside the three new ones;0.3.0holds only the plate walk's row.- Size: the workspace chunk carries the Stage A cap of 250.8 KB against a release target of 120 KB (D-0048,
scripts/size-budgets/assay-analyser.json;build-test-and-ci.md). Step 6 (12 October 2026) moved the Plate map panel, the Fit results block and the Report body into their own chunks, each budgeted; the master-mix, calibration, qPCR and other mode panels were already dynamic. The Import view and the fit settings stay in the frame.
How to add a model safely
The constraints below come from the roadmap's rule 1 ("every new method ships with an independent reference fixture and a methods entry"), D-0028 §6 (science changes arrive as a branch with a findings record and full checks), the implementation record's list of decisions that must not change silently, and the version checks in the code.
- Engine. Write the model as a pure module in
src/lib/fit/, or in its own directory assrc/lib/kinetics/,growth/andthermal/are, with the stored model, the compute step and the method record in separate modules so the workspace frame loads no engine (relative imports, no React or DOM), from its published equation; never port GPL code (drc, nplr, GRmetrics are oracles only). Provide an analytic Jacobian and reuselevenbergMarquardtwith deterministic starts and a bounded box; keep the identification guard, the numerical-flat screen and limit classification semantics, or define their equivalents explicitly. - Method record. Add a record to
src/lib/fit/methods.tswith id, implementation path, version and citation, its entry with its verification state incontent/apps/assay-analyser.methods-core.tsfor a dose-response method or in the mode'sassay-analyser.methods-<mode>.ts(a new mode adds its file toMODE_METHOD_FILESand to the loaders inmode-methods.ts), equation text inEQUATIONS(assay-analyser.equations.ts), and rerun the prebuild that writescontent/apps/assay-analyser.static-methods/<island>.ts(its staleness test fails otherwise). - Independent oracle. Add a generator and an oracle under
pipeline/assay-analyser/(or a sibling), commit the fixtures with their SHA-256 in a manifest, state tolerances absolutely where the quantity is a logarithm, and add a test that checks the hashes before comparing. Register the set inverificationSetsincontent/apps/assay-analyser.tsand publish it under a newpublic/verification/assay-analyser/<version>/. - Inference. Do not reuse the unweighted Wald or profile machinery for a model with a different error model; return no interval until a calibrated method exists (D-0028 §3, audit F2 and F3). Keep response evidence and the reporting rules in front of any reported value.
- Versions. Changing what an existing fit returns means bumping
FIT_VERSIONand the four-pl method version together (and the literal incalibration.ts). Every stored result with the old version is then withheld on load and every object measurement is flagged for refit (requiresAssayRefitcomparesconditions.fitVersionwithFOUR_PL_METHOD.version). A new stored shape needs a newAssayData.v, added toASSAY_RECORD_VERSIONS(record-version.ts), whichreadAssayDataand every other reader use; a new field on v6 is added toV6_KEYSand its reader in the same commit; a new recorded output needs a new result schema id or version. - Settings and privacy. Any new setting goes into
assaySchemaandFitSettingsState; add it toSHARE_KEYSonly if it is a setting, never data. - Words and exports. Copy goes in
content/apps/assay-analyser.*files, never in components. Extend the method block, method paragraph, results CSV and R snippet so each states the model and its settings; keep "checked against", never "validated", and make no positive compliance claims. - Tests. Add unit tests (adversarial cases: flat, step, beyond range, fixed parameters, zero residual), browser tests for anything visible, and run
npm run lint,npm test,npm run build,npx tsc --noEmitandnpm run test:e2e.
Scientific audit repairs — 11 October 2026
The independent audit's ASSAY-01 through ASSAY-04 are addressed without rewriting imported data, saved historical outputs, or published reference fixtures.
- Control input: the Z′ task accepts finite decimal/scientific numeric tokens. Invalid tokens such as
OVER,NaN, infinity or hexadecimal numbers block the answer and show the control group, line and token. Previously invalid observations disappeared silently. - Result integrity:
src/lib/assay/result-integrity.tssupplies the common panel, export and Bench-capture guards for kinetics, inhibition, thermal shift and growth. Each mode's guard is its own module (kinetics-integrity.ts,inhibition-integrity.ts,thermal-integrity.ts,growth-integrity.ts, overintegrity-core.ts), which the mode's panel imports so a task island carries only its own reader;result-integrity.tsre-exports them for the exports, the adapter and Bench (split in A5, when the inhibition comparison and Haldane readers would otherwise have pushed every Wave 1 island over 40 KB). Current input hash, full captured settings and contributing row identities must match. Conflicting imports remain readable for recovery, but their result tiles and result capture/export are withheld. A readings/rates export retains raw measurements and explicitly labels derived values unavailable, omitting cached fitted/residual/corrected columns. Sanitization never mutates the retained research payload. Consistent historical records retain their recorded method version and values; capture still requires the current method version. - Numerical scale: MM 1.2.0 and profile 1.1.0 optimize dimensionless substrate and rates; inhibition 1.1.0 also scales inhibitor concentration; thermal 1.1.0 scales temperature and fluorescence. Public results, residuals, RSS and profile thresholds return to their supplied units. A converged candidate is preferred only when objectives agree within arithmetic roundoff, otherwise the lower objective wins. All accepted estimates require the solver's convergence flag; a stall below the iteration cap is not success.
- Thermal sampling: the interior derivative is the quadratic-interpolant expression for unequal spacing (NumPy's derivation). It reduces to the old central difference on equal grids. Historical thermal 1.0.0 reading exports retain the old secant derivative; newer records use the corrected derivative. Sampling resolution is half the larger adjacent interval, explicitly not an uncertainty interval. Derivative/sigmoid disagreement requests inspection of the window, sampling and noise; it does not establish multiple transitions. The existing empirical two-peak refusal is still a heuristic, not a mechanistic diagnosis.
Verification includes the immutable 100-case independently optimized MM reference set from the audit: all 100 now yield estimates (previously 98); maximum relative Km error is 2.34e−8 and Vmax error 4.66e−9. M-to-µM parameter and profile-bound equivalence, exact quadratic derivatives on irregular grids, an analytic irregularly sampled sigmoid, strict Z′ token parsing and all four modes' conflicting-import-to-Bench refusal are regression checks. Published oracle files and their numeric tolerances are unchanged. Only the old thermal prose assertion changes to match the qualified wording. These numerical checks do not supply external experimental validation, R cross-checks, uncertainty intervals for thermal/inhibition fits, or validation of DSF mechanism inference; those limits remain.
Relationships (A-2 family 3)
The engines for the third statistics family, built to the five CSO memos of 13 October 2026 (docs/board/memos/2026-10-13-cso-correlation.md, -linear-regression.md, -nonlinear-families.md, -model-comparison.md, -method-comparison.md). They live in src/lib/relate/ beside family 1's src/lib/stats/ and reuse its distributions.ts (Φ and its quantile) and shapiro-wilk.ts, and src/lib/fit/ (stats.ts, lm.ts, profile.ts, compare.ts). The surface is the relationships mode (v6, src/lib/relate/record.ts, a strict reader; chooseMode("relationships", preset) sets the analysis from the chooser's panel: preset: linear, multiple, curve, compare, method; the mode's own entry is Correlation). Six chooser entries sit in "X and Y pairs". panels/relationships-panel.tsx (on demand with src/lib/relate/ and content/apps/assay-analyser.relationships.ts) shows the data grid (an xy grid for correlation, regression, curves and comparison; a columns grid for multiple regression and method comparison) with the cells left out listed, the analysis and its declaration, one SVG figure (drawn at its container's width, 260 to 640 units, read before the first paint and kept current by a ResizeObserver, so its text keeps its pixel size on a phone from the first frame: REL-90, REL-245; points, the line or curve over the X range used, the pointwise confidence band filled and the prediction band dashed, the identity and Deming lines, the Bland–Altman plot with the bias and limits), the results with each withheld statistic as its reason, and the memos' limits lines. analyse.ts runs one analysis from the stored record; nothing derived is stored. The export is "Relationships result CSV" (relationships-export.ts: every number by its path at full precision, a withheld one as its code and sentence); a repeat keeps the settings and clears the grids. Recording to a Study (captureRelationshipsWorkspaceResult in relationships-capture.ts, loaded on the Record press; schema assay-analyser.relationships-result v1, a presentation schema in result-projection-core.ts and a repeatable one in repeat-assay.ts) recomputes the analysis from the saved grids and settings and records its headline rows (relationshipSummary: the correlations, the coefficients, the curve's parameters, the ranking, Deming and the bias and limits; a withheld statistic as its sentence), the full long rows as output, the analysis's method pins, and the declaration warning and the limits lines (relationships-limits.ts, shared with the panel) as qualifications. e2e/assay-analyser/relationships.spec.ts covers linear regression, the comparison and method comparison at desktop and 375 px. The six methods (correlation, linearRegression, nonlinearFamilies, modelComparison, deming, blandAltman) are verified against relationships.json (0.11.0, 115 cases), the nonlinear families' intervals also against nonlinear-coverage.json (0.11.0) (13 October 2026; their notes in assay-analyser.verification.ts, the panel's method line "Checked against R on 115 published cases, SciPy agreeing on the quantities it computes").
- Input.
points.tsreads one Y group of anxygrid by the declaration: "each replicate is an independent unit" makes every Y value a point; otherwise each row is one point, the mean of its values, and rows with fewer values than the grid's sub-columns are listed. A row whose X is blank, text or excluded drops its Y cells (listed with the count); text and excluded Y cells are listed; blanks are missing. Acolumnsgrid gives named columns paired by row, complete case. - Correlation. Pearson r, r², t on n − 2 df and the Fisher-z interval (n ≥ 4) as
cor.test; Spearman ρ as the Pearson r of midranks with R's p path: exact enumeration for n ≤ 9, the AS 89 Edgeworth series up to n = 1290 without ties, the t approximation with ties or above 1290 (R's source hasn <= 1290; the memo's "n < 1290" is corrected by the fixture). Inference only with an independent unit; |r| = 1 withholds the test (PERFECT_CORRELATION). The Bonett–Wright interval on ρ is shown for untied data at n ≥ 10 at 95 percent: the coverage simulation recorded in the fixture (0.11.0, Oracles run 38422173515) ran at a nominal 95 percent and cleared every untied cell (SPEARMAN_INTERVAL_CLEARED, keyed by level and read throughspearmanIntervalCleared; its 0.95 key checked againstabout.spearmanIntervalCleared); with ties in X or Y it is withheld (SPEARMAN_CI_REGION), the simulation's tied cells falling below the band at high ρ; at 90 and 99 percent, which the simulation did not run, it is withheld (SPEARMAN_CI_LEVEL; the CSO's Stage A exit memo of 13 October 2026, section 4). - Regression. QR on explicitly centred predictors (never the normal equations); the intercept as the fit at X = 0; R² withheld through the origin; an aliased predictor (residual norm below 1e-7 of its centred norm) refuses the fit and is named; inference needs three residual df; an exact fit or flat Y withholds the SEs with the inference. Bands are pointwise and drawn only over the X range used (
bandGrid). Lack of fit only when replicates are declared independent points, with three distinct X and two pure-error df. VIF from the centred inverse; hat values, standardised residuals and Cook's distance, withLEVERAGE_ONEat h = 1. - Nonlinear families. In scaled coordinates (Y-like parameters over the Y span, V50 over the X range, rates and Slope in ln units) from the ruling's deterministic starts, LM settings as the kinetics engines (gradient 1e-8, the objective at the response's numeric resolution; profile refits at 1e-6). A lowest non-converged run resting on a box is reported as that parameter at its bound. Two-phase withholding follows a fixed order shared with the oracle: f at a box (
PHASE_VANISHES), K_fast past 10³/ΔX (AT_BOUND), d at a box (RATES_NOT_SEPARATED), any other box, then the coverage rules and the lower side of d's profile. Derived quantities take their parameter's profile bounds through the monotone map; K_fast and power's A are profiled in reparameterised problems. A family's profile intervals are shown only at a level at which the oracle's coverage simulation cleared it (NONLINEAR_COVERAGE_CLEARED, keyed by level and read throughnonlinearCoverageCleared; ruling 17;nonlinear-coverage.json0.11.0 ran at 95 percent and cleared all five there, lowest coverage 0.945). The level gate (SCI-90, reopened for 90 and 99 percent by the CSO's Stage A exit memo of 13 October 2026, section 4): the panel offers 90, 95 and 99 percent and the engine profiles at the level chosen, but only 0.95 is cleared, so at 90 and 99 percent, as for a family that does not clear, every interval reads "not available" (COVERAGE_NOT_CHECKED), the approximate-interval limits line is left out of the panel and the Study recording (relationships-limits.ts), and the panel's "approximate profile interval" note appears only beside an interval shown;ignoreCoverageGatelets the oracle test check the profile arithmetic at any level. Polynomials by QR on z = (X − X̄)/h with exact t intervals and pointwise bands; centred coefficients when |X̄|/h > 10³. - Units beside the results (the closing walk's S9): the linear fit says "Slope in {Y} per {X}; intercept in {Y}." and the curve fit gives one sentence per family in the CSO's words (
curveUnits[family]), in the table's units (unitsOf: the grid'sunitandx.unit, else the unit in brackets at the end of the column title, as in "Concentration (µg/mL)", else the column names). - Model comparison. ΔAICc on the RSS form (
aiccinsrc/lib/fit/compare.ts), sorted, with "lowest" and "within 2" as plain labels; the F only for ruling 4's nested pairs, labelled exact or approximate, and only when the points are declared independent (compareModels(…, unit), the curve fit's reading of the declaration; otherwise each pair is withheld withUNIT_NOT_INDEPENDENTorSERIAL_READS, as every other test is; the closing walk's S5, 14 October 2026);compareModelsdefaults to undeclared, so a caller that wants the F passes "independent". The ranking is information and stays, with the CSO's limits line when the points are not declared independent (compareUndeclared). The memo's "exponential without plateau within one phase" pair is built as the one-phase curve with Plateau held at 0 within the free one-phase curve (one-phase-p0). No weights and no BIC are computed. - Method comparison. Deming in closed form (the conjugate form when Syy < λSxx), λ assumed 1 or typed; delete-one jackknife intervals at n ≥ 10; decision levels inside the range of A. Bland–Altman on raw, percentage or log-ratio differences against the pair mean or A; bias with its t-interval; 95% limits d̄ ± qnorm(0.975)·s_d at n ≥ 10 with Bland and Altman's 1999 intervals; the proportional-bias line; Shapiro–Wilk on the differences. λ from replicates is not offered: a
columnsgrid holds one sub-column per method. - Tests.
relate.test.ts(the memos' hand cases, the Jacobian check against central differences throughwatchSpecsForTest) andrelationships-oracle.test.ts(every case ofrelationships.json, 0.11.0: all 115 pass), withnonlinear-coverage-oracle.test.tstying the 0.95 key ofNONLINEAR_COVERAGE_CLEAREDtononlinear-coverage.json(0.11.0) and refusing a cleared key at a level the fixture did not simulate; the level gate at 0.90, 0.95 and 0.99 is inrelate.test.ts(the nonlinear families and the Spearman interval) andrelationships-persistence.test.ts(a recorded curve's rows and limits lines).
Phone example layout (16 October 2026)
Assay opts into the shared discovery start’s stacked phone examples. Each button fills its row and wraps left-aligned at 320–390 px; desktop examples retain their compact layout.
Detected plate review (16 October 2026)
The existing 96/384 grid, SoftMax-like and Gen5-like import detection now renders every detected well before confirmation, including empty/rejected positions. A semantic table retains row/column headers and full raw numbers inside a keyboard-scrollable frame. The read selector remains explicit for multiple blocks; no channel or plate is pooled. “Correct the source table” returns to the retained editable input; changing it invalidates the candidate and requires a fresh review. Applying still uses the existing import guards, source coordinates, rejection ledger and explicit confirmation. No compound, concentration or control role is inferred from the detected well position.
Methods text from immutable dose-response results (16 October 2026)
recorded-methods-assay.ts supports the recorded-result v1 envelope and captured
Assay v6/v7 inputs with four-pl 2.0.0 and table-parser 1.0.0. It reads captured
settings, bounded source observations/explicit roles and exclusions, applied
per-curve constraints and selected reporting states. It never imports the live
Assay reader, report builder, consumed-input builder or fit/normalisation engines.
The captured consumption hash's shape is checked; no new fit verification is
claimed. Envelope pins and optional captured named method pins are retained as
two separate receipt layers, with their overlapping IDs cross-checked.
Table percent means provided percent. Plate control normalisation names the retained formula and roles. Raw-scale fallback from requested normalisation has a specific unsupported-variant explanation. Absolute endpoints never inherit a relative interval; weighted fits explicitly lack weighted intervals/comparisons. Unweighted relative text distinguishes reached, search-limit, refit-unresolved, missing metadata and absent selected intervals without printing numerical bounds or implying infinity. Withheld endpoint reasons stay visible. A result with no successful fit, or a cancelled/refused result, produces no normal Methods paragraph.
Initial coverage requires explicit table-decision role metadata for tables and an explicit exclusion array; missing scientific context is not defaulted. Kinetic, reporter, background-vehicle, mass-conversion, blind and supplementary comparisons or experiment declarations remain unavailable. A retained preparation sheet without an analysis block is truthfully described as unsupported preparation-sheet context, separately from attached planned-analysis comparisons. No stored data is upgraded. Replicate declarations remain declarations, and observation/well counts are never renamed biological n.
Contextual statistics guidance (16 October 2026)
The dose-response Compare panel offers the reviewed extra-sum-of-squares F-test
suggestion only when comparison.ts::ineligibility admits at least two unweighted
fits with reportable relative C50s. The visible explanation states independent
errors, roughly constant spread, adequate 4PL fit, nonlinear approximation and
plate confounding, and distinguishes evidence from biological importance. One
curve receives the explicit no-between-group-test explanation; other ineligible
sets retain the existing weighting/reporting refusal. No test runs automatically,
no technical wells become biological replicates, and no statistical engine changes.
The import panel's separate-plate action captures the unsplit import into a persistent batch disclosure. Every plate requires an explicit independence declaration and its own reviewed map before fitting; live setting/map changes invalidate prior rows. Captured import text changes only on an explicit recapture. See Independent analysis batches for recovery, limits and separate child-result recording.
Public reference workflows (16 October 2026)
Six public reference workflows import existing checked synthetic dose-response/calibration tables or the mapped native MTT plate. They use native import/fit/QC paths and suppress passive Assay autosave; explicit Save remains available. See Public reference workflows for the shared route, state, source and export contracts.
Removed plate-matrix, plate-QC and combination additions (17 October 2026)
A set of additions committed on 16–17 October 2026 without fixtures or CSO review was removed after the review of that work: a visual 8×12 paste grid with its own matrix and multi-vendor parsers, a plate-QC scorecard (Z′ bands, robust Z′, S/B, S/N, whole-plate drift and edge flags), a "Calc to Assay" dose-response bridge into the standard-curve panel, a curve-diagnostics badge ribbon with a follow-up dilution plan, a Bliss/HSA checkerboard panel on the Plate view, and an unreachable head-to-head run/candidate comparison. They placed readings in the wrong wells, pooled wells of different roles, reported values the existing engines withhold and used banned verdict bands. What remains is described above and is unchanged by the removal: plate import through parsePlates (src/lib/table/plate.ts) and its import review; Z′ (src/lib/fit/zprime.ts) and the per-plate diagnostics (src/lib/fit/plate-diagnostics.ts), shown as observations with n and no bands; normalisation (src/lib/fit/normalise.ts); the IC50 reporting rules (src/lib/fit/rules.ts); the standard-curve panel with its blank-based limits; and ligand efficiency (src/components/tools/assay-analyser/efficiency.ts). Two-agent HSA/Bliss comparison remains queued as its own Assay mode under docs/design/product-expansion/13-combination-analysis.md, with fixtures before any engine.
Study/Bench Repeat reviews the explicit destination before atomically creating a separate setup. Every Study-contained historical result inspector carries that Study through review, independent of the original owner or Experiment. Readings, results and run-specific declarations clear through the existing repeat reader. Captured Study summaries/reports share exact immutable scientific context, including calibration weighting/read basis, qPCR efficiencies and recorded replication; no live material enrichment or refitting occurs.
Immutable capture validation — 24 October 2026
The Study context/export path validates consumed scientific captures separately from the editable assay envelope. Supported v1–8 snapshots keep their bytes and missing bookkeeping; finite readings, declared molar units and retained settings and plate assignments are checked, with existing strict mode readers for mode subrecords. A shipped published v6 capture lacking editable table decisions is supported; hash-valid malformed rows or units withhold the preview/export. No analysis is rerun for presentation. Example kind and edited lineage remain explicit alongside retained scientific context. Source guards and plate profile identities are shared pure leaves; editable import/admission semantics stay as before.
Phone dose-response result cards (24 October 2026)
The existing ResultsTable uses the shared DataTable phone-card presentation
options for all dose-response results, including outside a guided walk. The
complete result row remains the walk target. Interval, constraints, structure
and Flags fields use the full reading width; short fields pair IC50/EC50 with
the corresponding pIC50/pEC50, Hill with R², and points with replicates. Each
fact retains its existing explicit label, unit, value renderer and precision.
Short or empty full-width values remain inline rather than adding a label row.
Only currently visible, rendered fields participate in a pair; hiding a
partner leaves the remaining fact intact. All optional columns, refusals,
qualifications and actions remain available. Desktop tables and exports retain
their original order and values. No fit, interval method, scientific threshold,
rounding or default column visibility changes. Long names, qualifications or
additional selected fields may still require ordinary page scrolling; no
fixed-height clipping or complete-visibility claim is added for oversized cards.
The CPO/CDO's measured reflow ruling and production checks are recorded in
docs/reviews/2026-10-24-core-workflows/README.md.