Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NO QA] Fix random numbers again #10700

Merged
merged 3 commits into from
Aug 31, 2022
Merged

[NO QA] Fix random numbers again #10700

merged 3 commits into from
Aug 31, 2022

Conversation

tylerkaraszewski
Copy link
Contributor

@tylerkaraszewski tylerkaraszewski commented Aug 30, 2022

A bit more testing required. Feel free to review.

Thread is here: https://expensify.slack.com/archives/C02HWMSMZEC/p1661439005224499

Details

Upon discussion with @quinthar, I got to testing the existing implementation here and found it resulting in a significant number of collisions (5707 collisions in 20,000,000 IDs).

David had a suggestion for a simpler implementation. it seems like it does almost exactly the same thing but does not result in the very high number of collisions. I have not yet discovered the problem with the older algorithm that resulted in the collisions.

Let me explain the new code:

return (Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)) + Math.floor(Math.random() * (2 ** 32));

We take:

Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)

And add:

Math.floor(Math.random() * (2 ** 32))

And return it.

The second part is pretty straightforward, we add a random 32-bit number. What about the first part? First we generate a 21-bit number (we're aiming for 53 total bits, remember. 32 + 21 = 53):

Math.random() * (2 ** 21))

Then we multiply by:

(2 ** 32)

This has the effect of moving the 21 bits we generated 32 places to the left. If you're bad at binary math, think of it with decimal. Let's say we want an 8-digit number, but we can only generate 6 digits at a time (because 32 digits is a lot to type). Remember that a bit is a binary digit.

Anyway, we want an 8 digit decimal number. First we generate a two digit decimal number:

let randomNum = Math.random() * 100; // say we get 42.

Now we over it over to the left by 6 places:

randomNum *= (10 ** 6);

10^6 is 1,000,000. Multiplying our example value 42 by 1000000 gives 42000000. See, we've moved 42 6 places to the left. Now binary is the same, except instead of raising 10 to the 6th power for six digits, we raise 2 to the 32nd power for 32 binary digits.

Then we add the rest to the end. Generate a second 6-digit number:

randomNum += Math.random() * 1000000;

It doesn't matter what number this generates, it will fit in the six 0s at the right of randomNum, so we can just add it to 42,000,000 and we'll have some random 8-digit decimal number.

We've done the exact same thing in binary but with longer numbers.

Fixed Issues

$ https://github.com/Expensify/Expensify/issues/224792

Tests

  • Verify that no errors appear in the JS console

Here's the elaborate testing process that was used for this:

Using the following test page, generate 1,000,000 random reportIDs in chrome. Repeat this 20x by refreshing, each time copying the output to the end of a text file:

<!DOCTYPE html>
<html> <head>
<script type="text/javascript">
function generateReportID() {
    return (Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)) + Math.floor(Math.random() * (2 ** 32));
}

// generate a million random 53-bit values.
let result = [];
for (var i = 0; i < 1000000;i++) {
    result.push(generateReportID());
}

window.onload = function() {
    let r = "";
    for (let i = 0; i < 1000000; i++) {
        r += result[i] + "\n";
    }
    document.getElementById("randomhere").innerText = r;
}
</script> </head> <body> <pre id="randomhere"> </pre> </body> </html>

I saved the output in final_20m_test.txt. I was going to attach it here, but it's 337mb, so I put it on bastion1.sjc if you want to get it, it's in /home/tyler/final_20m_test.txt.zip for you to look at.

Then run the following command to sort the output from your random number generator:

> sort final_20m_test.txt > final_20m_test_sorted.txt

This takes a few minutes (it's 337mb of text) and saves all the ids in (lexically, not numerically, but it doesn't matter) sorted order.

Once sorted, run it through uniq:

> uniq -d final_20m_test_sorted.txt

This will output any line that occurs twice. If there's no output, there's no collisions. The above set I generated has no collisions. I've done this twice to verify. You're welcome to repeat.

Note: this has only been tried with Chrome's Math.random() and nothing is guaranteed from any other implementation, but this seems to work correctly.

Simple test run making sure web doesn't seem broken:
Screen Shot 2022-08-30 at 23 42 53

PR Review Checklist

Contributor (PR Author) Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • iOS / native
    • Android / native
    • iOS / Safari
    • Android / Chrome
    • MacOS / Chrome
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product was added in all src/languages/* files
    • I verified any copy / text that was added to the app is correct English and approved by marketing by tagging the marketing team on the original GH to get the correct copy.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • Any functional components have the displayName property
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

PR Reviewer Checklist

The Contributor+ will copy/paste it into a new comment and complete it after the author checklist is completed

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I verified tests pass on all platforms & I tested again on:
    • iOS / native
    • Android / native
    • iOS / Safari
    • Android / Chrome
    • MacOS / Chrome
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product was added in all src/languages/* files
    • I verified any copy / text that was added to the app is correct English and approved by marketing by tagging the marketing team on the original GH to get the correct copy.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • Any functional components have the displayName property
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

QA Steps

  • Verify that no errors appear in the JS console

Screenshots

Web

Mobile Web

Desktop

iOS

Android

@tylerkaraszewski tylerkaraszewski requested a review from a team as a code owner August 30, 2022 19:51
@melvin-bot melvin-bot bot requested review from ctkochan22 and removed request for a team August 30, 2022 19:51
@tylerkaraszewski tylerkaraszewski requested review from luacmartins, flodnv, stitesExpensify and Julesssss and removed request for ctkochan22 August 30, 2022 19:56
@tylerkaraszewski tylerkaraszewski changed the title [WIP] Fix random numbers again Fix random numbers again Aug 30, 2022
@tylerkaraszewski tylerkaraszewski changed the title Fix random numbers again [NO QA] Fix random numbers again Aug 30, 2022
Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks way simpler and great write up.

Definitely curious to see where the previous approach fails.

Copy link
Contributor

@roryabraham roryabraham left a comment

}

return result;
return (Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)) + Math.floor(Math.random() * (2 ** 32));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to see this moved over to NumberUtils.js instead of ReportUtils.js

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do that, but it really shouldn't be used for anything but report ID's, everything else will be 64-bits. I don't have a hugely strong opinion, but leaving it here at least implies not to use it for other IDs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll be using it for IOUReportIDs too, since those are integers as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that leaving it in reportUtils is fine since it's only used for reportIDs

@tylerkaraszewski
Copy link
Contributor Author

Also, is this implementation is vulnerable to something I just learned about called a "modulo bias"?

* https://stackoverflow.com/questions/10984974/why-do-people-say-there-is-modulo-bias-when-using-a-random-number-generator/10984975#10984975

* https://crypto.stackexchange.com/questions/39186/what-does-it-mean-for-a-random-number-generator-to-be-cryptographically-secure/39188#39188?newreg=6e4d42032f4f452f8257c311c81413bd

* https://pthree.org/2018/06/13/why-the-multiply-and-floor-rng-method-is-biased/

* https://research.kudelskisecurity.com/2020/07/28/the-definitive-guide-to-modulo-bias-and-how-to-avoid-it/

Yes, probably. It's probably vulnerable to probably most RNG problems. It's specifically not cryptographically secure per the spec for Math.random(), which is why Crypto.getRandomValues() exists.

In theory, if we generate 32 bit numbers and only ever multiply them by even powers of two, then the modulo problem shouldn't be a thing. I.e., if we generate an underlying 32 bit random number and scale it to fit in 21 bits (like we do here), then this shouldn't be an issue, since these numbers always divide into each other easily. For example, 2^21 / 2^32 = exactly 2048. Each number in a 2^21 range should have exactly the same chance (2048 of the total 4294967296 possible options) of getting generated from 32 bits of randomness.

But we don't know the details of every Math.random() implementation we're using, so we're not really sure. It shouldn't matter critically that we have slightly less than perfectly even distributions here, since the amount of possible options is still so high. If you have a megajillion options, and some of them have a 90-bazillion/megajillion chance of getting chosen, and the rest of them have a (90-bazillion - 1)/megajillion it's not that huge of a deal.

There's lots of details to care about with RNGs, but this 53-bit generator just has to be good enough to get us through until we can deprecate oldDot with the fewest number of collisions in the meantime (then we need to worry about our 64-bit generator).

The "real" solution is probably to use Crypto.getRandomValues() but Hermes currently doesn't support it. We can switch to it later though easily enough, or switch implementations that support it.

The real goal here is just to be able to generate numbers that don't overlap for a year or two, so we don't need to be as good as NSA encryption keys.

@roryabraham roryabraham merged commit 928edfc into main Aug 31, 2022
@roryabraham roryabraham deleted the tyler-fix-random-ids-again branch August 31, 2022 07:33
Copy link
Contributor

@Julesssss Julesssss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for explaining!

@roryabraham
Copy link
Contributor

Yes, thank's for explaining!

In theory, if we generate 32 bit numbers and only ever multiply them by even powers of two, then the modulo problem shouldn't be a thing

So a potential problem is that we don't know how many bits are used in Math.random across JS engines here? Seems like it's 32 on chrome but we don't know for sure in other browsers ... could be 53?

@OSBotify
Copy link
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@tylerkaraszewski
Copy link
Contributor Author

So a potential problem is that we don't know how many bits are used in Math.random across JS engines here? Seems like it's 32 on chrome but we don't know for sure in other browsers ... could be 53?

We're basically basing our data on this 7-year-old SO post: https://stackoverflow.com/questions/3344447/precision-of-math-random#:~:text=It's%20browser%2FJavaScript%20engine%20dependent,16%20decimals%2C%20see%20Sly1024's%20answer

There's not really a way to know without looking at the source for Math.random() in each implementation.

@tylerkaraszewski
Copy link
Contributor Author

There's actually a comment in there saying:

Chrome seems to have upgraded their precision, I can no longer observe the 21 consistently 0 bits. –
Daniel Vestøl
Aug 11, 2019 at 16:25

That might be true, I haven't looked at recent Chrome source.

@OSBotify
Copy link
Contributor

🚀 Deployed to staging by @roryabraham in version: 1.1.95-0 🚀

platform result
🤖 android 🤖 success ✅
🖥 desktop 🖥 success ✅
🍎 iOS 🍎 success ✅
🕸 web 🕸 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants