Shopping Copilot - a conversational product-search agent that asks before it guesses
Track 4 · Shopping Copilot: AI Conversational Search and Recommendations Solo entry - Aman Khanna
- Repository: https://github.com/Khanna-Aman/techjam-2026-shopping-copilot
- Demo video (8 min): https://youtu.be/7Rx5649PaTc
The one-line version
A stateful conversational shopping agent that finds a hidden target product inside a frozen 50,000-item Amazon catalog in 2.19 turns instead of 9.81, scoring 9.03× the official baseline — on the pure Python standard library, with zero tokens, no API key, no network access, and no GPU.
official baseline this agent
Hit Rate@10 0.125 -> 1.000
MRR 0.068034 -> 0.956742
MTTC (turns) 9.81 -> 2.185
TechnicalScore 0.10671 -> 0.963323 (9.03x)
TechnicalScore = 0.50·HitRate@10 + 0.30·MRR + 0.20·clip((11−MTTC)/10, 0, 1)
Measured with the unmodified official evaluator over all 200 public sessions.
One command reproduces it: python -m evaluator.local_evaluator.
Three caveats I would rather state than have you find. The supplied baseline is explicitly
named weak_bm25, so the 9.03× multiple flatters me — the ablation table below is the
honest version of that claim. 200 sessions put a 95% confidence interval of
[0.9547, 0.9711] around the score, so the defensible number is 0.96 ± 0.01, not
0.963323 (python -m tools.bootstrap).
And 0.058 of that score comes from a mechanism that exploits how this benchmark stops measuring. It survives held-out validation and heavy paraphrase, and it would not transfer intact to a live storefront. It has its own section below, and I would rather you heard it from me than found it yourself.
How the solution addresses the problem statement
The insight the whole system is built on
The supplied BM25 baseline scores 0.107 and converges at turn 9.81 out of 10. My first assumption was that this was a retrieval-quality problem, so I started building a better ranker. It wasn't. Look at the baseline's response shape:
return {"message": "...", "ask_attribute": None, ...} # starter/baseline_agent.py
ask_attribute is always None. In this protocol the customer only discloses information
in response to a question — so with no question asked, every turn after the first
returns the same non-answer: "Those options are not quite right yet. Ask me about one
specific attribute." The baseline gains zero new information on every subsequent turn and
re-runs one OR-query ten times. It is negotiating with itself.
And the protocol lets a single response carry a clarification question and a ranked list. There is no ask-versus-recommend trade-off to balance — the correct policy is always to do both. Half the available channel was simply going unused.
That one change is worth +0.395 of the +0.857 total improvement. Everything else in the system is the other 0.46.
Mapping to the four required pillars
| Pillar | What I built | Evidence |
|---|---|---|
| I. Intent routing & hybrid pipeline | Two-tier message parsing classifies Buying / Browsing / Intent Override / Boundary, then a category lock cuts 50,000 products to a ~180-item pool, ranked by weighted BM25 + typed constraint satisfaction + popularity prior. I deliberately did not fork into two separate retrieval stacks. | Intent detection drives dialogue policy, not two rankers — the unified constraint-driven ranker already reaches Hit@10 = 1.000 on both Buying and Browsing, so a second stack had nothing left to win. Vector similarity is implemented (copilot/dense.py, offline fp16 + mmap, stdlib only) and measured at three encoder tiers up to bge-base (768-dim, GPU-built): no gain at any weight on any encoder, so it ships switched off behind a flag rather than omitted. |
| II. Multi-turn scenario evolution | A slot state machine accumulates typed constraints (set-membership, phrase-containment, numeric), handles retraction on Intent Override, marks attributes exhausted so a spent question is never re-asked, and proactively clarifies on every turn. | Ablation: removing state tracking costs −0.319; removing clarification costs −0.395. |
| III. Dynamic context programming | Constraint accumulation with per-constraint confidence and decay; the question policy re-plans every turn from the live pool, escalating from an open-ended prompt to a specific attribute once the open channel is exhausted; the anonymised user profile is distilled in at cold start. | The policy derives that the open question is optimal rather than hardcoding it (finding #2 below). Profile prior: cold start only, and now worth nothing measurable — removing it scores +0.0010, on an interval that spans zero (finding #3 below). |
| IV. Evaluation matrix | Scored on the organiser's own evaluator, unmodified, plus an ablation harness (11 configurations) and a paraphrase-robustness harness (5 perturbations) that imports the evaluator's own simulator functions. | Hit@10 1.000, MRR 0.9567, MTTC 2.185. |
Architecture
customer message
|
+--> [ dialogue.py ] exact templates, then content-driven recovery
| |
| +--> scenario (buying / browsing / intent override)
| +--> category ------------------+
| +--> constraints ---+ |
| v v
+--> observed tokens ---> [ slots.py ] [ catalog.py ]
(parse-failure net) | category buckets
| weighted BM25 index
| typed attribute tables
v
[ retrieval.py ] pool -> score -> rank
|
[ question.py ] what is worth asking next
v
{ message, ask_attribute, recommendations, usage }
About 2,600 lines of agent code across ten modules, plus ~3,500 lines of tests (280 tests, 53 of them adversarial) and fourteen measurement harnesses.
Three findings that overturned my first instinct
1. Don't erase a retracted preference — it is still true. The obvious reading of Intent Override is "the customer withdrew that, so drop it." That is wrong, and measurably so. The simulator draws both the withdrawn preference and its replacement from the same hidden target product. The customer changes their mind; the target never does. Full erasure scored worst of every setting tested. The agent retains the withdrawn value at half confidence.
2. A question that splits the pool perfectly is worthless if nobody answers it. My
first question-selection model scored purely on information gain. That is the wrong
objective here, and python -m tools.chat then /why prints the proof live: size is
the best discriminator in the catalog — mean partition quality 0.93 at turn one, above
every other attribute — and it is answered 7.6% of the time. Colour partitions far
worse and is answered 43% of the time, so colour is worth roughly three times more.
budget is the same effect at its extreme: a middling partition, unanswered 99.5% of
the time, and the lowest value on the board. Measuring P(the customer can answer) across all
50,000 products, from the catalog alone with no session labels:
| attribute | feature | material | colour | style | size | use_case | budget |
|---|---|---|---|---|---|---|---|
| P(yield) | 0.958 | 0.573 | 0.427 | 0.162 | 0.076 | 0.016 | 0.005 |
The fix is an expected-value model:
value(A) = P(customer can answer A) × E[constraints returned] × how well A splits the pool.
Worth +0.0211 over the specific-questions-only policy (0.9633 against
0.9422) — that ablation keeps the same expected-value formula and only declines to ask
the open question, so it isolates the open channel rather than the scoring model. It is also worth
saying that the policy ties always-asking-open exactly on this set: the escalation branch
earns nothing here, and is kept as insurance for a private set that may exhaust the open
channel more often.
3. Personalization is a cold-start signal, not a ranking signal. The anonymised profile tags ("fit", "comfort", "durability") match most of the catalog. As a global ranking term they are actively harmful (−0.044). Applied only before any constraint is known they used to be worth +0.015 — but the confidence gate absorbed that, and today removing the cold-start prior scores +0.0010, on an interval that spans zero. The timing effect and its sign still hold; the benefit does not. I kept the prior rather than move a default on a noise-level result — see the ablation note below.
Results
| scenario | n | Hit@10 | MRR | MTTC | baseline Hit@10 |
|---|---|---|---|---|---|
| buying | 80 | 1.0000 | 0.9738 | 1.638 | 0.2375 |
| browsing | 80 | 1.0000 | 0.9403 | 2.125 | 0.0250 |
| intent_override | 30 | 1.0000 | 0.9704 | 3.700 | 0.1333 |
| boundary | 10 | 1.0000 | 0.9111 | 2.500 | 0.0000 |
| overall | 200 | 1.0000 | 0.9567 | 2.185 | 0.1250 |
Ablation — every mechanism removed one at a time:
| configuration | score | Δ | 95% CI on Δ |
|---|---|---|---|
| full system | 0.9633 | — | — |
| no clarification | 0.5685 | −0.3949 | [−0.4511, −0.3384] |
| no state tracking | 0.6448 | −0.3186 | [−0.3735, −0.2647] |
| no confidence gate | 0.9054 | −0.0579 | [−0.0703, −0.0457] |
| no popularity prior | 0.9170 | −0.0463 | [−0.0654, −0.0300] |
| no constraint scoring | 0.9403 | −0.0230 | [−0.0367, −0.0117] |
| no category lock | 0.9519 | −0.0115 | [−0.0231, −0.0028] |
| no observed-token fallback | 0.9601 | −0.0033 | [−0.0098, 0.0000] spans zero |
| no profile prior (cold start) | 0.9643 | 0.0010 | [0.0000, +0.0020] spans zero |
| no override handling | 0.9634 | 0.0001 | [0.0000, +0.0003] spans zero |
| no top-10 padding | 0.9633 | 0.0000 | [0.0000, 0.0000] spans zero |
| MMR diversity added | 0.9633 | 0.0000 | [0.0000, 0.0000] spans zero |
An ablation is a paired comparison — both configurations answer the same 200 sessions —
so tools/ablation_ci.py bootstraps the delta itself rather than comparing two marginal
intervals, which resolves effects an order of magnitude smaller.
It also says something I did not want to hear. Five of the eleven mechanisms are not distinguishable from sampling noise. They stay in the default configuration, because an interval spanning zero means unresolved at n=200 rather than absent — but the claim I make for them is now the smaller one. I would rather report this than have a judge derive it.
One of the five used to be a win. The cold-start profile prior was worth −0.0152 to remove before the confidence gate existed and is worth +0.0010 now: the gate solved the same problem more directly, and absorbed it. I kept it anyway, because dropping a mechanism on a +0.0010 result would break the same noise-floor rule that made me stop trusting it.
Robustness — the same sessions, reworded. The specification warns that the organiser may paraphrase customer messages, noting only that paraphrasing "cannot decide correctness." Template-exact parsing was betting the entire score on wording explicitly declared unstable, so I built a perturbation harness that imports the evaluator's own simulator functions — customer policy, scoring and scenario mix identical, only surface wording changes. Its control run reproduces the official score exactly.
| perturbation | before hardening | after |
|---|---|---|
| control | 0.8219 | 0.9633 |
| lowercase | 0.8219 | 0.9633 |
| punctuation stripped | 0.4101 | 0.9515 |
| light paraphrase | 0.2691 | 0.9547 |
| heavy paraphrase (+filler, +case drift) | 0.2370 | 0.9342 |
Worst case now sits 3.0% below control, versus 71% below before hardening. That gap is slightly wider than the 2.4% the previous weight showed, even though every number in the column is higher — the clean control rose more than the paraphrased runs did. The floor is what matters for unseen wording, and it rose, from 0.9325 to 0.9342.
One disclosure about that table: the before hardening column is historical. It measured the template-exact parser the hardening replaced, and that code is no longer in the repository, so unlike every other figure here it cannot be regenerated by running anything. Running the harness today reproduces the after column only.
Impact & relevance
The finding that transfers, independent of this dataset
Two mechanisms account for 0.713 of the 0.857 improvement — clarification (+0.395) and state tracking (+0.319). The two are not independent: each is measured by removing it from the full system, and removing either disables much of what the other buys. Neither is a model. Neither needs one. The expensive component that a conversational-commerce roadmap usually funds first — an LLM semantic ranker over the catalog — is not what moved the number here.
That is the transferable claim: in conversational commerce, dialogue policy is worth more than model quality, and it is orders of magnitude cheaper. If you are building a shopping copilot, the first thing to fund is the decision of what to ask next, and only then the quality of the ranker underneath it. My own build order was the wrong way round, and the ablation table is what told me so.
Who benefits, concretely
Shoppers answer roughly one question instead of nine. The problem statement itself frames MTTC as penalising "unnecessary conversational cognitive load" — the drop from 9.81 turns to 2.19 is the difference between a copilot people finish and one they abandon. The gain is largest exactly where recommender systems are weakest: the Browsing cold-start case, where the customer opens with no constraints at all and there is no history to lean on (0.025 → 1.000).
Operators get a copilot whose marginal cost is CPU time. The arithmetic below is illustrative — substitute your own prices, the ratio is the point. Only the 0 tokens and 6 ms/turn figures are measured:
| per 1,000,000 shopping sessions | LLM-ranking copilot | this agent |
|---|---|---|
| turns (at measured MTTC 2.185) | ~2.19 M | ~2.19 M |
| input tokens (~6 k/turn to show ~40 candidates) | ~13 B | 0 |
| output tokens (~200/turn) | ~0.44 B | 0 |
| inference cost @ $0.30/M in, $1.50/M out | ~$4,600 | $0 |
| compute (12 ms/turn worst case ≈ 7.3 CPU-hours @ $0.04/hr) | on top of the above | ~$0.29 |
| added latency, p95 | ~0.5–2 s per turn | 40 ms |
Three to four orders of magnitude — and the latency figure matters as much as the money. 40 ms p95 fits inside an existing search-response budget, so this can ship as an inline component of the search path rather than as a separate async chat surface the shopper has to opt into. Driving every session to all ten turns — far past where the scored loop stops — pushes p95 to 75 ms, which is the figure to plan against if your sessions run long.
Deployments an API-gated copilot cannot reach. No key, no network, no GPU, 226 MB resident, standard library only. It runs on-device, at the edge, in air-gapped or regulated environments, and in price-sensitive markets where per-request inference cost is the reason conversational search never shipped. It also means the shopper's conversation never leaves the process — no third-party model provider sees what someone is shopping for. For a platform under standing scrutiny about where user data goes, "the copilot does not call out" is a product property, not a footnote.
A recipe any catalog owner can run. The P(yield) table above was computed from catalog statistics alone, with no session labels: count how often each attribute is actually populated and discriminating in your own inventory, then ask by expected value rather than by information gain. That method ports to any marketplace with a product catalog and a question-answering surface, and it is the part of this project I would hand to another team first.
Where this fits in a real system
I would not argue that no LLM belongs in a shopping copilot. I would argue this is the wrong place to spend it. The realistic production shape is a hybrid: this agent as the always-on core doing constraint tracking, question selection and ranking at 6 ms and zero marginal cost, with an optional language layer spent on phrasing the question naturally and absorbing genuinely open-ended input — the part a model is uniquely good at — while the scored retrieval path stays offline and deterministic. The repository is already built that way: any LLM layer is an environment-gated addition that is off by default, because the rules warn that official scoring may disable network access, and an agent that needs a key is a liability rather than a feature.
The mechanism I am least comfortable with, explained rather than buried
The single largest gain after clarification and state tracking comes from one line in the evaluator:
if override_applied and target in ranked:
best_rank = ranked.index(target) + 1
hit_turn = turn
break # the session is over
The session ends the moment the target enters the top ten, and the rank it happened to land at is the rank that is scored, permanently. A list shown early therefore spends the session's only scoring opportunity on the agent's worst-informed guess.
Diagnosing that (python -m tools.diagnose_rank) showed the problem was not bad ranking. Of
the 77 sessions finishing below rank 1, none lost to a genuine tie — the
target was always separable — and 91% were decided with two constraints or fewer in
hand. The agent was ranking too early, not too badly. (Reproduce with
--base '{"use_confidence_gate": false}'; the finding describes the agent before the gate
existed, so the flag is load-bearing.)
So while the evidence is thin, the agent shows one recommendation instead of ten, and keeps asking. MRR went 0.7356 → 0.9567; Hit@10 did not move. Worth +0.0579, CI [+0.0457, +0.0703].
Two things are true about it, and I would rather write both.
It is a real dialogue-policy decision. "Here is my single best match, and one question that would let me do better" is how a good salesperson behaves, and showing ten speculative items when you know almost nothing is worse for a shopper, not better. Note it shows one, not zero — a withheld list cannot convert, while a single correct guess converts at rank 1. The agent never stops recommending.
But its measured value here is amplified by a benchmark artifact. In a live storefront, showing the target at rank 7 beats not showing it, because the shopper can still see it and click. Break-on-first-hit is what turns "rank 7 now" into a permanent loss, and that rule belongs to this evaluator, not to shopping. A large part of this +0.058 would not survive contact with a real store. I would not ship this configuration to production without re-tuning it against a metric that keeps measuring after the first impression.
It is disclosed for the same reason as the popularity prior: it looks far worse discovered than declared. It is measured, it is behind a flag that restores the old behaviour exactly, and it passes every gate — including held-out targets and heavy paraphrase, where it is worth more (−0.0497) than on clean input.
What I turned down, and the one I had to un-turn-down
Two changes looked like wins. I rejected both. One of those rejections was itself a mistake, and finding that out was the most useful thing I did:
w_popularity 0.55 → 1.2 — rejected, then adopted, and the rejection is the interesting
part. It scored +0.0086
on the public set with a perfect Hit@10, then −0.0126 on uniform held-out targets, and I
called it the clearest overfit I had measured. That was wrong, for two reasons I had the
evidence to see at the time.
The uniform regime samples targets uniformly from the catalog, drawing products with under
100 ratings 81% of the time. The organiser samples sessions from the Clothing 5-core
leave-last-out split, where that happens 5% of the time, and the participant kit says
both splits are drawn the same way. tools/proxy_private.py's own docstring called the
uniform regime "the pessimistic bound" — I then read it as the estimate.
The regime that was right to read reported no effect, but could not have reported anything else: it took an equal count from ten popularity deciles, so it was capped at ten times the smallest — 110 targets of 43,149, an interval 0.046 wide, asked to resolve eight thousandths. Rebuilt to draw a reference popularity per target and take the nearest match, at n=800 with targets disjoint from the public set:
| regime | w=0.55 |
w=1.2 |
change |
|---|---|---|---|
| public | 0.9548 | 0.9633 | +0.0086 |
| held-out, popularity-matched | 0.9354 | 0.9416 | +0.0062 |
| held-out, uniform stress | 0.9135 | 0.9009 | −0.0126 |
Adopted. Popularity is evidence here rather than a bet: a leave-last-out target is somebody's real last purchase, and public targets sit at the 99.4th percentile of catalog popularity. The uniform row stays in the table because it is the honest cost — if the private set were sampled some other way, this weight loses about 0.013. The rules say it was not.
Dropping the now-redundant profile prior. Worth +0.0010, CI [0.0000, +0.0020]. Inside the noise floor, so adopting it would break the same rule that made me distrust the mechanism in the first place. Kept — and kept for the same reason after the popularity change moved it, which is the test of whether a rule is a rule.
Reporting these matters more than reporting the wins. A submission that only shows what worked gives a judge no way to tell tuning from measurement.
The honest counter-argument
The strongest criticism of this project is that it reverse-engineers a simulator rather than building a shopping assistant. I take that seriously, and my defence is measurement, not assertion:
- Every mechanism derives from the published protocol, not from public-set labels — the
ask-and-recommend insight, the constraint typing, the retraction semantics and the
P(yield) table all come from documented behaviour or from the catalog itself. The
P(yield) prior is the one a sceptic should push on hardest, since it ships as seven
hardcoded constants, so it is re-derivable:
python -m tools.yield_priorrecomputes all seven from the catalog using the evaluator's ownintent_cardandclassify_constraint, touching no session labels, and reproduces the shipped values to within 0.0003 — which is the rounding in the source file. A test enforces it. - Each was validated three ways: ablation, cross-scenario consistency, and paraphrase perturbation. A mechanism fitted to public wording would not survive the third.
- Weights were chosen from flat regions of their sweeps rather than sharp argmaxes.
- The one mechanism that genuinely is dataset-specific — the popularity prior, which works partly because target products come from a 5-core split and therefore carry ≥5 reviews — is flagged in the limitations as something I would not expect to transfer to a live catalog. Naming that voluntarily is what should make the rest credible.
I also kept two mechanisms that score ≈0 on the public set, deliberately: top-10 padding (0.0000 here, but an empty slot can never hit) and the observed-token fallback (−0.0008 on clean input, −0.0029 under heavy paraphrase, neither interval excluding zero). Both are kept as insurance against the 800 private sessions rather than as public-set optimisations — an argument from cost, since they are cheap and an unresolved interval is not a demonstration of absence, not an argument from measurement.
Feasibility & practicality
| Model / API | none |
| Token usage | 0 — zero by construction, not by estimate |
| Network access | none required — fully offline |
| Monetary cost | $0 |
| Dependencies | Python standard library only (pytest for tests) |
| Index build | ~12 s cold (one time), ~0.3 s warm from cache |
| Per-turn latency | 6 ms median, 40 ms p95, 111 ms max (scored loop); 12 ms / 75 ms / 172 ms if every session is driven to all ten turns |
| Memory | 226 MB resident, agent + index only |
| Full 200-session evaluation | ~6 s warm, ~18 s including a cold index build |
| Tests | 280 passing, including 53 adversarial |
Measured on an Intel i5-1340P laptop, CPU only, no GPU. Latency is measured over the 437
turns the scored loop actually runs, and over all 2,000 when every session is driven to ten
— not just cheap opening turns. Every figure is the median across three timed passes, so the
quoted maximum is a typical worst turn rather than the worst ever seen: the slowest single
turn in any pass was 183 ms. Index caching is best-effort
and wrapped in try/except, so a read-only judging sandbox simply rebuilds — slower, never
a failure. The system runs unchanged under the CPU, memory, timeout and network
restrictions the submission rules explicitly reserve the right to impose.
Development tools, APIs, libraries and data
Development tools: VS Code, Git, Windows 11 / PowerShell, Python 3.12.6, pytest,
plus tools/latency.py, which times respond() with time.perf_counter and reads
resident set size through the platform's own call (GetProcessMemoryInfo on Windows,
/proc/self/status on Linux, getrusage on macOS). tracemalloc is deliberately not
used: it roughly doubles the measured per-turn time, and a latency harness that perturbs
the latency it reports is worse than none. Claude (Anthropic)
was used as a coding assistant in the same sense as an IDE or a linter; every design
decision and every number reported here was verified by measurement against the official
evaluator in the repository.
APIs used: none. No LLM API, no external service, no network call at inference time. Token usage is zero by construction rather than by estimate.
Libraries and frameworks: on the scored path, none beyond the Python standard
library — no PyTorch, no Transformers, no scikit-learn, no pandas, no FAISS, no vector
database. The BM25 inverted index, the tokeniser, the slot state machine, the question
policy and the fp16 dense reader are all hand-written; pytest is a test dependency only.
Two optional extras exist and are never imported by the agent at scoring time, which I
would rather spell out than let the "stdlib only" claim quietly cover them.
tools/build_vectors.py — the offline script that builds the dense artifact — uses numpy,
scipy and sentence-transformers, and is a build-time tool that never runs at inference.
copilot/llm.py lazily imports anthropic only when the LLM layer is explicitly enabled by
both a config flag and an environment variable; with the default configuration the import
never executes. requirements.txt carries exactly one line — pytest, for the test suite.
pytest is used for the test suite only and is not required to run the agent.
Datasets and assets: the organiser's frozen participant kit — a 50,000-product catalog and 200 labelled public development sessions derived from Amazon Reviews 2023 (McAuley Lab, UCSD). The catalog is downloaded from the official release and SHA256-verified; it is not redistributed in the repository. No additional data was collected, scraped or labelled, and no organiser-only or private evaluation data is used anywhere.
What I learned
- The baseline's failure was in its output schema, not its ranker. I spent the first
pass improving retrieval and moved the score barely at all. The 9× came from reading the
protocol closely enough to notice that one field was permanently
None. - Measure the counter-intuitive option before discarding it. Erasing a retracted preference is the obvious behaviour, and it was the worst setting I tested.
- Build the harness that tries to break your own work. The paraphrase harness cost a day and revealed that a light rewording removed 67% of my score. Nothing in the ablation table was ever going to surface that.
- Report the zeroes, and make sure the zero is a measurement. MMR diversification measured exactly 0.0000 after a real implementation effort. It stays in the ablation table because leaving it out would misrepresent how this was built — and the row switches MMR on rather than off, because MMR ships off and "removing" it would have re-run the shipped configuration under a label saying otherwise, which can only ever report zero.
Limitations, and what I would do next
- Tuned on 200 public sessions; 800 private sessions decide the result.
- Hit Rate is perfect at 1.000, so the remaining headroom is entirely MRR (0.9567 of
1.0) and MTTC.
python -m tools.coverage_ceilingshows every one of the 41 products ranked above a target matches exactly the same constraints it does, so that headroom is not reachable by ranking: further public-set gains would be fitting the label. - Semantic retrieval is implemented but switched off, because it measured worse (below). Matching on the scored path is lexical, so a customer describing a product in words absent from its metadata is served poorly — on this benchmark that case barely occurs, and on a real storefront it would occur constantly.
- The paraphrase harness is my model of paraphrasing, not the organiser's; a paraphraser that also rewrote product attributes would degrade the agent further.
The first two items on this list are now built rather than planned, and both lost:
Offline dense retrieval (copilot/dense.py) encodes the catalog once and cosine-reranks
at inference — stdlib only, fp16 via struct and mmap, ~3.7 ms/turn, still no GPU and no
network at inference. It loses, and the interesting part is how it loses across three
encoder tiers built by the same script against the same catalog:
| encoder | dim | best Δ | 95% CI | Δ at w=3.0 | rows resolved |
|---|---|---|---|---|---|
| truncated SVD (LSA) | 128 | 0.0014 | [−0.0022, +0.0052] | −0.0359 | 5 of 10, all negative |
all-MiniLM-L6-v2 |
384 | 0.0002 | [−0.0020, +0.0024] | −0.0153 | 5 of 10, all negative |
BAAI/bge-base-en-v1.5 |
768 | −0.0001 | [−0.0003, 0.0000] | −0.0054 | 2 of 10, all negative |
A better encoder makes it less harmful and never resolvably helpful. The damage at high weight shrinks monotonically with model quality — −0.0359, −0.0153, −0.0054 — and the number of weights a paired bootstrap resolves as harmful falls from five to five to two: at 768 dimensions the layer has converged on being indistinguishable from doing nothing. No tier at any weight produces a gain whose interval excludes zero. That locates the ceiling in the task rather than the model: if the encoder were the binding constraint, tripling the dimension and moving from an unsupervised SVD to a contrastively-trained retriever would have flipped the sign somewhere. It moves the magnitude toward zero and never past it.
The structural reason, measured by python -m tools.constraint_stats: of the 602
constraint strings the simulator mines out of a target's own fields, 99.2% appear verbatim
in that target and 29.7% are unique to a single product in 50,000 (94.5% and 22.4% across
all 800 disclosable strings, including the ones the simulator synthesises rather than lifts).
Dense retrieval exists to close vocabulary mismatch, and this benchmark has almost none by
construction — so what it mostly does is blur an already-exact lexical match. It ships
switched off, behind a flag, so the ablation row reproduces.
An environment-gated LLM reranking layer (copilot/llm.py) is double-gated — a config
flag and COPILOT_LLM=1 — with a lazy import, silent total failure, permutation-
preserving sanitisation, and honest token accounting. Off by default, because the rules
warn that network access may be withdrawn at judging time and a scored path that depends on
it is a path that can score zero.
Next, in order: (1) a cross-encoder reranker trained on protocol-generated sessions and quantised to int8 ONNX, reranking the top ~30 — the only remaining lever on MRR, and one I will ship only if it wins on held-out targets rather than on the public set; (2) question selection over the true posterior — keep a distribution over candidate products and choose the question that minimises expected posterior entropy, rather than using per-attribute priors.
Built With
- bm25
- conversational-ai
- dialogue-state-tracking
- expected-value-decision-policy
- information-retrieval
- inverted-index
- no-llm
- offline-first
- pytest
- python
- python-standard-library
Log in or sign up for Devpost to join the conversation.