From d72fcd4fcaa0d0e916dbaff751b88c8645f7bb90 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 06:40:33 +0000 Subject: [PATCH 1/5] feat(trilean): export the evaluator's default budgets DEFAULT_MAX_EVALUATION_NODES and DEFAULT_MAX_NESTING_DEPTH are what createEvaluator and createSyncEvaluator apply when maxNodes and maxNestingDepth are not given. Exporting them lets a caller check a tree against the same limits before evaluating it, rather than copying the numbers, which would drift when the defaults change. --- packages/trilean/README.md | 2 ++ packages/trilean/src/index.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/trilean/README.md b/packages/trilean/README.md index c9a2808..a0859ed 100644 --- a/packages/trilean/README.md +++ b/packages/trilean/README.md @@ -863,6 +863,8 @@ function createSyncEvaluator(options: { functions?: FunctionRegistry; maxNodes?: The node rules are written once and run by either driver, so the two evaluators agree on every node kind and every three-valued rule. A resolver that returns a promise (or any thenable) to a synchronous evaluator makes the evaluation throw a `TypeError`, since there is nothing to await it with. Because nothing is concurrent, independent operands (both sides of an `and`, the candidates of a `memberOf`, the items of a collection) are evaluated one after another; when a `maxNodes` or `maxNestingDepth` limit is exhausted partway through a tree, which operand meets it first can therefore differ from the asynchronous evaluator. +Both evaluators default `maxNodes` to `DEFAULT_MAX_EVALUATION_NODES` and `maxNestingDepth` to `DEFAULT_MAX_NESTING_DEPTH`, and both constants are exported, so a caller that checks a tree against the budget before evaluating it (counting its nodes, with the root at nesting depth 0 and every child one deeper) reads the same limits the evaluator applies. One evaluation visits a node at most once unless the tree has a `treeReference`, a `fold` or a `some`/`every` quantifier, which revisit a subtree per resolved tree or item, so for any other tree its node count is exactly the most the evaluation can spend. + ## Indeterminacy reference How each reason category can arise, per node kind. "Propagates" means: an indeterminate operand/sub-result, with no other rule overriding it, makes the whole node indeterminate with that same reason (subject to the tie-break rule in [The evaluation model](#the-evaluation-model) when more than one candidate reason is present, and to the absorbing-value exceptions called out explicitly below). diff --git a/packages/trilean/src/index.ts b/packages/trilean/src/index.ts index b89ef05..c33de8e 100644 --- a/packages/trilean/src/index.ts +++ b/packages/trilean/src/index.ts @@ -49,6 +49,10 @@ export { evaluateValue, } from "./evaluator-factory"; export type { EvaluatorOptions } from "./evaluator-factory"; +export { + DEFAULT_MAX_EVALUATION_NODES, + DEFAULT_MAX_NESTING_DEPTH, +} from "./evaluator-budget"; export { and, From e4b5afc0f0098761d7251ec31293cd6d7d55a28a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 06:41:15 +0000 Subject: [PATCH 2/5] chore: exempt combinatrix from the minimum release age combinatrix is an org-owned package released by its own semantic-release CI with trusted publishing, like the two packages already exempted, and the workspace is about to depend on it. --- pnpm-workspace.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92b4708..34ca93a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,10 +27,11 @@ allowBuilds: # 60 minutes. pnpm reads workspace-level settings only from the workspace root, so this file is the one place they can take effect at all. minimumReleaseAge: 60 -# Bare package names (no @version), per pnpm's own documented syntax (https://pnpm.io/settings#minimumreleaseageexclude). Both are org-owned packages published only by their own semantic-release CI with npm OIDC trusted publishing, so a version-agnostic exemption is a deliberate, low-risk choice rather than an entry needing an edit on every release. +# Bare package names (no @version), per pnpm's own documented syntax (https://pnpm.io/settings#minimumreleaseageexclude). Each is an org-owned package published only by its own semantic-release CI with npm OIDC trusted publishing, so a version-agnostic exemption is a deliberate, low-risk choice rather than an entry needing an edit on every release. minimumReleaseAgeExclude: - "@exadev/eslint-config" - "@exadev/semantic-release-workspace" + - "combinatrix" overrides: # release-workspace.config.ts's generateNotes step uses the conventionalcommits preset, whose templates call a helper that only conventional-changelog-writer@9 or newer provides. @semantic-release/release-notes-generator depends on conventional-changelog-writer@^8, so without this the preset loads and then throws from inside handlebars at render time -- the preset itself detects the too-old writer and registers a helper whose only job is to raise "requires conventional-changelog-writer@9 or newer". Scoped to release-notes-generator rather than global because it is the only consumer that renders with the writer at all: @semantic-release/commit-analyzer declares the same dependency but never imports it, using only the preset's whatBump. From 41c3cd71dbd9bd22b0f904c31d24a9f2ad6a3a81 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 06:41:21 +0000 Subject: [PATCH 3/5] feat(trilean-combinatorics): constrain combinatrix models with trilean trees and cover them Adds packages/trilean-combinatorics, depending on trilean and on combinatrix. trileanConstraint turns a predicate tree given as JSON into a combinatrix Constraint. The tree is validated with PredicateNodeSchema and walked: node kinds the adapter has no resolver for (collections, lookup, delegate, treeReference), reference keys that are not strings or name no dimension, units on references, calls to unregistered functions and referenced domains holding null are all refused at definition time, and so is a tree larger or deeper than trilean's default evaluation budget. Evaluation uses the synchronous evaluator with an unassigned dimension resolved as not yet known and an unset one as known absent, so a definite answer on a partial assignment holds for every completion. An indeterminate result once every dependency is assigned throws UndecidedConstraintError instead of breaking the contract. coveringArray is a strategy yielding a covering array of strength t over the solution set: every t-tuple of values that some valid combination contains appears in a yielded row, built greedily and deterministically from the full solution set. isCoveringArray is the independent brute-force check, used as the oracle of property tests over seeded random models. The package is ESM only, as combinatrix is, and pins the first combinatrix version that will be published. --- .github/workflows/ci.yml | 10 +- README.md | 4 +- packages/trilean-combinatorics/LICENSE | 21 + packages/trilean-combinatorics/README.md | 120 ++++++ .../trilean-combinatorics/eslint.config.ts | 82 ++++ packages/trilean-combinatorics/package.json | 89 +++++ .../src/constraint.soundness.unit.test.ts | 226 +++++++++++ .../trilean-combinatorics/src/constraint.ts | 360 +++++++++++++++++ .../src/constraint.unit.test.ts | 369 ++++++++++++++++++ .../trilean-combinatorics/src/covering.ts | 113 ++++++ .../src/covering.unit.test.ts | 237 +++++++++++ packages/trilean-combinatorics/src/errors.ts | 81 ++++ packages/trilean-combinatorics/src/index.ts | 15 + .../src/readme-example.unit.test.ts | 58 +++ .../src/test-support/random-model.ts | 273 +++++++++++++ packages/trilean-combinatorics/src/verify.ts | 89 +++++ packages/trilean-combinatorics/tsconfig.json | 10 + .../trilean-combinatorics/tsconfig.node.json | 16 + .../trilean-combinatorics/tsdown.config.ts | 12 + .../trilean-combinatorics/vitest.config.ts | 23 ++ 20 files changed, 2206 insertions(+), 2 deletions(-) create mode 100644 packages/trilean-combinatorics/LICENSE create mode 100644 packages/trilean-combinatorics/README.md create mode 100644 packages/trilean-combinatorics/eslint.config.ts create mode 100644 packages/trilean-combinatorics/package.json create mode 100644 packages/trilean-combinatorics/src/constraint.soundness.unit.test.ts create mode 100644 packages/trilean-combinatorics/src/constraint.ts create mode 100644 packages/trilean-combinatorics/src/constraint.unit.test.ts create mode 100644 packages/trilean-combinatorics/src/covering.ts create mode 100644 packages/trilean-combinatorics/src/covering.unit.test.ts create mode 100644 packages/trilean-combinatorics/src/errors.ts create mode 100644 packages/trilean-combinatorics/src/index.ts create mode 100644 packages/trilean-combinatorics/src/readme-example.unit.test.ts create mode 100644 packages/trilean-combinatorics/src/test-support/random-model.ts create mode 100644 packages/trilean-combinatorics/src/verify.ts create mode 100644 packages/trilean-combinatorics/tsconfig.json create mode 100644 packages/trilean-combinatorics/tsconfig.node.json create mode 100644 packages/trilean-combinatorics/tsdown.config.ts create mode 100644 packages/trilean-combinatorics/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d734e4f..eecd367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: command: pnpm test:coverage $TURBO_FLAGS # One upload per package, each labelled by the package whose coverage it is, since the action takes a single report file rather than a set. # - # Both are conditional on their own report existing, which is not defensive: `pnpm test:coverage` runs through turbo with $TURBO_FLAGS, which carries --affected on a pull request, so a package no commit in the PR touched legitimately does not run and legitimately writes no report. `fail-on-error` does not cover that case -- it governs the upload call, while a missing input file fails the action before it gets that far -- so without the hashFiles guard a PR confined to one package fails this job on the other package's absent report. + # Each is conditional on its own report existing, which is not defensive: `pnpm test:coverage` runs through turbo with $TURBO_FLAGS, which carries --affected on a pull request, so a package no commit in the PR touched legitimately does not run and legitimately writes no report. `fail-on-error` does not cover that case -- it governs the upload call, while a missing input file fails the action before it gets that far -- so without the hashFiles guard a PR confined to one package fails this job on the other package's absent report. # # Code Quality is a billed GitHub feature that has to be enabled per repository, and it is not enabled here. With it off, the upload call fails with HTTP 403 and the action reports that as an error annotation on every run, even with fail-on-error: false. So each upload runs only when the repository variable CODE_QUALITY_ENABLED is 'true', which is the one thing to set when the feature is switched on. fail-on-error: false stays so that a later upload failure does not gate the release job below. Also guarded against fork PRs, which never hold the code-quality: write permission to upload. - name: Upload coverage report (trilean) @@ -106,6 +106,14 @@ 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 178fce7..9557d0e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ 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. @@ -39,7 +40,8 @@ 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-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 ├── 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/LICENSE b/packages/trilean-combinatorics/LICENSE new file mode 100644 index 0000000..e6cf1d1 --- /dev/null +++ b/packages/trilean-combinatorics/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..4c338fc --- /dev/null +++ b/packages/trilean-combinatorics/README.md @@ -0,0 +1,120 @@ +# 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 }), 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` bounds the solution set it reads, throwing combinatrix's `LimitExceededError` past it, because the construction needs the whole solution set before it can choose a row. 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 greedy and deterministic: it reads the solution set in enumeration order, then repeatedly yields the combination covering the most t-tuples not yet covered, ties going to the earliest, until every coverable tuple is covered. The same model always gives the same rows in the same order. Each row is yielded as it is chosen, so stopping early, or the `limit` on combinatrix's own `combinations`, ends the work there. An empty solution set yields nothing. + +### `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, and no larger than the solution set. The oracle is itself shown to discriminate: it refuses the strategy's output with its last 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 new file mode 100644 index 0000000..7b71fa3 --- /dev/null +++ b/packages/trilean-combinatorics/eslint.config.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 0000000..1f6ac02 --- /dev/null +++ b/packages/trilean-combinatorics/package.json @@ -0,0 +1,89 @@ +{ + "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": "0.1.0", + "bugs": { + "url": "https://github.com/ExaDev/trilean/issues" + }, + "dependencies": { + "combinatrix": "1.0.0", + "trilean": "1.11.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 new file mode 100644 index 0000000..a8d916b --- /dev/null +++ b/packages/trilean-combinatorics/src/constraint.soundness.unit.test.ts @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000..f756908 --- /dev/null +++ b/packages/trilean-combinatorics/src/constraint.ts @@ -0,0 +1,360 @@ +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 new file mode 100644 index 0000000..ae7a1d1 --- /dev/null +++ b/packages/trilean-combinatorics/src/constraint.unit.test.ts @@ -0,0 +1,369 @@ +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 new file mode 100644 index 0000000..23eff29 --- /dev/null +++ b/packages/trilean-combinatorics/src/covering.ts @@ -0,0 +1,113 @@ +import type { Strategy, StrategyContext, Value } from "combinatrix"; +import { LimitExceededError } from "combinatrix"; + +/** 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 largest solution set the strategy reads, a non-negative integer. The construction needs the whole solution set before it can choose a row, so past this many combinations it throws `LimitExceededError` instead. */ + readonly limit: number; +} + +/** + * A combinatrix strategy yielding a covering array of the model's solution set: a subset of the 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 greedy and deterministic: it reads the solution set in enumeration order, then repeatedly yields the combination covering the most t-tuples not yet covered, breaking ties by enumeration order, until none is left. Rows are therefore always yielded in the same order for the same model. It yields each row as it is chosen, so a consumer that stops early, or combinatrix's own `limit` on `combinations`, ends the work there. 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 (!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) => 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[]; +} + +/** The greedy construction `coveringArray` describes. */ +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, row)), + }); + } + 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; + } +} + +/** 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. */ +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]), + ); +} + +/** The canonical text of a row's values on the given dimensions: `[name]` for unset and `[name, value]` for a set value, so `null` never collides with unset and two tuples are equal exactly when their texts are. Equal values have equal JSON, so `0` and `-0` are one value, as combinatrix treats them. */ +function tupleKey( + subset: readonly string[], + row: Readonly>, +): string { + return JSON.stringify( + subset.map((name) => { + const value = row[name]; + + return value === undefined ? [name] : [name, value]; + }), + ); +} diff --git a/packages/trilean-combinatorics/src/covering.unit.test.ts b/packages/trilean-combinatorics/src/covering.unit.test.ts new file mode 100644 index 0000000..bf5ccda --- /dev/null +++ b/packages/trilean-combinatorics/src/covering.unit.test.ts @@ -0,0 +1,237 @@ +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 { 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 a solution set larger than its limit", () => { + const model = defineModel({ + dimensions: [choice("a", SIZES), choice("b", SIZES)], + }); + expect(() => + rowsOf( + model, + coveringArray({ strength: 2, limit: SIZES.length ** 2 - 1 }), + ), + ).toThrow(LimitExceededError); + expect( + rowsOf(model, coveringArray({ strength: 2, limit: SIZES.length ** 2 })), + ).toHaveLength(SIZES.length ** 2); + }); + + it("refuses a strength that is not a positive integer and a limit that is not a non-negative integer", () => { + expect(() => coveringArray({ strength: 0, limit: LIMIT })).toThrow( + RangeError, + ); + expect(() => coveringArray({ strength: 1.5, limit: LIMIT })).toThrow( + RangeError, + ); + expect(() => coveringArray({ strength: 2, limit: -1 })).toThrow(RangeError); + }); +}); + +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 its last row dropped", () => { + let checked = 0; + for (const { model, seed } of generated()) { + const rows = rowsOf(model, coveringArray({ strength: 2, limit: LIMIT })); + if (rows.length === 0) continue; + + checked += 1; + // The last row chosen covered at least one tuple no earlier row did, so without it that tuple is uncovered. + expect( + isCoveringArray(rows.slice(0, -1), model, { + strength: 2, + limit: LIMIT, + }), + `seed ${String(seed)}`, + ).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 new file mode 100644 index 0000000..3d9c797 --- /dev/null +++ b/packages/trilean-combinatorics/src/errors.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..2ecd5b4 --- /dev/null +++ b/packages/trilean-combinatorics/src/index.ts @@ -0,0 +1,15 @@ +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/readme-example.unit.test.ts b/packages/trilean-combinatorics/src/readme-example.unit.test.ts new file mode 100644 index 0000000..f833875 --- /dev/null +++ b/packages/trilean-combinatorics/src/readme-example.unit.test.ts @@ -0,0 +1,58 @@ +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 }), + 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/random-model.ts b/packages/trilean-combinatorics/src/test-support/random-model.ts new file mode 100644 index 0000000..a6ade81 --- /dev/null +++ b/packages/trilean-combinatorics/src/test-support/random-model.ts @@ -0,0 +1,273 @@ +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 new file mode 100644 index 0000000..a842cac --- /dev/null +++ b/packages/trilean-combinatorics/src/verify.ts @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..d69761a --- /dev/null +++ b/packages/trilean-combinatorics/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 0000000..b6529c9 --- /dev/null +++ b/packages/trilean-combinatorics/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..3a494c1 --- /dev/null +++ b/packages/trilean-combinatorics/tsdown.config.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..9900eb3 --- /dev/null +++ b/packages/trilean-combinatorics/vitest.config.ts @@ -0,0 +1,23 @@ +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"], + }, + }, + ], + }, +}); From a4564ba9c05e3f5a10456ec2c372783ff420433a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 08:35:25 +0000 Subject: [PATCH 4/5] build: resolve combinatrix from the registry in the lockfile --- pnpm-lock.yaml | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced0106..4b12604 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,61 @@ 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.11.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': @@ -2075,6 +2130,10 @@ 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'} @@ -5814,6 +5873,8 @@ snapshots: color-name@1.1.4: {} + combinatrix@1.0.0: {} + commander@10.0.1: {} commander@15.0.0: {} From f805212ed17d46688ee10b50c1faccba1a3e5308 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 29 Oct 2026 10:59:40 +0000 Subject: [PATCH 5/5] build(trilean-combinatorics): pin trilean to the workspace version The workspace links a sibling only when its version satisfies the pinned range, and trilean has since released, so the exact pin of the earlier version resolved to an unbuilt package in CI. --- packages/trilean-combinatorics/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trilean-combinatorics/package.json b/packages/trilean-combinatorics/package.json index 1f6ac02..2a12d62 100644 --- a/packages/trilean-combinatorics/package.json +++ b/packages/trilean-combinatorics/package.json @@ -7,7 +7,7 @@ }, "dependencies": { "combinatrix": "1.0.0", - "trilean": "1.11.0" + "trilean": "1.11.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b12604..72ebd4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,7 +312,7 @@ importers: specifier: 1.0.0 version: 1.0.0 trilean: - specifier: 1.11.0 + specifier: 1.11.1 version: link:../trilean devDependencies: '@arethetypeswrong/cli':