Skip to content

perf(exercises): trim search results to what picks an exercise - #5

Open
wromansky wants to merge 4 commits into
wger-project:masterfrom
wromansky:perf/lean-exercise-search
Open

perf(exercises): trim search results to what picks an exercise#5
wromansky wants to merge 4 commits into
wger-project:masterfrom
wromansky:perf/lean-exercise-search

Conversation

@wromansky

Copy link
Copy Markdown

Measurement

A ten-result search_exercises call returns 3,913 characters, measured against a live wger instance:

field chars share
translations 2,286 58%
uuid 480 12%
name 365 9%
equipment 328 8%
category 208 5%
images 140 3%
id 106 2%

The translation table is every language's name for every hit — sixteen entries for a common lift — and the caller already filtered by language when searching.

Why it matters

That lands in the model's context on every lookup, and building a routine means one search per exercise. On a self-hosted model with a 32k window it accumulates into a hard failure: the agent that prompted this change exhausted its context part-way through a six-day build, after which every inference returned

{"error":{"code":400,"message":"request (N tokens) exceeds the available context
 size (32768 tokens)","type":"exceed_context_size_error","n_ctx":32768}}

until it burned its iteration cap and gave up with a half-built routine. Nothing in that failure names the real cause — it surfaces only as "reached maximum tool iterations".

Change

search_exercises returns id, name, category, equipment. search_exercises_by_filter keeps muscles too. Both drop uuid, images and the translation list. The matched name is still resolved exactly as before, honouring the caller's language.

Nothing is lost — get_exercise still returns the complete record. Search picks an id; the detail call expands it.

_shape_images had no remaining callers and is removed with it.

Measured after the change, same queries against the same instance:

'barbell bench press'   4,843 -> 1,206 chars
'lateral raise'         3,914 -> 1,107 chars

Tests

4 new tests in tests/test_exercise_search_shape.py: the lean key set, that no translation text or uuid survives into the payload, that filter search keeps muscles, and that get_exercise still returns translations, images and uuid in full.

Full suite passes (75) and ruff check is clean.

Note

If you would rather keep the fields and make them opt-in, a verbose: bool = False argument would work equally well — happy to switch. I went with the smaller default because the common caller is an LLM picking an id, and the detail tool already exists for everything else.

Independent of #2, #3 and #4 — off master, different file.

A ten-result search returns 3,913 characters, of which 2,286 (58%) is a
translation table — every language's name for every hit — and a further 480
is uuids. Measured against a live wger instance.

That lands in an assistant's context on every lookup. Building a six-day
routine means a search per exercise, and the accumulated payload is enough to
matter on a self-hosted model with a 32k window: the agent that prompted this
change exhausted its context mid-build and every subsequent inference failed
with exceed_context_size_error until it burned its iteration cap.

search_exercises now returns id, name, category and equipment;
search_exercises_by_filter keeps muscles as well. Both drop uuid, images and
the translation list. The matched name is still resolved exactly as before,
using the caller's language.

Same shape, 75% smaller. Nothing is lost — get_exercise still returns the
complete record, which is the natural place for detail: search picks an id,
the detail call expands it.

_shape_images had no remaining callers and is removed with it.

Adds 4 tests: the lean key set, that no translation text or uuid survives into
the payload, that filter search keeps muscles, and that get_exercise still
returns translations, images and uuid in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wromansky and others added 3 commits August 12, 2026 23:14
Resolving exercise names one call at a time costs an inference round trip per
exercise. Filling a six-exercise training day is therefore six turns of the
model's tool budget spent before a single write happens.

Observed twice on a self-hosted 27B with a 32k window: an assistant asked to
fill a day issued 24 consecutive searches, exhausted its tool-iteration limit
without writing anything, and — because each result also lands in context —
exhausted the context window on the way. Nothing was created either time.

search_exercises_batch takes a list of names and returns a map keyed by query,
each holding the top matches in the same shape as search_exercises. Fetches run
concurrently with the same cap as lookup_foods_by_barcodes, whose batch/single
pairing this mirrors. Duplicate queries collapse; an empty list is not an error.

limit_per_query defaults to 3 rather than 10: a caller picking one exercise per
name does not need ten candidates each, and the difference is what the model
must read.

The shaping shared by both tools moves into one helper rather than being
duplicated.

Adds 3 tests: many names resolve in one call, duplicates collapse to a single
request, and an empty list returns an empty map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wger's exerciseinfo filters WHICH exercises match a language, but every result
still carries all of its translations. The shaping picked the first translation
whose name contained the query text, falling back to translations[0] — so an
English search that matched a German or Italian name returned it.

In practice an English search for "incline barbell bench press" came back as
"Bankdrucken LH", and a coaching assistant then wrote that name into a training
plan and read it back to its user. Wrong-language names also make the assistant
distrust its own earlier lookups and search again, which costs a round trip
each time.

The language code is now resolved to wger's numeric language id (cached; the
language table is static) and translations in that language are preferred, with
the previous behaviour as fallback when none exists. search_exercises_by_filter
had the same defect and gets the same treatment.

Also drops search_exercises_batch's limit_per_query default from 3 to 2. The
caller is picking one exercise per name, and every extra candidate is context
it must read: a 7-name batch at 3 each was 9.4 kB in one tool result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wger's name__search matches any single word of the query, and returns results
in its own order. A search for "incline barbell bench press" therefore came
back as ["Bench Press", "Bench Press Narrow Grip"] — the incline variant exists
and simply ranked lower than the cut-off.

An assistant picking from that list writes the wrong exercise into a training
plan, which is exactly what happened: a plan whose first movement was meant to
be incline barbell press was built around flat bench, and nothing about the
tool's output suggested anything was wrong.

Results are now fetched wider than requested (20), scored by how many of the
query's words the name actually contains, tie-broken by name length, then
trimmed to the caller's limit. The extra rows cost bandwidth, not caller
context. Shorter names winning ties keeps "Bench Press" ahead of "Bench Press
Narrow Grip" for the query "bench press".

Adds a test that the specific variant outranks the generic one it would
otherwise lose to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant