Skip to content

Commit 3fbd4e5

Browse files
kronosapiensclaude
andauthored
Remove unused select() method from PowerRanker (#115)
The select() method has been superseded by activeSelect(), which uses a more sophisticated multi-signal approach (coverage, proximity, position) instead of simple variance weighting. Remove dead code: select(), getVariances(), getVariance(), SelectOptions, ImpactTransform. Update README to document the actual bidirectional encoding and activeSelect() API. Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 4da912b commit 3fbd4e5

4 files changed

Lines changed: 15 additions & 317 deletions

File tree

backend/src/lib/power/PowerRanker.test.ts

Lines changed: 0 additions & 228 deletions
Original file line numberDiff line numberDiff line change
@@ -168,54 +168,6 @@ describe('PowerRanker', () => {
168168
});
169169
});
170170

171-
describe('generating variances', () => {
172-
test('returns uniform variances with no preferences', () => {
173-
const ranker = new PowerRanker({
174-
items: makeItems(ITEM_A, ITEM_B, ITEM_C),
175-
options: { k: K },
176-
});
177-
178-
const pairs = ranker.select();
179-
180-
// 3 items → 3 pairs
181-
expect(pairs).toHaveLength(3);
182-
183-
// With pseudocount k=0.15, off-diagonal cells are 0.15
184-
// Beta(0.15 + 1, 0.15 + 1) = Beta(1.15, 1.15)
185-
// Variance = (a * b) / ((a + b + 1) * (a + b)^2)
186-
const a = 0.15 + 1;
187-
const b = 0.15 + 1;
188-
const expectedVar = (a * b) / ((a + b + 1) * (a + b) ** 2);
189-
190-
for (const p of pairs) {
191-
expect(p.weight).toBeCloseTo(expectedVar);
192-
}
193-
});
194-
195-
test('variances decrease with preferences', () => {
196-
const ranker = new PowerRanker({
197-
items: makeItems(ITEM_A, ITEM_B, ITEM_C),
198-
options: { k: K },
199-
});
200-
201-
const beforePairs = ranker.select();
202-
203-
ranker.addPreferences([pref(ITEM_A, ITEM_B, 1)]);
204-
205-
const afterPairs = ranker.select();
206-
207-
// The A-B pair should have lower variance (more data)
208-
const abBefore = beforePairs.find(
209-
(p) => p.alpha === String(ITEM_A) && p.beta === String(ITEM_B)
210-
)!;
211-
const abAfter = afterPairs.find(
212-
(p) => p.alpha === String(ITEM_A) && p.beta === String(ITEM_B)
213-
)!;
214-
215-
expect(abAfter.weight).toBeLessThan(abBefore.weight);
216-
});
217-
});
218-
219171
describe('without pseudocounts', () => {
220172
test('strong preferences converge sharply without k', () => {
221173
const ranker = new PowerRanker({
@@ -233,186 +185,6 @@ describe('PowerRanker', () => {
233185
expect(score(rankings, ITEM_C)).toBeCloseTo(0, 10);
234186
});
235187
});
236-
237-
describe('selecting pairs', () => {
238-
test('selects the requested number of pairs', () => {
239-
const ranker = new PowerRanker({ items: new Set(['1', '2', '3']) });
240-
const pairs = ranker.select({ num: 2 });
241-
expect(pairs).toHaveLength(2);
242-
});
243-
244-
test('returns all pairs when num exceeds available', () => {
245-
const ranker = new PowerRanker({ items: new Set(['1', '2', '3']) });
246-
const pairs = ranker.select({ num: 10 });
247-
// 3 items → 3 pairs
248-
expect(pairs).toHaveLength(3);
249-
});
250-
251-
test('selects without replacement', () => {
252-
// 5 items → 10 pairs; request 5 — must be unique
253-
const ranker = new PowerRanker({ items: new Set(['1', '2', '3', '4', '5']) });
254-
const pairs = ranker.select({ num: 5 });
255-
const keys = pairs.map((p) => pairKey(p.alpha, p.beta));
256-
expect(pairs).toHaveLength(5);
257-
expect(new Set(keys).size).toBe(5);
258-
});
259-
260-
test('excludes specified pairs', () => {
261-
const ranker = new PowerRanker({ items: new Set(['1', '2', '3']) });
262-
const exclude = new Set(['1:2', '2:3']);
263-
const pairs = ranker.select({ num: 10, exclude });
264-
265-
expect(pairs).toHaveLength(1);
266-
expect(pairs[0].alpha).toBe('1');
267-
expect(pairs[0].beta).toBe('3');
268-
});
269-
270-
test('returns empty when all pairs excluded', () => {
271-
const ranker = new PowerRanker({ items: new Set(['1', '2', '3']) });
272-
const exclude = new Set(['1:2', '1:3', '2:3']);
273-
const pairs = ranker.select({ num: 5, exclude });
274-
expect(pairs).toHaveLength(0);
275-
});
276-
277-
test('preferences shift which pairs are selected more often', () => {
278-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c']) });
279-
280-
// Strong preference for a over b — reduces a:b variance
281-
ranker.addPreferences([
282-
{ target: 'a', source: 'b', value: 1 },
283-
{ target: 'a', source: 'b', value: 1 },
284-
{ target: 'a', source: 'b', value: 1 },
285-
]);
286-
287-
// Over many single-pair selections, a:b should appear least often
288-
const counts: Record<string, number> = { 'a:b': 0, 'a:c': 0, 'b:c': 0 };
289-
for (let i = 0; i < 1000; i++) {
290-
const [pair] = ranker.select({ num: 1 });
291-
counts[pairKey(pair.alpha, pair.beta)]++;
292-
}
293-
294-
expect(counts['a:b']).toBeLessThan(counts['a:c']);
295-
expect(counts['a:b']).toBeLessThan(counts['b:c']);
296-
});
297-
298-
test('exclude simulates judge-already-voted', () => {
299-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c']) });
300-
301-
const judgedPairs = new Set([pairKey('a', 'b'), pairKey('a', 'c')]);
302-
const pairs = ranker.select({ num: 10, exclude: judgedPairs });
303-
304-
expect(pairs).toHaveLength(1);
305-
expect(pairs[0]).toMatchObject({ alpha: 'b', beta: 'c' });
306-
});
307-
308-
test('impact weighting prioritizes pairs between high-ranked items', () => {
309-
// Use pseudocounts so all items retain nonzero weight
310-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c']), options: { k: K } });
311-
312-
// Strong preference: a > b > c (clear hierarchy)
313-
ranker.addPreferences([
314-
{ target: 'a', source: 'b', value: 1 },
315-
{ target: 'b', source: 'c', value: 1 },
316-
]);
317-
318-
// With impact: a:b should be favored over b:c because a and b have higher weights
319-
320-
const counts: Record<string, number> = { 'a:b': 0, 'a:c': 0, 'b:c': 0 };
321-
for (let i = 0; i < 1000; i++) {
322-
const [pair] = ranker.select({ num: 1, impact: ['weight'] });
323-
counts[pairKey(pair.alpha, pair.beta)]++;
324-
}
325-
326-
// b:c involves the two lowest-ranked items, so should be selected least
327-
expect(counts['b:c']).toBeLessThan(counts['a:b']);
328-
expect(counts['b:c']).toBeLessThan(counts['a:c']);
329-
});
330-
331-
test('impact weighting works with no preferences (uniform weights)', () => {
332-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c']) });
333-
334-
// With uniform weights, impact should behave like plain variance selection
335-
const pairs = ranker.select({ num: 3, impact: ['weight'] });
336-
expect(pairs).toHaveLength(3);
337-
const keys = pairs.map((p) => pairKey(p.alpha, p.beta));
338-
expect(new Set(keys).size).toBe(3);
339-
});
340-
341-
test('coverage transform prioritizes under-observed items', () => {
342-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c', 'd']) });
343-
344-
// Heavily observe a and b (many preferences between them)
345-
for (let i = 0; i < 10; i++) {
346-
ranker.addPreferences([{ target: 'a', source: 'b', value: 1 }]);
347-
}
348-
349-
const pairs = ranker.select({ impact: ['coverage'] });
350-
351-
// Find pairs involving c:d (both unobserved) vs a:b (both heavily observed)
352-
const cd = pairs.find((p) => p.alpha === 'c' && p.beta === 'd')!;
353-
const ab = pairs.find((p) => p.alpha === 'a' && p.beta === 'b')!;
354-
355-
// c:d should have higher weight — both items are unobserved
356-
expect(cd.weight).toBeGreaterThan(ab.weight);
357-
});
358-
359-
test('even votes (0.5) reduce both coverage and variance weights', () => {
360-
const ranker = new PowerRanker({ items: new Set(['a', 'b', 'c']) });
361-
362-
// Get baseline weights with coverage
363-
const before = ranker.select({ impact: ['coverage'] });
364-
const abBefore = before.find((p) => p.alpha === 'a' && p.beta === 'b')!;
365-
366-
// Add an "even" vote — with bidirectional flow, 0.5 adds 0.5 to both directions
367-
ranker.addPreferences([{ target: 'a', source: 'b', value: 0.5 }]);
368-
369-
const after = ranker.select({ impact: ['coverage'] });
370-
const abAfter = after.find((p) => p.alpha === 'a' && p.beta === 'b')!;
371-
372-
// With coverage, weight should decrease (items now have observations)
373-
expect(abAfter.weight).toBeLessThan(abBefore.weight);
374-
375-
// With bidirectional flow, 0.5 adds symmetric flow which also changes beta variance
376-
const afterPlain = ranker.select();
377-
const abPlain = afterPlain.find((p) => p.alpha === 'a' && p.beta === 'b')!;
378-
const beforePlain = before.find((p) => p.alpha === 'a' && p.beta === 'b')!;
379-
expect(abPlain.weight).toBeLessThan(beforePlain.weight);
380-
});
381-
382-
test('composable transforms apply multiplicatively', () => {
383-
const ranker = new PowerRanker({
384-
items: new Set(['a', 'b', 'c']),
385-
options: { k: K },
386-
});
387-
388-
ranker.addPreferences([
389-
{ target: 'a', source: 'b', value: 1 },
390-
{ target: 'b', source: 'c', value: 1 },
391-
]);
392-
393-
const plain = ranker.select();
394-
const withWeight = ranker.select({ impact: ['weight'] });
395-
const withCoverage = ranker.select({ impact: ['coverage'] });
396-
const withBoth = ranker.select({ impact: ['weight', 'coverage'] });
397-
398-
// All should return 3 pairs
399-
expect(plain).toHaveLength(3);
400-
expect(withWeight).toHaveLength(3);
401-
expect(withCoverage).toHaveLength(3);
402-
expect(withBoth).toHaveLength(3);
403-
404-
// Combined weights should be strictly less than either single transform
405-
const abBoth = withBoth.find((p) => p.alpha === 'a' && p.beta === 'b')!;
406-
const abWeight = withWeight.find((p) => p.alpha === 'a' && p.beta === 'b')!;
407-
const abCoverage = withCoverage.find((p) => p.alpha === 'a' && p.beta === 'b')!;
408-
const abPlain = plain.find((p) => p.alpha === 'a' && p.beta === 'b')!;
409-
410-
// Both transforms applied should produce smaller weight than either alone
411-
expect(abBoth.weight).toBeLessThan(abPlain.weight);
412-
expect(abBoth.weight).toBeLessThan(abWeight.weight);
413-
expect(abBoth.weight).toBeLessThan(abCoverage.weight);
414-
});
415-
});
416188
});
417189

418190
describe('activeSelect', () => {

backend/src/lib/power/PowerRanker.ts

Lines changed: 0 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ export interface PairWeight {
2828
weight: number;
2929
}
3030

31-
export type ImpactTransform = 'weight' | 'coverage';
32-
33-
export interface SelectOptions {
34-
num?: number;
35-
exclude?: Set<string>;
36-
impact?: ImpactTransform[];
37-
}
38-
3931
export type ActiveImpactTerm = 'coverage' | 'proximity' | 'position';
4032

4133
export interface ActiveSelectOptions {
@@ -139,66 +131,6 @@ export class PowerRanker {
139131
return edges;
140132
}
141133

142-
private getVariances(): PairWeight[] {
143-
const variances: PairWeight[] = [];
144-
145-
for (let i = 0; i < this.items.length; i++) {
146-
for (let j = i + 1; j < this.items.length; j++) {
147-
const weight = this.getVariance(i, j);
148-
variances.push({ alpha: this.items[i], beta: this.items[j], weight });
149-
}
150-
}
151-
152-
return variances;
153-
}
154-
155-
/**
156-
* Select pairs via variance-weighted sampling.
157-
*
158-
* With num specified, samples without replacement weighted by variance.
159-
* Without num, returns all pairs with their weights (useful for diagnostics).
160-
* impact is an optional array of transforms applied multiplicatively:
161-
* - 'weight': multiply by geometric mean of posterior rank weights (upsamples high-ranked pairs)
162-
* - 'coverage': multiply by 1/(1+n/N) per item (upsamples under-observed items)
163-
* Optionally excludes pairs (e.g. already judged).
164-
*/
165-
select({ num, exclude, impact }: SelectOptions = {}): PairWeight[] {
166-
const variances = this.getVariances();
167-
const transforms = impact ?? [];
168-
169-
const weights = transforms.includes('weight') ? this.run() : new Map<string, number>();
170-
171-
// Build candidate pool with sampling weights
172-
const candidates: PairWeight[] = [];
173-
174-
for (const v of variances) {
175-
if (exclude && exclude.has(pairKey(v.alpha, v.beta))) {
176-
continue;
177-
}
178-
179-
let weight = v.weight;
180-
181-
if (transforms.includes('weight')) {
182-
weight *= Math.sqrt(weights.get(v.alpha)! * weights.get(v.beta)!);
183-
}
184-
185-
if (transforms.includes('coverage')) {
186-
const nAlpha = this.itemObservations[v.alpha] ?? 0;
187-
const nBeta = this.itemObservations[v.beta] ?? 0;
188-
weight *= (1 / (1 + nAlpha)) * (1 / (1 + nBeta));
189-
}
190-
191-
candidates.push({ alpha: v.alpha, beta: v.beta, weight });
192-
}
193-
194-
// Without num, return all candidates
195-
if (num === undefined) {
196-
return candidates;
197-
} else {
198-
return this.selectWithoutReplacement(candidates, num);
199-
}
200-
}
201-
202134
/**
203135
* Select pairs using coverage × proximity × top-bias.
204136
*
@@ -325,14 +257,6 @@ export class PowerRanker {
325257
return vec.getRow(0);
326258
}
327259

328-
private getVariance(i: number, j: number): number {
329-
// Model as a Beta distribution with a (1, 1) prior
330-
const a = this.matrix.get(i, j) + 1;
331-
const b = this.matrix.get(j, i) + 1;
332-
333-
return (a * b) / ((a + b + 1) * (a + b) ** 2);
334-
}
335-
336260
private selectWithoutReplacement(candidates: PairWeight[], num: number): PairWeight[] {
337261
// Weighted sampling without replacement
338262
const remaining = [...candidates];

backend/src/lib/power/README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ Ported from [zaratanDotWorld/choreWheel](https://github.com/zaratanDotWorld/chor
77

88
### Ranking
99

10-
Pairwise preferences are accumulated into an N×N matrix where `matrix[i][j]` represents the strength of preference for item j over item i.
11-
Preferences are scaled from the input range [0, 1] to [-1, 1] centered at 0.5 (no preference).
10+
Pairwise preferences are accumulated into an N×N matrix using bidirectional encoding: a score `s` adds `s` to `matrix[source][target]` and `1-s` to `matrix[target][source]`.
11+
This means every observation informs both items — a strong preference (s=1) flows entirely toward the winner, while an even vote (s=0.5) splits equally.
1212

1313
The diagonal holds each item's total received preference (column sum), making it a self-reinforcing signal.
1414
The matrix is row-normalized into a stochastic matrix, then power iteration finds the dominant eigenvector — the stationary distribution that becomes the ranking.
@@ -17,11 +17,14 @@ Optional Bayesian pseudocounts (`k`) initialize off-diagonal cells, regularizing
1717

1818
### Active pair selection
1919

20-
`select()` chooses which pairs to present next using variance-weighted sampling without replacement.
21-
Each pair's uncertainty is modeled as a Beta distribution: `Beta(matrix[i][j] + 1, matrix[j][i] + 1)`.
22-
Higher variance means less certainty about the relative ranking — so those pairs are sampled more often.
20+
`activeSelect()` chooses which pairs to present next using three composable signals:
2321

24-
With `impact: true`, the variance is multiplied by `weight_a * weight_b` (the eigenvector scores), prioritizing uncertain pairs between high-ranked items.
22+
- **coverage** (`1/√(1+n_i) × 1/√(1+n_j)`) — dominates early when observations are sparse.
23+
- **proximity** (`1/(1+|pos_i-pos_j|)`) — favors pairs that are close in rank.
24+
- **position** (`1/√(pos_i×pos_j)`) — favors pairs near the top of the ranking.
25+
26+
A regularization parameter `r` (0–1) controls how strongly these signals influence selection via a power transform.
27+
At `r=0`, selection is uniform; at `r=1` (default), the full weighting applies.
2528

2629
## API
2730

@@ -40,19 +43,20 @@ ranker.addPreferences([
4043
// Get rankings (Map<string, number>, values sum to 1)
4144
const rankings = ranker.run();
4245

43-
// Get all pairs with their variance weights
44-
const allPairs = ranker.select();
46+
// Get all pairs with their selection weights
47+
const allPairs = ranker.activeSelect();
4548

4649
// Select pairs for a judging session (weighted sampling without replacement)
47-
const pairs = ranker.select({
50+
const pairs = ranker.activeSelect({
4851
num: 5,
4952
exclude: new Set(['a:b']), // pairs already judged
50-
impact: true, // weight by item importance
53+
terms: ['coverage', 'proximity', 'position'], // default: all three
54+
r: 0.9, // regularization strength
5155
});
5256
```
5357

5458
## Files
5559

5660
- `PowerRanker.ts` — Implementation
57-
- `PowerRanker.test.ts` — Tests (20 cases)
61+
- `PowerRanker.test.ts` — Tests
5862
- `index.ts` — Re-exports

0 commit comments

Comments
 (0)