diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eecd367..a875eb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,14 +106,6 @@ jobs: language: typescript label: unit-trilean-sql fail-on-error: false - - name: Upload coverage report (trilean-combinatorics) - if: vars.CODE_QUALITY_ENABLED == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && hashFiles('packages/trilean-combinatorics/coverage/cobertura-coverage.xml') != '' - uses: actions/upload-code-coverage@v1 - with: - file: packages/trilean-combinatorics/coverage/cobertura-coverage.xml - language: typescript - label: unit-trilean-combinatorics - fail-on-error: false test-integration: name: Integration test diff --git a/README.md b/README.md index 9557d0e..178fce7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ The pnpm workspace holding the trilean packages. Each package is versioned, rele | [trilean](packages/trilean/README.md) | `packages/trilean` | [`trilean`](https://www.npmjs.com/package/trilean) on npm, [`@exadev/trilean`](https://github.com/ExaDev/trilean/pkgs/npm/trilean) on GitHub Packages | | [trilean-sql](packages/trilean-sql/README.md) | `packages/trilean-sql` | [`trilean-sql`](https://www.npmjs.com/package/trilean-sql) on npm, [`@exadev/trilean-sql`](https://github.com/ExaDev/trilean/pkgs/npm/trilean-sql) on GitHub Packages | | [trilean-regex](packages/trilean-regex/README.md) | `packages/trilean-regex` | [`trilean-regex`](https://www.npmjs.com/package/trilean-regex) on npm, [`@exadev/trilean-regex`](https://github.com/ExaDev/trilean/pkgs/npm/trilean-regex) on GitHub Packages | -| [trilean-combinatorics](packages/trilean-combinatorics/README.md) | `packages/trilean-combinatorics` | [`trilean-combinatorics`](https://www.npmjs.com/package/trilean-combinatorics) on npm, [`@exadev/trilean-combinatorics`](https://github.com/ExaDev/trilean/pkgs/npm/trilean-combinatorics) on GitHub Packages | Each package is versioned, released, and published independently of every other, from its own commit history. There is no lockstep version shared across the workspace. @@ -40,8 +39,7 @@ The `pnpm ` scripts are thin wrappers over `turbo run _`; the unders ├── packages/ │ ├── trilean/ the evaluation library, with its own README, CHANGELOG, and configs │ ├── trilean-sql/ the SQL compiler over trilean's predicate trees, likewise self-contained -│ ├── trilean-regex/ the portable regex grammar trilean-sql pushes down where it can, likewise self-contained -│ └── trilean-combinatorics/ trilean trees as combinatrix constraints, and covering arrays of a model's valid combinations, likewise self-contained +│ └── trilean-regex/ the portable regex grammar trilean-sql pushes down where it can, likewise self-contained ├── pnpm-workspace.yaml package globs and pnpm's install-time settings ├── turbo.json the task pipeline every package's tasks are ordered and cached by ├── tsconfig.base.json the compiler options every package extends diff --git a/packages/trilean-combinatorics/CHANGELOG.md b/packages/trilean-combinatorics/CHANGELOG.md deleted file mode 100644 index 52522d4..0000000 --- a/packages/trilean-combinatorics/CHANGELOG.md +++ /dev/null @@ -1,22 +0,0 @@ -## [1.0.1](https://github.com/ExaDev/trilean/compare/trilean-combinatorics%401.0.0...trilean-combinatorics%401.0.1) (2026-10-29) - - -### Dependencies - -- Updated trilean to 1.13.0 - -## 1.0.0 (2026-10-29) - -### Features - -* **trilean-combinatorics:** build covering arrays without materialising the solution set ([62ce2ae](https://github.com/ExaDev/trilean/commit/62ce2aebac0aff87c95525a770c368336291ca8a)) -* **trilean-combinatorics:** constrain combinatrix models with trilean trees and cover them ([4e98909](https://github.com/ExaDev/trilean/commit/4e98909033c659251b255a559f94b60cc2ad3c5f)) - -### Build System - -* **trilean-combinatorics:** pin trilean to the workspace version ([dbe3ddd](https://github.com/ExaDev/trilean/commit/dbe3ddd3581b6d9a5320cfd61b0898e351a0d2ef)) - - -### Dependencies - -- Updated trilean to 1.12.0 diff --git a/packages/trilean-combinatorics/LICENSE b/packages/trilean-combinatorics/LICENSE deleted file mode 100644 index e6cf1d1..0000000 --- a/packages/trilean-combinatorics/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Joseph Mearman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/trilean-combinatorics/README.md b/packages/trilean-combinatorics/README.md deleted file mode 100644 index 14fb1ce..0000000 --- a/packages/trilean-combinatorics/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# trilean-combinatorics - -[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/trilean) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/trilean-combinatorics) [![Release](https://img.shields.io/github/v/release/ExaDev/trilean?filter=trilean-combinatorics@*&label=release)](https://github.com/ExaDev/trilean/releases?q=trilean-combinatorics%40) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/trilean/ci.yml?branch=main)](https://github.com/ExaDev/trilean/actions) - -Makes [trilean](https://www.npmjs.com/package/trilean) predicate trees the constraint language of a [combinatrix](https://www.npmjs.com/package/combinatrix) model, and covers the model's valid combinations with pairwise and t-wise covering arrays instead of enumerating all of them. - -```sh -pnpm add trilean-combinatorics -``` - -`trilean` and `combinatrix` are dependencies rather than implementation details: the trees it takes are trilean's `PredicateNode`, and what it returns plugs into combinatrix's own `Constraint` and `Strategy` contracts. It is ESM only, as combinatrix is. - -## The idea - -A combinatrix model is a set of option dimensions and the constraints between them, and its solution set is every combination the constraints allow. combinatrix searches that set by assigning dimensions one at a time and asking each constraint, on the partial assignment, whether every completion satisfies it, none does, or it cannot tell yet. A constraint that answers early prunes a whole branch, which is only sound if the answer never changes as more dimensions are assigned. - -trilean already evaluates rules three-valued: a tree is definitely true, definitely false, or indeterminate with a reason, and its connectives follow Kleene's strong tables, so an `or` with one true operand is true whatever the other is. The adapter feeds it the partial assignment through the resolvers: a dimension not yet assigned is reported as not yet known, which keeps everything reading it indeterminate (`exists` included), and a dimension assigned unset is reported as known absent, which `exists` reads as false. So a definite trilean answer on a partial assignment is the answer on every completion of it, and the tree prunes exactly as far as Kleene's logic allows. - -The second half answers a different question. Enumerating every valid combination of many dimensions is often far more than a test suite can run, and most faults depend on how two or three settings interact, not on all of them at once. A covering array of strength t is a set of valid combinations in which every t-way combination of values that the model allows appears at least once. Coverage is measured against the solution set, so a pair the constraints forbid is never asked for, and a model whose solution set is empty yields no rows. - -```ts -import { choice, collect, defineModel } from "combinatrix"; -import { coveringArray, isCoveringArray, trileanConstraint } from "trilean-combinatorics"; - -const dimensions = [ - choice("mode", ["fast", "safe"]), - choice("ratio", [undefined, 0.3, 0.5]), - choice("cache", [true, false]), - choice("retries", [0, 1, 3]), -]; - -// "fast" needs a ratio of at least 0.5. ratio can be unset, so its comparison is guarded with exists: when it is unset the guard is definitely false and absorbs the comparison. mode is never unset, so its comparison needs no guard. -const fastNeedsRatio = trileanConstraint( - { - kind: "or", - left: { - kind: "textCompare", - op: "notEquals", - left: { kind: "reference", key: "mode" }, - right: { kind: "textLiteral", value: "fast" }, - }, - right: { - kind: "and", - left: { kind: "exists", operand: { kind: "reference", key: "ratio" } }, - right: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "ratio" }, - right: { kind: "numberLiteral", value: 0.5 }, - }, - }, - }, - { dimensions, name: "fast needs a ratio of at least 0.5" }, -); - -const model = defineModel({ dimensions, constraints: [fastNeedsRatio] }); - -// 24 valid combinations; every pair of values among them appears in at least one of these rows. -const rows = collect(model, { strategy: coveringArray({ strength: 2 }), limit: 10_000 }).map(({ values }) => values); -isCoveringArray(rows, model, { strength: 2, limit: 10_000 }); // true -``` - -## API - -### `trileanConstraint(tree, { dimensions, name?, functions? })` - -Turns a trilean predicate tree, given as plain JSON, into a combinatrix `Constraint`. `dimensions` are the dimensions of the model it will be given to, `name` labels it in diagnostics, and `functions` is the registry `call` nodes may name (trilean's empty registry by default). - -When it is defined, the tree is validated with trilean's `PredicateNodeSchema` and walked. `PredicateTreeError` lists every defect, each with its path in the tree: - -- a node kind the adapter has no resolver for: `some`, `every`, `fold` and `accumulator` (collections), `lookup` (tables), `delegate` and `treeReference`. The accepted kinds are listed explicitly, so a kind trilean adds later is refused until the adapter learns it; -- a `reference` whose key is not a string, names no dimension, or carries a `unit`, since dimension values carry none; -- a `call` to a name that is not one of the registry's own keys; -- a referenced choice or first-valid dimension that can take `null`. - -`EvaluationBudgetError` is thrown when the tree has more nodes than trilean's `DEFAULT_MAX_EVALUATION_NODES` or nests to `DEFAULT_MAX_NESTING_DEPTH` (the root is at depth 0). The evaluator runs with those defaults, and a tree the adapter accepts visits each node at most once per evaluation, so checking the tree itself means an evaluation never meets the budget partway through. - -`dependencies` are the dimensions the tree's references name, once each, in the order the tree first mentions them. `evaluate` runs trilean's synchronous evaluator over the assignment: - -| The dimension is | `resolveValue` reports | So | -| --- | --- | --- | -| assigned a string | `{ found: true, value: { kind: "text", value } }` | compared with `textCompare` or `memberOf` | -| assigned a number | `{ found: true, value: { kind: "number", value } }`, with no unit | compared with `compare` or `memberOf` | -| assigned a boolean | `{ found: true, value: { kind: "boolean", value } }` | compared with `memberOf` | -| assigned unset | `{ found: false }` | `exists` is false; anything else reading it is indeterminate `not-found` | -| not yet assigned | `{ found: false, notYetKnown: true }` | `exists` and anything else reading it is indeterminate `not-yet-known` | - -`null`, the one other value a combinatrix dimension can hold, has no trilean counterpart: a choice or first-valid dimension that can take it is refused when the constraint is defined, and a computed dimension that produces it throws `UnmappableValueError` when it is read. - -A definite true is `valid` and a definite false `invalid`. An indeterminate result is `unknown` while any dependency is unassigned. Once every dependency is assigned, combinatrix's contract forbids `unknown`, so an indeterminate result there throws `UndecidedConstraintError` (a `ConstraintContractError`) carrying trilean's reason. The usual cause is reading a dimension that is unset in some combination without guarding it: `compare(reference, ...)` is indeterminate `not-found` when the dimension is unset, while `and(exists(reference), compare(reference, ...))` is definitely false there, because a definite false absorbs. Comparing values of different kinds (a text dimension with `compare`, say) is indeterminate `wrong-type` and throws the same way. - -### `coveringArray({ strength, limit? })` - -A combinatrix `Strategy` yielding a covering array of the solution set: valid combinations such that every t-way combination of dimension values that occurs in some valid combination occurs in at least one of them. `strength` is t, a positive integer: 1 covers each value, 2 is pairwise, 3 three-wise, and a strength at or above the dimension count yields the whole solution set. `limit`, optional, is the most rows the construction may hold: every row is held until the array is complete, so past it the strategy throws combinatrix's `LimitExceededError` instead of building a larger array. Without it the array has at most one row per coverable t-tuple. Either option out of range throws `RangeError` when the strategy is made. - -Every dimension is covered: - -- a free dimension's values, unset included when its domain lists `undefined`; -- a derived dimension's values (first-valid and computed), since a derived value is part of every combination and can decide behaviour as much as a free one. Coverage is measured on the solution set, so a derived value is only ever required where it really occurs; -- a dimension collapsed to unset by `activeWhen` is unset in those combinations, and unset counts as a value there. A combination does not record why a dimension is unset, so unset by choice and unset by inactivity are one value. - -The construction is in-parameter-order ([IPOG](https://csrc.nist.gov/pubs/conference/2007/03/29/ipog-a-general-strategy-for-tway-software-testing/final), with constraints handled on partial rows as [IPOG-C](https://www.nist.gov/node/572651) does) and never reads the solution set. It starts one row per coverable t-tuple of the first t dimensions, then places each later dimension in turn: every row takes the value covering the most of that dimension's uncovered t-tuples, and each tuple still uncovered goes into the first row it fits, or into a new row whose other dimensions are left open. Each row is completed to a valid combination at the end, and a row whose every t-tuple another row also holds is dropped. Whether a t-tuple is coverable, and whether a row may take a value, are both asked of the engine as "is there a valid combination agreeing with this partial assignment" through the strategy context's `completions`, stopping at the first combination found, so the constraints' three-valued verdicts on partial assignments prune each question as they prune enumeration. - -So the construction holds at most one row per coverable t-tuple and one dimension's t-tuples at a time, which is polynomial in the dimension count for a fixed strength and independent of how large the solution set is, and its engine queries are polynomial in the t-tuple count. A computed dimension's possible values are found by one query per choice of the values of the dimensions it reads. The same model always gives the same rows in the same order. The whole array is built before the first row is yielded, since a later dimension can still change any row. An empty solution set yields nothing. - -The array is not always smaller than a greedy construction over the whole solution set (repeatedly taking the valid combination covering the most uncovered t-tuples), which is not size-optimal either: it is larger on some models and smaller on others, and the tests check it is no larger in total over the generated models. - -### `isCoveringArray(rows, model, { strength, limit })` - -Whether `rows` are a covering array of the model's solution set at that strength: every row is a complete combination (derived dimensions included) that `explain` accepts, and every t-tuple occurring in the solution set occurs in some row. It is the brute-force definition, written independently of `coveringArray` so that it can test it, and it enumerates the whole solution set with `collect` under `limit`. - -### Errors - -`PredicateTreeError` (with `issues`), `EvaluationBudgetError` (with `budget`, `actual` and `limit`) and `UnmappableValueError` (with `dimension` and `value`) extend combinatrix's `CombinatrixError`; `UndecidedConstraintError` (with trilean's `reason`) extends its `ConstraintContractError`. - -## Tests - -The unit suite holds the adapter and the strategy to definitions rather than to examples alone. Kleene soundness is checked exhaustively on a small model: for every partial assignment, a verdict that is not `unknown` equals the verdict on every completion of it, for trees covering every connective, `exists`, `coalesce` and `conditional`, and the adapter agrees with trilean's own `evaluatePredicate` on every complete assignment. The evaluator's budgets are checked at their boundaries, a tree exactly at each limit evaluating and one beyond it being refused, which pins the definition-time count to what the evaluator spends. - -The strategy is checked against `isCoveringArray` as an oracle over hundreds of seeded random models using every combinatrix feature (unset values, conditional activity, first-valid and computed dimensions, rules and guarded trilean constraints), at strengths 1, 2 and 3: every output is a covering array of valid, distinct combinations, deterministic, no larger than the solution set and with no more rows than coverable t-tuples. The same models compare it with the greedy construction over the whole solution set, kept in the test support as a reference, and its total over the models must not exceed the reference's. A work-count test runs it on a model whose solution set is far larger than its pair count, counting every query it makes of the engine and every combination the engine hands back, and holds both to a bound derived from the pair count, while the reference cannot even start within that many combinations. The oracle is itself shown to discriminate: it refuses the strategy's output with any one row removed, and a row outside the solution set. - -## Licence - -MIT, see [LICENSE](LICENSE). diff --git a/packages/trilean-combinatorics/eslint.config.ts b/packages/trilean-combinatorics/eslint.config.ts deleted file mode 100644 index 7b71fa3..0000000 --- a/packages/trilean-combinatorics/eslint.config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { builtinModules } from "node:module"; -import { exadevConfig } from "@exadev/eslint-config"; -import { defineConfig } from "eslint/config"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; -import globals from "globals"; - -const nodeBuiltinBaseModules = [ - ...new Set( - builtinModules - .filter((name) => !name.startsWith("_") && !name.startsWith("node:")) - .map((name) => - name.includes("/") ? name.slice(0, name.indexOf("/")) : name, - ), - ), -].sort(); -const bareNodeBuiltinPattern = `^(${nodeBuiltinBaseModules.join("|")})(/.*)?$`; - -export default defineConfig( - ...exadevConfig(), - { - ignores: ["dist", "coverage", "node_modules", ".turbo"], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, - }, - }, - { - files: ["**/*.{ts,tsx,mts,cts}"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - "exadev/barrel-policy": ["error", { mode: "single" }], - }, - }, - { - files: ["src/**/*.ts"], - ignores: ["src/**/*.test.ts", "src/test-support/**"], - rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["node:*", "node:*/**"], - message: - "This is an isomorphic library: node:* imports are banned in runtime src.", - }, - { - regex: bareNodeBuiltinPattern, - message: - "This is an isomorphic library: bare Node builtin imports are banned in runtime src.", - }, - ], - }, - ], - "no-restricted-globals": [ - "error", - { - name: "Buffer", - message: "Buffer is Node-only; use Uint8Array/plain objects instead.", - }, - ], - }, - }, - { - files: ["**/*.test.ts"], - rules: { - "@typescript-eslint/no-empty-function": [ - "error", - { allow: ["arrowFunctions", "asyncFunctions"] }, - ], - }, - }, - eslintPluginPrettierRecommended, -); diff --git a/packages/trilean-combinatorics/package.json b/packages/trilean-combinatorics/package.json deleted file mode 100644 index 5e071c3..0000000 --- a/packages/trilean-combinatorics/package.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "name": "trilean-combinatorics", - "description": "Constrains combinatrix models with trilean predicate trees, evaluated three-valued on partial assignments, and covers their valid combinations with pairwise and t-wise covering arrays.", - "version": "1.0.1", - "bugs": { - "url": "https://github.com/ExaDev/trilean/issues" - }, - "dependencies": { - "combinatrix": "1.0.0", - "trilean": "1.13.0" - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.18.5", - "@exadev/eslint-config": "3.0.0", - "@types/node": "^26.6.5", - "@vitest/coverage-v8": "^5.0.4", - "eslint": "^10.12.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "globals": "^17.13.0", - "prettier": "^3.9.10", - "publint": "^0.3.25", - "tsdown": "^0.23.1", - "turbo": "^2.11.7", - "typescript": "^6.0.3", - "typescript-eslint": "^8.71.1", - "vitest": "^5.0.3" - }, - "engines": { - "node": ">=22" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./*": { - "types": "./dist/*.d.ts", - "import": "./dist/*.js" - } - }, - "files": [ - "dist" - ], - "homepage": "https://github.com/ExaDev/trilean/tree/main/packages/trilean-combinatorics", - "keywords": [ - "combinatorial-testing", - "combinatrix", - "constraint-satisfaction", - "covering-array", - "pairwise", - "predicate", - "t-wise", - "three-valued-logic", - "trilean" - ], - "license": "MIT", - "main": "./dist/index.js", - "module": "./dist/index.js", - "publishConfig": { - "access": "public", - "provenance": true, - "registry": "https://registry.npmjs.org/" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/trilean.git", - "directory": "packages/trilean-combinatorics" - }, - "scripts": { - "_build": "tsdown", - "_lint": "eslint . --fix --cache --max-warnings 0", - "_prepush": "true", - "_test": "vitest run --project unit", - "_test:coverage": "vitest run --project unit --coverage", - "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", - "_typecheck:attw": "attw --pack --profile esm-only", - "build": "turbo run _build", - "lint": "turbo run _lint", - "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only", - "prepush": "turbo run _prepush", - "test": "turbo run _test", - "test:coverage": "turbo run _test:coverage", - "typecheck": "turbo run _typecheck _typecheck:attw" - }, - "sideEffects": false, - "type": "module", - "types": "./dist/index.d.ts" -} diff --git a/packages/trilean-combinatorics/src/constraint.soundness.unit.test.ts b/packages/trilean-combinatorics/src/constraint.soundness.unit.test.ts deleted file mode 100644 index a8d916b..0000000 --- a/packages/trilean-combinatorics/src/constraint.soundness.unit.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Dimension, PartialAssignment, Value } from "combinatrix"; -import { choice } from "combinatrix"; -import type { - ExpressionNode, - JsonValue, - PredicateNode, - Resolution, -} from "trilean"; -import { coalesce, evaluatePredicate } from "trilean"; -import { describe, expect, it } from "vitest"; -import { trileanConstraint } from "./index"; - -/** The ratio values the model takes, and one strictly between them. */ -const LOW = 0.3; -const HIGH = 0.5; -const BETWEEN = 0.4; - -const DOMAINS = { - mode: [undefined, "fast", "safe"], - ratio: [undefined, LOW, HIGH], - cache: [undefined, true, false], -} as const satisfies Record; -type Name = keyof typeof DOMAINS; -const NAMES = ["mode", "ratio", "cache"] as const satisfies readonly Name[]; - -const dimensions: readonly Dimension[] = NAMES.map((name) => { - const values: readonly Value[] = DOMAINS[name]; - - return choice(name, values); -}); - -const ref = (key: Name): ExpressionNode => ({ kind: "reference", key }); -const exists = (key: Name): PredicateNode => ({ - kind: "exists", - operand: ref(key), -}); -const and = (left: PredicateNode, right: PredicateNode): PredicateNode => ({ - kind: "and", - left, - right, -}); -const or = (left: PredicateNode, right: PredicateNode): PredicateNode => ({ - kind: "or", - left, - right, -}); -const not = (operand: PredicateNode): PredicateNode => ({ - kind: "not", - operand, -}); -const isMode = (value: string): PredicateNode => - and(exists("mode"), { - kind: "textCompare", - op: "equals", - left: ref("mode"), - right: { kind: "textLiteral", value }, - }); -const ratioAtLeast = (value: number): PredicateNode => - and(exists("ratio"), { - kind: "compare", - op: "gte", - left: ref("ratio"), - right: { kind: "numberLiteral", value }, - }); -const cacheOn: PredicateNode = and(exists("cache"), { - kind: "memberOf", - op: "in", - operand: ref("cache"), - candidates: [{ kind: "booleanLiteral", value: true }], -}); - -/** Trees that are definite on every complete assignment of the model, covering every connective, exists on its own and under negation, coalesce and a conditional. */ -const TREES: readonly (readonly [string, PredicateNode])[] = [ - ["exists", exists("mode")], - ["not exists", not(exists("ratio"))], - ["guarded text", isMode("fast")], - ["guarded number", ratioAtLeast(HIGH)], - ["guarded boolean", cacheOn], - ["or of guards", or(isMode("safe"), ratioAtLeast(BETWEEN))], - ["and of negations", and(not(isMode("fast")), not(cacheOn))], - ["implication", or(not(isMode("fast")), and(exists("ratio"), not(cacheOn)))], - [ - "anyOf", - { - kind: "anyOf", - operands: [cacheOn, not(exists("mode")), ratioAtLeast(LOW)], - }, - ], - [ - "allOf", - { - kind: "allOf", - operands: [exists("cache"), or(isMode("safe"), not(exists("ratio")))], - }, - ], - [ - "coalesce", - { - kind: "compare", - op: "gte", - left: coalesce(ref("ratio"), { kind: "numberLiteral", value: BETWEEN }), - right: { kind: "numberLiteral", value: BETWEEN }, - }, - ], - [ - "conditional", - { - kind: "compare", - op: "eq", - left: { - kind: "conditional", - cases: [ - { when: isMode("fast"), then: { kind: "numberLiteral", value: 1 } }, - ], - fallback: { kind: "numberLiteral", value: 0 }, - }, - right: { kind: "numberLiteral", value: 1 }, - }, - ], -]; - -/** A complete assignment's value for a key, written out independently of the adapter's own mapping: text, number or boolean when set, known absent when unset. */ -async function resolutionOf( - assignment: PartialAssignment, - key: JsonValue, -): Promise { - const value = typeof key === "string" ? assignment.get(key) : undefined; - if (typeof value === "string") { - return Promise.resolve({ found: true, value: { kind: "text", value } }); - } - if (typeof value === "number") { - return Promise.resolve({ found: true, value: { kind: "number", value } }); - } - if (typeof value === "boolean") { - return Promise.resolve({ found: true, value: { kind: "boolean", value } }); - } - - return Promise.resolve({ found: false }); -} - -/** Every partial assignment of the model: each dimension unassigned or assigned one of its values. */ -function partialAssignments(): Map[] { - let assignments: Map[] = [new Map()]; - for (const name of NAMES) { - assignments = assignments.flatMap((assignment) => [ - assignment, - ...DOMAINS[name].map( - (value): Map => new Map([...assignment, [name, value]]), - ), - ]); - } - - return assignments; -} - -/** Every complete assignment agreeing with the partial one. */ -function completions(partial: PartialAssignment): Map[] { - return partialAssignments().filter( - (assignment) => - assignment.size === NAMES.length && - [...partial].every(([name, value]) => assignment.get(name) === value), - ); -} - -describe("trileanConstraint: Kleene soundness", () => { - it.each(TREES)( - "%s: a definite verdict on a partial assignment is the verdict on every completion", - (_, tree) => { - const constraint = trileanConstraint(tree, { dimensions }); - for (const partial of partialAssignments()) { - const verdict = constraint.evaluate(partial); - const complete = completions(partial).map((assignment) => - constraint.evaluate(assignment), - ); - expect(complete).not.toContain("unknown"); - if (verdict !== "unknown") { - expect(new Set(complete), JSON.stringify([...partial])).toEqual( - new Set([verdict]), - ); - } - } - }, - ); - - it("the soundness check discriminates: a constraint that reads unassigned as unset breaks it", () => { - const constraint = trileanConstraint(exists("mode"), { dimensions }); - const unsound = (assignment: PartialAssignment) => - constraint.evaluate( - new Map([ - ...NAMES.map((name) => [name, undefined] as const), - ...assignment, - ]), - ); - const partial = new Map(); - expect(unsound(partial)).toBe("invalid"); - expect(completions(partial).map(unsound)).toContain("valid"); - }); - - it.each(TREES)( - "%s: agrees with trilean's own evaluator on every complete assignment", - async (_, tree) => { - const constraint = trileanConstraint(tree, { dimensions }); - const complete = partialAssignments().filter( - (each) => each.size === NAMES.length, - ); - const evaluations = await Promise.all( - complete.map(async (assignment) => - evaluatePredicate(tree, undefined, { - resolveValue: async (key) => resolutionOf(assignment, key), - resolveLookup: async () => Promise.resolve({ found: false }), - resolveCollection: async () => Promise.resolve([]), - }), - ), - ); - complete.forEach((assignment, index) => { - const evaluation = evaluations[index]; - expect(evaluation?.status).toBe("definite"); - const expected = - evaluation?.status === "definite" && evaluation.value - ? "valid" - : "invalid"; - expect(constraint.evaluate(assignment)).toBe(expected); - }); - }, - ); -}); diff --git a/packages/trilean-combinatorics/src/constraint.ts b/packages/trilean-combinatorics/src/constraint.ts deleted file mode 100644 index f756908..0000000 --- a/packages/trilean-combinatorics/src/constraint.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { - Constraint, - Dimension, - PartialAssignment, - Scalar, - Verdict, -} from "combinatrix"; -import type { - ComputedValue, - ExpressionNode, - FunctionRegistry, - PredicateNode, - ReferenceNode, - Resolution, - SyncResolvers, -} from "trilean"; -import { - DEFAULT_MAX_EVALUATION_NODES, - DEFAULT_MAX_NESTING_DEPTH, - PredicateNodeSchema, - createSyncEvaluator, - emptyFunctionRegistry, -} from "trilean"; -import { - EvaluationBudgetError, - PredicateTreeError, - UndecidedConstraintError, - UnmappableValueError, -} from "./errors"; - -/** Options for `trileanConstraint`. */ -export interface TrileanConstraintOptions { - /** The dimensions of the model the constraint will be given to. Every reference in the tree must name one of them. */ - readonly dimensions: readonly Dimension[]; - /** A label for diagnostics, such as combinatrix's `explain` report and this package's errors. */ - readonly name?: string; - /** The functions a `call` node may name, bound into the evaluator as trilean's `createSyncEvaluator` binds them. Defaults to trilean's empty registry, so a tree with a `call` node needs one. Every name the tree calls must be one of the registry's own keys. */ - readonly functions?: FunctionRegistry; -} - -/** One node still to visit in the walk over a tree, with where it is and how deep. */ -interface PendingNode { - readonly node: PredicateNode | ExpressionNode; - readonly path: string; - readonly depth: number; -} - -/** What a walk over a tree finds: its defects, the dimensions its references name in the order first met, its node count and its deepest node's depth. */ -interface TreeSurvey { - readonly issues: string[]; - readonly references: string[]; - nodes: number; - deepest: number; -} - -/** The node kinds the adapter refuses: each needs a resolver an assignment of dimensions cannot back. */ -type RefusedKind = - | "some" - | "every" - | "fold" - | "accumulator" - | "lookup" - | "delegate" - | "treeReference"; - -/** Why each refused node kind cannot be evaluated over an assignment of dimensions. */ -const REFUSED_KINDS: Readonly> = { - some: "it quantifies over a collection, and an assignment of dimensions resolves no collections", - every: - "it quantifies over a collection, and an assignment of dimensions resolves no collections", - fold: "it folds over a collection, and an assignment of dimensions resolves no collections", - accumulator: "it is meaningful only inside a fold, which is refused", - lookup: - "it reads a table, and an assignment of dimensions resolves no tables", - delegate: - "it defers to an external system, and the adapter supplies no delegate resolver", - treeReference: - "it resolves another tree by key, and the adapter supplies no tree resolver", -}; - -/** - * Turns a trilean predicate tree, given as plain JSON, into a combinatrix `Constraint` over the given dimensions. - * - * The tree is validated with trilean's `PredicateNodeSchema` and then walked, and `PredicateTreeError` lists every defect: a node kind the adapter cannot resolve (`some`, `every`, `fold`, `accumulator`, `lookup`, `delegate`, `treeReference`), a `reference` whose key is not a string or names no dimension, a `reference` carrying a `unit` (dimension values carry none), a `call` to a function the registry does not hold, and a referenced choice or first-valid dimension that can take `null`, which has no trilean value. The kinds accepted are listed explicitly, so a node kind trilean adds later is refused until the adapter is taught it. `EvaluationBudgetError` is thrown when the tree's node count or depth exceeds trilean's default evaluation budget, which is checked here because an accepted tree visits each node at most once per evaluation. - * - * `dependencies` are the dimensions the tree's references name, in the order the tree first mentions them. `evaluate` runs trilean's synchronous evaluator with each reference resolved from the assignment: a dimension assigned a string, number or boolean resolves to that text, number or boolean; one assigned unset resolves as known absent (`{ found: false }`, so `exists` is definitely false); one not yet assigned resolves as not yet known (`{ found: false, notYetKnown: true }`, so `exists` and everything reading it stays indeterminate until it is assigned, which is what keeps a definite answer the answer on every completion). A definite true is `valid` and a definite false `invalid`. An indeterminate result is `unknown` while some dependency is unassigned, and throws `UndecidedConstraintError`, carrying trilean's reason, once every dependency is assigned, since the contract forbids `unknown` there. A computed dimension that produces `null` throws `UnmappableValueError` when it is read. - */ -export function trileanConstraint( - tree: unknown, - options: TrileanConstraintOptions, -): Constraint { - const parsed = PredicateNodeSchema.safeParse(tree); - if (!parsed.success) { - throw new PredicateTreeError( - parsed.error.issues.map( - (issue) => - `${pathText(issue.path)}: ${issue.message} (not a trilean predicate tree)`, - ), - ); - } - const node = parsed.data; - const functions = options.functions ?? emptyFunctionRegistry; - const byName = new Map( - options.dimensions.map((dimension) => [dimension.name, dimension]), - ); - const survey = surveyTree(node, byName, functions); - if (survey.issues.length > 0) throw new PredicateTreeError(survey.issues); - if (survey.nodes > DEFAULT_MAX_EVALUATION_NODES) { - throw new EvaluationBudgetError( - "nodes", - survey.nodes, - DEFAULT_MAX_EVALUATION_NODES, - ); - } - if (survey.deepest >= DEFAULT_MAX_NESTING_DEPTH) { - throw new EvaluationBudgetError( - "depth", - survey.deepest, - DEFAULT_MAX_NESTING_DEPTH, - ); - } - - const dependencies: readonly string[] = survey.references; - const label = - options.name === undefined - ? "trilean constraint" - : `trilean constraint "${options.name}"`; - const evaluator = createSyncEvaluator({ functions }); - const evaluate = (assignment: PartialAssignment): Verdict => { - const result = evaluator.evaluatePredicate( - node, - undefined, - resolversFor(assignment), - ); - if (result.status === "definite") return result.value ? "valid" : "invalid"; - if (dependencies.every((dependency) => assignment.has(dependency))) { - throw new UndecidedConstraintError(label, result.reason); - } - - return "unknown"; - }; - - return options.name === undefined - ? { dependencies, evaluate } - : { dependencies, evaluate, name: options.name }; -} - -/** Walks the tree from the root, depth first, without recursion, so the depth check reports a deep tree rather than overflowing the walk's own stack. */ -function surveyTree( - root: PredicateNode, - byName: ReadonlyMap, - functions: Readonly, -): TreeSurvey { - const survey: TreeSurvey = { - issues: [], - references: [], - nodes: 0, - deepest: 0, - }; - const pending: PendingNode[] = [{ node: root, path: "$", depth: 0 }]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - survey.nodes += 1; - survey.deepest = Math.max(survey.deepest, next.depth); - const children = visit(next, survey, byName, functions); - // Pushed in reverse, so they are popped, and their references first met, in the order the tree lists them. - for (const [suffix, child] of children.toReversed()) { - pending.push({ - node: child, - path: `${next.path}${suffix}`, - depth: next.depth + 1, - }); - } - } - - return survey; -} - -/** Records the node's own defects and references in the survey, and returns its children with the path suffix that reaches each. */ -function visit( - { node, path }: PendingNode, - survey: TreeSurvey, - byName: ReadonlyMap, - functions: Readonly, -): (readonly [string, PredicateNode | ExpressionNode])[] { - switch (node.kind) { - case "not": - case "exists": - case "negate": - return [[".operand", node.operand]]; - case "and": - case "or": - case "compare": - case "textCompare": - case "arithmetic": - return [ - [".left", node.left], - [".right", node.right], - ]; - case "allOf": - case "anyOf": - return node.operands.map( - (operand, index) => [`.operands[${String(index)}]`, operand] as const, - ); - case "memberOf": - return [ - [".operand", node.operand], - ...node.candidates.map( - (candidate, index) => - [`.candidates[${String(index)}]`, candidate] as const, - ), - ]; - case "call": - if (!Object.hasOwn(functions, node.fn)) { - survey.issues.push( - `${path}: calls "${node.fn}", which the function registry does not hold`, - ); - } - - return node.args.map( - (argument, index) => [`.args[${String(index)}]`, argument] as const, - ); - case "conditional": - return [ - ...node.cases.flatMap( - ({ when, then }, index) => - [ - [`.cases[${String(index)}].when`, when], - [`.cases[${String(index)}].then`, then], - ] as const, - ), - [".fallback", node.fallback], - ]; - case "reference": - checkReference(node, path, survey, byName); - - return []; - case "numberLiteral": - case "textLiteral": - case "booleanLiteral": - case "instantLiteral": - case "durationLiteral": - case "complexLiteral": - return []; - case "some": - case "every": - case "fold": - case "accumulator": - case "lookup": - case "delegate": - case "treeReference": - survey.issues.push( - `${path}: "${node.kind}" is not supported, because ${REFUSED_KINDS[node.kind]}`, - ); - - return []; - default: - return unreachableKind(node); - } -} - -/** Records a reference's defects, or the dimension it names when it has none. */ -function checkReference( - { key, unit }: ReferenceNode, - path: string, - survey: TreeSurvey, - byName: ReadonlyMap, -): void { - if (typeof key !== "string") { - survey.issues.push( - `${path}: the reference key ${JSON.stringify(key)} is not a string naming a dimension`, - ); - - return; - } - const dimension = byName.get(key); - if (dimension === undefined) { - survey.issues.push( - `${path}: the reference key "${key}" names no dimension of the model`, - ); - - return; - } - if (unit !== undefined) { - survey.issues.push( - `${path}: the reference to "${key}" carries a unit, and dimension values carry none, so it could never match`, - ); - } - if (survey.references.includes(key)) return; - - survey.references.push(key); - if (declaredValues(dimension).includes(null)) { - survey.issues.push( - `${path}: dimension "${key}" can take null, which has no trilean value`, - ); - } -} - -/** The values a dimension declares: a choice's domain or a first-valid dimension's candidates. A computed dimension declares none, so its values are checked as they are read. */ -function declaredValues(dimension: Dimension): readonly unknown[] { - if (dimension.kind === "choice") return dimension.values; - if (dimension.kind === "first-valid") return dimension.candidates; - - return []; -} - -/** Resolvers reading each reference from the assignment. Only `resolveValue` is reachable: the tree was checked to hold no node kind that calls the others. */ -function resolversFor(assignment: PartialAssignment): SyncResolvers { - return { - resolveValue: (key): Resolution => { - if (typeof key !== "string") { - throw new Error( - `unreachable: every reference key was checked to be a string, not ${JSON.stringify(key)}`, - ); - } - if (!assignment.has(key)) return { found: false, notYetKnown: true }; - - const value = assignment.get(key); - if (value === undefined) return { found: false }; - - return { found: true, value: computedValueOf(key, value) }; - }, - resolveLookup: () => { - throw new Error( - "unreachable: a tree with a lookup node is refused when the constraint is defined", - ); - }, - resolveCollection: () => { - throw new Error( - "unreachable: a tree with a collection node is refused when the constraint is defined", - ); - }, - }; -} - -/** A dimension's set value as trilean's value of the same kind: a string is text, a finite number is a number with no unit, a boolean is a boolean. `null` has no counterpart and throws. */ -function computedValueOf(dimension: string, value: Scalar): ComputedValue { - if (typeof value === "string") return { kind: "text", value }; - if (typeof value === "number") return { kind: "number", value }; - if (typeof value === "boolean") return { kind: "boolean", value }; - - throw new UnmappableValueError(dimension, value); -} - -/** A Zod issue path as the walk's path notation: `$`, then `.name` per key and `[n]` per index. */ -function pathText(path: readonly PropertyKey[]): string { - return path.reduce( - (text, segment) => - typeof segment === "number" - ? `${text}[${String(segment)}]` - : `${text}.${String(segment)}`, - "$", - ); -} - -/** Fails the build when trilean adds a node kind the walk does not list, and the walk at run time if a tree reaches it some other way. */ -function unreachableKind(node: never): never { - throw new Error( - `unreachable: node kind ${JSON.stringify(node)} is not one the walk knows`, - ); -} diff --git a/packages/trilean-combinatorics/src/constraint.unit.test.ts b/packages/trilean-combinatorics/src/constraint.unit.test.ts deleted file mode 100644 index ae7a1d1..0000000 --- a/packages/trilean-combinatorics/src/constraint.unit.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type { Dimension, PartialAssignment, Value } from "combinatrix"; -import { - ConstraintContractError, - choice, - collect, - computed, - defineModel, -} from "combinatrix"; -import type { ExpressionNode, PredicateNode } from "trilean"; -import { - DEFAULT_MAX_EVALUATION_NODES, - DEFAULT_MAX_NESTING_DEPTH, -} from "trilean"; -import { describe, expect, it } from "vitest"; -import { - EvaluationBudgetError, - PredicateTreeError, - UndecidedConstraintError, - UnmappableValueError, - trileanConstraint, -} from "./index"; - -/** The ratio values the tests use, and one strictly between them. */ -const LOW = 0.3; -const HIGH = 0.5; -const BETWEEN = 0.4; - -const ref = (key: string): ExpressionNode => ({ kind: "reference", key }); -const text = (value: string): ExpressionNode => ({ - kind: "textLiteral", - value, -}); -const number = (value: number): ExpressionNode => ({ - kind: "numberLiteral", - value, -}); -const exists = (key: string): PredicateNode => ({ - kind: "exists", - operand: ref(key), -}); -const and = (left: PredicateNode, right: PredicateNode): PredicateNode => ({ - kind: "and", - left, - right, -}); - -const dimensions: readonly Dimension[] = [ - choice("mode", [undefined, "fast", "safe"]), - choice("ratio", [undefined, LOW, HIGH]), - choice("cache", [true, false]), -]; - -/** An assignment from an object: a key present is assigned (to unset when its value is `undefined`), a key absent is unassigned. */ -const assigned = (values: Readonly>): PartialAssignment => - new Map(Object.entries(values)); - -describe("trileanConstraint: values and absence", () => { - const fast = trileanConstraint( - { - kind: "textCompare", - op: "equals", - left: ref("mode"), - right: text("fast"), - }, - { dimensions }, - ); - - it("maps a string to text, a number to a number and a boolean to a boolean", () => { - expect(fast.evaluate(assigned({ mode: "fast" }))).toBe("valid"); - expect(fast.evaluate(assigned({ mode: "safe" }))).toBe("invalid"); - const ratio = trileanConstraint( - { kind: "compare", op: "gt", left: ref("ratio"), right: number(BETWEEN) }, - { dimensions }, - ); - expect(ratio.evaluate(assigned({ ratio: HIGH }))).toBe("valid"); - expect(ratio.evaluate(assigned({ ratio: LOW }))).toBe("invalid"); - const cache = trileanConstraint( - { - kind: "memberOf", - op: "in", - operand: ref("cache"), - candidates: [{ kind: "booleanLiteral", value: true }], - }, - { dimensions }, - ); - expect(cache.evaluate(assigned({ cache: true }))).toBe("valid"); - expect(cache.evaluate(assigned({ cache: false }))).toBe("invalid"); - }); - - it("reads an unset dimension as known absent, so exists is definitely false", () => { - const set = trileanConstraint(exists("mode"), { dimensions }); - expect(set.evaluate(assigned({ mode: undefined }))).toBe("invalid"); - expect(set.evaluate(assigned({ mode: "safe" }))).toBe("valid"); - }); - - it("reads an unassigned dimension as not yet known, so exists is unknown", () => { - const set = trileanConstraint(exists("mode"), { dimensions }); - expect(set.evaluate(assigned({}))).toBe("unknown"); - expect( - trileanConstraint( - { kind: "not", operand: exists("mode") }, - { dimensions }, - ).evaluate(assigned({ ratio: LOW })), - ).toBe("unknown"); - }); - - it("answers definitely before every dependency is assigned when Kleene absorption decides", () => { - const either: PredicateNode = { - kind: "or", - left: exists("mode"), - right: exists("ratio"), - }; - const constraint = trileanConstraint(either, { dimensions }); - expect(constraint.evaluate(assigned({ mode: "fast" }))).toBe("valid"); - expect(constraint.evaluate(assigned({ mode: undefined }))).toBe("unknown"); - }); - - it("throws UndecidedConstraintError for an unguarded comparison on an unset dimension once everything is assigned", () => { - expect(fast.evaluate(assigned({}))).toBe("unknown"); - expect(() => fast.evaluate(assigned({ mode: undefined }))).toThrow( - UndecidedConstraintError, - ); - try { - fast.evaluate(assigned({ mode: undefined })); - } catch (error) { - expect(error).toBeInstanceOf(ConstraintContractError); - expect(error).toMatchObject({ reason: { code: "not-found" } }); - } - }); - - it("is definitely false on an unset dimension when the comparison is guarded with exists", () => { - const guarded = trileanConstraint( - and(exists("mode"), { - kind: "textCompare", - op: "equals", - left: ref("mode"), - right: text("fast"), - }), - { dimensions }, - ); - expect(guarded.evaluate(assigned({ mode: undefined }))).toBe("invalid"); - expect(guarded.evaluate(assigned({ mode: "fast" }))).toBe("valid"); - expect(guarded.evaluate(assigned({}))).toBe("unknown"); - }); - - it("throws UndecidedConstraintError for a comparison of the wrong kinds once everything is assigned", () => { - const wrong = trileanConstraint( - { kind: "compare", op: "gt", left: ref("mode"), right: number(1) }, - { dimensions }, - ); - expect(() => wrong.evaluate(assigned({ mode: "fast" }))).toThrow( - /wrong-type/u, - ); - }); - - it("names the dimensions the tree references, once each, in the order first met", () => { - const constraint = trileanConstraint( - { - kind: "allOf", - operands: [exists("ratio"), exists("mode"), exists("ratio")], - }, - { dimensions, name: "both" }, - ); - expect(constraint.dependencies).toEqual(["ratio", "mode"]); - expect(constraint.name).toBe("both"); - }); - - it("evaluates call nodes through the given registry", () => { - const constraint = trileanConstraint( - { - kind: "compare", - op: "eq", - left: { kind: "call", fn: "double", args: [ref("ratio")] }, - right: number(1), - }, - { - dimensions, - functions: { - double: ([argument]) => - argument?.kind === "number" - ? { kind: "number", value: argument.value * 2 } - : { wrongType: "double takes a number" }, - }, - }, - ); - expect(constraint.evaluate(assigned({ ratio: HIGH }))).toBe("valid"); - expect(constraint.evaluate(assigned({ ratio: LOW }))).toBe("invalid"); - }); - - it("throws UnmappableValueError when a computed dimension it reads produces null", () => { - const withComputed: readonly Dimension[] = [ - ...dimensions, - computed("label", { - reads: ["mode"], - compute: (values) => (values.mode === undefined ? null : "named"), - }), - ]; - const constraint = trileanConstraint(exists("label"), { - dimensions: withComputed, - }); - expect(constraint.evaluate(assigned({ label: "named" }))).toBe("valid"); - expect(() => constraint.evaluate(assigned({ label: null }))).toThrow( - UnmappableValueError, - ); - }); -}); - -describe("trileanConstraint: definition-time checks", () => { - const issuesOf = (tree: unknown, functions = {}): readonly string[] => { - try { - trileanConstraint(tree, { dimensions, functions }); - } catch (error) { - if (error instanceof PredicateTreeError) return error.issues; - throw error; - } - - throw new Error("expected a PredicateTreeError"); - }; - - it("refuses JSON that is not a predicate tree", () => { - expect(issuesOf({ kind: "maybe" })).toHaveLength(1); - expect(issuesOf({ kind: "maybe" })[0]).toMatch(/^\$/u); - }); - - it.each([ - ["some", { kind: "some", collection: "items", item: exists("mode") }], - ["every", { kind: "every", collection: "items", item: exists("mode") }], - ["treeReference", { kind: "treeReference", key: "other" }], - [ - "lookup", - { kind: "exists", operand: { kind: "lookup", table: "t", keys: [] } }, - ], - [ - "delegate", - { - kind: "exists", - operand: { kind: "delegate", system: "s", payload: null }, - }, - ], - [ - "fold", - { - kind: "exists", - operand: { - kind: "fold", - collection: "items", - combiner: { mode: "max", item: { kind: "accumulator" } }, - }, - }, - ], - ["accumulator", { kind: "exists", operand: { kind: "accumulator" } }], - ])("refuses a %s node", (kind, tree) => { - expect(issuesOf(tree)).toEqual([ - expect.stringContaining(`"${kind}" is not supported`), - ]); - }); - - it("refuses a reference key that is not a string, one naming no dimension, and one with a unit, listing each", () => { - const issues = issuesOf({ - kind: "allOf", - operands: [ - { kind: "exists", operand: { kind: "reference", key: ["mode"] } }, - exists("missing"), - { - kind: "exists", - operand: { kind: "reference", key: "ratio", unit: { s: 1 } }, - }, - ], - }); - expect(issues).toEqual([ - expect.stringMatching(/^\$\.operands\[0\]\.operand: .*not a string/u), - expect.stringMatching( - /^\$\.operands\[1\]\.operand: .*"missing" names no dimension/u, - ), - expect.stringMatching(/^\$\.operands\[2\]\.operand: .*carries a unit/u), - ]); - }); - - it("refuses a call to a function the registry does not hold, inherited names included", () => { - expect( - issuesOf({ - kind: "exists", - operand: { kind: "call", fn: "toString", args: [] }, - }), - ).toEqual([expect.stringContaining('calls "toString"')]); - }); - - it("refuses a referenced dimension that can take null", () => { - expect(() => - trileanConstraint(exists("nullable"), { - dimensions: [choice("nullable", [null, "x"])], - }), - ).toThrow(/can take null/u); - }); - - it("accepts a tree at the evaluator's node budget and refuses one node more", () => { - // allOf (1 node) over exists(literal) operands (2 nodes each), with one exists(negate(literal)) (3 nodes) making the total exactly the budget, which is even. - const allOfNodes = 1; - const negatedOperandNodes = 3; - const twoNodeOperands = - (DEFAULT_MAX_EVALUATION_NODES - allOfNodes - negatedOperandNodes) / 2; - const atBudget: PredicateNode = { - kind: "allOf", - operands: [ - ...Array.from({ length: twoNodeOperands }, (): PredicateNode => ({ - kind: "exists", - operand: number(1), - })), - { kind: "exists", operand: { kind: "negate", operand: number(1) } }, - ], - }; - expect( - trileanConstraint(atBudget, { dimensions }).evaluate(new Map()), - ).toBe("valid"); - const overBudget: PredicateNode = { - kind: "not", - operand: atBudget, - }; - expect(() => trileanConstraint(overBudget, { dimensions })).toThrow( - EvaluationBudgetError, - ); - }); - - it("accepts a tree nested to just below the evaluator's depth budget and refuses one level deeper", () => { - const chain = (depth: number): PredicateNode => { - // exists(literal) puts the literal one level below the exists, so `depth - 1` nots above it make the literal's depth `depth`. - let tree: PredicateNode = { kind: "exists", operand: number(1) }; - for (let level = 0; level < depth - 1; level += 1) { - tree = { kind: "not", operand: tree }; - } - - return tree; - }; - const deepest = DEFAULT_MAX_NESTING_DEPTH - 1; - const accepted = trileanConstraint(chain(deepest), { dimensions }); - expect(["valid", "invalid"]).toContain(accepted.evaluate(new Map())); - expect(() => trileanConstraint(chain(deepest + 1), { dimensions })).toThrow( - EvaluationBudgetError, - ); - }); -}); - -describe("trileanConstraint in a model", () => { - it("restricts the solution set exactly as filtering the product with the tree would", () => { - const tree: PredicateNode = { - kind: "or", - left: { kind: "not", operand: exists("mode") }, - right: and(exists("ratio"), { - kind: "compare", - op: "gte", - left: ref("ratio"), - right: number(HIGH), - }), - }; - const model = defineModel({ - dimensions, - constraints: [trileanConstraint(tree, { dimensions })], - }); - const rows = collect(model, { limit: 100 }).map(({ values }) => values); - const expected = [undefined, "fast", "safe"].flatMap((mode) => - [undefined, LOW, HIGH].flatMap((ratio) => - [true, false] - .map((cache) => ({ mode, ratio, cache })) - .filter(() => mode === undefined || ratio === HIGH), - ), - ); - expect(rows).toEqual(expected); - }); -}); diff --git a/packages/trilean-combinatorics/src/covering.ts b/packages/trilean-combinatorics/src/covering.ts deleted file mode 100644 index 8f9991c..0000000 --- a/packages/trilean-combinatorics/src/covering.ts +++ /dev/null @@ -1,384 +0,0 @@ -import type { - Dimension, - PartialAssignment, - Strategy, - StrategyContext, - Value, -} from "combinatrix"; -import { LimitExceededError } from "combinatrix"; -import { productOf, subsetsOf, tupleKey } from "./internal/tuples"; - -/** Options for `coveringArray`. */ -export interface CoveringArrayOptions { - /** The strength t, a positive integer: every t-way combination of values that occurs in the solution set is covered. 1 covers each value, 2 is pairwise, 3 three-wise. A strength above the model's dimension count covers every dimension at once, which is the whole solution set. */ - readonly strength: number; - /** The most rows the construction may hold, a non-negative integer. Every row is held until the last dimension has been placed, so this bounds the construction's memory in rows; past it the strategy throws combinatrix's `LimitExceededError` instead of building a larger array. Without it the array is as large as the construction makes it, which is at most the number of coverable t-tuples. */ - readonly limit?: number; -} - -/** - * A combinatrix strategy yielding a covering array of the model's solution set: valid combinations such that every t-way combination of dimension values that occurs in at least one valid combination occurs in at least one yielded row. Coverage is defined against the solution set only, so a t-tuple no valid combination contains is not required, and an empty solution set yields nothing. - * - * Every dimension of the model is covered, its values being whatever it takes in the solution set: - * - A free dimension's values, unset included when its domain lists `undefined`. - * - A derived dimension's values (first-valid and computed) too. A derived value is part of every combination and can decide behaviour as much as a free one, and because coverage is measured on the solution set, a derived value is required only where it really occurs, so including derived dimensions never asks for a combination the model cannot produce. - * - A dimension collapsed to unset by `activeWhen` is unset in those combinations, and unset counts as a value like any other there. The combination does not record why a dimension is unset, so unset by choice and unset by inactivity are one value, and covering it with the other dimensions' values is covering the dimension's absence alongside them. - * - * The construction is in-parameter-order, IPOG with its constraint handling done by the engine: Lei, Kacker, Kuhn, Okun and Lawrence, "IPOG: A General Strategy for T-Way Software Testing", ECBS 2007 (https://csrc.nist.gov/pubs/conference/2007/03/29/ipog-a-general-strategy-for-tway-software-testing/final), with constraints as in Yu, Lei, Nourozborazjany, Kacker and Kuhn, "An Efficient Algorithm for Constraint Handling in Combinatorial Test Generation", ICST 2013 (https://www.nist.gov/node/572651). Rows are partial assignments over the dimensions in declaration order. The first t dimensions start one row per coverable t-tuple of theirs. Each later dimension is then placed in two phases: horizontal growth gives every row the value covering the most of that dimension's uncovered t-tuples, and vertical growth puts each tuple still uncovered into the first row it fits, or into a new row whose other dimensions are left open. Each row is then completed to a valid combination by the engine, and last, visiting the rows from the last, a row whose every t-tuple some other row still kept also holds is dropped, which removes repeats and rows later placements made redundant without uncovering anything. - * - * It never reads the solution set. Which t-tuples are coverable, and whether a row may take a value, are both asked of the engine as "is there a valid combination agreeing with this partial assignment", the first combination `completions` yields for it, so the constraints' verdicts on partial assignments prune the search exactly as they do for enumeration, and the strategy stops at the first combination found. Every row is kept able to complete, so every yielded row is valid. Its memory is therefore the rows, the current dimension's tuples and, at the end, one count per distinct t-tuple of the rows: at most one row per coverable t-tuple, since every row is started for a tuple no earlier row holds, each over the model's dimensions, and at most the coverable t-tuples themselves. That is polynomial in the dimension count for a fixed strength and independent of the size of the solution set. The engine queries are polynomial in the t-tuple count too, each closing its search at the first combination: one per candidate tuple, at most one per row and value in horizontal growth and one more for a row none of them fits, at most one per row for each tuple vertical growth places, and one per row to complete it, besides a computed dimension's. A computed dimension's possible values are found from the values of the dimensions it reads, by one engine query per choice of those values, so they are its value set and never a list of combinations. - * - * The output is deterministic: the same model gives the same rows in the same order, ties going to the earlier value and the earlier row. The strategy is lazy in that nothing is computed until the first row is asked for, but the whole array is built before the first row is yielded, since a later dimension may still change any row and the redundancy pass needs every row. The array is not always smaller than the greedy construction over the whole solution set, which is not size-optimal either: on some models it is larger, on others smaller. Throws `RangeError` for a strength that is not a positive integer or a limit that is not a non-negative integer, when the strategy is made. - */ -export function coveringArray(options: CoveringArrayOptions): Strategy { - const { strength, limit } = options; - if (!Number.isSafeInteger(strength) || strength < 1) { - throw new RangeError( - `strength must be a positive integer, not ${String(strength)}`, - ); - } - if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) { - throw new RangeError( - `limit must be a non-negative integer, not ${String(limit)}`, - ); - } - - return { - name: `covering array of strength ${String(strength)}`, - select: (context) => inParameterOrderRows(context, strength, limit), - }; -} - -/** A row under construction: the dimensions placed in it so far, an absent dimension being open and one present with `undefined` being unset. */ -type Row = Map; - -/** The construction `coveringArray` describes. */ -function* inParameterOrderRows( - context: StrategyContext, - strength: number, - limit: number | undefined, -): Generator>> { - const { dimensions } = context; - const width = Math.min(strength, dimensions.length); - const names = dimensions.map((dimension) => dimension.name); - const values: (readonly Value[])[] = []; - const rows: Row[] = []; - const addRow = (row: Row): void => { - if (rows.length === limit) throw new LimitExceededError(limit); - - rows.push(row); - }; - - for (let index = 0; index < width; index += 1) { - values.push(possibleValues(context, index, values)); - } - const firstNames = names.slice(0, width); - for (const choice of productOf(values)) { - const row: Row = new Map( - firstNames.map((name, index) => [name, choice[index]]), - ); - if (completes(context, row)) addRow(row); - } - - for (let index = width; index < dimensions.length; index += 1) { - values.push(possibleValues(context, index, values)); - const placement: Placement = { context, names, values, index, width }; - const uncovered = coverableTuples(placement); - growHorizontally(placement, rows, uncovered); - growVertically(context, rows, uncovered, addRow); - } - - yield* withoutRedundantRows( - rows.map((row) => { - const completed = firstCompletion(context, row); - if (completed === undefined) { - throw new Error( - "unreachable: a row is only ever given values the engine found a valid combination for", - ); - } - - return completed; - }), - subsetsOf(names, width), - ); -} - -/** The complete rows less every row whose t-tuples all occur in rows kept, visiting the rows from the last, so a row that repeats an earlier one, or that later rows made redundant, is dropped and the rest keep their order. Coverage is unchanged, since a row goes only when every tuple it holds is held by another row still kept. Holds one count per distinct t-tuple of the rows. */ -function* withoutRedundantRows( - rows: readonly Readonly>[], - subsets: readonly (readonly string[])[], -): Generator>> { - const tuplesOf = (row: Readonly>): string[] => - subsets.map((subset) => tupleKey(subset.map((name) => [name, row[name]]))); - const holders = new Map(); - for (const row of rows) { - for (const tuple of tuplesOf(row)) { - holders.set(tuple, (holders.get(tuple) ?? 0) + 1); - } - } - const kept = rows.map(() => true); - for (let index = rows.length - 1; index >= 0; index -= 1) { - const tuples = tuplesOf(rowAt(rows, index)); - if (tuples.some((tuple) => holders.get(tuple) === 1)) continue; - - kept[index] = false; - for (const tuple of tuples) { - holders.set(tuple, (holders.get(tuple) ?? 0) - 1); - } - } - for (const [index, row] of rows.entries()) { - if (kept[index] === true) yield row; - } -} - -function rowAt(rows: readonly T[], index: number): T { - const row = rows[index]; - if (row === undefined) { - throw new Error( - `unreachable: position ${String(index)} is within the ${String(rows.length)} rows`, - ); - } - - return row; -} - -/** - * Every value the dimension at `index` can take in the solution set, possibly with some it cannot, in a fixed order: a free dimension's domain, a first-valid dimension's candidates, or a computed dimension's results on every choice of the values of the dimensions it reads that some valid combination agrees with; unset is added when `activeWhen` can collapse the dimension. Values the solution set never has are harmless, since a tuple holding one is never coverable. `known` holds the possible values of every dimension before `index`. - */ -function possibleValues( - context: StrategyContext, - index: number, - known: readonly (readonly Value[])[], -): Value[] { - const dimension = dimensionAt(context.dimensions, index); - const found = new Map(); - const add = (value: Value): void => { - found.set(tupleKey([[dimension.name, value]]), value); - }; - if (dimension.kind === "choice") dimension.values.forEach(add); - if (dimension.kind === "first-valid") dimension.candidates.forEach(add); - if (dimension.kind === "computed") { - const readLists = dimension.reads.map((read) => { - const list = - known[context.dimensions.findIndex(({ name }) => name === read)]; - if (list === undefined) { - throw new Error( - `unreachable: computed dimension "${dimension.name}" reads "${read}", which combinatrix requires to be declared before it`, - ); - } - - return list; - }); - for (const choice of productOf(readLists)) { - const reads = new Map( - dimension.reads.map((read, position) => [read, choice[position]]), - ); - // Only a choice of the read values some valid combination has is computed, so `compute` is called only on inputs the model can produce. - if (completes(context, reads)) { - add(dimension.compute(Object.fromEntries(reads))); - } - } - } - if (dimension.activeWhen !== undefined) add(undefined); - - return [...found.values()]; -} - -/** What placing one dimension after the first t reads: the engine, every dimension's name, the possible values of this dimension and every one before it, this dimension's position, and the tuple width t. */ -interface Placement { - readonly context: StrategyContext; - readonly names: readonly string[]; - readonly values: readonly (readonly Value[])[]; - readonly index: number; - readonly width: number; -} - -/** Every t-tuple holding the dimension at `index` and `width - 1` dimensions before it that some valid combination contains, keyed by `tupleKey` in dimension order, in the order of the earlier dimensions' subsets and then of their values. */ -function coverableTuples(placement: Placement): Map { - const { context, names, values, index, width } = placement; - const coverable = new Map(); - const earlier = Array.from({ length: index }, (_, position) => position); - for (const subset of subsetsOf(earlier, width - 1)) { - const positions = [...subset, index]; - const lists = positions.map((position) => valuesAt(values, position)); - for (const choice of productOf(lists)) { - const pairs = positions.map((position, place): [string, Value] => [ - nameAt(names, position), - choice[place], - ]); - const tuple: Row = new Map(pairs); - if (completes(context, tuple)) coverable.set(tupleKey(pairs), tuple); - } - } - - return coverable; -} - -/** Gives every row a value for the dimension at `index`: of the values that leave the row able to complete, the one covering the most uncovered tuples, the earlier value on a tie. Removes the tuples it covers from `uncovered`. Every row can complete before, so some value always can after. */ -function growHorizontally( - placement: Placement, - rows: readonly Row[], - uncovered: Map, -): void { - const { context, names, index, width } = placement; - const name = nameAt(names, index); - // The values the uncovered tuples hold for this dimension, once each, in the tuples' order. - const distinct = new Map(); - for (const tuple of uncovered.values()) { - const value = tuple.get(name); - const key = tupleKey([[name, value]]); - if (!distinct.has(key)) distinct.set(key, value); - } - const values = [...distinct.values()]; - for (const row of rows) { - const placed = names - .slice(0, index) - .filter((earlierName) => row.has(earlierName)); - const subsets = subsetsOf(placed, width - 1); - let best: { value: Value; covered: string[] } | undefined; - for (const value of candidatesFor(context, row, name, values)) { - const covered = subsets - .map((subset) => - tupleKey([ - ...subset.map((earlierName): [string, Value] => [ - earlierName, - row.get(earlierName), - ]), - [name, value], - ]), - ) - .filter((key) => uncovered.has(key)); - // Only a strictly larger gain replaces the best, so a tie goes to the earlier value. - if (best === undefined || covered.length > best.covered.length) { - best = { value, covered }; - } - } - if (best === undefined) { - throw new Error( - `unreachable: a row that can complete can take some value of "${name}"`, - ); - } - row.set(name, best.value); - for (const key of best.covered) uncovered.delete(key); - } -} - -/** The values a row may take for a dimension while staying able to complete, lazily: the values of the dimension's coverable tuples first, in their order, and only if none of them fits, the value of the row's own first completion, which always fits. */ -function* candidatesFor( - context: StrategyContext, - row: Row, - name: string, - coveringValues: readonly Value[], -): Generator { - let any = false; - for (const value of coveringValues) { - if (completes(context, new Map([...row, [name, value]]))) { - any = true; - yield value; - } - } - if (any) return; - - const completed = firstCompletion(context, row); - if (completed !== undefined) yield completed[name]; -} - -/** Places every tuple still uncovered after horizontal growth: into the first row agreeing with it wherever both are placed that can still complete with it, or else into a new row of its own. A tuple an earlier placement already covered is skipped. */ -function growVertically( - context: StrategyContext, - rows: readonly Row[], - uncovered: ReadonlyMap, - addRow: (row: Row) => void, -): void { - for (const tuple of uncovered.values()) { - if (rows.some((row) => covers(row, tuple))) continue; - - const host = rows.find((row) => { - if (!agrees(row, tuple)) return false; - - return completes(context, new Map([...row, ...tuple])); - }); - if (host === undefined) { - addRow(new Map(tuple)); - continue; - } - for (const [name, value] of tuple) host.set(name, value); - } -} - -/** Whether the row has every value of the tuple. */ -function covers(row: Row, tuple: Row): boolean { - return [...tuple].every( - ([name, value]) => row.has(name) && sameValue(row.get(name), value), - ); -} - -/** Whether the row and the tuple have the same value on every dimension both place. */ -function agrees(row: Row, tuple: Row): boolean { - return [...tuple].every( - ([name, value]) => !row.has(name) || sameValue(row.get(name), value), - ); -} - -function sameValue(left: Value, right: Value): boolean { - return tupleKey([["", left]]) === tupleKey([["", right]]); -} - -/** Whether some valid combination agrees with the assignment, asked of the engine, which stops at the first one. */ -function completes( - context: StrategyContext, - assignment: PartialAssignment, -): boolean { - return firstCompletion(context, assignment) !== undefined; -} - -/** The first valid combination, in enumeration order, agreeing with the assignment, or `undefined` when there is none. Only the first is searched for: the search is closed once it is found. */ -function firstCompletion( - context: StrategyContext, - assignment: PartialAssignment, -): Readonly> | undefined { - const search = context.completions(assignment); - const first = search.next(); - search.return(undefined); - - return first.done === true ? undefined : first.value; -} - -function dimensionAt( - dimensions: readonly Dimension[], - index: number, -): Dimension { - const dimension = dimensions[index]; - if (dimension === undefined) { - throw new Error( - `unreachable: position ${String(index)} is within the ${String(dimensions.length)} dimensions`, - ); - } - - return dimension; -} - -function nameAt(names: readonly string[], index: number): string { - const name = names[index]; - if (name === undefined) { - throw new Error( - `unreachable: position ${String(index)} is within the ${String(names.length)} names`, - ); - } - - return name; -} - -function valuesAt( - values: readonly (readonly Value[])[], - index: number, -): readonly Value[] { - const list = values[index]; - if (list === undefined) { - throw new Error( - `unreachable: the possible values of position ${String(index)} are found before any later position's`, - ); - } - - return list; -} diff --git a/packages/trilean-combinatorics/src/covering.unit.test.ts b/packages/trilean-combinatorics/src/covering.unit.test.ts deleted file mode 100644 index 8c4d9fe..0000000 --- a/packages/trilean-combinatorics/src/covering.unit.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import type { Model, Strategy, Value } from "combinatrix"; -import { - LimitExceededError, - StrategyError, - choice, - collect, - count, - defineModel, - firstValid, -} from "combinatrix"; -import { describe, expect, it } from "vitest"; -import { coveringArray, isCoveringArray } from "./index"; -import { subsetsOf, tupleKey } from "./internal/tuples"; -import { greedyCoveringArray } from "./test-support/greedy-reference"; -import { randomModel } from "./test-support/random-model"; - -/** How many generated models each property is checked against. */ -const MODELS = 300; -const SEED = 20_261_029; -/** Bounds every solution set the tests read; the generated models are far smaller. */ -const LIMIT = 10_000; -/** The strongest coverage the property tests check: three-wise. */ -const MAX_STRENGTH = 3; -/** Each value, pairwise and three-wise. */ -const STRENGTHS = Array.from({ length: MAX_STRENGTH }, (_, index) => index + 1); -/** The share of the generated models the slower whole-model checks run on. */ -const SAMPLE_DIVISOR = 10; -const SIZES = ["small", "medium", "large"] as const; - -type Row = Readonly>; - -function rowsOf(model: Model, strategy: Strategy): Row[] { - return collect(model, { strategy, limit: LIMIT }).map(({ values }) => values); -} - -function generated(): ReturnType[] { - return Array.from({ length: MODELS }, (_, index) => - randomModel(SEED + index), - ); -} - -describe("coveringArray", () => { - it("yields each value once over, earliest first, at strength 1", () => { - const model = defineModel({ - dimensions: [choice("size", SIZES), choice("shape", ["x", "y"])], - }); - expect(rowsOf(model, coveringArray({ strength: 1, limit: LIMIT }))).toEqual( - [ - { size: "small", shape: "x" }, - { size: "medium", shape: "y" }, - { size: "large", shape: "x" }, - ], - ); - }); - - it("yields every pair of an unconstrained two-dimensional model at strength 2", () => { - const model = defineModel({ - dimensions: [choice("a", [undefined, true]), choice("b", ["x", "y"])], - }); - expect(rowsOf(model, coveringArray({ strength: 2, limit: LIMIT }))).toEqual( - [ - { a: undefined, b: "x" }, - { a: undefined, b: "y" }, - { a: true, b: "x" }, - { a: true, b: "y" }, - ], - ); - }); - - it("covers pairs of a three-by-three-by-three model in fewer rows than the product", () => { - const values = [0, 1, 2]; - const model = defineModel({ - dimensions: [ - choice("a", values), - choice("b", values), - choice("c", values), - ], - }); - const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); - expect(isCoveringArray(rows, model, { strength: 2, limit: LIMIT })).toBe( - true, - ); - expect(rows.length).toBeLessThan(values.length ** model.dimensions.length); - }); - - it("does not require a tuple no valid combination contains", () => { - const model = defineModel({ - dimensions: [choice("a", [1, 2]), choice("b", ["x", "y"])], - rules: [{ excludes: { all: [{ is: ["a", 1] }, { is: ["b", "y"] }] } }], - }); - const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); - expect(rows).toEqual([ - { a: 1, b: "x" }, - { a: 2, b: "x" }, - { a: 2, b: "y" }, - ]); - }); - - it("covers derived values and unset from inactivity like any other value", () => { - const model = defineModel({ - dimensions: [ - choice("feature", [undefined, "on"]), - choice("tuning", [1, 2], { activeWhen: { set: "feature" } }), - firstValid("channel", ["stable", "beta"]), - ], - rules: [ - { requires: { is: ["channel", "beta"] }, when: { is: ["tuning", 2] } }, - ], - }); - const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); - expect(rows).toEqual( - expect.arrayContaining([ - { feature: undefined, tuning: undefined, channel: "stable" }, - { feature: "on", tuning: 2, channel: "beta" }, - ]), - ); - expect(isCoveringArray(rows, model, { strength: 2, limit: LIMIT })).toBe( - true, - ); - }); - - it("yields nothing for an empty solution set", () => { - const model = defineModel({ - dimensions: [choice("a", [1])], - rules: [{ excludes: { is: ["a", 1] } }], - }); - expect(rowsOf(model, coveringArray({ strength: 2, limit: LIMIT }))).toEqual( - [], - ); - expect(isCoveringArray([], model, { strength: 2, limit: LIMIT })).toBe( - true, - ); - }); - - it("refuses to hold more rows than its limit, and needs no limit", () => { - // Every pair of two unconstrained dimensions is a whole combination, so the array is the product and needs that many rows. - const model = defineModel({ - dimensions: [choice("a", SIZES), choice("b", SIZES)], - }); - const rows = SIZES.length ** 2; - expect(() => - rowsOf(model, coveringArray({ strength: 2, limit: rows - 1 })), - ).toThrow(LimitExceededError); - expect( - rowsOf(model, coveringArray({ strength: 2, limit: rows })), - ).toHaveLength(rows); - expect(rowsOf(model, coveringArray({ strength: 2 }))).toHaveLength(rows); - }); - - it("refuses a strength that is not a positive integer and a limit that is not a non-negative integer", () => { - expect(() => coveringArray({ strength: 0 })).toThrow(RangeError); - expect(() => coveringArray({ strength: 1.5 })).toThrow(RangeError); - expect(() => coveringArray({ strength: 2, limit: -1 })).toThrow(RangeError); - expect(() => coveringArray({ strength: 2, limit: 1.5 })).toThrow( - RangeError, - ); - }); - - it("never reads the solution set: the combinations it asks the engine for are bounded by the t-tuple count", () => { - // Unconstrained, so the solution set is the full product of the domains while the pairs grow only with the square of the dimension count. - const dimensionCount = 14; - const domain = [0, 1, 2]; - const strength = 2; - const model = defineModel({ - dimensions: Array.from({ length: dimensionCount }, (_, index) => - choice(`d${String(index)}`, domain), - ), - }); - const solutions = count(model, { limit: Number.MAX_SAFE_INTEGER }); - const tupleCount = - subsetCount(dimensionCount, strength) * domain.length ** strength; - // The construction's engine queries, from its structure: one per candidate tuple when the tuples are listed (the first t dimensions' included), and per later dimension at most one per row and value plus one fallback in horizontal growth and one per row and uncovered tuple in vertical growth, plus one completion per row. Every row holds a tuple no earlier row did, so there are at most tupleCount rows. - const queryBound = - tupleCount + - dimensionCount * tupleCount * (domain.length + 1) + - tupleCount * tupleCount + - tupleCount; - expect(queryBound).toBeLessThan(solutions); - - const work = { queries: 0, combinations: 0 }; - const counted: Strategy = { - name: "counted", - select: (context) => - coveringArray({ strength }).select({ - ...context, - completions: function* (assignment) { - work.queries += 1; - for (const combination of context.completions(assignment)) { - work.combinations += 1; - yield combination; - } - }, - }), - }; - const rows = rowsOf(model, counted); - expect(rows.length).toBeLessThanOrEqual(tupleCount); - expect(work.queries).toBeLessThanOrEqual(queryBound); - // Each query stops at its first combination, so no more combinations pass through than queries are made. - expect(work.combinations).toBeLessThanOrEqual(work.queries); - // The greedy reference, by contrast, holds the whole solution set before its first row, so even as many combinations as there are tuples is too few for it. - expect(() => - rowsOf(model, greedyCoveringArray({ strength, limit: tupleCount })), - ).toThrow(LimitExceededError); - }); -}); - -/** The number of ways to choose `size` of `total` items. */ -function subsetCount(total: number, size: number): number { - let result = 1; - for (let chosen = 0; chosen < size; chosen += 1) { - result = (result * (total - chosen)) / (chosen + 1); - } - - return result; -} - -describe("coveringArray against the greedy reference", () => { - it.each(STRENGTHS)( - "is no larger in total than the greedy reference over the generated models at strength %i, and never more rows than coverable tuples", - (strength) => { - let rowsTotal = 0; - let referenceTotal = 0; - for (const { model, seed } of generated()) { - const rows = rowsOf(model, coveringArray({ strength })); - const reference = rowsOf( - model, - greedyCoveringArray({ strength, limit: LIMIT }), - ); - rowsTotal += rows.length; - referenceTotal += reference.length; - expect(rows.length, `seed ${String(seed)}`).toBeLessThanOrEqual( - coverableTupleCount(model, strength), - ); - } - expect(rowsTotal).toBeLessThanOrEqual(referenceTotal); - }, - ); -}); - -/** The number of distinct t-tuples the model's solution set contains, read from the whole solution set. */ -function coverableTupleCount(model: Model, strength: number): number { - const names = model.dimensions.map((dimension) => dimension.name); - const subsets = subsetsOf(names, Math.min(strength, names.length)); - const tuples = new Set(); - for (const { values } of collect(model, { limit: LIMIT })) { - for (const subset of subsets) { - tuples.add(tupleKey(subset.map((name) => [name, values[name]]))); - } - } - - return tuples.size; -} - -describe("coveringArray against the isCoveringArray oracle", () => { - it.each(STRENGTHS)( - "is a covering array of strength %i on every generated model, of valid, distinct rows", - (strength) => { - for (const { model, seed } of generated()) { - const rows = rowsOf(model, coveringArray({ strength, limit: LIMIT })); - expect( - isCoveringArray(rows, model, { strength, limit: LIMIT }), - `seed ${String(seed)}`, - ).toBe(true); - expect( - new Set(rows.map((row) => JSON.stringify(Object.entries(row)))).size, - ).toBe(rows.length); - expect(rows.length).toBeLessThanOrEqual(count(model, { limit: LIMIT })); - } - }, - ); - - it("yields the same rows in the same order every time", () => { - for (const { model } of generated().slice(0, MODELS / SAMPLE_DIVISOR)) { - const strategy = coveringArray({ strength: 2, limit: LIMIT }); - expect(rowsOf(model, strategy)).toEqual(rowsOf(model, strategy)); - } - }); - - it("yields the whole solution set at a strength of at least the dimension count", () => { - for (const { model } of generated().slice(0, MODELS / SAMPLE_DIVISOR)) { - const strength = model.dimensions.length; - expect( - rowsOf(model, coveringArray({ strength, limit: LIMIT })), - ).toHaveLength(count(model, { limit: LIMIT })); - } - }); - - it("the oracle refuses the strategy's output with any one row dropped", () => { - let checked = 0; - for (const { model, seed } of generated().slice( - 0, - MODELS / SAMPLE_DIVISOR, - )) { - const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); - // Redundant rows are removed, so every row holds a tuple no other row does, and without it that tuple is uncovered. - for (const [index] of rows.entries()) { - checked += 1; - expect( - isCoveringArray(rows.toSpliced(index, 1), model, { - strength: 2, - limit: LIMIT, - }), - `seed ${String(seed)}, row ${String(index)}`, - ).toBe(false); - } - } - expect(checked).toBeGreaterThan(0); - }); - - it("the oracle refuses a row outside the solution set, and combinatrix refuses a strategy yielding one", () => { - const model = defineModel({ - dimensions: [choice("a", [1, 2]), choice("b", ["x", "y"])], - rules: [{ excludes: { all: [{ is: ["a", 1] }, { is: ["b", "y"] }] } }], - }); - const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); - const invalid = { a: 1, b: "y" }; - expect( - isCoveringArray([...rows, invalid], model, { strength: 2, limit: LIMIT }), - ).toBe(false); - expect( - isCoveringArray([...rows, { a: 1 }], model, { - strength: 2, - limit: LIMIT, - }), - ).toBe(false); - const broken: Strategy = { - name: "broken", - select: (context) => [ - ...coveringArray({ strength: 2, limit: LIMIT }).select(context), - invalid, - ], - }; - expect(() => rowsOf(model, broken)).toThrow(StrategyError); - }); -}); diff --git a/packages/trilean-combinatorics/src/errors.ts b/packages/trilean-combinatorics/src/errors.ts deleted file mode 100644 index 3d9c797..0000000 --- a/packages/trilean-combinatorics/src/errors.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Scalar } from "combinatrix"; -import { CombinatrixError, ConstraintContractError } from "combinatrix"; -import type { IndeterminateReason } from "trilean"; - -/** - * Thrown by `trileanConstraint` when a tree cannot be a constraint over the given dimensions: it does not parse as a trilean predicate tree, uses a node kind whose resolver the adapter cannot provide, references a key that is not a string or names no dimension, calls a function the registry does not hold, tags a reference with a unit, or reads a dimension that can take a value with no trilean counterpart. Every defect found is listed, not only the first. - */ -export class PredicateTreeError extends CombinatrixError { - /** One message per defect, each naming where in the tree it is (`$` is the root). */ - readonly issues: readonly string[]; - - constructor(issues: readonly string[]) { - super( - `invalid trilean constraint:\n${issues.map((issue) => ` ${issue}`).join("\n")}`, - ); - this.name = "PredicateTreeError"; - this.issues = issues; - } -} - -/** - * Thrown by `trileanConstraint` when evaluating the tree could exhaust trilean's default evaluation budget. A tree accepted by the adapter visits each of its nodes at most once per evaluation, so its node count and its depth are exactly what an evaluation can spend, and checking them when the constraint is defined means the budget is never met partway through an evaluation. - */ -export class EvaluationBudgetError extends CombinatrixError { - /** Which of the evaluator's two budgets the tree exceeds: `nodes` is its node count against `maxNodes`, `depth` its deepest node's nesting depth (the root is at 0) against `maxNestingDepth`. */ - readonly budget: "nodes" | "depth"; - - /** The tree's own node count or deepest nesting depth. */ - readonly actual: number; - - /** The evaluator's default for that budget: the largest node count it accepts, or the first nesting depth it refuses. */ - readonly limit: number; - - constructor(budget: "nodes" | "depth", actual: number, limit: number) { - super( - budget === "nodes" - ? `the tree has ${String(actual)} nodes, more than the ${String(limit)} one evaluation may visit` - : `the tree nests to depth ${String(actual)}, and evaluation refuses depth ${String(limit)} and deeper`, - ); - this.name = "EvaluationBudgetError"; - this.budget = budget; - this.actual = actual; - this.limit = limit; - } -} - -/** - * Thrown by a trilean constraint that is still indeterminate once every dimension it reads is assigned, which combinatrix's contract forbids: there is nothing left to wait for, so the tree has no answer for that combination. The usual cause is reading a dimension that is unset there without guarding it with `exists`, which leaves a `compare` indeterminate with `not-found`; `and(exists(reference), compare(...))` is definitely false when the dimension is unset, because a definite false absorbs. It extends `ConstraintContractError`, the error combinatrix raises for the same breach, and carries trilean's own reason. - */ -export class UndecidedConstraintError extends ConstraintContractError { - /** Trilean's reason the evaluation stayed indeterminate. */ - readonly reason: IndeterminateReason; - - constructor(label: string, reason: Readonly) { - super( - `${label} is indeterminate with every dimension it reads assigned (${reason.code}: ${reason.message})`, - ); - this.name = "UndecidedConstraintError"; - this.reason = reason; - } -} - -/** - * Thrown when a trilean constraint reads a computed dimension whose function produced `null`, which has no trilean value: trilean's values are text, numbers, booleans and its own richer kinds, and its absence is a resolver's report rather than a value. A choice or first-valid dimension that can take `null` is refused earlier, by `trileanConstraint` itself, since its domain is known when the constraint is defined. - */ -export class UnmappableValueError extends CombinatrixError { - /** The dimension whose value could not be mapped. */ - readonly dimension: string; - - /** The value it held. */ - readonly value: Scalar; - - constructor(dimension: string, value: Scalar) { - super( - `dimension "${dimension}" holds ${JSON.stringify(value)}, which has no trilean value`, - ); - this.name = "UnmappableValueError"; - this.dimension = dimension; - this.value = value; - } -} diff --git a/packages/trilean-combinatorics/src/index.ts b/packages/trilean-combinatorics/src/index.ts deleted file mode 100644 index 2ecd5b4..0000000 --- a/packages/trilean-combinatorics/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { TrileanConstraintOptions } from "./constraint"; -export { trileanConstraint } from "./constraint"; - -export type { CoveringArrayOptions } from "./covering"; -export { coveringArray } from "./covering"; - -export type { CoveringCheckOptions } from "./verify"; -export { isCoveringArray } from "./verify"; - -export { - EvaluationBudgetError, - PredicateTreeError, - UndecidedConstraintError, - UnmappableValueError, -} from "./errors"; diff --git a/packages/trilean-combinatorics/src/internal/tuples.ts b/packages/trilean-combinatorics/src/internal/tuples.ts deleted file mode 100644 index f3e240a..0000000 --- a/packages/trilean-combinatorics/src/internal/tuples.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Value } from "combinatrix"; - -/** Every way to choose `size` of the items, each keeping the items' order, listed lexicographically by position. Choosing none gives the one empty subset; choosing more than there are gives none. */ -export function subsetsOf(items: readonly T[], size: number): T[][] { - if (size === 0) return [[]]; - - return items.flatMap((item, index) => - subsetsOf(items.slice(index + 1), size - 1).map((rest) => [item, ...rest]), - ); -} - -/** Every choice of one value from each list, lazily and lexicographically by position, the last list varying fastest. No lists give the one empty choice; an empty list gives none. */ -export function* productOf( - lists: readonly (readonly Value[])[], -): Generator { - const [first, ...rest] = lists; - if (first === undefined) { - yield []; - - return; - } - for (const value of first) { - for (const tail of productOf(rest)) yield [value, ...tail]; - } -} - -/** The canonical text of dimension values given as name and value pairs: `[name]` for unset and `[name, value]` for a set value, so `null` never collides with unset. Two lists of pairs naming the same dimensions in the same order have equal texts exactly when their values are equal, and equal values have equal JSON, so `0` and `-0` are one value, as combinatrix treats them. */ -export function tupleKey(pairs: readonly (readonly [string, Value])[]): string { - return JSON.stringify( - pairs.map(([name, value]) => - value === undefined ? [name] : [name, value], - ), - ); -} diff --git a/packages/trilean-combinatorics/src/readme-example.unit.test.ts b/packages/trilean-combinatorics/src/readme-example.unit.test.ts deleted file mode 100644 index 8b04ce2..0000000 --- a/packages/trilean-combinatorics/src/readme-example.unit.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { choice, collect, count, defineModel } from "combinatrix"; -import { describe, expect, it } from "vitest"; -import { coveringArray, isCoveringArray, trileanConstraint } from "./index"; - -const LOW = 0.3; -const HIGH = 0.5; -/** The README lists retries of 0, 1 and 3. */ -const MOST_RETRIES = 3; -const RETRIES = [0, 1, MOST_RETRIES]; -/** What the README's comment states: "safe" with every ratio (3 x 2 x 3) plus "fast" with the one ratio of at least 0.5 (1 x 2 x 3). */ -const VALID_COMBINATIONS = 24; - -/** The worked example in README.md, run as written, so the claims its comments make are checked. */ -describe("the README example", () => { - it("counts the valid combinations the comment states and covers every pair of them", () => { - const dimensions = [ - choice("mode", ["fast", "safe"]), - choice("ratio", [undefined, LOW, HIGH]), - choice("cache", [true, false]), - choice("retries", RETRIES), - ]; - const fastNeedsRatio = trileanConstraint( - { - kind: "or", - left: { - kind: "textCompare", - op: "notEquals", - left: { kind: "reference", key: "mode" }, - right: { kind: "textLiteral", value: "fast" }, - }, - right: { - kind: "and", - left: { - kind: "exists", - operand: { kind: "reference", key: "ratio" }, - }, - right: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "ratio" }, - right: { kind: "numberLiteral", value: HIGH }, - }, - }, - }, - { dimensions, name: "fast needs a ratio of at least 0.5" }, - ); - const model = defineModel({ dimensions, constraints: [fastNeedsRatio] }); - const limit = 10_000; - - expect(count(model, { limit })).toBe(VALID_COMBINATIONS); - const rows = collect(model, { - strategy: coveringArray({ strength: 2 }), - limit, - }).map(({ values }) => values); - expect(isCoveringArray(rows, model, { strength: 2, limit })).toBe(true); - expect(rows.length).toBeLessThan(VALID_COMBINATIONS); - }); -}); diff --git a/packages/trilean-combinatorics/src/test-support/greedy-reference.ts b/packages/trilean-combinatorics/src/test-support/greedy-reference.ts deleted file mode 100644 index 11d7365..0000000 --- a/packages/trilean-combinatorics/src/test-support/greedy-reference.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Strategy, StrategyContext, Value } from "combinatrix"; -import { LimitExceededError } from "combinatrix"; -import { subsetsOf, tupleKey } from "../internal/tuples"; - -/** - * The reference covering-array construction `coveringArray` is compared with in tests: greedy over the whole solution set, so its size is a yardstick and its memory is the solution set. It reads every valid combination in enumeration order, then repeatedly yields the combination covering the most t-tuples not yet covered, ties going to the earliest, until none is left. Past `limit` combinations it throws `LimitExceededError`. Coverage and the strength reading are those of `coveringArray`. - */ -export function greedyCoveringArray(options: { - readonly strength: number; - readonly limit: number; -}): Strategy { - const { strength, limit } = options; - - return { - name: `greedy covering array of strength ${String(strength)}`, - select: (context) => greedyRows(context, strength, limit), - }; -} - -/** A combination of the solution set with the keys of every t-tuple it contains. */ -interface Candidate { - readonly row: Readonly>; - readonly tuples: readonly string[]; -} - -function* greedyRows( - context: StrategyContext, - strength: number, - limit: number, -): Generator>> { - const names = context.dimensions.map((dimension) => dimension.name); - const subsets = subsetsOf(names, Math.min(strength, names.length)); - // A Set iterates in insertion order, so the candidates stay in enumeration order as chosen ones are removed. - const unchosen = new Set(); - for (const row of context.completions()) { - if (unchosen.size === limit) throw new LimitExceededError(limit); - - unchosen.add({ - row, - tuples: subsets.map((subset) => - tupleKey(subset.map((name) => [name, row[name]])), - ), - }); - } - const uncovered = new Set( - [...unchosen].flatMap((candidate) => candidate.tuples), - ); - while (uncovered.size > 0) { - let best: Candidate | undefined; - let bestGain = 0; - // Only a strictly larger gain replaces the best, so a tie goes to the earliest candidate in enumeration order. - for (const candidate of unchosen) { - const gain = candidate.tuples.filter((tuple) => - uncovered.has(tuple), - ).length; - if (gain > bestGain) { - best = candidate; - bestGain = gain; - } - } - if (best === undefined) { - throw new Error( - "unreachable: every uncovered tuple came from a candidate not yet chosen, since a chosen candidate's tuples are all covered", - ); - } - for (const tuple of best.tuples) uncovered.delete(tuple); - unchosen.delete(best); - yield best.row; - } -} diff --git a/packages/trilean-combinatorics/src/test-support/random-model.ts b/packages/trilean-combinatorics/src/test-support/random-model.ts deleted file mode 100644 index a6ade81..0000000 --- a/packages/trilean-combinatorics/src/test-support/random-model.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { - Condition, - Constraint, - Dimension, - Model, - Rule, - Scalar, - Value, -} from "combinatrix"; -import { choice, computed, defineModel, firstValid } from "combinatrix"; -import type { ExpressionNode, PredicateNode } from "trilean"; -import { trileanConstraint } from "../constraint"; - -/** 2 to the power 32: maps an unsigned 32-bit integer into `[0, 1)`. */ -const UINT32 = 4_294_967_296; -const MULBERRY_INCREMENT = 0x6d2b79f5; -const MULBERRY_SHIFT_A = 15; -const MULBERRY_SHIFT_B = 7; -const MULBERRY_SHIFT_C = 14; -const MULBERRY_OR_A = 1; -const MULBERRY_OR_B = 61; - -/** The value pools a free dimension draws its domain from. Each dimension draws from one pool, so a trilean `memberOf` comparing it with a literal of that pool's kind is always definite once it is set. */ -const POOLS: readonly (readonly Scalar[])[] = [ - ["a", "b", "c"], - [0, 1, 2], - [true, false], -]; -const MAX_FREE_DIMENSIONS = 5; -const MAX_DOMAIN = 3; -const MAX_RULES = 2; -const MAX_TRILEAN_CONSTRAINTS = 1; -/** The deepest a generated condition or predicate tree nests below its root connective. */ -const MAX_DEPTH = 2; -/** One in this many free dimensions after the first is conditionally active. */ -const CONDITIONAL_ONE_IN = 3; -/** One in this many models gains a first-valid dimension, and one in this many a computed one. */ -const DERIVED_ONE_IN = 3; -/** The number of leaf condition forms `randomLeaf` chooses between: is, in, set and unset. */ -const LEAF_FORMS = 4; -const FIRST_VALID_CANDIDATES: readonly Scalar[] = ["low", "mid", "high"]; - -/** Mulberry32: a small seeded generator, so every run checks the same models and a failure reproduces from its seed. */ -export class Random { - #state: number; - - constructor(seed: number) { - this.#state = seed; - } - - /** A number in `[0, 1)`. */ - next(): number { - this.#state = (this.#state + MULBERRY_INCREMENT) | 0; - let mixed = Math.imul( - this.#state ^ (this.#state >>> MULBERRY_SHIFT_A), - this.#state | MULBERRY_OR_A, - ); - mixed ^= - mixed + - Math.imul(mixed ^ (mixed >>> MULBERRY_SHIFT_B), mixed | MULBERRY_OR_B); - - return ((mixed ^ (mixed >>> MULBERRY_SHIFT_C)) >>> 0) / UINT32; - } - - /** An integer in `[low, high]`. */ - int(low: number, high: number): number { - return low + Math.floor(this.next() * (high - low + 1)); - } - - /** True one time in `oneIn`. */ - oneIn(oneIn: number): boolean { - return this.int(1, oneIn) === 1; - } - - pick(items: readonly T[]): T { - const item = items[this.int(0, items.length - 1)]; - if (item === undefined) throw new Error("pick from an empty list"); - - return item; - } - - /** A selection of `size` distinct items, at most all of them, in their original order. */ - subset(items: readonly T[], size: number): T[] { - const chosen = new Set(); - while (chosen.size < Math.min(size, items.length)) { - chosen.add(this.int(0, items.length - 1)); - } - - return items.filter((_, index) => chosen.has(index)); - } -} - -/** A free dimension as the generator built it, with the values it may take. */ -interface FreeDimension { - readonly name: string; - readonly values: readonly Value[]; -} - -/** A generated model with the parts the tests reason about. */ -export interface GeneratedModel { - readonly model: Model>>; - /** The seed it was generated from, for a failure message. */ - readonly seed: number; -} - -/** - * A random model using every feature the strategy has to handle: free dimensions whose domains may include unset, conditional activity on earlier dimensions, an optional first-valid dimension constrained by an earlier one, an optional computed dimension, declarative rules, and trilean constraints whose every reference is guarded, so they are definite on every complete assignment. Its solution set can be empty. - */ -export function randomModel(seed: number): GeneratedModel { - const random = new Random(seed); - const free: FreeDimension[] = []; - const dimensions: Dimension[] = []; - const count = random.int(1, MAX_FREE_DIMENSIONS); - for (let index = 0; index < count; index += 1) { - const name = `d${String(index)}`; - const pool = random.pick(POOLS); - const scalars: Value[] = random.subset(pool, random.int(1, MAX_DOMAIN)); - const values = random.oneIn(2) ? [undefined, ...scalars] : scalars; - const earlier = free.at(-1); - if (earlier !== undefined && random.oneIn(CONDITIONAL_ONE_IN)) { - dimensions.push( - choice(name, values, { activeWhen: randomLeaf(random, [earlier]) }), - ); - free.push({ name, values: [undefined, ...values] }); - } else { - dimensions.push(choice(name, values)); - free.push({ name, values }); - } - } - const rules: Rule[] = Array.from({ length: random.int(0, MAX_RULES) }, () => - randomRule(random, free), - ); - const [first] = free; - if (first !== undefined && random.oneIn(DERIVED_ONE_IN)) { - dimensions.push(firstValid("level", FIRST_VALID_CANDIDATES)); - rules.push({ - requires: { - in: ["level", random.subset(FIRST_VALID_CANDIDATES, random.int(1, 2))], - }, - when: randomLeaf(random, [first]), - }); - } - if (first !== undefined && random.oneIn(DERIVED_ONE_IN)) { - dimensions.push( - computed("shape", { - reads: [first.name], - compute: (values) => - values[first.name] === undefined ? "none" : "some", - }), - ); - } - const constraints: Constraint[] = Array.from( - { length: random.int(0, MAX_TRILEAN_CONSTRAINTS) }, - (_, index) => - trileanConstraint(randomScopedTree(random, free), { - dimensions, - name: `trilean ${String(index)}`, - }), - ); - - return { model: defineModel({ dimensions, rules, constraints }), seed }; -} - -/** A random leaf condition on one of the dimensions, comparing only with values it can take. */ -function randomLeaf( - random: Random, - dimensions: readonly FreeDimension[], -): Condition { - const dimension = random.pick(dimensions); - const scalars = dimension.values.filter((value) => value !== undefined); - const form = random.int(0, LEAF_FORMS - 1); - if (form === 0 && scalars.length > 0) { - return { is: [dimension.name, random.pick(scalars)] }; - } - if (form === 1 && scalars.length > 0) { - return { - in: [ - dimension.name, - random.subset(scalars, random.int(1, scalars.length)), - ], - }; - } - - return form === 2 ? { set: dimension.name } : { unset: dimension.name }; -} - -function randomRule( - random: Random, - dimensions: readonly FreeDimension[], -): Rule { - const operands = Array.from({ length: random.int(1, 2) }, () => - randomLeaf(random, dimensions), - ); - const body: Condition = random.oneIn(2) - ? { all: operands } - : { any: operands }; - - const rule: Rule = random.oneIn(2) ? { requires: body } : { excludes: body }; - - return random.oneIn(2) - ? { ...rule, when: randomLeaf(random, dimensions) } - : rule; -} - -/** A trilean literal of a scalar's own kind. */ -function literal(value: Scalar): ExpressionNode { - if (typeof value === "string") return { kind: "textLiteral", value }; - if (typeof value === "number") return { kind: "numberLiteral", value }; - if (typeof value === "boolean") return { kind: "booleanLiteral", value }; - - throw new Error("the pools hold no null"); -} - -/** A random tree that, like a scoped rule, binds only when one dimension is set: `not(exists(d)) or tree`. Scoping keeps most generated solution sets non-empty. */ -function randomScopedTree( - random: Random, - dimensions: readonly FreeDimension[], -): PredicateNode { - const scope = random.pick(dimensions); - - return { - kind: "or", - left: { - kind: "not", - operand: { - kind: "exists", - operand: { kind: "reference", key: scope.name }, - }, - }, - right: randomTree(random, dimensions, 1), - }; -} - -/** - * A random predicate tree over the dimensions whose every reference is guarded: a leaf is `exists(d)`, or `exists(d) and d in [v]` with `v` of the dimension's own kind. A guarded leaf is definite on every complete assignment (false when the dimension is unset, since a definite false absorbs), so the whole tree is. - */ -function randomTree( - random: Random, - dimensions: readonly FreeDimension[], - depth: number, -): PredicateNode { - if (depth >= MAX_DEPTH || random.oneIn(2)) { - const dimension = random.pick(dimensions); - const reference: ExpressionNode = { - kind: "reference", - key: dimension.name, - }; - const exists: PredicateNode = { kind: "exists", operand: reference }; - const scalars = dimension.values.filter((value) => value !== undefined); - if (scalars.length === 0 || random.oneIn(2)) return exists; - - return { - kind: "and", - left: exists, - right: { - kind: "memberOf", - op: "in", - operand: reference, - candidates: [literal(random.pick(scalars))], - }, - }; - } - const form = random.int(0, 2); - if (form === 0) { - return { kind: "not", operand: randomTree(random, dimensions, depth + 1) }; - } - const left = randomTree(random, dimensions, depth + 1); - const right = randomTree(random, dimensions, depth + 1); - - return form === 1 - ? { kind: "and", left, right } - : { kind: "or", left, right }; -} diff --git a/packages/trilean-combinatorics/src/verify.ts b/packages/trilean-combinatorics/src/verify.ts deleted file mode 100644 index a842cac..0000000 --- a/packages/trilean-combinatorics/src/verify.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Model, Value } from "combinatrix"; -import { collect, explain } from "combinatrix"; - -/** Options for `isCoveringArray`. */ -export interface CoveringCheckOptions { - /** The strength t to check, a positive integer, read as `coveringArray` reads it: above the model's dimension count it means every dimension at once. */ - readonly strength: number; - /** The largest solution set the check enumerates, a non-negative integer; past it combinatrix's `collect` throws `LimitExceededError`. */ - readonly limit: number; -} - -/** - * Whether the rows are a covering array of the model's solution set at the given strength: every row is a complete combination in the solution set (every dimension present, derived ones included, and `explain` finding no reason against it), and every t-way combination of dimension values that occurs in some combination of the solution set occurs in some row. Tuples no valid combination contains are not required. Duplicate rows are allowed. - * - * It is the brute-force definition, written independently of `coveringArray` so that it can test it: it enumerates the whole solution set with combinatrix's `collect`, lists every t-tuple each combination contains, and checks each against the rows. Throws `RangeError` for a strength that is not a positive integer. - */ -export function isCoveringArray>>( - rows: Readonly>>>, - model: Model, - options: CoveringCheckOptions, -): boolean { - const { strength, limit } = options; - if (!Number.isSafeInteger(strength) || strength < 1) { - throw new RangeError( - `strength must be a positive integer, not ${String(strength)}`, - ); - } - const rowList = [...rows]; - if ( - !rowList.every((row) => model.isTuple(row) && explain(model, row).valid) - ) { - return false; - } - - const names = model.dimensions.map((dimension) => dimension.name); - const width = Math.min(strength, names.length); - const covered = new Set(); - for (const row of rowList) { - for (const tuple of tuplesIn(row, names, width)) covered.add(tuple); - } - for (const { values } of collect(model, { limit })) { - for (const tuple of tuplesIn(values, names, width)) { - if (!covered.has(tuple)) return false; - } - } - - return true; -} - -/** Every `width`-tuple of a combination's values, as text naming each dimension with its value, or with nothing for unset. */ -function* tuplesIn( - values: Readonly>, - names: readonly string[], - width: number, -): Generator { - for (const chosen of nameChoices(names, width, 0)) { - yield JSON.stringify( - chosen.map((name) => { - const value = values[name]; - - return value === undefined ? { name } : { name, value }; - }), - ); - } -} - -/** Every choice of `width` names from position `from` on, each in the order the names are listed. */ -function* nameChoices( - names: readonly string[], - width: number, - from: number, -): Generator { - if (width === 0) { - yield []; - - return; - } - for (let first = from; first <= names.length - width; first += 1) { - const name = names[first]; - if (name === undefined) { - throw new Error( - `unreachable: position ${String(first)} is within the ${String(names.length)} names`, - ); - } - for (const rest of nameChoices(names, width - 1, first + 1)) { - yield [name, ...rest]; - } - } -} diff --git a/packages/trilean-combinatorics/tsconfig.json b/packages/trilean-combinatorics/tsconfig.json deleted file mode 100644 index d69761a..0000000 --- a/packages/trilean-combinatorics/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - // Same isomorphism gate packages/trilean uses: no "node" types and a WebWorker lib, so a Node-only global referenced from runtime src/ fails to compile here rather than at some consumer's runtime. The adapter and the covering-array construction are pure computations over in-memory values, so they have no reason to reach for a platform API at all. - "lib": ["ES2024", "WebWorker"], - "types": [] - }, - "exclude": ["src/**/*.test.ts", "src/test-support/**/*.ts"], - "extends": "../../tsconfig.base.json", - "include": ["src"] -} diff --git a/packages/trilean-combinatorics/tsconfig.node.json b/packages/trilean-combinatorics/tsconfig.node.json deleted file mode 100644 index b6529c9..0000000 --- a/packages/trilean-combinatorics/tsconfig.node.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "allowImportingTsExtensions": true, - "lib": ["ES2024"], - "types": ["node"] - }, - "exclude": [], - "extends": "./tsconfig.json", - "include": [ - "src/**/*.test.ts", - "src/test-support/**/*.ts", - "test/**/*.ts", - "*.config.ts", - "eslint.config.ts" - ] -} diff --git a/packages/trilean-combinatorics/tsdown.config.ts b/packages/trilean-combinatorics/tsdown.config.ts deleted file mode 100644 index 3a494c1..0000000 --- a/packages/trilean-combinatorics/tsdown.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "tsdown"; - -/** - * ESM only, unlike trilean-sql: combinatrix, which every module here imports, is published as ESM only, so a CommonJS build could load it only through require(esm) and would describe types the CommonJS resolution of combinatrix cannot reach. - */ -export default defineConfig({ - entry: ["src/*.ts", "!src/**/*.test.ts"], - format: ["esm"], - dts: true, - platform: "neutral", - clean: true, -}); diff --git a/packages/trilean-combinatorics/vitest.config.ts b/packages/trilean-combinatorics/vitest.config.ts deleted file mode 100644 index 9900eb3..0000000 --- a/packages/trilean-combinatorics/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "vitest/config"; - -/** - * One project, "unit": the adapter and the covering-array strategy are pure computations over in-memory models, so every test runs them directly with no engine, service or built output to stand up. The property tests that check the strategy against the `isCoveringArray` oracle and the adapter's Kleene soundness live here too, since they need nothing more. - */ -export default defineConfig({ - test: { - coverage: { - provider: "v8", - include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts", "src/test-support/**"], - reporter: ["text", "html", "lcov", "cobertura"], - }, - projects: [ - { - test: { - name: "unit", - include: ["src/**/*.test.ts"], - }, - }, - ], - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c23838..0bf0304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,61 +306,6 @@ importers: specifier: ^5.0.3 version: 5.0.3(@types/node@26.6.5)(@vitest/coverage-v8@5.0.4)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.15)(yaml@2.9.0)) - packages/trilean-combinatorics: - dependencies: - combinatrix: - specifier: 1.0.0 - version: 1.0.0 - trilean: - specifier: 1.13.0 - version: link:../trilean - devDependencies: - '@arethetypeswrong/cli': - specifier: ^0.18.5 - version: 0.18.5 - '@exadev/eslint-config': - specifier: 3.0.0 - version: 3.0.0(@eslint/json@2.1.1)(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.71.1(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) - '@types/node': - specifier: ^26.6.5 - version: 26.6.5 - '@vitest/coverage-v8': - specifier: ^5.0.4 - version: 5.0.4(vitest@5.0.3) - eslint: - specifier: ^10.12.0 - version: 10.12.0(jiti@2.6.1)(supports-color@10.2.2) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2)) - eslint-plugin-prettier: - specifier: ^5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2)))(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2))(prettier@3.9.10) - globals: - specifier: ^17.13.0 - version: 17.13.0 - prettier: - specifier: ^3.9.10 - version: 3.9.10 - publint: - specifier: ^0.3.25 - version: 0.3.25 - tsdown: - specifier: ^0.23.1 - version: 0.23.1(@arethetypeswrong/core@0.18.5)(publint@0.3.25)(tsx@4.23.15)(typescript@6.0.3) - turbo: - specifier: ^2.11.7 - version: 2.11.7 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - typescript-eslint: - specifier: ^8.71.1 - version: 8.71.1(eslint@10.12.0(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - vitest: - specifier: ^5.0.3 - version: 5.0.3(@types/node@26.6.5)(@vitest/coverage-v8@5.0.4)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.23.15)(yaml@2.9.0)) - packages/trilean-regex: devDependencies: '@arethetypeswrong/cli': @@ -2130,10 +2075,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combinatrix@1.0.0: - resolution: {integrity: sha512-lk48dsHFsJMWbv0BPDNFSG9aYETJd0MEKGyHLRYKayLbABRqdhu4EWhSsGekpCTpSddhgcynH8EfnoVtM+QH2w==} - engines: {node: '>=22'} - commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -5873,8 +5814,6 @@ snapshots: color-name@1.1.4: {} - combinatrix@1.0.0: {} - commander@10.0.1: {} commander@15.0.0: {}