diff --git a/flake.nix b/flake.nix
index ced12c3eac79a6691425414f0e92e8d70a91f102..3d9407d352a327bf35f4f1cce936455cd5736763 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,6 +1,6 @@
 {
   inputs = {
-    nixpkgs.url = "github:nixos/nixpkgs?ref=23.05";
+    nixpkgs.url = "github:nixos/nixpkgs?ref=branch-off-24.11";
     flake-utils.url = "github:numtide/flake-utils";
     typst = {
       url = "github:typst/typst/main";
diff --git a/stud_proj_alloc.py b/stud_proj_alloc.py
new file mode 100755
index 0000000000000000000000000000000000000000..9a0a5d3baa3a7589042f9553763410fe6ae5c746
--- /dev/null
+++ b/stud_proj_alloc.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+import collections
+import copy
+import random
+
+import numpy as np
+import pandas as pd
+
+def moodle_poll_csv_filename_to_df(filename, vote_field='Q01_preference'):
+    df = pd.read_csv(filename)
+    df.rename(columns={
+        'Nom complet': 'voter_name',
+        vote_field: 'vote',
+        'Soumis le :': 'date',
+    }, inplace=True)
+    df = df[['date', 'voter_name', 'vote']]
+    return df
+
+def check_vote(vote):
+    if not isinstance(vote, str):
+        return False
+    if len(vote) != 4:
+        return False
+    if ''.join(sorted(vote)) != '1234':
+        return False
+    return True
+
+def serial_dictatorship(vote_df):
+    empty_slots = {str(x+1):3 for x in range(4)}
+    alloc = []
+    for _, row in vote_df.iterrows():
+        allocated = False
+        for topic in row['vote']:
+            if empty_slots[topic] > 0:
+                alloc.append((row['voter_name'], topic))
+                empty_slots[topic] = empty_slots[topic] - 1
+                allocated = True
+                break
+        if not allocated:
+            raise Exception('Could not find any available slot for student {}, whose vote is {}'.format(row['voter_name'], row['vote']))
+    return sorted(alloc)
+
+def alloc_rank(vote, got):
+    # returns the rank of the choice that a voter (that voted 'vote') obtained in a given allocation
+    # 0 is the best, 3 the worst
+    for rank, value in enumerate(vote):
+        if value == got:
+            return rank
+    raise Exception(f'no rank found from vote={vote} when got={got}')
+
+def compare_alloc(allocA, allocB, vote_df):
+    # return how many voters prefer A to B. can be negative or 0.
+    prefA = 0
+    prefB = 0
+    for _, row in vote_df.iterrows():
+        rankA = alloc_rank(row['vote'], allocA[row['voter_name']])
+        rankB = alloc_rank(row['vote'], allocA[row['voter_name']])
+        if rankA < rankB:
+            prefA += 1
+        elif rankB < rankA:
+            prefB += 1
+    return prefA, prefB
+
+candidates = ['1', '2', '3', '4']
+
+# example from CSV
+#df = moodle_poll_csv_filename_to_df('./proj-preferences.csv')
+#df_valid = df[df['vote'].apply(check_vote)]
+#df_latest = df_valid.sort_values(by='date').groupby('voter_name', as_index=False).last()
+
+# random data
+random_data = []
+random.seed(2)
+for stud_id in range(12):
+    stud_name = f"stud{stud_id:02d}"
+    stud_vote = copy.deepcopy(candidates)
+    random.shuffle(stud_vote)
+    stud_vote = ''.join(stud_vote)
+    random_data.append({'voter_name': stud_name, 'vote': stud_vote})
+random_df = pd.DataFrame(random_data)
+
+# generate many allocations from various random order traversal of votes, only keep unique solutions
+df = random_df
+pareto_optimal_allocs = dict()
+alloc_values_seen = set()
+for seed in range(100):
+    alloc_name = f'seed{seed:03d}'
+    reordered_df = df.sample(frac=1, random_state=seed)
+    alloc = serial_dictatorship(reordered_df)
+    alloc_values = ''.join([topic for stud,topic in alloc])
+    if alloc_values not in alloc_values_seen:
+        pareto_optimal_allocs[alloc_name] = alloc
+        alloc_values_seen.add(alloc_values)
+print(len(alloc_values_seen))
\ No newline at end of file