Drive PSM Desk from your own code
Everything the web page does is available over HTTP: send the peptide-spectrum-match export one
finished LC-MS/MS run just produced — the identification table as your engine wrote it, with the
search parameters and the run notes beside it — and get back the same structured review the browser
renders. Three lanes share one request shape and one response envelope:
ident decides whether the identifications are reportable at all,
quant decides whether the intensities in the same export can carry a quantitative
claim, and methods writes the methods paragraph, the reporting table and the
deposition checklist you can actually defend.
The natural uses are a gate in a core facility's own pipeline — refuse to release a run whose
ident lane comes back not_reportable, the way a test suite refuses a
failing build — and a batch pass over a directory of saved exports that says which runs were
released as 1% FDR while their own decoy counts say otherwise, before a reviewer has to find it.
Neither use needs the browser, and both are three calls: mint, price, run.
PSM Desk reads one finished run. It does not search spectra, it never sees your raw or mzML files, and it cannot compute a number the export does not carry. Everything below is built on what the table and the parameters say, which is exactly why the contract is so insistent about the difference between a number this page recomputed, a number your software reported, and a quantity nothing in the input establishes at all.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... }, "meta": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
meta is advisory — request ids, timing, occasional deprecation notes — and never
carries the answer. Read ok first, then data or error, and
never branch on the HTTP status alone: a 200 with ok: false is possible and an
error object is always the authority on what went wrong.
There is no X-App-Slug header. The browser SDK this app ships sends
exactly two headers on a normal call — Content-Type: application/json and
Authorization: Bearer … — plus Idempotency-Key on a run and
Accept: text/event-stream on a stream. The slug psm-desk appears in one
place only: the body of POST /guest. A token is already bound to its app, so nothing
downstream needs to be told the slug again, and a header that looks like it should work is simply
ignored. People invent that header, spend an afternoon on it, and it was never read.
The endpoints, in the order a caller uses them:
| call | auth | cost | what it does |
|---|---|---|---|
POST /guest | none | free | Mints a guest token for one app. Answers 201 with {token, guest_id, expires_at}. |
GET /me | token | free | Returns {subject_type, subject_id, credits} and nothing else. |
POST /estimate | token | free | Prices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token. |
POST /run | token | metered | Starts a review. Returns {job_id}. |
GET /job/{job_id} | token | free | Polls one job. The terminal job carries output.output, charged_credits and truncated. |
POST /run-stream | token | metered | The same run as server-sent events: job, delta, done, and error on a failure. |
The request body IS the input object. It is never wrapped in an
input key.
The body of /estimate, /run and /run-stream is the flat
input object — {"task": …, "psms": …, "params": …, …}.
Not {"input": {…}}, not {"body": {…}}, not
{"data": {…}}. This is the single most important sentence on the page, because
the wrong shape does not fail: a wrapped body returns 200,
reserves a plausible-looking hold, produces a job that succeeds, and bills you — while the model
receives an object with none of the fields it is told to read. What comes back is a fluent
identification review of nothing: an assessment of a run whose PSM table, decoy counts and search
parameters it never saw. There is no error code for it and no warning in the reply. The only
defence is sending the object flat, which is what every sample on this page does, plus the one
assertion in the verification step — read lane back and
compare it to the task you sent.
See the request body for the field list.
Error codes
| code | status | what causes it here, and what to do |
|---|---|---|
VALIDATION_ERROR | 400 | The body is not the shape the app expects — most often a missing psms, or a prescan_facts sent as a JSON string instead of an object. A body that is not valid JSON at all lands here too. Note what is not a validation error: an absent task (the model picks a lane), an absent params (legitimate, and the commonest shape of a first paste), and a body wrapped in input (a silent 200). |
UNAUTHORIZED | 401 | The token is missing, malformed or past its expires_at. The common surprise is a 401 from /estimate: it is free but still authenticated, so it cannot run before step 1. Mint another token with POST /guest, or copy a personal one from the token page. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. Call /estimate first — it is free — and compare min_credits against credits from /me before you start a batch of runs. A balance between min_credits and hold_credits does not 402: it runs truncated. See truncation. |
FORBIDDEN | 403 | The token is valid but not for this app, or a guest token tried a metered run. Mint the token against psm-desk, and sign in for a metered lane — a review is metered, so a guest gets a 403 on /run rather than a 402. |
NOT_FOUND | 404 | An unknown job_id, or a slug in the /guest body that does not exist. Check for a typo in slug; psm and psm-desk are not the same app. |
RATE_LIMITED | 429 | Too many requests. Back off and retry with the same Idempotency-Key; a tight retry loop with fresh keys is how a batch bills four times for one export. |
INTERNAL | 500 | A server-side failure. Retry with the SAME Idempotency-Key so a half-finished run is not billed twice. If it repeats on one run and not on others, send fewer table rows or shorten params — a forty-thousand-row export pasted whole is the usual trigger, and it is also the case the row sampler exists to prevent. |
The two failure modes with no error code are worth more attention than the seven above. One is the
wrapped input key. The other is a reply that looks complete and quietly dropped a
blocking fact you handed it in prescan_facts — which is exactly what the
reconciliation contract exists to make checkable.
The task field, before anything else
This app is three reviews behind one endpoint. task chooses which one you get, it is
always present in a well-formed request, and it changes the shape of body in the reply.
Everything else in the input — the PSM table, the parameters, the goal, the stage, the question, the
prescan facts — is identical across all three. Send the same input three times with three different
task values and you get three documents about one run.
| # | task | name | the question it answers | what body carries |
|---|---|---|---|---|
| 1 | ident | Identification confidence | Are these peptides and proteins reportable? Recomputes the error rate from the decoys in the list, reads the digestion, the mass accuracy, the modifications, the inference and the contaminants, and says which identifications may be reported and which may not. | fdr_assessment, evidence_review, drop_list, trust_scope |
| 2 | quant | Quantification readiness | Can the intensities in this same export carry a quantitative claim? Reads the measurement columns, the missingness, the normalisation, the replication and the statistics, and names the smallest reportable result. | quant_readiness, normalisation, missingness, design_review, changes, min_reportable |
| 3 | methods | Write it up and deposit it | What can I actually write down, and what does a repository still want? Drafts the methods paragraph, the reporting table with a provenance on every row, the deposition checklist, the limitations and the open items. | methods_paragraph, reporting_table, deposition_checklist, limitations, open_items |
The three lanes are a pipeline, and they are worth running in this order, because each one can
invalidate the next. An export whose decoys imply a 1.7% error rate against a claimed 1% makes every
fold change in quant a fold change between two lists of unknown purity; a
quant readiness call written before the identifications have been judged prescribes a
normalisation for intensities attached to peptides that should have been dropped; and
methods is worth writing only once ident has stopped finding things. Run
ident on the first search, quant when the identifications hold, and
methods when both do.
The lanes are not interchangeable and they are not additive. A reply never blends two lanes'
body shapes — a merged body fails to render — and the lane's own question decides where
a fact lands. One example, the one that comes up most: 1,483 of 6,214 protein groups resting
on a single peptide, with the FDR controlled at the PSM level is an
evidence_review row on inference plus a
trust_scope.may_not_report entry in ident; it is a
design_review row in quant, because a one-peptide group's intensity is one
peptide's intensity and the fold change inherits its variance; and in methods it is one
reporting_table row, one deposition_checklist item on the inference
settings, and one limitations sentence. One fact, three places, and it must be reported
at the same severity in all three. Let the lane decide where it goes; never let it decide how bad it
is.
If task is absent or unrecognised the run does not fail. The model
picks the lane the input best fits — a bare PSM table with decoys and q-values and no question is
ident, an export whose columns are mostly intensities with a stated design is
quant, a run with a deposition deadline in question is
methods — sets lane to whatever it chose, and says so in the first
sentence of summary. What it never does is blend two contracts to cover itself. That
fallback exists so a malformed request still returns something useful, not so you can skip the
field: read lane from the reply before you read body, and send the lane.
A note on what the three lanes share, since it is the reason one request shape is enough. All three read the same two artefacts: the table says what came back, row by row, and the parameters say what was asked for — the database, the enzyme, the tolerances, the FDR method and level, the quantification and the design. Almost every interesting finding in this app is a disagreement between those two. A parameter block that says "1% FDR, PSM level" next to a filtered list whose own decoys imply 1.74% is not a table problem or a prose problem; it is the finding. Send both.
The request body
Every field is top-level. task and psms are required; everything else is
optional and absent means absent — nothing is defaulted on your behalf, and nothing in the reply may
assume a field you did not send.
{
"task": "ident",
"psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,decoy\nAAGVNVEPFWPK,2,672.8531,5.8,42.11,28.4,0.0004,P60709,target\n...",
"params": "Instrument: Orbitrap Exploris 480\nSearch engine: MSFragger 4.0\nFDR: 1%, PSM level\n...",
"goal": "publication",
"stage": "first_search",
"question": "can I report the 6,214 protein groups as a 1% FDR list?",
"prescan_facts": { }
}
| field | type | meaning |
|---|---|---|
task | string, required | The lane: ident, quant or methods. See above. Absent or unrecognised does not fail — the model picks the closest lane and names it in lane — but it is not a field to leave out on purpose. |
psms | string, required | The PSM, peptide or protein export of one run, as text. CSV, TSV, a markdown pipe table or whitespace-aligned columns all parse; send whatever your engine already wrote rather than reformatting it. Column names are matched as whole normalised tokens against a long alias list, so Annotated Sequence, Modified sequence and peptide are one column, and q_value, PSM q-value, global q-value and FDR are another. The columns read are listed below. Large exports are sampled, not truncated: the browser sends a golden-ratio low-discrepancy draw across the whole table with the extreme row of every check forced in — the worst q-value, the largest absolute mass error, the highest missed-cleavage count, the shortest and longest peptide, at least one decoy row if any decoy exists, and one row per protein group while the budget allows — and announces the cut in-band with the real row count, so a reply can say that a statistic was computed over all rows while only a sample was shown. |
params | string, optional — but it is what turns on most of the review | The search parameters and run notes: labelled lines (Enzyme: trypsin, FDR: 1% PSM level), prose, or both. Thirty reporting items are read out of it — instrument, acquisition mode, gradient, sample, search engine, database and its entry count, enzyme, missed cleavages, fixed and variable modifications, precursor and fragment tolerances, FDR method, decoy strategy, threshold and level, minimum peptides per protein, protein inference, rescoring, quantification method, normalisation, missing-value handling, replicates, statistical test, multiple-testing correction, match-between-runs, contaminant handling, deposition and software versions. Each resolves to stated, none or missing, and the middle state is a real answer rather than a gap: Imputation: none is a careful methods section, not an incomplete one. |
goal | string | What the output is for: publication, repository_deposition, internal_review, decision or self_check. It sets register and emphasis, never severity. A publication run writes text you can paste and is strict about what may be claimed from which error rate; repository_deposition leads with what PRIDE or MassIVE will ask for and what is still missing from the submission; decision leads with what the run does and does not license you to decide; internal_review is written for a colleague who knows the instrument; self_check is blunt and short. |
stage | string | Where the run is in the workflow: first_search, after_rescore, final_check or reviewer_response. It sets the bar, not the findings. A 24% one-peptide protein share at first_search is expected and instructive; the same share at final_check is a blocking objection to the protein list, and at after_rescore it means the rescoring did not do what it was added for and the reply says which part of it clearly did not take. |
question | string, optional | Free text: the reviewer comment you are answering, the deadline, the claim you want to make, a constraint you cannot change (a database you are not allowed to re-search, a single replicate you cannot add). Often the most decisive field in the whole input, and it is not decorative: every claim in it comes back as one context_notes entry marked honoured, contradicted or unverifiable. A stated constraint that nothing in the review reflects is itself a finding. |
prescan_facts | object, optional | What the browser's own free reader already computed, passed in so the lane is held to it: {coverage, table, settings, fdr, digestion, calibration, modifications, inference, contamination, quantitation, spectra, verdict, severity_counts, severity_by_area, flags, unassessable}. flags is a contract; the rest is arithmetic the reply must not contradict. See below. |
The columns the table reader looks for, and what each one turns on. A column that is absent is not
an error — it closes the checks that depend on it, and those land in unassessable
rather than being inferred from something else:
| column | read as | what it enables |
|---|---|---|
peptide | sequence, modifications stripped for the arithmetic and kept for the modification checks | Length distribution, missed cleavages against the stated enzyme, distinct peptide counts, the modification tokens. |
charge | integer | Charge distribution; the singly-charged share, which is where a tryptic DDA run's questionable identifications concentrate; the high-charge share. |
precursor_mz | number | Reported alongside the mass error; on its own it establishes little, which is why the ppm column matters more. |
mass_error_ppm | signed number | Median offset, MAD, 5th and 95th percentiles, and the count outside the stated precursor tolerance. A systematic offset is a calibration finding, not a per-identification one. |
rt | number | Retention-time span, which is what makes a gradient claim checkable. |
score | number | Only whether a score column exists at all and its median; an engine score is not comparable across engines and this app does not pretend otherwise. |
q_value | number in [0, 1] | Filtering at the stated threshold, the count of rows above it, and the maximum q in the list. |
pep | posterior error probability | A second confidence axis. Matched on exactly pep, posterior_error_probability and their spellings — peptide is never read as pep. |
protein | accession or group, split on ; | Protein group count, peptides per group, the one-peptide share, shared-accession rows, and the decoy read when there is no decoy column. |
description | protein name text | The contaminant check. An export whose accession column is a bare P02768 and whose description says "Serum albumin" carries the commonest contaminant in proteomics, and matching the accession alone finds nothing. |
decoy | boolean-ish flag | The whole target-decoy arithmetic. Absent, decoys are read from accession prefixes (DECOY_, rev_, REVERSED); absent both, no error rate can be recomputed and every FDR check is unassessable. |
mods | modification list | The modification checks, together with the tokens inside the sequence. |
spectrum | scan or spectrum id | Distinct spectra, and how many carry more than one PSM — which is how a list that looks twice as deep as it is gets caught. |
| any intensity column | number, recognised by shape | Everything the quant lane does. Recognised by stem — Intensity 3, LFQ intensity WT_2, Abundance: F1: 126, iBAQ, Reporter ion 127N, Area, Ratio — so the column count is the measurement count, not the column count of the file. |
Two absences that are not the same thing, and the reply is held to the difference. No
decoy information at all means the error rate was never checked — the reply says "no
decoy column and no decoy-prefixed accessions, so the q-values have to be taken on trust", never
"the FDR is fine". And in the parameters, a quantity the author explicitly says was not done
(Normalisation: none, Imputation: none) is a stated absence,
which is a stronger answer than a quantity the block never reaches: the first is a decision, the
second is an omission. Never flatten those two into "missing". The same distinction is what the
methods lane's source column exists to carry: computed,
reported and not_established are three different provenances and only the
first two can go in a paper.
prescan_facts, and the reconciliation contract
In the browser, prescan_facts comes from the free in-page read that runs before anyone
signs in — the pass described under what the browser does before you pay. It
resolves thirty reporting items to one of three states, recomputes the false-discovery rate from the
decoys in the list under both target-decoy conventions, recomputes the missed cleavages from the
sequences against the stated enzyme, summarises the mass-error distribution against the stated
tolerance, counts the contaminant classes, counts the protein groups and their peptide support, and
measures the missingness of every intensity column. It costs nothing and it never calls a model.
An API caller does not have to reproduce any of that. Sending no prescan_facts at all
is legitimate, and the review still works — the model reads psms and
params either way.
What makes it worth sending is the contract. Every uid you put in
flags comes back exactly once in the response's reconciliation array — no
more, no fewer, and no uid you did not send — each with a
status:
status | means |
|---|---|
confirmed | The reviewer agrees, at the same or a higher severity. A useful confirmed adds the consequence the flag itself does not state — not "yes, the recomputed FDR is 1.74%" but "1.74% over 40,453 filtered rows means roughly 700 of the peptides in this list are wrong, and because the FDR was controlled at the PSM level the protein-level error is higher still, so the 6,214-group count is not a 1% list in any sense". |
adjusted | Real, but the severity or the reading changes — and the note says what changed it. A 31% missed-cleavage share is high on a run whose parameters claim a complete overnight digest, and medium on a run whose parameters say the enzyme was semi-specific, because then a missed cleavage is expected rather than a failure. |
set_aside | Not a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all. |
noted | Carried forward without a judgement, because the lane's question does not turn on it but the reader should still see it. A contaminant count in the quant lane, where it belongs in design_review rather than in the readiness call. |
not_applicable | The flag does not apply to this lane's question — a normalisation flag in an ident run that never looked at an intensity column, a deposition-coverage item in quant. |
That turns a fact your own tooling established into something the reply is held to. A
uid that never appears is a failed run, not a passing one. Asserting both directions in
your client is three lines and it catches the one failure this app cares most about: a fluent,
well-written review that quietly dropped the blocking fact you handed it. The check is written out
in the verification step.
uid is the flag's stable handle within one prescan — P01,
P02, … in the order the checks run, which is by area: fdr, then
digestion, calibration, modification,
inference, contamination, quantitation,
design, reporting. Send them as you received them; they are opaque to the
model, which reconciles them by identity rather than by parsing them, and the area and
title beside each one carry the meaning. Each flag also carries the fields the reply is
allowed to quote back:
| flag field | type | meaning |
|---|---|---|
uid | string | The handle. Reconciled by identity, exactly once. |
area | enum | One of the nine areas listed with the output contract. |
severity | enum | The severity after mitigation, which is the one the reply is held to. |
base_severity | enum | What the check would have raised with no mitigating fact. Sent so the reply can see that a step-down already happened and not step it down twice. |
mitigated_by | string[] | Why it stepped down, in the words of the parameter that did it — "the parameters say the search was semi-specific", "the parameters say the run was recalibrated". Empty when nothing mitigated it. |
title | string | One line, the check's own name for the problem. |
detail | string | The prescan's own explanation. The reply is not required to repeat it and is required not to contradict it. |
evidence | string | The arithmetic or the source text behind it, clipped to 200 characters. |
row | integer or null | A 1-based row index into the table as read, when the flag came from one row. |
line | integer or null | A 1-based line number in params, when the flag came from the parameter block. |
value, threshold | number or null | The measured number and the number it was compared against, when the check is a threshold check. Present so the reply can quote them without recomputing them differently. |
The rest of prescan_facts is arithmetic the reply must not contradict:
| key | shape | what it is for |
|---|---|---|
coverage | {answered, total, stated_none[], never_mentioned[]} | The reporting-checklist roll-up over the parameter block. total is the fixed number of items the free read looks for (thirty); answered is how many the parameters resolved; the two arrays are the items explicitly declared absent and the items never mentioned — and those are different findings, which is why they are different arrays rather than one "missing" list. |
table | {rows_total, rows_sent, sampled, columns_read[], quant_columns, distinct_peptides, distinct_modified_peptides, protein_groups, spectra_distinct} | What was parsed and what was actually sent. rows_total is the whole export; rows_sent is the sample in psms. Every statistic in prescan_facts is computed over rows_total, which is why a reply may cite a count larger than the rows it can see — and why it must not cite a row index that is not in the sample. |
settings | {enzyme, enzyme_stated, tolerance_ppm, tolerance_unit, q_threshold, q_threshold_stated, fdr_level, decoy_search, decoy_generation, missed_allowed, min_peptides, database_entries, replicates, quant_method} | The parameters, already parsed, each with a companion boolean where an assumption was needed. enzyme_stated: false with enzyme: "trypsin" means the missed-cleavage arithmetic assumed trypsin — a reply that presents that as something you said is contradicting the input. |
fdr | {decoy_source, strategy, claimed_threshold, claimed_level, whole_list{targets,decoys,separate,concatenated}, at_threshold{...}, rows_above_threshold, decoys_above_threshold, q_values_present, decoy_status_unresolved, max_q} | The error-rate arithmetic, under both conventions, because they differ by close to a factor of two: separate target and decoy searches give D/T, one concatenated database gives 2D/(T+D). decoy_source is column, accession or none; when it is none the whole block is null and every FDR check is unassessable. |
digestion | {enzyme_used, enzyme_assumed, missed_known, missed_any, missed_any_share, missed_max, length_median, length_min, length_max, short_share} | Recomputed from the sequences, not read from a column. short_share is the fraction under seven residues, which is the length below which a tryptic peptide is rarely proteotypic and therefore cannot carry a protein identification on its own. |
calibration | {ppm_known, ppm_median, ppm_mad, ppm_p05, ppm_p95, ppm_min, ppm_max, tolerance_ppm, outside_tolerance, outside_share} | The precursor mass-error distribution. The median is the systematic offset and the MAD is the spread; a large offset with a small spread is a calibration problem and not an identification problem, and the reply is expected to say which of the two it is. |
modifications | {rows_with_any_token, tokens{}, cys_peptides, cys_with_fixed_mod, cys_check_assessable, cys_missing_fixed_mod, met_peptides, met_oxidised, ox_check_assessable, met_ox_share} | The *_assessable booleans are load-bearing. Most engines apply a fixed modification silently and never write it into the reported sequence, so a correctly configured run shows zero carbamidomethyl tokens — and reporting that as "every cysteine peptide is missing the fixed modification" is an inverted finding of exactly the kind that corrupts a whole review. When the flag is false the check did not run and the reply must not run it either. |
inference | {assessable, protein_groups, one_peptide_groups, one_peptide_share, median_peptides_per_group, max_peptides_per_group, rows_with_shared_accessions, declared_peptide_counts} | The protein-level picture. one_peptide_share against settings.min_peptides is the pair that matters: a 24% one-peptide share is a consequence of min_peptides: 1, not an accident, and the reply should say so rather than treating it as a surprise. |
contamination | {assessable, matched_on, rows, share, by_class[{key,label,rows}]} | cRAP classes matched on accession and description together. matched_on says which, because "accession only" on an export whose accessions are bare UniProt ids finds almost nothing and the reply should not read a zero there as a clean run. |
quantitation | {columns[{name,present,missing,missing_share,min,max,median,log2_range}], column_count, comparable, rows_complete_across_all_columns, complete_share} | Everything the quant lane needs. comparable is false below two measurement columns, and then the readiness call is a statement about what cannot be compared rather than a judgement of a comparison. |
spectra | {assessable, distinct, with_multiple_psms, max_psms_per_spectrum} | Whether one spectrum is carrying several PSMs, which is how a list looks deeper than it is. assessable: false when no spectrum or scan column was found. |
verdict | "reportable" | "reportable_with_caveats" | "revise" | "not_reportable" | "unassessable" | What the free read concluded from its own flags alone. The model's verdict may differ, and when it is more favourable the summary has to say what justified the move. |
severity_counts | {blocking, high, medium, low, info} | How many flags of each severity. The cheapest cross-check there is: a reply whose verdict is reportable against a non-zero blocking count has contradicted the input, not the model. |
severity_by_area | {area: {blocking, high, medium, low, info}} | Severity crossed with area, because the verdict floors are per-lane and per-area: a blocking fdr fact binds the ident lane's verdict, a blocking design fact binds quant, and a blocking reporting fact binds methods. Shipping the cross-tabulation rather than prose means the rule is a lookup against this object and cannot contradict it. |
flags | [{uid, area, severity, base_severity, mitigated_by[], title, detail, evidence, row, line, value, threshold}] | The contract above. |
unassessable | [{item, why}] | Checks the free read could not make. These are not flags and not part of the reconciliation contract; the reply is expected to carry them forward into its own unassessable rather than pretend the check was made. |
"prescan_facts": {
"coverage": { "answered": 26, "total": 30,
"stated_none": ["normalisation", "missing_values", "multiple_testing"],
"never_mentioned": ["rescoring", "fragment_tolerance", "gradient",
"match_between_runs"] },
"table": { "rows_total": 41207, "rows_sent": 150, "sampled": true,
"columns_read": ["peptide", "charge", "precursor_mz", "mass_error_ppm", "rt",
"score", "q_value", "protein", "description", "decoy",
"mods", "spectrum"],
"quant_columns": 4, "distinct_peptides": 28911,
"distinct_modified_peptides": 31004, "protein_groups": 6214,
"spectra_distinct": 39018 },
"settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
"tolerance_unit": "ppm", "q_threshold": 0.01, "q_threshold_stated": true,
"fdr_level": "psm", "decoy_search": "concatenated",
"decoy_generation": "reversed", "missed_allowed": 2, "min_peptides": 1,
"database_entries": 20428, "replicates": 4,
"quant_method": "label-free (LFQ)" },
"fdr": { "decoy_source": "column", "strategy": "concatenated",
"claimed_threshold": 0.01, "claimed_level": "psm",
"whole_list": { "targets": 40251, "decoys": 956,
"separate": 0.023751, "concatenated": 0.046400 },
"at_threshold": { "targets": 40101, "decoys": 352,
"separate": 0.008778, "concatenated": 0.017403 },
"rows_above_threshold": 754, "decoys_above_threshold": 604,
"q_values_present": 41207, "decoy_status_unresolved": 0, "max_q": 0.191 },
"digestion": { "enzyme_used": "trypsin", "enzyme_assumed": false, "missed_known": 41013,
"missed_any": 12714, "missed_any_share": 0.31, "missed_max": 3,
"length_median": 13, "length_min": 6, "length_max": 41,
"short_share": 0.021 },
"calibration": { "ppm_known": 41190, "ppm_median": 5.9, "ppm_mad": 1.8, "ppm_p05": 3.1,
"ppm_p95": 8.4, "ppm_min": -1.2, "ppm_max": 14.7,
"tolerance_ppm": 20, "outside_tolerance": 0, "outside_share": 0 },
"modifications": { "rows_with_any_token": 19442,
"tokens": { "Carbamidomethyl (C)": 14877, "Oxidation (M)": 4104,
"Acetyl (Protein N-term)": 461 },
"cys_peptides": 14903, "cys_with_fixed_mod": 14877,
"cys_check_assessable": true, "cys_missing_fixed_mod": 26,
"met_peptides": 9318, "met_oxidised": 1042,
"ox_check_assessable": true, "met_ox_share": 0.112 },
"inference": { "assessable": true, "protein_groups": 6214, "one_peptide_groups": 1483,
"one_peptide_share": 0.2387, "median_peptides_per_group": 3,
"max_peptides_per_group": 214, "rows_with_shared_accessions": 3902,
"declared_peptide_counts": false },
"contamination": { "assessable": true, "matched_on": "accession and description",
"rows": 1146, "share": 0.0278,
"by_class": [{ "key": "keratin", "label": "Keratins", "rows": 611 },
{ "key": "albumin", "label": "Serum albumin", "rows": 248 },
{ "key": "trypsin", "label": "Trypsin", "rows": 187 },
{ "key": "casein", "label": "Caseins", "rows": 100 }] },
"quantitation": { "column_count": 4, "comparable": true,
"columns": [
{ "name": "Intensity_DMSO_1", "present": 30104, "missing": 11103,
"missing_share": 0.2694, "min": 4100, "max": 1.84e9,
"median": 1240000, "log2_range": 18.8 },
{ "name": "Intensity_DMSO_2", "present": 29881, "missing": 11326,
"missing_share": 0.2749, "min": 3900, "max": 1.71e9,
"median": 1190000, "log2_range": 18.7 },
{ "name": "Intensity_STS_1", "present": 28744, "missing": 12463,
"missing_share": 0.3024, "min": 4400, "max": 1.66e9,
"median": 1010000, "log2_range": 18.5 },
{ "name": "Intensity_STS_2", "present": 27990, "missing": 13217,
"missing_share": 0.3207, "min": 4200, "max": 1.59e9,
"median": 968000, "log2_range": 18.5 }],
"rows_complete_across_all_columns": 25548, "complete_share": 0.6199 },
"spectra": { "assessable": true, "distinct": 39018, "with_multiple_psms": 1904,
"max_psms_per_spectrum": 2 },
"verdict": "not_reportable",
"severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
"severity_by_area": {
"fdr": { "blocking": 1, "high": 1, "medium": 1, "low": 0, "info": 0 },
"digestion": { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 0 },
"calibration": { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 1 },
"modification": { "blocking": 0, "high": 0, "medium": 0, "low": 0, "info": 1 },
"inference": { "blocking": 0, "high": 1, "medium": 0, "low": 0, "info": 0 },
"contamination": { "blocking": 0, "high": 0, "medium": 1, "low": 0, "info": 0 },
"quantitation": { "blocking": 0, "high": 1, "medium": 0, "low": 0, "info": 0 },
"design": { "blocking": 1, "high": 0, "medium": 0, "low": 0, "info": 0 },
"reporting": { "blocking": 0, "high": 0, "medium": 0, "low": 1, "info": 0 }
},
"flags": [
{ "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
"mitigated_by": [],
"title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
"detail": "Recomputed from the rows at or below the stated threshold, the false-discovery rate is 1.74% against a claimed 1%. Either the filter was not applied to this export, or the q-values were computed over a different set than the one pasted.",
"evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
"row": null, "line": 11, "value": 0.017403, "threshold": 0.01 },
{ "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
"mitigated_by": [],
"title": "Protein-level claims from a PSM-level error rate",
"detail": "The FDR was controlled at the PSM level and 6214 protein groups are being reported from it. PSM-level control does not bound the protein-level error, and on a list this size the protein-level rate is typically several times higher.",
"evidence": "6214 protein groups, FDR stated at the PSM level",
"row": null, "line": 11, "value": 6214, "threshold": null },
{ "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
"mitigated_by": [],
"title": "Just under a third of peptides carry at least one missed cleavage",
"detail": "12714 of 41013 peptides have an internal cleavage site the enzyme did not cut, computed with the trypsin rule including the proline exception. At this level the digest was incomplete, which suppresses identification rate and skews label-free quantification toward whatever digested well.",
"evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
"row": null, "line": 6, "value": 0.31, "threshold": null },
{ "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
"mitigated_by": [],
"title": "Nearly a quarter of protein groups rest on a single peptide",
"detail": "1483 of 6214 groups are supported by one peptide, and the parameters state a minimum of one peptide per protein. That is a choice rather than an accident, and it means the protein list carries a much higher error rate than the peptide list it was built from.",
"evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
"row": null, "line": 13, "value": 0.2387, "threshold": null },
{ "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
"mitigated_by": [],
"title": "Label-free intensities with normalisation stated as none",
"detail": "Four MS1 intensity columns with a stated normalisation of none. Column medians differ by more than 20% across the four, so any fold change taken from these columns is part loading difference.",
"evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
"row": null, "line": 15, "value": null, "threshold": null }
],
"unassessable": [
{ "item": "the transferred-identification error rate",
"why": "match-between-runs is not mentioned in the parameters, so whether any of these rows are transferred cannot be established" },
{ "item": "peptide-level and protein-level q-values",
"why": "the export carries one q-value column and the parameters state the PSM level, so no peptide- or protein-level rate is present to check" }
]
}
One asymmetry worth knowing before you build on this. The prescan's flags are things it
could prove from the two artefacts — arithmetic, a threshold, a contradiction between a
stated setting and a computed count. It never flags a judgement, and it never guesses: the
target-decoy arithmetic runs only when a threshold and resolvable decoys both exist, so it cannot
fire on an assumption. A prescan with an empty flags array and a verdict
of reportable is therefore not a clean bill of health; it means nothing
checkable was wrong, which is precisely when a lane is worth paying for.
The output contract
data.output.output is a string holding one JSON object — no preamble, no code fence, no
prose outside it. The web app still takes everything from the first { to the last
} before parsing, and a caller should do the same: it costs one slice and it survives
the small variations a model produces.
Eleven keys, identical in all three lanes except body:
{
"lane": "ident | quant | methods",
"title": "short name for this review, naming the run as the parameters name it",
"verdict": "reportable | reportable_with_caveats | revise | not_reportable | unassessable",
"headline": "one sentence naming the single fact that decides the verdict",
"summary": "two to five sentences a proteomics analyst can act on. No restating of the JSON.",
"findings": [
{
"id": "F-001",
"severity": "blocking | high | medium | low | info",
"area": "fdr | digestion | calibration | modification | inference | contamination | quantitation | design | reporting",
"title": "one line",
"detail": "what is wrong, what it does to the claim, and what makes it this severity and not another",
"evidence": "the exact cell, count or parameter line this rests on",
"row": 118,
"fix": "the concrete change, with the actual number or setting named"
}
],
"reconciliation": [
{ "flag_uid": "P01",
"status": "confirmed | adjusted | set_aside | noted | not_applicable",
"note": "why" }
],
"caveats": [
{ "area": "fdr",
"fact": "a fact established elsewhere in this run that constrains this lane",
"why_it_matters": "what it forbids saying, or forces hedging, here" }
],
"context_notes": [
{ "claim": "what you said in question",
"status": "honoured | contradicted | unverifiable",
"note": "what the table and the parameters actually support" }
],
"unassessable": [
{ "item": "the check that could not be made", "why": "what was missing from the input" }
],
"body": { }
}
| key | type | meaning |
|---|---|---|
lane | enum | The lane that actually ran. Normally it echoes task; when task was absent or unrecognised it is the lane that was chosen, and summary says so in its first sentence. Read this, not your own request, before you read body — a lane you did not ask for is also what a wrapped input key looks like from the outside. |
title | string | Short name for the review, naming the run as the parameters name it — HeLa staurosporine DDA, first search rather than Proteomics review. |
verdict | enum | One of five values, below. The single field a release gate should branch on. |
headline | string | One sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it. "The decoys at q ≤ 0.01 imply 1.74%, so this is not a 1% list" is a headline; "several confidence issues were found" is not. |
summary | string | Two to five sentences, and not a restatement of the JSON. It is also where the exceptions are announced: a sampled table, an absent task, a verdict that moved away from the prescan's, an error rate that is a lower bound because the decoy status of some rows could not be read. |
findings | object[] | {id, severity, area, title, detail, evidence, row, fix}. Ids are F-001, F-002, … in the order reported — note that these are the reply's ids and they are not the prescan's uids. May be empty, and an empty array is a real answer; no placeholder finding is ever emitted to fill it. |
findings[].row | integer or null | A 1-based row index into the table as read, or null. Copied from prescan_facts or taken from a row visible in psms; never estimated, and never a row that is not in the sample that was sent. null is the correct answer for a finding about the list as a whole — a false row index is worse than none, because someone will open the export and look at it. |
reconciliation | object[] | {flag_uid, status, note}. One entry per uid you sent in prescan_facts.flags, exactly once, no more and no fewer. Empty when you sent no flags. This is the contract worth asserting. |
caveats | object[] | {area, fact, why_it_matters} — the facts established outside this lane's own question that still constrain it. This is how the pipeline stays honest across three calls: a quant run over a list whose ident pass found a 1.74% empirical FDR carries that as a caveat with area: "fdr", and the readiness call is written under it. Empty is legitimate on a clean run; empty against a not_reportable prescan is a defect. |
context_notes | object[] | {claim, status, note}, one entry per claim in question. Send no question and this is empty; send three claims and expect three entries. An empty context_notes against a paragraph of question text means the most decisive field in the input was not read. |
unassessable | object[] | {item, why} — the checks that genuinely could not be made from what was sent. An honest entry here is preferred to a confident guess, and the prescan's own unassessable entries are carried forward into it rather than dropped. |
body | object | The lane's own document. Three shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below. |
The enums
These strings are shared verbatim with the browser's own free reader, so the two never disagree about what a clean result is called. The renderer keys on them: an unrecognised value renders as an error rather than being coerced to something plausible, so treat them as closed sets.
verdict | when |
|---|---|
reportable | Nothing above info is left. The error rate holds under the convention the parameters state, the digestion and the mass accuracy are what the settings imply, the inference is bounded, and the lane's question is answered. |
reportable_with_caveats | The worst finding is medium or low. The run is usable; read the caveats before you quote a count or a fold change. |
revise | The worst finding is high. Something needs a re-search, a re-filter or a changed pipeline step before it is reportable — not a wording change. |
not_reportable | At least one finding is blocking: the export does not support the claims being made from it. A recomputed error rate well above the claimed threshold, no confidence information of any kind, a fold change from one replicate, thousands of uncorrected per-protein tests. Nothing from this run goes out until it is redone. |
unassessable | The input did not carry enough to reach any of the four above — a table with no confidence columns and no decoys, or a paste that parsed as one column. This is a statement about the input, not about the run, and unassessable[] then carries the specifics. It is the one verdict that is not a judgement of the science. |
The verdict follows the findings, mechanically, and never contradicts them. That makes two cheap
assertions available to any client: a not_reportable verdict with no
blocking finding is a broken reply, and so is a reportable verdict with a
finding above info. Both are worth failing on rather than rendering.
severity | meaning |
|---|---|
blocking | A stated claim is not supported by what the export itself contains. A recomputed FDR half again above the claimed threshold, no decoy information and no q-values at all, a quantitative comparison with one measurement column, per-protein tests over thousands of proteins with no multiple-testing correction, a protein list built at a threshold the rows do not respect. |
high | The result may stand and cannot currently be trusted, or a reviewer will demand a re-search: protein-level claims from a PSM-level error rate, a quarter of the protein groups on one peptide, label-free intensities with no normalisation and visibly different column medians, a digest so incomplete that the identification depth is not what the method claims. |
medium | Real, bounded or mitigated — worth fixing before the next run rather than before the next paper. A systematic mass offset well inside the search window, a third of peptides with a missed cleavage, a contaminant class left in the export, rows above the stated threshold still present in the file. |
low | Worth naming, not worth holding the run for. An undeposited dataset, an unstated gradient, missing software versions, a fragment tolerance the parameters never give. |
info | Context the reader should have. Zero rows outside the stated precursor tolerance, an oxidation share consistent with normal handling, a decoy count that reconciles exactly with the claimed threshold. info alone still permits reportable, and these are worth saying out loud — a review that only ever reports problems cannot be checked for the things it looked at and found fine. |
Severity depends on the mitigating facts, and detail names the mitigation whenever the
grade moved because of it. A 31% missed-cleavage share is high against parameters
claiming a complete overnight digest and medium against parameters that say the search
was semi-specific. A 6 ppm median offset is medium on a run that says nothing about
calibration and info on one whose parameters say a lock mass was used and the search
window was widened deliberately. If you diff two reviews of the same export and a severity moved,
the reason is in detail.
area | covers |
|---|---|
fdr | The error rate and everything that establishes it: decoys and how they were generated and searched, q-values, posterior error probabilities, the threshold, the level it was controlled at, and the arithmetic that connects them. |
digestion | Enzyme, specificity, missed cleavages, peptide length, and what an incomplete digest does to depth and to intensity. |
calibration | Precursor and fragment mass accuracy: the systematic offset, the spread, the tolerance window, rows outside it, recalibration. |
modification | Fixed and variable modifications as configured against the modifications actually present in the sequences, localisation confidence, enrichment, and the site-level claims that rest on them. |
inference | Protein grouping: razor and unique peptides, shared accessions, one-peptide groups, the minimum peptides per protein, and whether a protein-level claim is bounded by anything. |
contamination | cRAP and its friends — keratins, albumin, trypsin, caseins — whether they were appended, whether they were removed, and what they do to a quantitative comparison when they were not. |
quantitation | The measurement columns: what was measured, normalisation, missingness and how it was handled, dynamic range, ratio compression, and whether the columns are comparable at all. |
design | Replication and statistics: how many replicates and of what kind, the test, multiple-testing correction, batch structure, and whether the comparison being made is the comparison the design supports. |
reporting | What was written down: the database and its version, the settings, the software versions, the contaminant policy, the deposition, and every claim in the parameters the table does not support. |
Nine areas, and no synonyms: identification, statistics,
qc, search and proteomics are not values. If you bucket
findings for a dashboard, bucket on these nine. The pairing worth internalising is
fdr/inference: they are two different questions — how many of these
peptides are wrong, and what does that imply for a protein list assembled from them — and
collapsing them is exactly how a 1% PSM-level list gets released as a 1% protein list.
Two more closed sets, both small and both easy to get wrong. reconciliation.status is
confirmed, adjusted, set_aside, noted or
not_applicable — described with the reconciliation
contract above. context_notes.status is honoured,
contradicted or unverifiable: the first means the claim in
question was addressed on its own terms, the second means the input contradicts it and
the note says with which cell or which parameter line, and the third means nothing in the input
speaks to it either way. unverifiable is the honest answer to "we always run a
two-hour gradient" when the parameters do not say so, and it is not a criticism.
body for task: "ident"
Whether the identifications are reportable. Four blocks, ordered the way a reviewer reads an export: what the error rate actually is, what each line of evidence says, what should come out of the list, and — the block that matters most to a caller — what may and may not be reported.
fdr_assessment is a paragraph and it is the one place the arithmetic is allowed to be
spelled out: which convention was used and why, what the recomputed rate is, how it compares to the
claimed threshold, and what the level it was controlled at does and does not bound. When the decoy
source was none it says so and says what that costs, rather than repeating the
q-values as though they had been checked.
evidence_review is one row per area the lane looked at, with a one-clause
judgement and a note that names the number behind it. The
area is from the closed set; judgement is deliberately free text of a
clause or two, because "adequate for a discovery list, not for a targeted follow-up" is the honest
reading and no enum carries it — do not build a renderer that keys on it. An area the input could
not speak to does not get a row here; it gets an unassessable entry.
drop_list is the actionable block: what to remove, why, and how many rows it is. It is
written so it can be executed — "rows with q above the stated threshold", "peptides shorter than
seven residues", "the 1,146 rows matching a cRAP class" — and count is the number of
rows, taken from the prescan or from the visible table, never estimated. An empty
drop_list on a clean run is a real answer.
trust_scope is the block to build tooling on. may_report and
may_not_report are arrays of claims, phrased as a person would make them, and they
partition what this run produced — a claim in neither is a gap, and a claim in both is a broken
reply.
"body": {
"fdr_assessment": "One paragraph: the convention used, the recomputed rate, the claimed threshold, and what the level it was controlled at bounds and does not bound.",
"evidence_review": [
{ "area": "fdr",
"judgement": "fail",
"note": "The claimed 1% does not hold for this list — 2D/(T+D) over the 40,453 rows at q <= 0.01 gives 1.74%; 352 decoys survived the filter" },
{ "area": "digestion",
"judgement": "concern",
"note": "Incomplete, and it shows in the depth — 31% of peptides carry at least one missed cleavage against 2 allowed; median length 13" },
{ "area": "calibration",
"judgement": "concern",
"note": "Offset but well inside the window — median +5.9 ppm, MAD 1.8, no rows outside the stated 20 ppm" },
{ "area": "inference",
"judgement": "fail",
"note": "Not bounded by anything the parameters state — 1,483 of 6,214 groups on one peptide, with min_peptides = 1 and PSM-level FDR" }
],
"drop_list": [
{ "what": "rows above the stated q-value threshold",
"why": "the file was exported unfiltered, so any count taken from the row total is not the count at 1%",
"count": 754 },
{ "what": "rows matching a cRAP contaminant class",
"why": "the parameters say contaminants were appended and not removed; keratin and trypsin peptides are not part of the biology being compared",
"count": 1146 },
{ "what": "protein groups supported by one peptide",
"why": "PSM-level FDR does not bound the protein-level error, and a one-peptide group is where that gap is widest",
"count": 1483 }
],
"trust_scope": {
"may_report": [
"the peptide-level identifications for groups with three or more peptides, described as a PSM-level 1.7% list rather than a 1% list",
"the presence or absence of a named protein with several peptides across replicates"
],
"may_not_report": [
"the 6,214 protein groups as a 1% FDR protein list",
"any single-peptide identification as a detection",
"peptide or protein counts taken from the unfiltered row total"
]
}
}
Two things the ident lane will not do. It will not tell you the biology is wrong — a
technically impeccable run of the wrong experiment is reportable here, and that is the
honest answer to the question this lane asks. And it will not compute a rate the export does not
support: no decoy column and no decoy-prefixed accessions means fdr_assessment says so
and an unassessable entry appears, rather than an inference from the q-values that were
never checked.
body for task: "quant"
Whether the intensities in the same export can carry a quantitative claim. Six blocks, and they are ordered as the decision is actually made: is this comparable at all, was it normalised, what is missing and how was that handled, does the design support the comparison, what would you change, and — the block a caller wants — what is the smallest thing that can be reported as it stands.
quant_readiness is the paragraph. normalisation.method_as_stated is quoted
from the parameters verbatim, including "none" and including "not stated",
because the difference matters: a stated none on TMT reporter ions is a defensible
choice and an unstated one on label-free MS1 areas is a gap. judgement and
note are a clause and a sentence, and the note is where the column medians go.
missingness is one judgement and one note over the whole matrix, and the note is
expected to distinguish the two kinds: values missing because the peptide was below the limit of
detection in that sample, and values missing because the identification was not transferred. They
have opposite implications and only the parameters can tell them apart, which is why
match-between-runs is one of the thirty items.
design_review is one row per design question — replication, the test, the correction,
the batch structure, the contaminant policy where it touches quantification — each with a
judgement clause and a note naming the number. changes is the
ordered list of what to do, each with an effort: cheap is a re-analysis of
the same export, moderate is a re-run of the pipeline, expensive is new
mass-spectrometer time. That field exists so a caller can sort by what is actually available to them
this week.
min_reportable is the sentence a person will paste. It is the strongest claim the
current data supports without any of the changes above — often much weaker than the claim that was
wanted, and occasionally "nothing quantitative", which is a legitimate value and is written as a
sentence rather than as an empty string.
"body": {
"quant_readiness": "One paragraph: what was measured, in how many columns, over what design, and whether a fold change from it means anything yet.",
"normalisation": {
"method_as_stated": "none",
"judgement": "fail",
"note": "Required here and absent — Four label-free MS1 intensity columns whose medians run 1.24e6, 1.19e6, 1.01e6 and 9.68e5 - a 28% spread between the extremes. Without a median or quantile normalisation, part of every fold change is the loading difference between injections."
},
"missingness": {
"judgement": "fail",
"note": "High and not at random — 38% of rows are incomplete across the four columns and the missing share rises monotonically from 26.9% in DMSO_1 to 32.1% in STS_2, which is the pattern of a treatment that lowered overall intensity rather than of random dropout. The parameters say values were left as-is, so every t-test silently ran on a different subset of replicates."
},
"design_review": [
{ "item": "replication",
"judgement": "concern",
"note": "Adequate in number, unverified in kind — 4 replicates per condition are stated; the export carries 4 columns, so these are one measurement per replicate. Whether they are biological or technical is not stated, and the claim you can make differs." },
{ "item": "statistical test",
"judgement": "fail",
"note": "Stated but unusable as configured — A two-sample t-test per protein across 6,214 proteins with the correction stated as none. At an uncorrected 0.05, roughly 310 proteins are expected to reach significance by chance alone." },
{ "item": "contaminants",
"judgement": "concern",
"note": "Left in, and they carry intensity — 1,146 rows match a cRAP class, keratin being 611 of them. Keratin intensity varies with handling rather than with treatment and it is part of the total each column would be normalised against." }
],
"changes": [
{ "change": "median-normalise the four intensity columns, then recompute the fold changes",
"why": "removes the loading difference that is currently inside every ratio",
"effort": "cheap" },
{ "change": "apply Benjamini-Hochberg across the per-protein tests and report q-values, not p-values",
"why": "6,214 uncorrected tests produce hundreds of false positives at any threshold worth quoting",
"effort": "cheap" },
{ "change": "restrict the quantitative comparison to proteins with a value in at least three of four replicates per condition",
"why": "a fold change computed from one replicate against four is not a fold change",
"effort": "cheap" },
{ "change": "re-run the pipeline with contaminants removed after identification rather than kept in the export",
"why": "keratin and trypsin intensity should not be inside a normalisation total",
"effort": "moderate" },
{ "change": "acquire a second biological set with the replicate kind recorded, and randomise the acquisition order",
"why": "one replicate per condition cannot support a tested fold change at any correction, so no reanalysis of this export fixes it",
"effort": "expensive" }
],
"min_reportable": "With no changes at all: nothing quantitative at the protein level. The export supports a qualitative statement that 6,214 groups were identified across the eight injections and that intensities were recorded for most of them, with the caveats above. After median normalisation, a completeness filter and Benjamini-Hochberg - all three of which run on this same file - a fold-change table with q-values would be reportable for the subset of proteins measured in at least three replicates per condition."
}
The quant lane reads the identifications it was handed and does not re-judge them; it
carries the ident facts in caveats instead. That is deliberate: the two
questions have different answers and a run can be perfectly quantifiable and not identifiable, or
the reverse. What it will not do is give you a fold change. It has the columns, and computing
statistics the caller has not asked for from a sampled table would be the least defensible thing on
this page.
body for task: "methods"
What can actually be written down, and what a repository still wants. This is the lane with the
strongest provenance discipline, and reporting_table[].source is where it lives:
computed means the number was derived here from what you sent (an empirical FDR from a
decoy count, a missed-cleavage share from the sequences), reported means the parameters
stated it and the value is quoted as given, and not_established means the item belongs
in the table and nothing in the input establishes it. That third value is not a gap to be filled in
later — it is the row, and it prints as "not reported" in the drafted table so the omission is
visible rather than invisible.
methods_paragraph is written to be pasted, in the register goal asked for,
and every number in it came from the input. It names the instrument, the acquisition, the software
and versions, the database and its entry count, the enzyme and missed cleavages, the modifications,
the tolerances, the FDR method and level and threshold, the inference rule, and the quantification —
and where one of those is not_established it writes the sentence with the gap visible
rather than filling it in from convention. A methods paragraph that invents "Carbamidomethylation of
cysteine was set as a fixed modification" because that is what everybody does is the single most
damaging thing this lane could produce.
deposition_checklist is what PRIDE, MassIVE or jPOST will ask for, each item with a
status of present, missing or needs_a_number.
The third one is the useful state and it is not a synonym for the second: the raw files exist and
the accession does not, the search parameters exist and the exact database version string does not,
the FDR is stated as 1% and the level it applies to is not. needs_a_number means you
have the thing and it is not quotable yet.
limitations is an array of sentences, each one a limitation a reviewer will raise, in
the order they will raise it — not a paragraph, because a client usually wants to render them as
bullets under a methods draft. open_items is what has to happen before submission, also
as sentences, and the two are kept apart because one is text for the paper and the other is work for
a person.
"body": {
"methods_paragraph": "Peptides were identified with MSFragger 4.0 (FragPipe 21.1) against the UniProt human reference proteome (UP000005640, 20,428 entries) with reversed decoys appended to a single concatenated database. Trypsin was specified with up to two missed cleavages, carbamidomethylation of cysteine as a fixed modification and oxidation of methionine and protein N-terminal acetylation as variable modifications, with a 20 ppm precursor and 0.02 Da fragment tolerance. Peptide-spectrum matches were filtered to 1% false discovery rate at the PSM level using Percolator q-values; the decoys surviving that filter in the exported list correspond to 1.74% by the concatenated-database estimator, which is reported here rather than the nominal threshold. Proteins were assembled with razor peptides and single-peptide identifications were retained, so 1,483 of 6,214 groups rest on one peptide. Label-free quantification used MS1 areas from IonQuant; no normalisation, imputation or multiple-testing correction was applied. The LC gradient, the fragment-tolerance rationale and the rescoring configuration are not established by the available parameters and are omitted rather than assumed.",
"reporting_table": [
{ "item": "Instrument", "value": "Orbitrap Exploris 480", "source": "reported" },
{ "item": "Acquisition", "value": "DDA, top-20", "source": "reported" },
{ "item": "LC gradient", "value": "not reported", "source": "not_established" },
{ "item": "Search engine", "value": "MSFragger 4.0 (FragPipe 21.1)", "source": "reported" },
{ "item": "Database", "value": "UniProt UP000005640, 20,428 entries", "source": "reported" },
{ "item": "Enzyme and missed cleavages", "value": "trypsin, 2", "source": "reported" },
{ "item": "Fixed modifications", "value": "carbamidomethyl (C)", "source": "reported" },
{ "item": "Variable modifications", "value": "oxidation (M), acetyl (protein N-term)", "source": "reported" },
{ "item": "Precursor tolerance", "value": "20 ppm", "source": "reported" },
{ "item": "Observed precursor mass error", "value": "median +5.9 ppm, MAD 1.8 ppm", "source": "computed" },
{ "item": "FDR method, level and threshold", "value": "Percolator q-values, PSM level, 1%", "source": "reported" },
{ "item": "Empirical FDR in the exported list", "value": "1.74% at q <= 0.01 by 2D/(T+D)", "source": "computed" },
{ "item": "PSMs", "value": "41,207 rows exported, 40,453 at q <= 0.01", "source": "computed" },
{ "item": "Distinct peptides", "value": "28,911 stripped, 31,004 modified", "source": "computed" },
{ "item": "Protein groups", "value": "6,214, of which 1,483 single-peptide", "source": "computed" },
{ "item": "Missed cleavages", "value": "31% of peptides carry at least one", "source": "computed" },
{ "item": "Contaminant rows", "value": "1,146 (2.8%), cRAP appended and retained", "source": "computed" },
{ "item": "Quantification", "value": "label-free MS1 area (IonQuant), match-between-runs not stated", "source": "reported" },
{ "item": "Normalisation", "value": "none", "source": "reported" },
{ "item": "Multiple-testing correction", "value": "none", "source": "reported" },
{ "item": "Repository accession", "value": "not reported", "source": "not_established" }
],
"deposition_checklist": [
{ "item": "Raw files for all eight injections", "status": "needs_a_number",
"note": "The parameters describe 4 replicates in 2 conditions but do not say how many raw files that is or name them; a submission needs the file list." },
{ "item": "Repository accession", "status": "missing",
"note": "Stated as not yet deposited. PRIDE issues the accession before the manuscript needs it, so this is the first thing to start." },
{ "item": "Search parameter file", "status": "present",
"note": "The FragPipe workflow file covers engine, database, enzyme, modifications and tolerances." },
{ "item": "Exact database version string", "status": "needs_a_number",
"note": "UP000005640 with 20,428 entries is stated; the release date or the FASTA checksum is what makes it reproducible." },
{ "item": "Decoy strategy statement", "status": "present",
"note": "Reversed decoys, concatenated database - stated, and it is what the empirical rate above was computed under." },
{ "item": "Contaminant FASTA", "status": "present", "note": "cRAP appended, and stated as retained in the export." },
{ "item": "Quantification and normalisation settings", "status": "present",
"note": "Stated, including the normalisation as none, which is the answer a repository wants recorded even though it is the finding above." },
{ "item": "Statistical analysis script or parameters", "status": "missing",
"note": "A per-protein t-test is named with no script, no correction and no software version." }
],
"limitations": [
"The exported list is filtered at a nominal 1% PSM-level FDR, and its own surviving decoys correspond to 1.74% by the concatenated-database estimator; peptide counts should be read against that figure.",
"The false discovery rate was controlled at the PSM level, so the protein-level error rate for the 6,214 groups is not bounded by the stated threshold and is expected to be several times higher.",
"1,483 protein groups rest on a single peptide and are reported as detections without independent support.",
"31% of peptides carry at least one missed cleavage, so identification depth and label-free intensity both reflect an incomplete digest.",
"Intensities were not normalised and missing values were not addressed, so no fold change between the two conditions is reported here.",
"Contaminant proteins were appended and retained in the export, and were not excluded before any quantitative summary."
],
"open_items": [
"Open a PRIDE submission and obtain the accession before the methods paragraph is finalised.",
"Re-filter the export at the stated threshold and re-count, or state the empirical 1.74% in the text.",
"Decide and record whether protein-level FDR will be controlled, or scope every protein claim to the peptide evidence.",
"Record the LC gradient, the fragment-tolerance rationale, the rescoring configuration and the statistical software versions.",
"State whether the four replicates per condition are biological or technical."
]
}
The methods lane is the one people are most tempted to run first, and the one that
punishes it. It will not launder a run: a not_reportable ident pass turns
the nominal threshold in the paragraph into the empirical one, fills limitations with
the reasons, and leaves reporting_table rows at not_established where the
input established nothing — which is the correct output and not a comfortable manuscript. Run it
when ident has stopped objecting.
A worked ident reply, in full
This is the reply to the ident request in step 6 — a HeLa
staurosporine DDA run, 41,207 exported PSMs, a nominal 1% PSM-level FDR whose surviving decoys
imply 1.74%, and 6,214 protein groups of which 1,483 rest on one peptide. It is shown whole,
including the parts a renderer usually hides, because the relationships between the keys are the
contract: five reconciliation entries for five prescan uids, a
verdict of not_reportable justified by exactly one blocking
finding, a row on every finding that came from a row and null on every
finding that did not, and a trust_scope that partitions the claims rather than
summarising them.
{
"lane": "ident",
"title": "HeLa staurosporine DDA - first search, 41,207 PSMs, 6,214 groups",
"verdict": "not_reportable",
"headline": "The decoys surviving the stated 1% filter correspond to 1.74% by the concatenated-database estimator, so this is not a 1% list and the 6,214 protein groups are not a 1% protein list by any route.",
"summary": "Recomputed from the rows at or below the stated threshold, the false-discovery rate is 1.74% - 352 decoys among 40,453 filtered rows under the 2D/(T+D) convention the parameters imply. That alone means the headline counts cannot be reported as stated. Two further facts compound it: the FDR was controlled at the PSM level, which does not bound the protein-level error at all, and single-peptide identifications were retained, so 1,483 of the 6,214 groups have no independent support. The digestion and the mass accuracy are workable - a 31% missed-cleavage share and a +5.9 ppm median offset well inside the 20 ppm window - and neither is why this run is blocked. The next step is to re-filter and re-count, or to state the empirical rate, and then to decide whether protein-level FDR is being controlled at all.",
"findings": [
{
"id": "F-001",
"severity": "blocking",
"area": "fdr",
"title": "The decoys in the filtered list imply 1.74% against a claimed 1%",
"detail": "At q <= 0.01 the export carries 40,101 target rows and 352 decoy rows. The parameters state reversed decoys in one concatenated database, so the estimator is 2D/(T+D) = 704/40,453 = 1.74%. That is three quarters again above the nominal threshold, which is too large to be decoy-counting noise at this depth: 352 decoys is a well-determined count. Either the filter was not applied to this exported file, or the q-values were computed over a different set of spectra than the one exported. This is blocking rather than high because every count in the run - PSMs, peptides, protein groups - is quoted against the 1% figure, and each of those numbers is a claim the export itself refutes.",
"evidence": "2D/(T+D) over 40,101 targets and 352 decoys at q <= 0.01; parameters line 11: \"FDR: 1%, controlled at the PSM level with Percolator q-values\"",
"row": null,
"fix": "Re-filter this export at q <= 0.01 from the q-values it carries, recount, and check the decoys again; if the counts do not change, the q-values came from a different set and the search needs re-exporting. Until then, quote 1.74% rather than 1% wherever a rate is stated."
},
{
"id": "F-002",
"severity": "high",
"area": "fdr",
"title": "Protein-level claims from a PSM-level error rate",
"detail": "The threshold was applied at the PSM level and 6,214 protein groups are being reported from it. PSM-level control says nothing about how many protein groups are wrong: a single false PSM can create a protein group, and on a list of this size the protein-level rate is routinely several times the PSM-level one. This is high rather than blocking on its own terms because the peptide-level list remains meaningful; it is the protein-level claim that is unbounded.",
"evidence": "6,214 protein groups; parameters line 11 states the PSM level and no peptide- or protein-level rate is present in the export",
"row": null,
"fix": "Control the FDR at the protein level as well - Percolator or Philosopher will do it on this same search - and report both rates. If that is not going to happen, scope every protein statement to its peptide evidence and do not present the group count as an FDR-controlled number."
},
{
"id": "F-003",
"severity": "high",
"area": "inference",
"title": "1,483 of 6,214 protein groups rest on a single peptide",
"detail": "23.9% of the groups are one-peptide identifications, and the parameters state a minimum of one peptide per protein, so this is a configured outcome rather than an accident. Combined with F-002 it is the widest part of the gap between the peptide list and the protein list: a one-peptide group at a PSM-level threshold has exactly one piece of evidence behind it, and if that PSM is one of the wrong ones the whole protein is wrong. Median support across groups is 3 peptides, so the bulk of the list is better than this - which is the argument for scoping rather than for discarding.",
"evidence": "1,483 of 6,214 groups have one peptide; parameters line 13: \"Protein inference: razor peptides, single-peptide identifications retained\"",
"row": null,
"fix": "Report the two-or-more-peptide list as the protein list and the one-peptide groups as a separate, explicitly weaker table, or raise the minimum to two peptides and re-count. Either is defensible; presenting 6,214 as one homogeneous list is not."
},
{
"id": "F-004",
"severity": "medium",
"area": "fdr",
"title": "The export still contains rows above the stated threshold",
"detail": "754 of 41,207 rows sit above q = 0.01, 604 of them decoys. That is entirely normal for an unfiltered engine export and it is not itself a problem - but it means any count taken from the row total is not the count at the stated threshold, and it is the most likely explanation for how a 1.74% figure and a 1% claim came to be written in the same place.",
"evidence": "row 9: q_value 0.0402, target; row 7: q_value 0.0193, decoy; 754 of 41,207 rows above q = 0.01",
"row": 9,
"fix": "Filter on the q-value column before counting anything, and state the row count before and after so the two numbers are never confused."
},
{
"id": "F-005",
"severity": "medium",
"area": "digestion",
"title": "Just under a third of peptides carry a missed cleavage",
"detail": "12,714 of 41,013 peptides have an internal K or R the enzyme did not cut, computed with the trypsin rule including the proline exception, against two missed cleavages allowed. A 31% share is an incomplete digest rather than a search-space artefact - two allowances would produce a share in the low tens of per cent on a complete digest. It costs identification depth, and because label-free intensity is spread across the missed-cleavage forms of the same peptide it also biases the quantification the quant lane will look at.",
"evidence": "row 4: VLDELTLARK carries one internal cleavage site; 12,714 of 41,013 peptides, up to 3 missed cleavages observed against 2 allowed",
"row": 4,
"fix": "Longer or warmer digestion, or more trypsin, on the next preparation. For this run, nothing to fix retrospectively - state the share, since it bounds how much of the proteome the depth represents."
},
{
"id": "F-006",
"severity": "medium",
"area": "calibration",
"title": "A systematic +5.9 ppm precursor offset inside a 20 ppm window",
"detail": "The median mass error is +5.9 ppm with a MAD of 1.8 - a tight distribution sitting well off zero, which is a calibration offset and not a scattering of bad matches. No row is outside the stated 20 ppm tolerance, so nothing was lost to it, but a 20 ppm window that is really +2 to +10 ppm is three times wider than the data needs, and that width is search space that decoys also occupy. This is medium because it costs sensitivity and specificity rather than invalidating anything.",
"evidence": "median +5.9 ppm, MAD 1.8, p05 3.1, p95 8.4, min -1.2, max 14.7 over 41,190 rows with a ppm value; tolerance 20 ppm",
"row": null,
"fix": "Recalibrate and re-search with a 10 ppm window, or apply the engine's own mass-error correction. The same spectra will yield a few per cent more identifications at the same FDR, and the decoy competition improves."
},
{
"id": "F-007",
"severity": "medium",
"area": "contamination",
"title": "1,146 contaminant rows, appended and retained",
"detail": "cRAP was appended to the database and the parameters state it was not removed from the export: 611 keratin rows, 248 serum albumin, 187 trypsin, 100 casein. Keeping contaminants in the search is correct - they compete for spectra that would otherwise be mismatched to real proteins - and keeping them in the reported list is not. For identification they are 2.8% of rows that are not part of the biology; for the quant lane they carry intensity that varies with handling.",
"evidence": "row 3: P04264 keratin type II cytoskeletal 1; row 2: P02768 serum albumin; row 10: P00761 trypsin (porcine); 1,146 of 41,207 rows across 4 cRAP classes",
"row": 3,
"fix": "Keep them in the database, drop them from the reported list after identification, and say in the methods that you did. Do not remove them from the FASTA."
},
{
"id": "F-008",
"severity": "info",
"area": "calibration",
"title": "No row falls outside the stated precursor tolerance",
"detail": "Every one of the 41,190 rows carrying a ppm value is inside the 20 ppm window, maximum 14.7 ppm. Worth stating explicitly: the tolerance was honoured, so nothing here suggests the window itself was mis-set or that the search accepted matches it should not have. This is the check F-006 is a finding against - the offset is real and the window was still respected.",
"evidence": "0 of 41,190 rows outside +/-20 ppm; max observed 14.7 ppm",
"row": null,
"fix": "Nothing. Tighten the window only in the context of F-006."
}
],
"reconciliation": [
{ "flag_uid": "P01", "status": "confirmed",
"note": "Confirmed at the same severity, with the consequence the flag does not state: because every headline count in this run is quoted against the nominal 1%, the discrepancy is not one number being wrong but every count being labelled with a rate the list does not meet." },
{ "flag_uid": "P02", "status": "confirmed",
"note": "Confirmed. It compounds P01 rather than duplicating it - P01 says the peptide-level rate is higher than claimed, P02 says the protein-level rate was never bounded at all, and the protein list inherits both." },
{ "flag_uid": "P04", "status": "adjusted",
"note": "Adjusted downward in emphasis, not in severity: 31% is a real incomplete digest and it stays medium, but it is not why this run is blocked and the fix is a wet-lab one that cannot be applied to this export. Reported here so it is not lost, and flagged for the quant lane where it does bias a comparison." },
{ "flag_uid": "P06", "status": "confirmed",
"note": "Confirmed at high. The note the flag leaves implicit: median support is 3 peptides, so the remedy is to split the list rather than to discard it, and that is what trust_scope does below." },
{ "flag_uid": "P08", "status": "not_applicable",
"note": "Not applicable to this lane. Normalisation is a quantitation question and this lane makes no statement about the intensity columns; it is carried into caveats so the quant run inherits it rather than rediscovering it." }
],
"caveats": [
{ "area": "design",
"fact": "The parameters state 4 replicates per condition and the export carries exactly 4 intensity columns, so there is one measurement per replicate and no technical repeat behind any of them.",
"why_it_matters": "A peptide identified in one injection is one observation, so it cannot be called a detection for the condition. This bounds what may_report below can say about presence and absence." },
{ "area": "quantitation",
"fact": "Normalisation is stated as none across four label-free MS1 intensity columns whose medians span 28%.",
"why_it_matters": "Nothing in this lane rests on it, but no count of identifications in this reply should be read as a quantitative statement, and the quant lane inherits it as its first finding rather than as new information." }
],
"context_notes": [
{ "claim": "can I report the 6,214 protein groups as a 1% FDR protein list?",
"status": "contradicted",
"note": "No, on two independent grounds. The list is not at 1% - its own surviving decoys give 1.74% - and it was never protein-level controlled, so even a correctly filtered PSM-level list would not make the group count a 1% protein list. What you can report is a peptide-level list at the empirical rate, and a protein list scoped to groups with two or more peptides and described as PSM-level controlled." },
{ "claim": "we need to submit this week",
"status": "unverifiable",
"note": "Nothing in the table or the parameters speaks to a deadline, so this is taken as context rather than as a fact. It does change the advice: re-filtering and re-counting from this same export is an afternoon, whereas protein-level FDR control and a re-search with a tighter window are not, so the fix in F-001 is the one that fits the constraint." }
],
"unassessable": [
{ "item": "the error rate of transferred identifications",
"why": "Match-between-runs is never mentioned in the parameters and the export carries no transfer flag, so whether any of these rows are transferred rather than searched cannot be established - and a transferred identification is not covered by a search-level FDR." },
{ "item": "peptide-level and protein-level q-values",
"why": "The export carries one q-value column and the parameters state the PSM level, so no peptide- or protein-level rate exists in the input to check F-002 against directly." },
{ "item": "site localisation for the oxidised methionine peptides",
"why": "Modification tokens are present but no localisation probability or site-score column is, so nothing here supports or refutes a site-level claim." },
{ "item": "fragment mass accuracy",
"why": "The parameters state a 0.02 Da fragment tolerance and the export carries no fragment-level columns, so only the precursor side could be checked." }
],
"body": {
"fdr_assessment": "The parameters state reversed decoys appended to a single concatenated database, so the estimator is 2D/(T+D) rather than D/T - the two differ by close to a factor of two here and the choice is not free. Over the whole 41,207-row export: 956 decoys against 40,251 targets, 4.64%. Over the 40,453 rows at or below the stated q <= 0.01: 352 decoys against 40,101 targets, 1.74%. The claimed threshold is 1%, and 352 is a large enough decoy count that this is not sampling noise. The decoy status of every row resolved from the decoy column, so no row was excluded from the arithmetic and 1.74% is not a lower bound. Separately, the level matters as much as the number: control at the PSM level bounds the PSM error rate and nothing else, so the 28,911 distinct peptides and the 6,214 protein groups are both being reported at unstated rates. Nothing in the input establishes a peptide-level or protein-level q-value to put in their place.",
"evidence_review": [
{ "area": "fdr",
"judgement": "fail",
"note": "The claimed threshold does not hold for this list — 1.74% recomputed at q <= 0.01 under 2D/(T+D), against a claimed 1%; 754 rows including 604 decoys sit above the threshold in the file." },
{ "area": "digestion",
"judgement": "concern",
"note": "Incomplete, and it costs depth — 12,714 of 41,013 peptides carry a missed cleavage against 2 allowed; median length 13, 2.1% under 7 residues, longest 41." },
{ "area": "calibration",
"judgement": "concern",
"note": "Offset but never outside the window — median +5.9 ppm, MAD 1.8, no row beyond 14.7 ppm against a 20 ppm tolerance - a recalibration and a 10 ppm window would buy identifications." },
{ "area": "modification",
"judgement": "pass",
"note": "Configured as stated and written into the export — 14,877 rows carry Carbamidomethyl (C) against 14,903 cysteine-containing target peptides, so the fixed modification is present and the 26 exceptions are worth a look rather than a finding; oxidation on 11.2% of methionine peptides is unremarkable." },
{ "area": "inference",
"judgement": "fail",
"note": "Not bounded by anything the parameters state — 6,214 groups, 1,483 on one peptide, median 3, maximum 214; min_peptides = 1 and the FDR is PSM-level." },
{ "area": "contamination",
"judgement": "concern",
"note": "Present, retained, and quantitatively relevant — 1,146 rows over keratin, albumin, trypsin and casein, matched on accession and description together." }
],
"drop_list": [
{ "what": "the rows above the stated q-value threshold",
"why": "The file is an unfiltered export, so every count taken from the row total is a count at max_q 0.191 rather than at 0.01.",
"count": 754 },
{ "what": "the rows matching a cRAP contaminant class",
"why": "Correct to search, wrong to report: keratin, albumin, trypsin and casein are not part of the comparison and they carry intensity.",
"count": 1146 },
{ "what": "the protein groups supported by a single peptide, as a separate table",
"why": "Not to be deleted - to be reported separately, because a PSM-level threshold gives them no independent support and 23.9% of the list is too much to leave unqualified.",
"count": 1483 }
],
"trust_scope": {
"may_report": [
"the peptide-level identifications, described as a PSM-level list at an empirical 1.74% rather than at 1%",
"the protein groups with two or more peptides - 4,731 of them - as identifications, with the error rate described as PSM-level and unbounded at the protein level",
"the presence of a named protein supported by several peptides across both conditions",
"the search settings and the observed mass accuracy, which are what the methods lane will need"
],
"may_not_report": [
"the 6,214 protein groups as a 1% FDR protein list",
"any count taken from the 41,207-row total as a count at 1%",
"a single-peptide group as a detection without saying it is one peptide",
"the identification depth as a property of the sample rather than of an incomplete digest",
"contaminant proteins as findings"
]
}
}
}
Read three things off that reply before anything else. lane is ident, so
the body is the one documented above and the request arrived flat. reconciliation has
five entries for the five uids that went in — including the
not_applicable one, which is a real answer and not an omission — and that is the whole
reason to send prescan_facts. And verdict is not_reportable
with exactly one blocking finding behind it, so the verdict and the findings agree.
Everything else on the page is easier to trust once those three hold.
One detail worth copying into your own tooling: 4,731 in
trust_scope.may_report is 6,214 - 1,483, and it is there because a caller
asked what may be reported and the answer is a number they can use. Every number in a reply is
either quoted from the input, quoted from prescan_facts, or arithmetic over those two
that the reply shows. If you find one that is none of the three, that is the bug this contract
exists to make visible.
Check the reply before you trust it
The browser does not render a reply verbatim and neither should a caller. Seven assertions cover everything this app can get wrong in a way that still looks plausible, and all seven are cheap:
- The lane is the one you asked for. Compare
laneagainst thetaskyou sent. A mismatch meanstaskdid not arrive — which is what a wrappedinputkey looks like from the outside, and it is the only symptom that failure has. - Reconciliation covers the prescan exactly. Every
uidyou sent appears once inreconciliation; nouidyou did not send appears at all. This catches a fluent review that dropped your blocking fact. - The verdict matches the worst severity.
not_reportableneeds ablockingfinding,reviseahighone, andreportableneeds nothing aboveinfo. A verdict its own findings contradict is a broken reply, not a judgement call. - The body keys belong to that lane. Three shapes, never blended. A body carrying both
trust_scopeandmin_reportableis malformed even though it parses. - The enums are in range.
verdict,severity,area,reconciliation.status,context_notes.statusand — inside the lane bodies —effort,sourceand the checkliststatusare closed sets; an unrecognised value renders as an error rather than being coerced to something plausible. - Every
rowis a row you sent.rowisnullor an integer in[1, rows_sent]. A row index past the end of the sample is a fabricated location, and it is the one defect a reader will act on immediately by opening the export and looking at the wrong line. truncatedis false. A truncated reply is a prefix, not a review. Retry; do not repair. See below.
One more, and it is the cheapest of all: context_notes is non-empty exactly when you
sent question. The assertion code for all of these is in step 4,
in all eight languages.
0. A tiny client
One helper that adds the two headers, unwraps data and raises on ok: false.
Two headers is the whole story: Content-Type and Authorization. If you
find yourself reaching for X-App-Slug, the token already carries the app. Every later
step on this page uses this helper, and if you would rather not deal with tokens in code at all, the
token page prints the one this browser already holds, with a copy button
and a ready-made shell export — no DevTools, no console.
# Every call is the same three things: the base URL, your bearer token, and a
# JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN" # from https://psm-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
# Unwrap the envelope: print data, or exit non-zero with the API error code.
data() {
python3 -c '
import sys, json
env = json.load(sys.stdin)
if not env.get("ok"):
err = env.get("error") or {}
raise SystemExit("%s: %s" % (err.get("code"), err.get("message")))
json.dump(env["data"], sys.stdout)
'
}
call me | data
# {"subject_type": "user", "subject_id": "usr_...", "credits": 51234}
#
# No X-App-Slug header. The slug was only ever needed by POST /guest.
# The three lanes, for reference: ident quant methods
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
LANES = ("ident", "quant", "methods")
class ApiError(RuntimeError):
def __init__(self, code, message, details=None):
super().__init__(f"{code}: {message}")
self.code, self.message, self.details = code, message, details or {}
def call(path, body=None, headers=None):
"""Returns the unwrapped `data`, or raises ApiError with the API error code.
Two headers only: Content-Type and Authorization. There is no X-App-Slug -
the token is already bound to psm-desk.
"""
payload = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}/{path}", data=payload, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if payload is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as res:
env = json.load(res)
except urllib.error.HTTPError as exc: # 4xx and 5xx carry the envelope too
env = json.loads(exc.read() or b"{}")
if not env.get("ok"):
err = env.get("error") or {}
raise ApiError(err.get("code", "INTERNAL"), err.get("message", "no message"),
err.get("details"))
return env["data"]
print(call("me"))
# {'subject_type': 'user', 'subject_id': 'usr_...', 'credits': 51234}
// Node 18+ or any browser. Paste a token from
// https://psm-desk.skillsafe.ai/tokens.html, or mint a guest one in step 1.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
const LANES = ["ident", "quant", "methods"];
class ApiError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details ?? {};
}
}
// call("me") -> GET; call("estimate", input) -> POST with the input object as
// the whole body. Extra headers are for Idempotency-Key on a run.
async function call(path, body, extraHeaders = {}) {
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const env = await res.json();
if (!env.ok) throw new ApiError(env.error.code, env.error.message, env.error.details);
return env.data;
}
console.log(await call("me"));
// { subject_type: 'user', subject_id: 'usr_...', credits: 51234 }
// Two headers, and no X-App-Slug: the token already carries the app.
package main
// Imports used across every Go sample on this page:
// bufio, bytes, crypto/sha256, encoding/json, fmt, io, net/http, os, strings, time
const base = "https://api.skillsafe.ai/v1/app-api"
// From https://psm-desk.skillsafe.ai/tokens.html, or minted in step 1.
var token = func() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}()
var lanes = []string{"ident", "quant", "methods"}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *apiError) Error() string { return e.Code + ": " + e.Message }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Meta json.RawMessage `json:"meta"`
Error *apiError `json:"error"`
}
// call returns the raw `data` for the caller to unmarshal into its own struct.
// Two headers only - there is no X-App-Slug in this API.
func call(path string, body any, extra map[string]string) (json.RawMessage, error) {
method := http.MethodGet
var reader io.Reader
if body != nil {
method = http.MethodPost
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, base+"/"+path, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
if env.Error != nil {
return nil, env.Error
}
return nil, fmt.Errorf("INTERNAL: no error body on HTTP %d", res.StatusCode)
}
return env.Data, nil
}
// java.net.http, single file. Imports: java.net.URI, java.net.http.*,
// java.security.MessageDigest, java.util.Map.
public final class PsmDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String[] LANES = { "ident", "quant", "methods" };
// From https://psm-desk.skillsafe.ai/tokens.html, or minted in step 1.
static String token = System.getenv("SKILLSAFE_TOKEN") == null
? "YOUR_TOKEN" : System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static class ApiError extends RuntimeException {
final String code;
ApiError(String code, String message) { super(code + ": " + message); this.code = code; }
}
/** GET when body is null, POST otherwise. Returns the raw response text.
* Two headers: Content-Type and Authorization. There is no X-App-Slug. */
static String call(String path, String jsonBody, Map<String, String> extra) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + token);
if (jsonBody == null) {
b = b.GET();
} else {
b = b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
for (var e : extra.entrySet()) b = b.header(e.getKey(), e.getValue());
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String text = res.body();
// These samples keep the envelope as text and use a real JSON library in
// production (Jackson, Gson). The only thing to get right is the check:
// an envelope with "ok":false carries error.code and never data.
if (text.contains("\"ok\":false")) throw new ApiError("API_ERROR", text);
return text;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
# From https://psm-desk.skillsafe.ai/tokens.html, or minted in step 1.
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
LANES = %w[ident quant methods].freeze
class ApiError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code = code
@details = details
end
end
# call("me") -> GET; call("estimate", input) -> POST with the input object as
# the whole body. No X-App-Slug: the token already carries the app.
def call(path, body = nil, extra = {})
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
extra.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
unless env["ok"]
err = env["error"] || {}
raise ApiError.new(err["code"], err["message"], err["details"] || {})
end
env["data"]
end
p call("me")
# {"subject_type"=>"user", "subject_id"=>"usr_...", "credits"=>51234}
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
// From https://psm-desk.skillsafe.ai/tokens.html, or minted in step 1.
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN");
const LANES = ["ident", "quant", "methods"];
class ApiError extends RuntimeException {
public string $code;
public array $details;
public function __construct(string $code, string $message, array $details = []) {
parent::__construct("$code: $message");
$this->code = $code;
$this->details = $details;
}
}
// call("me") is a GET; call("estimate", $input) POSTs $input as the whole body.
// Two headers only - there is no X-App-Slug in this API.
function call(string $path, ?array $body = null, array $extra = []): array {
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) $headers[] = "Content-Type: application/json";
foreach ($extra as $k => $v) $headers[] = "$k: $v";
$ch = curl_init(BASE . "/" . $path);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
curl_close($ch);
$env = json_decode($raw, true) ?: [];
if (empty($env["ok"])) {
$err = $env["error"] ?? [];
throw new ApiError($err["code"] ?? "INTERNAL", $err["message"] ?? "no message",
$err["details"] ?? []);
}
return $env["data"];
}
print_r(call("me"));
// Array ( [subject_type] => user [subject_id] => usr_... [credits] => 51234 )
// .NET 8. Usings: System.Net.Http.Json, System.Security.Cryptography,
// System.Text, System.Text.Json.
static class PsmDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
// From https://psm-desk.skillsafe.ai/tokens.html, or minted in step 1.
public static string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
public static readonly string[] Lanes = { "ident", "quant", "methods" };
static readonly HttpClient Http = new();
public class ApiError : Exception
{
public string Code { get; }
public ApiError(string code, string message) : base($"{code}: {message}") => Code = code;
}
/// GET when body is null, POST otherwise. Returns the unwrapped `data`.
/// Two headers: Content-Type and Authorization. There is no X-App-Slug.
public static async Task<JsonElement> Call(string path, object? body = null,
Dictionary<string, string>? extra = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
foreach (var kv in extra ?? new()) req.Headers.Add(kv.Key, kv.Value);
var res = await Http.SendAsync(req);
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!env.GetProperty("ok").GetBoolean())
{
var err = env.GetProperty("error");
throw new ApiError(err.GetProperty("code").GetString() ?? "INTERNAL",
err.GetProperty("message").GetString() ?? "no message");
}
return env.GetProperty("data");
}
}
var me = await PsmDesk.Call("me");
Console.WriteLine(me.GetProperty("credits").GetInt32());
1. Get a token
For a human, the shortest path is the token page — that is the link to follow if you would rather not touch DevTools. It shows the token this browser already holds, with a copy button and a ready-made shell export, and a sign-in button for a personal token. Nothing on it needs a developer tool: it reads the same storage the app itself uses and prints the token for you.
For a program, POST /guest mints one. The body is {"slug": "psm-desk"} —
this is the one and only place the slug appears in this API — and the call answers
201 Created:
HTTP/1.1 201 Created
{ "ok": true, "data": {
"token": "sk_guest_...",
"guest_id": "gst_...",
"expires_at": "2026-08-27T09:14:02Z"
} }
Three things follow from that shape. expires_at is real, so a long-lived worker
re-mints rather than caching forever; a 401 on a previously good token usually means it lapsed.
guest_id is worth keeping — it is what lets a later sign-in migrate the guest wallet,
and it is the only handle you have on an anonymous session. And a guest token is enough for
/me and /estimate but not for a metered run: a review is
metered, so /run and /run-stream want a personal token from signing in. A
guest attempting a run gets 403 FORBIDDEN, not a 402.
Watch the status code rather than the body when you wire this up. POST /guest is the
only call on this API that answers 201, and a client that tests status === 200 before
parsing will decide the mint failed while holding a perfectly good token. Treat any 2xx as success
and read ok.
# The slug goes in the BODY, not in a header. This is the only call that needs it.
TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"psm-desk"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["token"])')
echo "${TOKEN:0:12}..." # sk_guest_...
# Keep the whole object if you want guest_id and expires_at:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"psm-desk"}' | python3 -m json.tool
# {
# "ok": true,
# "data": { "token": "sk_guest_...", "guest_id": "gst_...",
# "expires_at": "2026-08-27T09:14:02Z" }
# }
#
# 201 Created, not 200. A guest token can /me and /estimate; a metered /run
# needs a personal token from https://psm-desk.skillsafe.ai/tokens.html.
import datetime, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "psm-desk" # the ONLY place the slug appears in this API
def mint_guest():
"""POST /guest -> 201 with {token, guest_id, expires_at}. No auth header."""
req = urllib.request.Request(
f"{BASE}/guest", data=json.dumps({"slug": SLUG}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as res:
assert res.status // 100 == 2, f"expected 2xx, got {res.status}"
env = json.load(res)
if not env.get("ok"):
err = env.get("error") or {}
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return env["data"]
tok = mint_guest()
print(tok["token"][:12], tok["guest_id"], tok["expires_at"])
# sk_guest_ gst_... 2026-08-27T09:14:02Z
# expires_at is real. A worker that runs for hours re-mints rather than caching:
expiry = datetime.datetime.fromisoformat(tok["expires_at"].replace("Z", "+00:00"))
if expiry - datetime.datetime.now(datetime.timezone.utc) < datetime.timedelta(minutes=5):
tok = mint_guest()
TOKEN = tok["token"] # feed this to call() from step 0
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "psm-desk"; // the ONLY place the slug appears
// POST /guest answers 201 Created. No Authorization header on this one call.
async function mintGuest() {
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }),
});
// Test for 2xx, not for 200: this endpoint returns 201 and a strict
// `res.status === 200` check reads a successful mint as a failure.
if (!res.ok) throw new Error(`guest mint: HTTP ${res.status}`);
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
return env.data; // { token, guest_id, expires_at }
}
const guest = await mintGuest();
console.log(guest.guest_id, guest.expires_at);
// Re-mint when it is close to lapsing rather than caching forever.
function expiring(g, marginMs = 5 * 60 * 1000) {
return Date.parse(g.expires_at) - Date.now() < marginMs;
}
let TOKEN = guest.token;
if (expiring(guest)) TOKEN = (await mintGuest()).token;
// POST /guest is the one call with no Authorization header and the one call
// that answers 201. The slug goes in the body.
type guestToken struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
}
func mintGuest() (*guestToken, error) {
body, _ := json.Marshal(map[string]string{"slug": "psm-desk"})
req, err := http.NewRequest(http.MethodPost, base+"/guest", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("guest mint: HTTP %d", res.StatusCode)
}
var env struct {
OK bool `json:"ok"`
Data guestToken `json:"data"`
Error *apiError `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, env.Error
}
return &env.Data, nil
}
func mustToken() string {
g, err := mintGuest()
if err != nil {
panic(err)
}
// expires_at is real; a long-lived worker re-mints rather than caching.
if t, err := time.Parse(time.RFC3339, g.ExpiresAt); err == nil {
if time.Until(t) < 5*time.Minute {
if g2, err := mintGuest(); err == nil {
g = g2
}
}
}
return g.Token
}
// POST /guest: no Authorization header, slug in the body, 201 on success.
static String mintGuest() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"psm-desk\"}"))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// 201 Created, not 200. Accept any 2xx.
if (res.statusCode() / 100 != 2)
throw new ApiError("HTTP_" + res.statusCode(), res.body());
String body = res.body();
// Replace with Jackson in production; this keeps the sample dependency-free.
int i = body.indexOf("\"token\":\"") + 9;
token = body.substring(i, body.indexOf('"', i));
return token;
}
public static void main(String[] args) throws Exception {
if (System.getenv("SKILLSAFE_TOKEN") == null) mintGuest();
System.out.println(call("me", null, Map.of()));
// {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":1200}}
//
// A guest can /me and /estimate. A metered /run needs a personal token from
// https://psm-desk.skillsafe.ai/tokens.html
}
require "time"
# POST /guest: no Authorization header, the slug in the body, 201 on success.
def mint_guest
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "psm-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
# 201 Created, not 200 - check the class, not the exact code.
raise "guest mint: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
env = JSON.parse(res.body)
raise ApiError.new(env.dig("error", "code"), env.dig("error", "message")) unless env["ok"]
env["data"] # { "token" => ..., "guest_id" => ..., "expires_at" => ... }
end
guest = mint_guest
puts "#{guest['guest_id']} expires #{guest['expires_at']}"
# Re-mint near expiry rather than caching a token for hours.
guest = mint_guest if Time.parse(guest["expires_at"]) - Time.now < 300
TOKEN_FROM_GUEST = guest["token"]
<?php
// POST /guest: no Authorization header, the slug in the body, 201 on success.
function mint_guest(): array {
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "psm-desk"]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// 201 Created, not 200. Accept any 2xx.
if (intdiv($status, 100) !== 2) throw new RuntimeException("guest mint: HTTP $status");
$env = json_decode($raw, true) ?: [];
if (empty($env["ok"])) {
$err = $env["error"] ?? [];
throw new ApiError($err["code"] ?? "INTERNAL", $err["message"] ?? "no message");
}
return $env["data"]; // ["token" => ..., "guest_id" => ..., "expires_at" => ...]
}
$guest = mint_guest();
printf("%s expires %s\n", $guest["guest_id"], $guest["expires_at"]);
// Re-mint near expiry.
if (strtotime($guest["expires_at"]) - time() < 300) $guest = mint_guest();
// POST /guest: no Authorization header, slug in the body, 201 on success.
static async Task<JsonElement> MintGuest()
{
var res = await new HttpClient().PostAsync(
"https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\":\"psm-desk\"}", Encoding.UTF8, "application/json"));
// 201 Created, not 200 - IsSuccessStatusCode covers both.
if (!res.IsSuccessStatusCode)
throw new Exception($"guest mint: HTTP {(int)res.StatusCode}");
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!env.GetProperty("ok").GetBoolean())
throw new PsmDesk.ApiError(
env.GetProperty("error").GetProperty("code").GetString() ?? "INTERNAL",
env.GetProperty("error").GetProperty("message").GetString() ?? "");
return env.GetProperty("data");
}
var guest = await MintGuest();
PsmDesk.Token = guest.GetProperty("token").GetString()!;
Console.WriteLine(guest.GetProperty("expires_at").GetString());
// Re-mint near expiry rather than caching for hours.
if (DateTimeOffset.Parse(guest.GetProperty("expires_at").GetString()!) - DateTimeOffset.UtcNow
< TimeSpan.FromMinutes(5))
PsmDesk.Token = (await MintGuest()).GetProperty("token").GetString()!;
2. Check the session and the balance
GET /me returns three fields and nothing else:
{ "ok": true, "data": {
"subject_type": "guest",
"subject_id": "gst_...",
"credits": 1200
} }
Read that literally, because the fields people expect are not there. There is no
user_id — the identifier is subject_id whichever kind of subject
it is, so a guest's subject_id is its guest_id and a signed-in person's is
their user id. There is no is_guest flag, so a truthiness test on it is
silently false for everybody, which reads as "this is a real user" for a guest token. Branch on
subject_type, which is guest or user. There is no
username, no email and no plan field either; if you need a display name,
you need your own.
credits is the wallet balance. Compare it against min_credits from step 3
before you run, so a shortfall becomes your own clear message instead of a 402 in the middle of a
batch of forty exports.
call me | data | python3 -m json.tool
# {
# "subject_type": "user",
# "subject_id": "usr_...",
# "credits": 51234
# }
# Branch on subject_type. There is no is_guest field and no user_id field.
SUBJ=$(call me | data | python3 -c 'import sys,json; print(json.load(sys.stdin)["subject_type"])')
if [ "$SUBJ" = "guest" ]; then
echo "guest token: /me and /estimate are fine, a metered /run will 403"
fi
# Balance, as a plain integer, for a shell gate before a batch:
CREDITS=$(call me | data | python3 -c 'import sys,json; print(json.load(sys.stdin)["credits"])')
echo "balance $CREDITS credits"
me = call("me")
print(me["subject_type"], me["subject_id"], me["credits"])
# user usr_... 51234
# Branch on subject_type. There is no is_guest and no user_id:
# me.get("is_guest") -> always None, which reads as "a real user" for a guest
# me["user_id"] -> KeyError
if me["subject_type"] == "guest":
print("guest: /estimate is fine, a metered /run will 403 - sign in for a run")
def assert_can_afford(min_credits):
"""Turn a 402 in the middle of a batch into one clear message up front."""
credits = call("me")["credits"]
if credits < min_credits:
raise SystemExit(
f"balance {credits} is below min_credits {min_credits}: "
"top up at https://psm-desk.skillsafe.ai/tokens.html")
return credits
const me = await call("me");
console.log(me.subject_type, me.subject_id, me.credits);
// user usr_... 51234
// Branch on subject_type. Both of these are bugs:
// if (me.is_guest) { ... } // the field does not exist -> always falsy
// me.user_id // undefined; the field is subject_id
if (me.subject_type === "guest") {
console.warn("guest token: /estimate is fine, a metered /run will 403");
}
async function assertCanAfford(minCredits) {
const { credits } = await call("me");
if (credits < minCredits) {
throw new Error(
`balance ${credits} is below min_credits ${minCredits}: top up at ` +
"https://psm-desk.skillsafe.ai/tokens.html");
}
return credits;
}
type me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
// Three fields, and only three. No UserID, no IsGuest - a struct that declares
// them unmarshals them as zero values and a guest reads as a signed-in user.
func whoAmI() (*me, error) {
raw, err := call("me", nil, nil)
if err != nil {
return nil, err
}
var m me
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return &m, nil
}
func main() {
m, err := whoAmI()
if err != nil {
panic(err)
}
fmt.Println(m.SubjectType, m.SubjectID, m.Credits)
if m.SubjectType == "guest" {
fmt.Println("guest token: /estimate is fine, a metered /run will 403")
}
}
String me = call("me", null, Map.of());
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
// Branch on subject_type. There is no is_guest field to test and no user_id.
boolean isGuest = me.contains("\"subject_type\":\"guest\"");
if (isGuest)
System.out.println("guest token: /estimate is fine, a metered /run will 403");
// The balance, for a gate before a batch. Use Jackson in production; this is
// the dependency-free version.
static long credits(String meJson) {
int i = meJson.indexOf("\"credits\":") + 10;
int j = i;
while (j < meJson.length() && (Character.isDigit(meJson.charAt(j)) || meJson.charAt(j) == '-')) j++;
return Long.parseLong(meJson.substring(i, j));
}
me = call("me")
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']}"
# user usr_... 51234
# Branch on subject_type. me["is_guest"] is nil for everybody, which is exactly
# the wrong answer for a guest token, and me["user_id"] does not exist.
if me["subject_type"] == "guest"
warn "guest token: /estimate is fine, a metered /run will 403"
end
def assert_can_afford(min_credits)
credits = call("me")["credits"]
if credits < min_credits
abort "balance #{credits} is below min_credits #{min_credits}: " \
"top up at https://psm-desk.skillsafe.ai/tokens.html"
end
credits
end
<?php
$me = call("me");
printf("%s %s %d\n", $me["subject_type"], $me["subject_id"], $me["credits"]);
// user usr_... 51234
// Branch on subject_type. $me["is_guest"] is not set - and an isset() test on it
// is false for a guest too, so it reads as "a signed-in user".
if ($me["subject_type"] === "guest") {
fwrite(STDERR, "guest token: /estimate is fine, a metered /run will 403\n");
}
function assert_can_afford(int $minCredits): int {
$credits = call("me")["credits"];
if ($credits < $minCredits) {
throw new RuntimeException(
"balance $credits is below min_credits $minCredits: top up at " .
"https://psm-desk.skillsafe.ai/tokens.html");
}
return $credits;
}
var me = await PsmDesk.Call("me");
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} " +
$"{me.GetProperty("subject_id").GetString()} " +
$"{me.GetProperty("credits").GetInt64()}");
// user usr_... 51234
// Branch on subject_type. There is no is_guest property and no user_id:
// TryGetProperty("is_guest", out _) is false for a guest as well as for a user.
if (me.GetProperty("subject_type").GetString() == "guest")
Console.Error.WriteLine("guest token: /estimate is fine, a metered /run will 403");
static async Task<long> AssertCanAfford(long minCredits)
{
var credits = (await PsmDesk.Call("me")).GetProperty("credits").GetInt64();
if (credits < minCredits)
throw new InvalidOperationException(
$"balance {credits} is below min_credits {minCredits}: top up at " +
"https://psm-desk.skillsafe.ai/tokens.html");
return credits;
}
3. Price the run — free, but authenticated
POST /estimate creates no job and charges nothing. It is still an
authenticated call, which is the ordering trap: it has to come after step 1. A caller who
builds an input, prices it and only then goes looking for a token gets a 401
UNAUTHORIZED on the free call and reads it as a broken endpoint. Mint the token first,
price second.
The body is the input object from above, flat. What comes back:
| field | type | meaning |
|---|---|---|
model | string | The exact model the run will bind to. Read it from the reply and log it; do not hard-code it, because the concrete model moves. |
model_alias | string | The stable alias that binding came from — the thing to log, and the thing to assert on if you want to be certain you are talking to this app and not to a different one behind the same base URL. |
markup_bps | integer | The app's markup in basis points; 1000 is ten per cent. |
hold_credits | integer, varies by lane | What gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price. |
min_credits | integer, varies by lane | The balance you must clear for the run to start at all. Compare this against credits from /me. |
sponsor_enabled | boolean | Whether the app is covering this run rather than your wallet. |
hold_credits is a reservation and not the price. The
charged_credits you see on the settled job is normally far lower, because an
ident pass over a clean 400-row export says so in a few hundred tokens while the cap
has to allow for a methods lane with a twenty-row reporting table, an eight-item
deposition checklist and a drafted paragraph. Budget against hold_credits; report
against charged_credits.
The hold differs per lane, and it differs a lot here. methods is the
wordiest — it drafts prose and two tables. ident is the leanest on a clean run and the
fattest on a broken one, since every failing check earns a finding with its own evidence string.
quant sits between them and is the one most sensitive to the number of measurement
columns. If you are running all three lanes over one export, price all three rather than multiplying
the ident figure by three.
And psms moves the estimate more than anything else in the input: 150
rows of sixteen columns is a lot of text, every row is read, and a 40,000-row export sampled to 150
rows still prices several times a ten-row example. Price the real input, not a stub — a stub is the
one way to be surprised by the hold on the run that matters.
# The two artefacts as shell variables, so the JSON stays readable. Use quoted
# heredocs ('EOF') so nothing inside gets expanded by the shell.
PARAMS=$(cat <<'EOF'
Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)
Instrument: Orbitrap Exploris 480
Acquisition: DDA, top-20, 90 min gradient
Search engine: MSFragger 4.0 (FragPipe 21.1)
Database: UniProt human reference UP000005640, 20428 entries, reversed decoys appended
Enzyme: trypsin, up to 2 missed cleavages
Fixed modifications: carbamidomethyl (C)
Variable modifications: oxidation (M), acetyl (protein N-term)
Precursor tolerance: 20 ppm
Fragment tolerance: 0.02 Da
FDR: 1%, controlled at the PSM level with Percolator q-values
Decoy strategy: reversed, concatenated
Protein inference: razor peptides, single-peptide identifications retained
Quantification: label-free, MS1 area (IonQuant)
Normalisation: none
Missing values: left as-is
Replicates: 4 per condition
Statistical test: two-sample t-test per protein
Multiple testing: none
Contaminants: cRAP appended, not removed from the export
Deposition: not yet deposited
Software versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10
EOF
)
# In real use this is your engine's export, read straight off disk:
# PSMS=$(cat psm.csv)
PSMS=$(cat <<'EOF'
peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2
AAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8
LVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7
SGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6
VLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,
LKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6
MDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,
SLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,
ELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7
CDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,
TGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5
EOF
)
# Build the body with python3 so the newlines and commas are escaped correctly.
# NOTE the shape: the input object IS the body. No "input" wrapper.
INPUT=$(PARAMS="$PARAMS" PSMS="$PSMS" python3 -c '
import json, os
print(json.dumps({
"task": "ident",
"psms": os.environ["PSMS"],
"params": os.environ["PARAMS"],
"goal": "publication",
"stage": "first_search",
"question": "can I report the 6,214 protein groups as a 1% FDR protein list?",
}))')
call estimate "$INPUT" | data
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":2480,"min_credits":300,"sponsor_enabled":false}
#
# Free, but authenticated: without the Authorization header this is a 401, not
# an anonymous price check. And it differs per lane - price the lane you will run.
for LANE in ident quant methods; do
BODY=$(INPUT="$INPUT" LANE="$LANE" python3 -c '
import json, os
b = json.loads(os.environ["INPUT"]); b["task"] = os.environ["LANE"]
print(json.dumps(b))')
printf '%-8s %s\n' "$LANE" "$(call estimate "$BODY" | data)"
done
# In real use the two artefacts are files. The example keeps them inline and
# short; a real psms is your engine's export, sampled to about 150 rows.
PARAMS = """Instrument: Orbitrap Exploris 480
Search engine: MSFragger 4.0 (FragPipe 21.1)
Database: UniProt human reference UP000005640, 20428 entries, reversed decoys appended
Enzyme: trypsin, up to 2 missed cleavages
Fixed modifications: carbamidomethyl (C)
Variable modifications: oxidation (M), acetyl (protein N-term)
Precursor tolerance: 20 ppm
FDR: 1%, controlled at the PSM level with Percolator q-values
Decoy strategy: reversed, concatenated
Protein inference: razor peptides, single-peptide identifications retained
Quantification: label-free, MS1 area (IonQuant)
Normalisation: none
Multiple testing: none
Contaminants: cRAP appended, not removed from the export
"""
PSMS = """peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy
AAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target
LVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target
VLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target
SLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy
CDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target
"""
# Or, much more likely:
# PSMS = pathlib.Path("psm.csv").read_text()
# PARAMS = pathlib.Path("search_params.txt").read_text()
INPUT = {
"task": "ident",
"psms": PSMS,
"params": PARAMS,
"goal": "publication",
"stage": "first_search",
"question": "can I report the 6,214 protein groups as a 1% FDR protein list?",
}
# The body IS this dict. Never {"input": INPUT}.
est = call("estimate", INPUT)
print(est)
# {'model': 'gpt-5.6-terra', 'model_alias': 'gpt-terra', 'markup_bps': 1000,
# 'hold_credits': 2480, 'min_credits': 300, 'sponsor_enabled': False}
# Price every lane you intend to run; the hold is not the same for all three.
for lane in LANES:
e = call("estimate", dict(INPUT, task=lane))
print(f"{lane:<8} hold {e['hold_credits']:>6} min {e['min_credits']:>5} "
f"model {e['model_alias']}")
# Gate on min_credits before the batch rather than on a 402 inside it.
worst = max(call("estimate", dict(INPUT, task=lane))["min_credits"] for lane in LANES)
assert_can_afford(worst)
import { readFileSync } from "node:fs";
// The two artefacts, off disk. In a browser these are the textarea values.
const PSMS = readFileSync("psm.csv", "utf8");
const PARAMS = readFileSync("search_params.txt", "utf8");
const INPUT = {
task: "ident",
psms: PSMS,
params: PARAMS,
goal: "publication",
stage: "first_search",
question: "can I report the 6,214 protein groups as a 1% FDR protein list?",
};
// The body IS this object. Never { input: INPUT }.
const est = await call("estimate", INPUT);
console.log(est);
// { model: 'gpt-5.6-terra', model_alias: 'gpt-terra', markup_bps: 1000,
// hold_credits: 2480, min_credits: 300, sponsor_enabled: false }
// /estimate is free but authenticated: called before the token lands it is a
// 401 UNAUTHORIZED, which reads like a broken endpoint and is not one.
for (const lane of LANES) {
const e = await call("estimate", { ...INPUT, task: lane });
console.log(lane.padEnd(8), "hold", e.hold_credits, "min", e.min_credits);
}
// hold_credits is a reservation. Budget against it; report charged_credits.
const worst = Math.max(
...(await Promise.all(
LANES.map(async (lane) => (await call("estimate", { ...INPUT, task: lane })).min_credits)
))
);
await assertCanAfford(worst);
// The flat input object. Struct tags matter: these are the field names the app
// reads, and there is no wrapper struct around them.
type runInput struct {
Task string `json:"task"`
Psms string `json:"psms"`
Params string `json:"params,omitempty"`
Goal string `json:"goal,omitempty"`
Stage string `json:"stage,omitempty"`
Question string `json:"question,omitempty"`
Prescan any `json:"prescan_facts,omitempty"`
}
type estimate struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
func priceIt(in runInput) (*estimate, error) {
raw, err := call("estimate", in, nil) // free, but the token is required
if err != nil {
return nil, err
}
var e estimate
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
return &e, nil
}
func main() {
psms, err := os.ReadFile("psm.csv")
if err != nil {
panic(err)
}
params, _ := os.ReadFile("search_params.txt")
in := runInput{
Task: "ident",
Psms: string(psms),
Params: string(params),
Goal: "publication",
Stage: "first_search",
Question: "can I report the 6,214 protein groups as a 1% FDR protein list?",
}
for _, lane := range lanes {
in.Task = lane
e, err := priceIt(in)
if err != nil {
panic(err)
}
fmt.Printf("%-8s hold %6d min %5d %s\n",
lane, e.HoldCredits, e.MinCredits, e.ModelAlias)
}
}
// The flat input object as JSON text. Build it with Jackson in production; the
// point of this sample is the shape, which has no "input" wrapper.
static String buildInput(String task, String psms, String params, String question) {
return "{"
+ "\"task\":" + quote(task) + ","
+ "\"psms\":" + quote(psms) + ","
+ "\"params\":" + quote(params) + ","
+ "\"goal\":\"publication\","
+ "\"stage\":\"first_search\","
+ "\"question\":" + quote(question)
+ "}";
}
/** Minimal JSON string quoting - a real client uses a library. */
static String quote(String s) {
StringBuilder b = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> b.append("\\\"");
case '\\' -> b.append("\\\\");
case '\n' -> b.append("\\n");
case '\r' -> b.append("\\r");
case '\t' -> b.append("\\t");
default -> b.append(c < 0x20 ? String.format("\\u%04x", (int) c) : c);
}
}
return b.append('"').toString();
}
public static void main(String[] args) throws Exception {
if (System.getenv("SKILLSAFE_TOKEN") == null) mintGuest();
String psms = Files.readString(Path.of("psm.csv"));
String params = Files.readString(Path.of("search_params.txt"));
String question = "can I report the 6,214 protein groups as a 1% FDR protein list?";
// Free, but authenticated - this line before mintGuest() above is a 401.
for (String lane : LANES) {
String est = call("estimate", buildInput(lane, psms, params, question), Map.of());
System.out.printf("%-8s %s%n", lane, est);
}
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":2480,"min_credits":300,"sponsor_enabled":false}}
}
psms = File.read("psm.csv")
params = File.read("search_params.txt")
INPUT = {
task: "ident",
psms: psms,
params: params,
goal: "publication",
stage: "first_search",
question: "can I report the 6,214 protein groups as a 1% FDR protein list?"
}.freeze
# The body IS this hash. Never { input: INPUT }.
est = call("estimate", INPUT) # free, and still authenticated
pp est
# {"model"=>"gpt-5.6-terra", "model_alias"=>"gpt-terra", "markup_bps"=>1000,
# "hold_credits"=>2480, "min_credits"=>300, "sponsor_enabled"=>false}
# The hold is per lane. Price each one you mean to run.
LANES.each do |lane|
e = call("estimate", INPUT.merge(task: lane))
puts format("%-8s hold %6d min %5d %s",
lane, e["hold_credits"], e["min_credits"], e["model_alias"])
end
worst = LANES.map { |lane| call("estimate", INPUT.merge(task: lane))["min_credits"] }.max
assert_can_afford(worst)
<?php
$psms = file_get_contents("psm.csv");
$params = file_get_contents("search_params.txt");
$input = [
"task" => "ident",
"psms" => $psms,
"params" => $params,
"goal" => "publication",
"stage" => "first_search",
"question" => "can I report the 6,214 protein groups as a 1% FDR protein list?",
];
// The body IS this array. Never ["input" => $input].
$est = call("estimate", $input); // free, and still authenticated
print_r($est);
// Array ( [model] => gpt-5.6-terra [model_alias] => gpt-terra [markup_bps] => 1000
// [hold_credits] => 2480 [min_credits] => 300 [sponsor_enabled] => )
// The hold differs per lane; price each lane you intend to run.
$worst = 0;
foreach (LANES as $lane) {
$e = call("estimate", array_merge($input, ["task" => $lane]));
printf("%-8s hold %6d min %5d %s\n",
$lane, $e["hold_credits"], $e["min_credits"], $e["model_alias"]);
$worst = max($worst, $e["min_credits"]);
}
assert_can_afford($worst);
var psms = await File.ReadAllTextAsync("psm.csv");
var parameters = await File.ReadAllTextAsync("search_params.txt");
// The flat input object. An anonymous type is enough; there is no wrapper.
object BuildInput(string lane) => new
{
task = lane,
psms,
@params = parameters, // `params` is a C# keyword - the JSON name is params
goal = "publication",
stage = "first_search",
question = "can I report the 6,214 protein groups as a 1% FDR protein list?",
};
var est = await PsmDesk.Call("estimate", BuildInput("ident")); // free, authenticated
Console.WriteLine(est.GetRawText());
// {"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":2480,"min_credits":300,"sponsor_enabled":false}
long worst = 0;
foreach (var lane in PsmDesk.Lanes)
{
var e = await PsmDesk.Call("estimate", BuildInput(lane));
Console.WriteLine($"{lane,-8} hold {e.GetProperty("hold_credits").GetInt64(),6} " +
$"min {e.GetProperty("min_credits").GetInt64(),5} " +
$"{e.GetProperty("model_alias").GetString()}");
worst = Math.Max(worst, e.GetProperty("min_credits").GetInt64());
}
await AssertCanAfford(worst);
The @params in the C# sample is not a typo: params is a C# keyword, so the
property is written @params and serialises to the JSON name params, which
is what the app reads. Every language has one of these — in Go it is the struct tag, in Ruby the
symbol key, in PHP the array key. The JSON field name is always params.
4. Run it, poll, and check the reply
POST /run returns {job_id}; poll GET /job/{job_id} until
status is succeeded or failed. The review is a JSON string at
data.output.output — one object, the envelope described in
the output contract. The terminal job also carries
charged_credits, the real price, and truncated.
The body is the input object itself — again, and for the last time. This is the call
where the wrapped-input mistake costs money: {"input": {…}} returns
200, takes the hold, runs, and bills you for a review of an empty object. The reply will be fluent
and it will be about nothing — an identification review of a run with no decoys, no q-values and no
protein groups. There is no error code and no warning; the only signal is that lane
comes back as whatever the model guessed from an empty input, and every
reconciliation entry you were promised is missing. Send it flat, and assert
lane.
Always send an Idempotency-Key, and put the lane in it. Derive it from
the input the way the web app does — a content hash plus the lane plus an attempt counter,
psm-desk:<hash>:<lane>:a<attempt>. A retried request carrying the
same key returns the same job instead of billing a second run, which is what makes a retry safe
after a network blip on an export you have already paid to review. Two lanes over one paste
are two distinct runs, and they must not share a key: ident and
quant over the same table are different questions with different outputs, and a key
reused across them either replays the wrong job or is rejected outright. The lane is part of the
body, so it belongs in the key. Bump the attempt suffix whenever the input actually changed.
The polling interval that works here is one second with a cap: an ident pass over a
short export settles in a few seconds, and a methods lane drafting a paragraph and two
tables against 150 rows can take most of a minute. Poll on a fixed short interval rather than an
exponential backoff — the job is not rate-limited and a backoff mostly adds latency to the common
case.
# Always send an Idempotency-Key derived from the input, WITH THE LANE IN IT.
# A retried request with the same key returns the SAME job instead of billing twice.
LANE=ident
KEY="psm-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):$LANE:a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')
echo "job $JOB"
# Poll on a fixed one-second interval, with a ceiling.
for i in $(seq 1 120); do
RES=$(curl -sS "$BASE/job/$JOB" -H "Authorization: Bearer $TOKEN")
STATUS=$(printf '%s' "$RES" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["status"])')
case "$STATUS" in
succeeded|failed) break ;;
esac
sleep 1
done
# Pull the review out of data.output.output and run the assertions.
printf '%s' "$RES" | TASK="$LANE" ROWS_SENT=10 python3 -c '
import json, os, sys
LANE_BODY_KEYS = {
"ident": {"fdr_assessment", "evidence_review", "drop_list", "trust_scope"},
"quant": {"quant_readiness", "normalisation", "missingness", "design_review",
"changes", "min_reportable"},
"methods": {"methods_paragraph", "reporting_table", "deposition_checklist",
"limitations", "open_items"},
}
VERDICTS = {"reportable", "reportable_with_caveats", "revise", "not_reportable",
"unassessable"}
SEVERITIES = {"blocking", "high", "medium", "low", "info"}
AREAS = {"fdr", "digestion", "calibration", "modification", "inference",
"contamination", "quantitation", "design", "reporting"}
job = json.load(sys.stdin)["data"]
if job["status"] != "succeeded":
raise SystemExit("job %s: %s" % (job["status"], job.get("error")))
if job.get("truncated"):
raise SystemExit("truncated: retry, do not repair")
raw = job["output"]["output"]
review = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
assert review["lane"] == os.environ["TASK"], (review["lane"], "wrapped input?")
assert set(review["body"]) <= LANE_BODY_KEYS[review["lane"]], "body keys from another lane"
assert review["verdict"] in VERDICTS, review["verdict"]
rows_sent = int(os.environ["ROWS_SENT"])
worst = set()
for f in review["findings"]:
assert f["severity"] in SEVERITIES and f["area"] in AREAS, f["id"]
assert f["row"] is None or 1 <= f["row"] <= rows_sent, (f["id"], f["row"])
worst.add(f["severity"])
if review["verdict"] == "not_reportable": assert "blocking" in worst
if review["verdict"] == "reportable": assert not (worst - {"info"})
print(review["verdict"], "|", review["headline"])
print("charged", job["charged_credits"], "credits")
'
import hashlib, time
LANE_BODY_KEYS = {
"ident": {"fdr_assessment", "evidence_review", "drop_list", "trust_scope"},
"quant": {"quant_readiness", "normalisation", "missingness", "design_review",
"changes", "min_reportable"},
"methods": {"methods_paragraph", "reporting_table", "deposition_checklist",
"limitations", "open_items"},
}
VERDICTS = {"reportable", "reportable_with_caveats", "revise", "not_reportable",
"unassessable"}
SEVERITIES = {"blocking", "high", "medium", "low", "info"}
AREAS = {"fdr", "digestion", "calibration", "modification", "inference",
"contamination", "quantitation", "design", "reporting"}
RECON_STATUS = {"confirmed", "adjusted", "set_aside", "noted", "not_applicable"}
CONTEXT_STATUS = {"honoured", "contradicted", "unverifiable"}
def idem_key(body, attempt=1):
"""psm-desk:<hash>:<lane>:a<attempt>.
The lane is in the key because the lane is in the body: two lanes over one
paste are two runs, and sharing a key between them is how one of them comes
back as the other one's answer.
"""
blob = json.dumps(body, sort_keys=True).encode()
return (f"psm-desk:{hashlib.sha256(blob).hexdigest()[:16]}"
f":{body.get('task', 'auto')}:a{attempt}")
def run(body, attempt=1, timeout=240):
job = call("run", body, {"Idempotency-Key": idem_key(body, attempt)})
job_id = job["job_id"]
deadline = time.time() + timeout
while time.time() < deadline:
j = call(f"job/{job_id}")
if j["status"] in ("succeeded", "failed"):
return j
time.sleep(1)
raise TimeoutError(job_id)
def rows_in(body):
"""How many table rows were actually sent - the bound every `row` must obey."""
lines = [ln for ln in body["psms"].splitlines() if ln.strip()]
return max(0, len(lines) - 1) # minus the header
def review_of(job, body):
"""Unwrap data.output.output and assert everything worth asserting."""
if job["status"] != "succeeded":
raise RuntimeError(f"job {job['status']}: {job.get('error')}")
if job.get("truncated"):
raise RuntimeError("truncated reply: retry with a retry_note, do not repair")
raw = job["output"]["output"]
review = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
expected = body["task"]
# 1. the lane you asked for (a mismatch is what a wrapped input looks like)
assert review["lane"] == expected, (review["lane"], expected)
# 2. the body belongs to that lane, and to no other
keys = set(review["body"])
assert keys <= LANE_BODY_KEYS[review["lane"]], keys - LANE_BODY_KEYS[review["lane"]]
# 3. enums in range, everywhere they appear
assert review["verdict"] in VERDICTS, review["verdict"]
for r in review["reconciliation"]:
assert r["status"] in RECON_STATUS, r
for c in review["context_notes"]:
assert c["status"] in CONTEXT_STATUS, c
# 4. the verdict matches the worst severity, and no row is invented
n_rows = rows_in(body)
worst = set()
for f in review["findings"]:
assert f["severity"] in SEVERITIES and f["area"] in AREAS, f
assert f["row"] is None or 1 <= f["row"] <= n_rows, (f["id"], f["row"], n_rows)
worst.add(f["severity"])
if review["verdict"] == "not_reportable":
assert "blocking" in worst, "not_reportable with no blocking finding"
if review["verdict"] == "revise":
assert worst & {"blocking", "high"}, "revise with nothing above medium"
if review["verdict"] == "reportable":
assert not (worst - {"info"}), f"reportable with {worst}"
return review
job = run(INPUT)
review = review_of(job, INPUT)
# 5. the reconciliation contract: one entry per prescan uid, exactly
sent = {f["uid"] for f in INPUT.get("prescan_facts", {}).get("flags", [])}
got = [r["flag_uid"] for r in review["reconciliation"]]
assert sorted(got) == sorted(sent), set(got) ^ sent
assert len(got) == len(set(got)), "a uid was reconciled twice"
# 6. trust_scope partitions, in the ident lane
if review["lane"] == "ident":
ts = review["body"]["trust_scope"]
both = set(ts["may_report"]) & set(ts["may_not_report"])
assert not both, f"claim on both sides of trust_scope: {both}"
# 7. the question came back as context_notes
if INPUT.get("question"):
assert review["context_notes"], "a question was sent and not read"
print(review["verdict"], "|", review["headline"])
print("charged", job["charged_credits"], "credits")
import { createHash } from "node:crypto"; // in a browser: SubtleCrypto
const LANE_BODY_KEYS = {
ident: ["fdr_assessment", "evidence_review", "drop_list", "trust_scope"],
quant: ["quant_readiness", "normalisation", "missingness", "design_review",
"changes", "min_reportable"],
methods: ["methods_paragraph", "reporting_table", "deposition_checklist",
"limitations", "open_items"],
};
const VERDICTS = ["reportable", "reportable_with_caveats", "revise", "not_reportable",
"unassessable"];
const SEVERITIES = ["blocking", "high", "medium", "low", "info"];
const AREAS = ["fdr", "digestion", "calibration", "modification", "inference",
"contamination", "quantitation", "design", "reporting"];
// psm-desk:<hash>:<lane>:a<attempt>. The lane belongs in the key because two
// lanes over one paste are two runs - sharing a key replays the wrong one.
function idemKey(body, attempt = 1) {
const hash = createHash("sha256")
.update(JSON.stringify(body, Object.keys(body).sort()))
.digest("hex").slice(0, 16);
return `psm-desk:${hash}:${body.task ?? "auto"}:a${attempt}`;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function run(body, attempt = 1, timeoutMs = 240_000) {
const { job_id } = await call("run", body, { "Idempotency-Key": idemKey(body, attempt) });
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const job = await call(`job/${job_id}`);
if (job.status === "succeeded" || job.status === "failed") return job;
await sleep(1000);
}
throw new Error(`timeout waiting for ${job_id}`);
}
const rowsIn = (body) =>
Math.max(0, body.psms.split("\n").filter((l) => l.trim()).length - 1);
function reviewOf(job, body) {
if (job.status !== "succeeded") throw new Error(`job ${job.status}`);
if (job.truncated) throw new Error("truncated reply: retry, do not repair");
const raw = job.output.output;
const review = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
// 1. the lane you asked for
if (review.lane !== body.task) throw new Error(`lane ${review.lane} - wrapped input?`);
// 2. the body belongs to that lane
const allowed = LANE_BODY_KEYS[review.lane];
const strays = Object.keys(review.body).filter((k) => !allowed.includes(k));
if (strays.length) throw new Error(`body keys from another lane: ${strays}`);
// 3. enums in range
if (!VERDICTS.includes(review.verdict)) throw new Error(`verdict ${review.verdict}`);
// 4. verdict against the worst severity, and no invented row
const nRows = rowsIn(body);
const worst = new Set();
for (const f of review.findings) {
if (!SEVERITIES.includes(f.severity) || !AREAS.includes(f.area)) throw new Error(f.id);
if (f.row !== null && !(f.row >= 1 && f.row <= nRows))
throw new Error(`${f.id}: row ${f.row} is not in the ${nRows} rows sent`);
worst.add(f.severity);
}
if (review.verdict === "not_reportable" && !worst.has("blocking"))
throw new Error("not_reportable with no blocking finding");
if (review.verdict === "reportable" && [...worst].some((s) => s !== "info"))
throw new Error(`reportable with ${[...worst]}`);
return review;
}
const job = await run(INPUT);
const review = reviewOf(job, INPUT);
// 5. the reconciliation contract, both directions
const sent = (INPUT.prescan_facts?.flags ?? []).map((f) => f.uid).sort();
const got = review.reconciliation.map((r) => r.flag_uid).sort();
if (JSON.stringify(sent) !== JSON.stringify(got))
throw new Error(`reconciliation mismatch: sent ${sent} got ${got}`);
// 6. trust_scope partitions
if (review.lane === "ident") {
const { may_report, may_not_report } = review.body.trust_scope;
const both = may_report.filter((c) => may_not_report.includes(c));
if (both.length) throw new Error(`claim on both sides: ${both}`);
}
// 7. the question came back
if (INPUT.question && review.context_notes.length === 0)
throw new Error("a question was sent and not read");
console.log(review.verdict, "|", review.headline);
console.log("charged", job.charged_credits, "credits");
type jobState struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Truncated bool `json:"truncated"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Error json.RawMessage `json:"error"`
}
type finding struct {
ID string `json:"id"`
Severity string `json:"severity"`
Area string `json:"area"`
Title string `json:"title"`
Detail string `json:"detail"`
Evidence string `json:"evidence"`
Row *int `json:"row"` // pointer: null is a legitimate value
Fix string `json:"fix"`
}
type review struct {
Lane string `json:"lane"`
Title string `json:"title"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Summary string `json:"summary"`
Findings []finding `json:"findings"`
Reconciliation []struct {
FlagUID string `json:"flag_uid"`
Status string `json:"status"`
Note string `json:"note"`
} `json:"reconciliation"`
ContextNotes []json.RawMessage `json:"context_notes"`
Body map[string]any `json:"body"`
}
// psm-desk:<hash>:<lane>:a<attempt> - the lane is in the key because two lanes
// over one export are two separate runs.
func idemKey(in runInput, attempt int) string {
raw, _ := json.Marshal(in)
sum := sha256.Sum256(raw)
return fmt.Sprintf("psm-desk:%x:%s:a%d", sum[:8], in.Task, attempt)
}
func runAndPoll(in runInput, attempt int) (*jobState, error) {
raw, err := call("run", in, map[string]string{"Idempotency-Key": idemKey(in, attempt)})
if err != nil {
return nil, err
}
var started jobState
if err := json.Unmarshal(raw, &started); err != nil {
return nil, err
}
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
raw, err := call("job/"+started.JobID, nil, nil)
if err != nil {
return nil, err
}
var j jobState
if err := json.Unmarshal(raw, &j); err != nil {
return nil, err
}
if j.Status == "succeeded" || j.Status == "failed" {
return &j, nil
}
time.Sleep(time.Second)
}
return nil, fmt.Errorf("timeout waiting for %s", started.JobID)
}
var laneBodyKeys = map[string]map[string]bool{
"ident": {"fdr_assessment": true, "evidence_review": true, "drop_list": true,
"trust_scope": true},
"quant": {"quant_readiness": true, "normalisation": true, "missingness": true,
"design_review": true, "changes": true, "min_reportable": true},
"methods": {"methods_paragraph": true, "reporting_table": true,
"deposition_checklist": true, "limitations": true, "open_items": true},
}
func reviewOf(j *jobState, in runInput) (*review, error) {
if j.Status != "succeeded" {
return nil, fmt.Errorf("job %s: %s", j.Status, string(j.Error))
}
if j.Truncated {
return nil, fmt.Errorf("truncated reply: retry, do not repair")
}
raw := j.Output.Output
start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("no JSON object in output")
}
var r review
if err := json.Unmarshal([]byte(raw[start:end+1]), &r); err != nil {
return nil, err
}
if r.Lane != in.Task {
return nil, fmt.Errorf("lane %q for task %q - wrapped input?", r.Lane, in.Task)
}
for k := range r.Body {
if !laneBodyKeys[r.Lane][k] {
return nil, fmt.Errorf("body key %q does not belong to lane %q", k, r.Lane)
}
}
rows := len(strings.Split(strings.TrimSpace(in.Psms), "\n")) - 1
blocking := false
for _, f := range r.Findings {
if f.Row != nil && (*f.Row < 1 || *f.Row > rows) {
return nil, fmt.Errorf("%s: row %d is not in the %d rows sent", f.ID, *f.Row, rows)
}
if f.Severity == "blocking" {
blocking = true
}
}
if r.Verdict == "not_reportable" && !blocking {
return nil, fmt.Errorf("not_reportable with no blocking finding")
}
return &r, nil
}
// psm-desk:<hash>:<lane>:a<attempt>. The lane is part of the key because it is
// part of the body: ident and quant over one export are two runs.
static String idemKey(String inputJson, String lane, int attempt) throws Exception {
var md = MessageDigest.getInstance("SHA-256");
byte[] d = md.digest(inputJson.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var hex = new StringBuilder();
for (int i = 0; i < 8; i++) hex.append(String.format("%02x", d[i]));
return "psm-desk:" + hex + ":" + lane + ":a" + attempt;
}
/** POST /run then poll GET /job/{id} on a one-second interval. */
static String runAndPoll(String inputJson, String lane) throws Exception {
String started = call("run", inputJson,
Map.of("Idempotency-Key", idemKey(inputJson, lane, 1)));
String jobId = between(started, "\"job_id\":\"", "\"");
long deadline = System.currentTimeMillis() + 240_000;
String job = "";
while (System.currentTimeMillis() < deadline) {
job = call("job/" + jobId, null, Map.of());
String status = between(job, "\"status\":\"", "\"");
if (status.equals("succeeded") || status.equals("failed")) return job;
Thread.sleep(1000);
}
throw new ApiError("TIMEOUT", jobId);
}
static String between(String s, String open, String close) {
int i = s.indexOf(open);
if (i < 0) throw new ApiError("PARSE", open + " not found");
i += open.length();
return s.substring(i, s.indexOf(close, i));
}
public static void main(String[] args) throws Exception {
if (System.getenv("SKILLSAFE_TOKEN") == null) mintGuest();
String psms = Files.readString(Path.of("psm.csv"));
String params = Files.readString(Path.of("search_params.txt"));
String question = "can I report the 6,214 protein groups as a 1% FDR protein list?";
String input = buildInput("ident", psms, params, question);
String job = runAndPoll(input, "ident");
// The assertions, on the raw text. Use Jackson in production and check:
// lane == the task you sent (a mismatch means a wrapped input)
// truncated == false (a prefix is not a review)
// every findings[].row is null or in [1, rows sent]
// one reconciliation entry per prescan uid, no more and no fewer
if (job.contains("\"truncated\":true"))
throw new ApiError("TRUNCATED", "retry, do not repair");
if (!job.contains("\"lane\":\"ident\""))
throw new ApiError("LANE", "lane is not ident - wrapped input?");
System.out.println(between(job, "\"verdict\":\"", "\""));
System.out.println("charged " + between(job, "\"charged_credits\":", ",") + " credits");
}
require "digest"
LANE_BODY_KEYS = {
"ident" => %w[fdr_assessment evidence_review drop_list trust_scope],
"quant" => %w[quant_readiness normalisation missingness design_review changes
min_reportable],
"methods" => %w[methods_paragraph reporting_table deposition_checklist limitations
open_items]
}.freeze
VERDICTS = %w[reportable reportable_with_caveats revise not_reportable unassessable].freeze
SEVERITIES = %w[blocking high medium low info].freeze
AREAS = %w[fdr digestion calibration modification inference contamination quantitation
design reporting].freeze
# psm-desk:<hash>:<lane>:a<attempt> - the lane is in the key because two lanes
# over one paste are two runs.
def idem_key(body, attempt = 1)
blob = JSON.generate(body.sort.to_h)
hash = Digest::SHA256.hexdigest(blob)[0, 16]
"psm-desk:#{hash}:#{body[:task] || body['task'] || 'auto'}:a#{attempt}"
end
def run(body, attempt = 1, timeout = 240)
started = call("run", body, { "Idempotency-Key" => idem_key(body, attempt) })
job_id = started["job_id"]
deadline = Time.now + timeout
while Time.now < deadline
job = call("job/#{job_id}")
return job if %w[succeeded failed].include?(job["status"])
sleep 1
end
raise "timeout waiting for #{job_id}"
end
def rows_in(body)
[(body[:psms] || body["psms"]).lines.count { |l| !l.strip.empty? } - 1, 0].max
end
def review_of(job, body)
raise "job #{job['status']}: #{job['error']}" unless job["status"] == "succeeded"
raise "truncated reply: retry, do not repair" if job["truncated"]
raw = job.dig("output", "output")
review = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
task = body[:task] || body["task"]
raise "lane #{review['lane']} for task #{task} - wrapped input?" if review["lane"] != task
strays = review["body"].keys - LANE_BODY_KEYS[review["lane"]]
raise "body keys from another lane: #{strays}" unless strays.empty?
raise "verdict #{review['verdict']}" unless VERDICTS.include?(review["verdict"])
n = rows_in(body)
worst = review["findings"].map do |f|
raise "bad enum in #{f['id']}" unless SEVERITIES.include?(f["severity"]) &&
AREAS.include?(f["area"])
unless f["row"].nil? || (1..n).cover?(f["row"])
raise "#{f['id']}: row #{f['row']} is not in the #{n} rows sent"
end
f["severity"]
end.to_set
raise "not_reportable with no blocking finding" if
review["verdict"] == "not_reportable" && !worst.include?("blocking")
raise "reportable with #{worst.to_a}" if
review["verdict"] == "reportable" && !(worst - ["info"]).empty?
review
end
job = run(INPUT)
review = review_of(job, INPUT)
# The reconciliation contract, both directions.
sent = (INPUT.dig(:prescan_facts, :flags) || []).map { |f| f[:uid] }.sort
got = review["reconciliation"].map { |r| r["flag_uid"] }.sort
raise "reconciliation mismatch: sent #{sent} got #{got}" if sent != got
puts "#{review['verdict']} | #{review['headline']}"
puts "charged #{job['charged_credits']} credits"
<?php
const LANE_BODY_KEYS = [
"ident" => ["fdr_assessment", "evidence_review", "drop_list", "trust_scope"],
"quant" => ["quant_readiness", "normalisation", "missingness", "design_review",
"changes", "min_reportable"],
"methods" => ["methods_paragraph", "reporting_table", "deposition_checklist",
"limitations", "open_items"],
];
const VERDICTS = ["reportable", "reportable_with_caveats", "revise", "not_reportable",
"unassessable"];
const SEVERITIES = ["blocking", "high", "medium", "low", "info"];
const AREAS = ["fdr", "digestion", "calibration", "modification", "inference",
"contamination", "quantitation", "design", "reporting"];
// psm-desk:<hash>:<lane>:a<attempt> - the lane is in the key because two lanes
// over one export are two distinct runs.
function idem_key(array $body, int $attempt = 1): string {
ksort($body);
$hash = substr(hash("sha256", json_encode($body)), 0, 16);
return "psm-desk:$hash:" . ($body["task"] ?? "auto") . ":a$attempt";
}
function run_job(array $body, int $attempt = 1, int $timeout = 240): array {
$started = call("run", $body, ["Idempotency-Key" => idem_key($body, $attempt)]);
$jobId = $started["job_id"];
$deadline = time() + $timeout;
while (time() < $deadline) {
$job = call("job/$jobId");
if (in_array($job["status"], ["succeeded", "failed"], true)) return $job;
sleep(1);
}
throw new RuntimeException("timeout waiting for $jobId");
}
function rows_in(array $body): int {
$lines = preg_split('/\R/', trim($body["psms"]));
return max(0, count($lines) - 1);
}
function review_of(array $job, array $body): array {
if ($job["status"] !== "succeeded") throw new RuntimeException("job " . $job["status"]);
if (!empty($job["truncated"])) throw new RuntimeException("truncated: retry, do not repair");
$raw = $job["output"]["output"];
$slice = substr($raw, strpos($raw, "{"), strrpos($raw, "}") - strpos($raw, "{") + 1);
$review = json_decode($slice, true, flags: JSON_THROW_ON_ERROR);
if ($review["lane"] !== $body["task"])
throw new RuntimeException("lane {$review['lane']} - wrapped input?");
$strays = array_diff(array_keys($review["body"]), LANE_BODY_KEYS[$review["lane"]]);
if ($strays) throw new RuntimeException("body keys from another lane: " . implode(",", $strays));
if (!in_array($review["verdict"], VERDICTS, true))
throw new RuntimeException("verdict {$review['verdict']}");
$n = rows_in($body);
$worst = [];
foreach ($review["findings"] as $f) {
if (!in_array($f["severity"], SEVERITIES, true) || !in_array($f["area"], AREAS, true))
throw new RuntimeException("bad enum in {$f['id']}");
if ($f["row"] !== null && ($f["row"] < 1 || $f["row"] > $n))
throw new RuntimeException("{$f['id']}: row {$f['row']} not in $n rows sent");
$worst[$f["severity"]] = true;
}
if ($review["verdict"] === "not_reportable" && empty($worst["blocking"]))
throw new RuntimeException("not_reportable with no blocking finding");
return $review;
}
$job = run_job($input);
$review = review_of($job, $input);
// The reconciliation contract, both directions.
$sent = array_map(fn($f) => $f["uid"], $input["prescan_facts"]["flags"] ?? []);
$got = array_map(fn($r) => $r["flag_uid"], $review["reconciliation"]);
sort($sent); sort($got);
if ($sent !== $got) throw new RuntimeException("reconciliation mismatch");
echo $review["verdict"], " | ", $review["headline"], "\n";
echo "charged ", $job["charged_credits"], " credits\n";
static readonly Dictionary<string, string[]> LaneBodyKeys = new()
{
["ident"] = new[] { "fdr_assessment", "evidence_review", "drop_list", "trust_scope" },
["quant"] = new[] { "quant_readiness", "normalisation", "missingness",
"design_review", "changes", "min_reportable" },
["methods"] = new[] { "methods_paragraph", "reporting_table", "deposition_checklist",
"limitations", "open_items" },
};
static readonly string[] Verdicts = { "reportable", "reportable_with_caveats", "revise",
"not_reportable", "unassessable" };
static readonly string[] Severities = { "blocking", "high", "medium", "low", "info" };
static readonly string[] Areas = { "fdr", "digestion", "calibration", "modification",
"inference", "contamination", "quantitation",
"design", "reporting" };
// psm-desk:<hash>:<lane>:a<attempt>. The lane is in the key because two lanes
// over one export are two runs, and a shared key replays the wrong job.
static string IdemKey(object body, string lane, int attempt = 1)
{
var json = JsonSerializer.Serialize(body);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))
.ToLowerInvariant()[..16];
return $"psm-desk:{hash}:{lane}:a{attempt}";
}
static async Task<JsonElement> RunAndPoll(object body, string lane, int attempt = 1)
{
var started = await PsmDesk.Call("run", body,
new Dictionary<string, string> { ["Idempotency-Key"] = IdemKey(body, lane, attempt) });
var jobId = started.GetProperty("job_id").GetString()!;
var deadline = DateTime.UtcNow.AddMinutes(4);
while (DateTime.UtcNow < deadline)
{
var job = await PsmDesk.Call($"job/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") return job;
await Task.Delay(1000);
}
throw new TimeoutException(jobId);
}
static JsonElement ReviewOf(JsonElement job, string lane, string psms)
{
if (job.GetProperty("status").GetString() != "succeeded")
throw new InvalidOperationException("job did not succeed");
if (job.TryGetProperty("truncated", out var t) && t.GetBoolean())
throw new InvalidOperationException("truncated: retry, do not repair");
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
var slice = raw[raw.IndexOf('{')..(raw.LastIndexOf('}') + 1)];
var review = JsonDocument.Parse(slice).RootElement;
if (review.GetProperty("lane").GetString() != lane)
throw new InvalidOperationException("lane mismatch - wrapped input?");
foreach (var k in review.GetProperty("body").EnumerateObject())
if (!LaneBodyKeys[lane].Contains(k.Name))
throw new InvalidOperationException($"body key {k.Name} is from another lane");
if (!Verdicts.Contains(review.GetProperty("verdict").GetString()))
throw new InvalidOperationException("verdict out of range");
var rows = psms.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length - 1;
var blocking = false;
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
var sev = f.GetProperty("severity").GetString()!;
if (!Severities.Contains(sev) || !Areas.Contains(f.GetProperty("area").GetString()))
throw new InvalidOperationException("enum out of range");
var row = f.GetProperty("row");
if (row.ValueKind != JsonValueKind.Null)
{
var n = row.GetInt32();
if (n < 1 || n > rows)
throw new InvalidOperationException($"row {n} is not in the {rows} rows sent");
}
blocking |= sev == "blocking";
}
if (review.GetProperty("verdict").GetString() == "not_reportable" && !blocking)
throw new InvalidOperationException("not_reportable with no blocking finding");
return review;
}
var job = await RunAndPoll(BuildInput("ident"), "ident");
var review = ReviewOf(job, "ident", psms);
Console.WriteLine($"{review.GetProperty("verdict").GetString()} | " +
$"{review.GetProperty("headline").GetString()}");
Console.WriteLine($"charged {job.GetProperty("charged_credits").GetInt64()} credits");
The terminal job, for reference — output.output is the string those samples slice:
{ "ok": true, "data": {
"job_id": "job_01J9Z...",
"status": "succeeded",
"truncated": false,
"charged_credits": 1180,
"output": {
"output": "{\"lane\":\"ident\",\"title\":\"HeLa staurosporine DDA - first search...\"}"
}
} }
Two details in those samples that are easy to skip. findings[].row is checked against
the number of rows you actually sent, not against the row count of your original export — the model
can only see the sample, so a row index beyond it is fabricated by definition. And the
Idempotency-Key hash is computed over the whole body including
prescan_facts, so adding a flag changes the key: that is correct, because a run held to
one more fact is a different run.
5. Or stream it
POST /run-stream is the same call over server-sent events, with
Accept: text/event-stream added to the same headers and the same
Idempotency-Key. The events are job ({job_id}, first),
delta ({"text": "..."}, a chunk of the review JSON), done
(status, charged_credits, truncated) and error
on a failure. Two practical details: an idempotent replay of a key that already ran comes back as
plain JSON rather than a stream, so check the response Content-Type before you
start reading lines; and events are separated by a blank line, so split on \n\n
rather than assuming one data: line per event.
For a progress display, do not parse the partial JSON — watch for key names arriving in the
accumulating text. "findings" means the review is naming problems,
"reconciliation" means it has reached your prescan flags, "body" means the
lane's own document has started, and the lane's last block means it is nearly done:
"trust_scope" for ident, "min_reportable" for
quant, "open_items" for methods. Substring matching on the
quoted key name is enough and it costs nothing.
That ordering is also the reason a truncated stream is dangerous rather than merely incomplete.
body is the last key in the envelope, so a cut-off ident reply is a list of
findings with no trust_scope — the block that says what may not be reported is exactly
the part that goes missing. Read truncated off the done event before you
render anything.
# Same body, same Idempotency-Key, one extra header.
curl -sSN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# event: job
# data: {"job_id":"job_..."}
#
# event: delta
# data: {"text":"{\"lane\":\"ident\",\"title\":\"HeLa staurosporine DDA"}
#
# event: delta
# data: {"text":"\",\"verdict\":\"not_reportable\",\"headline\":\"The decoys"}
#
# event: done
# data: {"status":"succeeded","charged_credits":1180,"truncated":false}
# -N disables curl's buffering; without it the deltas arrive in one lump at the
# end and the stream was pointless. Reassemble the review from the delta texts:
curl -sSN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" -d "$INPUT" \
| python3 -c '
import json, sys
text = ""
seen = set()
for line in sys.stdin:
if not line.startswith("data:"):
continue
payload = json.loads(line[5:].strip() or "{}")
if "text" in payload:
text += payload["text"]
for key in ("findings", "reconciliation", "body", "trust_scope"):
if ("\"%s\"" % key) in text and key not in seen:
seen.add(key)
print("...", key, file=sys.stderr)
elif payload.get("status"):
if payload.get("truncated"):
raise SystemExit("truncated: retry, do not repair")
review = json.loads(text[text.index("{"):text.rindex("}") + 1])
print(review["verdict"], "|", review["headline"])
'
def stream(body, attempt=1, on_progress=None):
"""POST /run-stream. Returns the parsed review.
Events are separated by a blank line, so accumulate and split on \n\n rather
than assuming one data: line per event.
"""
payload = json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}/run-stream", data=payload, method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Idempotency-Key", idem_key(body, attempt))
with urllib.request.urlopen(req) as res:
ctype = res.headers.get("Content-Type", "")
# An idempotent replay of a key that already ran answers with plain JSON.
if "text/event-stream" not in ctype:
env = json.load(res)
return review_of(env["data"], body)
text, buf, done = "", "", None
for chunk in res:
buf += chunk.decode("utf-8", "replace")
while "\n\n" in buf:
raw, buf = buf.split("\n\n", 1)
name, data = "message", ""
for line in raw.splitlines():
if line.startswith("event:"):
name = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if not data:
continue
ev = json.loads(data)
if name == "delta":
text += ev.get("text", "")
if on_progress:
on_progress(text)
elif name == "done":
done = ev
elif name == "error":
raise ApiError(ev.get("code", "INTERNAL"), ev.get("message", ""))
if done and done.get("truncated"):
raise RuntimeError("truncated reply: retry with a retry_note, do not repair")
review = json.loads(text[text.index("{"):text.rindex("}") + 1])
assert review["lane"] == body["task"], (review["lane"], body["task"])
return review
# A progress display that never parses partial JSON - it watches for key names.
MARKS = [("findings", "naming problems"), ("reconciliation", "checking your flags"),
("body", "writing the lane document"), ("trust_scope", "deciding what you may report")]
seen = set()
def show(text):
for key, label in MARKS:
if f'"{key}"' in text and key not in seen:
seen.add(key)
print("...", label)
review = stream(INPUT, on_progress=show)
print(review["verdict"], "|", review["headline"])
// Browser or Node 18+. The response body is a stream of bytes; split events on
// a blank line, not on newlines.
async function stream(body, { attempt = 1, onProgress } = {}) {
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"Idempotency-Key": idemKey(body, attempt),
},
body: JSON.stringify(body),
});
// An idempotent replay of a key that already ran answers with plain JSON.
const ctype = res.headers.get("content-type") ?? "";
if (!ctype.includes("text/event-stream")) {
const env = await res.json();
return reviewOf(env.data, body);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", text = "", done = null;
for (;;) {
const { value, done: finished } = await reader.read();
if (finished) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) >= 0) {
const rawEvent = buf.slice(0, sep);
buf = buf.slice(sep + 2);
let name = "message", data = "";
for (const line of rawEvent.split("\n")) {
if (line.startsWith("event:")) name = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const ev = JSON.parse(data);
if (name === "delta") {
text += ev.text ?? "";
onProgress?.(text);
} else if (name === "done") {
done = ev;
} else if (name === "error") {
throw new ApiError(ev.code ?? "INTERNAL", ev.message ?? "");
}
}
}
if (done?.truncated) throw new Error("truncated reply: retry, do not repair");
const review = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
if (review.lane !== body.task) throw new Error(`lane ${review.lane} - wrapped input?`);
return review;
}
// Progress without parsing partial JSON: watch for the key names arriving.
const MARKS = [["findings", "naming problems"],
["reconciliation", "checking your flags"],
["body", "writing the lane document"],
["trust_scope", "deciding what you may report"]];
const seen = new Set();
const review = await stream(INPUT, {
onProgress(text) {
for (const [key, label] of MARKS) {
if (text.includes(`"${key}"`) && !seen.has(key)) {
seen.add(key);
console.log("...", label);
}
}
},
});
console.log(review.verdict, "|", review.headline);
// POST /run-stream. bufio.Scanner over the body, events split on a blank line.
func streamRun(in runInput, attempt int, onProgress func(string)) (*review, error) {
raw, _ := json.Marshal(in)
req, err := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", idemKey(in, attempt))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
// An idempotent replay answers with plain JSON, not a stream.
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
OK bool `json:"ok"`
Data jobState `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
return reviewOf(&env.Data, in)
}
var text strings.Builder
var truncated bool
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
name, data := "message", strings.Builder{}
flush := func() error {
if data.Len() == 0 {
return nil
}
payload := data.String()
data.Reset()
switch name {
case "delta":
var ev struct{ Text string `json:"text"` }
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
return err
}
text.WriteString(ev.Text)
if onProgress != nil {
onProgress(text.String())
}
case "done":
var ev struct {
Status string `json:"status"`
Truncated bool `json:"truncated"`
}
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
return err
}
truncated = ev.Truncated
case "error":
return fmt.Errorf("stream error: %s", payload)
}
name = "message"
return nil
}
for scanner.Scan() {
line := scanner.Text()
switch {
case line == "":
if err := flush(); err != nil {
return nil, err
}
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data.WriteString(strings.TrimSpace(line[5:]))
}
}
if err := flush(); err != nil {
return nil, err
}
if truncated {
return nil, fmt.Errorf("truncated reply: retry, do not repair")
}
s := text.String()
fake := jobState{Status: "succeeded"}
fake.Output.Output = s
return reviewOf(&fake, in)
}
// POST /run-stream with a line-by-line body handler. Events end at a blank line.
static String streamRun(String inputJson, String lane) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", idemKey(inputJson, lane, 1))
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
// An idempotent replay of a spent key answers with plain JSON, not a stream.
String ctype = res.headers().firstValue("content-type").orElse("");
var lines = res.body().toList();
if (!ctype.contains("text/event-stream")) {
return String.join("", lines);
}
StringBuilder text = new StringBuilder();
String event = "message";
boolean truncated = false;
var seen = new java.util.HashSet<String>();
for (String line : lines) {
if (line.startsWith("event:")) { event = line.substring(6).trim(); continue; }
if (!line.startsWith("data:")) continue;
String data = line.substring(5).trim();
if (data.isEmpty()) continue;
if (event.equals("delta")) {
// The delta payload is {"text":"..."} with JSON-escaped content.
int i = data.indexOf("\"text\":\"");
if (i >= 0) {
String chunk = data.substring(i + 8, data.lastIndexOf('"'));
text.append(chunk.replace("\\n", "\n").replace("\\\"", "\"")
.replace("\\\\", "\\"));
}
for (String key : new String[] { "findings", "reconciliation", "body",
"trust_scope" }) {
if (text.indexOf("\"" + key + "\"") >= 0 && seen.add(key))
System.out.println("... " + key);
}
} else if (event.equals("done")) {
truncated = data.contains("\"truncated\":true");
} else if (event.equals("error")) {
throw new ApiError("STREAM", data);
}
}
if (truncated) throw new ApiError("TRUNCATED", "retry, do not repair");
String s = text.toString();
return s.substring(s.indexOf('{'), s.lastIndexOf('}') + 1);
}
# POST /run-stream. Net::HTTP yields chunks; buffer them and split on a blank line.
def stream(body, attempt = 1)
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Idempotency-Key"] = idem_key(body, attempt)
req.body = JSON.generate(body)
text = +""
truncated = false
plain = nil
seen = {}
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
# An idempotent replay of a spent key answers with plain JSON.
unless res["Content-Type"].to_s.include?("text/event-stream")
plain = JSON.parse(res.body)
next
end
buf = +""
res.read_body do |chunk|
buf << chunk
while (sep = buf.index("\n\n"))
raw = buf.slice!(0, sep + 2)
name = "message"
data = +""
raw.each_line do |line|
name = line[6..].strip if line.start_with?("event:")
data << line[5..].strip if line.start_with?("data:")
end
next if data.empty?
ev = JSON.parse(data)
case name
when "delta"
text << ev.fetch("text", "")
%w[findings reconciliation body trust_scope].each do |key|
next unless text.include?(%("#{key}")) && !seen[key]
seen[key] = true
warn "... #{key}"
end
when "done"
truncated = ev["truncated"] ? true : false
when "error"
raise ApiError.new(ev["code"] || "INTERNAL", ev["message"] || "")
end
end
end
end
end
return review_of(plain["data"], body) if plain
raise "truncated reply: retry, do not repair" if truncated
JSON.parse(text[text.index("{")..text.rindex("}")])
end
review = stream(INPUT)
puts "#{review['verdict']} | #{review['headline']}"
<?php
// POST /run-stream. CURLOPT_WRITEFUNCTION is called as bytes arrive; buffer and
// split on a blank line rather than assuming one data: line per event.
function stream_run(array $body, int $attempt = 1): array {
$text = "";
$buf = "";
$truncated = false;
$seen = [];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: " . idem_key($body, $attempt),
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (
&$text, &$buf, &$truncated, &$seen
) {
$buf .= $chunk;
while (($sep = strpos($buf, "\n\n")) !== false) {
$rawEvent = substr($buf, 0, $sep);
$buf = substr($buf, $sep + 2);
$name = "message";
$data = "";
foreach (preg_split('/\R/', $rawEvent) as $line) {
if (str_starts_with($line, "event:")) $name = trim(substr($line, 6));
elseif (str_starts_with($line, "data:")) $data .= trim(substr($line, 5));
}
if ($data === "") continue;
$ev = json_decode($data, true) ?: [];
if ($name === "delta") {
$text .= $ev["text"] ?? "";
foreach (["findings", "reconciliation", "body", "trust_scope"] as $key) {
if (str_contains($text, "\"$key\"") && empty($seen[$key])) {
$seen[$key] = true;
fwrite(STDERR, "... $key\n");
}
}
} elseif ($name === "done") {
$truncated = !empty($ev["truncated"]);
} elseif ($name === "error") {
throw new ApiError($ev["code"] ?? "INTERNAL", $ev["message"] ?? "");
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
if ($truncated) throw new RuntimeException("truncated: retry, do not repair");
$slice = substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1);
return json_decode($slice, true, flags: JSON_THROW_ON_ERROR);
}
$review = stream_run($input);
echo $review["verdict"], " | ", $review["headline"], "\n";
// POST /run-stream, read as a stream. HttpCompletionOption.ResponseHeadersRead is
// what makes it a stream rather than a buffered response.
static async Task<JsonElement> StreamRun(object body, string lane, int attempt = 1)
{
var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Add("Authorization", $"Bearer {PsmDesk.Token}");
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", IdemKey(body, lane, attempt));
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
// An idempotent replay of a spent key answers with plain JSON.
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
return ReviewOf(env.GetProperty("data"), lane, "");
}
await using var stream = await res.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
var text = new StringBuilder();
var name = "message";
var data = new StringBuilder();
var truncated = false;
var seen = new HashSet<string>();
void Flush()
{
if (data.Length == 0) return;
var payload = JsonDocument.Parse(data.ToString()).RootElement;
data.Clear();
if (name == "delta")
{
text.Append(payload.GetProperty("text").GetString());
foreach (var key in new[] { "findings", "reconciliation", "body", "trust_scope" })
if (text.ToString().Contains($"\"{key}\"") && seen.Add(key))
Console.WriteLine($"... {key}");
}
else if (name == "done")
{
truncated = payload.TryGetProperty("truncated", out var t) && t.GetBoolean();
}
else if (name == "error")
{
throw new PsmDesk.ApiError("STREAM", payload.ToString());
}
name = "message";
}
while (await reader.ReadLineAsync() is { } line)
{
if (line.Length == 0) Flush();
else if (line.StartsWith("event:")) name = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
Flush();
if (truncated) throw new InvalidOperationException("truncated: retry, do not repair");
var s = text.ToString();
return JsonDocument.Parse(s[s.IndexOf('{')..(s.LastIndexOf('}') + 1)]).RootElement;
}
var streamed = await StreamRun(BuildInput("ident"), "ident");
Console.WriteLine(streamed.GetProperty("headline").GetString());
One thing the stream does not give you that the poll does: charged_credits arrives only
on the done event, so a client that stops reading as soon as the JSON closes never
learns the price. Read to the end of the stream even when you already have a parseable review.
6. One worked request per lane
Three bodies, one per lane, each a complete request you can POST as-is, followed by a trimmed but
structurally complete reply. They are deliberately small: a real psms is a sample of
about 150 rows drawn from tens of thousands, and a real params is the whole workflow
file. The only things that change between these three are task, goal,
stage and the question being asked. The prescan_facts are trimmed here to
keep the examples readable; in the browser they carry every flag the free read raised.
ident — are these identifications reportable?
The lane to run first, and the one worth gating on: if the error rate does not hold, nothing
downstream is worth reading. It is the only lane that recomputes the target-decoy arithmetic, so this
is the request where a decoy column and a q_value column earn their place.
{
"task": "ident",
"psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
"params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
"goal": "publication",
"stage": "first_search",
"question": "we need to submit this week; can I report the 6,214 protein groups as a 1% FDR protein list?",
"prescan_facts": {
"table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
"quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
"settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
"q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
"missed_allowed": 2, "min_peptides": 1, "replicates": 4,
"quant_method": "label-free (LFQ)" },
"fdr": { "decoy_source": "column", "strategy": "concatenated",
"claimed_threshold": 0.01, "claimed_level": "psm",
"at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
"rows_above_threshold": 754, "decoys_above_threshold": 604,
"decoy_status_unresolved": 0, "max_q": 0.191 },
"verdict": "not_reportable",
"severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
"flags": [
{ "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
"mitigated_by": [], "row": null, "line": 11,
"title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
"evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
"value": 0.017403, "threshold": 0.01 },
{ "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 11,
"title": "Protein-level claims from a PSM-level error rate",
"evidence": "6214 protein groups, FDR stated at the PSM level",
"value": 6214, "threshold": null },
{ "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
"mitigated_by": [], "row": null, "line": 6,
"title": "Just under a third of peptides carry at least one missed cleavage",
"evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
"value": 0.31, "threshold": null },
{ "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 13,
"title": "Nearly a quarter of protein groups rest on a single peptide",
"evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
"value": 0.2387, "threshold": null },
{ "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 15,
"title": "Label-free intensities with normalisation stated as none",
"evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
"value": null, "threshold": null }
],
"unassessable": [
{ "item": "the transferred-identification error rate",
"why": "match-between-runs is not mentioned in the parameters" }
]
}
}
The reply to that request is written out in full above, which is why it
is not repeated here. The two things to look at in it are the five reconciliation
entries against the five uids — one of which is not_applicable, which is
an answer and not a dodge — and trust_scope, which is the block that answers the
question a caller actually has: what may I say?
quant — can the intensities carry a claim?
Run this when ident holds, or when it does not and you want to know what else is wrong
before deciding whether to re-search. The input is the same table and the same parameters — the
measurement columns were always there — with goal and the question changed to the
decision being made. Note that the prescan_facts here would carry the quantitation
block and the design flags; the trimmed version below keeps the two flags the lane turns on.
{
"task": "quant",
"psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
"params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
"goal": "decision",
"stage": "final_check",
"question": "the DMSO/staurosporine fold changes are going into a figure this week - which of them can I actually show?",
"prescan_facts": {
"table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
"quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
"settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
"q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
"missed_allowed": 2, "min_peptides": 1, "replicates": 4,
"quant_method": "label-free (LFQ)" },
"fdr": { "decoy_source": "column", "strategy": "concatenated",
"claimed_threshold": 0.01, "claimed_level": "psm",
"at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
"rows_above_threshold": 754, "decoys_above_threshold": 604,
"decoy_status_unresolved": 0, "max_q": 0.191 },
"verdict": "not_reportable",
"severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
"flags": [
{ "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
"mitigated_by": [], "row": null, "line": 11,
"title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
"evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
"value": 0.017403, "threshold": 0.01 },
{ "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 11,
"title": "Protein-level claims from a PSM-level error rate",
"evidence": "6214 protein groups, FDR stated at the PSM level",
"value": 6214, "threshold": null },
{ "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
"mitigated_by": [], "row": null, "line": 6,
"title": "Just under a third of peptides carry at least one missed cleavage",
"evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
"value": 0.31, "threshold": null },
{ "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 13,
"title": "Nearly a quarter of protein groups rest on a single peptide",
"evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
"value": 0.2387, "threshold": null },
{ "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 15,
"title": "Label-free intensities with normalisation stated as none",
"evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
"value": null, "threshold": null }
],
"unassessable": [
{ "item": "the transferred-identification error rate",
"why": "match-between-runs is not mentioned in the parameters" }
]
}
}
An abridged quant reply. The envelope keys are identical; only body
differs:
{
"lane": "quant",
"title": "HeLa staurosporine DDA - quantification readiness, 4 label-free columns",
"verdict": "not_reportable",
"headline": "Four unnormalised label-free columns whose medians span 28%, 38% of rows incomplete with no missing-value policy, and 6,214 per-protein t-tests with no correction: no individual fold change from this export is reportable.",
"summary": "The measurement side of this export is four MS1 intensity columns, one per replicate, which is enough to compare two conditions in principle. Three things stop it in practice, and all three are fixable on this same file without new instrument time. The columns were not normalised and their medians differ by 28%, so part of every ratio is loading. Missingness is 38% overall and rises monotonically from DMSO_1 to STS_2, which is the signature of a treatment that lowered intensity rather than of random dropout - and with values left as-is, each t-test silently ran on whatever subset was present. And 6,214 uncorrected per-protein tests produce roughly 310 significant results at p < 0.05 from noise alone. Fix the three and a fold-change table with q-values is reportable for the proteins measured in at least three of four replicates per condition.",
"findings": [
{ "id": "F-001", "severity": "blocking", "area": "design",
"title": "6,214 per-protein t-tests with the correction stated as none",
"detail": "The parameters state a two-sample t-test per protein and multiple testing as none. At an uncorrected 0.05 threshold, 6,214 tests yield about 310 false positives, which is the same order as the number of true changes a six-hour kinase-inhibitor treatment typically produces at this depth. Any list of significant proteins from this analysis is therefore mostly unordered with respect to truth. This is blocking rather than high because it is not a caveat on the result - it is the result being indistinguishable from noise at the list level.",
"evidence": "parameters line 19: \"Multiple testing: none\"; 6,214 protein groups",
"row": null,
"fix": "Apply Benjamini-Hochberg across the per-protein p-values and report q-values. It is a one-line change and it runs on the numbers you already have." },
{ "id": "F-002", "severity": "high", "area": "quantitation",
"title": "Label-free intensities with normalisation stated as none",
"detail": "Column medians are 1.24e6, 1.19e6, 1.01e6 and 9.68e5 - a 28% spread between the extremes, and the two lower ones are both staurosporine columns. That is exactly the pattern that a median normalisation exists to remove, and leaving it in means a systematic downward shift is being read as biology in every ratio.",
"evidence": "parameters line 15: \"Normalisation: none\"; per-column medians from the 4 intensity columns",
"row": null,
"fix": "Median- or quantile-normalise the four columns before any ratio is taken, and state which in the methods." },
{ "id": "F-003", "severity": "high", "area": "quantitation",
"title": "38% of rows are incomplete and the missingness is not random",
"detail": "25,548 of 41,207 rows carry a value in all four columns. The missing share rises from 26.9% (DMSO_1) to 32.1% (STS_2) monotonically across the treatment axis, so this is intensity-dependent dropout and not random. With values left as-is, a protein present in four DMSO columns and one staurosporine column produces a fold change from one measurement against four.",
"evidence": "complete_share 0.6199; per-column missing shares 0.2694, 0.2749, 0.3024, 0.3207",
"row": null,
"fix": "Require a value in at least three of four replicates per condition, and state the rule. Impute only if you can defend the assumption, and say which method - left-censored and random imputation are different claims." },
{ "id": "F-004", "severity": "medium", "area": "contamination",
"title": "Contaminant intensity is inside the totals",
"detail": "1,146 contaminant rows, 611 of them keratin, are still in the export. Keratin intensity tracks sample handling rather than treatment, and it is part of the column total any normalisation would be computed against, so the contaminants affect the ratios of everything else and not only their own rows.",
"evidence": "1,146 rows over 4 cRAP classes; parameters line 20 says they were not removed",
"row": 3,
"fix": "Remove contaminant rows before normalising, not after." },
{ "id": "F-005", "severity": "medium", "area": "design",
"title": "Replicate kind is not stated",
"detail": "Four replicates per condition are stated and four columns are present, so there is one measurement per replicate and no technical repeat inside any of them. Whether those four are biological or technical decides what the fold change generalises to, and the parameters do not say.",
"evidence": "parameters line 17: \"Replicates: 4 per condition\"; 4 intensity columns",
"row": null,
"fix": "State it. If they are technical, the comparison is about these two flasks and the wording has to say so." },
{ "id": "F-006", "severity": "low", "area": "quantitation",
"title": "Match-between-runs is not mentioned",
"detail": "IonQuant is named and match-between-runs is not, so whether intensities were transferred between injections cannot be established. It matters here because transfer is the usual remedy for exactly the missingness pattern in F-003, and a transferred value is not covered by the search-level FDR.",
"evidence": "parameters line 14 names IonQuant; no match-between-runs statement anywhere",
"row": null,
"fix": "State whether it was on. If it was, report the transfer FDR alongside the search FDR." }
],
"reconciliation": [
{ "flag_uid": "P01", "status": "noted",
"note": "Carried forward rather than re-judged: the 1.74% empirical FDR is an identification fact and this lane does not re-litigate it. It does bound this lane's output, so it is in caveats - a fold change between two lists at 1.74% is a fold change between two lists of that purity." },
{ "flag_uid": "P02", "status": "noted",
"note": "Same: a quantitative comparison across 6,214 groups whose protein-level error is unbounded inherits that gap, and it compounds F-001 rather than replacing it." },
{ "flag_uid": "P04", "status": "adjusted",
"note": "Adjusted upward in relevance for this lane. In the ident lane a 31% missed-cleavage share costs depth; here it also splits one peptide's signal across its missed-cleavage forms, so the MS1 areas being compared are systematically low and unevenly so." },
{ "flag_uid": "P06", "status": "confirmed",
"note": "Confirmed, with a quantitative consequence the identification reading does not state: a one-peptide group's intensity is one peptide's intensity, so 1,483 of the 6,214 fold changes have no internal replication at the peptide level at all." },
{ "flag_uid": "P08", "status": "confirmed",
"note": "Confirmed at high, and it is F-002. The flag's evidence and this reply's evidence are the same four column medians." }
],
"caveats": [
{ "area": "fdr",
"fact": "The exported list's own decoys correspond to 1.74% at the stated q <= 0.01, not 1%.",
"why_it_matters": "Every fold change is a ratio between two lists of that purity, so a change on a single peptide near the threshold may be a change in a wrong identification. It argues for restricting the figure to multi-peptide proteins." },
{ "area": "inference",
"fact": "1,483 of 6,214 groups rest on one peptide.",
"why_it_matters": "Those fold changes have no peptide-level replication, so they should not appear in a figure of top changers even after the three fixes above." }
],
"context_notes": [
{ "claim": "the DMSO/staurosporine fold changes are going into a figure this week - which of them can I actually show?",
"status": "contradicted",
"note": "None of them, as the data stand - not because the experiment is bad but because three unapplied steps are between the intensities and a ratio that means anything. All three run on this file in an afternoon, which fits the week: normalise, filter for completeness, correct for multiplicity. After that, show the proteins with three or more values per condition and two or more peptides, with q-values." }
],
"unassessable": [
{ "item": "batch structure",
"why": "The parameters name no acquisition order, no batch and no date, so whether the two conditions were run interleaved or in blocks cannot be established - and that decides whether the 28% median spread is loading or drift." },
{ "item": "the transferred-identification error rate",
"why": "Match-between-runs is not mentioned, so no transfer FDR can be checked." },
{ "item": "whether the four replicates are biological or technical",
"why": "Stated only as \"4 per condition\"." }
],
"body": {
"quant_readiness": "Four label-free MS1 intensity columns, one per replicate, two conditions - a design that supports a two-group comparison. What is missing is everything between the raw areas and a ratio: no normalisation, no missing-value policy, no multiple-testing correction, and contaminants still in the totals. None of that requires new instrument time; all of it changes the answer. As it stands the export supports a qualitative depth statement and no individual fold change.",
"normalisation": {
"method_as_stated": "none",
"judgement": "fail",
"note": "Required here and absent — Medians 1.24e6, 1.19e6, 1.01e6, 9.68e5 across DMSO_1, DMSO_2, STS_1, STS_2 - a 28% spread with both lower values on the treatment side. Median normalisation is the minimum; a variance-stabilising transform would be defensible given the 18.5-18.8 log2 dynamic range per column."
},
"missingness": {
"judgement": "fail",
"note": "High and intensity-dependent — 25,548 of 41,207 rows complete across all four columns (62.0%). The per-column missing share rises monotonically 26.9%, 27.5%, 30.2%, 32.1% along the treatment axis, which is dropout at the low-intensity end rather than random loss. Values were left as-is, so every test ran on a different subset and no test's degrees of freedom are what a reader would assume."
},
"design_review": [
{ "item": "replication",
"judgement": "concern",
"note": "Adequate in number, unstated in kind — 4 per condition, one measurement column each. Biological or technical is not stated and it decides what the result generalises to." },
{ "item": "statistical test",
"judgement": "fail",
"note": "Named, and unusable as configured — Two-sample t-test per protein over 6,214 proteins with correction stated as none: about 310 expected false positives at p < 0.05." },
{ "item": "contaminants",
"judgement": "concern",
"note": "Left in, and they carry intensity — 1,146 rows, 611 keratin. Inside the normalisation total, so they affect every other protein's ratio too." },
{ "item": "batch structure",
"judgement": "unassessable",
"note": "Not established — No acquisition order or date in the parameters, so drift cannot be separated from loading." }
],
"changes": [
{ "change": "median-normalise the four intensity columns, after removing contaminant rows",
"why": "Removes the 28% loading difference that is currently inside every ratio, and keeps keratin out of the total it is computed against.",
"effort": "cheap" },
{ "change": "require a value in at least 3 of 4 replicates per condition before testing",
"why": "Stops a one-against-four comparison being reported as a fold change.",
"effort": "cheap" },
{ "change": "apply Benjamini-Hochberg across the per-protein tests and report q-values",
"why": "6,214 uncorrected tests produce hundreds of false positives at any threshold worth quoting.",
"effort": "cheap" },
{ "change": "restrict the figure to proteins with two or more peptides",
"why": "A one-peptide group's fold change has no peptide-level replication behind it.",
"effort": "cheap" },
{ "change": "state the replicate kind and the acquisition order, or record them for the next run",
"why": "Decides what the comparison generalises to and whether drift is separable from loading.",
"effort": "moderate" },
{ "change": "re-acquire the affected conditions with a randomised injection order",
"why": "Drift and loading are confounded in this export and no reprocessing can separate them.",
"effort": "expensive" }
],
"min_reportable": "Without any of the changes: that 6,214 protein groups were identified across eight injections and MS1 areas were recorded for 62% of rows in all four channels - a depth statement, not a comparison. With the four cheap changes, all of which run on this export: a fold-change table with Benjamini-Hochberg q-values for the proteins that have two or more peptides and a value in at least three of four replicates per condition, described as label-free MS1 area with median normalisation and no imputation."
}
}
methods — write it up and deposit it
Run this last. The input is the same again, with goal set to what the text is for —
repository_deposition here, which is what turns the deposition checklist from a
courtesy into the leading block — and the question set to what you are actually writing.
{
"task": "methods",
"psms": "peptide,charge,precursor_mz,mass_error_ppm,rt,score,q_value,protein,description,decoy,mods,spectrum,Intensity_DMSO_1,Intensity_DMSO_2,Intensity_STS_1,Intensity_STS_2\nAAGVNVEPFWPK,2,672.8531,5.4,42.11,28.4,0.0004,P60709,Actin cytoplasmic 1,target,,ctrl01.12043,1.84e8,1.71e8,1.66e8,1.59e8\nLVNELTEFAK,2,575.3126,6.1,31.44,26.1,0.0006,P02768,Serum albumin,target,,ctrl01.10877,4.2e7,3.9e7,4.4e7,4.1e7\nSGGGGGGGLGSGGSIR,2,703.3402,5.8,18.92,22.7,0.0009,P04264,Keratin type II cytoskeletal 1,target,,ctrl01.07213,9.1e6,8.4e6,9.6e6,8.8e6\nVLDELTLARK,2,573.3346,6.4,26.70,19.8,0.0041,Q9NZ08,ER aminopeptidase 1,target,,ctrl01.09931,3.1e5,2.8e5,,\nLKEAETRAEFAERSVAK,3,635.0089,5.6,34.15,24.3,0.0012,P35579,Myosin-9,target,,ctrl01.11402,7.7e6,7.1e6,6.2e6,5.9e6\nMDSTEPPYSQKR,2,720.3355,7.2,22.48,17.4,0.0087,P0DP23,Calmodulin-1,target,Oxidation (M),ctrl01.08814,4.4e5,,3.9e5,\nSLGKVGTR,2,409.2325,6.9,9.87,14.2,0.0193,DECOY_P31946,Reversed sequence,decoy,,ctrl01.03318,,,,\nELISNSSDALDKIR,2,779.4045,5.1,29.03,25.6,0.0008,P07900;P07900-2,Heat shock protein HSP 90-alpha,target,,ctrl01.10164,2.2e7,2.0e7,2.4e7,2.3e7\nCDIDIRK,2,453.2231,6.6,7.41,12.9,0.0402,P02769,Serum albumin (bovine),target,Carbamidomethyl (C),ctrl01.02761,1.1e5,,,\nTGQAPGFTYTDANKNK,2,853.4192,5.9,25.66,21.9,0.0021,P00761,Trypsin (porcine),target,,ctrl01.09447,6.8e5,7.2e5,6.4e5,6.1e5\n",
"params": "Sample: HeLa whole-cell lysate, 4 replicates per condition (DMSO vs 100 nM staurosporine, 6 h)\nInstrument: Orbitrap Exploris 480\nAcquisition: DDA, top-20, 90 min gradient\nSearch engine: MSFragger 4.0 (FragPipe 21.1)\nDatabase: UniProt human reference UP000005640, 20428 entries, reversed decoys appended\nEnzyme: trypsin, up to 2 missed cleavages\nFixed modifications: carbamidomethyl (C)\nVariable modifications: oxidation (M), acetyl (protein N-term)\nPrecursor tolerance: 20 ppm\nFragment tolerance: 0.02 Da\nFDR: 1%, controlled at the PSM level with Percolator q-values\nDecoy strategy: reversed, concatenated\nProtein inference: razor peptides, single-peptide identifications retained\nQuantification: label-free, MS1 area (IonQuant)\nNormalisation: none\nMissing values: left as-is\nReplicates: 4 per condition\nStatistical test: two-sample t-test per protein\nMultiple testing: none\nContaminants: cRAP appended, not removed from the export\nDeposition: not yet deposited\nSoftware versions: FragPipe 21.1, MSFragger 4.0, Philosopher 5.1, IonQuant 1.10\n",
"goal": "repository_deposition",
"stage": "final_check",
"question": "drafting the methods section and starting a PRIDE submission - what do I still need before either can be finished?",
"prescan_facts": {
"table": { "rows_total": 41207, "rows_sent": 10, "sampled": true,
"quant_columns": 4, "protein_groups": 6214, "distinct_peptides": 28911 },
"settings": { "enzyme": "trypsin", "enzyme_stated": true, "tolerance_ppm": 20,
"q_threshold": 0.01, "fdr_level": "psm", "decoy_search": "concatenated",
"missed_allowed": 2, "min_peptides": 1, "replicates": 4,
"quant_method": "label-free (LFQ)" },
"fdr": { "decoy_source": "column", "strategy": "concatenated",
"claimed_threshold": 0.01, "claimed_level": "psm",
"at_threshold": { "targets": 40101, "decoys": 352, "concatenated": 0.017403 },
"rows_above_threshold": 754, "decoys_above_threshold": 604,
"decoy_status_unresolved": 0, "max_q": 0.191 },
"verdict": "not_reportable",
"severity_counts": { "blocking": 2, "high": 3, "medium": 4, "low": 1, "info": 2 },
"flags": [
{ "uid": "P01", "area": "fdr", "severity": "blocking", "base_severity": "blocking",
"mitigated_by": [], "row": null, "line": 11,
"title": "The decoys in the filtered list imply a higher error rate than the threshold claims",
"evidence": "2D/(T+D) over 40101 targets and 352 decoys at q <= 0.01",
"value": 0.017403, "threshold": 0.01 },
{ "uid": "P02", "area": "fdr", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 11,
"title": "Protein-level claims from a PSM-level error rate",
"evidence": "6214 protein groups, FDR stated at the PSM level",
"value": 6214, "threshold": null },
{ "uid": "P04", "area": "digestion", "severity": "medium", "base_severity": "medium",
"mitigated_by": [], "row": null, "line": 6,
"title": "Just under a third of peptides carry at least one missed cleavage",
"evidence": "12714 of 41013 peptides, up to 3 missed cleavages, 2 allowed",
"value": 0.31, "threshold": null },
{ "uid": "P06", "area": "inference", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 13,
"title": "Nearly a quarter of protein groups rest on a single peptide",
"evidence": "1483 of 6214 groups have one peptide; min_peptides = 1",
"value": 0.2387, "threshold": null },
{ "uid": "P08", "area": "quantitation", "severity": "high", "base_severity": "high",
"mitigated_by": [], "row": null, "line": 15,
"title": "Label-free intensities with normalisation stated as none",
"evidence": "Normalisation: none; column medians 1.24e6, 1.19e6, 1.01e6, 9.68e5",
"value": null, "threshold": null }
],
"unassessable": [
{ "item": "the transferred-identification error rate",
"why": "match-between-runs is not mentioned in the parameters" }
]
}
}
An abridged methods reply, with body shortened to the shape rather than
the full text (the full body for this lane is written out
above):
{
"lane": "methods",
"title": "HeLa staurosporine DDA - methods draft and deposition checklist",
"verdict": "revise",
"headline": "The methods paragraph can be written today from what the parameters state, but it has to quote the empirical 1.74% rather than the nominal 1%, and the submission is blocked on a repository accession and a statistical analysis record.",
"summary": "Twenty-one of the twenty-one items a reader needs are either stated or computable from this input, which is why a full paragraph is drafted below rather than a template with gaps. Three of them are not established and are written as omissions rather than filled in from convention: the LC gradient, the fragment-tolerance rationale and the rescoring configuration. The verdict is revise rather than reportable_with_caveats for one reason: the paragraph as it would naturally be written says 1% FDR, and the export's own decoys say 1.74%, so the sentence has to change before it goes anywhere. On the deposition side, two items are missing outright - the accession and the statistical analysis record - and two need a number rather than a description.",
"findings": [
{ "id": "F-001", "severity": "high", "area": "reporting",
"title": "The nominal threshold and the empirical rate disagree, and the paragraph must not quote the nominal one alone",
"detail": "The parameters state 1% at the PSM level. The export's surviving decoys give 1.74% under the concatenated estimator the same parameters imply. A methods paragraph that states 1% is not describing this file. This is high rather than blocking because the fix is a sentence: state both, or re-filter and re-count.",
"evidence": "parameters line 11 against 352 decoys among 40,453 rows at q <= 0.01",
"row": null,
"fix": "Write: filtered to a nominal 1% PSM-level FDR; the decoys surviving in the exported list correspond to 1.74% by the concatenated-database estimator." },
{ "id": "F-002", "severity": "medium", "area": "reporting",
"title": "No repository accession, and the dataset is not deposited",
"detail": "Stated as not yet deposited. Every journal in the field requires an accession and PRIDE issues one at submission, before review, so this is the item with the longest lead time and the least work in it.",
"evidence": "parameters line 21: \"Deposition: not yet deposited\"",
"row": null,
"fix": "Open the PRIDE submission now; quote the PXD accession in the paragraph as soon as it exists." },
{ "id": "F-003", "severity": "medium", "area": "reporting",
"title": "The statistical analysis has no recorded parameters",
"detail": "A two-sample t-test per protein is named with no software, no version, no script and no correction. That is not reproducible, and it is also the analysis the quant lane found unusable, so the record and the fix are the same piece of work.",
"evidence": "parameters lines 18-19 name the test and \"Multiple testing: none\"",
"row": null,
"fix": "Record the script or the tool and version, and the correction once it is applied." },
{ "id": "F-004", "severity": "low", "area": "reporting",
"title": "Three items are not established and are omitted rather than assumed",
"detail": "The LC gradient is given as \"90 min\" with no composition, the fragment tolerance has no stated rationale, and rescoring is never mentioned although Percolator q-values are, which implies it. Each is written as an omission in the draft. Convention would supply plausible values for all three and that is precisely what this lane will not do.",
"evidence": "coverage.never_mentioned includes rescoring, fragment_tolerance detail and gradient composition",
"row": null,
"fix": "Add the gradient composition, one line on the fragment tolerance, and the rescoring step - each is one sentence you already know." }
],
"reconciliation": [
{ "flag_uid": "P01", "status": "confirmed",
"note": "Confirmed, and in this lane it is a wording obligation rather than a re-analysis: the paragraph quotes 1.74% alongside the nominal threshold." },
{ "flag_uid": "P02", "status": "confirmed",
"note": "Confirmed. The reporting-table row for the FDR names the level explicitly, and limitations carries the consequence for the group count." },
{ "flag_uid": "P04", "status": "noted",
"note": "Noted and reported as a computed number in the reporting table. Nothing to write differently; a reader needs it to interpret the depth." },
{ "flag_uid": "P06", "status": "confirmed",
"note": "Confirmed, and it appears twice by design: as a computed reporting-table row and as a limitation sentence." },
{ "flag_uid": "P08", "status": "confirmed",
"note": "Confirmed as a reported row - normalisation: none - which is exactly what a repository wants recorded even though it is a finding elsewhere." }
],
"caveats": [
{ "area": "quantitation",
"fact": "No normalisation, no missing-value policy and no multiple-testing correction were applied.",
"why_it_matters": "The drafted paragraph therefore reports no fold changes and no significance, and says so. If the quant lane's changes are applied, this paragraph needs a new quantification sentence." }
],
"context_notes": [
{ "claim": "drafting the methods section and starting a PRIDE submission - what do I still need before either can be finished?",
"status": "honoured",
"note": "Both answered separately: the paragraph is drafted with three omissions marked, and the deposition checklist lists two missing items and two that need a number. The accession is the long-lead one; start it first." }
],
"unassessable": [
{ "item": "the raw file list",
"why": "The parameters describe the design but name no files, so the submission's file manifest cannot be drafted from this input." },
{ "item": "the exact database release",
"why": "UP000005640 with 20,428 entries is stated; the release date or checksum that makes it reproducible is not." }
],
"body": {
"methods_paragraph": "Peptides were identified with MSFragger 4.0 (FragPipe 21.1) against the UniProt human reference proteome (UP000005640, 20,428 entries) with reversed decoys appended to a single concatenated database ... [full text as documented above] ... The LC gradient composition, the fragment-tolerance rationale and the rescoring configuration are not established by the available parameters and are omitted rather than assumed.",
"reporting_table": [
{ "item": "Instrument", "value": "Orbitrap Exploris 480", "source": "reported" },
{ "item": "Empirical FDR in the exported list", "value": "1.74% at q <= 0.01 by 2D/(T+D)", "source": "computed" },
{ "item": "LC gradient composition", "value": "not reported", "source": "not_established" }
],
"deposition_checklist": [
{ "item": "Repository accession", "status": "missing",
"note": "Stated as not yet deposited; PRIDE issues it at submission." },
{ "item": "Exact database version string", "status": "needs_a_number",
"note": "Entry count is given; the release date or FASTA checksum is what makes it reproducible." },
{ "item": "Decoy strategy statement", "status": "present",
"note": "Reversed, concatenated - and it is the convention the empirical rate was computed under." }
],
"limitations": [
"The exported list is filtered at a nominal 1% PSM-level FDR and its own surviving decoys correspond to 1.74%.",
"1,483 of 6,214 protein groups rest on a single peptide."
],
"open_items": [
"Open a PRIDE submission and obtain the accession.",
"Re-filter and re-count at the stated threshold, or state 1.74% in the text."
]
}
}
Running all three lanes over one export
The normal shape of an API integration: price all three, run them in lane order, and stop when
ident comes back not_reportable — because a methods paragraph
written over a list that cannot be reported is a paragraph nobody can use. Note the
Idempotency-Key changing with the lane, which is what keeps the three runs distinct.
# Price all three, then run in lane order, stopping if ident blocks.
HASH=$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16)
for LANE in ident quant methods; do
BODY=$(INPUT="$INPUT" LANE="$LANE" python3 -c '
import json, os
b = json.loads(os.environ["INPUT"]); b["task"] = os.environ["LANE"]
print(json.dumps(b))')
# The lane is in the key: two lanes over one export are two runs.
KEY="psm-desk:$HASH:$LANE:a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')
for i in $(seq 1 240); do
RES=$(curl -sS "$BASE/job/$JOB" -H "Authorization: Bearer $TOKEN")
STATUS=$(printf '%s' "$RES" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["status"])')
case "$STATUS" in succeeded|failed) break ;; esac
sleep 1
done
VERDICT=$(printf '%s' "$RES" | python3 -c '
import json, sys
job = json.load(sys.stdin)["data"]
raw = job["output"]["output"]
r = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print("%s\t%s\t%s" % (r["lane"], r["verdict"], job["charged_credits"]))')
printf '%s\n' "$VERDICT"
case "$VERDICT" in
ident*not_reportable*)
echo "ident blocks - stopping before quant and methods"
break ;;
esac
done
def all_lanes(base_input, stop_on_block=True):
"""Price, then run ident -> quant -> methods, stopping if ident blocks."""
prices = {lane: call("estimate", dict(base_input, task=lane)) for lane in LANES}
total_hold = sum(p["hold_credits"] for p in prices.values())
print("holds:", {k: v["hold_credits"] for k, v in prices.items()},
"total", total_hold)
assert_can_afford(max(p["min_credits"] for p in prices.values()))
out = {}
for lane in LANES: # ident, quant, methods - in this order
body = dict(base_input, task=lane)
job = run(body) # Idempotency-Key carries the lane
review = review_of(job, body)
out[lane] = review
print(f"{lane:<8} {review['verdict']:<24} "
f"{job['charged_credits']:>6} credits {review['headline'][:70]}")
if stop_on_block and lane == "ident" and review["verdict"] == "not_reportable":
print("ident blocks: not running quant or methods over a list that "
"cannot be reported")
break
return out
reviews = all_lanes(INPUT)
# The three lanes are one document about one run. A blocking fact must appear at
# the same severity in all of them - this is the cross-lane check worth having.
def worst_severity(review, area):
order = ["blocking", "high", "medium", "low", "info"]
sevs = [f["severity"] for f in review["findings"] if f["area"] == area]
return min(sevs, key=order.index) if sevs else None
for area in ("fdr", "inference"):
grades = {lane: worst_severity(r, area) for lane, r in reviews.items()}
stated = {g for g in grades.values() if g}
assert len(stated) <= 1, f"{area} graded differently across lanes: {grades}"
async function allLanes(baseInput, { stopOnBlock = true } = {}) {
const prices = {};
for (const lane of LANES) prices[lane] = await call("estimate", { ...baseInput, task: lane });
console.log("holds", Object.fromEntries(
Object.entries(prices).map(([k, v]) => [k, v.hold_credits])));
await assertCanAfford(Math.max(...Object.values(prices).map((p) => p.min_credits)));
const out = {};
for (const lane of LANES) { // ident, quant, methods
const body = { ...baseInput, task: lane };
const job = await run(body); // the key carries the lane
const review = reviewOf(job, body);
out[lane] = review;
console.log(lane.padEnd(8), review.verdict.padEnd(24),
`${job.charged_credits} credits`, review.headline.slice(0, 70));
if (stopOnBlock && lane === "ident" && review.verdict === "not_reportable") {
console.log("ident blocks: stopping before quant and methods");
break;
}
}
return out;
}
const reviews = await allLanes(INPUT);
// One fact, three lanes, one severity. This is the cross-lane check worth having.
const ORDER = ["blocking", "high", "medium", "low", "info"];
const worstIn = (review, area) => {
const sevs = review.findings.filter((f) => f.area === area).map((f) => f.severity);
return sevs.length ? sevs.sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))[0] : null;
};
for (const area of ["fdr", "inference"]) {
const grades = Object.entries(reviews).map(([lane, r]) => [lane, worstIn(r, area)]);
const stated = new Set(grades.map(([, g]) => g).filter(Boolean));
if (stated.size > 1) throw new Error(`${area} graded differently: ${JSON.stringify(grades)}`);
}
// Price all three, run in lane order, stop if ident blocks.
func allLanes(in runInput) (map[string]*review, error) {
var totalHold int64
for _, lane := range lanes {
in.Task = lane
e, err := priceIt(in)
if err != nil {
return nil, err
}
totalHold += e.HoldCredits
fmt.Printf("%-8s hold %6d min %5d\n", lane, e.HoldCredits, e.MinCredits)
}
fmt.Println("total hold", totalHold)
out := map[string]*review{}
for _, lane := range lanes {
in.Task = lane
job, err := runAndPoll(in, 1) // idemKey(in, 1) carries in.Task
if err != nil {
return out, err
}
r, err := reviewOf(job, in)
if err != nil {
return out, err
}
out[lane] = r
fmt.Printf("%-8s %-24s %6d credits %.70s\n",
lane, r.Verdict, job.ChargedCredits, r.Headline)
if lane == "ident" && r.Verdict == "not_reportable" {
fmt.Println("ident blocks - stopping before quant and methods")
break
}
}
return out, nil
}
// One fact, three lanes, one severity.
func worstIn(r *review, area string) string {
order := []string{"blocking", "high", "medium", "low", "info"}
best := ""
for _, f := range r.Findings {
if f.Area != area {
continue
}
if best == "" {
best = f.Severity
continue
}
for _, s := range order {
if s == f.Severity {
best = f.Severity
break
}
if s == best {
break
}
}
}
return best
}
// Price all three, run in lane order, stop if ident blocks.
public static void main(String[] args) throws Exception {
if (System.getenv("SKILLSAFE_TOKEN") == null) mintGuest();
String psms = Files.readString(Path.of("psm.csv"));
String params = Files.readString(Path.of("search_params.txt"));
String question = "what can I report from this run?";
for (String lane : LANES) {
String est = call("estimate", buildInput(lane, psms, params, question), Map.of());
System.out.printf("%-8s %s%n", lane, between(est, "\"hold_credits\":", ","));
}
for (String lane : LANES) {
String input = buildInput(lane, psms, params, question);
String job = runAndPoll(input, lane); // the key carries the lane
if (job.contains("\"truncated\":true"))
throw new ApiError("TRUNCATED", "retry, do not repair");
// Inside output.output the JSON is escaped, so the needle is escaped too.
String verdict = between(job, "\\\"verdict\\\":\\\"", "\\\"");
System.out.printf("%-8s %-24s %s credits%n",
lane, verdict, between(job, "\"charged_credits\":", ","));
// ident is the gate: a not_reportable list is not worth writing up.
if (lane.equals("ident") && verdict.equals("not_reportable")) {
System.out.println("ident blocks - stopping before quant and methods");
break;
}
}
}
def all_lanes(base_input, stop_on_block: true)
prices = LANES.to_h { |lane| [lane, call("estimate", base_input.merge(task: lane))] }
prices.each { |lane, e| puts format("%-8s hold %6d min %5d", lane, e["hold_credits"], e["min_credits"]) }
assert_can_afford(prices.values.map { |e| e["min_credits"] }.max)
out = {}
LANES.each do |lane| # ident, quant, methods
body = base_input.merge(task: lane)
job = run(body) # idem_key(body) carries the lane
review = review_of(job, body)
out[lane] = review
puts format("%-8s %-24s %6d credits %s",
lane, review["verdict"], job["charged_credits"],
review["headline"][0, 70])
if stop_on_block && lane == "ident" && review["verdict"] == "not_reportable"
puts "ident blocks: stopping before quant and methods"
break
end
end
out
end
reviews = all_lanes(INPUT)
# One fact, three lanes, one severity.
ORDER = %w[blocking high medium low info].freeze
def worst_in(review, area)
sevs = review["findings"].select { |f| f["area"] == area }.map { |f| f["severity"] }
sevs.min_by { |s| ORDER.index(s) }
end
%w[fdr inference].each do |area|
grades = reviews.transform_values { |r| worst_in(r, area) }
stated = grades.values.compact.uniq
raise "#{area} graded differently across lanes: #{grades}" if stated.size > 1
end
<?php
function all_lanes(array $baseInput, bool $stopOnBlock = true): array {
$prices = [];
foreach (LANES as $lane) {
$prices[$lane] = call("estimate", array_merge($baseInput, ["task" => $lane]));
printf("%-8s hold %6d min %5d\n", $lane,
$prices[$lane]["hold_credits"], $prices[$lane]["min_credits"]);
}
assert_can_afford(max(array_column($prices, "min_credits")));
$out = [];
foreach (LANES as $lane) { // ident, quant, methods
$body = array_merge($baseInput, ["task" => $lane]);
$job = run_job($body); // idem_key($body) carries the lane
$review = review_of($job, $body);
$out[$lane] = $review;
printf("%-8s %-24s %6d credits %s\n", $lane, $review["verdict"],
$job["charged_credits"], substr($review["headline"], 0, 70));
if ($stopOnBlock && $lane === "ident" && $review["verdict"] === "not_reportable") {
echo "ident blocks: stopping before quant and methods\n";
break;
}
}
return $out;
}
$reviews = all_lanes($input);
// One fact, three lanes, one severity.
const ORDER = ["blocking", "high", "medium", "low", "info"];
function worst_in(array $review, string $area): ?string {
$sevs = array_column(array_filter($review["findings"],
fn($f) => $f["area"] === $area), "severity");
if (!$sevs) return null;
usort($sevs, fn($a, $b) => array_search($a, ORDER) <=> array_search($b, ORDER));
return $sevs[0];
}
foreach (["fdr", "inference"] as $area) {
$grades = array_map(fn($r) => worst_in($r, $area), $reviews);
$stated = array_values(array_unique(array_filter($grades)));
if (count($stated) > 1) throw new RuntimeException("$area graded differently across lanes");
}
static async Task<Dictionary<string, JsonElement>> AllLanes(
Func<string, object> buildInput, string psms, bool stopOnBlock = true)
{
long worstMin = 0;
foreach (var lane in PsmDesk.Lanes)
{
var e = await PsmDesk.Call("estimate", buildInput(lane));
Console.WriteLine($"{lane,-8} hold {e.GetProperty("hold_credits").GetInt64(),6} " +
$"min {e.GetProperty("min_credits").GetInt64(),5}");
worstMin = Math.Max(worstMin, e.GetProperty("min_credits").GetInt64());
}
await AssertCanAfford(worstMin);
var out = new Dictionary<string, JsonElement>();
foreach (var lane in PsmDesk.Lanes) // ident, quant, methods
{
var body = buildInput(lane);
var job = await RunAndPoll(body, lane); // IdemKey carries the lane
var review = ReviewOf(job, lane, psms);
out[lane] = review;
Console.WriteLine($"{lane,-8} {review.GetProperty("verdict").GetString(),-24} " +
$"{job.GetProperty("charged_credits").GetInt64(),6} credits");
if (stopOnBlock && lane == "ident" &&
review.GetProperty("verdict").GetString() == "not_reportable")
{
Console.WriteLine("ident blocks: stopping before quant and methods");
break;
}
}
return out;
}
var reviews = await AllLanes(BuildInput, psms);
// One fact, three lanes, one severity.
static readonly string[] Order = { "blocking", "high", "medium", "low", "info" };
static string? WorstIn(JsonElement review, string area)
{
var sevs = review.GetProperty("findings").EnumerateArray()
.Where(f => f.GetProperty("area").GetString() == area)
.Select(f => f.GetProperty("severity").GetString()!)
.OrderBy(s => Array.IndexOf(Order, s))
.ToList();
return sevs.Count > 0 ? sevs[0] : null;
}
foreach (var area in new[] { "fdr", "inference" })
{
var stated = reviews.Values.Select(r => WorstIn(r, area))
.Where(g => g is not null).Distinct().ToList();
if (stated.Count > 1)
throw new InvalidOperationException($"{area} graded differently across lanes");
}
The cross-lane check at the bottom of those samples is the one assertion that is not about a single
reply. One fact must be reported at the same severity in all three lanes: the lane decides
where a fact lands, never how bad it is. If fdr is
blocking in ident and medium in methods, one of
the two is wrong and the pair is worth failing on — it is also the cheapest possible detector for a
run that silently received a different input than you think it did.
What the browser does before you pay
Everything described so far costs credits. The web app does a substantial amount of work before it
asks for any, in the page, with no model call and no network request, and an API caller can either
reproduce it or simply send its output as prescan_facts and let the lane be held to it.
It is worth understanding either way, because it is what makes the reconciliation contract more than
a formality: these are facts, established by arithmetic and string comparison, that a model cannot
argue with.
Thirty reporting items, in three states
The free read resolves thirty items against the parameter block — the instrument, the acquisition mode, the gradient, the sample, the search engine, the database and its entry count, the enzyme, the missed cleavages, the fixed and variable modifications, the precursor and fragment tolerances, the FDR method, the decoy strategy, the threshold, the level, the minimum peptides per protein, the inference method, the rescoring, the quantification method, the normalisation, the missing-value handling, the replicates, the statistical test, the multiple-testing correction, match-between-runs, the contaminant handling, the deposition and the software versions. Each lands in exactly one of three states, and the distinction between the last two is the point of the whole exercise:
| state | means | where it goes |
|---|---|---|
stated | The parameters give a value. It is parsed, kept, and cross-checked against everything else that touches it. | counts toward coverage.answered |
none | The parameters explicitly say there is none — Normalisation: none, Imputation: none, Contaminants: none detected. This is a positive fact and it is the strongest thing a methods section can say about an absence. | coverage.stated_none |
missing | The parameters never reach the item. Nothing is known, in either direction. | coverage.never_mentioned |
Collapsing the last two into "missing" is the mistake this design exists to prevent. "No imputation
was applied" and "we did not think about imputation" are opposite statements about the same
decision, and a review that treats them alike reports a careful methods section as an incomplete
one. There is a fourth state the reader can also produce — contradictory, for a value
that carries both a none-word and a number, as in Imputation: none, 3 values replaced —
and it is never silently resolved to one of the two.
The reading rules matter as much as the states, because this is where a parameter parser normally
goes wrong. A none-word only counts when the value is the none-expression, so
Contaminants: removed, none remained reads as handled rather than as absent. Labels
match on a whole normalised token, so fdr_level cannot be answered by the text of
fdr_threshold and a compound label cannot be claimed by one of its parts. A value
wrapped onto indented continuation lines is joined before it is read, so a long modification list is
not truncated at the first newline. And a sentence-level match reports the line the sentence starts
on, which is why a line in a flag is a line you can find.
The error rate, recomputed under both conventions
The decoys are resolved from a decoy column if there is one, from accession prefixes if there is
not, and not at all if neither exists — and which of the three happened is reported as
fdr.decoy_source, because a rate computed from accession prefixes on an export whose
decoys are marked only in a column is a rate over nothing. Rows whose status cannot be read are
excluded from the arithmetic rather than assumed to be targets, and their count travels as
decoy_status_unresolved so a reply can say the rate is a lower bound.
Then both estimators are computed and both are reported: D/T for separate target and
decoy searches, 2D/(T+D) for one concatenated database. They differ by close to a
factor of two on the same counts, which is why the app never picks one silently — when the decoy
strategy is stated, the matching one is used for the comparison against the threshold and the other
is still shipped; when it is not stated, that absence is itself a flag and both numbers are
reported. The comparison against the claimed threshold runs only when a threshold and resolvable
decoys both exist, so it cannot fire on an assumption.
The digestion, recomputed from the sequences
Missed cleavages are counted from the peptide sequences against the stated enzyme's own rule —
trypsin with the proline exception, Lys-C, Arg-C, chymotrypsin, Glu-C, or no enzyme at all — and not
read from a column that may or may not exist. When the enzyme is not stated, trypsin is assumed
and the assumption is flagged, because almost every run uses it and none of them said so.
Peptide length comes from the sequence with modification tokens stripped, so
_(ac)AAGVNVEPFWPK is twelve residues rather than eighteen characters.
Modifications, and the check that must not run
The cysteine check is the one place this design is most obviously defensive. Most engines apply a
fixed modification silently and never write it into the reported sequence, so a run with
carbamidomethylation correctly configured can show zero carbamidomethyl tokens. Reporting that as
"every cysteine peptide is missing the fixed modification" is an inverted finding, and an inverted
finding at the top of a review corrupts everything a reader takes from it. So the check only runs
when the export demonstrably writes fixed modifications at all — at least one row carries the token
— and modifications.cys_check_assessable says whether it did. Decoy rows are excluded
from the residue denominators for the same class of reason: a reversed sequence's cysteines were
never in the sample, and counting them manufactures a search-configuration finding out of the decoy
database.
Contaminants, matched on both columns
cRAP classes are matched against the accession and the description together, because the
contaminant's name usually lives in the description: an export whose accession column holds a bare
P02768 and whose description says "Serum albumin" carries the commonest contaminant in
proteomics, and matching the accession alone finds nothing. contamination.matched_on
reports which columns were available, so a zero can be read correctly — as "none found in the
accessions" rather than as "none present".
The row sample, and why it is not every hundredth row
A forty-thousand-row export cannot be sent whole, and the way it is cut decides whether the review is about the run or about an accident. Rows are drawn by a golden-ratio low-discrepancy sequence over the row indices rather than by a fixed stride, because a PSM export is very often sorted — by protein, by scan, by charge — and a fixed stride resonates with that order: at a stride equal to the block size every drawn row comes from the same block, so the model is handed one protein's peptides and told it is looking at a run. The golden-ratio increment has no period to resonate with.
Two guarantees sit on top of the draw. Every protein group present in the table is represented where
the budget allows, so a lane never reviews a run having seen one protein. And the extreme
row of every check survives — the worst q-value, the largest absolute mass error, the
highest missed-cleavage count, the shortest and the longest peptide, and at least one decoy row if
any decoy exists — because a sample that drops the one bad row hands the model a clean list while
the browser is flagging a problem, which is the single most misleading thing the sampler could do.
The cut is announced in-band, with the real row count and the statement that every statistic in
prescan_facts was computed over all rows rather than over the sample.
All of it is free, all of it is deterministic, and none of it needs a token. The lanes exist for the judgements it cannot make: whether an incomplete digest matters for this claim, what to change, and what a sentence in a paper is allowed to say.
Truncation, retries and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused: it executes with a reduced output cap and comes back with truncated: true on
the finished job and on the streaming done event. What you hold then is a prefix — the
findings may be complete while reconciliation, context_notes and
body are missing or cut mid-string. In this app that is worse than an error, because
body is the last key: a prefix of an ident lane reads as a list of findings
with no trust_scope, and trust_scope is the block that says what may
not be reported. A prefix of quant is a set of complaints with no
min_reportable, and a prefix of methods is a methods paragraph with no
deposition checklist and no limitations — a paragraph someone will paste.
Check the flag before you treat a review as complete, and treat a truncation as a retry rather than a
repair. Send the same input with a concrete retry_note and the attempt suffix on the
Idempotency-Key incremented, so the new body is not a replay of the old key:
"retry_note": "The previous reply was truncated after findings[]. Return the same
findings, keep reconciliation complete for all five prescan uids, and shorten each
evidence_review note to one clause so body.trust_scope is reached."
The same route handles a reply that fails your own checks in the verification
list: a missing reconciliation entry, a verdict its findings contradict, a body
carrying another lane's keys, a row index past the end of the sample, a claim on both
sides of trust_scope. Name the defect in retry_note — it is obeyed exactly
— and bump the attempt. Do not append closing braces to truncated JSON; that produces something that
parses and is not what the model meant.
The cheaper prevention is to send less: fewer table rows moves the estimate more than anything else in the input, and a 150-row sample that keeps the extremes is a better input than a 600-row one that gets truncated on the way out. If a particular export truncates repeatedly at the same lane, halve the rows before you change anything else.
A last note on grounding, because it changes how you read a clean review. Every finding names the
row, the count or the parameter line that produced it, and nothing is invented — not a number, not a
threshold, not a row index, not a q-value for a peptide that is not in the sample. So an empty
findings array with a full unassessable array is not a pass; it is a
statement that the input did not contain enough to judge. Read the two together, and read
unassessable before you tell anyone the run is clean. And read
context_notes too: a contradicted entry there is the app telling you that
something you asserted is not what the export supports.