HelmsDeep — HTTP Endpoint Load Measurement System, Determining Each Endpoint's Performance — drives a stepped ramp of concurrent users against a single NCATS Translator component and reports the max sustainable concurrency (the "knee") — the highest load where the service still meets a latency/error SLO.
The Translator stack cascades ARS → ARAs → KPs, so a run targets exactly one layer at a time (testing a higher layer already loads everything beneath it). All three workflows are wired up: Retriever (KP), Shepherd (ARA), and the asynchronous ARS.
In plain language: HelmsDeep slowly turns up the number of simultaneous users hitting a service and watches when it starts getting too slow or too error-prone. The single headline number it reports — the knee, or "max sustainable concurrency" — is the largest number of simultaneous users the service handled while still staying fast enough and erroring rarely enough. Above that load, the service is overwhelmed. If you only read one thing, read the "How to read the results" section.
pip install -e .# Python >= 3.12; installs locust# Retriever (KP) — sync lookup queries, scalar parameters.tier per query
helmsdeep --targets kps \
--host https://your-retriever-service.example.org \
--csv-prefix run1
# Shepherd (ARA) — sync creative-mode (inferred) queries, cache bypassed
helmsdeep --targets aras \
--host https://your-ara-service.example.org \
--csv-prefix run1
# ARS — async submit/poll/merge of inferred queries (host = the ARS API base)
helmsdeep --targets ars \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix run1
# Pathfinder — its own heavier run type (ARA/ARS only); pins two endpoints and# asks for connecting paths. Sync via the ARA, async via the ARS.
helmsdeep --targets aras_pathfinder \
--host https://your-ara-service.example.org \
--csv-prefix pf1
helmsdeep --targets ars_pathfinder \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix pf1
# Mixed capacity profile (ARA/ARS only) — 2/3 inferred MVP1+MVP2, 1/3 Pathfinder# in one blended workload, ramped to 30 → 45 → 60 concurrent users and judged# against pass/fail acceptance checkpoints.
helmsdeep --targets aras_mixed \
--host https://your-ara-service.example.org \
--csv-prefix mix1
helmsdeep --targets ars_mixed \
--host https://ars.ci.transltr.io/ars/api \
--csv-prefix mix1--targetsselects the layer:kps(Retriever),aras(Shepherd),ars, the Pathfinder run typesaras_pathfinder/ars_pathfinder(ARA/ARS only), or the mixed capacity profilearas_mixed/ars_mixed(ARA/ARS only — see "Mixed capacity profile").--hostis required — the base URL of the target service. Forkps/arasthe/querypath is appended; forarsthe host is the API base and the tool uses/submitthen/messages/{pk}.--csv-prefixis optional; it falls back to theLOCUST_CSV_PREFIXenv var, then totrapi_run.--time-budget/--quickcompress a run to fit a wall clock — see "Shorter runs" below.
The load profile (users, spawn rate, duration) is driven by the StepLoad shape,
not by CLI flags — so there is intentionally no -u/-r/-t. The ramp and knee
threshold are per target in helmsdeep/config.py (stages and
p99_slo_ms), since cost profiles differ wildly by layer; edit them there.
An optional per-target cooldown_s inserts a quiet gap between stages: users
ramp to 0 so slow in-flight queries drain (counted under the stage that launched
them) before the next stage starts clean, instead of bleeding into it. It defaults
to 0 (cheap KP lookups don't bleed) and is set on the expensive ARA/ARS/Pathfinder
targets.
A full run is long on purpose: slow queries need long holds to accumulate enough
samples for a trustworthy percentile (ars_mixed takes ~70 minutes). When you
don't need that — checking a host, corpus, or config change end to end, or taking
a rough read — compress it:
# Smoke run: the whole ramp in about 10 minutes.
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --quick
# Or name your own budget: 30m, 1h, 90s, or plain seconds.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --time-budget 30m--quick is shorthand for --time-budget 10m. Both scale the durations —
stage holds, cooldowns, and the poll/timeout caps that gate how long one query may
run — by a single factor. The shape is untouched: same user counts, same
stages, same SLOs, same checkpoints. So --targets ars_mixed --time-budget 30m
still ramps 10 → 30 → 45 → 60 users and still judges all three checkpoints; each
stage just gets ~6 minutes instead of ~15.
The CLI prints the compressed plan before it starts, so you know what you're committing to:
Compressed run: 1h10m -> 30m02s (time scale 0.43), budget 30m00s
Stages: 10u x 4m17s -> 30u x 6m26s -> 45u x 6m26s -> 60u x 6m26s, 2m09s cooldown between
Per-query cap cut to 4m17s -- a query slower than that is recorded as a
timeout failure, so error rates are not comparable to a full run's.
What you give up. Two things, and they matter:
- Sample count, and with it the tail. A p99 over a handful of queries is noise. The mean and p50 hold up far better than p95/p99.
- What counts as a failure. The per-query caps shrink with everything else, so a query that would have finished in 4 minutes is a timeout when the cap is 2. Error rates — and therefore checkpoint verdicts — are not comparable to a full run's.
Every output is stamped with config.time_scale (1.0 = a full run), and the
printed summary leads with a warning banner. Don't quote a compressed run's knee
as a capacity result, and don't gate CI on its checkpoints.
Compression only ever speeds a run up: a budget above the target's natural
duration is ignored (kps already runs in 7 minutes, so --quick leaves it
alone). Stages won't shrink below a 30-second floor, so a budget too small to fit
them simply overruns — the CLI says so and prints the real projected duration.
Want each stage to get a specific amount of time? The budget is for the whole run, including cooldowns, so divide accordingly — or edit the per-target
stagesinconfig.pydirectly, which is the only way to change the shape (dropping the baseline stage from a mixed run, say, to give the three checkpoints 10 minutes each).
Written to the working directory by the standalone/master node:
<prefix>_stages.csv— one row per stage (overall metrics), including astage_startcolumn with the stage's wall-clock start time (ISO 8601 UTC)<prefix>_by_qtype.csv— one row per (stage, query type)<prefix>_summary.json— config (including whichtargetwas measured and thetime_scaleit ran at), all stages, and the chosen knee (pluscheckpointsfor targets that define them)<prefix>_checkpoints.csv— targets with acceptance criteria (aras_mixed/ars_mixed): one row per checkpoint with itsPASS/FAILverdict<prefix>_ars_health.csv— ARS only: per-stage health signals (see below)<prefix>_ars_queries.csv— ARS only: one row per logical query with its pk, the HTTP status of each step, the terminal ARS status, any intermediate (retried, non-fatal) errors it hit along the way, and a ready-made URL for pulling that query up (see below)<prefix>_ars_completion.csv— ARS only: one row per logical query with its end-to-end response time and whether it eventually finished — polled past themax_poll_sfailure threshold up tocompletion_max_poll_s(see below)<prefix>_report.html— Locust's native, self-contained HTML report (latency-over-time charts, request/failure tables). Open it in any browser. See the caveat under "How to read the results": its totals are blended across the whole run, so the authoritative ceiling is the knee insummary.json, not the HTML aggregate.- a printed summary table ending in the headline max sustainable concurrency
For a guide to interpreting every one of these — written for non-specialists — see "How to read the results" below.
Every other target characterizes one query class in isolation to find the knee. The mixed profile answers a different, operational question: can the system hold a target concurrency when both query classes arrive together, the way they do in production?
Corpus — one blended workload: 2/3 inferred MVP1+MVP2 creative queries and 1/3 Pathfinder. MVP1/MVP2 keep their relative
SHEPHERD_CORPUSweights (so retuning that mix carries over); only the 2:1 split is imposed.by_qtype.csvstill breaks the two classes out separately, so you can see which one drives the tail.Ramp — a short low-load baseline stage, then the three concurrency levels the profile exists to judge: 30 → 45 → 60 simultaneous users. The baseline exists so a failure at 30 can be read against a healthy reference (is 30 slow, or is everything slow?).
Verdict — each level carries pass/fail acceptance checkpoints instead of only feeding the knee:
Users Goal Criteria 30 sustain peak load p99 ≤ 300 000 ms and errors ≤ 1% 45 headroom above peak p99 ≤ 300 000 ms and errors ≤ 1% 60 no substantial failures under overload errors ≤ 5% (latency not judged — slowdown is expected here, failures are not)
The verdicts land in <prefix>_checkpoints.csv, in summary.json (checkpoints
plus a single checkpoints_passed boolean), and in the printed report:
ACCEPTANCE CHECKPOINTS
usr p99 bar err% bar verdict goal
30 184203 300000 0.4 1.0 PASS sustain peak load
45 291560 300000 0.9 1.0 PASS headroom above peak
60 402118 n/a 3.1 5.0 PASS no substantial failures under overload
RESULT: all 3 checkpoints met.
A missed checkpoint prints why (-> p99 331402ms > 300000ms) and makes the run
exit non-zero, so it can gate a CI/acceptance job. A checkpoint whose stage
recorded no completed requests is reported as NO DATA rather than a silent pass.
The knee is still computed and reported alongside, unchanged.
Checkpoints are a generic per-target feature (checkpoints in config.py), not
special-cased to this profile: any target can define them, and targets that don't
behave exactly as before. Edit the users, goals, and bars there — the ARA and ARS
variants carry the same three checkpoints so the two layers are directly
comparable.
Layering still applies:
ars_mixedfans out to the ARAs, so run one or the other — never both at once.
The ars target is asynchronous: each logical query is POST /submit → poll
GET /messages/{pk}?trace=y (every poll_interval_s, capped at max_poll_s,
default 15 min) until status is Done/Error → fetch GET /messages/{merged_pk}
and count fields.data.message.results. Latency is the wall-clock submit→terminal
time; one measurement is recorded per logical query. A single ars_query Locust
request event carries that full wall-clock, so Locust's native stats table shows
the true per-query time — the individual ars_submit/ars_poll/ars_merge calls
also appear there as per-step diagnostics (e.g. ars_merge times only the final
merge fetch, not the whole query), but aren't double-counted as the query time.
Because the ARS is what real users hit, the run also captures health signals to
flag silent downstream breakage, written to <prefix>_ars_health.csv and
summary.json (ars_health + a human-readable red_flags list):
- result-count variation (min/mean/max + coefficient of variation) across identical queries;
- zero-result
Donecount — by default aDonewith 0 results is treated as a failure (counts against the error rate and the knee) and flagged; the per-targetzero_result_is_failureflag can score it as a success instead (see "Is an empty answer a failure?"); - response size (merged-message bytes, mean/max);
- result drop under load — flagged when the mean result count falls sharply as concurrency rises across stages.
A terminal Done that carries 0 results is scored as a failure by
default: under load an empty answer set usually means a downstream agent silently
dropped out, which is exactly what the knee should catch. But that conflates a
silent break with a query whose answer set is legitimately empty, so it is a
per-target switch in config.py:
"zero_result_is_failure": True, # the default; set False to score only# transport/protocol outcomes as failuresWith False, only submit errors, an Error status, and Timeout count as
failures. Note this shifts more than the error rate: a failed request
contributes no latency sample, so a zero-result query that stops being a failure
starts feeding the percentile pool — mean, p99, and therefore the Little's-Law
concurrency all move too. The two policies give two different knees, which is the
point: run both to see how much of your ceiling is empty answers versus broken
transport.
Whichever policy is in force, the zero-result query is still counted in
ars_health.csv (zero_result_done), still raises a red flag, and still carries
the Done with 0 results note in the per-query debug log. The policy itself is
recorded in summary.json (config.zero_result_is_failure) and printed above the
ARS health table, so a run's numbers can't be read under the wrong assumption:
ARS HEALTH (per stage)
0-result 'Done' scored as: FAILURE (zero_result_is_failure=True)
The aggregate reports tell you that 3% of queries failed at 45 users. This file tells you which ones, and hands you what you need to go look at them: one row per logical ARS query, written for every query — successful, failed, or timed out.
| Column | What it's for |
|---|---|
query | Query id. Joins to ars_completion.csv on the same column. |
pk | The ARS primary key. Empty only when the submit never returned one. |
stage, users | Which stage the query is attributed to (the stage active when it reached its outcome — the same rule the per-stage stats use). |
qtype | Which corpus query it was (mvp1_heavy, pathfinder_drug_disease, …). |
submit_start | When it was submitted (ISO 8601 UTC) — line it up against service logs. |
latency_s | Wall clock from submit to terminal status. |
ars_status | Done / Error / Timeout / SubmitError / NoPK. |
failed | Whether it counted against the error rate. A Done with 0 results is failed=True under the default zero_result_is_failure policy — see below. |
submit_http, poll_http, merge_http | HTTP status of the submit, the last poll, and the merged fetch. Separates "the ARS said no" from "the HTTP call broke". |
polls | How many status polls it took — a large number on a slow query says it was genuinely working, not stuck. |
intermediate_error_count | How many non-fatal errors the query hit on the way to its outcome (0 = it ran clean). The poll loop retries through these, so they never decide ars_status — but they say a query only limped to Done. |
result_count, response_bytes | Size of the merged answer. |
intermediate_errors | Those same errors in words, tallied: poll HTTP 502 x3; merge HTTP 500. Covers polls that came back non-200 (poll request failed when there was no HTTP response at all), poll bodies that wouldn't parse, and a merged fetch that 500'd, went missing (Done without merged_version) or wouldn't parse. Empty on a clean query. |
error | Why it failed, in words — the terminal reason, empty on success (as opposed to intermediate_errors, which is the trouble on the way there). Also carries Done with 0 results when a target scores that as a success — the note is what makes the pk worth opening either way. |
message_url | Full URL to that query's message. Paste it into a browser or curl it. |
The printed summary also points straight at the failures, so you don't have to open the file to start:
FAILED QUERIES (12 of 380; first 5 shown, all in run1_ars_queries.csv)
stage 2 pathfinder_drug_disease [Timeout] pk=8f3c… -- no terminal status within 600s (last: Running)
https://ars.ci.transltr.io/ars/api/messages/8f3c…?trace=y
Intermediate errors get their own line, because most of the queries carrying them succeeded — they never appear in the failed block, and a run that only limped to green otherwise reads as clean:
INTERMEDIATE ERRORS: 34 of 380 queries hit retried, non-fatal errors (intermediate_errors column in run1_ars_queries.csv)
stage 4 mvp1_heavy [Done] pk=a01f… -- poll HTTP 502 x3
Useful slices, once you have the file:
# Every failed query, as a table
awk -F, 'NR==1 || $9=="True"' run1_ars_queries.csv | column -s, -t
# The pks of everything that timed out at the top stage
awk -F, '$8=="Timeout" && $4==60 {print $2}' run1_ars_queries.csv
# Slowest queries, with their URLs
sort -t, -k7 -gr run1_ars_queries.csv | head -5 | cut -d, -f2,7,8,19
# Queries that succeeded but hit non-fatal errors on the way
awk -F, 'NR==1 || ($14+0 > 0 && $9=="False")' run1_ars_queries.csv | column -s, -tOn the relationship to ars_completion.csv: this file records what happened
within the measurement window (a query not terminal by max_poll_s is a
Timeout row here, which is exactly how the knee scores it). The completion
sidecar separately answers whether that same query eventually finished. Join
them on query. A timed-out query whose extended poll was still running at
shutdown appears here but not there — which is precisely the query you most
want the pk of.
max_poll_s (default 6 min for ars) is the failure threshold: a query that
hasn't reached a terminal status by then is recorded as a Timeout failure in the
main stats and the knee — unchanged. But a timed-out query isn't necessarily
stuck; the ARS may still finish it, just slowly. To capture that, once a query
blows max_poll_s a background poller keeps watching it up to
completion_max_poll_s (default 10 min) and records the outcome to
<prefix>_ars_completion.csv — one row per logical query:
total_response_s— true end-to-end submit→terminal time (up tocompletion_max_poll_s);finished— whether it ever reached a terminal status (DoneorError);within_slo— whether it finished insidemax_poll_s(i.e. was not a Timeout failure in the main stats); a slow-but-eventually-done query isfinished=True, within_slo=False;status— the terminal status (Done/Error/Timeout/SubmitError/NoPK).
summary.json carries a completion roll-up (finished, finished-within-SLO,
finished-after-SLO, never-finished, submit-failed), and two red flags surface the
key distinctions: queries that exceeded the SLO but did finish (slow, not
stuck) and queries that never finished even within completion_max_poll_s.
This separates slow from broken — a service that eventually answers every
query is degraded differently than one that drops them. Set
completion_max_poll_s equal to (or omit it, defaulting to) max_poll_s to turn
extended tracking off; the sidecar then just marks each timed-out query
finished=False. Any query still being polled when the test ends is drained for a
bounded window at shutdown; ones still unfinished after that are absent from the
file.
This section is for anyone — technical or not — who has a run's output files in hand and wants to answer one question: how much load can this service take?
A few terms show up everywhere in the outputs. Here's what each one means, without the jargon:
| Term | What it means |
|---|---|
| Stage | One step of the ramp. Each stage pins a fixed number of simultaneous users for a fixed time, then the next stage adds more. The run climbs stage by stage until the service struggles. |
| Users / concurrency | How many simultaneous users are hammering the service. "Users" is what we asked for in a stage; concurrency is the effective number actually in flight, computed from the measured throughput and speed. |
| RPS | Requests Per Second — how many queries the service actually completed each second during that stage. Higher is faster. |
| Latency | How long one query took, in milliseconds (1000 ms = 1 second). |
| mean / p50 / p95 / p99 | Different ways to summarize latency. mean is the average. p50 (median) is the typical query. p95 / p99 are the slow tail: "95% (or 99%) of queries were at least this fast." p99 is the one we hold to a standard, because it captures the bad experiences, not just the average. |
| Error rate | The fraction of queries that failed (e.g. 0.02 = 2%). For the ARS, a query that finishes but returns zero answers also counts as a failure by default — a per-target flag can change that, and the run says which policy applied. |
| SLO | Service Level Objective — the line in the sand for "acceptable." Here it's a p99 latency cap (e.g. 60000 ms = 60 s) plus a max error rate (default 1%). A stage "passes" only if it stays under both. |
| Checkpoint | A pass/fail question asked of one specific load level ("does 30 simultaneous users still work?"), with its own latency/error bars. Only the mixed capacity profile defines these; other runs just report the knee. |
| Knee | The highest-load stage that still passes the SLO. It's the headline result: the most simultaneous users the service handled while staying fast enough and reliable enough. Past the knee, things fall apart. |
Open <prefix>_summary.json first. Two fields tell you almost everything:
max_sustainable_concurrency— the headline number. This is the answer to "how much can this service take?"knee— the stage that number came from (its users, latency, error rate, etc.). This is the last healthy stage of the ramp.
If knee is null (and max_sustainable_concurrency is empty), the service
failed even at the lightest load — it was already too slow or too error-prone
in the very first stage. That's a strong signal something is wrong (or the SLO is
set tighter than the service can ever meet).
The same file also echoes the config that produced the run (which target,
which endpoint, the SLO, the ramp stages) so a result is self-documenting.
On a run with acceptance criteria (aras_mixed / ars_mixed), two more fields
answer the pass/fail question directly: checkpoints_passed (one boolean for
the whole run) and checkpoints (per-level verdicts and why). See
"Mixed capacity profile".
<prefix>_stages.csv has one row per stage and shows the service degrading as
load climbs. Here's an illustrative example (SLO: p99 ≤ 60 000 ms, errors ≤ 1%):
| stage | users | rps | mean_ms | p99_ms | error_rate | concurrency |
|---|---|---|---|---|---|---|
| 1 | 5 | 4.8 | 1 040 | 2 100 | 0.000 | 5.0 |
| 2 | 10 | 9.1 | 1 090 | 3 400 | 0.000 | 9.9 |
| 3 | 20 | 17.0 | 1 170 | 9 800 | 0.004 | 19.9 |
| 4 | 40 | 22.0 | 1 800 | 41 000 | 0.008 | 39.6 ← knee |
| 5 | 80 | 19.0 | 4 200 | 78 000 | 0.140 | — |
How to read it:
- Top to bottom = more load. Each stage adds users.
- Watch two columns:
p99_msanderror_rate. As long as both stay under the SLO (here 60 000 ms and 0.01), the service is coping. - The knee is the last "green" row — stage 4 above. Its
concurrency(≈ 40) is the headline number. - The row after the knee shows the cliff: at stage 5, p99 latency blew past the 60 s cap and the error rate jumped to 14%. Notice RPS actually dropped (19 vs 22) even though we added users — a classic sign the service is saturated and thrashing, not going faster.
A healthy run shows latency rising gently and errors near zero until a clear knee, then a sharp cliff. If the very first row already breaks the SLO, you get no knee (see above).
Why not just trust Locust's own totals? During a ramp, an overall average would blend the easy early stages with the saturated late ones and hide the knee. That's why HelmsDeep reports per stage. (It's also why the HTML report's blended totals aren't the official number — see below.)
<prefix>_by_qtype.csv breaks each stage down by type of query (e.g. simple
single-hop lookups vs. heavier multi-hop or "what treats this disease?"
queries). Use it to answer "what's slowing things down?" — often one query
shape saturates long before the others, and that's where to focus.
The ARS is what real users actually hit, and it can fail silently — a query
can come back "successful" but with fewer answers than it should, or zero.
<prefix>_ars_health.csv and the red_flags list in summary.json exist to
catch exactly this. In plain terms they flag things like:
- Answers quietly disappearing under load — the average number of results per query drops sharply as concurrency rises (something downstream is dropping out).
- "Done" but empty — queries that finished but returned zero answers (counted as failures).
- Wild result-count swings — identical queries returning very different amounts, a sign of instability.
If red_flags is non-empty, the service may be degrading in a way the raw
latency/error numbers alone wouldn't reveal — read those flags.
This is Locust's built-in report. Just double-click it to open in a browser. It gives you nice visuals: response-time and request-rate charts over the whole run, a table of every request type, and a list of any failures. It's great for a quick visual feel and for sharing a screenshot.
Important caveat: the HTML report's headline totals are blended across the entire run — it averages the gentle early stages together with the overwhelmed late ones. That makes its single "average response time" / "total RPS" numbers not the right ones to quote. For the official ceiling, always read the knee in
summary.json(and the per-stage story instages.csv). Think of the HTML as the pictures, andsummary.json/stages.csvas the verdict.
Swap the CURIEs. The corpus in
helmsdeep/trapi_corpus.pyuses a few real MONDO/CHEBI entities; replace them with entities your target service actually knows about, or queries return empty and won't reflect real cost.Tier is per query (Retriever only). Retriever exposes
parameters.tier(0 or 1) to pick its backend graph.RETRIEVER_CORPUSpairs multi-hop shapes with tier 0 and single-hop shapes with tier 1. ARA queries carry no tier.Shepherd/ARS send inferred + bypass_cache, mixing MVP1 and MVP2.
SHEPHERD_CORPUS(also used forars) holds creative-mode queries (knowledge_type: "inferred",bypass_cache: true) split evenly between two Translator templates, with entities varied per request to spread load and avoid cache-warming.by_qtype.csvbreaks out latency per template/tier.- MVP1 — "what treats disease X?" (
chemical -[treats]-> disease): the pinned disease is sampled from size-tiered pools (heavy/medium/light), so cost tracks answer-set size. Heavy is a curated list of common disease hubs; the long-tail pool is ~1000 real MONDO CURIEs incurie_list.json. - MVP2 — chemical⇄gene "affects" (
biolink:affects,inferred, withobject_aspect/object_directionqualifiers): both edge directions (chemical→gene and gene→chemical), with the gene (curatedGENESpool) and qualifier combo varied per request.
These are far heavier than KP lookups, so the
arastarget ships a gentler ramp and a looserp99_slo_ms(seeconfig.py). Tune the per-template weights and entity pools (HEAVY_DISEASES,GENES, the tiers) to your real traffic.- MVP1 — "what treats disease X?" (
ARS reuses the Shepherd corpus (
ARS_CORPUS = SHEPHERD_CORPUS) — the same inferred query the ARS fans out to its ARAs. Its poll cadence and per-query timeout (poll_interval_s,max_poll_s) are tunable inconfig.py.Pathfinder is its own run type (
aras_pathfinder/ars_pathfinder, ARA/ARS only).PATHFINDER_CORPUSsends a single drug↔disease shape that pins two endpoints and asks for connecting paths (apathsmap in the query_graph, notedges); the(chemical, disease)pair varies per request from a curatedCHEM_DISEASE_PAIRSlist — swap these for pairs your service knows, and keep them plausibly connected so paths come back non-empty (on ARS a zero-resultDoneis a failure by default). It's the heaviest query class, so its targets ship the gentlest ramps and loosest SLOs (and, forars_pathfinder, the longestmax_poll_s); tune them inconfig.py.The mixed profile blends the two ARA/ARS classes (
aras_mixed/ars_mixed).MIXED_CORPUSis computed fromSHEPHERD_CORPUS+PATHFINDER_CORPUSat theINFERRED_PATHFINDER_RATIO(2:1), so it tracks any retuning of the inferred mix. Change the blend by editing that ratio; change what counts as a pass by editing the per-targetcheckpointsinconfig.py. Because 1/3 of the mix is Pathfinder — the heaviest class — and p99 is a tail statistic, both mixed targets inherit the looser Pathfinder SLO.aras_mixedalso raisesrequest_timeout_sabove the default 210 s: a sync target whose p99 SLO sits near the HTTP timeout records slow queries as client timeouts (errors) instead of latencies.Adjust corpus weights in
RETRIEVER_CORPUS/SHEPHERD_CORPUSto match your traffic mix.
You can also invoke the locustfile without the CLI (defaults to the kps
target):
LOCUST_CSV_PREFIX=run1 \
locust -f helmsdeep/trapi_loadtest.py --headless \
--host https://your-retriever-service.example.org \
--html run1_report.htmlThe output-file prefix comes from the LOCUST_CSV_PREFIX env var (locust has no
--csv-prefix flag). Set LOADTEST_TARGET to choose a different (implemented)
layer. Add --html <name>.html yourself to get the HTML report (the helmsdeep
CLI passes this for you automatically, as <prefix>_report.html).