Skip to content

Add next-watch: a GenRec-shaped ranker on one Choice per member - #16

Merged
aarora79 merged 7 commits into
aarora79:mainfrom
joelnishanth:add-next-watch-sample
Oct 5, 2026
Merged

aarora79 merged 7 commits into
aarora79:mainfrom
joelnishanth:add-next-watch-sample

Conversation

@joelnishanth

Copy link
Copy Markdown
Contributor

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 one Choice, and the probabilities that come back are the ranking.

  • Rules serve members with fewer than five likes a popularity list, with no call.
  • A second Choice, discovery, pulls toward titles outside a member's usual genres, blended by a weight in questions.yml.
  • fetch_movielens.py downloads MovieLens ml-latest-small and checks the MD5 GroupLens publishes. The CSVs stay out of git.
  • --user recommends for one member, --explain prints one member's state and every title under every ranker, and --baselines-only needs 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 in samples/next-watch/data/.

Ranker MRR Hit@1 Hit@5 Widens Distinct #1
random (expected) 0.180 0.050 0.250 n/a n/a
popularity 0.178 0.050 0.265 0.180 54
genre match 0.294 0.132 0.412 0.000 89
shortlist order 0.253 0.100 0.430 0.000 74
jev next_watch 0.431 0.255 0.648 0.020 86
jev blend 0.411 0.255 0.565 0.020 86
  • jev next_watch leads genre match by 0.137 MRR, paired bootstrap 95% interval 0.070 to 0.206, better for 64 members and worse for 26.
  • The discovery blend 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.
  • 100 calls, 242,771 input tokens, $0.0102 at $0.042 per million, 102 ms per call on average from the machine that ran it.

Verified

  • uv run python -m py_compile, uvx ruff check . and uvx ruff format --check . on both modules.
  • Live runs: an evaluation, --baselines-only, --explain on a ranked member (414) and 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 with agents_md_readiness.py, the same as main, above the 0.85 CI bar.

Not done

  • No Go port and no skill. The Python stands alone, as readme-check does.
  • No test suite, matching the repo.

Data

MovieLens ml-latest-small from 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).

@aarora79 aarora79 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-triage treats 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 above

We 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 security

3. 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 results entries, chosen to span the range rather than taken off the top. Every member in this run came back ranked, 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.
@joelnishanth
joelnishanth force-pushed the add-next-watch-sample branch from bde6ac7 to 81bd9e8 Compare October 5, 2026 22:11
@joelnishanth

Copy link
Copy Markdown
Contributor Author

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. questions.yml now carries your paragraph beside the runtime criteria explanation, and build_questions in next_watch.py says why it treats the option text as untrusted, right where it builds the options. The dict that holds them is now catalog_text_by_option, after pr-triage's habit of naming text for its source. The README's "What to notice" ends with your entry, extended to say that recent_likes and recent_dislikes carry the same catalog text. I kept the state field names, option keys and option descriptions as Jev sees them, since renaming those would move answers and void the recorded numbers; a capture of the request for five seed-7 members matches the old one byte for byte.

2. Bandit. Both lines carry # nosec B311 - seeded for reproducibility, not security, and so does the bootstrap's generator from point 3. uvx bandit -q -r . -x ./.venv in samples/next-watch reports no issues (the -x keeps the local virtualenv's third-party code out of the scan).

3. The committed report. data/movielens-small-100-members-seed-7-jev.json drops from 553 KB to 45.5 KB (45,528 bytes). It keeps the six metric rows, results_shown: 5, results_total: 100, and five members in full, which evaluate.py picks by rule: sort by jev next_watch reciprocal rank and take five even steps from best to worst. On this run that gives members 50, 80, 62, 332 and 520 at 1.000, 0.750, 0.250, 0.129 and 0.060. The README's bootstrap also needs every member, so the file adds a reciprocal_ranks table (100 members by five rankers, four places), and the evaluation now computes the bootstrap, stores it and prints it. I rebuilt the file from the recorded 30 September run without new calls, and checked that the metrics, the timestamp, the model and the five kept answers match the original byte for byte. uv run next_watch.py --bootstrap data/movielens-small-100-members-seed-7-jev.json recomputes it with no key and no calls and prints: 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. The markdown report stays as the 30 September run printed it, as you asked. Later runs print the bootstrap as one more line, and the README says so under its output block.

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. evaluate.py holds the hold-out split, the sampled negatives, per-member scoring, the metrics and their notes, the bootstrap, the explain view and the reports (883 lines). next_watch.py keeps the catalog, the shortlist, the state, the Jev call, the blend, recommend() and main() (910 lines), so every README command still starts there. evaluate.py imports next_watch, and main() imports evaluate at call time, which keeps the two from importing each other while they load. The helpers evaluate.py calls became public, and the AGENTS.md repo map lists the new file. After the move, --baselines-only on seed 7 wrote a markdown report byte-identical to the one before it.

Verification, 5 October 2026 against jev-1.13.0, 15 calls. py_compile on all three files, ruff check and ruff format --check clean, bandit clean. Live: an evaluation (--users 10 --seed 99 --explain 54, which printed the bootstrap line and served member 54 as cold-start with no call), --explain 414, --baselines-only, --user 414 --verbose --debug, --user 54, and --bootstrap on the committed file and on a scratch report. AGENTS.md scores 0.94 readiness on agents-md-readiness, as before.

@aarora79
aarora79 merged commit 1bc4121 into aarora79:main Oct 5, 2026
1 check passed
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.

2 participants