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

fix(marshal)!: Update compareRank to terminate comparability at the first remotable #2597

Merged
merged 6 commits into from
Oct 22, 2024

Conversation

gibson042
Copy link
Contributor

@gibson042 gibson042 commented Oct 21, 2024

Fixes #2588

Description

To be refinable into a total order that distinguishes remotables, compareRank must consider [r1, 'x'] vs. [r2, 'y'] as a tie rather than as equivalent to [0, 'x'] vs. [0, 'y'], in case r1 vs. r2 ends up comparing differently than 'x' vs. 'y'.

Security Considerations

None known.

Scaling Considerations

None known.

Documentation Considerations

This includes implicit fixup in types.js, but there may be some Markdown that needs updating as well.

Testing Considerations

This expands property-based testing, but only narrowly. Expansion is expected in the future.

Compatibility Considerations

This affects storage of compound Keys in e.g. CopySets/CopyBags/CopyMaps, but any ordering written by the old compareRank should be accepted by the new one (which now reclassifies as ties what its predecessor considered to be distinct ranks). As such, we should be fine, but I welcome challenges to this assertion.

Upgrade Considerations

This is breaking in the sense of changing compareRank output, but as far as I know, nothing depends upon that except in combination with compareKeys (which is fixed by this PR). I am not aware of any concerns that should hinder upgrade to a version of endo including this PR.

But I am wondering what if anything to put in NEWS.md file(s).

@gibson042 gibson042 force-pushed the gibson-2588-fullorder-narrows-rankorder branch 2 times, most recently from e502d10 to 4e39e58 Compare October 21, 2024 04:16
Copy link
Member

@kriskowal kriskowal left a comment

Choose a reason for hiding this comment

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

Flushing this review before I get to the end, to keep the conversation lively.

export const makeComparatorKit = (compareRemotables = (_x, _y) => 0) => {
/** @type {RankCompare} */
export const makeComparatorKit = (compareRemotables = (_x, _y) => NaN) => {
/** @type {PartialCompare} */
Copy link
Member

Choose a reason for hiding this comment

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

Confirm or deny:

Previously:

  • values from dispirit ranks are compared by the total order of ranks
  • all values within atom ranks, e.g., numbers and strings, are mutually comparable
  • all values within composite ranks, e.g., arrays, are mutually comparable provided
    • that their respective entries are all comparable, or
    • if they contain any respective pairs that are incomparable, those arrays have non-equivalent prefixes
    • and are otherwise incomparable
  • within the rank of promises, all values are incomparable
  • within the rank of remotables, all values are equivalent
  • comparing incomparable values produces 0

After:

  • within the rank of remotables, all values are incomparable
  • comparing incomparable values produces NaN

Copy link
Contributor Author

@gibson042 gibson042 Oct 21, 2024

Choose a reason for hiding this comment

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

That seems correct, although I would rephrase a bit:

Previously:

  • within the rank of remotables, all values are equivalent in the default comparison (used by compareRank)
  • comparing incomparable values produces 0 within the respective ranks of errors and promises, all values are equivalent (and encountering an unacceptable pass style would result in a thrown exception)

After:

  • within the rank of remotables, all values are incomparable
  • comparing incomparable values produces NaN in the inner comparator and short-circuits the outer comparator result to 0

Copy link
Member

Choose a reason for hiding this comment

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

So, both before and after this change, comparing any pair of promises would by default return NaN?

Consequently, both before and after this change, the range of comparison was -1, 0, 1, and NaN?

Consequently, both before and after this change, rank compare is and was a partial order?

Copy link
Member

Choose a reason for hiding this comment

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

I’m far enough along in my read to see with my own eyes that, both before and after this change:

  • all exported comparators produce values in the range -1, 0, +1 for the default comparator for remotables.
  • all promises are equivalent, not incoparable

@erights
Copy link
Contributor

erights commented Oct 21, 2024

Just skimming, but saw the q at end. rankCompare is a full order. It cannot answer NaN. (Unless this PR is making a more radical change than I thought.

For all promises p,q, compareRank(p,q) === 0

Copy link
Member

@kriskowal kriskowal left a comment

Choose a reason for hiding this comment

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

I’m convinced about the correctness and practical forward-compatibility of the fix.

I note that the fix occurs almost entirely outside the diff as an emergent behavior of having compareRemoatables return NaN and coercing NaN back down to 0 at the border. The relevant behavior appears to be stopping early and propagating NaN on:

case 'copyArray': {
// Lexicographic
const len = Math.min(left.length, right.length);
for (let i = 0; i < len; i += 1) {
const result = comparator(left[i], right[i]);
if (result !== 0) {
return result;
}

I am not qualified to read the property based tests yet.

Comment on lines 347 to 358
let i = 0;
for (;;) {
const { value: char1 } = iter1.next();
const { value: char2 } = iter2.next();
if (char1 !== char2 || char1 === undefined) {
return str1.substring(0, i);
}
i += char1.length;
}
Copy link
Member

Choose a reason for hiding this comment

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

I’m sure you’re cognizant of the discrepancy here between iterating code points and indexing code units, but that might not be obvious under maintenance of these tests. Anything that compensates for that would be great, albeit accumulating common code points by concatenation or documenting a limitation to correctness under strings where code unit and code point are the same, but if documenting, consider using the dumber algorithm that co-iterates code units to be consistent with the substring call.

Comment on lines +299 to +314
const sorted = unsorted.sort(compare);
// For reverse comparison, move `undefined` values from the end to the start.
// Note that passStylePrefixes (@see {@link ./encodePassable.js}) MUST NOT
// sort any category after `undefined`.
if (compare(true, undefined) > 0) {
let i = sorted.length - 1;
while (i >= 0 && sorted[i] === undefined) i -= 1;
const n = sorted.length - i - 1;
if (n > 0 && n < sorted.length) {
sorted.copyWithin(n, 0);
sorted.fill(/** @type {T} */ (undefined), 0, n);
}
}
harden(sorted);
Copy link
Member

Choose a reason for hiding this comment

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

✅ the undefined fix

Comment on lines 228 to 231
const outerComparator = (x, y) =>
/** @type {Exclude<PartialComparison, NaN>} */ (comparator(x, y) || 0);
Copy link
Member

Choose a reason for hiding this comment

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

✅ narrowing the range in case of a compareRemotables that returns NaN, as it now will by default.

This does break any existing program that expects to distinguish NaN and provided a compareRemotables to produce that effect, but as you state you’re convinced nobody does that in practice, I’m fine with this. It may be worth noting in NEWS.md if only to build trust.

Copy link
Member

Choose a reason for hiding this comment

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

It might be worth explicitly reminding reviewers that NaN || 0 === 0 because it’s not entirely obvious which JavaScript operators implicitly coerce down from the float to integer domain.

export const makeComparatorKit = (compareRemotables = (_x, _y) => 0) => {
/** @type {RankCompare} */
export const makeComparatorKit = (compareRemotables = (_x, _y) => NaN) => {
/** @type {PartialCompare} */
Copy link
Member

Choose a reason for hiding this comment

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

I’m far enough along in my read to see with my own eyes that, both before and after this change:

  • all exported comparators produce values in the range -1, 0, +1 for the default comparator for remotables.
  • all promises are equivalent, not incoparable

Comment on lines 142 to 161
await fc.assert(
fc.property(
fc.uniqueArray(arbKey, { comparator: keyEQ }),
fc.infiniteStream(fc.boolean()),
(arr, keepSeq) => {
const sub = arr.filter(() => keepSeq.next().value);
setIsSuperset(makeCopySet(arr), makeCopySet(sub)) ||
Fail`${q(sub)} of ${q(arr)}`;
},
),
);
Copy link
Member

Choose a reason for hiding this comment

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

I’m not a qualified reviewer for property tests yet.

Copy link
Contributor

Choose a reason for hiding this comment

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

I read this as: For an arbitrary array of distinct keys arr and subsequence sub of arr, setIsSuperset(makeCopySet(arr), makeCopySet(sub)) holds, where a subsequence is formed by filtering using elements of an arbitrary infinite stream of booleans.

Copy link
Contributor

@dckc dckc Oct 22, 2024

Choose a reason for hiding this comment

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

I thought it would be illustrative to show the output when it fails, before the fix is applied. But even when I check out 14fca7b that has the test and not the fix, this test seems to pass. I suppose the failing case is pretty obscure and the generator doesn't get that far?

When I crank numRuns up to 4000, I get a failure that might be showing another problem? Got error: Error: ["[undefined]"] of ["[Symbol()]",null,"[undefined]"] with seed: -1238147821

Copy link
Contributor

Choose a reason for hiding this comment

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

oic... it's finding cases of the undefined problem.

@gibson042 gibson042 force-pushed the gibson-2588-fullorder-narrows-rankorder branch from 6094bcb to 85ff875 Compare October 22, 2024 01:12
@gibson042
Copy link
Contributor Author

Just skimming, but saw the q at end. rankCompare is a full order. It cannot answer NaN. (Unless this PR is making a more radical change than I thought.

@erights Right, and that remains true... the default compareRemotables (i.e., the one used by compareRank) now returns NaN to avoid misclassification of e.g. [r1, 'x'] vs. [r2, 'y'] as being in different ranks (without disrupting such correct classification of e.g. [0, 'x'] vs. [0, 'y']), but that NaN is replaced with 0 on the way out—compareRank and indeed any function returned from makeComparatorKit continues to implement a total preorder in which all valid inputs are comparable but ties are possible.

Do you have further comments or reason(s) to delay merging now that @kriskowal has approved?

@@ -143,61 +143,17 @@ export {};
*/

/**
* @typedef {RankCompare} FullCompare
Copy link
Contributor

Choose a reason for hiding this comment

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

Are static types considered part of the API? This FullCompare type is exported, right? So is removing it from this package a breaking change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, that's a good question. I've updated to reëxport FullCompare to preserve any remaining imports, on the assumption that dropping it would be considered breaking.

@erights
Copy link
Contributor

erights commented Oct 22, 2024

Do you have further comments or reason(s) to delay merging now that @kriskowal has approved?

I have not found time to review this in enough depth to approve it. But I saw no red flags and am satisfied from the conversations above that all the issues I thought to worry about have been extremely well thought through and reviewed. So please consider your existing approval adequate and do not wait for me. In this case, I'm happy to complete my review post merge.

Really looks great. A clever simple solution that I would not have thought of. Thanks much!!

…irst remotable

To be refinable into a total order that distinguishes remotables,
`compareRank` must consider `[r1, 'x']` vs. `[r2, 'y']` as a tie rather
than as equivalent to `[0, 'x']` vs. `[0, 'y']`, in case r1 vs. r2 ends
up comparing differently than 'x' vs. 'y'.

Fixes #2588
@gibson042 gibson042 force-pushed the gibson-2588-fullorder-narrows-rankorder branch from 85ff875 to 95c427a Compare October 22, 2024 06:08
@gibson042 gibson042 enabled auto-merge (squash) October 22, 2024 06:38
@gibson042 gibson042 merged commit 1dea84d into master Oct 22, 2024
15 checks passed
@gibson042 gibson042 deleted the gibson-2588-fullorder-narrows-rankorder branch October 22, 2024 06:47
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.

failure in elementsIsSuperset({x, y, z}, {z})
4 participants