Introduction
The game library can identify games usable tonight. Now it needs a transparent list of similar alternatives, with enough explanation for a person to choose. You will calculate distances from two published features, show signed differences in their original units, and preserve every game tied at the selection boundary.
This lab assumes the previous lab's Python eligibility and MinMax scaling concepts. It starts independently: all inputs and the completed suitability helper are already present. You need no files or running processes from another environment. You will implement ranking and produce real JSON files that a supplied reader displays without filling in results. The 50-minute duration is an author estimate. These content similarities do not predict personal enjoyment or establish performance on large catalogs.
Measure Every Suitable Alternative
In this step, you will build a full distance table and show what each candidate changes compared with the reference game.
Open the Terminal Tab and enter the independent workspace. Opening or reconnecting this entry does not reinstall the workspace or erase progress. cd sets this terminal's working directory; repeat it in any new terminal.
cd /home/labex/project/board-games
The catalog uses unique game_id keys, display-only titles, play time in minutes, a 1–5 complexity rating and inclusive min_players/max_players. The separate availability CSV contains one boolean true/false per ID in a different order. night.json identifies the query example, tonight's players and the desired k. The reference can be unavailable or unsuitable itself; only returned candidates need tonight's eligibility. Same-title different IDs and identical-feature different IDs remain legal, including zero distance. Only the exact reference_id is excluded.
cat prints a text file without changing it. Review the request and the supplied helper, which independently supplies the previous lab's completed work.
cat night.json
cat suitability.py
Run that existing interface before adding a distance calculation. --mode suitable invokes choose_games; required --output names the JSON file to write. With no other input options, the runner reads catalog.csv, availability.csv and night.json from the current directory. The reader prints only the file you saved.
python3 planner.py --mode suitable --output suitable.json
python3 show_results.py suitable.json
You should see the six alphabetical eligible IDs left, near, raw, right, same and zero, with the scale below. This is a working eligibility result, not a completed recommendation: the ranking module still has empty implementation regions.
choose_games joins availability by ID and returns every eligible ID plus a scale learned from the entire catalog, before filtering: feature order minutes, then complexity. scale.min is [10, 1] and scale.range is [100, 4]. Use those same parameters for the query and every candidate. Borrowing a game must not move the ruler. There are no target labels and no supervised train/test split.
The runner planner.py handles command-line options and file formats through coursekit.py. It does not select or rank games. You may edit suitability.py, ranking.py and generated results; preserve all supplied CSV/JSON inputs, planner.py, coursekit.py, show_results.py and the two starter-*.py files.
For a value x, MinMax scaling is (x - minimum) / range. A constant feature saves range zero but uses divisor one; because the query is a catalog member its differences on that feature are all zero. With the scaled query q and scaled candidate c, the Euclidean distance is sqrt((c_minutes-q_minutes)^2 + (c_complexity-q_complexity)^2). Both features have equal weight after scaling. A smaller distance means more similar under this declared rule, not more enjoyable.
The raw explanation is separate: minutes_delta and complexity_delta are candidate minus reference, before scaling. A negative minutes difference means a shorter game. These columns must come from the actual candidate with that ID, not from another row or from its distance.
Inspect the ranking scaffold before editing it. from suitability import choose_games imports the local helper. The supplied prepared, by_id and reference lines connect that output and the query to ID-keyed catalog records. The empty rows list, candidate loop, sort and final return provide the surrounding structure. Three comments mark the regions you will complete; they do not calculate the missing distances or selection.
cat ranking.py
Make a source backup. cp copies the current file; this backup is the unfinished base, not a completed answer.
cp ranking.py ranking.py.bak
np.where chooses one for a zero range and otherwise uses the real range. np.linalg.norm gives the Euclidean length of the two-element difference vector. float converts NumPy values into JSON-compatible numbers. The supplied sort key is a pair: first the unrounded distance, then game_id if distances are exactly equal. The short lambda defines that key for each row. Do not round before sorting. Tiny arithmetic differences between equivalent formulas can change the order of nearly equal values; the displayed decimal places are not the stored ordering rule.
Open the module in nano. As in the first lab, LC_ALL=C.UTF-8 applies only to this editor invocation. Keep four spaces per indentation level; Python uses indentation to group statements.
LC_ALL=C.UTF-8 nano ranking.py
Press Ctrl+W, search for ## Metric parameters and press Enter. Move down to the following pass, press Home, then Ctrl+K once to remove that placeholder line. Insert these five lines with their four leading spaces. Preserve rows = [] and the surrounding function.
minimum = np.array(prepared['scale']['min'])
ranges = np.array(prepared['scale']['range'])
divisor = np.where(ranges == 0, 1, ranges)
query = np.array([reference['minutes'], reference['complexity']])
query_scaled = (query - minimum) / divisor
Next press Ctrl+W, search for ## Candidate record and press Enter. Move down to that region's pass, press Home, and Ctrl+K once. Insert the following block. It is inside the existing loop, so its statements begin with eight spaces; the fields inside rows.append use twelve. Keep the for line, game = by_id[game_id], and the later rows.sort unchanged.
values = np.array([game['minutes'], game['complexity']])
candidate_scaled = (values - minimum) / divisor
distance = float(np.linalg.norm(candidate_scaled - query_scaled))
difference = values - query
rows.append({
'game_id': game_id, 'title': game['title'], 'distance': distance,
'minutes_delta': float(difference[0]),
'complexity_delta': float(difference[1]),
})
Leave the ## Cutoff selection region as cutoff = None and selected = [] for now. It makes this intermediate table readable while honestly leaving selection unfinished. Press Ctrl+O, Enter, then Ctrl+X to save and exit. If you notice an unwanted edit before saving, Ctrl+X, then N discards this editor session; reopen and correct only the intended region.
Run planner.py in recommend mode. --output names your real output file. With no other input options it reads the current directory's catalog.csv, availability.csv and night.json. --catalog, --availability and --request can supply explicit paths.
python3 planner.py --mode recommend --output recommendations.json
Display your saved JSON using the supplied reader. This reader only opens that file; it does not recompute from the catalog or supply missing rows.
python3 show_results.py recommendations.json
You should see six candidate rows. zero has distance zero, both near and same are about 0.1 away, and left/right are 0.3 away. raw is about 0.5001 away, even though it is only one minute longer: its complexity differs by two points. Minutes +/- and Complexity +/- display the signed original-unit differences. The first version intentionally has Cutoff: None and no selected IDs. Your next step supplies the selection rule.
If a SyntaxError or IndentationError appears, read its file and line number, reopen ranking.py, and correct that line or region. Do not overwrite the whole module to repair one line. A missing-file error usually means the terminal is in the wrong directory: pwd displays it, and the opening cd restores the right location, as practiced in the first lab. cp ranking.py.bak ranking.py restores the unfinished base only if you deliberately want to abandon this step's work. Preserve the input files rather than editing them to make a result pass.
Preserve the Full Cutoff Set
In this step, you will complete the selection rule, then use the same program on nights with fewer candidates and no candidates.
Continue in the Terminal Tab. All commands use this working directory.
cd /home/labex/project/board-games
Here k is a target minimum when enough suitable games exist, not a promise of exactly k rows. First calculate every eligible distance and sort. If there are N candidates, the cutoff is the distance at position min(k, N) in that ordered list. Python list positions start at zero, so its index is min(k, N) - 1. Keep every candidate whose unrounded distance is at most cutoff + 1e-9. The absolute 1e-9 allowance defines a cutoff tie; it is not a sorting bucket and does not authorize rounding distances.
If fewer than k games qualify, return all of them. With no candidates, return an empty candidates list, empty selected_ids and a null cutoff in JSON (None in Python). Do not fall back to an unavailable or unsuitable game. Taking the nearest k across the whole catalog before applying eligibility can lose later legal alternatives; the eligibility-first helper prevents that mistake.
The normal request has k=2. zero is the closest candidate, and near and same share the cutoff under the tie rule. All three must be selected. Their order follows the actual unrounded sorted rows; game_id breaks exact distance ties. The full six-row candidate table remains, so readers can see what was considered.
Back up your working distance table before adding selection. This copy retains the metric and candidate records you have just tested.
cp ranking.py ranking-distances.py.bak
Open the same module, without replacing its earlier work.
LC_ALL=C.UTF-8 nano ranking.py
Press Ctrl+W, search for ## Cutoff selection and press Enter. Move down to cutoff = None, press Home, then Ctrl+K twice to remove only that line and selected = []. Insert this block with four spaces for if and else, and eight inside each branch. The final return stays after the block at its existing indentation.
if rows:
cutoff = rows[min(request['k'], len(rows)) - 1]['distance']
selected = [row['game_id'] for row in rows
if row['distance'] <= cutoff + 1e-9]
else:
cutoff = None
selected = []
The if rows guard prevents indexing an empty list. A list comprehension keeps matching IDs in the existing sorted order. Preserve the metric parameters, candidate loop and sort from step 1. Save with Ctrl+O, Enter, and exit with Ctrl+X. If you need to back out of this step, cp ranking-distances.py.bak ranking.py restores the tested distance-table version, with selection still incomplete; make the cutoff edit again rather than repeating the whole file.
Your JSON output requires scale.min and scale.range in feature order, every candidate's game_id, title, numeric distance, signed minutes_delta and complexity_delta, plus cutoff and selected_ids. Save finite numbers at their computed precision, not rounded strings. Additional harmless explanation fields are allowed. Grading checks each ID's values with absolute and relative numeric tolerances of 1e-8. That numeric allowance is different from the public 1e-9 cutoff-tie rule. Equivalent formulas and ordinary implementation choices are accepted; personal preferences are not graded. Both public interfaces must remain usable: choose_games returns alphabetical eligible_ids and the full-catalog scale, while rank_games returns the recommendation result. Ranking may use an equivalent independent implementation; it does not have to call the helper internally. No extra live suitability artifact is required in this lab.
Regenerate and inspect the normal result.
python3 planner.py --mode recommend --output recommendations.json
python3 show_results.py recommendations.json
zero, near and same should now be selected; the other candidate rows remain visible. For an unformatted view, cat recommendations.json prints the saved JSON, including its unrounded distances.
Two additional prepared request files change only the night, not the catalog or lending-state inputs. cat lets you inspect the selected request first. --request chooses it for that one command; it does not alter the default night.json or set a terminal variable. The distinct output path preserves your normal result.
cat sparse-night.json
python3 planner.py --mode recommend --request sparse-night.json --output sparse.json
python3 show_results.py sparse.json
Eight players can use only right, even though k=5. One honest candidate is the correct result. The scale is still the full-catalog scale, and its distance remains 0.3.
cat empty-night.json
python3 planner.py --mode recommend --request empty-night.json --output empty.json
python3 show_results.py empty.json
Twenty players have no suitable games. Expect no table rows, Cutoff: None, and the explicit no-suitable-games message. In Terminal, cat empty.json shows that its saved cutoff is null. The reader is showing your empty result, not selecting a fallback.
All three result files are required: recommendations.json, sparse.json and empty.json. They must correspond to their respective requests and remain rerunnable from unchanged inputs. To recover from an accidental code edit, reopen nano and correct the affected region, preserving your other completed work; then rerun all three commands. cp starter-ranking.py ranking.py restores only the incomplete starter if you want to practice again. Reopening the terminal does not reset data or rerun setup.
Summary
You measured every eligible candidate with the same catalog-defined scale, explained each distance with real signed original-unit differences, and selected all cutoff ties without rounding first. Your three saved files cover an ordinary night, fewer-than-requested candidates and zero candidates. The recommendation is transparent content similarity, not a prediction of what someone will enjoy.



