Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -39,7 +40,8 @@ The `pnpm <task>` scripts are thin wrappers over `turbo run _<task>`; 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
Expand Down
21 changes: 21 additions & 0 deletions packages/trilean-combinatorics/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 120 additions & 0 deletions packages/trilean-combinatorics/README.md
Original file line number Diff line number Diff line change
@@ -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).
82 changes: 82 additions & 0 deletions packages/trilean-combinatorics/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -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,
);
Loading
Loading