Skip to content

Milab 6496 binding profiling impl - #12

Merged
PaulNewling merged 282 commits into
mainfrom
MILAB-6496_binding-profiling-impl
Aug 25, 2026
Merged

Milab 6496 binding profiling impl#12
PaulNewling merged 282 commits into
mainfrom
MILAB-6496_binding-profiling-impl

Conversation

@PaulNewling

@PaulNewling PaulNewling commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Greptile Summary

The PR adds clonotype-set antigen-binding profiling, associated quality measurements, workflow imports, configurable comparator/grouping rules, and a punchcard UI replacing the prior verdict grids. Important touched terms:

  • Binding verdict — a four-state clonotype-set/antigen result: bound, not bound, never asked, or unreliable; this PR introduces its computation and emitted columns.
  • Antigen identity — one or more panel tags grouped under a shared antigen identity; this PR supports per-tag identities or grouping by a declared panel property.
  • Reference source — the comparator used to interpret antigen counts: declared reference tags, the panel’s readings, or none; this PR makes it configurable and records both requested and served sources.
  • Voting cell — a cell with an admissible answer contributing to its clonotype set’s verdict; this PR adds minimum-voter and agreement controls.
  • Contending group — identities declared to compete for the same binding site; this PR preserves canonicalized groups and emits competition notes.
  • Punchcard — a clonotype-set-by-antigen grid whose color shows verdict and size shows support; this PR introduces the pivoted model output and UI page.
  • Grouping rule — a rule mapping tags to identities by tag or panel-property value; this PR carries it from persisted settings through workflow execution.
  • Panel/reference QC — measurements describing coverage, panel/read mismatches, comparator depth, and related quality conditions; this PR computes and emits these artifacts.

Confidence Score: 4/5

The PR should not merge until replacing a panel can no longer leave stale punchcard selections that silently hide an otherwise valid result table.

The new punchcard filter persists across panel replacement, and the model returns no table when all persisted identities are absent from the new panel even though valid new identity options exist.

Files Needing Attention: ui/src/pages/MainPage.vue, model/src/index.ts, ui/src/pages/PunchcardPage.vue

Important Files Changed

Filename Overview
software/per-cell-metrics/src/verdict.py Implements count flooring, comparator selection, cell-level states, and silent-position accounting with explicit boundary handling.
software/per-cell-metrics/src/combine.py Aggregates cell states into clonotype-set verdicts, support counts, agreement, and competitor annotations.
software/per-cell-metrics/src/emit_verdicts.py Defines the verdict CLI and emits long-form, pivoted, metadata, property, and quality artifacts.
workflow/src/main.tpl.tengo Adds the optional VDJ-backed verdict stage while keeping its parameters outside the per-sample parsing fanout.
workflow/src/verdict-import.tpl.tengo Imports verdict outputs and conditionally exposes identity-pivot columns according to run metadata.
model/src/index.ts Adds persisted-data migrations, verdict arguments, source choices, run metadata, and the dynamically filtered punchcard table; stale identity selections can make that output absent.
ui/src/pages/MainPage.vue Resets panel-derived settings on CSV replacement but omits the new persisted punchcard identity filter.
ui/src/pages/PunchcardPage.vue Adds punchcard selection, rendering, comparator notices, and empty states, but does not explain a table hidden solely by stale selections.
model/src/types.ts Introduces the principal profiling types, including ReferenceSource, GroupingRule, verdict settings, and punchcard state.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Feature-barcode reads] --> B[Per-cell tag counts]
  P[Panel declarations] --> C[Tag-to-identity grouping]
  B --> C
  D[VDJ dataset and cell linker] --> E[Clonotype-set membership]
  C --> F[Comparator and admissibility checks]
  E --> F
  F --> G[Per-cell binding states]
  G --> H[Clonotype-set voting]
  H --> I[Four-state verdict columns]
  I --> J[Punchcard UI]
  F --> K[Quality measurements and run metadata]
Loading

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
ui/src/pages/MainPage.vue:184-195
**Stale punchcard identity filter**

When a user narrows the punchcard and then replaces the panel, `clearOnCsvChange` preserves the old identity selection. The model filters the new columns against those stale identities and returns no table when none match, causing a valid punchcard to disappear without triggering the existing empty-state alert.

```suggestion
function clearOnCsvChange() {
  app.model.data.barcodeSeqColumn = undefined;
  app.model.data.featureNameColumn = undefined;
  app.model.data.combineColumn = undefined;
  app.model.data.roleColumn = undefined;
  app.model.data.referenceValues = undefined;
  app.model.data.grouping = undefined;
  app.model.data.contendingGroups = undefined;
  app.model.data.panelColumnSnapshot = undefined;
  app.model.data.punchcardIdentities = [];
  clearControlOnInputChange();
  clearSampleAwareOnInputChange();
}
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "MILAB-6496: point the settings comments ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

The block computed a dominant feature per cell — one antigen named as the
winner. The verdict model asks a different question of every antigen
independently, and a single winner is not a simplification of it: a cell that
bound three antigens has three verdicts, not one dominant one and two absences.

Removes the dominance call, the consensus and specificity outputs, and the
four CLI flags that drove them. The workflow, model and UI still reference
those flags and outputs; they are rewritten for verdicts in later work, and
the block does not run end to end until they are.
The coverage fixture used one unjudged and one not-evaluated measurement, so
swapping the two counters left every assertion passing -- the questions "was a
line defensible" and "did anybody look" were silently interchangeable. The
counts now differ.

DEFAULT_OUTLIER_FENCE was unpinned while every fence assertion derived its
expected value from it, so the constant was free to move unnoticed. It is now
pinned like every other line.
…it unjudged

identityDisagreement and tagDisagreement declare the against-the-run route, so
the spec says they carry a status. status_for resolved a status only from the
line table, and neither has a line there -- correctly, since that route carries
no absolute number -- so both returned unjudged.

Unjudged is the one wrong answer that hides: it never enters a rollup, so an
outlying reagent would leave its whole panel reading clean, which is the failure
the rollup exists to invert. status_for now refuses these outright and names
outlier_status, turning a silent wrong reading into a loud one.
A spec review checked the shipped lines against the evidence file behind the
spec's "lines come from four routes" decision. Three of the four did not survive
it. Operator ruled on each; decisions recorded in .meta/milab-6496-decisions.md.

The undeclared-barcode line of 0.1 was invented. The field publishes 0.50, and
for one aggregate library fraction -- while this measurement is per sequence at
tag level, where a fraction's line does not transfer. Given a count instead, any
upper bound collapses into "alerting if a single undeclared barcode exists". It
now ships unjudged, with its sequences and no claim about what a bad value means.

panelAssignedFraction was labelled "Fraction of antigen reads usable", whose
published line is 0.20, while shipping 0.50 -- the complement of the unrecognized
fraction, which is what the code actually computes. The label was the part that
had drifted: the shipped p-column's own description already named the recognized
fraction. Relabelled, line unchanged. The id is a p-column name and is untouched.

readsPerBarcode applied a per-cell recommendation to a per-observed-barcode rate.
In droplet data that denominator runs one to two orders of magnitude above the
called-cell count, so a healthy library would alert. Renamed readsPerCell and now
divides by the cell list, which the entrypoint supplies; no p-column existed to
break.

at-most keeps its place in the comparison vocabulary but now has no member, so it
is exercised against a registered stand-in rather than left an untested branch.
Composes the twelve modules into one CLI: the panel reader, the identity
universe, the count floor, the reference and its gate, the per-cell state
reader, the cell-to-set majority vote, the competitor notes, both levels of
self-disagreement, and the quality measurements with their statuses and
rollups.

The grid of every cell against every identity is never built. A cutoff at or
below specificity_score(0, 0) is refused, because below that bound the
analytic silent count and the row-per-position reference part company with
no error raised.

One Admissibility bundle reaches read_states, combine_cells and
self_disagreement, so none of them can disagree about which cells cannot be
compared. What each sample was offered stays keyed by sample throughout.

Barcodes outside the cell list are labelled rather than dropped, and the
sparse per-tag counts plus the per-cell scalars are exported as the material
a reader needs to regroup the panel without a re-run.
…ests

The cutoff refusal is "at or below the analytic bound", but the test used 0.04
and 0.05 and never the bound itself, so it could not tell that from "below".
Now asserted at specificity_score(0, 0) exactly.

Nothing distinguished the comparator served from the one requested. A run asking
for a panel comparator with too small a panel now asserts the record says none.

Nothing distinguished the depth denominator. The bed now carries four observed
barcodes against three listed cells, where dividing by the wrong one crosses the
5000 line: 18000/3 clears it, 18000/4 does not.

The sortedness checks ran on a bed of one set and one identity, where sorted and
unsorted are the same frame. A wider bed -- three identities declared in
descending order across two sets -- makes the order observable.

Two frames keep a redundant sort: `offered` and `tag_identity` are already built
from sorted iterations, as the verdicts frame is by combine_cells. Left in place
as cheap insurance against a future construction change.
Found by reviewing the entrypoint against the spec atoms rather than the plan.
All three changed what a default run returns, and none was covered by a test.

The comparator skipped a rung. The order is a declared reagent, else the panel's
own readings where the panel carries enough members, else nothing -- and only
the empty-droplet comparator is conditional on a scientist asking for it. The
default resolver answered declared-or-nothing, so a twenty-antigen panel with no
declared control read unreliable throughout when it could have been read against
itself. A panel below the minimum still falls to no comparator, so the founding
three-antigen case is unchanged.

The cell list was derived from the antigen counts when neither list input
arrived. Which barcodes held a cell is an input, and nothing in the antigen
readings separates a cell from an empty droplet. Labelling the derivation in the
run record did not make it allowed, and it was not inert: reads-per-cell divides
by it, and observed barcodes outnumber cells by one to two orders of magnitude,
so a healthy library read undersequenced. With no list, membership is now
unknown rather than false and the measurements needing one read not evaluated.

The capture rollup discarded the membership it had just computed whenever no
capture map was given, which is the ordinary case. The level whose stated job is
that nothing hides aggregated nothing, reporting not evaluated over runs whose
samples and panels were measured perfectly well.
result_offered.csv and result_tag_identity.csv carried key columns only. A
p-column is built from a CSV's value columns, so a key-only file imports as
nothing -- silently, since the file exists and is well formed. What each sample
was offered, and which identity a tag feeds, would never have left the block,
and the tag-to-identity linker the whole cross-layer join depends on could not
have been declared at all.

Offered carries offered="true"; the linker carries a column named "1" holding 1,
matching the cell-linker convention used elsewhere in the platform.
Where a per-tag figure alerts, the identity figures for the identities that tag
feeds are now shown beside it. attach_alerting_identities already implemented
this and was imported nowhere, so nothing computed which tags alert. A noisy
reagent whose identities read steady is a reagent to replace, not a run to
distrust, and only the two numbers together say which. Neither is suppressed:
the identity rows are still emitted in full, and the attachment is a copy on the
row that raised the question.

A tag the grouping property could not place is now named in the run record. It
keeps its own identity rather than vanishing, so a bare barcode sits among the
family identities; previously the only trace was a stderr line truncated to the
first eight tags. A property the panel file does not carry narrows what can be
answered, and the narrowing belongs where the answers are.
Each scenario writes a counts CSV, a panel CSV and a linker CSV and runs
emit_verdicts.py as a subprocess. No test constructs a per-cell state frame,
which is how an earlier revision of these checks passed while the reading was
turning an antigen every cell failed to bind into 'never asked'.

Covers epitope loss produced from zero count rows, an unasked off-target that
leaves one clonotype unsettled and a bound off-target that disqualifies
another, support of 40 cells against 3 inside one clonotype's row set, and a
set whose every cell the gate set aside.
Every verdict test so far writes its own three-line bed, which keeps each one
readable and none of them realistic: a run whose panel is one size, whose
comparator is one tag and whose cells all come from one sample cannot show what
happens when four samples were stained differently.

The bed at software/test-data/fixtures/verdicts/ carries those shapes at once --
four samples with panels of 3, 4, 4 and 5 tags, four barcodes recurring under
two antigen names each, one antigen on two barcodes, no comparator / one / two,
and a barcode declared on one sample and read on another. Three panel files
share one counts file, so the same readings can be put against a comparator of
6, of 60 and of none.

Every count is chosen against a named threshold and the choice is written down:
8 clears the floor of 4 and still scores 0.0001, so it is compared and fails;
500 scores 100 against a comparator of 6 and 0.1 against 60; 5000 survives
either; the one comparator reading of 1 sits below the thin line of 2 and is the
bed's only source of *unreliable*.

Seven tests read the bed through the CLI: identity keyed by barcode where the
names would split, all four states in one run, *never asked* on the short panel,
both directions of the per-sample panel-versus-reads check, one antigen read by
its highest member, the higher of two comparators serving, and a panel with no
comparator standing in as its own. The handles they need are recovered from the
files by the role each barcode plays rather than written down, so a bed
regenerated under a different seed still exercises the same shapes.

The generator is stdlib only and takes one fixed seed. Barcodes are random ACGT,
antigens are AgNN, samples are SNN: the repository is public and nothing here
comes from a real panel.
The post-fan-out stage that runs emit-verdicts and imports its thirteen
outputs, plus the cleanup that made the block runnable again.

The reading is answered across the whole run rather than per sample, so the
per-sample tag-stat tables are gathered into one sparse counts CSV first
(gather-counts.tpl.tengo), keyed with the real sampleId taken from the
resource-map key. Keys come through maps.getKeys, which sorts them: a bare
range over a map has no defined order, which would vary the concatenated
file's handle run to run and cost every downstream node its deduplication
with nothing reported.

The stage is two templates. verdict-run resolves the bundle, exports the
linker as flat rows and runs the exec; verdict-import takes the run record as
an input and builds the specs. The comparator and the cell list that SERVED
are resolved by the software, not requested — a declared comparator degrades
to none where the panel carries no reference tag — and both sit in the emitted
columns' domain, so specs built inside the exec's own template would have
recorded the request instead.

The single-cell V(D)J dataset is resolved in the body and deliberately not in
prepare: prepare's bundle is awaited in full, spec and data, before the body
runs, so a linker there would hold the whole mitool fan-out behind the
clonotyping chain. A dataset may bring one linker per receptor; the one whose
clonotype axis is the anchor's is the one the user chose, so a BCR + TCR run
needs no panic. A sample-axis mismatch is named instead of joining to nothing.

Without a dataset the block still runs and emits its per-cell contract
columns, the per-sample QC and the per-feature properties.

Threading is now assertable without a backend. verdict-args builds the whole
command line, staged filenames included, and unit tests cover every flag —
including --qc-summary, without which readsTotal, panelAssignedFraction and
readsPerCell have no source at all and read "not evaluated" silently, taking
the only sequencing-depth alert the block ships with them. fanout-inputs
closes the per-sample body inputs and the resource meta to a checked list, so
a verdict parameter cannot reach the fan-out and cost every user a full
parse / refine-tags / tag-stat re-run.

Cleanup: the consensus and specificity outputs, the four CLI flags that drove
them, and the negative control that gated the specificity import are removed
from main, fb-pipeline and fb-downstream. per_cell_metrics.py stopped writing
either table and stopped accepting the flags, so the block could not complete
a run until now.

The run record gains the identity list. The pivoted per-identity summary
builds one p-column per identity, and the identities are panel data unknown
until the software runs; a count cannot name them, so without the list the
only per-antigen state a clonotype-anchored reader can see imported as
nothing.
A barcode recurring across panels is the ordinary case -- the same reagent is
stained into several samples -- and it writes one QC row per panel at the same
(level, entity, measurement). With the panel as a value column those rows share
an axis key, which is a duplicate-key import: it does not raise, it keeps one row
and loses the rest, so a reagent misbehaving in one panel and reading clean in
another reports as whichever row happened to survive.

Measured on the committed fixture bed: 22 of 122 QC rows collided, one identity
appearing under four different panels.

Sample-level and capture-level rows carry an empty panel, which is honest -- a
per-sample measurement belongs to no single panel. Keying per-tag QC by
(panel, tag) is what the architecture already called for.
The block test is deliberately the last task now, so the assertion that no
emitted column keys on pl7.app/feature/featureId does not exist until after live
verification. That axis is keyed by feature NAME; a new antigen table reusing it
while carrying barcode SEQUENCES would keep the axis identity and invert its
value space, so no downstream query would fail -- joins would return wrong rows.

Every antigen table imported by this block goes through one helper, which makes
it the single place the check can run for all of them. The legacy per-cell
contract columns are built elsewhere and keep the axis legitimately.

The predicate is split from the assertion so it can be tested: tengo has no
try/catch, so a panicking guard cannot be exercised from a test at all. Both
directions are covered -- the legacy axis is detected, the minted antigen axes
pass.
BlockData and BlockArgs gain the binding reading's own parameters: the
optional single-cell V(D)J dataset anchor, the role column and reference
values, the reference source, the panel minimum and thin line, the count
floor, the bound cutoff, minimum voting cells, minimum agreement, the
admissibility gate, the high-reference line, the grouping rule and the
contending groups, plus the verdict grid state. Field names are the ones
verdict-args.lib.tengo already consumes.

The dataset is the block's one optional input: the args lambda never
throws on its absence, and the workflow skips the verdict stage alone.
The dataset is offered as an ANCHOR, not a linker ref, by the same
result-pool query VDJ Multiomic Integration uses; the linker itself is
infrastructure and stays unpickable.

Grouping is a rule over declared panel properties, never a tag->identity
map. Contending groups are canonicalised in the args lambda - each group
sorted, groups sorted, groups of fewer than two members dropped - so a
reorder in the editor cannot stale the block. Their options come from
the new identityOptions output; nothing writes that list back to data.

A v2 -> v3 migration drops dominanceThreshold and the off-target
designation, whose rules no longer exist, and seeds the new defaults.
controlFeature, combineColumn and minUmi are KEPT: main.tpl.tengo still
passes them to emit_feature_properties.py and per_cell_metrics.py, which
still implement them.
The Binding verdicts page: one row per (clonotype set, antigen identity),
carrying the four-state verdict, why an unsettled one is unsettled, and the
cells behind it. Filtering and default visibility come from the column specs
the workflow already builds — the states and the competed flag are discrete
filters, and nothing in the family is orderable.

Three states the page names rather than leaves the reader to infer: no V(D)J
dataset (the run skipped the verdict stage entirely, so no antigen columns were
produced at all), no comparator (every reading unreliable, with the setting that
changes it), and a comparator request the panel could not serve.

The verdicts frame is surfaced as a block output as well as an export, because a
block's own exports are not in its own result pool: without it the block that
produced the verdicts is the one place that cannot show them. The identity label
column travels in the table's column list for the same reason, and is what puts
the antigen's name in the row where the identity id is a bare barcode.

The reading's settings live in one component mounted in both Settings drawers,
so the rule that produced a table can be changed from the table. Picking the
role or grouping column snapshots the panel's headers into data, so args can
refuse a column the panel no longer carries — emit_verdicts.py ends the whole
run over one, and the user meets that as a dead run. A panel swap now clears
every verdict setting that names a panel column or value.

Also: the sample-column dropdown no longer offers columns already bound to the
barcode or feature roles, the dead v-if="false" combine-mode markup is gone (its
validation alert stays, since a migrated project can still carry the value), and
the two settings strings promising a specificity score are rewritten to say what
the negative control actually does.
blockOutputs.antigenQcTable and blockOutputs.antigenPanelMismatchTable were
computed, exported and read by nobody: the whole measurement set, its statuses,
the coverage triple and the three-level rollup were invisible, and so was the
panel-versus-reads check.

Adds a Quality checks page carrying both, switched by a button group so each
table gets the page's full height. Two model outputs resolve the frames with
createPlDataTableV2 for the reason already recorded on perCellTable: V3's
discovery walks the entire result pool and hangs on the upstream Samples & Data
FASTQ dataset.

The panel axis is a twelve-character hash of the sorted tag list, so both tables
carry the panel label column from the exported verdict frame — a block's own
exports are not in its own pool, so it travels in the columns list the way the
identity label already does on the verdict table.

The coverage triple and the deferral reason become visible by default. A status
says whether what was checked is wrong and the triple says how much of the level
was checked at all; a deferral whose reason is one click away is a blank cell to
everyone who does not click, which reads as "checked, and fine" — the one thing
the four-status vocabulary exists to prevent.

Two grid states join the block data, and the v3 migration and the block test's
data literal seed them alongside the verdict grid state.
The silent tally drew its numerator and denominator from two different
populations. `asked` counts a member only where that member's OWN sample offered
the identity; the observed count applied no such test, so a reading from a cell
whose sample never offered the identity was counted against a denominator that
excluded it.

Reachable on ordinary data: a sample-keyed panel, a clonotype set spanning two
samples, and a tag declared for one sample but read in the other -- which is the
undeclared-in-panel case this block measures on purpose. Two regimes, both
reproduced. Above the imbalance threshold the run dies on an assertion blaming a
uniqueness violation that is not there. Below it, the verdict is silently wrong:
a vote from a cell that was never asked displaces a silent cell's real one, and
a tie reads as bound with full agreement.

This is not new policy. Where a set sits in one sample the reading is already
discarded as never-asked; the multi-sample case now behaves the same way. The
same guard goes on the explicit votes in combine_cells and self_disagreement,
whose counts must agree with the tally's.

Also: panelId is an AXIS of the imported QC frame and was written null on every
sample-level and capture-level row -- 45 of 122 on the fixture bed. A null is not
a usable p-column key, as panel.py states of itself. Those rows belong to no
panel and now carry an empty string, which is what the column spec already
documented.
Three error messages named no control the reader could act on, and one sent them
to the wrong drawer entirely: the panel minimum lives under "Advanced verdict
settings", not the "Advanced Settings" accordion the message named, which holds
a different parameter on another page.

The four inherited QC numbers were cited as "atom 315 names them" -- a doc
reference this workspace forbids in code comments, since a reader cannot follow
it from here. Expanded into the four numbers and their published thresholds.

_build_grouping was annotated as returning a 2-tuple and has returned a 3-tuple
since the unplaceable-tag list was added.

The fixture README said a comparator of 6 is "above the thin line of 2", but the
test is `reference < thin_line`, so exactly 2 is comparable. Someone calibrating
a new fixture at 2 would have predicted the wrong state.
Found by mutation testing the suites: 63 single-edit mutations, 48 caught. The
survivors below were all the same shape -- a fixture whose values cannot tell
the right behaviour from a wrong one.

The parameter-threading suite set every number to the library's own default, so
a builder that ignored the block's value and emitted its fallback produced an
identical vector. Six threading defects survived the file whose stated purpose
is to catch exactly that. The fixture now differs from every default and the
values are asserted, not just the flags' presence.

The silent-tally oracle drew counts from 0-30 against references of 2-20. The
highest score reachable that way is about 11.8 against a cutoff of 75, so no
cell in any seed was ever bound, and the assertion that a silent admissible cell
is never observed as bound compared zero against zero in every run. Counts now
sometimes clear the cutoff: the oracle produces 16 bound rows across the seeds
that previously produced none.

cellsWithSignal and cellsAboveTheLine landed on the same rows, so counting bound
cells for both passed. A cell that reads but does not bind now separates them.

Two stated contracts had no test at all: the outlier fence is one-sided, because
a tag disagreeing less than its peers is behaving well rather than misbehaving;
and three peers is enough to compare against while two is not.
gather-counts.tpl.tengo now carries totalWeight alongside the distinct-UMI
count, and emit_verdicts.py calls qc_measures.usable_read_fraction over the
per-sample slice of that table and the cell list it already holds. The
column is read only when present, so a run built before it existed still
reads a stated blank rather than crashing.
emit_verdicts.py is 2494 lines and six of the last nine tasks touched it, which
cost two worktree merges and one hand-resolved conflict. The helpers move out by
responsibility; main() stays whole, as its own comment requires.

- frame_io.py         input readers and the sorted writer
- identity_tables.py  the grouping rule and every table keyed by it
- qc_rows.py          QcRow, the measurement rows, and the two report shapes

Pure move: every body line is byte-identical, verified line-by-line against the
original. emit_verdicts.py drops to 1465 lines, of which main() is 1250.

The dense-oracle guard now reads all four modules, since it checks source text and
a helper moved out of emit_verdicts.py would otherwise escape it. The two tests
that monkeypatch MEASUREMENTS now patch qc_rows, which owns that walk.
result_qc.csv gains lineWarn, lineAlert and route: a reader who sees
warn can now see the number it warned against and which of the three
defence routes backs it. route reads from Measurement.line alone;
lineWarn/lineAlert read from the lines dict _add scored the row with,
so an operator override renders here too. All three are null where no
line stands behind a measurement.
runQualityTable stacks run, sample and tag rows behind one entity column
that means a different thing per row. AntigenQcPage.vue now derives one
PlDataTableSheet from the frame's own pl7.app/antigen/qcLevel axis at
render time (PFrameImpl.listColumns + getUniqueValues), so the sheet
options are whichever levels have rows in this run rather than a fixed
list of three names.
The note claimed _qc_frame keeps a row for every declared measurement. It
does not: it builds from the rows its call sites added. The conclusion holds
for a different reason, now stated.

listColumns fixes no order, so values[0] made the sheet the page opens on
vary between runs.
emit_verdicts.py gains a flag per inherited-line threshold
(cellBarcodeValidFraction, readsPerCell, aggregateBarcodeFraction,
undeclaredBarcodeShare, usableReadFraction warn/error), each defaulting
to DEFAULT_LINES, and threads the built dict through _add/_qc_frame in
place of the hardcoded default.

qc_report.py gains --aggregate-iqr-multiplier, --aggregate-min-umi-threshold
and --aggregate-top-n, defaulting to qc_measures.py's own constants --
this is the module that actually calls detect_aggregate_barcodes; top_n
is now a parameter of detect_aggregate_barcodes/aggregate_barcode_fraction
rather than a fixed module constant read inside their bodies.
verdict-args.lib.tengo restates each line's shipped default (mirroring
qc_measures.py DEFAULT_LINES) and emits it on every build() call, so a
status is always readable against the number that produced it. The
nine parameter names are added to PARAMETER_NAMES, asserted absent
from the per-sample mitool fan-out by fanout-inputs.test.tengo.
IDENTITY_SUMMARY_MAX_IDENTITIES 100 -> 10000, CELL_PUNCH_MAX_CELLS 200000 -> 2000000.

Both gates skip an export and leave every verdict computed, so neither
changes a value. The identity gate bounds the pivot's width and the cell
gate its length; the old note conflated them and claimed the page reports
the skip, which no page does.
model/src/types.ts and index.ts add nine optional line-threshold
fields (BlockArgs/BlockData) alongside minAgreement/gateThreshold, and
three optional aggregate-barcode-detection fields alongside
combineColumn/minUmi -- the latter reach qc_report.py, which runs
inside the per-sample mitool fan-out, not through verdict-args.

main.tpl.tengo sets the nine line params on verdictParams (verdict
stage) and adds the three knobs to extraInputs only when set (per-
sample fan-out, mirroring limitInput's pattern so an unset knob never
stalls a body waiting for a value). fanout-inputs.lib.tengo's
EXTRA_INPUT_KEYS gains the three knob names. fb-downstream.tpl.tengo
passes them to qc-report's exec only when present.

ui/src/pages/MainPage.vue adds two PlAccordionSections in the Settings
drawer: "Quality lines" (all nine thresholds, each tooltip stating the
number is inherited from the field and uncalibrated for this assay)
and "Aggregate-barcode detection" (the three knobs, with an alert
stating that moving any of them changes what the 0.05 line judges).
…round fits by sample

320 requires doublets, a false-discovery rate and the empty-droplet share
to be stated as absent rather than left to be noticed. They were written
into qc_measures.py as comments and reached no reader. A footer states
them, plus saturation, which is carried apart because it exists and the
three do not.

Not a status and not a measurement row: a permanent absence is a property
of the method and 'not evaluated' is a property of one run.

The fitted-background scatter now facets by sample. The fit runs per
(tag, sample) and one panel pooled every sample's fits.
The three plot tabs were pinned to 480px, so each drew in roughly half the
window. PlBlockPage's body is a flex column in a minmax(0, 1fr) grid row,
so flex: 1 takes whatever the tabs and alerts leave.
…t reaches stored projects

:default-options seeds a plot with no saved state and never overwrites one,
so a reader who opened that tab before keeps the pooled single-panel chart.
The other two plot states are left alone.
The nine inherited lines and the three aggregate-detection knobs are stored
undefined until someone types one, and verdict-args.lib.tengo substitutes the
shipped default on the command line. The settings fields rendered that state as
an empty box, so a reader could not see the number the run was scored against.

The fields now display the substituted value. args() still projects undefined,
so a field showing 0.75 and a field holding 0.75 produce the same command line
and the same args hash, and no stored project stales.

QC_LINE_DEFAULTS and AGGREGATE_DETECTION_DEFAULTS replace the same numbers
previously stated only as tooltip prose. test/src/qcDefaults.test.ts asserts all
twelve against verdict-args.lib.tengo and qc_measures.py, which nothing else
compared.
…page

Two readings of the same table were harder than they needed to be.

The measurements grid keyed its rows on the qcEntity axis, which carries an
opaque key per level: a sampleId on the sample rows, a barcode on the tag rows,
the literal "run" on the run row. Sample rows showed the raw id. They now show
the label the sample list uses, and a key with no label renders as itself, which
is the path the tag and run rows take.

The undeclared-barcode table showed its status as the bare words OK and warn.
It now uses the same status tag, in the same colours, as the sample list.

Both go through PlAgDataTableV2's cellRendererSelector, which reaches the table
as defaultColDef, so each selector claims its column by p-column name and
returns undefined for the rest.
@PaulNewling
PaulNewling marked this pull request as ready for review August 25, 2026 16:07
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Too many files changed for review (131 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@PaulNewling
PaulNewling added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 8b5fcb4 Aug 25, 2026
14 checks passed
@PaulNewling
PaulNewling deleted the MILAB-6496_binding-profiling-impl branch August 25, 2026 16:12
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.

1 participant