Shopping Copilot — Solution
A multi-turn conversational shopping agent for the TechJam Conversational E-Commerce Search Challenge. It locates a hidden target product inside a frozen 50,000-item Amazon apparel catalog by accumulating constraints across a bounded 10-turn dialogue.
TechnicalScore 0.9373 on the 200 public sessions, up from the 0.1067 weak baseline — 8.8×. Runs on the Python standard library alone: no network, no credentials, no model download, no third-party package.
The headline metrics are reproduced by the official evaluator
(python3 -m evaluator.local_evaluator). Ablation figures were measured during
development with a throwaway harness whose baseline configuration reproduced
results.json to six decimals; that harness is not part of the submission, so
those rows are reported as measured rather than as re-runnable.
Results
| Metric | Baseline | This agent | Best possible |
|---|---|---|---|
| Hit Rate@10 | 0.125 | 0.990 | 1.000 |
| MRR | 0.068 | 0.9574 | 1.000 |
| MTTC | 9.81 | 3.245 | 1.39 |
| Efficiency | 0.119 | 0.7755 | 0.961 |
| TechnicalScore | 0.1067 | 0.9373 | 0.9922 |
By scenario:
| Scenario | n | Hit@10 | MRR | MTTC |
|---|---|---|---|---|
| Buying | 80 | 0.988 | 0.972 | 2.78 |
| Browsing | 80 | 1.000 | 0.979 | 2.99 |
| Intent Override | 30 | 1.000 | 0.933 | 4.17 |
| Boundary | 10 | 0.900 | 0.742 | 6.30 |
The 0.9922 ceiling is not 1.0 because the evaluator refuses to convert an
intent-override session before the override message arrives — turn 3 for 12 of
the 30 sessions, turn 4 for the other 18. That floors MTTC at
(170·1 + 12·3 + 18·4) / 200 = 1.39 and caps Efficiency at 0.961.
Architecture
Each turn runs observe → route → retrieve → rerank → answer + clarify.
| Module | Responsibility |
|---|---|
starter/state.py |
Slot tracking, constraint extraction, override detection, disclosure bookkeeping |
starter/ask.py |
Clarification strategy; keeps ask_attribute and the prose in sync |
starter/retrieve.py |
In-memory SQLite FTS5 index, per-term IDF, normalized text blobs |
starter/rerank.py |
IDF-weighted constraint coverage, category match, verbatim bonus, elimination |
starter/agent.py |
Orchestrator, Buying/Browsing routing, recommendation-width policy |
Tooling: scripts/show_conversation.py replays any session turn by turn.
Retrieval. BM25 over an FTS5 index with column weights favouring title and categories. Pool size is route-dependent: 100 for Buying (a hard constraint arrives early, so precision pays), 200 for Browsing (vague openings need a wider net).
Reranking. The pool is reordered by IDF-weighted coverage of every disclosed constraint, coverage of the stated category, a bounded bonus for verbatim clause matches, and a large demotion for products already ruled out.
Constraint extraction is marker-driven with a free-text fallback.
Why FTS5 and IDF weighting
Both choices follow from one property of the data, established below: the customer quotes catalog metadata verbatim.
FTS5 gives BM25 ranking, phrase queries and a C-speed inverted index from the standard library — no dependency, no build step beyond 3.6 s of ingest, and no model download. Since the customer's words are literally substrings of the target's catalog row, a lexical index is not a fallback for something better; it is the operation the task actually calls for.
IDF weighting is needed because disclosures are wildly unequal in value. Measured document frequencies across the 50,000-product catalog:
| Token | Products containing it | IDF |
|---|---|---|
imported |
15,300 | 2.18 |
polyester |
10,884 | 2.53 |
cotton |
9,775 | 2.63 |
leather |
7,503 | 2.90 |
alloy |
803 | 5.13 |
pentagram |
16 | 8.99 |
kandinsky |
1 | 11.13 |
A single card can pair Imported with a phrase that occurs in one product.
Only 25 of 798 public constraints contain a token appearing in one product
or fewer — roughly 1 in 32. Weighting every disclosure equally lets the 15,300-
product token drown out the decisive one, so each constraint is weighted by the
IDF of its rarest token.
How a session actually runs
scripts/show_conversation.py replays any public session through the real agent
and prints the dialogue turn by turn. It imports the evaluator's own simulator
functions rather than reimplementing them, so what it prints is exactly what
gets scored.
python3 -m scripts.show_conversation --sample-id public_0001 --verbose
public_0001 [buying / easy]
target B09PYB7B6Z QIAN0813 Celttic Knot Triple Moon Pentagram Pentacle Star…
hidden constraints: Material:alloy; Triple Moon Pentagram Symbol
--- turn 1 ---
customer I'm looking for Jewelry Necklaces. A key requirement is: Material:alloy.
agent Going on Material:alloy, here are the closest matches. Anything else
that matters for this one?
asks about: other
1. B09LV2N6CM YYDSBBA Sunflower Alloy Necklace for Women…
--- turn 2 ---
customer For that, what matters is: Triple Moon Pentagram Symbol; The Triple
Moon represents the Phases of the Moon…
agent Going on Triple Moon Pentagram Symbol, …, here are the closest matches.
>> 1. B09PYB7B6Z QIAN0813 Celttic Knot Triple Moon Pentagram… $9.99
note: target at rank 1 -- session ends
result: HIT on turn 2 at rank 1 (RR 1.000)
The customer holds the answer sheet, and never asks anything
Before turn 1 the evaluator reads the hidden target product and compresses it
into an intent card (intent_card() in evaluator/local_evaluator.py): the
product title becomes target_category, and its features and details
fields — plus any regex-detected material, colour, and price — become two
hard_constraints and two soft_preferences. Every public session's card holds
exactly four constraints.
So the simulated customer is not reasoning or improvising. It is reciting
catalog metadata about one specific product, a slice at a time, and the session
ends when the agent hands that product back. Above, Material:alloy and
Triple Moon Pentagram Symbol are not things a shopper thought up — they are
features[0] and features[1] lifted verbatim off B09PYB7B6Z. This is why
exact lexical matching is the correct operation here, and it is the root cause
of the dense negative result further down.
The customer releases only what it is asked for
customer_reply() reads the agent's ask_attribute, sorts every
still-undisclosed constraint into a bucket with classify_constraint(), and
returns up to two whose bucket matches. Four branches:
| What the agent asks | What the customer says back |
|---|---|
null (no attribute) |
"Those options are not quite right yet." — a wasted turn |
| An attribute with undisclosed constraints in its bucket | "For that, what matters is: …" — up to two constraints released |
| An attribute whose bucket is empty | "I don't have an additional preference for X." |
Any attribute, first time only, in a boundary session |
"I don't have a preference for X; please use your judgment." |
Independently of the ask, an intent_override session pre-empts the reply on
its scheduled turn with "Actually, ignore my earlier preference…". That turn
is rng.choice([3, 4]) seeded from sample_id + scenario_type
(materialize_hidden_fields()), so it is deterministic: 12 sessions get turn 3,
18 get turn 4.
The customer never asks a question and never volunteers a second constraint
unprompted. ask_attribute is the key, and each turn unlocks at most two
slices of a four-slice card.
The agent chooses the key
next_attribute() in starter/ask.py picks that key. It asks the open-ended
other first, and only walks a priority ladder — material → style → use_case →
colour → size → budget → feature → brand → category — once other is spent,
skipping any attribute already asked, already covered, or refused.
That ordering matters because in customer_reply() the matching test is
attribute == "other" or classify_constraint(value) == attribute. other is
a wildcard: it matches any undisclosed constraint, and takes the first two in
card order — hard constraints before soft preferences, the most decisive
information first. A specific ask can only ever unlock its own bucket, and
misses whenever that bucket is empty.
Measured across all 647 turns of the public set:
| Ask | Times used | Turns that returned nothing |
|---|---|---|
other (wildcard) |
509 | 34 (6.7%) |
| A specific attribute | 138 | 81 (58.7%) |
A specific question comes back empty more often than not; the wildcard almost
never does. A further 10 turns (all boundary) lose the wildcard to a refusal.
How the ladder order was chosen
The shipped order is a hand-authored guess at how much a typical answer narrows an apparel catalog. That guess does not match how constraints actually distribute across buckets:
| Bucket | Share of the 800 public constraints | Ladder position |
|---|---|---|
feature |
50.5% | 7th |
material |
37.8% | 1st |
color |
7.5% | 4th |
style |
2.4% | 2nd |
size |
1.4% | 5th |
use_case |
0.5% | 3rd |
budget, brand, category |
0% — never occur | 6th, 8th, 9th |
Reordering the ladder to match observed frequency is the obvious fix, and it was tested. It loses:
| Ladder order | Hit@10 | MRR | MTTC | Score |
|---|---|---|---|---|
| Shipped (material-first) | 0.990 | 0.9574 | 3.245 | 0.9373 |
| By observed bucket frequency | 0.985 | 0.9471 | 3.190 | 0.9328 |
| Frequency order, dead buckets dropped | 0.980 | 0.9453 | 3.195 | 0.9297 |
| Reversed | 0.980 | 0.9464 | 3.295 | 0.9280 |
The reason is that the ladder only fires after the other wildcard is spent,
and the wildcard drains the common buckets first. By the time the ladder runs,
the frequent buckets are usually already covered and get skipped — so ordering
by global frequency optimises for a situation that has already passed.
The spread is ~0.01, close to what n=200 can resolve, so this is evidence the shipped order is not worse rather than proof it is optimal. It is kept because nothing tested beat it.
Why it converges in three turns
| First hit on turn | 1 | 2 | 3 | 4 | 5 | 6–10 | never |
|---|---|---|---|---|---|---|---|
| Sessions | 9 | 74 | 62 | 26 | 9 | 18 | 2 |
145 of 200 sessions land by turn 3, and 188 of the 198 hits land at rank 1. The card holds four constraints; 256 informative replies handed over two at once and only 36 handed over one, so two wildcard exchanges typically transfer the entire card. At the moment of conversion the agent holds an average of 3.20 of the target's 4 constraints.
The failure modes are visible in the same tool. --scenario boundary shows the
weakest case: the customer burns the wildcard on a refusal, pushing MTTC to
6.30. --only misses shows both public failures, and they share one cause:
every constraint on the card is catalog boilerplate. public_0020 discloses
cotton, color: grey and a heather-blend note; public_0180 discloses
100% Mesh, Imported, Rubber sole. Nothing on either card carries enough
IDF to separate one product from the thousands sharing those phrases.
Which signals earn their place
| Configuration | Hit@10 | MRR | MTTC | Score |
|---|---|---|---|---|
| Full agent (shipped) | 0.990 | 0.9574 | 3.245 | 0.9373 |
| No verbatim-phrase bonus | 0.975 | 0.9241 | 3.475 | 0.9152 |
| No ruled-out elimination | 0.955 | 0.8635 | 3.595 | 0.8846 |
| Neither (pure IDF matching) | 0.945 | 0.8328 | 3.900 | 0.8643 |
Elimination is the larger of the two, and it is free. Any product scored in a previous turn without ending the session provably is not the target, so it can be demoted. The one exception is an intent-override session before the override lands, where the evaluator blocks conversion entirely; we demote optimistically and re-admit pre-override items once the override turn is known — zero risk, because those turns could never have converted anyway. Worth +0.053.
The verbatim-phrase bonus is worth +0.022, and is deliberately bounded at
W_PHRASE = 1.5 because it is the signal most specific to the public
simulator's literal phrasing.
Narrow early, widen late — worth +0.066
| Policy | Hit@10 | MRR | MTTC | Score |
|---|---|---|---|---|
| Always return 10 | 0.990 | 0.6868 | 2.480 | 0.8715 |
| Always return 3 | 0.985 | 0.8267 | 2.840 | 0.9037 |
| Narrow → widen (shipped) | 0.990 | 0.9574 | 3.245 | 0.9373 |
The session ends at the first hit. Surfacing the target at rank 9 on turn 2 banks a reciprocal rank of 0.111 and forfeits every later chance to present it first; holding back and returning it at rank 1 two turns later is worth 1.0. With MRR weighted 0.30 against Efficiency 0.20, that trade is strongly profitable: widening buys 0.014 of Efficiency and pays 0.082 of MRR.
The agent returns a single best guess for turns 1–5, a shortlist of 3 for turns 6–7, and the full ten from turn 8 so a near-miss still lands in the scored window. This is a deliberate precision-over-recall stance, and it matches how a chat interface actually behaves: one confident recommendation and a question, not a ten-item dump.
Intent override: keep the context, drop only the contradicted slot
The problem statement suggests "slot erasure and rewriting" on intent override. The override replaces a stated preference but does not change the target product, so earlier disclosures remain true of it.
| Override handling | Overall score | intent_override Hit@10 | intent_override MRR |
|---|---|---|---|
| Keep full context (shipped) | 0.9373 | 1.000 | 0.933 |
| Drop constraints disclosed on turn 1 | 0.9361 | 1.000 | 0.909 |
| Wipe all state on override | 0.9350 | 1.000 | 0.916 |
Keeping context wins, but by 0.0023 — a small margin, and hit rate is unaffected in all three. An earlier iteration of this agent showed a much larger gap; on the current tree, elimination and IDF weighting recover enough signal that override handling is no longer decisive. Reported as measured.
Robustness under paraphrase
The public simulator emits fixed phrasings and the specification reserves the
right to paraphrase the private 800. We tested that by rewriting every
customer utterance so that all marker strings the agent keys on are broken
— CATEGORY_RE, all three CONSTRAINT_MARKERS, NO_ADDITIONAL_RE,
NO_PREFERENCE_RE, OVERRIDE_RE, STALLED_RE and every BOILERPLATE phrase
in starter/state.py — while preserving the information content. A second
stressor cripples extraction by discarding every second disclosure.
| Configuration | clean | paraphrased | starved | both |
|---|---|---|---|---|
| Full agent | 0.9373 | 0.7581 | 0.9197 | 0.6986 |
| No verbatim-phrase bonus | 0.9152 | 0.7581 | 0.8978 | 0.6986 |
| No ruled-out elimination | 0.8846 | 0.7427 | 0.8489 | 0.6036 |
| Neither (pure IDF matching) | 0.8643 | 0.7427 | 0.8291 | 0.6036 |
This is the weakest part of the system and we are reporting it plainly. Under a total marker break the score falls from 0.9373 to 0.7581 — a 19% degradation, not a marginal one. Starved extraction alone is survivable (−0.018); paraphrase is not.
Isolating the cause:
| Paraphrase scope | Score |
|---|---|
| None | 0.9373 |
| Only the "no preference" markers | 0.9310 |
| Everything except the "no preference" markers | 0.7634 |
| Everything | 0.7581 |
The damage is not in exhaustion tracking; it is in constraint extraction. When
the For that, what matters is: A; B. marker survives, _extract() splits on
; and yields two clean, separately IDF-weighted constraints. When it does
not, the free-text fallback treats the whole utterance as one blob — merging
two constraints and dragging in filler words, which dilutes exactly the IDF
weighting the reranker depends on.
Note also that under paraphrase the verbatim-phrase bonus becomes inert (the first two rows converge to the same score), which is the expected behaviour for a signal keyed to literal phrasing.
The fix is known and not yet implemented: the fallback should segment an utterance into clauses on conjunctions and punctuation, rather than emitting one blob. That is the highest-value robustness work remaining.
Negative result: the dense route does not help
We built a MiniLM (all-MiniLM-L6-v2) dense retrieval route — 50,000 cached
384-dimensional embeddings, cosine similarity, blended into the reranker — and
it never beat sparse-only:
| Dense weight | 0.0 | 1.0 | 2.0 | 3.0 | 5.0 | 8.0 |
|---|---|---|---|---|---|---|
| Score | 0.9327 | 0.9313 | 0.9308 | 0.9285 | 0.9296 | 0.9266 |
(Swept against the then-current 0.9327 baseline, before constraint discriminance was added. Relative ordering is the result; the absolute baseline has since moved to 0.9373.)
Gating it to fire only when few constraints were known, and testing boundary sessions in isolation (the regime where semantic matching should help most), also failed to beat zero.
Why: the disclosed constraints are literal catalog metadata — "100%
Leather", "Buckle closure", "Material:alloy". Exact lexical matching is
the correct operation for attribute satisfaction. Embeddings blur exactly the
fine distinctions that separate the target from its near neighbours, and the
BM25 pool is already topically relevant, so semantic similarity adds noise
rather than signal.
The route has since been removed from the tree. The shipped agent has no optional imports and no numpy, torch or sentence-transformers dependency — the sparse path is the only path. Removal was verified score-neutral: 0.937314 before and after, with all 200 per-session records identical.
Setup and reproduction
Python 3.10+. No dependencies.
# 1. Place the catalog (50,000 rows) at data/catalog.jsonl
gzip -dk catalog.jsonl.gz && mv catalog.jsonl data/catalog.jsonl
# 2. Score against the official evaluator
python3 -m evaluator.local_evaluator # -> results.json, ~20 s
# 3. Tests
python3 -m unittest discover tests -v
# 4. Watch sessions play out turn by turn
python3 -m scripts.show_conversation --limit 3 --verbose
Steps 1-4 reproduce every headline and per-scenario number in this document. The ablation and robustness tables are development measurements and are not re-derivable from the shipped tree; each states the configuration it varied so the experiment can be reconstructed.
Feasibility disclosure
| Model | none — no LLM in the scoring path |
| API cost | $0.00 |
| Token usage | 0 prompt, 0 completion |
| Network | not required at any point |
| Dependencies | Python standard library only |
| Index build | 3.6 s, one-off |
| Latency | 14.0 ms p50, 34.6 ms p95 per turn (647 turns, 4 passes) |
| Peak memory | 0.40 GB |
Measured on the development machine (Apple silicon, Python 3.14). Latency is dominated by the FTS5 query and the rerank scan over the candidate pool, so the Browsing route (pool 200) sits at the upper end.
Limitations and what we would improve
Paraphrase robustness is the real weakness. A 19% drop under full marker
break is the largest single risk to private-set performance, and the cause is
localised to one function (_extract()'s fallback branch). Clause segmentation
is the fix.
Boundary is the weakest scenario (Hit 0.900, MRR 0.742, n=10). When the customer declines to specify an attribute, the agent has no fallback beyond asking about a different one. With only 10 public sessions the measurement is too noisy to tune against — its entire remaining shortfall is 0.0117 of final score.
Question selection is heuristic, not decision-theoretic. The agent asks an open question first and then walks a fixed priority order that, as shown above, is not justified by bucket frequency — merely not beaten by it. A genuine question-value estimator — choosing the attribute whose answer most reduces expected candidate entropy — is the natural next step. 125 of 647 turns currently return no new information; eliminating them is worth about +0.0125.
No semantic generalisation. Because the dense route lost, the system cannot bridge vocabulary the catalog does not literally contain. Against a private set whose customers speak more naturally than the public simulator, that is the most likely failure mode, and the paraphrase result above is the evidence for it rather than against it.
Everything is tuned on n=200. Standard error on hit rate at this sample size is roughly 2.5 points — larger than the entire remaining gap to a perfect score. Treat any difference below ~0.005 as unresolved by this data. Of the 0.0627 still on the table, 8% sits in Hit Rate, 20% in MRR and 72% in Efficiency — and 0.0078 of that Efficiency share is locked by the override-conversion gate, so roughly 0.037 is genuinely addressable.
Weight tuning is exhausted. A full sweep of both pool sizes and all rerank weights moved nothing outside ±0.005. Further gains require a new signal.
Team contributions
To be completed.
Log in or sign up for Devpost to join the conversation.