Guidance for working in this repo. Keep claims here grounded in the actual code — if you change behavior, update this file.
HelmsDeep (HTTP Endpoint Load Measurement System, Determining Each Endpoint's
Performance) measures, for each NCATS Translator service, the maximum
sustainable concurrency — how many concurrent users the service can feasibly
handle before it violates a latency/error SLO. The Translator stack has three
classes of service (Retriever/KPs, Shepherd/ARAs, and the ARS), each
of which speaks a slightly different protocol and expects a different kind of
TRAPI query. This tool sends each component the query type it expects and reports
a single headline number per run: the knee.
The architecture below is now implemented: all three layers (KPs/ARAs/ARS) are runnable through a real
helmsdeepCLI, config is per-target, and the corpus is segmented per component. A few refinements remain — see Status & what's left. Check the Current code map for exactly what each file does before assuming behavior.
A run drives a stepped ramp of concurrent users (the per-target stages
table) and, for each stage, records RPS, mean/p50/p95/p99 latency, and error
rate. The knee is the highest stage where both:
p99 <= P99_SLO_MS(per-targetp99_slo_ms; e.g. 60 000 ms for KPs, larger for the slower ARA/ARS layers), anderror_rate <= MAX_ERROR_RATE(shared, default 0.01 = 1%)
The knee's effective concurrency — computed via Little's Law,
concurrency = rps * mean_latency_seconds — is the deliverable: the max
sustainable concurrency for that service.
Implementation: _stage_stats() computes per-stage metrics and the Little's-Law
concurrency; the knee-selection loop lives in on_test_stop() in
helmsdeep/trapi_loadtest.py. If no stage meets the SLO, the service
saturated below the first stage (or the SLO is too strict).
Each component class has its own (a) endpoint + protocol and (b) expected query subset. The tool must route the correct corpus + protocol per target.
| Component | Role | Protocol it expects | Query it expects |
|---|---|---|---|
| Retriever / KP | Knowledge Provider (one service: Retriever) | sync POST /query (TRAPI) |
lookup-mode queries; set parameters.tier to 0 or 1 (see below). Cheapest layer. |
| Shepherd / ARAs | Reasoning agents | sync POST /query (or /asyncquery) |
inferred/creative-mode queries. Expensive. |
| ARS | Autonomous Relay System | async: POST /submit → poll GET /messages/{pk} until Done/Error → fetch merged results |
inferred query; very long-running (minutes–~1 hr). |
Pathfinder is an additional, heavier query class for the ARA and ARS layers
only (never KPs). It pins two endpoint entities and asks for connecting
paths via a paths map in the query_graph (not edges; no knowledge_type). It
gets its own run types — aras_pathfinder (sync, via the ARA /query) and
ars_pathfinder (async, via the ARS submit/poll/merge) — so the single-class
targets never mix it into the inferred corpus. Same protocols/endpoints as
aras/ars, just a different corpus + a gentler ramp / looser SLO.
The one place the classes are combined is the mixed capacity profile
(aras_mixed/ars_mixed): a deliberate 2/3 inferred + 1/3 Pathfinder blend that
asks whether the system holds a target concurrency under the workload mix
production actually sends, rather than characterizing one class in isolation.
In the current system the KP layer is a single service, Retriever — not the
~40+ independent KP endpoints of the old architecture (those survive only in git
history; see Reusable assets). Retriever exposes a
message["parameters"]["tier"] field that selects the backend graph it queries:
- Tier 0 — backend graph that can handle arbitrary multi-hop queries.
- Tier 1 — backend graph that handles mostly single-hop queries.
So when characterizing Retriever, tier is a first-class load dimension: a Tier 0
multi-hop query is a heavier cost profile than a Tier 1 single-hop. A KP corpus
should set tier deliberately per query (and pair multi-hop shapes with Tier 0,
single-hop shapes with Tier 1) rather than sending one fixed value for everything.
The stack cascades: ARS → ARAs → KPs. A query to the ARS fans out to the ARAs, which fan out to the KPs. So load-testing the ARS already exercises everything downstream.
Therefore the tool hits one component class per run, mutually exclusively — never all layers at once. Running an ARA test and an ARS test at the same time would double-load the ARAs (and the KPs beneath them) and corrupt both measurements. The operator picks the single layer they want to characterize; the lower layers are loaded only as a side effect of that one run. There is no "test everything simultaneously" mode, and there should never be one.
The ARS contract is fundamentally different from KP/ARA: it is asynchronous
(submit → poll → merge), not a single blocking POST /query. Any ARS support must
model that submit/poll loop rather than reuse the sync request path.
target registry → per-component dispatch → Locust step-load engine → per-service report
(config.TARGETS: (TRAPIUser picks protocol (stages ramp, knee (stages.csv,
endpoint/protocol/ + corpus for the chosen detection — shared by_qtype.csv,
corpus/stages/SLO) layer via LOADTEST_TARGET) across layers) summary.json[, ars_health])
The Locust step-load engine in trapi_loadtest.py is the reusable core and
is shared across layers. What varies by component is only (1) the request
protocol (sync POST vs ARS submit/poll/merge) and (2) which corpus subset is
sent (lookup for KPs, inferred for ARAs/ARS) — both selected from
config.TARGETS by the LOADTEST_TARGET the CLI sets.
Package helmsdeep/:
config.py— the target registry.TARGETSmaps each--targetsvalue (kps/aras/ars, the Pathfinder run typesaras_pathfinder/ars_pathfinder, and the mixed capacity profilearas_mixed/ars_mixed) to a component config:label,endpoint,corpus(key into the corpus module),protocol(sync/async),implemented, and per-targetstages+p99_slo_ms+ an optionalcooldown_s(a quiet gap between stages — users ramp to 0 so slow in-flight queries drain into the stage that launched them instead of bleeding into the next; defaults to 0, set on the expensive ARA/ARS/Pathfinder targets). Async targets (ars,ars_pathfinder) addmessages_endpoint,poll_interval_s,max_poll_s, and an optionalcompletion_max_poll_s(>=max_poll_s; the extended cap for the completion sidecar — how long a background poller keeps watching a timed-out query to see if it eventually finishes; defaults tomax_poll_s= off) and an optionalzero_result_is_failure(whether a terminalDonecarrying 0 results scores as a failure; defaults toTrue). The*_pathfindertargets reuse the ARA/ARS endpoints + protocols but point at thepathfindercorpus and ship a gentler ramp + looser SLO (pinned-two-endpoint path-finding is the heaviest query class). The*_mixedtargets add two more optional fields:request_timeout_s(per-HTTP-call timeout, default 210; raised onaras_mixedso its 5-min p99 SLO is measurable instead of landing as client timeouts) andcheckpoints— a list of pass/fail acceptance criteria (users,goal,p99_slo_ms,max_error_rate) evaluated against the stage that ran that many users.MAX_ERROR_RATEis shared;DEFAULT_TARGET="kps". Per-target ramps/SLOs live here because cost profiles differ wildly by layer:kpsholds each stage 5 minutes with a 60s cooldown because Retriever answers in seconds to tens of seconds, not the milliseconds the old ~40-KP layer did -- at the 60s holds it used to carry, every stage tripped both measurement-quality flags (see_stage_quality).build_timeline(stages, cooldown_s)lays the ramp out on a wall clock as(start, end, stage_idx_or_None)windows (None= a cooldown gap); theStepLoadshape drives users from it, and the live console uses it only for the run's total duration (its stage identity comes from the collector -- seeconsole.Dashboard._phase).natural_duration_s()andtime_scaled(cfg, budget_s)implement--time-budget/--quick:time_scaledreturns a(compressed_cfg, scale)pair whose durations (stage holds,cooldown_s,request_timeout_s,max_poll_s/completion_max_poll_s/poll_interval_s) are scaled to fit the budget, with per-knob floors (MIN_HOLD_Setc.) and spawn rates raised so a short stage still spends its time at load rather than climbing to it. The shape — users, SLOs, checkpoints — is never touched, and a budget above the natural duration is a no-op (scale 1.0): it only ever speeds a run up.cli.py— thehelmsdeepentry point (registered insetup.pyconsole_scripts). Parses--targets(required, one layer),--host(required),--csv-prefix, the mutually exclusive--time-budget DURATION/--quick(=--time-budget 10m), and the two output flags--no-live(setsHELMSDEEP_LIVE=0) /--verbose; rejects not-yet-implementedtargets; setsLOADTEST_TARGET(+LOCUST_CSV_PREFIX,HELMSDEEP_TIME_BUDGET_Swhen a budget is given) and launchespython -m locust -f trapi_loadtest.py --headless --host …. Unless--verbose, it adds--only-summary --loglevel WARNING: Locust's 2-second request table and its ramp narration are exactly the noise the live display replaces (Locust still prints its final tables at shutdown)._print_plan()shows the (possibly compressed) ramp and what was traded away before the run starts;_duration()parses600/90s/10m/1h30m.
console.py— the live terminal display. Purely cosmetic and strictly read-only: every number comes from the run'sStageCollectorand its_stage_stats, so the screen can't drift fromstages.csv, and an exception in the render loop is caught and logged rather than failing the run.Dashboard— spawns a gevent greenlet that redraws twice a second._phase()answers "stage N, holding or draining, how far in" from the collector'sstage_idx/stage_started/stage_ended, never from elapsed time: the shape ticks off Locust's runner clock and the display offtest_start, so a clock-derived stage disagreed with the measurements for a second or so at every boundary — exactly when the display has something to say._footer_lines()renders the block (run progress, stage progress, live load/latency vs the SLO, plus anarsline for async targets);_announce()/_recap()write the scrolling record — a header when a stage starts, a✓/✗verdict line (the knee test on one stage) when it ends. Since a new stage can only appear aftermark_stageclosed the previous one, the previous stage's verdict is always final and always printed immediately before the next header: the transcript order is fixed, not a race.StickyFooter— the bottom-of-screen block.install()wraps stdout/stderr (and re-points the logging handlers that captured them at setup time) in a proxy that erases the block before any other write, so prints and log lines scroll above it instead of colliding with it.sparkline(values, threshold, paint)— the shape of a metric across the ramp, one character per stage, red where the stage brokethreshold; the summary prints one for p99 and one for the error rate.live_enabled()/color_enabled()— a non-TTY stdout (CI, a pipe) orHELMSDEEP_LIVE=0drops to plain mode: the same content as one status line everyPLAIN_EVERY_S.NO_COLORdrops colour only.
trapi_loadtest.py— the measurement engine (Locust). The component under test is chosen byLOADTEST_TARGET, and whenHELMSDEEP_TIME_BUDGET_Sis set the target config is passed throughconfig.time_scaledat import (module-levelTIME_SCALE, stamped intosummary.jsonasconfig.time_scaleand printed as a warning banner) so everything downstream reads the compressed values;ENDPOINT,CORPUS,STAGES,P99_SLO_MS, and the async knobs (PROTOCOL,MESSAGES_PATH,POLL_INTERVAL_S,MAX_POLL_S) plusCOOLDOWN_Sare all derived fromconfig.TARGETS[...].StepLoad(LoadTestShape)— drives thestagesramp and marks the active stage. WhenCOOLDOWN_Sis set it inserts a drain gap between stages (tick()returns 0 users in the gap and callsCOLLECTOR.end_active_stage()to freeze the just-finished stage's end time); an@events.initlistener setsstop_timeout = REQUEST_TIMEOUTso a slow in-flight query finishes rather than being killed when users ramp to 0.StageCollector/COLLECTOR— buckets every completed request into the stage active when it finished (per-stage, per-qtype). Also holds the two per-query ARS lists (queries,completions) and hands out the sharednew_query_id()that joins them. Records each stage's wall-clockstage_started/stage_ended(the latter issetdefault, so a cooldown freeze isn't overwritten by the nextmark_stage).record()also takes optional ARS signals:status,result_count,response_bytes.begin_inflight()/end_inflight()track logical queries currently running, for the live display only -- no report reads them._stage_stats()— per-stage RPS, percentiles, error rate, Little's-Law concurrency._ars_stage_health()— per-stage ARS health row.TRAPIUser(HttpUser)— dispatches onPROTOCOL:_run_sync()(KP/ARA: one blockingPOST) or_run_ars()(submit → poll/messages/{pk}withgevent.sleepuntilDone/Error/timeout →_fetch_merged()countsfields.data.message.resultsand returns its HTTP status). OneCOLLECTOR.recordper logical query, plus oneCOLLECTOR.record_querydebug row (_record_query) carrying thepk, the submit/poll/merge HTTP codes, the poll count, the terminal ARS status, aQueryIssuestally of the intermediate (retried, non-fatal) errors hit along the way (intermediate_error_count+intermediate_errors), and amessage_url— written on every terminal path includingSubmitError/NoPK/Timeout. When a query blowsMAX_POLL_S(already recorded as a Timeout failure — main stats unchanged),_run_arsspawns a detached_extended_pollgreenlet that keeps polling toCOMPLETION_MAX_POLL_Sand appends oneCOLLECTOR.record_completionrow (end-to-end time + whether itfinished); queries that finish withinMAX_POLL_Srecord their completion row inline._stage_quality(row)— the two conditions that make a stage's row untrust- worthy however correct its arithmetic: fewer thanMIN_P99_SAMPLES(100) completed requests, so the p99 rests on the two slowest and moves with one outlier; and Little's-Law concurrency belowMIN_CONCURRENCY_RATIO(0.5) of the stage's user count, meaning most users were blocked on queries that finished in the next stage (we bucket by finish time), inflating its tail and deflating this one. Each issue is{kind, detail}(thin_samples/in_flight_bleed) so the printed remedy names only the fix that applies and a script can branch without parsing prose.on_test_stopcollects these intostage_warnings+knee_unsupportedin the summary, a printed MEASUREMENT QUALITY block, and a caveat on the final headline when the knee rests on a flagged stage._evaluate_checkpoints()— for targets that configurecheckpoints, judges each one against the stage matching its user count and returns aPASS/FAIL/NO DATAverdict (a checkpoint'sp99_slo_msdefaults to the target's; explicitNonemeans latency isn't judged — an overload probe where slowdown is expected but failures aren't)._start_dashboard()(@events.test_start) /DASHBOARD— starts theconsole.Dashboardon the master/standalone node;on_test_stoprecaps the final stage and lifts the footer before printing the summary._stash_headline()+_print_headline()(@events.quit) repeat the knee (or the missed checkpoints) after Locust's own end-of-run tables, so the number the run exists to produce is the last thing on screen.on_test_stop()— drains any in-flight completion greenlets (bounded), finds the knee, writesstages.csv(incl. astage_startISO-8601-UTC column) /by_qtype.csv/summary.json, (checkpointed targets only)checkpoints.csv+checkpoints/checkpoints_passedin the summary and a printed verdict block that setsenvironment.process_exit_code = 1on any miss, prints the stage table plus twoconsole.sparklineshape rows (p99 and error rate across the ramp, red where a stage broke its bar), and (async only)ars_health.csv, thears_queries.csvper-query debug log + a printed "FAILED QUERIES" block naming the first few pks/URLs, thears_completion.csvsidecar, plusred_flags+ acompletionroll-up in the summary + printed block.
trapi_corpus.py— the per-component query corpuses:_qg(nodes, edges, tier=None, bypass_cache=None)— TRAPI envelope; adds scalarparameters.tier(KP-only) or top-levelbypass_cache(ARA/ARS) only when supplied.RETRIEVER_CORPUS—lookup-mode KP builders, each pinning its owntier(multi-hop→0, single-hop→1):one_hop_lookup_pinned,one_hop_lookup_open,one_hop_no_predicate,two_hop_lookup,batch_lookup,malformed_query.SHEPHERD_CORPUS(also used asARS_CORPUS) —inferred+bypass_cachecreative queries, an even MVP1/MVP2 split (50/50), entity varied per request:- MVP1 "what treats disease X?" (
chemical-[treats]->disease), disease sampled from size-tiered pools viamvp1_heavy/mvp1_medium/mvp1_light(10/15/25 = the 50% MVP1 half, tiered 20/30/50 within it). - MVP2 chemical
biolink:affectsgene, with object aspect/direction qualifiers on the gene. The edge is always oriented chemical(subject)→ gene(object) (matching the Translator TestHarnessgenerate_query.py); the two variants differ only by which endpoint is pinned —mvp2_chem_affects_gene(pinned gene, open chemical) /mvp2_chem_affects_open_gene(pinned chemical, open gene) (25/25). Aspect is the canonicalactivity_or_abundance; direction + entity sampled per request.
- MVP1 "what treats disease X?" (
PATHFINDER_CORPUS— the Pathfinder run type (ARA/ARS only, selected byaras_pathfinder/ars_pathfinder).pathfinder_drug_diseasepins two endpoints (a drug + a disease, sampled per request fromCHEM_DISEASE_PAIRS) and asks for connecting paths via apathsmap in the query_graph — built by_pathfinder_qg(nodes, paths)(nodes+ emptyedges+paths; noknowledge_type, notier;bypass_cache=True). Most intensive query class.MIXED_CORPUS— the mixed capacity profile (aras_mixed/ars_mixed). Not hand-written:_mixed_corpus()blendsSHEPHERD_CORPUSandPATHFINDER_CORPUSatINFERRED_PATHFINDER_RATIO = (2, 1)— 2/3 inferred MVP1+MVP2, 1/3 Pathfinder — preserving each corpus's internal weights, so retuning the MVP1/MVP2 mix propagates here automatically.- Entity pools:
HEAVY_DISEASES(curated hubs) +LONG_TAIL_DISEASESfromcurie_list.json(~1000 real MONDO CURIEs, shipped viapackage_data), a curatedGENESpool (NCBIGene), and curated drug↔diseaseCHEM_DISEASE_PAIRS.corpus_for(name)returns the right list.
For KPs, cost is driven by query-graph shape (hops, mode, pinned vs open, batch, predicate). For ARA/ARS creative queries, the dominant cost driver is the pinned disease's answer-set size, which is why that corpus varies the entity.
Implemented: component awareness, the helmsdeep CLI + entry point,
per-target stages/SLO, segmented corpuses, scalar parameters.tier per KP query,
the ARS async submit/poll/merge user, ARS health metrics + red flags, the
tiered inferred disease mix, and the mixed capacity profile (2:1
inferred/Pathfinder blend + pass/fail acceptance checkpoints, ARA and ARS).
Remaining refinements (not yet done — don't assume these exist):
- Medium vs light tiers aren't calibrated.
inferred_mediumandinferred_lightcurrently draw from the sameLONG_TAIL_DISEASESpool; split it by measured answer-set size (a one-time profiling pass) for true separation. - No per-ARA child-result breakdown. ARS health treats the merged message as
a whole; the
trace=yresponse exposes children, so per-agent health is possible. - No full multi-endpoint registry. Retriever is a single service today; the old per-KP/ARA URL registries survive only in git history (see below).
- Config is env/registry-driven, not file-driven. Stages/SLO are edited in
config.py; there's no external config-file or full CLI override yet. - No
results/output convention — outputs land in the working directory.
The "Start the rewrite" commit (9fe6cd0) deleted the old per-service scripts, but
they contain assets worth recovering for the roadmap below. Retrieve with
git show <commit>:<path>:
git show b912968:kps.json— registry of ~40+ KP endpoints (*.ci.transltr.io), some with per-KPpredicatesoverrides.git show b912968:aras.json— registry of ARA endpoints (Aragorn, ARAX, BTE, mediKanren, CQS, imProving Agent).git show b912968:ars_stress_test.py— the ARS submit/poll/merge workflow (base URLhttps://ars.ci.transltr.io/ars/api).git show b912968:generate_message.py— TRAPI message builders, including batch handling (set_interpretation: "BATCH") andinferredARA queries.git show b912968:curie_list.json— ~1000 MONDO disease CURIEs. Already restored into the package ashelmsdeep/curie_list.json(the long-tail disease pool for the inferred corpus).
pip install -e . # Python >= 3.12; installs locust
# One layer per run (kps | aras | ars); --host required.
helmsdeep --targets kps --host https://your-retriever.example.org --csv-prefix run1
helmsdeep --targets aras --host https://your-ara.example.org --csv-prefix run1
helmsdeep --targets ars --host https://ars.ci.transltr.io/ars/api --csv-prefix run1
# Pathfinder is its own (heavier) run type, ARA/ARS only:
helmsdeep --targets aras_pathfinder --host https://your-ara.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,
# ramped to 30 -> 45 -> 60 concurrent and judged pass/fail per checkpoint.
helmsdeep --targets aras_mixed --host https://your-ara.example.org --csv-prefix mix1
helmsdeep --targets ars_mixed --host https://ars.ci.transltr.io/ars/api --csv-prefix mix1- The
LoadTestShape(StepLoad) drives users, spawn rate, and duration, so there is no-u/-r/-t. Tune the ramp via the per-targetstagesinconfig.py. --csv-prefixis optional; it falls back to theLOCUST_CSV_PREFIXenv var, then totrapi_run.- You can also run the locustfile directly (
locust -f helmsdeep/ trapi_loadtest.py --headless --host …), selecting the layer with theLOADTEST_TARGETenv var (defaults tokps). Note locust has no--csv-prefixflag — setLOCUST_CSV_PREFIXinstead. - A checkpointed run (
*_mixed) exits non-zero when a checkpoint is missed, so it can gate a CI/acceptance job. --quick(=--time-budget 10m) or--time-budget 30mcompresses any target to a wall clock; see the conventions note below for what that costs.- Outputs (written by the master/standalone node only):
<prefix>_stages.csv,<prefix>_by_qtype.csv,<prefix>_summary.json(+<prefix>_checkpoints.csvfor checkpointed targets) (+<prefix>_ars_health.csv, the<prefix>_ars_queries.csvper-query debug log, the<prefix>_ars_completion.csvsidecar, and ared_flagslist +completionroll-up for thearstarget), plus a printed summary table with the knee.
- The shape owns concurrency. Tune load by editing the per-target
stagesinconfig.py, not CLI flags. - Two kinds of run: knee-finding vs acceptance. Every target reports the knee
("how far can we go?"). A target that also defines
checkpointsanswers a pass/fail question at named concurrency levels ("does 30 hold?") and exits non-zero on a miss.aras_mixed/ars_mixedare the acceptance profile: a 2:1 inferred/Pathfinder blend checked at 30 (peak) / 45 (headroom) / 60 (overload, error-rate only). Checkpoints are generic, not special-cased to that profile -- a target without them behaves exactly as before. - Cooldown drains, it doesn't bleed. With
cooldown_sset, the gap between stages ramps users to 0; the just-finished stage's end time is frozen so itsduration_s/RPS reflect the active window, and a slow query still running drains into that stage (viastop_timeout), keeping the next stage clean. - Closed-loop load.
TRAPIUser.wait_time = constant(0)— no think time; users hammer the endpoint as fast as responses return. - A compressed run is not a measurement.
--time-budget/--quickscale durations only (holds, cooldowns, poll/timeout caps) — the ramp, SLOs, and checkpoints are identical, so the run asks the same questions of the same load levels. But it answers them from far fewer samples (a p99 over a handful of queries is noise), and the shrunken per-query caps change what counts as a failure: a query that would finish in 4 minutes is a timeout when the cap is 2. Compressed runs are for exercising a host/corpus/config end to end, not for quoting a knee or gating CI.config.time_scaleinsummary.json(< 1.0) is how you tell after the fact. - The live display never measures anything.
console.pyreads the sameStageCollectorand_stage_stats()the CSVs are written from; it owns no tally of its own, and its greenlet swallows its own exceptions. Changing what the footer says is a rendering change, never a measurement change. - The collector owns "which stage are we on" -- for the display too. The
display derives the stage, its progress, and its verdict from
COLLECTOR.stage_idx/stage_started/stage_ended, not from its own elapsed clock. Two clocks meant the screen and the CSV could name different stages at a boundary, and stage verdicts landed on either side of the next stage's header depending on which won the race. If you add anything that reports where a run is, read it from the collector. - A stage's row can be right and still not mean anything.
_stage_qualityflags the two ways that happens -- a p99 over too few samples, and effective concurrency far below the user count (in-flight queries bleeding into the next stage). Both are ramp-shape problems, fixed with longer holds and acooldown_sinconfig.py, never by adjusting the numbers. If you retune a target's stages, check a real run for these flags rather than assuming the old holds still fit the service's latency. - Don't trust Locust's blended aggregate during a ramp. We bucket per stage in
StageCollectorprecisely because an aggregate p99 would mix easy early stages with saturated late ones. malformed_query4xx is success. A 4xx on the malformed query is treated as a valid measurement of the error path; only 5xx counts as a failure. See theTRAPIUser.queryhandling.- Long timeouts on purpose.
REQUEST_TIMEOUTdefaults to 210 s (per-target override:request_timeout_s) because TRAPI queries are slow; ARS runs are far longer still (minutes–~1 hr) and need the async model. - gevent concurrency. Locust uses gevent green-threads; avoid blocking calls in
the user path. The ARS poll loop uses
gevent.sleep, nevertime.sleep. - ARS submit/poll/merge is one logical measurement.
_run_arsissues several HTTP calls (namedars_submit/ars_poll/ars_merge— they show in Locust's own table as per-step diagnostics) but records exactly oneCOLLECTOR.recordper logical query, with latency = wall-clock submit→terminal. It also fires a single syntheticars_queryLocust request event carrying that same full wall-clock, so Locust's native stats table shows the true per-query time (otherwise the only ARS rows would be the individual sub-calls — e.g. thears_mergeGET, which times just the final merge fetch, not the whole query). ADonethat returns 0 results counts as a failure by default (and raises a red flag) — seezero_result_is_failurebelow; a non-terminal status pastmax_poll_sis aTimeoutfailure. zero_result_is_failuredecides what a 0-resultDonemeans. DefaultTrue: an empty answer set under load usually means a downstream agent silently dropped out, so it scores as a failure and counts against the knee. Set itFalseon a target to score only transport/protocol outcomes (submit error,Errorstatus,Timeout) as failures — the zero-result query's latency then also joins the percentile pool instead of being discarded (failed requests contribute no latency samples), so it shifts mean/p99/concurrency, not just the error rate. Either way the query is still tallied inars_health(zero_result_done) and still raises a red flag, the per-query debug log still carries theDone with 0 resultsnote (itsfailedcolumn follows the policy), and the summary JSON + printed ARS health block record which policy was in force.- Intermediate errors are the trouble a query survived. The poll loop retries
through non-200 polls, unparseable poll bodies and a bad merge fetch, so none of
them change
ars_status— which means a query that fought through thirty 502s lands in the log as a cleanDone.QueryIssuestallies them per query intointermediate_error_count(0 = clean) +intermediate_errors(poll HTTP 502 x3; merge HTTP 500), andon_test_stopprints a roll-up separate from the FAILED QUERIES block precisely because most queries carrying them succeeded. - The per-query debug log is the bridge from a number to a query. The
aggregates say 3% failed;
<prefix>_ars_queries.csvsays which pks, with the HTTP status of each step and amessage_urlto pull one up. One row per logical query on every terminal path (includingSubmitError/NoPK, which have no pk at all), attributed to the stage active when it reached its outcome. It joins toars_completion.csvonquery; a timed-out query whose extended poll was still in flight at shutdown is in the debug log but not the sidecar. - Completion tracking is a sidecar, not a metric change.
max_poll_sstays the failure threshold for the main stats and the knee — a query not terminal by then is aTimeoutfailure, exactly as before. Separately,completion_max_poll_s(>=max_poll_s, default 10 min onars/ars_pathfinder) lets a detached background greenlet keep polling that same query to see if it eventually finishes; the outcome (end-to-end time +finished/within_slo/status) goes only to<prefix>_ars_completion.csvand thecompletionsummary roll-up. It never feeds the per-stage stats, thears_queryevent, or the knee, so existing measurements are unchanged. The greenlets are drained (bounded by the extra budget) inon_test_stop; queries still unfinished at shutdown are simply absent from the sidecar. This separates slow (finished after the SLO) from broken (never finished). - Inferred corpus mixes MVP1 + MVP2 and varies entities per request. MVP1
(
mvp1_heavy/medium/light, treats-disease) samples a tiered disease; MVP2 (mvp2_chem_affects_gene/mvp2_chem_affects_open_gene, a chemical→geneaffectsedge with the gene-pinned and chemical-pinned variants) samples the pinned entity + direction. The edge orientation is always chemical(subject)→ gene(object) and the aspect qualifier is alwaysactivity_or_abundance. The per-request variation covers the real cost surface and avoids warming caches. MVP1 medium and light share the long-tail pool until calibrated (see Status & what's left). - Environments & TRAPI versions vary per service. Endpoints live across
*.ci.transltr.io,*.test.transltr.io, and prod, and individual services pin different TRAPI versions in their URL paths. Target deliberately. - Swap the CURIEs. The KP corpus uses a few real MONDO/CHEBI entities and the
inferred corpus draws diseases from
curie_list.json; replace/extend them with entities the target service actually knows about, or queries return empty and won't reflect real cost.
Done in earlier phases: per-component adapter + --targets CLI entry point
(one layer per run — there is intentionally no "all" mode, which would
double-load shared downstream services per the layering rule), the ARS async
submit→poll→merge user, per-target config, and a README for human onboarding.
Remaining, ordered so a future session can pick up where this leaves off:
a. Calibrate the inferred tiers. Profile each disease once (sort by merged
result count) and split LONG_TAIL_DISEASES into real medium/light pools.
b. Per-ARA health breakdown for ARS, parsing the trace=y children so a
red flag can name which downstream agent dropped answers.
c. Restore the full endpoint registry as config (per-KP/ARA URLs +
predicate/query overrides) from the git-history assets above, if/when the
stack returns to multiple independent KP/ARA endpoints.
d. Make config file-driven (external config file / full CLI overrides for
stages, SLOs, poll knobs) instead of editing config.py.
e. Adopt a results/ output convention so each service's reports land in a
predictable, per-service location.