Skip to content

MILAB-6496: Run quality reads the distribution the run holds - #16

Merged
10 commits merged into
mainfrom
MILAB-6496_integer-count-bin-edges
Sep 3, 2026
Merged

MILAB-6496: Run quality reads the distribution the run holds#16
10 commits merged into
mainfrom
MILAB-6496_integer-count-bin-edges

Conversation

@PaulNewling

@PaulNewling PaulNewling commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Five commits on the Run quality view. Four change what a scientist sees. The fifth fixes the third.

0f2fcb2 Whole-number bin edges

count_bin_edges used np.geomspace, which places edges between whole numbers. A UMI count is a whole number, so a bin can fall between two counts and stand empty at every weight a run can produce. np.geomspace(1, 5155, 25) puts one at [2.039, 2.911). That bin held nothing on all 15 tags of a real run, and read as a missing bar.

Edges are now whole and strictly increasing. The step is 1 near a count of 1, and geometric above that.

70f728b The fitted background draws density

Whole edges stop an empty bin. They do not stop an invented hump. Bin width in counts rises across the edge set, so a bin covering 4 counts stood four times a neighbour covering 1 at equal density.

raw      71  150  193  489  689  490  244   98   47
ints      1    1    1    2    3    4    6   10   15
density  71  150  193  244  230  122   41   10    3

The raw series shows two humps. The density shows one mode that decays. Bars are now cells per count.

Atom 330-the-quality-readout binds the x axis, and only on the plots a scientist declares from. This grid informs nothing a scientist sets.

Cost: the hover readout on this grid now reports the density. PlChartHistogram prints the number it receives under a fixed count: label. Each panel caption carries the cell count instead.

b0818af Run quality hides its tab strip while the run computes

The tab set comes from the baseline rung, and the run does not report that rung until it settles. The strip therefore offered every plot mid-run, then dropped the ones the served rung cannot draw. The open view keeps drawing, and shows its own processing placeholder.

3af02c5 The undeclared Status judges each barcode

Status read the sample's aggregate share, so it was one word repeated down every row. It now reads each row's own share of its sample's pre-refine reads. It warns above 1%. It alerts above 5%.

These two numbers need a spec atom. Atom 315-where-the-lines-come-from lists four field-published lines. One is "Fraction of undeclared barcodes: above 0.50 / at 1.0", published for a sample's AGGREGATE share. That line does not transfer to one sequence, because an aggregate reaches 0.50 while no single sequence comes near it. The code marks the new line operator-set. spec-evolve on atom 315 must admit a per-barcode line, and must record what became of the aggregate one.

The error comparison moved from alerting-at to at-most. Equality fired alert only at exactly 5%, and every larger share read warn.

Also here: the sample-level share is relabelled "Sample Undeclared (%)" and carries no status. Every column description in that table now uses one instruction per sentence and active voice. Column order is unchanged.

f02d7a4 The per-row status is an expression

The per-row status first called the scalar status_for once per row over a materialised column. The row cap does not bound that loop, because keep is int | None and None keeps every row.

status_expr is the same rule over a column, read from the same lines dict and the same _COMPARISON table. test_status_expr_agrees_with_status_for runs both over every registered measurement, at each line's thresholds, 1e-9 and 0.01 either side, and over null, NaN and both infinities. Four mutations each fail there.

Verification

  • 603 fast and 30 slow Python tests. The 4 skips predate this branch.
  • 26 Tengo tests. qcDefaults.test.ts 19/19 pins the Python, Tengo and TypeScript line defaults together.
  • ruff, ui check and model check clean. build:dev-no-software 10/10.
  • Live on a real run: whole-number edges, the new column label, one hump per panel, and the strip hidden while the block computes.

Greptile Summary

The PR revises run-quality distributions, navigation readiness, and undeclared-barcode classification. It keeps quality defaults synchronized across the model, workflow, and Python implementation.

  • Produces whole-number, strictly increasing count-bin edges and renders fitted-background bars as cells per count.
  • Hides run-quality tabs while workflow outputs are still computing.
  • Calculates undeclared-barcode status from each sequence’s share of its sample, with warning above 1% and alert above 5%.
  • Vectorizes per-row status evaluation with a Polars expression.
  • Count-bin edge — A boundary separating adjacent UMI-count histogram bins. Edges are now whole numbers, strictly increasing, and end one count above the observed maximum.
  • Histogram density — Cell weight normalized by the number of integer counts covered by a bin. Fitted-background plots now display cells per count instead of raw cells per bin.
  • Fitted background — The modeled background component of a sample-and-tag count distribution. Its grid now uses density bars and shows the total contributing cell count in each panel caption.
  • Baseline rung — The comparison method that served the run and determines which run-quality plots apply. Its tabs remain hidden until workflow computation settles.
  • Undeclared barcode — An observed feature-barcode sequence absent from the declared panel. Its status is now judged independently for each sequence rather than copied from the sample aggregate.
  • Barcode share — One undeclared sequence’s reads divided by all pre-refinement reads in its sample. It now drives the row’s status.
  • Sample undeclared share — The aggregate share of a sample’s reads assigned to all undeclared sequences. It remains displayed as “Sample Undeclared (%)” but no longer drives row status.
  • Quality line — Warning and alert thresholds used to classify a measurement. The undeclared-barcode defaults change to 1% and 5%, with both comparisons applying above their thresholds.
  • Status expression — A column-oriented Polars implementation of quality classification. It replaces scalar row iteration while retaining the same null, finite-value, threshold, and comparison behavior.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
software/per-cell-metrics/src/qc_measures.py Adds integer count-bin generation and a vectorized status evaluator while changing the per-barcode thresholds and comparison direction.
software/per-cell-metrics/src/emit_verdicts.py Applies the vectorized status rule to each undeclared sequence’s own sample share.
ui/src/components/CountHistogram.vue Adds optional density normalization and corresponding y-axis labeling for pre-binned histograms.
ui/src/components/FittedBackgroundGrid.vue Enables density rendering and adds contributing-cell counts to panel captions.
ui/src/pages/AntigenQcPage.vue Hides baseline-dependent run-quality navigation while workflow outputs are computing.
workflow/src/column-specs.lib.tengo Updates undeclared-barcode column labels and descriptions to reflect sequence-level status semantics.
workflow/src/verdict-args.lib.tengo Changes shipped undeclared-barcode warning and alert defaults to 1% and 5%.
model/src/index.ts Aligns public model quality defaults with the updated workflow and Python thresholds.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Raw[Pre-refinement barcode counts] --> Tally[Per-sample undeclared tally]
  Tally --> BarcodeShare[Per-sequence barcode share]
  Tally --> SampleShare[Aggregate sample undeclared share]
  BarcodeShare --> StatusExpr[Vectorized status expression]
  Lines[1% warn / 5% alert lines] --> StatusExpr
  StatusExpr --> Table[Undeclared-barcode table]
  SampleShare --> Table
  Counts[Per-cell UMI counts] --> Edges[Whole-number shared bin edges]
  Edges --> Weights[Per-sample and tag bin weights]
  Weights --> Density[Divide by integer bin span]
  Density --> Grid[Fitted-background grid]
Loading

Reviews (2): Last reviewed commit: "MILAB-6496: the per-row status is an exp..." | Re-trigger Greptile

Context used (6)

`count_bin_edges` built its edges with `np.geomspace`, which places them between
whole numbers. A UMI count is a whole number, so a bin could fall strictly
between two counts and stand empty at every weight a run could produce.
`np.geomspace(1, 5155, 25)` puts one at [2.039, 2.911); on a 15-tag run it held
nothing on all 15 panels and read as a missing bar.

Edges are now whole and strictly increasing, so every bin holds at least one
count. The step is `max(previous + 1, geometric)`, which draws one count per bar
near a count of 1 and stays geometric above that. The last edge is one past the
top count, making every bin half-open rather than closing the last one and
giving it a count more than its width.

A run now takes at most COUNT_BIN_COUNT bins instead of always that many.
Nothing reads the count: `bin_values`, `per_tag_count_bins` and the chart all
take it from the edge list.
Whole-number edges stop a bin standing empty, but they do not make bar heights
comparable. Bin width in counts rises across the edge set -- one real set spans
1, 1, 2, 3, 4, 6, 9, 14, 21 -- so a bin covering 4 counts stood about four times
a neighbour covering 1 at equal density. That step drew a second hump on tags
whose counts hold one population, and the grid exists to answer whether two
populations separated at all, so a hump the bins invented is the one error this
surface cannot carry.

`CountHistogram` takes a `density` flag, applied in the `log-bins` branch only,
which divides each weight by the whole counts its bin spans. The y axis reads
"Cells per count". The two linear callers, the score spread and the reference
reading, are untouched.

Atom 330 binds the x axis -- "the unit the gate is declared in follows from the
axis" -- and only on the plots a scientist declares from. This grid informs
nothing settable, so its y axis carries no such constraint.

`PlChartHistogram` prints the number it is handed under a fixed `count:` label,
so a hovered bar on this grid now reports the density. Each panel's caption
carries the cell count instead.
VIEW_TABS is derived from the rung the run reports, and that rung is unreported
until the run settles. A strip drawn mid-run therefore offered every plot and
then dropped the ones the served rung cannot draw, so a reader could open a tab
that stopped existing under them.

`isRunning` is the block's own computing signal, the one already driving the
block spinner. The open view's body is untouched and keeps drawing its own
processing placeholder, so the section still shows progress while the strip is
away.
The Status column read `readShare`, the sample's aggregate undeclared share, so
it was one word repeated down every row of a sample. A sample carrying one heavy
undeclared sequence among many light ones said nothing about which sequence to
look at.

It now reads `barcodeShare`, the row's own share of its sample's pre-refine
reads, evaluated per row through the same scalar `status_for` every other status
goes through rather than a polars expression rebuilding the thresholds. The
frame is capped at UNDECLARED_BARCODES_KEPT rows per sample, so the loop cannot
grow with the library.

Warn above 0.01, alert above 0.05, carried in the three places `qcDefaults`
pins together. Operator-set, not inherited: the field publishes 0.50/1.0 for a
sample's AGGREGATE undeclared share and that line does not transfer to one
sequence, since an aggregate reaches 0.50 while no single sequence comes near
it. Admitting a per-barcode line needs an atom on 315, and so does saying what
became of the aggregate one.

The error comparison moved from `alerting-at` to `at-most`. It compared for
equality, which fired only at exactly the error threshold and let every larger
share read warn.

`readShare` keeps its column, relabelled "Sample Undeclared (%)", and carries no
status. Column order is unchanged: the order priorities already produced it.
Every description in the table was rewritten to one instruction per sentence,
active voice, and short sentences.
The undeclared-barcode status was evaluated by calling the scalar `status_for`
once per row over a materialised column. That was justified on the row cap, and
the justification was wrong: `keep` is `int | None` and None keeps every row, so
the loop is a loop over every distinct pre-refine sequence -- 10.2M per sample
and 240.7M over a run, by the figures recorded above the cap. Materialising that
column also undoes the memory work this stage carries.

`status_expr` is the same rule over a column. It reads the SAME `lines` dict and
the SAME `_COMPARISON` table the scalar reads, and `_breaches_expr` mirrors
`_breaches` branch for branch, so only the evaluator differs.

Two evaluators can drift, which is what the loop was avoiding, so
`test_status_expr_agrees_with_status_for` runs them against one another over
every registered measurement: each line's own thresholds, 1e-9 and 0.01 either
side of each, and null, NaN and both infinities. Four mutations were checked
against it and each one fails there: at-most losing its strictness,
alerting-at inverted, warn tested before alert, and the not-a-number guard
removed.
@PaulNewling
PaulNewling marked this pull request as ready for review September 1, 2026 11:17
@PaulNewling

Copy link
Copy Markdown
Collaborator Author

@greptileai

PaulNewling and others added 5 commits September 2, 2026 14:27
Undeclared-barcode lines judge one sequence's own share, warning above
0.01 and alerting above 0.05. Several baseline tags combine by the
highest count rather than stopping the run. Quality-line tooltips no
longer claim no test asserts the default. Per-sample QC headers for
reads parsed, counts removed, unique counts and sticky cells describe
the one number each column holds. Baseline reading, cells called bound,
seen-in and why-unsettled name the population or values they carry.
Semicolons and em-dashes removed from every tooltip.
… can do

The per-barcode grouping option reads "One identity per barcode". The
combine-column alert no longer tells the reader to pick or clear a
column the form does not offer. Uploading the panel file again is the
one action that clears it, so the alert says that.
- Expose Expected binder fraction (ω), default 10% — the fit's starting split, settable per experiment
- Expose Bound probability, default/floor 0.9
- Adopt the paper's initialisation (split at the ω quantile, was the median)
- Bound-count marker per (sample, tag) on the distribution plots, plus boundAtCount in the fit output
- Sample selector on Fitted background; barcodes in declared panel order via new tagOrder
- New export antigenCellTagCounts [sampleId][cellId][tagId] → pre-floor UMI count, partitioned by sample

Plots
- Equal-width log1p bins (0.2) replacing integer geometric bins; drops the per-bar density division
- Histograms include the zeros the fit was taken over
- Bins computed inside the fit — 2.07M numbers held → 7.8k

Fixes
- Panel column hidden (axis + label) — it was a constant hash
- bg 0 displayed instead of 0.000488 (formatter dropped its own significant digits)
- Run quality page crashed on load (temporal dead zone)
- Absent vs null boundAtCount reported as the same finding
- Resize debounce; JSON indent dropped (~halves payload)

Removals
- count_bin_edges (no caller; reasoning folded into log1p_bin_edges) and the density prop
@github-merge-queue github-merge-queue Bot closed this pull request by merging all changes into main in 5d73918 Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants