Skip to content

Commit

Permalink
feat(patterns): pattern-based compression
Browse files Browse the repository at this point in the history
  • Loading branch information
erights committed Sep 16, 2023
1 parent 66bb24c commit 061c7e6
Show file tree
Hide file tree
Showing 8 changed files with 1,327 additions and 171 deletions.
2 changes: 2 additions & 0 deletions packages/patterns/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export {
getInterfaceGuardPayload,
} from './src/patterns/patternMatchers.js';

export { mustCompress, mustDecompress } from './src/patterns/compress.js';

// ////////////////// Temporary, until these find their proper home ////////////

export { listDifference, objectMap } from './src/utils.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/patterns/src/keys/checkKey.js
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,7 @@ const checkKeyInternal = (val, check) => {
}
case 'error':
case 'promise': {
return check(false, X`A ${q(passStyle)} cannot be a key`);
return check(false, X`A ${q(passStyle)} cannot be a key: ${val}`);
}
default: {
// Unexpected tags are just non-keys, but an unexpected passStyle
Expand Down
292 changes: 292 additions & 0 deletions packages/patterns/src/patterns/compress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
// @ts-check
import { assertChecker, makeTagged, passStyleOf } from '@endo/marshal';
import { recordNames, recordValues } from '@endo/marshal/src/encodePassable.js';

import {
kindOf,
assertPattern,
maybeMatchHelper,
matches,
checkMatches,
mustMatch,
} from './patternMatchers.js';
import { isKey } from '../keys/checkKey.js';
import { keyEQ } from '../keys/compareKeys.js';

/** @typedef {import('@endo/pass-style').Passable} Passable */
/** @typedef {import('../types.js').Compress} Compress */
/** @typedef {import('../types.js').MustCompress} MustCompress */
/** @typedef {import('../types.js').Decompress} Decompress */
/** @typedef {import('../types.js').MustDecompress} MustDecompress */
/** @typedef {import('../types.js').Pattern} Pattern */

const { fromEntries } = Object;
const { Fail, quote: q } = assert;

const isNonCompressingMatcher = pattern => {
const patternKind = kindOf(pattern);
if (patternKind === undefined) {
return false;
}
const matchHelper = maybeMatchHelper(patternKind);
return matchHelper && matchHelper.compress === undefined;
};

/**
* When, for example, all the specimens in a given store match a
* specific pattern, then each of those specimens must contain the same
* literal superstructure as their one shared pattern. Therefore, storing
* that literal superstructure would be redumdant. If `specimen` does
* match `pattern`, then `compress(specimen, pattern)` will return a bindings
* array which is hopefully more compact than `specimen` as a whole, but
* carries all the information from specimen that cannot be derived just
* from knowledge that it matches this `pattern`.
*
* @type {Compress}
*/
const compress = (specimen, pattern) => {
if (isNonCompressingMatcher(pattern)) {
if (matches(specimen, pattern)) {
return harden({ compressed: specimen });
}
return undefined;
}

// Not yet frozen! Used to accumulate bindings
const bindings = [];
const emitBinding = binding => {
bindings.push(binding);
};
harden(emitBinding);

/**
* @param {Passable} innerSpecimen
* @param {Pattern} innerPattern
* @returns {boolean}
*/
const compressRecur = (innerSpecimen, innerPattern) => {
assertPattern(innerPattern);
if (isKey(innerPattern)) {
return keyEQ(innerSpecimen, innerPattern);
}
const patternKind = kindOf(innerPattern);
const specimenKind = kindOf(innerSpecimen);
switch (patternKind) {
case undefined: {
return false;
}
case 'copyArray': {
if (
specimenKind !== 'copyArray' ||
innerSpecimen.length !== innerPattern.length
) {
return false;
}
return innerPattern.every((p, i) => compressRecur(innerSpecimen[i], p));
}
case 'copyRecord': {
if (specimenKind !== 'copyRecord') {
return false;
}
const specimenNames = recordNames(innerSpecimen);
const pattNames = recordNames(innerPattern);

if (specimenNames.length !== pattNames.length) {
return false;
}
const specimenValues = recordValues(innerSpecimen, specimenNames);
const pattValues = recordValues(innerPattern, pattNames);

return pattNames.every(
(name, i) =>
specimenNames[i] === name &&
compressRecur(specimenValues[i], pattValues[i]),
);
}
case 'copyMap': {
if (specimenKind !== 'copyMap') {
return false;
}
const {
payload: { keys: pattKeys, values: valuePatts },
} = innerPattern;
const {
payload: { keys: specimenKeys, values: specimenValues },
} = innerSpecimen;
// TODO BUG: this assumes that the keys appear in the
// same order, so we can compare values in that order.
// However, we're only guaranteed that they appear in
// the same rankOrder. Thus we must search one of these
// in the other's rankOrder.
if (!keyEQ(specimenKeys, pattKeys)) {
return false;
}
return compressRecur(specimenValues, valuePatts);
}
default:
{
const matchHelper = maybeMatchHelper(patternKind);
if (matchHelper) {
if (matchHelper.compress) {
const subCompressedRecord = matchHelper.compress(
innerSpecimen,
innerPattern.payload,
compress,
);
if (subCompressedRecord === undefined) {
return false;
} else {
emitBinding(subCompressedRecord.compressed);
return true;
}
} else if (matches(innerSpecimen, innerPattern)) {
assert(isNonCompressingMatcher(innerPattern));
emitBinding(innerSpecimen);
return true;
} else {
return false;
}
}
}
throw Fail`unrecognized kind: ${q(patternKind)}`;
}
};

if (compressRecur(specimen, pattern)) {
return harden({ compressed: bindings });
} else {
return undefined;
}
};
harden(compress);

/**
* `mustCompress` is to `compress` approximately as `fit` is to `matches`.
* Where `compress` indicates pattern match failure by returning `undefined`,
* `mustCompress` indicates pattern match failure by throwing an error
* with a good pattern-match-failure diagnostic. Thus, like `fit`,
* `mustCompress` has an additional optional `label` parameter to be used on
* the outside of that diagnostic if needed. If `mustCompress` does return
* normally, then the pattern match succeeded and `mustCompress` returns a
* valid compressed value.
*
* @type {MustCompress}
*/
export const mustCompress = (specimen, pattern, label = undefined) => {
const compressedRecord = compress(specimen, pattern);
if (compressedRecord !== undefined) {
return compressedRecord.compressed;
}
// `compress` is validating, so we don't need to redo all of `mustMatch`.
// We use it only to generate the error.
// Should only throw
checkMatches(specimen, pattern, assertChecker, label);
throw Fail`internal: ${label}: inconsistent pattern match: ${q(pattern)}`;
};
harden(mustCompress);

/**
* `decompress` reverses the compression performed by `compress`
* or `mustCompress`, in order to recover the equivalent
* of the original specimen from the `bindings` array and the `pattern`.
*
* @type {Decompress}
*/
const decompress = (compressed, pattern) => {
if (isNonCompressingMatcher(pattern)) {
return compressed;
}

assert(Array.isArray(compressed));
passStyleOf(compressed) === 'copyArray' ||
Fail`Pattern ${pattern} expected bindings array: ${compressed}`;
let i = 0;
const takeBinding = () => {
i < compressed.length ||
Fail`Pattern ${q(pattern)} expects more than ${q(
compressed.length,
)} bindings: ${compressed}`;
const binding = compressed[i];
i += 1;
return binding;
};
harden(takeBinding);

const decompressRecur = innerPattern => {
assertPattern(innerPattern);
if (isKey(innerPattern)) {
return innerPattern;
}
const patternKind = kindOf(innerPattern);
switch (patternKind) {
case undefined: {
throw Fail`decompress expected a pattern: ${q(innerPattern)}`;
}
case 'copyArray': {
return harden(innerPattern.map(p => decompressRecur(p)));
}
case 'copyRecord': {
const pattNames = recordNames(innerPattern);
const pattValues = recordValues(innerPattern, pattNames);
const entries = pattNames.map((name, j) => [
name,
decompressRecur(pattValues[j]),
]);
// Reverse so printed form looks less surprising,
// with ascenting rather than descending property names.
return harden(fromEntries(entries.reverse()));
}
case 'copyMap': {
const {
payload: { keys: pattKeys, values: valuePatts },
} = innerPattern;
return makeTagged(
'copyMap',
harden({
keys: pattKeys,
values: valuePatts.map(p => decompressRecur(p)),
}),
);
}
default:
{
const matchHelper = maybeMatchHelper(patternKind);
if (matchHelper) {
if (matchHelper.decompress) {
const subCompressed = takeBinding();
return matchHelper.decompress(
subCompressed,
innerPattern.payload,
decompress,
);
} else {
assert(isNonCompressingMatcher(innerPattern));
return takeBinding();
}
}
}
throw Fail`unrecognized pattern kind: ${q(patternKind)} ${q(
innerPattern,
)}`;
}
};

return decompressRecur(pattern);
};
harden(decompress);

/**
* `decompress` reverses the compression performed by `compress`
* or `mustCompress`, in order to recover the equivalent
* of the original specimen from `compressed` and `pattern`.
*
* @type {MustDecompress}
*/
export const mustDecompress = (compressed, pattern, label = undefined) => {
const value = decompress(compressed, pattern);
// `decompress` does some checking, but is not validating, so we
// need to do the full `mustMatch` here to validate as well as to generate
// the error if invalid.
mustMatch(value, pattern, label);
return value;
};
44 changes: 38 additions & 6 deletions packages/patterns/src/patterns/internal-types.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/// <reference types="ses"/>

/** @typedef {import('@endo/marshal').Passable} Passable */
/** @typedef {import('@endo/marshal').PassStyle} PassStyle */
/** @typedef {import('@endo/marshal').CopyTagged} CopyTagged */
/** @template T @typedef {import('@endo/marshal').CopyRecord<T>} CopyRecord */
/** @template T @typedef {import('@endo/marshal').CopyArray<T>} CopyArray */
/** @typedef {import('@endo/marshal').Checker} Checker */
/** @typedef {import('@endo/pass-style').Passable} Passable */
/** @typedef {import('@endo/pass-style').PassStyle} PassStyle */
/** @typedef {import('@endo/pass-style').CopyTagged} CopyTagged */
/** @template T @typedef {import('@endo/pass-style').CopyRecord<T>} CopyRecord */
/** @template T @typedef {import('@endo/pass-style').CopyArray<T>} CopyArray */
/** @typedef {import('@endo/pass-style').Checker} Checker */
/** @typedef {import('@endo/marshal').RankCompare} RankCompare */
/** @typedef {import('@endo/marshal').RankCover} RankCover */

Expand All @@ -21,6 +21,7 @@
*/
/** @typedef {import('../types.js').MethodGuardMaker0} MethodGuardMaker0 */

/** @typedef {import('../types.js').Kind} Kind */
/** @typedef {import('../types').MatcherNamespace} MatcherNamespace */
/** @typedef {import('../types').Key} Key */
/** @typedef {import('../types').Pattern} Pattern */
Expand All @@ -29,12 +30,20 @@
/** @typedef {import('../types').AllLimits} AllLimits */
/** @typedef {import('../types').GetRankCover} GetRankCover */

/** @typedef {import('../types.js').CompressedRecord} CompressedRecord */
/** @typedef {import('../types.js').Compress} Compress */
/** @typedef {import('../types.js').MustCompress} MustCompress */
/** @typedef {import('../types.js').Decompress} Decompress */
/** @typedef {import('../types.js').MustDecompress} MustDecompress */

/**
* @typedef {object} MatchHelper
* This factors out only the parts specific to each kind of Matcher. It is
* encapsulated, and its methods can make the stated unchecked assumptions
* enforced by the common calling logic.
*
* @property {string} tag
*
* @property {(allegedPayload: Passable,
* check: Checker
* ) => boolean} checkIsWellFormed
Expand All @@ -48,6 +57,27 @@
* Assuming validity of `matcherPayload` as the payload of a Matcher corresponding
* with this MatchHelper, reports whether `specimen` is matched by that Matcher.
*
* @property {(specimen: Passable,
* matcherPayload: Passable,
* compress: Compress
* ) => (CompressedRecord | undefined)} [compress]
* Assuming a valid Matcher of this type with `matcherPayload` as its
* payload, if this specimen matches this matcher, then return a
* CompressedRecord that represents this specimen,
* perhaps more compactly, given the knowledge that it matches this matcher.
* If the specimen does not match the matcher, return undefined.
* If this matcher has a `compress` method, then it must have a matching
* `decompress` method.
*
* @property {(compressed: Passable,
* matcherPayload: Passable,
* decompress: Decompress
* ) => Passable} [decompress]
* If `compressed` is the result of a successful `compress` with this matcher,
* then `decompress` must return a Passable equivalent to the original specimen.
* If this matcher has an `decompress` method, then it must have a matching
* `compress` method.
*
* @property {import('../types').GetRankCover} getRankCover
* Assumes this is the payload of a CopyTagged with the corresponding
* matchTag. Return a RankCover to bound from below and above,
Expand All @@ -69,5 +99,7 @@
* @property {(patt: Pattern) => void} assertPattern
* @property {(patt: Passable) => boolean} isPattern
* @property {GetRankCover} getRankCover
* @property {(passable: Passable, check?: Checker) => (Kind | undefined)} kindOf
* @property {(tag: string) => (MatchHelper | undefined)} maybeMatchHelper
* @property {MatcherNamespace} M
*/
Loading

0 comments on commit 061c7e6

Please sign in to comment.