blob: 4e0140ead7ed675111a4e504296215c60e3b2e07 [file] [log] [blame]
#!/usr/bin/python
#
# Copyright 2008 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from google.appengine.api import datastore
from google.appengine.api import datastore_types
from common import transactional
class Ranker(object):
"""A data structure for storing integer scores and quickly retrieving their
relative ranks.
Scores are stored as "name: score" mapping. A score is inserted by
calling SetScore with a new name. The score can later be updated by
calling SetScore again with the same name.
The scores are actually lists of integers with the same number of elements,
and their ordering is lexicographic. That is to say that score A is higher
than score B if they are different, and the first element that differs between
the two is higher in A. Thus [5, 3] is ranked higher than [4, 99].
Example Use Case:
Some number of people are participating in a programming contest. Solving a
problem gets points; contestants also get a tie-breaking "penalty time." The
higher the time, the worse the score.
STEP 1:
# Creates the ranker when the contest is created:
rank = ranker.Ranker.Create([0, 10000, -999999, 1], 100)
# In our contest, people won't have more than 9999 points or 999999 penalty
# seconds. Since penalty time is bad, we store [score, -penalty].
contest['ranker'] = rank.rootkey
datastore.Put(contest)
STEP 2:
# Someone registers for the contest. The default score is [0, 0], and for
# efficiency we don't put them in the ranker; they won't be ahead of anyone
# anyway.
STEP 3:
# Player "Jon" gets points for the first time!
rank = ranker.Ranker(contest['ranker'])
# Loads up the ranker. This is the first step of all the STEPs below.
rank.SetScore("Jon", [5, -120]) # 5 points, 2 minutes
STEP 4:
# Player "Jon" gets points for the second time!
rank.SetScore("Jon", [10, -300]) # 10 points, 5 minutes
STEP 5:
# What is the rank of the person with score [10, -300]?
position = rank.FindRank([10, 300])
# What are the ranks for the people with scores a, b, c?
positions = rank.FindRanks([a, b, c])
STEP 6:
# What is the score of the person ranked 20th?
score = rank.FindScore(19)
# This is particularly useful for seeing multiple pages of ranked people. If
# the scores are separately tracked in an entity called 'scores', the
# datastore can efficiently answer:
# q = datastore.Query('scores')
# q['score <='] = rank.FindScore(1000)[0]
# next_twenty_scores = q.Get(20)
# This is a simplified case, where scores are a single integer.
STEP 7:
# How many people have points?
num_pointy_people = rank.TotalRankedScores()
Implementation Details:
The Ranker is a rooted tree of 'ranker_node' entities. It is an
N-ary tree, where N = self.branching_factor, which is specified by the
constructor. Each node represents some range of scores, and is assigned a
unique node_id.
Take for example a 3-ary tree with point range [0, 10000, -36000, 1]. This
could represent a contest where contestants can have between 0 and 9999
points, with ties being broken by (negative) penalty seconds that can be
between 0 and 10 hours.
The root represents the score range [0, 10000, -36000, 1]. It has node_id 0.
Its first child has node_id 1 and score range [0, 3333, -36000, 1].
The root's second child, node_id 2, has score range [3333, 6666, -36000, 1],
and its third child, node_id 3, has score range [6666, 10000, -36000, 1].
Node 1's first child, node_id 4, has range [0, 1111, -36000, 1], and so on.
See __WhichChild for details of how children are assigned score ranges.
The point of a node is to track how many stored scores are in the score range
of each of its children. The root in the above example would start off with
child_node_counts = [0, 0, 0]; adding score [4000, 0] would change the root's
child_node_counts to [0, 1, 0] and node 5's child_node_counts to [1, 0, 0],
and so forth.
Ranker also has a "ranker_score" entity for every score stored in the ranker.
These entities are part of the same entity group as the ranker_node
entities. This allows for atomic, idempotent calls to SetScores.
Ranker supports the following operations, which can be read about in detail
in their docstrings:
SetScores(scores): Set scores for multiple players.
FindRank(score): Finds the 0-based rank of the provided score.
FindScore(rank): Finds the score with the provided 0-based rank.
FindScoreApproximate(rank): Finds a score >= the score of the provided 0-based
rank, < the score of rank-1 (unless rank and rank-1 are tied, in which case
it returns their mutual score).
TotalRankedScores: The total number of scores in the Ranker.
See __FindNodeIDs for more notes on structure.
"""
def __init__(self, rootkey):
"""Pulls a ranker out of the datastore, given the key of the root node.
Args:
rootkey: The datastore key of the ranker.
"""
# Get the root from the datastore:
assert rootkey.kind() == "ranker"
root = datastore.Get(rootkey)
# Initialize some class variables:
self.rootkey = rootkey
self.score_range = root["score_range"]
self.branching_factor = root["branching_factor"]
# Sanity checking:
assert len(self.score_range) > 1
assert len(self.score_range) % 2 == 0
for i in xrange(0, len(self.score_range), 2):
assert self.score_range[i + 1] > self.score_range[i]
assert self.branching_factor > 1
@classmethod
def Create(cls, score_range, branching_factor):
"""Constructs a new Ranker and returns it.
Args:
score_range: A list showing the range of valid scores, in the form:
[most_significant_score_min, most_significant_score_max,
less_significant_score_min, less_significant_score_max, ...]
Ranges are [inclusive, exclusive)
branching_factor: The branching factor of the tree. The number of
datastore Gets is Theta(1/log(branching_factor)), and the amount of data
returned by each Get is Theta(branching_factor).
Returns:
A new Ranker.
"""
# Put the root in the datastore:
root = datastore.Entity("ranker")
root["score_range"] = score_range
root["branching_factor"] = branching_factor
datastore.Put(root)
myrank = Ranker(root.key())
return myrank
def __FindNodeIDs(self, score):
"""Finds the nodes along the path from the root to a certain score.
Args:
score: The score we're finding the path for.
Returns:
A sorted list of (node_id, child) tuples, indicating that node_id is the
node id of a node on the path, and child is which child of that node is
next. Note that the lowest child node (which would be a leaf node) does
not actually exist, since all its relevant information (number of times
that score was inserted) is stored in its parent.
Nodes are numbered row-by-row: the root is 0, its children are in the range
[1, self.branching_factor + 1), its grandchildren are in the range
[self.branching_factor + 1,
self.branching_factor**2 + self.branching_factor + 1), etc.
Score ranges are lists of the form: [min_0, max_0, min_1, max_1, ...]
A node representing a score range will be divided up by the first index
where max_i != min_i + 1 (score ranges are [inclusive, exclusive)).
Child x (0-indexed) of a node [a,b) will get the range:
[a+x*(b-a)/branching_factor, a+(x+1)*(b-a)/branching_factor);
Thus not all nodes will have nonzero ranges. Nodes with zero range will
never be visited, but they and their descendants will be counted in the node
numbering scheme, so row x still has self.branching_factor**x nodes.
"""
nodes = []
node = 0
cur_range = list(self.score_range)
# The current range of scores. This will be narrowed as we move down the
# tree; 'index' keeps track of the score type we're currently changing.
for index in xrange(0, len(cur_range), 2):
while cur_range[index + 1] - cur_range[index] > 1:
# Subdivide cur_range[index]..cur_range[index + 1]
which_child = self.__WhichChild(cur_range[index],
cur_range[index + 1],
score[index // 2],
self.branching_factor)
child = which_child[0]
cur_range[index] = which_child[1][0]
cur_range[index + 1] = which_child[1][1]
assert 0 <= child < self.branching_factor
nodes.append((node, child))
node = self.__ChildNodeId(node, child)
return nodes
def __WhichChild(self, low, high, want, branching_factor):
"""Determines which child of the range [low, high) 'want' belongs to.
Args:
low: An int, the low end of the range.
high: An int, the high end of the range.
want: An int, the score we're trying to determine a child for.
branching_factor: The branching factor of the tree being used.
Returns:
A tuple, (child, [child's score range]). Note that in general a score
has multiple sub-scores, written in order of decreasing significance; this
function divides up a single sub-score.
Raises:
An AssertionError if things go horribly wrong.
"""
assert low <= want < high
# Need to find x such that (using integer division):
# x *(high-low)/branching_factor <= want - low <
# (x+1)*(high-low)/branching_factor
# Which is the least x such that (using integer division):
# want - low < (x+1)*(high-low)/branching_factor
# Which is the ceiling of x such that (using floating point division):
# want - low + 1 == (x+1)*(high-low)/branching_factor
# x = -1 + math.ceil((want-low+1) * branching_factor / (high - low))
# We get ceil by adding high - low - 1 to the numerator.
x = -1 + (((want - low + 1) * branching_factor + high - low - 1) //
(high - low))
assert (x * (high - low) // branching_factor <=
want - low < (x + 1) * (high - low) // branching_factor)
return (x, self.__ChildScoreRange([low, high], x, branching_factor))
def __ChildScoreRange(self, score_range, child, branching_factor):
"""Calculates the score_range for a node's child.
Args:
score_range: A score range [min0, max0, min1, max1, ...]
child: Which child of the node with score range score_range we're
calculating the score range of.
branching_factor: The branching factor of the tree in question.
Returns:
A score range [min0', max0', min1', max1', ...] for that child.
"""
for i in xrange(1, len(score_range), 2):
if score_range[i] > score_range[i - 1] + 1:
child_score_range = list(score_range)
low, high = score_range[i - 1], score_range[i]
child_score_range[i - 1], child_score_range[i] = (
low + child * (high - low) // branching_factor,
low + (child + 1) * (high - low) // branching_factor)
return child_score_range
raise AssertionError("Node with score range %s has no children." %
score_range)
def __ChildNodeId(self, node_id, child):
"""Calculates the node id for a known node id's child.
Args:
node_id: The parent node's node_id
child: Which child of the parent node we're finding the id for
Returns:
The node_id for the child'th child of node_id.
"""
return node_id * self.branching_factor + 1 + child
def __GetMultipleNodes(self, node_ids):
"""Gets multiple nodes from the datastore.
Args:
node_ids: A list of node ids we want to get.
Returns:
A dict of the nodes that were found, indexed by the node ids found
in node_ids.
"""
if len(node_ids) == 0:
return []
node_ids = set(node_ids)
keys = [self.__KeyFromNodeId(node_id) for node_id in node_ids]
nodes = datastore.Get(keys)
return dict((node_id, node) for (node_id, node) in zip(node_ids, nodes)
if node)
# Although, this method is currently not needed, we'll keep this
# since we might need it and some point and it's an interesting
# relationship
def __ParentNode(self, node_id):
"""Returns the node id of the parameter node id's parent. Returns None if
the parameter is 0."""
if node_id == 0:
return None
return (node_id - 1) // self.branching_factor
def __KeyFromNodeId(self, node_id):
"""Creates a (named) key for the node with a given id.
The key will have the ranker as a parent element to guarantee
uniqueness (in the presence of multiple rankers) and to put all
nodes in a single entity group.
Args:
node_id: The node's id as an integer.
Returns:
A (named) key for the node with the id 'node_id'.
"""
name = "node_%x" % node_id
return datastore_types.Key.from_path("ranker_node", name,
parent=self.rootkey)
def __KeyForScore(self, name):
"""Returns a (named) key for a ranker_score entity.
Args:
name: Name of the score to create a key for.
Returns:
A (named) key for the entity storing the score of 'name'.
"""
return datastore_types.Key.from_path("ranker_score", name,
parent=self.rootkey)
def __Increment(self, nodes_with_children, score_entities,
score_entities_to_delete):
"""Changes child counts for given nodes.
This method will create nodes as needed.
Args:
nodes_with_children: A dict of (node_key, child) tuples to deltas
score_entities: Additional score entities to persist as part of
this transaction
Returns:
None
"""
keys = list(set(key for ((key, _), delta) in nodes_with_children.iteritems()
if delta != 0))
if not keys:
return # Nothing to do
nodes = datastore.Get(keys)
node_dict = {}
for (key, node) in zip(keys, nodes):
if not node:
node = datastore.Entity("ranker_node", parent=self.rootkey,
name=key.name())
node["child_counts"] = [0] * self.branching_factor
node_dict[key] = node
for ((key, child), amount) in nodes_with_children.iteritems():
if amount != 0:
node = node_dict[key]
node["child_counts"][child] += amount
assert node["child_counts"][child] >= 0
datastore.Put(node_dict.values() + score_entities)
if score_entities_to_delete:
datastore.Delete(score_entities_to_delete)
def SetScore(self, name, score):
"""Sets a single score.
This is equivalent to calling 'SetScores({name: score})'
Args:
name: the name of the score as a string
score: the score to set name to
"""
return self.SetScores({name: score})
@transactional
def SetScores(self, scores):
"""Changes multiple scores atomically.
Sets the scores of the named entities in scores to new values. For
named entities that have not been registered with a score before,
a new score is created. For named entities that already had a score,
the score is changed to reflect the new score. If a score is None,
the named entity's score will be removed from the ranker.
Args:
scores: A dict mapping entity names (strings) to scores (integer lists)
"""
score_deltas, score_ents, score_ents_del = self.__ComputeScoreDeltas(scores)
node_ids_to_deltas = self.__ComputeNodeModifications(score_deltas)
self.__Increment(node_ids_to_deltas, score_ents, score_ents_del)
def __ComputeScoreDeltas(self, scores):
"""Compute which scores have to be incremented and decremented.
Args:
scores: A dict mapping entity names to scores
Returns:
A tuple (score_deltas, score_entities, score_entities_to_delete).
'score_deltas' is a dict, mapping scores (represented as tuples)
to integers. 'score_deltas[s]' represents how many times the
score 's' has to be incremented (or decremented).
'score_entities' is a list of 'ranker_score' entities that have
to be updated in the same transaction as modifying the ranker
nodes. The entities already contain the updated score.
Similarly, 'score_entities_to_delete' is a list of entities that
have to be deleted in the same transaction as modifying the ranker
nodes.
"""
score_keys = [self.__KeyForScore(score) for score in scores]
old_scores = {}
for old_score in datastore.Get(score_keys):
if old_score:
old_scores[old_score.key().name()] = old_score
score_deltas = {}
# Score entities to update
score_ents = []
score_ents_del = []
for score_name, score_value in scores.iteritems():
if score_name in old_scores:
score_ent = old_scores[score_name]
if score_ent["value"] == score_value:
continue # No change in score => nothing to do
old_score_key = tuple(score_ent["value"])
score_deltas.setdefault(old_score_key, 0)
score_deltas[old_score_key] -= 1
else:
score_ent = datastore.Entity("ranker_score", parent=self.rootkey,
name=score_name)
if score_value:
score_key = tuple(score_value)
score_deltas.setdefault(score_key, 0)
score_deltas[score_key] += 1
score_ent["value"] = score_value
score_ents.append(score_ent)
else:
# Do we have to delete an old score entity?
if score_name in old_scores:
score_ents_del.append(old_scores[score_name])
return (score_deltas, score_ents, score_ents_del)
def __ComputeNodeModifications(self, score_deltas):
"""Computes modifications to ranker nodes.
Given score deltas, computes which nodes need to be modified and by
how much their child count has to be incremented / decremented.
Args:
score_deltas: A dict of scores to integers, as returned by
_ComputeScoreDeltas.
Returns:
A dict of nodes (represented as node_key, child tuples) to integers.
'result[(node_key, i)]' represents the amount that needs to be added to
the i-th child of node node_key.
"""
nodes_to_deltas = {}
for score, delta in score_deltas.iteritems():
for (node_id, child) in self.__FindNodeIDs(score):
node = (self.__KeyFromNodeId(node_id), child)
nodes_to_deltas[node] = nodes_to_deltas.get(node, 0) + delta
return nodes_to_deltas
def __FindRank(self, node_ids_with_children, nodes):
"""Utility function. Finds the rank of a score.
Args:
node_ids_with_children: A list of node ids down to that score,
paired with which child links to follow.
nodes: A dict mapping node id to node entity.
Returns:
The score's rank.
"""
tot = 0 # Counts the number of higher scores.
for (node_id, child) in node_ids_with_children:
if node_id in nodes:
node = nodes[node_id]
for i in xrange(child + 1, self.branching_factor):
tot += node["child_counts"][i]
else:
# If the node isn't in the dict, the node simply doesn't exist. We
# are probably finding the rank for a score that doesn't appear in the
# ranker, but that's perfectly fine.
pass
return tot
def FindRank(self, score):
"""Finds the 0-based rank of a particular score; more precisely, returns the
number of strictly higher scores stored.
Args:
score: The score whose rank we wish to find.
Returns:
The number of tracked scores that are higher. Does not check whether
anyone actually has the requested score.
"""
return self.FindRanks([score])[0]
def FindRanks(self, scores):
"""Finds the 0-based ranks of a number of particular scores.
Like FindRank, but more efficient for multiple scores.
Args:
scores: A list of scores.
Returns:
A list of ranks.
"""
for score in scores:
assert len(score) * 2 == len(self.score_range)
# Find the nodes we'll need to query to find information about these scores:
node_ids_with_children_list = [self.__FindNodeIDs(score)
for score in scores]
node_ids = []
for node_ids_with_children in node_ids_with_children_list:
node_ids += [node_id for (node_id, _) in node_ids_with_children]
# Query the needed nodes:
nodes_dict = self.__GetMultipleNodes(node_ids)
# Call __FindRank, which does the math, for each score:
return [self.__FindRank(node_ids_with_children, nodes_dict) for
node_ids_with_children in node_ids_with_children_list]
def __FindScore(self, node_id, rank, score_range, approximate):
"""To be run in a transaction. Finds the score ranked 'rank' in the subtree
defined by node 'nodekey.'
Args:
node_id: The id of the node whose subtree we wish to find the
score of rank 'rank' in.
rank: The rank (within this subtree) of the score we wish to find.
score_range: The score range for this particular node, as a list.
Derivable from the node's node_id, but included for convenience.
approximate: Do we have to return an approximate result, or an exact one?
See the docstrings for FindScore and FindScoreApproximate.
Returns:
A tuple, (score, rank_of_tie), indicating the score's rank within
node_id's subtree. The way it indicates rank is defined in the dosctrings
of FindScore and FindScoreApproximate, depending on the value of
'approximate'.
"""
# If we're approximating and thus allowed to do so, early-out if we just
# need to return the highest available score.
if approximate and rank == 0:
return ([score - 1 for score in score_range[1::2]], 0)
# Find the current node.
node = datastore.Get(self.__KeyFromNodeId(node_id))
child_counts = node["child_counts"]
initial_rank = rank
for i in xrange(self.branching_factor - 1, -1, -1):
# If this child has enough scores that rank 'rank' is in
# there, recurse.
if rank - child_counts[i] < 0:
child_score_range = self.__ChildScoreRange(score_range, i,
self.branching_factor)
if self.__IsSingletonRange(child_score_range):
# Base case; child_score_range refers to a single score. We don't
# store leaf nodes so we can return right here.
return (child_score_range[0::2], initial_rank - rank)
# Not a base case. Keep descending into children.
ans = self.__FindScore(self.__ChildNodeId(node_id, i), rank,
child_score_range,
approximate)
# Note the 'initial_rank - rank': we've asked the child for a score of
# some rank among *its* children, so we have to add back in the scores
# discarded on the way to that child.
return (ans[0], ans[1] + (initial_rank - rank))
else:
rank -= child_counts[i]
return None
def __IsSingletonRange(self, scorerange):
"""Returns whether a range contains exactly one score."""
return [score + 1 for score in scorerange[0::2]] == scorerange[1::2]
@transactional
def FindScore(self, rank):
"""Finds the score ranked at 'rank'.
Args:
rank: The rank of the score we wish to find.
Returns:
A tuple, (score, rank_of_tie). 'score' is the score ranked at 'rank',
'rank_of_tie' is the rank of that score (which may be different from
'rank' in the case of ties).
e.g. if there are two scores tied at 5th and rank == 6, returns
(score, 5).
"""
return self.__FindScore(0, rank, self.score_range, False)
@transactional
def FindScoreApproximate(self, rank):
"""Finds a score that >= the score ranked at 'rank'.
This method could be preferred to FindScore because it is more efficient.
For example, if the objective is to find the top 50 scores of rank X or
less, and those scores are stored in entities called scoreboard_row:
score, rank = myrank.FindScoreApproximate(X)
query = datastore.Query('scoreboard_row')
query['score <='] = score
result = query.Get(50 + X - rank)[X-rank:]) # Takes care of ties.
Args:
rank: The rank of the score we wish to find.
Returns:
A tuple, (score, rank_of_tie).
If there is a tie at rank 'rank-1':
rank's score <= score < rank-1's score, rank_of_tie == rank
else:
score == rank's score, rank_of_tie == the tied rank of everyone
in the tie.
e.g. if two scores are tied at 5th and rank == 6, returns (score, 5).
"""
return self.__FindScore(0, rank, self.score_range, True)
def TotalRankedScores(self):
"""Returns the total number of ranked scores.
Returns:
The total number of ranked scores.
"""
root = datastore.Get([self.__KeyFromNodeId(0)])[0]
if root:
return sum(root["child_counts"])
else:
# Ranker doesn't have any ranked scores, yet
return 0