#!/usr/bin/env node /* * verify.js - independent verifier for the Holyhead Town Council 3G pitch draw. * * WHAT THIS IS FOR * This script lets anyone check the published allocation for themselves. It * takes only the files published on the results page and recomputes the whole * draw from scratch. If what it computes differs from what was published, it * says so and exits non-zero. * * It has no dependencies. You need only Node.js installed. * * node verify.js draw.json * * where draw.json is the published verification file downloaded from the * results page. * * DELIBERATELY A SEPARATE IMPLEMENTATION * This file does not import the council system's allocation engine. It is * written independently, from the published method, precisely so that it can * catch a mistake in that engine rather than repeat it. Do not "tidy" this by * making it share code with src/allocation/engine.js. That would destroy the * only thing it is for. * * THE METHOD, IN FULL * 1. For each organisation, compute SHA-256 of the text * + ":" + * for example "04-11-23-29-41-48:ORG-001", and take the lowercase hex. * 2. Sort the organisations by that hex string, smallest first. That is the * draw order. * 3. Going down the draw order, give each organisation the highest choice on * its list that nobody has taken yet. * 4. Any organisation whose choices were all taken, and which ticked "would * accept any remaining slot", waits until the end. Then, again in draw * order, each is given the remaining slot with the lowest slot id. * Slot ids sort as Monday to Friday, then ascending hour. * 5. Anything left over is an unallocated slot. */ var crypto = require('crypto'); var fs = require('fs'); function sha256Hex(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); } function recompute(published) { var numbers = published.numbers; var slots = published.slots.slice().sort(); var prefs = published.preferences; // Step 1 and 2: hash, then order by hash. var order = Object.keys(prefs).map(function (ref) { return { org_ref: ref, hash: sha256Hex(numbers + ':' + ref) }; }); order.sort(function (a, b) { return a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0; }); var used = {}; var result = {}; var waiting = []; // Step 3: everyone's best still-free choice, in draw order. for (var i = 0; i < order.length; i++) { var ref = order[i].org_ref; var list = prefs[ref].ranked; var won = null; var wonRank = null; for (var j = 0; j < list.length; j++) { if (!used[list[j]]) { won = list[j]; wonRank = j + 1; break; } } if (won !== null) { used[won] = ref; result[ref] = { slot_id: won, rank: wonRank }; } else if (prefs[ref].accept_any) { waiting.push(ref); } else { result[ref] = { slot_id: null, rank: 'none' }; } } // Step 4: the "any remaining slot" queue, lowest slot id first. for (var k = 0; k < waiting.length; k++) { var free = null; for (var s = 0; s < slots.length; s++) { if (!used[slots[s]]) { free = slots[s]; break; } } if (free !== null) { used[free] = waiting[k]; result[waiting[k]] = { slot_id: free, rank: 'any' }; } else { result[waiting[k]] = { slot_id: null, rank: 'none' }; } } var leftover = slots.filter(function (s) { return !used[s]; }); return { order: order, result: result, leftover: leftover }; } function verify(published) { var problems = []; var mine = recompute(published); // Check the draw order matches, position by position, hash by hash. var theirs = published.draw_order; if (theirs.length !== mine.order.length) { problems.push( 'draw order has ' + theirs.length + ' entries, expected ' + mine.order.length ); } else { for (var i = 0; i < theirs.length; i++) { if (theirs[i].org_ref !== mine.order[i].org_ref) { problems.push( 'position ' + (i + 1) + ': published ' + theirs[i].org_ref + ', recomputed ' + mine.order[i].org_ref ); } if (theirs[i].hash !== mine.order[i].hash) { problems.push( theirs[i].org_ref + ': published hash ' + theirs[i].hash + ', recomputed ' + mine.order[i].hash ); } } } // Check every allocation matches, both the slot and the rank it satisfied. var seenSlots = {}; for (var a = 0; a < published.allocations.length; a++) { var row = published.allocations[a]; var expect = mine.result[row.org_ref]; if (!expect) { problems.push(row.org_ref + ' appears in allocations but not in preferences'); continue; } if (row.slot_id !== expect.slot_id) { problems.push( row.org_ref + ': published slot ' + row.slot_id + ', recomputed ' + expect.slot_id ); } if (String(row.rank) !== String(expect.rank)) { problems.push( row.org_ref + ': published rank ' + row.rank + ', recomputed ' + expect.rank ); } if (row.slot_id) { if (seenSlots[row.slot_id]) { problems.push('slot ' + row.slot_id + ' allocated more than once'); } seenSlots[row.slot_id] = true; } } // Independently check the reserved slot never appears anywhere. var reserved = published.reserved_slots || []; for (var r = 0; r < reserved.length; r++) { if (published.slots.indexOf(reserved[r]) !== -1) { problems.push('reserved slot ' + reserved[r] + ' was offered as bookable'); } if (seenSlots[reserved[r]]) { problems.push('reserved slot ' + reserved[r] + ' was allocated'); } } // Check the leftover list matches. var pubLeft = (published.unallocated_slots || []).slice().sort().join(','); var myLeft = mine.leftover.slice().sort().join(','); if (pubLeft !== myLeft) { problems.push('unallocated slots: published [' + pubLeft + '], recomputed [' + myLeft + ']'); } return problems; } module.exports = { verify: verify, recompute: recompute, sha256Hex: sha256Hex }; // Command line use. if (require.main === module) { var file = process.argv[2]; if (!file) { console.error('Usage: node verify.js '); process.exit(2); } var data = JSON.parse(fs.readFileSync(file, 'utf8')); var problems = verify(data); console.log('Holyhead 3G pitch draw, independent verification'); console.log('Lottery numbers used: ' + data.numbers); console.log('Organisations in the draw: ' + data.draw_order.length); console.log(''); if (problems.length === 0) { console.log('PASS. The published allocation is exactly what the method produces'); console.log('from the published numbers and preferences.'); process.exit(0); } else { console.log('FAIL. ' + problems.length + ' discrepancy/discrepancies found:'); for (var p = 0; p < problems.length; p++) console.log(' - ' + problems[p]); process.exit(1); } }