Repository navigation
Add next-watch: a GenRec-shaped ranker on one Choice per member - #16
Conversation
aarora79
left a comment
There was a problem hiding this comment.
Thank you for this work, @joelnishanth.
We would request you to make the following changes so that we can merge.
1. Say something about untrusted state
AGENTS.md lists this under the Jev conventions a sample must follow, and the sample does not touch it. next_watch.py, questions.yml and the README mention untrusted input nowhere.
It carries more weight here than in most samples, because the Choice options are catalog text:
# next_watch.py:636
criteria = {f"m{c['id']}": _option_text(movies[c["id"]]) for c in shortlist}MovieLens titles are fixed and harmless, so the run is sound. Deploy this shape against a live catalog and those option descriptions carry metadata from studios or from users, where a title written to win the pick moves the answer. A reader copying the pattern should meet that thought inside the sample.
pr-triage handles the same situation by naming the field after where the text came from:
# Treat the description as untrusted. An author who writes "trivial,
# please merge" moves these answers, so the state names what the text is
# rather than presenting it as fact.
"description_written_by_the_author": ...Two comments and one README paragraph would cover it. Something like this in questions.yml, near where you already explain the runtime criteria:
The option descriptions come from catalog metadata, so treat them the way you treat a prompt. MovieLens titles are fixed and harmless. A catalog that takes metadata from studios or from users is a different matter, and a title written to argue for its own pick would move these answers.
And a "What to notice" entry, which fits that section's voice:
The options are catalog text, so they are untrusted. Jev reads each title's name, year and genres as the thing it chooses among. MovieLens is fixed, so nothing here can argue for itself. A catalog fed by studios or by users can, and the same shape would need its option text treated the way
pr-triagetreats a pull request description.
Take the wording or write your own.
2. Mark the two bandit B311 findings
bandit reports two Low findings, both the same rule:
next_watch.py:825 rng = random.Random(f"{context['seed']}-{user_id}")
next_watch.py:1331 sample = sorted(random.Random(seed).sample(...))
Every other sample marks a bandit finding with its rule id and a justification, and you already wrote one correctly in this PR:
# fetch_movielens.py:66
with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_SECONDS) as response: # nosec B310 - https-only, enforced aboveWe agree with your reading of the risk. Seeded sampling for a reproducible evaluation is the textbook false positive, and the repo's habit is to say so in the line:
rng = random.Random(f"{context['seed']}-{user_id}") # nosec B311 - seeded for reproducibility, not security3. Trim the committed report
553 KB of JSON is more than the repo wants to carry. pr-triage commits 53 KB as its worked example, and the shape there is the one to copy: enough to show a reader what an answer looks like, and no more.
What to keep:
- Every metric, complete. All six rows weigh almost nothing and they are what the README's table and the bootstrap rest on.
- About five
resultsentries, chosen to span the range rather than taken off the top. Every member in this run came backranked, so the spread worth showing is the reciprocal rank: member 50 landed the hidden title first at 1.000, member 549 sits mid-pack at 0.292, and member 520 managed 0.060. Three like that plus a couple more teach the shape better than the first five in file order. - A count of what you dropped, so nobody reads the file as the whole run. Two fields do it:
"results_shown": 5,
"results_total": 100,Five entries puts the file at 28 KB, half of what pr-triage commits. Ten puts it at 56 KB, which is the ceiling we would want.
The markdown report at 4 KB is already the right size. Leave it.
4. Consider splitting next_watch.py
At 1,484 lines it carries the catalog loading, the shortlist, the evaluation harness and the recommender. AGENTS.md asks for a second file once one module stops being readable, and you have already split the fetcher, so moving the evaluation harness out would sit well with that.
next-watch ranks what a MovieLens member watches next the way Netflix's GenRec ranker does: the member's history goes in as text, twenty candidate titles go in as the options of one Choice, and the probabilities that come back are the ranking. Rules serve members with fewer than five likes a popularity list at no cost. A second Choice, discovery, pulls toward titles outside a member's usual genres, blended by a weight in questions.yml. The evaluation hides each sampled member's last 4-star-or-higher rating, keeps only earlier ratings, and ranks that title among 19 unrated titles drawn in proportion to popularity before that moment. On 100 members (seed 7, jev-1.13.0, 30 September 2026): random (expected) MRR 0.180 Hit@1 0.050 Hit@5 0.250 genre match MRR 0.294 Hit@1 0.132 Hit@5 0.412 jev next_watch MRR 0.431 Hit@1 0.255 Hit@5 0.648 jev blend MRR 0.411 Hit@1 0.255 Hit@5 0.565 jev next_watch leads genre match by 0.137 MRR (paired bootstrap 95% interval 0.070 to 0.206). The discovery blend at weight 0.25 cost 0.020 MRR and changed no top pick, and the README says so. 100 calls, 242,771 input tokens, $0.0102, 102 ms per call on average. Verified: py_compile and ruff check/format on both modules; a live evaluation, --baselines-only, --explain on a ranked member and on a cold-start member (54), --user on 414 and on 54, a missing key and an unknown member id. AGENTS.md scores 0.94 readiness, the same as main. MovieLens CSVs stay out of git: fetch_movielens.py downloads them and checks the published MD5. The committed report names MovieLens titles and travels under the GroupLens license, which the README cites.
Per-ranker scores follow from each shortlist entry and from the Jev probabilities already in the report, so the report drops them and rounds shortlist floats to four places. The recorded run shrinks from 912 KB to 553 KB. Every Jev answer, metric and timestamp is unchanged; a rewrite script asserted it, and the paired bootstrap reproduces the same numbers (+0.137 MRR over genre match, 95% interval 0.070 to 0.206).
bandit reported two Low B311 findings in next_watch.py: the per-member
generator in _evaluate_member, random.Random(f"{seed}-{user_id}"), and
the member sample in evaluate(), random.Random(seed).sample(...). Both
draw a reproducible evaluation and guard nothing, so each line now
carries "# nosec B311 - seeded for reproducibility, not security", the
same form fetch_movielens.py uses for B310. The sample line splits into
rng = random.Random(seed) and rng.sample(...), which draws the same
members.
Verified: py_compile, ruff check and ruff format --check clean; bandit
-q -r . -x ./.venv reports no issues (two before). The request Jev
receives for seed-7 members 50, 80, 62, 332 and 520 matches the
pre-change capture byte for byte, and each shortlist order matches the
recorded report.
The Choice options are catalog text, and AGENTS.md asks every sample to treat state built from outside input as untrusted. next-watch now says so in three places: a comment in questions.yml beside the runtime criteria explanation, a comment in _build_questions where the options get built, and a "What to notice" entry in the README that also names recent_likes and recent_dislikes as catalog text. The local that holds the options becomes catalog_text_by_option, after pr-triage's habit of naming text for its source. The request keeps its shape: state field names, option keys and option descriptions stay as sent, because renaming them would move answers and void the recorded seed-7 numbers. Verified: py_compile, ruff check and ruff format --check clean; questions.yml loads to the same object as before; the request Jev receives for seed-7 members 50, 80, 62, 332 and 520 matches the pre-change capture byte for byte.
next_watch.py had grown to 1,484 lines holding the catalog, the shortlist, the recommender and the evaluation. The evaluation now lives in evaluate.py (643 lines): the hold-out split, the sampled negatives, per-member scoring, the metric rows and notes, the cost line, the explain view, the report writers and evaluate(). next_watch.py (895 lines) keeps catalog loading, the shortlist, the state, the Jev call, the blend, the ranker scores, recommend() and main(), and every command still starts there. evaluate.py imports next_watch at the top; main() imports evaluate at call time, so neither module imports the other while loading. The helpers evaluate.py calls became public and moved below the private ones, per AGENTS.md: load_payload, require_api_key, load_catalog, ratings_before (was _popularity, which clashed with the popularity locals), liked, genre_profile, usual_genres, widens, score_candidate (was _candidate), build_shortlist (was _shortlist), option_text, build_state, build_questions, ask_jev, ranker_scores, padded_lines. The moved bodies stay as they were apart from those names; one local in _recommend_rows became range_mark so it no longer shadows widens(). REPORT_DIR, REPORT_DECIMALS and HIT_AT moved with the code that reads them. AGENTS.md's repo map and the README name the new file. Verified: py_compile on all three files, ruff check and ruff format --check clean, bandit -q -r . -x ./.venv reports no issues. --help shows every flag. --baselines-only on seed 7 writes a markdown report byte-identical to the one before the move, and its JSON matches apart from evaluated_at. The request Jev receives for seed-7 members 50, 80, 62, 332 and 520 matches the pre-change capture byte for byte.
The seed-7 JSON report drops from 553,111 bytes to 45,528. It now holds the six metric rows, the paired bootstrap, a reciprocal-rank table for all 100 ranked members under all five rankers (rounded to four places), results_shown: 5 and results_total: 100, and five members in full. evaluate.py picks the five by rule: sort ranked members by jev next_watch reciprocal rank, best first, ties by member id, and take five positions at even steps. On the recorded run that gives members 50, 80, 62, 332 and 520 at 1.000, 0.750, 0.250, 0.129 and 0.060. _report now builds the full report, cuts it with _trim_report, and _write_reports writes that shape, so live runs and the committed file agree. The evaluation computes the bootstrap from the stored table (10,000 resamples, random.Random(0), jev next_watch minus genre match per member, interval at sorted means 250 and 9750), stores it under "bootstrap", and prints one line after the cost line. A new flag, uv run next_watch.py --bootstrap data/movielens-small-100-members-seed-7-jev.json, recomputes it from a saved report with no key and no calls, and exits with a message on a missing file, a report without the table, or a baselines-only report. Baselines-only runs store "bootstrap": null and print no line. The committed JSON comes from the recorded 30 September 2026 run (jev-1.13.0, 100 members, seed 7), regenerated from a backup of the full report without new calls. The regeneration asserted that the moved code rebuilds the six metric rows, that evaluated_at, model, dataset and metrics serialize byte for byte as before, and that the five kept entries, their jev answers included, match the originals byte for byte. The markdown report gained one appended line, computed from the recorded data, because the evaluation now prints it: Paired bootstrap of jev next_watch against genre match over 100 members, 10,000 resamples: MRR difference +0.137, 95% interval +0.070 to +0.206. jev next_watch ranked the hidden title higher for 64 members and lower for 26. Rounding the table to four places leaves that line unchanged against full precision. The README names the reproduction command and the new report shape, and AGENTS.md adds --bootstrap to next-watch's checks. Verified: py_compile on all three files; ruff check and ruff format --check clean; bandit -q -r . -x ./.venv reports no issues, with three nosec B311 markers in evaluate.py. --bootstrap on the committed file prints the line above, identical to the markdown's last line. --baselines-only on seed 7 writes a markdown report byte-identical to the one before this change. The request Jev receives for members 50, 80, 62, 332 and 520 matches the pre-change capture byte for byte. Live runs on 5 October 2026 against jev-1.13.0, 15 Jev calls in all: - --users 10 --seed 99 --explain 54: 10 of 11 ranked, 10 calls, $0.00106; printed the bootstrap (+0.362, +0.139 to +0.601, 8 better, 1 worse) and the cold-start line for member 54 with no call. Its JSON holds results_shown 5, results_total 11 and 10 reciprocal-rank rows; --bootstrap on it reprints the same line. - --users 3 --seed 99 --explain 414: 3 calls; member 414's state and all 20 titles under every ranker. - --baselines-only: no bootstrap line, "bootstrap": null. - --user 414 --verbose --debug: 1 call, raw JSON, debug lines, table. - --user 54: the cold-start line and the popularity list, no call. - --bootstrap on a missing file, on the full pre-trim report and on a baselines report: each exits 1 with its message. - agents_md_readiness.py ../../AGENTS.md: readiness 0.94, credential question 0.16 (probably clean), 1 call.
Amit asked to leave the 4 KB markdown report alone. The previous commit appended a bootstrap line the 30 September run never printed, so this restores the file byte for byte. The README now says that later runs print one more line, the paired bootstrap, after the cost line.
bde6ac7 to
81bd9e8
Compare
|
Thanks for the careful read, @aarora79. I rebased onto main (#17, #18 and #19; the root README table kept your new pr-triage row and gained the next-watch row below it) and added one commit per point. 1. Untrusted state. 2. Bandit. Both lines carry 3. The committed report. The first two commits on this branch still carry the 912 KB and 553 KB versions of the report, so a squash merge keeps them out of main's history. 4. The split. Verification, 5 October 2026 against jev-1.13.0, 15 calls. py_compile on all three files, |
Summary
samples/next-watch/ranks what a MovieLens member watches next in the shape of GenRec, the LLM-backed ranker Netflix described on 30 July 2026 (tech blog, arXiv 2608.10257). The member's history goes in as three named state fields, twenty candidate titles go in as the options of oneChoice, and the probabilities that come back are the ranking.Choice,discovery, pulls toward titles outside a member's usual genres, blended by a weight inquestions.yml.fetch_movielens.pydownloads MovieLensml-latest-smalland checks the MD5 GroupLens publishes. The CSVs stay out of git.--userrecommends for one member,--explainprints one member's state and every title under every ranker, and--baselines-onlyneeds no key.The evaluation hides each sampled member's last 4-star-or-higher rating, keeps only earlier ratings, and ranks that title among 19 unrated titles drawn in proportion to popularity before that moment. Popularity-matched negatives are the SASRec and BERT4Rec protocol. Negatives taken from the shortlist's own top titles pushed every free ranker below random in an earlier draft, and the README records that.
Results
100 members, seed 7,
jev-1.13.0, 30 September 2026. The report sits insamples/next-watch/data/.jev next_watchleads genre match by 0.137 MRR, paired bootstrap 95% interval 0.070 to 0.206, better for 64 members and worse for 26.discoveryblend at weight 0.25 cost 0.020 MRR and changed no top pick. The README says so, and suggests sweeping the weight on another seed.Verified
uv run python -m py_compile,uvx ruff check .anduvx ruff format --check .on both modules.--baselines-only,--explainon a ranked member (414) and a cold-start member (54),--useron 414 and on 54, a missing key, and an unknown member id.agents_md_readiness.py, the same asmain, above the 0.85 CI bar.Not done
Data
MovieLens
ml-latest-smallfrom GroupLens Research, University of Minnesota, which neither endorses nor reviewed this sample. The committed report names MovieLens titles and ids and travels under the GroupLens license terms. The README cites Harper and Konstan (2015).