What is the optimal winning strategy for this modified blackjack game? - ruby
Questions
Is there a best value to stay on so that I win the greatest percentage of games possible? If so, what is it?
Edit: Is there an exact probability of winning that can be calculated for a given limit, independent of whatever the opponent does? (I haven't done probability & statistics since college). I'd be interested in seeing that as an answer to contrast it with my simulated results.
Edit: Fixed bugs in my algorithm, updated result table.
Background
I've been playing a modified blackjack game with some rather annoying rule tweaks from the standard rules. I've italicized the rules that are different from the standard blackjack rules, as well as included the rules of blackjack for those not familiar.
Modified Blackjack Rules
Exactly two human players (dealer is irrelevant)
Each player is dealt two cards face down
Neither player _ever_ knows the value of _any_ of the opponent's cards
Neither player knows the value of the opponent's hand until _both_ have finished the hand
Goal is to come as close to score of 21 as possible. Outcomes:
If player's A & B have identical score, game is a draw
If player's A & B both have a score over 21 (a bust), game is a draw
If player A's score is <= 21 and player B has busted, player A wins
If player A's score is greater than player B's, and neither have busted, player A wins
Otherwise, player A has lost (B has won).
Cards are worth:
Cards 2 through 10 are worth the corresponding amount of points
Cards J, Q, K are worth 10 points
Card Ace is worth 1 or 11 points
Each player may request additional cards one at a time until:
The player doesn't want any more (stay)
The player's score, with any Aces counted as 1, exceeds 21 (bust)
Neither player knows how many cards the other has used at any time
Once both players have either stayed or busted the winner is determined per rule 3
above.
After each hand the entire deck is reshuffled and all 52 cards are in play again
What is a deck of cards?
A deck of cards consists of 52 cards, four each of the following 13 values:
2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A
No other property of the cards are relevant.
A Ruby representation of this is:
CARDS = ((2..11).to_a+[10]*3)*4
Algorithm
I've been approaching this as follows:
I will always want to hit if my score is 2 through 11, as it is impossible to bust
For each of the scores 12 through 21 I will simulate N hands against an opponent
For these N hands, the score will be my "limit". Once I reach the limit or greater, I will stay.
My opponent will follow the exact same strategy
I will simulate N hands for every permutation of the sets (12..21), (12..21)
Print the difference in wins and losses for each permutation as well as the net win loss difference
Here is the algorithm implemented in Ruby:
#!/usr/bin/env ruby
class Array
def shuffle
sort_by { rand }
end
def shuffle!
self.replace shuffle
end
def score
sort.each_with_index.inject(0){|s,(c,i)|
s+c > 21 - (size - (i + 1)) && c==11 ? s+1 : s+c
}
end
end
N=(ARGV[0]||100_000).to_i
NDECKS = (ARGV[1]||1).to_i
CARDS = ((2..11).to_a+[10]*3)*4*NDECKS
CARDS.shuffle
my_limits = (12..21).to_a
opp_limits = my_limits.dup
puts " " * 55 + "opponent_limit"
printf "my_limit |"
opp_limits.each do |result|
printf "%10s", result.to_s
end
printf "%10s", "net"
puts
printf "-" * 8 + " |"
print " " + "-" * 8
opp_limits.each do |result|
print " " + "-" * 8
end
puts
win_totals = Array.new(10)
win_totals.map! { Array.new(10) }
my_limits.each do |my_limit|
printf "%8s |", my_limit
$stdout.flush
opp_limits.each do |opp_limit|
if my_limit == opp_limit # will be a tie, skip
win_totals[my_limit-12][opp_limit-12] = 0
print " --"
$stdout.flush
next
elsif win_totals[my_limit-12][opp_limit-12] # if previously calculated, print
printf "%10d", win_totals[my_limit-12][opp_limit-12]
$stdout.flush
next
end
win = 0
lose = 0
draw = 0
N.times {
cards = CARDS.dup.shuffle
my_hand = [cards.pop, cards.pop]
opp_hand = [cards.pop, cards.pop]
# hit until I hit limit
while my_hand.score < my_limit
my_hand << cards.pop
end
# hit until opponent hits limit
while opp_hand.score < opp_limit
opp_hand << cards.pop
end
my_score = my_hand.score
opp_score = opp_hand.score
my_score = 0 if my_score > 21
opp_score = 0 if opp_score > 21
if my_hand.score == opp_hand.score
draw += 1
elsif my_score > opp_score
win += 1
else
lose += 1
end
}
win_totals[my_limit-12][opp_limit-12] = win-lose
win_totals[opp_limit-12][my_limit-12] = lose-win # shortcut for the inverse
printf "%10d", win-lose
$stdout.flush
end
printf "%10d", win_totals[my_limit-12].inject(:+)
puts
end
Usage
ruby blackjack.rb [num_iterations] [num_decks]
The script defaults to 100,000 iterations and 4 decks. 100,000 takes about 5 minutes on a fast macbook pro.
Output (N = 100 000)
opponent_limit
my_limit | 12 13 14 15 16 17 18 19 20 21 net
-------- | -------- -------- -------- -------- -------- -------- -------- -------- -------- -------- --------
12 | -- -7666 -13315 -15799 -15586 -10445 -2299 12176 30365 65631 43062
13 | 7666 -- -6962 -11015 -11350 -8925 -975 10111 27924 60037 66511
14 | 13315 6962 -- -6505 -9210 -7364 -2541 8862 23909 54596 82024
15 | 15799 11015 6505 -- -5666 -6849 -4281 4899 17798 45773 84993
16 | 15586 11350 9210 5666 -- -6149 -5207 546 11294 35196 77492
17 | 10445 8925 7364 6849 6149 -- -7790 -5317 2576 23443 52644
18 | 2299 975 2541 4281 5207 7790 -- -11848 -7123 8238 12360
19 | -12176 -10111 -8862 -4899 -546 5317 11848 -- -18848 -8413 -46690
20 | -30365 -27924 -23909 -17798 -11294 -2576 7123 18848 -- -28631 -116526
21 | -65631 -60037 -54596 -45773 -35196 -23443 -8238 8413 28631 -- -255870
Interpretation
This is where I struggle. I'm not quite sure how to interpret this data. At first glance it seems like always staying at 16 or 17 is the way to go, but I'm not sure if it's that easy. I think it's unlikely that an actual human opponent would stay on 12, 13, and possibly 14, so should I throw out those opponent_limit values? Also, how can I modify this to take into account the variability of a real human opponent? e.g. a real human is likely to stay on 15 just based on a "feeling" and may also hit on 18 based on a "feeling"
I'm suspicious of your results. For example, if the opponent aims for 19, your data says that the best way to beat him is to hit until you reach 20. This does not pass a basic smell test. Are you sure you don't have a bug? If my opponent is striving for 19 or better, my strategy would be to avoid busting at all costs: stay on anything 13 or higher (maybe even 12?). Going for 20 has to wrong -- and not just by a small margin, but by a lot.
How do I know that your data is bad? Because the blackjack game you are playing isn't unusual. It's the way a dealer plays in most casinos: the dealer hits up to a target and then stops, regardless of what the other players hold in their hands. What is that target? Stand on hard 17 and hit soft 17. When you get rid of the bugs in your script, it should confirm that the casinos know their business.
When I make the following replacements to your code:
# Replace scoring method.
def score
s = inject(0) { |sum, c| sum + c }
return s if s < 21
n_aces = find_all { |c| c == 11 }.size
while s > 21 and n_aces > 0
s -= 10
n_aces -= 1
end
return s
end
# Replace section of code determining hand outcome.
my_score = my_hand.score
opp_score = opp_hand.score
my_score = 0 if my_score > 21
opp_score = 0 if opp_score > 21
if my_score == opp_score
draw += 1
elsif my_score > opp_score
win += 1
else
lose += 1
end
The results agree with the behavior of casino dealers: 17 is the optimal target.
n=10000
opponent_limit
my_limit | 12 13 14 15 16 17 18 19 20 21 net
-------- | -------- -------- -------- -------- -------- -------- -------- -------- -------- -------- --------
12 | -- -843 -1271 -1380 -1503 -1148 -137 1234 3113 6572
13 | 843 -- -642 -1041 -1141 -770 -93 1137 2933 6324
14 | 1271 642 -- -498 -784 -662 93 1097 2977 5945
15 | 1380 1041 498 -- -454 -242 -100 898 2573 5424
16 | 1503 1141 784 454 -- -174 69 928 2146 4895
17 | 1148 770 662 242 174 -- 38 631 1920 4404
18 | 137 93 -93 100 -69 -38 -- 489 1344 3650
19 | -1234 -1137 -1097 -898 -928 -631 -489 -- 735 2560
20 | -3113 -2933 -2977 -2573 -2146 -1920 -1344 -735 -- 1443
21 | -6572 -6324 -5945 -5424 -4895 -4404 -3650 -2560 -1443 --
Some miscellaneous comments:
The current design is inflexible. With a just little refactoring, you could achieve a clean separation between the operation of the game (dealing, shuffling, keeping running stats) and player decision making. This would allow you to test various strategies against each other. Currently, your strategies are embedded in loops that are all tangled up in the game operation code. Your experimentation would be better served by a design that allowed you to create new players and set their strategy at will.
Two comments:
It looks like there isn't a single dominating strategy based on a "hit limit":
If you choose 16 your opponent can choose 17
if you choose 17 your opponent can choose 18
if you choose 18 your opponent can choose 19
if you choose 19 your opponent can choose 20
if you choose 20 your opponent can choose 12
if you choose 12 your opponent can choose 16.
2. You do not mention whether players can see how many cards their opponent has drawn (I would guess so). I would expect this information to be incorporated into the "best" strategy. (answered)
With no information about the other players decisions, the game gets simpler. But since there is clearly no dominant "pure" strategy, the optimal strategy will be a "mixed" strategy. That is: a set of probabilities for each score from 12 to 21 for whether you should stop or draw another card (EDIT: you will need different probabilities for a given score with no aces vs the score with aces.) Executing the strategy then requires you to randomly choose (according to the probabilities) whether to stop or continue after each new draw. You can then find the Nash equilibrium for the game.
Of course, if you are only asking the simpler question: what is the optimal winning strategy against suboptimal players (for example ones that always stop on 16, 17, 18 or 19) you are asking an entirely diiferent question, and you will have to specify exactly in which way the other player is limited compared to you.
Here are some thoughts about the data you've collected:
It's mildly useful for telling you what your "hit limit" should be, but only if you know that your opponent is following a similar "hit limit" strategy.
Even then, It's only really useful if you know what your opponent's "hit limit" is or is likely to be. You can just choose a limit that gives you more wins than them.
You can more or less ignore the actual values in the table. It's whether they're positive or negative that matters.
To show your data another way, the first number is your opponent's limit, and the second group of numbers are the limits you can choose and win with. The one with an asterisk is the "winningest" choice:
12: 13, 14, 15, 16*, 17, 18
13: 14, 15, 16*, 17, 18, 19
14: 15, 16, 17*, 18, 19
15: 16, 17*, 18, 19
16: 17, 18*, 19
17: 18*, 19
18: 19*, 20
19: 12, 20*
20: 12*, 13, 14, 15, 16, 17
21: 12*, 13, 14, 15, 16, 17, 18, 19, 20
From that, you can see that a hit limit of 17 or 18 is the safest option if the opponent is following a random "hit limit" selection strategy because 17 and 18 will beat 7/10 opponent "hit limits".
Of course if your opponent is human, you can't reply on them self-imposing a "hit limit" of under 18 or over 19, so that completely negates the previous calculations. I still think these numbers are useful however:
I agree that for any individual hand, you can be reasonably confident that your opponent will have a limit after which they will stop hitting, and they'll stay. If you can guess at that limit, you can choose your own limit based on that estimate.
If you think they're being optimistic or they're happy to risk it, choose a limit of 20 - you'll beat them in the long run provided their limit is above 17. If you're really confident, choose a limit of 12 - that will win if their limit is above 18 and there are much more frequent winnings to be had here.
If you think they're being conservative or risk averse, choose a limit of 18. That will win if they're staying anywhere below 18 themselves.
For neutral ground, maybe think about what your limit would be without any outside influence. Would you normally hit on a 16? A 17?
In short, you can only guess at what your opponent's limit is, but if you guess well, you can beat them over the long term with those statistics.
Related
Issue with Lua Random Number Generation in Loops
I have a script for a rock-paper-scissors (RPS) game I am making, and I am trying to generate a random number to determine a series of RPS moves. The logic is as follows: moves = {} table.insert(moves, 'rock') table.insert(moves, 'paper') table.insert(moves, 'scissors') currentMoves = {} math.randomseed(playdate.getSecondsSinceEpoch()) -- game SDK library function that returns seconds since midnight January 1 2000 UTC to initialize new random sequence math.random(); math.random(); math.random(); -- generates a list of rps moves to display on the screen function generateMoves(maxMovesLength) -- i set maxMovesLength to 3 currentMoves = {} for i = 1, maxMovesLength, 1 do randomNumber = math.random(1, 3) otherRandomNumber = math.random(1,99) -- even with this, based on the presumption 1~33 is rock, 34~66 is paper, 67~99 is scissors, I get a suspicious number of 3 of the same move) print(otherRandomNumber) table.insert(currentMoves, moves[randomNumber]) end return currentMoves end However, I noticed that using the Lua math.random() function, I seem to be getting a statistically unlikely number of series of 3 of the same RPS move. The likelihood of getting 3 of the same move (rock rock rock, paper paper paper, or scissors scissors scissors) should be about 11%, but I am getting sets of 3 much more often. For example, here is what I got when I set maxMovesLength to 15: 36 -paper 41 -paper 60 -paper 22 -rock 1 -rock 2 -rock 91 -scissors 36 -paper 69 -scissors 76 -scissors 35 -paper 18 -rock 22 -rock 22 -rock 92 -scissors From this sample, it seems that sets of 3 of a kind are happening much more often than they should be. There are 13 series of 3 moves in this list of 15 moves, and among those 3/13 are three of a kind which would be a probability of about 23%, higher than the expected statistical probability of 11%. Is this just a flaw in the Lua math library?
It seems that when setting maxMovesLength to a very high number this issue doesn't exist, so I will just call math.random() a bunch of times before I actually use it in my game (more than the 3 times I currently do under randomseed().
Generate an unique identifier such as a hash every N minutes. But they have to be the same in N minutes timeframe without storing data
I want to create a unique identifier such as a small hash every N minutes but the result should be the same in the N timeframe without storing data. Examples when the N minute is 10: 0 > 10 = 25ba38ac9 10 > 20 = 900605583 20 > 30 = 6156625fb 30 > 40 = e130997e3 40 > 50 = 2225ca027 50 > 60 = 3b446db34 Between minute 1 and 10 i get "25ba38ac9" but with anything between 10 and 20 i get "900605583" etc. I have no start/example code because i have no idea or algorithm i can use to create the desired result. I did not provide a specific tag or language in this question because i am interested in the logic and not the final code. But i appreciate documented code as a example.
Pick your favourite hash-function h. Pick your favourite string sugar. To get a hash at time t, append the euclidean quotient of t divided by N to sugar, and apply h to it. Example in python: h = lambda x: hex(abs(hash(x))) sugar = 'Samir' def hash_for_N_minutes(t, N=10): return h(sugar + str(t // N)) for t in range(0, 30, 2): print(t, hash_for_N_minutes(t, 10)) Output: 0 0xeb3d3abb787c890 2 0xeb3d3abb787c890 4 0xeb3d3abb787c890 6 0xeb3d3abb787c890 8 0xeb3d3abb787c890 10 0x45e2d2a970323e9f 12 0x45e2d2a970323e9f 14 0x45e2d2a970323e9f 16 0x45e2d2a970323e9f 18 0x45e2d2a970323e9f 20 0x334dce1d931e5da8 22 0x334dce1d931e5da8 24 0x334dce1d931e5da8 26 0x334dce1d931e5da8 28 0x334dce1d931e5da8 Weakness of this hash method, and suggested improvement Of course, nothing stops you from inputting a time in the future. So you can easily answer the question "What will the hash be in exactly one hour ?". If you want future hashes to be unpredictable, you can combine the value t // N with a real-world value that's dependent on the time, not known in advance, but for which we keep records. There are two well-known time-series that fit this criterion: values related to the meteo, and values related to the stock market. See also this 2008 xkcd comic: https://xkcd.com/426/
Weekly group assignment algorithm
A friend of mine who who is a teacher has 23 students in a class. They want an algorithm that assigns students in groups of 2 and one group of 3 (handle the odd number of students) across 14 weeks such that no two pairs repeat across the 14 weeks (a pair is assigned to one week). A brute force approach would be too inefficient, so I was thinking of other approaches, matrix representation sounds appealing, and graph theory. Does anyone have any ideas? The problems that I could find deal only with 1 week and this answer I could quite figure out.
Round-robin algorithm will do the trick i think. Add the remaining student to the second group and you are done. First run 1 2 3 4 5 6 7 8 9 10 11 12 23 22 21 20 19 18 17 16 15 14 13 Second run 1 23 2 3 4 5 6 7 8 9 10 11 22 21 20 19 18 17 16 15 14 13 12 ...
Another possibility might be graph matching, 14 distinct graph matchings would be needed.
Try to describe the problem in terms of constraints. Then pass the constraints to a tool like ECLiPSe (not Eclipse), see http://eclipseclp.org/. In fact, your problem seems similar to that of the Golf example on that site (http://eclipseclp.org/examples/golf.ecl.txt).
Here's an example in Haskell that will produce groups of 14 non-repeating 11-pair-combinations. The value 'pairs' is all combinations of pairs from 1 to 23 (e.g., [1,2], [1,3] etc.). Then the program builds lists where each list is 14 lists of 11 pairs (choosing from the value 'pairs') such that no pair is repeated and no single number is repeated in one list of 11 pairs. It's up to you to simply place the missing last student for each week as you see fit. (It took about three minutes to calculate before it started to output results): import Data.List import Control.Monad pairs = nubBy (\x y -> reverse x == y) $ filter (\x -> length (nub x) == length x) $ replicateM 2 [1..23] solve = solve' [] where solve' results = if length results == 14 then return results else solveOne [] where solveOne result = if length result == 11 then solve' (result:results) else do next <- pairs guard (notElem (head next) result' && notElem (last next) result' && notElem next results') solveOne (next:result) where result' = concat result results' = concat results One sample from the output: [[[12,17],[10,19],[9,18],[8,22],[7,21],[6,23],[5,11],[4,14],[3,13],[2,16],[1,15]], [[12,18],[11,19],[9,17],[8,21],[7,23],[6,22],[5,10],[4,15],[3,16],[2,13],[1,14]], [[12,19],[11,18],[10,17],[8,23],[7,22],[6,21],[5,9],[4,16],[3,15],[2,14],[1,13]], [[15,23],[14,22],[13,17],[8,18],[7,19],[6,20],[5,16],[4,9],[3,10],[2,11],[1,12]], [[16,23],[14,21],[13,18],[8,17],[7,20],[6,19],[5,15],[4,10],[3,9],[2,12],[1,11]], [[16,21],[15,22],[13,19],[8,20],[7,17],[6,18],[5,14],[4,11],[3,12],[2,9],[1,10]], [[16,22],[15,21],[14,20],[8,19],[7,18],[6,17],[5,13],[4,12],[3,11],[2,10],[1,9]], [[20,21],[19,22],[18,23],[12,13],[11,14],[10,15],[9,16],[4,5],[3,6],[2,7],[1,8]], [[20,22],[19,21],[17,23],[12,14],[11,13],[10,16],[9,15],[4,6],[3,5],[2,8],[1,7]], [[20,23],[18,21],[17,22],[12,15],[11,16],[10,13],[9,14],[4,7],[3,8],[2,5],[1,6]], [[19,23],[18,22],[17,21],[12,16],[11,15],[10,14],[9,13],[4,8],[3,7],[2,6],[1,5]], [[22,23],[18,19],[17,20],[14,15],[13,16],[10,11],[9,12],[6,7],[5,8],[2,3],[1,4]], [[21,23],[18,20],[17,19],[14,16],[13,15],[10,12],[9,11],[6,8],[5,7],[2,4],[1,3]], [[21,22],[19,20],[17,18],[15,16],[13,14],[11,12],[9,10],[7,8],[5,6],[3,4],[1,2]]]
Start off with a set (maybe a bitset mapping to students for less memory consumption) for each student that has all other students in it. Iterate 14 times, each time picking 11 students (for the 11 groups you will form) for whom you will pick partners. For each student, pick a partner they haven't been in a group with yet. For one random student of those 11, pick a second partner, but make sure no student has less remaining partners than there are iterations left. For every pick, adjust the sets.
algorithm to check a connect four field
I'm wondering what's the best way to check for a winner on a connect four field. I'm interested in what you guys think and whether there is some "well-known" algorithm for this sort of problems? Solution: I implemented Ardavan's hash-table solution in Python. I let the algorithm run over every field once. The best checking time with my implementation was 0.047 ms, the worst 0.154 ms and the average 0.114 ms on my Intel(R) Core(TM)2 Duo CPU T9600 # 2.80GHz. This is fast enough for my needs, and the algorithm seems neat to me.
The source code from the Fhourstones Benchmark from John Tromp uses a fascinating algorithm for testing a connect four game for a win. The algorithm uses following bitboard representation of the game: . . . . . . . TOP 5 12 19 26 33 40 47 4 11 18 25 32 39 46 3 10 17 24 31 38 45 2 9 16 23 30 37 44 1 8 15 22 29 36 43 0 7 14 21 28 35 42 BOTTOM There is one bitboard for the red player and one for the yellow player. 0 represents a empty cell, 1 represents a filled cell. The bitboard is stored in an unsigned 64 bit integer variable. The bits 6, 13, 20, 27, 34, 41, >= 48 have to be 0. The algorithm is: // return whether 'board' includes a win bool haswon(unsigned __int64 board) { unsigned __int64 y = board & (board >> 6); if (y & (y >> 2 * 6)) // check \ diagonal return true; y = board & (board >> 7); if (y & (y >> 2 * 7)) // check horizontal return true; y = board & (board >> 8); if (y & (y >> 2 * 8)) // check / diagonal return true; y = board & (board >> 1); if (y & (y >> 2)) // check vertical return true; return false; } You have to call the function for the bitboard of the player who did the last move. I try to explain the algorithm in my answer to the question "How to determine game end, in tic-tac-toe?".
Each cell can only attribute to a maximum number of 12 winning combinations. (4 horizontal, 4 vertical and 4 diagonal). Each combination would have 4 cells including the one under consideration. And these numbers are going to be much lower for the cells closer to the sides. So it would make sense to pre-compile these combinations and store a hash of hash of related cells which can make a single play a winner. This way after each cell is player you simply pull out the related combinations/cells to check if it's a winner.
This is related to this question: How to find the winner of a tic-tac-toe game of any size? The twist is the 7x6 board with 4 in a row winning rather than a NxN board with N in a row winning. But it is trivial to adapt the solution to NxN tic tac toe to connect 4. EDIT: Actually, it's not quite trivial to adapt the other solution to this one. But you can get there with a little bit of extra work. Store a count for each player for every row, column, diagonal and anti-diagonal that could ever have 4 pieces in a row. When that count hits 4 or more for either player, check to see if that row/column/diagonal/anti-diagonal has the four pieces in a row. If it does, that player wins!
Why isn't this valid USPS tracking number validating according to their spec?
I'm writing a gem to detect tracking numbers (called tracking_number, natch). It searches text for valid tracking number formats, and then runs those formats through the checksum calculation as specified in each respective service's spec to determine valid numbers. The other day I mailed a letter using USPS Certified Mail, got the accompanying tracking number from USPS, and fed it into my gem and it failed the validation. I am fairly certain I am performing the calculation correctly, but have run out of ideas. The number is validated using USS Code 128 as described in section 2.8 (page 15) of the following document: http://www.usps.com/cpim/ftp/pubs/pub109.pdf The tracking number I got from the post office was "7196 9010 7560 0307 7385", and the code I'm using to calculate the check digit is: def valid_checksum? # tracking number doesn't have spaces at this point chars = self.tracking_number.chars.to_a check_digit = chars.pop total = 0 chars.reverse.each_with_index do |c, i| x = c.to_i x *= 3 if i.even? total += x end check = total % 10 check = 10 - check unless (check.zero?) return true if check == check_digit.to_i end According to my calculations based on the spec provided, the last digit should be a 3 in order to be valid. However, Google's tracking number auto detection picks up the number fine as is, so I can only assume I am doing something wrong.
From my manual calculations, it should match what your code does: posn: 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 sum mult even: 7 9 9 1 7 6 0 0 7 8 54 162 odd: 1 6 0 0 5 0 3 7 3 25 25 === 187 Hence the check digit should be three. If that number is valid, then they're using a different algorithm to the one you think they are. I think that might be the case since, when I plug the number you gave into the USPS tracker page, I can see its entire path. In fact, if you look at publication 91, the Confirmation Services Technical Guide, you'll see it uses two extra digits, including the 91 at the front for the tracking application ID. Applying the algorithm found in that publication gives us: posn: 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 sum mult even: 9 7 9 9 1 7 6 0 0 7 8 63 189 odd: 1 1 6 0 0 5 0 3 7 3 26 26 === 215 and that would indeed give you a check digit of 5. I'm not saying that's the answer but it does match with the facts and is at least a viable explanation. Probably your best bet would be to contact USPS for the information.
I don't know Ruby, but it looks as though you're multiplying by 3 at each even number; and the way I read the spec, you sum all the even digits and multiply the sum by 3. See the worked-through example pp. 20-21. (later) your code may be right. this Python snippet gives 7 for their example, and 3 for yours: #!/usr/bin/python 'check tracking number checksum' import sys def check(number = sys.argv[1:]): to_check = ''.join(number).replace('-', '') print to_check even = sum(map(int, to_check[-2::-2])) odd = sum(map(int, to_check[-3::-2])) print even * 3 + odd if __name__ == '__main__': check(sys.argv[1:]) [added later] just completing my code, for reference: jcomeau#intrepid:~$ /tmp/track.py 7196 9010 7560 0307 7385 False jcomeau#intrepid:~$ /tmp/track.py 91 7196 9010 7560 0307 7385 True jcomeau#intrepid:~$ /tmp/track.py 71123456789123456787 True jcomeau#intrepid:~$ cat /tmp/track.py #!/usr/bin/python 'check tracking number checksum' import sys def check(number): to_check = ''.join(number).replace('-', '') even = sum(map(int, to_check[-2::-2])) odd = sum(map(int, to_check[-3::-2])) checksum = even * 3 + odd checkdigit = (10 - (checksum % 10)) % 10 return checkdigit == int(to_check[-1]) if __name__ == '__main__': print check(''.join(sys.argv[1:]).replace('-', ''))