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
7 changes: 7 additions & 0 deletions .changeset/brave-decisions-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rineex/decision-engine': minor
---

Expose the framework-agnostic decision execution pipeline, including validation,
normalization, scoring, ranking, selection, immutable execution results, and
in-memory event publication.
7 changes: 6 additions & 1 deletion packages/ddd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
"check-types": "tsc --noEmit",
"build": "tsup"
},
"tsd": {
"compilerOptions": {
"strictNullChecks": true
}
},
"files": [
"dist/**"
],
Expand All @@ -31,7 +36,7 @@
"tslib": "2.8.1",
"tsup": "8.5.1",
"type-fest": "5.3.1",
"typescript": "5.9.3",
"typescript": "6.0.3",
"ua-parser-js": "2.0.7",
"uuid": "13.0.0",
"vite-tsconfig-paths": "6.0.2",
Expand Down
5 changes: 4 additions & 1 deletion packages/ddd/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"compilerOptions": {
"strict": false,
"types": ["node"],
"module": "commonjs",
"declaration": true,
"noImplicitAny": false,
Expand All @@ -11,7 +13,8 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"target": "es6",
"moduleResolution": "node",
"moduleResolution": "bundler",
"ignoreDeprecations": "6.0",
"skipLibCheck": true,
"sourceMap": false,
"outDir": "./dist",
Expand Down
156 changes: 156 additions & 0 deletions packages/decision-engine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# @rineex/decision-engine

A framework-independent, synchronous engine for making explainable selections
from a set of candidates. It evaluates hard constraints, normalizes feature
values, scores and ranks eligible candidates, selects a final subset, and
returns the complete decision trail plus domain events.

## Installation

```sh
pnpm add @rineex/decision-engine
```

## Quick start

```ts
import {
DecisionEngine,
DefaultDecisionValidator,
DescendingScoreRankingStrategy,
FeatureObjective,
MinMaxNormalizer,
SelectTopNStrategy,
WeightedSumScoringStrategy,
} from '@rineex/decision-engine';

interface Supplier {
readonly id: string;
readonly approved: boolean;
readonly quality: number;
readonly cost: number;
}

const suppliers: readonly Supplier[] = [
{ id: 'acme', approved: true, quality: 98, cost: 125 },
{ id: 'beta', approved: true, quality: 80, cost: 80 },
{ id: 'blocked', approved: false, quality: 95, cost: 70 },
];

const engine = new DecisionEngine(new DefaultDecisionValidator());

const execution = engine.execute(
{
id: 'supplier-selection',
version: 'v1',
candidateRefResolver: { resolve: candidate => candidate.id },
constraints: [
{
id: 'approved',
evaluate: supplier =>
supplier.approved
? { constraintId: 'approved', satisfied: true }
: {
constraintId: 'approved',
satisfied: false,
reasonCode: 'NOT_APPROVED',
},
},
],
features: [
{
key: 'quality',
objective: FeatureObjective.MAXIMIZE,
evaluate: supplier => ({
key: 'quality',
objective: FeatureObjective.MAXIMIZE,
rawValue: supplier.quality,
}),
},
{
key: 'cost',
objective: FeatureObjective.MINIMIZE,
evaluate: supplier => ({
key: 'cost',
objective: FeatureObjective.MINIMIZE,
rawValue: supplier.cost,
}),
},
],
normalizer: new MinMaxNormalizer(),
scoringStrategy: new WeightedSumScoringStrategy({
resolve: key => (key === 'quality' ? 0.7 : 0.3),
}),
rankingStrategy: new DescendingScoreRankingStrategy(),
selectionStrategy: new SelectTopNStrategy(1),
},
{
candidates: suppliers,
context: undefined,
policy: { id: 'default', version: 'v1', values: {} },
},
);

console.log(
execution.result.selected.map(evaluation => evaluation.candidate.id),
);
// ['acme']
```

## Execution model

`execute` is synchronous and has no I/O. It performs these stages in order:

1. Validate the definition and request.
2. Resolve and validate unique candidate references.
3. Evaluate hard constraints for every candidate.
4. Extract, normalize, score, rank, and select eligible candidates.
5. Return immutable result data and events.

Rejected candidates are retained in `result.evaluations` with their constraint
results, but have no features, score, rank, or selection flag. This makes the
outcome explainable without re-running the decision.

## Built-in strategies

| Component | Purpose |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `MinMaxNormalizer` | Maps each feature across eligible candidates to `0..1`; minimize objectives are inverted and zero-range features become `1`. |
| `WeightedSumScoringStrategy` | Multiplies each normalized feature by a policy-derived finite weight. |
| `DescendingScoreRankingStrategy` | Ranks higher scores first while preserving input order for ties. |
| `SelectFirstStrategy` | Selects the highest-ranked candidate. |
| `SelectTopNStrategy(n)` | Selects up to `n` ranked candidates. |
| `SelectAboveThresholdStrategy(score)` | Selects ranked candidates at or above the score threshold. |

## Results and events

`execution.result` contains the selected evaluations and every candidate
evaluation. `execution.events` includes one `candidate.rejected` event for each
rejected candidate and one `decision.completed` event. Output collections and
engine-owned records are frozen; supplied candidate objects are not modified.

The engine creates events but never delivers them. Publish them explicitly at
your application boundary:

```ts
import {
type DecisionCompletedEvent,
InMemoryDecisionEventPublisher,
} from '@rineex/decision-engine';

const publisher = new InMemoryDecisionEventPublisher();
publisher.subscribe<DecisionCompletedEvent>('decision.completed', {
handle: async event => {
console.log(event.payload.selectedCandidateRefs);
},
});

await publisher.publish(execution.events);
```

## Errors

Invalid definitions throw `InvalidDecisionDefinitionError`. Invalid runtime
input or failed component execution throws `DecisionExecutionError`. Both extend
`DecisionError` and expose a stable `code`, structured `details`, and the
original `cause` when available.
3 changes: 3 additions & 0 deletions packages/decision-engine/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import config from '@rineex/eslint-config/base';

export default config;
59 changes: 59 additions & 0 deletions packages/decision-engine/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@rineex/decision-engine",
"version": "0.1.0",
"description": "Framework-agnostic decision engine for Rineex applications",
"author": "Rineex Team",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist/**",
"README.md"
],
"sideEffects": false,
"scripts": {
"build": "tsup",
"check-types": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"test": "vitest run",
"test:watch": "vitest --watch"
},
"keywords": [
"rineex",
"decision-engine",
"rules",
"policy",
"decision-making"
],
"license": "Apache-2.0",
"publishConfig": {
"provenance": true,
"access": "public",
"registry": "https://registry.npmjs.org"
},
"repository": {
"type": "git",
"url": "https://github.com/rineex/core.git",
"directory": "packages/decision-engine"
},
"homepage": "https://github.com/rineex/core/tree/main/packages/decision-engine",
"packageManager": "pnpm@10.22.0",
"devDependencies": {
"@rineex/eslint-config": "workspace:*",
"@rineex/typescript-config": "workspace:*",
"@types/node": "24.10.4",
"tsup": "8.5.1",
"type-fest": "5.3.1",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.0.2",
"vitest": "4.0.16"
}
}
Loading
Loading