diff --git a/.changeset/brave-decisions-work.md b/.changeset/brave-decisions-work.md new file mode 100644 index 0000000..6f6bd64 --- /dev/null +++ b/.changeset/brave-decisions-work.md @@ -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. diff --git a/packages/ddd/package.json b/packages/ddd/package.json index 4b8eb4b..454d59a 100644 --- a/packages/ddd/package.json +++ b/packages/ddd/package.json @@ -14,6 +14,11 @@ "check-types": "tsc --noEmit", "build": "tsup" }, + "tsd": { + "compilerOptions": { + "strictNullChecks": true + } + }, "files": [ "dist/**" ], @@ -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", diff --git a/packages/ddd/tsconfig.json b/packages/ddd/tsconfig.json index c382492..6e9cc1f 100644 --- a/packages/ddd/tsconfig.json +++ b/packages/ddd/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "strict": false, + "types": ["node"], "module": "commonjs", "declaration": true, "noImplicitAny": false, @@ -11,7 +13,8 @@ "esModuleInterop": true, "experimentalDecorators": true, "target": "es6", - "moduleResolution": "node", + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", "skipLibCheck": true, "sourceMap": false, "outDir": "./dist", diff --git a/packages/decision-engine/README.md b/packages/decision-engine/README.md new file mode 100644 index 0000000..e011d40 --- /dev/null +++ b/packages/decision-engine/README.md @@ -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('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. diff --git a/packages/decision-engine/eslint.config.mjs b/packages/decision-engine/eslint.config.mjs new file mode 100644 index 0000000..7a8a273 --- /dev/null +++ b/packages/decision-engine/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from '@rineex/eslint-config/base'; + +export default config; diff --git a/packages/decision-engine/package.json b/packages/decision-engine/package.json new file mode 100644 index 0000000..22065cf --- /dev/null +++ b/packages/decision-engine/package.json @@ -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" + } +} diff --git a/packages/decision-engine/src/application/decision-engine.ts b/packages/decision-engine/src/application/decision-engine.ts new file mode 100644 index 0000000..d5d7f55 --- /dev/null +++ b/packages/decision-engine/src/application/decision-engine.ts @@ -0,0 +1,965 @@ +import type { CandidateRejectedEvent } from '../domain/event/candidate-rejected-event.js'; +import type { DecisionCompletedEvent } from '../domain/event/decision-completed-event.js'; +import type { CandidateEvaluation } from '../domain/model/candidate-evaluation.js'; +import type { DecisionDefinition } from '../domain/model/decision-definition.js'; +import type { DecisionEvent } from '../domain/model/decision-event.js'; +import type { DecisionExecution } from '../domain/model/decision-execution.js'; +import type { DecisionPolicy } from '../domain/model/decision-policy.js'; +import type { DecisionRequest } from '../domain/model/decision-request.js'; +import type { DecisionResult } from '../domain/model/decision-result.js'; +import type { DecisionValidator } from './decision-validator.js'; + +import { DecisionError } from '../domain/error/decision-error.js'; +import { DecisionExecutionError } from '../domain/error/decision-execution-error.js'; + +/** + * Executes reusable decision definitions against runtime decision inputs. + * + * The engine is intentionally: + * + * - framework-agnostic; + * - deterministic; + * - synchronous; + * - side-effect free; + * - infrastructure independent; + * - immutable from the caller's perspective. + * + * External I/O, persistence, event publication, logging, and telemetry must + * happen outside this class. + */ +export class DecisionEngine { + /** + * Performs framework-level validation before execution begins. + */ + private readonly validator: DecisionValidator; + + /** + * Creates a decision engine. + * + * @param validator - Validator responsible for validating decision + * definitions and runtime requests. + * + * @throws TypeError when no validator is supplied. + */ + public constructor(validator: DecisionValidator) { + if (!validator) { + throw new TypeError('Decision validator is required.'); + } + + this.validator = validator; + } + + /** + * Executes the complete decision pipeline. + * + * Pipeline: + * + * 1. Validate definition and request. + * 2. Validate candidate references. + * 3. Evaluate hard constraints. + * 4. Extract features for eligible candidates. + * 5. Normalize feature values. + * 6. Score eligible candidates. + * 7. Rank scored candidates. + * 8. Select final candidates. + * 9. Mark selected evaluations. + * 10. Build immutable result. + * 11. Produce immutable domain events. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Identifiable and versioned policy type. + * + * @param definition - Reusable decision algorithm definition. + * @param request - Runtime decision input. + * + * @returns Final decision result together with generated domain events. + * + * @throws InvalidDecisionDefinitionError when the definition is invalid. + * @throws DecisionExecutionError when the execution cannot complete safely. + */ + public execute( + definition: DecisionDefinition, + request: DecisionRequest, + ): DecisionExecution { + this.validator.validateDefinition(definition); + + this.validator.validateRequest(request); + + this.validateCandidateReferences(definition, request); + + const constrained = this.evaluateConstraints(definition, request); + const featured = this.evaluateFeatures(definition, request, constrained); + const normalized = this.normalizeFeatures(definition, featured); + const scored = this.scoreCandidates(definition, request, normalized); + const ranked = this.rankCandidates(definition, request, scored); + + const selected = this.selectCandidates(definition, request, ranked); + + const evaluations = this.markSelectedCandidates( + definition, + ranked, + selected, + ); + + const result = this.createDecisionResult(definition, request, evaluations); + + const events = this.createDecisionEvents( + definition, + request, + evaluations, + result, + ); + + const execution = { + result, + events, + }; + + this.freezeOutput(execution); + + return execution; + } + + private candidateRefs( + definition: DecisionDefinition, + evaluations: readonly CandidateEvaluation[], + ): Set { + return new Set( + evaluations.map(evaluation => + this.resolveCandidateRef(definition, evaluation.candidate), + ), + ); + } + + private correlation( + request: DecisionRequest, + ): Pick { + return request.correlationId === undefined + ? {} + : { correlationId: request.correlationId }; + } + + /** + * Creates domain events describing externally relevant facts produced + * during the decision execution. + * + * Event publication is intentionally not performed here. + */ + private createDecisionEvents< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + result: DecisionResult, + ): readonly DecisionEvent[] { + const rejectedEvents: CandidateRejectedEvent[] = evaluations + .filter(evaluation => !evaluation.eligible) + .map(evaluation => ({ + type: 'candidate.rejected', + definitionId: definition.id, + definitionVersion: definition.version, + ...this.correlation(request), + payload: { + candidateRef: this.resolveCandidateRef( + definition, + evaluation.candidate, + ), + failedConstraints: evaluation.constraints.filter( + constraint => !constraint.satisfied, + ), + }, + })); + const eligibleCount = evaluations.length - rejectedEvents.length; + + const completedEvent: DecisionCompletedEvent = { + type: 'decision.completed', + definitionId: definition.id, + definitionVersion: definition.version, + ...this.correlation(request), + payload: { + candidateCount: evaluations.length, + eligibleCount, + rejectedCount: evaluations.length - eligibleCount, + selectedCandidateRefs: result.selected.map(evaluation => + this.resolveCandidateRef(definition, evaluation.candidate), + ), + }, + }; + + return [...rejectedEvents, completedEvent]; + } + + /** + * Creates the canonical immutable decision result. + */ + private createDecisionResult< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + ): DecisionResult { + const selected = this.sortByRank( + evaluations.filter(evaluation => evaluation.selected), + ); + + return { + definitionId: definition.id, + definitionVersion: definition.version, + policyId: request.policy.id, + policyVersion: request.policy.version, + ...this.correlation(request), + selected, + evaluations, + }; + } + + /** + * Evaluates every configured hard constraint against every candidate. + * + * All constraints are evaluated even after one fails so the final + * evaluation contains the complete rejection explanation. + * + * @returns One initial evaluation per candidate. + */ + private evaluateConstraints< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + request: DecisionRequest, + ): readonly CandidateEvaluation[] { + return request.candidates.map(candidate => { + const candidateRef = this.resolveCandidateRef(definition, candidate); + + const constraints = definition.constraints.map(constraint => { + try { + const result = constraint.evaluate( + candidate, + request.context, + request.policy, + ); + + if (result.constraintId !== constraint.id) { + throw new DecisionExecutionError( + 'Constraint returned an unexpected constraint identifier.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'constraint', + componentId: constraint.id, + candidateRef, + returnedConstraintId: result.constraintId, + }, + ); + } + + return result; + } catch (error) { + return this.rethrowOrWrap(error, 'Constraint evaluation failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'constraint', + componentId: constraint.id, + candidateRef, + }); + } + }); + + const eligible = constraints.every(constraint => constraint.satisfied); + + return { + candidate, + constraints, + eligible, + features: [], + selected: false, + }; + }); + } + + /** + * Extracts raw feature values for eligible candidates. + * + * Rejected candidates are preserved unchanged and do not incur feature + * evaluation cost. + */ + private evaluateFeatures( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + return evaluations.map(evaluation => { + if (!evaluation.eligible) { + return evaluation; + } + + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + const features = definition.features.map(feature => { + try { + const value = feature.evaluate( + evaluation.candidate, + request.context, + request.policy, + ); + + if (value.key !== feature.key) { + throw new DecisionExecutionError( + 'Feature returned an unexpected feature key.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'feature', + componentId: feature.key, + candidateRef, + returnedFeatureKey: value.key, + }, + ); + } + + if (value.objective !== feature.objective) { + throw new DecisionExecutionError( + 'Feature returned an unexpected optimization objective.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'feature', + componentId: feature.key, + candidateRef, + }, + ); + } + + if (!Number.isFinite(value.rawValue)) { + throw new DecisionExecutionError( + 'Feature returned a non-finite raw value.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'feature', + componentId: feature.key, + candidateRef, + rawValue: value.rawValue, + }, + ); + } + + return value; + } catch (error) { + return this.rethrowOrWrap(error, 'Feature evaluation failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'feature', + componentId: feature.key, + candidateRef, + }); + } + }); + + return { + ...evaluation, + features, + }; + }); + } + + /** Freezes engine-owned output while leaving caller-owned candidates mutable. */ + private freezeOutput(value: unknown, seen = new WeakSet()): void { + if (!value || typeof value !== 'object' || seen.has(value)) { + return; + } + + seen.add(value); + for (const [key, nested] of Object.entries(value)) { + if (key !== 'candidate') { + this.freezeOutput(nested, seen); + } + } + Object.freeze(value); + } + + /** + * Produces the final immutable candidate evaluations by marking only + * candidates returned by the selection strategy as selected. + */ + private markSelectedCandidates< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + evaluations: readonly CandidateEvaluation[], + selected: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + const selectedRefs = this.candidateRefs(definition, selected); + + return evaluations.map(evaluation => { + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + return { + ...evaluation, + selected: evaluation.eligible && selectedRefs.has(candidateRef), + }; + }); + } + + /** + * Normalizes features across all eligible candidates. + * + * Rejected evaluations bypass normalization completely. + */ + private normalizeFeatures( + definition: DecisionDefinition, + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + const eligible = evaluations.filter(evaluation => evaluation.eligible); + + if (eligible.length === 0) { + return evaluations; + } + + let normalized: readonly CandidateEvaluation[]; + + try { + normalized = definition.normalizer.normalize(eligible); + } catch (error) { + this.rethrowOrWrap(error, 'Feature normalization failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + }); + } + + if (normalized.length !== eligible.length) { + throw new DecisionExecutionError( + 'Normalizer changed the number of eligible candidate evaluations.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + expectedCount: eligible.length, + actualCount: normalized.length, + }, + ); + } + + for (let index = 0; index < normalized.length; index += 1) { + const before = eligible[index]; + const after = normalized[index]; + + const beforeRef = this.resolveCandidateRef(definition, before.candidate); + + const afterRef = this.resolveCandidateRef(definition, after.candidate); + + if (beforeRef !== afterRef) { + throw new DecisionExecutionError( + 'Normalizer changed candidate ordering or candidate identity.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + expectedCandidateRef: beforeRef, + actualCandidateRef: afterRef, + }, + ); + } + + if (!after.eligible) { + throw new DecisionExecutionError( + 'Normalizer changed candidate eligibility.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + candidateRef: afterRef, + }, + ); + } + + if (after.features.length !== before.features.length) { + throw new DecisionExecutionError( + 'Normalizer changed the configured feature set.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + candidateRef: afterRef, + }, + ); + } + + this.validateNormalizedFeatures(definition, before, after, afterRef); + } + + let eligibleIndex = 0; + + return evaluations.map(evaluation => { + if (!evaluation.eligible) { + return evaluation; + } + + const result = normalized[eligibleIndex]; + + eligibleIndex += 1; + + return result; + }); + } + + /** + * Orders eligible candidates and assigns one-based ranks. + * + * The ranking strategy controls ordering only. The engine deliberately + * ignores any other mutations potentially returned by the strategy. + */ + private rankCandidates( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + const eligible = evaluations.filter(evaluation => evaluation.eligible); + + if (eligible.length === 0) { + return evaluations; + } + + let ranked: readonly CandidateEvaluation[]; + + try { + ranked = definition.rankingStrategy.rank( + eligible, + request.context, + request.policy, + ); + } catch (error) { + this.rethrowOrWrap(error, 'Candidate ranking failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'ranking', + componentId: definition.rankingStrategy.id, + }); + } + + if (ranked.length !== eligible.length) { + throw new DecisionExecutionError( + 'Ranking strategy changed the number of eligible candidates.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'ranking', + componentId: definition.rankingStrategy.id, + expectedCount: eligible.length, + actualCount: ranked.length, + }, + ); + } + + const originalByRef = new Map>(); + + for (const evaluation of eligible) { + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + originalByRef.set(candidateRef, evaluation); + } + + const rankedByRef = new Map>(); + + ranked.forEach((evaluation, index) => { + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + const original = originalByRef.get(candidateRef); + + if (!original) { + throw new DecisionExecutionError( + 'Ranking strategy returned an unknown candidate.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'ranking', + componentId: definition.rankingStrategy.id, + candidateRef, + }, + ); + } + + if (rankedByRef.has(candidateRef)) { + throw new DecisionExecutionError( + 'Ranking strategy returned the same candidate more than once.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'ranking', + componentId: definition.rankingStrategy.id, + candidateRef, + }, + ); + } + + rankedByRef.set(candidateRef, { + ...original, + rank: index + 1, + }); + }); + + return evaluations.map(evaluation => { + if (!evaluation.eligible) { + return evaluation; + } + + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + const rankedEvaluation = rankedByRef.get(candidateRef); + + if (!rankedEvaluation) { + throw new DecisionExecutionError( + 'Ranking strategy omitted an eligible candidate.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'ranking', + componentId: definition.rankingStrategy.id, + candidateRef, + }, + ); + } + + return rankedEvaluation; + }); + } + + /** + * Resolves a stable reference for one candidate and converts unexpected + * resolver failures into a decision execution failure. + */ + private resolveCandidateRef< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + candidate: Candidate, + ): string { + try { + const candidateRef = definition.candidateRefResolver.resolve(candidate); + + if (typeof candidateRef !== 'string') { + throw new DecisionExecutionError( + 'Candidate reference resolver returned a non-string value.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'candidate-reference', + }, + ); + } + + return candidateRef; + } catch (error) { + this.rethrowOrWrap(error, 'Candidate reference resolution failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'candidate-reference', + }); + } + } + + /** + * Preserves known decision errors while wrapping unexpected implementation + * failures with structured execution metadata. + * + * This prevents repeated wrapping of errors already expressed using the + * decision error model. + */ + private rethrowOrWrap( + error: unknown, + message: string, + details: Readonly>, + ): never { + if (error instanceof DecisionError) { + throw error; + } + + throw new DecisionExecutionError(message, details, error); + } + + /** + * Calculates a final numeric score for each eligible candidate. + */ + private scoreCandidates( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + return evaluations.map(evaluation => { + if (!evaluation.eligible) { + return evaluation; + } + + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + let score: number; + + try { + score = definition.scoringStrategy.score( + evaluation.features, + request.policy, + ); + } catch (error) { + this.rethrowOrWrap(error, 'Candidate scoring failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'scoring', + componentId: definition.scoringStrategy.id, + candidateRef, + }); + } + + if (!Number.isFinite(score)) { + throw new DecisionExecutionError( + 'Scoring strategy produced a non-finite score.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'scoring', + componentId: definition.scoringStrategy.id, + candidateRef, + score, + }, + ); + } + + return { + ...evaluation, + score, + }; + }); + } + + /** + * Invokes the configured selection strategy against candidates ordered + * by their assigned rank. + * + * @returns Validated selected candidate evaluations. + */ + private selectCandidates( + definition: DecisionDefinition, + request: DecisionRequest, + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + const ranked = this.sortByRank( + evaluations.filter( + (evaluation): evaluation is CandidateEvaluation => + evaluation.eligible && evaluation.rank !== undefined, + ), + ); + + let selected: readonly CandidateEvaluation[]; + + try { + selected = definition.selectionStrategy.select( + ranked, + request.context, + request.policy, + ); + } catch (error) { + this.rethrowOrWrap(error, 'Candidate selection failed.', { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'selection', + componentId: definition.selectionStrategy.id, + }); + } + + const rankedRefs = this.candidateRefs(definition, ranked); + + const selectedRefs = new Set(); + + for (const evaluation of selected) { + const candidateRef = this.resolveCandidateRef( + definition, + evaluation.candidate, + ); + + if (!rankedRefs.has(candidateRef)) { + throw new DecisionExecutionError( + 'Selection strategy returned a candidate outside the ranked set.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'selection', + componentId: definition.selectionStrategy.id, + candidateRef, + }, + ); + } + + if (selectedRefs.has(candidateRef)) { + throw new DecisionExecutionError( + 'Selection strategy returned the same candidate more than once.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'selection', + componentId: definition.selectionStrategy.id, + candidateRef, + }, + ); + } + + selectedRefs.add(candidateRef); + } + + /* + * Return the engine-owned ranked evaluations rather than trusting + * potentially modified evaluation objects returned by the strategy. + */ + return ranked.filter(evaluation => + selectedRefs.has( + this.resolveCandidateRef(definition, evaluation.candidate), + ), + ); + } + + private sortByRank( + evaluations: readonly CandidateEvaluation[], + ): CandidateEvaluation[] { + return [...evaluations].sort( + (left, right) => + (left.rank ?? Number.MAX_SAFE_INTEGER) - + (right.rank ?? Number.MAX_SAFE_INTEGER), + ); + } + + /** + * Ensures every candidate resolves to a valid and unique stable reference. + * + * Candidate references are required for event generation, diagnostics, + * selection validation, and deterministic association between pipeline stages. + * + * @param definition - Active decision definition. + * @param request - Runtime decision request. + * + * @throws DecisionExecutionError when a candidate reference is empty, + * duplicated, or cannot be resolved. + */ + private validateCandidateReferences< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + request: DecisionRequest, + ): void { + const references = new Set(); + + for (const candidate of request.candidates) { + const candidateRef = this.resolveCandidateRef(definition, candidate); + + if (candidateRef.trim().length === 0) { + throw new DecisionExecutionError( + 'Candidate reference must not be empty.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'candidate-reference', + }, + ); + } + + if (references.has(candidateRef)) { + throw new DecisionExecutionError( + 'Candidate references must be unique within one decision execution.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'candidate-reference', + candidateRef, + }, + ); + } + + references.add(candidateRef); + } + } + + private validateNormalizedFeatures< + Candidate, + Context, + Policy extends DecisionPolicy, + >( + definition: DecisionDefinition, + before: CandidateEvaluation, + after: CandidateEvaluation, + candidateRef: string, + ): void { + for (const [featureIndex, feature] of after.features.entries()) { + const originalFeature = before.features[featureIndex]; + if ( + !originalFeature || + feature.key !== originalFeature.key || + feature.objective !== originalFeature.objective || + feature.rawValue !== originalFeature.rawValue + ) { + throw new DecisionExecutionError( + 'Normalizer changed a feature identity or raw value.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + candidateRef, + featureKey: feature.key, + }, + ); + } + if ( + typeof feature.normalizedValue !== 'number' || + !Number.isFinite(feature.normalizedValue) + ) { + throw new DecisionExecutionError( + 'Normalizer must produce finite normalized feature values.', + { + definitionId: definition.id, + definitionVersion: definition.version, + stage: 'normalization', + componentId: definition.normalizer.id, + candidateRef, + featureKey: feature.key, + normalizedValue: feature.normalizedValue, + }, + ); + } + } + } +} diff --git a/packages/decision-engine/src/application/decision-validator.ts b/packages/decision-engine/src/application/decision-validator.ts new file mode 100644 index 0000000..f942301 --- /dev/null +++ b/packages/decision-engine/src/application/decision-validator.ts @@ -0,0 +1,29 @@ +import type { DecisionDefinition } from '../domain/model/decision-definition.js'; +import type { DecisionRequest } from '../domain/model/decision-request.js'; + +/** + * Validates decision definitions and execution requests before the + * decision pipeline begins. + * + * Validation must be deterministic and side-effect free. + */ +export interface DecisionValidator { + /** + * Validates a decision definition. + * + * @throws InvalidDecisionDefinitionError when the definition violates + * required structural invariants. + */ + validateDefinition: ( + definition: DecisionDefinition, + ) => void; + + /** + * Validates a decision execution request. + * + * @throws DecisionExecutionError when the request cannot be executed safely. + */ + validateRequest: ( + request: DecisionRequest, + ) => void; +} diff --git a/packages/decision-engine/src/application/default-decision-validator.ts b/packages/decision-engine/src/application/default-decision-validator.ts new file mode 100644 index 0000000..dc40394 --- /dev/null +++ b/packages/decision-engine/src/application/default-decision-validator.ts @@ -0,0 +1,181 @@ +import type { DecisionDefinition } from '../domain/model/decision-definition.js'; +import type { DecisionRequest } from '../domain/model/decision-request.js'; +import type { DecisionValidator } from './decision-validator.js'; + +import { DecisionExecutionError } from '../domain/error/decision-execution-error.js'; +import { InvalidDecisionDefinitionError } from '../domain/error/invalid-decision-definition-error.js'; +import { FeatureObjective } from '../domain/feature/feature-objective.js'; + +/** + * Default validator for decision definitions and execution requests. + * + * Performs framework-level validation only. + * Domain-specific validation remains the responsibility of policies, + * constraints, features, and strategies. + */ +export class DefaultDecisionValidator implements DecisionValidator { + /** + * Validates the reusable decision definition. + */ + public validateDefinition( + definition: DecisionDefinition, + ): void { + if (!definition || typeof definition !== 'object') { + throw new InvalidDecisionDefinitionError( + 'Decision definition is required.', + ); + } + + this.requireText(definition.id, 'definition id'); + this.requireText(definition.version, 'definition version'); + + this.validateCandidateRefResolver(definition.candidateRefResolver); + + this.validateComponents(definition.constraints, 'constraint', 'id'); + this.validateComponents(definition.features, 'feature', 'key'); + for (const feature of definition.features) { + if ( + feature.objective !== FeatureObjective.MAXIMIZE && + feature.objective !== FeatureObjective.MINIMIZE + ) { + throw new InvalidDecisionDefinitionError( + 'Feature objective must be maximize or minimize.', + { featureKey: feature.key, objective: feature.objective }, + ); + } + } + this.validateStrategy(definition.normalizer, 'normalizer', 'normalize'); + this.validateStrategy( + definition.scoringStrategy, + 'scoring strategy', + 'score', + ); + this.validateStrategy( + definition.rankingStrategy, + 'ranking strategy', + 'rank', + ); + this.validateStrategy( + definition.selectionStrategy, + 'selection strategy', + 'select', + ); + } + + /** + * Validates runtime decision input. + */ + public validateRequest( + request: DecisionRequest, + ): void { + if (!request || typeof request !== 'object') { + throw new DecisionExecutionError('Decision request is required.'); + } + + if (!Array.isArray(request.candidates)) { + throw new DecisionExecutionError( + 'Decision request candidates must be an array.', + ); + } + + if (!request.policy || typeof request.policy !== 'object') { + throw new DecisionExecutionError('Decision request policy is required.'); + } + + const policy = request.policy as Record; + this.requireRequestText(policy.id, 'policy id'); + this.requireRequestText(policy.version, 'policy version'); + + if (request.correlationId !== undefined) { + this.requireRequestText(request.correlationId, 'correlation id'); + } + } + + private requireRequestText( + value: unknown, + label: string, + ): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new DecisionExecutionError( + `Decision ${label} must be a non-empty string.`, + ); + } + } + + private requireText(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new InvalidDecisionDefinitionError( + `Decision ${label} must be a non-empty string.`, + ); + } + } + + private validateCandidateRefResolver(resolver: unknown): void { + if (!resolver || typeof resolver !== 'object') { + throw new InvalidDecisionDefinitionError( + 'A candidate reference resolver is required.', + ); + } + + if (typeof (resolver as Record).resolve !== 'function') { + throw new InvalidDecisionDefinitionError( + 'A candidate reference resolver is required.', + ); + } + } + + private validateComponents( + components: unknown, + componentName: string, + identifier: 'id' | 'key', + ): void { + if (!Array.isArray(components)) { + throw new InvalidDecisionDefinitionError( + `Decision ${componentName}s must be an array.`, + ); + } + + const identifiers = new Set(); + for (const component of components) { + if (!component || typeof component !== 'object') { + throw new InvalidDecisionDefinitionError( + `Each ${componentName} must be an object.`, + ); + } + const record = component as Record; + const value = record[identifier]; + this.requireText(value, `${componentName} ${identifier}`); + if (identifiers.has(value)) { + throw new InvalidDecisionDefinitionError( + `Decision ${componentName} ${identifier}s must be unique.`, + { value }, + ); + } + if (typeof record.evaluate !== 'function') { + throw new InvalidDecisionDefinitionError( + `Decision ${componentName} ${value} must implement evaluate.`, + ); + } + identifiers.add(value); + } + } + + private validateStrategy( + strategy: unknown, + label: string, + method: string, + ): void { + if (!strategy || typeof strategy !== 'object') { + throw new InvalidDecisionDefinitionError( + `Decision ${label} is required.`, + ); + } + const record = strategy as Record; + this.requireText(record.id, `${label} id`); + if (typeof record[method] !== 'function') { + throw new InvalidDecisionDefinitionError( + `Decision ${label} must implement ${method}.`, + ); + } + } +} diff --git a/packages/decision-engine/src/application/port/decision-event-publisher.ts b/packages/decision-engine/src/application/port/decision-event-publisher.ts new file mode 100644 index 0000000..4178226 --- /dev/null +++ b/packages/decision-engine/src/application/port/decision-event-publisher.ts @@ -0,0 +1,21 @@ +import type { DecisionEvent } from '@/domain/model/decision-event'; + +/** + * Publishes decision events produced by the decision engine. + * + * Implementations may deliver events in-process or through external + * infrastructure such as a message broker or transactional outbox. + */ +export interface DecisionEventPublisher { + /** + * Publishes events in the order supplied by the caller. + * + * @param events - Immutable decision events to publish. + * @returns Completion when all events have been processed according + * to the publisher's delivery semantics. + * + * @throws When event delivery fails and the implementation cannot + * satisfy its configured delivery guarantees. + */ + publish: (events: readonly DecisionEvent[]) => Promise; +} diff --git a/packages/decision-engine/src/decision-engine.spec.ts b/packages/decision-engine/src/decision-engine.spec.ts new file mode 100644 index 0000000..39a3d22 --- /dev/null +++ b/packages/decision-engine/src/decision-engine.spec.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from 'vitest'; + +import type { + CandidateEvaluation, + DecisionDefinition, + DecisionPolicy, + DecisionRequest, +} from './index.js'; + +import { + DecisionEngine, + DefaultDecisionValidator, + DescendingScoreRankingStrategy, + FeatureObjective, + InMemoryDecisionEventPublisher, + MinMaxNormalizer, + SelectAboveThresholdStrategy, + SelectFirstStrategy, + SelectTopNStrategy, + WeightedSumScoringStrategy, +} from './index.js'; + +interface Candidate { + readonly id: string; + readonly cost: number; + readonly quality: number; + readonly approved: boolean; +} + +interface Policy extends DecisionPolicy { + readonly values: { + readonly qualityWeight: number; + readonly costWeight: number; + }; +} + +const definition: DecisionDefinition = { + id: 'supplier-selection', + version: 'v1', + candidateRefResolver: { resolve: candidate => candidate.id }, + constraints: [ + { + id: 'approved', + evaluate: candidate => + candidate.approved + ? { constraintId: 'approved', satisfied: true } + : { + constraintId: 'approved', + satisfied: false, + reasonCode: 'NOT_APPROVED', + }, + }, + ], + features: [ + { + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + evaluate: candidate => ({ + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + rawValue: candidate.quality, + }), + }, + { + key: 'cost', + objective: FeatureObjective.MINIMIZE, + evaluate: candidate => ({ + key: 'cost', + objective: FeatureObjective.MINIMIZE, + rawValue: candidate.cost, + }), + }, + ], + normalizer: new MinMaxNormalizer(), + scoringStrategy: new WeightedSumScoringStrategy({ + resolve: (key, policy) => + key === 'quality' + ? policy.values.qualityWeight + : policy.values.costWeight, + }), + rankingStrategy: new DescendingScoreRankingStrategy(), + selectionStrategy: new SelectTopNStrategy(1), +}; + +const request: DecisionRequest = { + candidates: [ + { id: 'slow-cheap', quality: 50, cost: 10, approved: true }, + { id: 'fast-expensive', quality: 100, cost: 100, approved: true }, + { id: 'blocked', quality: 90, cost: 30, approved: false }, + ], + context: undefined, + policy: { + id: 'default', + version: 'v1', + values: { qualityWeight: 0.75, costWeight: 0.25 }, + }, + correlationId: 'decision-42', +}; + +describe('decisionEngine', () => { + it('executes constraints, normalization, scoring, ranking, selection, and events', () => { + const execution = new DecisionEngine( + new DefaultDecisionValidator(), + ).execute(definition, request); + + expect( + execution.result.selected.map(evaluation => evaluation.candidate.id), + ).toEqual(['fast-expensive']); + expect( + execution.result.evaluations.map(evaluation => ({ + id: evaluation.candidate.id, + score: evaluation.score, + rank: evaluation.rank, + selected: evaluation.selected, + })), + ).toEqual([ + { id: 'slow-cheap', score: 0.25, rank: 2, selected: false }, + { id: 'fast-expensive', score: 0.75, rank: 1, selected: true }, + { id: 'blocked', score: undefined, rank: undefined, selected: false }, + ]); + expect(execution.events).toEqual([ + expect.objectContaining({ + type: 'candidate.rejected', + correlationId: 'decision-42', + }), + expect.objectContaining({ + type: 'decision.completed', + payload: expect.objectContaining({ + candidateCount: 3, + eligibleCount: 2, + rejectedCount: 1, + selectedCandidateRefs: ['fast-expensive'], + }), + }), + ]); + expect(Object.isFrozen(execution)).toBe(true); + expect(Object.isFrozen(execution.result.evaluations)).toBe(true); + expect(Object.isFrozen(execution.result.evaluations[0].features)).toBe( + true, + ); + expect(Object.isFrozen(execution.events)).toBe(true); + }); + + it('rejects duplicate candidate references before evaluating a candidate', () => { + const duplicated = { + ...request, + candidates: [request.candidates[0], { ...request.candidates[0] }], + }; + + expect(() => + new DecisionEngine(new DefaultDecisionValidator()).execute( + definition, + duplicated, + ), + ).toThrow('Candidate references must be unique'); + }); + + it('rejects blank candidate references and malformed normalizer output', () => { + const engine = new DecisionEngine(new DefaultDecisionValidator()); + const blankReference = { + ...definition, + candidateRefResolver: { resolve: () => ' ' }, + }; + const badNormalizer = { + ...definition, + normalizer: { + id: 'bad-normalizer', + normalize: (evaluations: readonly CandidateEvaluation[]) => + evaluations.map(evaluation => ({ ...evaluation, features: [] })), + }, + }; + + expect(() => engine.execute(blankReference, request)).toThrow( + 'Candidate reference must not be empty', + ); + expect(() => engine.execute(badNormalizer, request)).toThrow( + 'Normalizer changed the configured feature set', + ); + }); + + it('wraps invalid feature, score, and selection strategy output', () => { + const engine = new DecisionEngine(new DefaultDecisionValidator()); + const nonFiniteFeature = { + ...definition, + features: [ + { + ...definition.features[0], + evaluate: () => ({ + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + rawValue: Number.POSITIVE_INFINITY, + }), + }, + ], + }; + const nonFiniteScore = { + ...definition, + scoringStrategy: { id: 'bad-score', score: () => Number.NaN }, + }; + const unknownSelection = { + ...definition, + selectionStrategy: { + id: 'unknown-selection', + select: () => [ + { ...emptyEvaluation({ ...request.candidates[0], id: 'unknown' }) }, + ], + }, + }; + + expect(() => engine.execute(nonFiniteFeature, request)).toThrow( + 'Feature returned a non-finite raw value', + ); + expect(() => engine.execute(nonFiniteScore, request)).toThrow( + 'Scoring strategy produced a non-finite score', + ); + expect(() => engine.execute(unknownSelection, request)).toThrow( + 'Selection strategy returned a candidate outside the ranked set', + ); + }); + + it('normalizes a zero-range feature to one without mutating source evaluations', () => { + const evaluations: CandidateEvaluation[] = [ + { + candidate: request.candidates[0], + constraints: [], + eligible: true, + selected: false, + features: [ + { + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + rawValue: 10, + }, + ], + }, + { + candidate: request.candidates[1], + constraints: [], + eligible: true, + selected: false, + features: [ + { + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + rawValue: 10, + }, + ], + }, + ]; + const normalized = new MinMaxNormalizer().normalize(evaluations); + + expect( + normalized.map(evaluation => evaluation.features[0].normalizedValue), + ).toEqual([1, 1]); + expect(evaluations[0].features[0].normalizedValue).toBeUndefined(); + }); + + it('keeps score ties in input order and rejects invalid built-in strategy inputs', () => { + const ranked = new DescendingScoreRankingStrategy< + Candidate, + undefined, + Policy + >().rank( + [ + { ...emptyEvaluation(request.candidates[0]), score: 1 }, + { ...emptyEvaluation(request.candidates[1]), score: 1 }, + ], + undefined, + request.policy, + ); + + expect(ranked.map(evaluation => evaluation.candidate.id)).toEqual([ + 'slow-cheap', + 'fast-expensive', + ]); + expect(() => new SelectTopNStrategy(0)).toThrow('positive integer'); + expect(() => new SelectAboveThresholdStrategy(Number.NaN)).toThrow( + 'finite', + ); + expect(() => + new SelectFirstStrategy().select( + [{ ...emptyEvaluation(request.candidates[0]), rank: 1 }], + undefined, + request.policy, + ), + ).toThrow('Selection requires eligible'); + expect(() => + new WeightedSumScoringStrategy({ + resolve: () => Number.POSITIVE_INFINITY, + }).score( + [ + { + key: 'quality', + objective: FeatureObjective.MAXIMIZE, + rawValue: 1, + normalizedValue: 1, + }, + ], + request.policy, + ), + ).toThrow('Feature weight must be finite'); + }); +}); + +describe('inMemoryDecisionEventPublisher', () => { + it('delivers matching events and handlers in registration order', async () => { + const publisher = new InMemoryDecisionEventPublisher(); + const calls: string[] = []; + publisher.subscribe('decision.completed', { + handle: async () => { + calls.push('first'); + }, + }); + publisher.subscribe('decision.completed', { + handle: async () => { + calls.push('second'); + }, + }); + + await publisher.publish([ + { + type: 'decision.completed', + definitionId: 'definition', + definitionVersion: 'v1', + payload: {}, + }, + ]); + + expect(calls).toEqual(['first', 'second']); + }); + + it('preserves event order across event types', async () => { + const publisher = new InMemoryDecisionEventPublisher(); + const calls: string[] = []; + publisher.subscribe('candidate.rejected', { + handle: async () => { + calls.push('rejected'); + }, + }); + publisher.subscribe('decision.completed', { + handle: async () => { + calls.push('completed'); + }, + }); + + await publisher.publish([ + { + type: 'candidate.rejected', + definitionId: 'definition', + definitionVersion: 'v1', + payload: { candidateRef: 'candidate', failedConstraints: [] }, + }, + completedEvent(), + ]); + + expect(calls).toEqual(['rejected', 'completed']); + }); + + it('rejects blank subscriptions and propagates handler failures', async () => { + const publisher = new InMemoryDecisionEventPublisher(); + + expect(() => + publisher.subscribe(' ', { handle: async () => undefined }), + ).toThrow('non-empty string'); + + publisher.subscribe('decision.completed', { + handle: async () => { + throw new Error('delivery failed'); + }, + }); + + await expect(publisher.publish([completedEvent()])).rejects.toThrow( + 'delivery failed', + ); + }); +}); + +function completedEvent() { + return { + type: 'decision.completed' as const, + definitionId: 'definition', + definitionVersion: 'v1', + payload: {}, + }; +} + +function emptyEvaluation(candidate: Candidate): CandidateEvaluation { + return { + candidate, + constraints: [], + eligible: true, + features: [], + selected: false, + }; +} diff --git a/packages/decision-engine/src/decision-engine.ts b/packages/decision-engine/src/decision-engine.ts new file mode 100644 index 0000000..134273f --- /dev/null +++ b/packages/decision-engine/src/decision-engine.ts @@ -0,0 +1,31 @@ +/** Options used to identify a decision-engine module instance. */ +export interface DecisionEngineOptions { + /** A human-readable name for this initialized instance. */ + readonly name?: string; + + /** The application version associated with this initialized instance. */ + readonly version?: string; +} + +/** A ready-to-compose decision-engine module. */ +export interface DecisionEngineModule { + readonly name: string; + readonly version?: string; +} + +const defaultName = 'decision-engine'; + +/** + * Initializes a framework-independent decision-engine module. + * + * The returned descriptor is intentionally immutable so it can safely be + * shared as the composition root grows to include rules and evaluators. + */ +export function initDecisionEngine( + options: DecisionEngineOptions = {}, +): DecisionEngineModule { + const name = options.name?.trim() || defaultName; + const version = options.version?.trim() || undefined; + + return Object.freeze({ name, version }); +} diff --git a/packages/decision-engine/src/domain/candidate/candidate-ref-resolver.ts b/packages/decision-engine/src/domain/candidate/candidate-ref-resolver.ts new file mode 100644 index 0000000..aaf3bf7 --- /dev/null +++ b/packages/decision-engine/src/domain/candidate/candidate-ref-resolver.ts @@ -0,0 +1,25 @@ +/** + * Resolves a stable reference for a decision candidate. + * + * The resolver must be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate type. + */ +export interface CandidateRefResolver { + /** + * Resolves a stable reference for the supplied candidate. + * + * The returned value should uniquely identify the candidate + * within the scope of the decision domain. + * + * Examples: + * supplier.id + * machine.code + * route.key + * composite business identifier + * + * @param candidate - Candidate whose reference should be resolved. + * @returns Stable candidate reference. + */ + resolve: (candidate: Candidate) => string; +} diff --git a/packages/decision-engine/src/domain/constraint/constraint-result.ts b/packages/decision-engine/src/domain/constraint/constraint-result.ts new file mode 100644 index 0000000..ff2dda6 --- /dev/null +++ b/packages/decision-engine/src/domain/constraint/constraint-result.ts @@ -0,0 +1,38 @@ +export type ConstraintResult = + | { + /** + * Stable identifier of the constraint that produced this result. + */ + readonly constraintId: string; + + /** + * Indicates that the candidate failed the constraint. + */ + readonly satisfied: false; + + /** + * Stable machine-readable explanation of the rejection. + */ + readonly reasonCode: string; + + /** + * Optional diagnostic metadata. + */ + readonly details?: Readonly>; + } + | { + /** + * Stable identifier of the constraint that produced this result. + */ + readonly constraintId: string; + + /** + * Indicates that the candidate satisfied the constraint. + */ + readonly satisfied: true; + + /** + * Optional diagnostic metadata. + */ + readonly details?: Readonly>; + }; diff --git a/packages/decision-engine/src/domain/constraint/constraint.ts b/packages/decision-engine/src/domain/constraint/constraint.ts new file mode 100644 index 0000000..97d2ca1 --- /dev/null +++ b/packages/decision-engine/src/domain/constraint/constraint.ts @@ -0,0 +1,38 @@ +import type { ConstraintResult } from './constraint-result.js'; + +/** + * Defines a mandatory eligibility rule for a decision candidate. + * + * Constraints are expected to be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate being evaluated. + * @typeParam Context - Runtime facts available during evaluation. + * @typeParam Policy - Business policy configuration used by the rule. + */ +export interface Constraint { + /** + * Stable identifier of the constraint. + * + * Used for diagnostics, audit records, and explainability. + * + * Example: + * "minimum-quality" + */ + readonly id: string; + + /** + * Evaluates whether the candidate satisfies this constraint. + * + * Implementations must not mutate the candidate, context, or policy. + * + * @param candidate - Candidate currently being evaluated. + * @param context - Runtime facts for the current decision execution. + * @param policy - Active business policy. + * @returns Structured eligibility result. + */ + evaluate: ( + candidate: Candidate, + context: Context, + policy: Policy, + ) => ConstraintResult; +} diff --git a/packages/decision-engine/src/domain/error/decision-error.ts b/packages/decision-engine/src/domain/error/decision-error.ts new file mode 100644 index 0000000..3bd8377 --- /dev/null +++ b/packages/decision-engine/src/domain/error/decision-error.ts @@ -0,0 +1,39 @@ +/** + * Base error for failures produced by the decision engine or its components. + * + * Decision errors represent invalid configuration, invalid execution input, + * or failures while evaluating decision components. + */ +export abstract class DecisionError extends Error { + /** + * Stable machine-readable error code. + * + * Consumers should prefer this value over matching error messages. + */ + public abstract readonly code: string; + + /** + * Optional structured diagnostic metadata. + * + * Metadata should contain serializable values only and must not contain + * services, functions, or mutable infrastructure objects. + */ + public readonly details?: Readonly>; + + /** + * Creates a decision error. + * + * @param message - Human-readable diagnostic message. + * @param details - Optional structured metadata describing the failure. + * @param cause - Optional original error that caused this failure. + */ + protected constructor( + message: string, + details?: Readonly>, + cause?: unknown, + ) { + super(message, cause === undefined ? undefined : { cause }); + this.name = new.target.name; + this.details = details; + } +} diff --git a/packages/decision-engine/src/domain/error/decision-execution-error.ts b/packages/decision-engine/src/domain/error/decision-execution-error.ts new file mode 100644 index 0000000..70762d1 --- /dev/null +++ b/packages/decision-engine/src/domain/error/decision-execution-error.ts @@ -0,0 +1,26 @@ +import { DecisionError } from './decision-error.js'; + +/** + * Raised when a decision execution fails after validation has succeeded. + */ +export class DecisionExecutionError extends DecisionError { + /** + * Stable machine-readable error code. + */ + public readonly code = 'DECISION_EXECUTION_FAILED'; + + /** + * Creates a decision execution error. + * + * @param message - Human-readable description of the execution failure. + * @param details - Structured metadata identifying the failed execution stage. + * @param cause - Original underlying error. + */ + public constructor( + message: string, + details?: Readonly>, + cause?: unknown, + ) { + super(message, details, cause); + } +} diff --git a/packages/decision-engine/src/domain/error/invalid-decision-definition-error.ts b/packages/decision-engine/src/domain/error/invalid-decision-definition-error.ts new file mode 100644 index 0000000..e7e5c32 --- /dev/null +++ b/packages/decision-engine/src/domain/error/invalid-decision-definition-error.ts @@ -0,0 +1,27 @@ +import { DecisionError } from './decision-error.js'; + +/** + * Raised when a decision definition violates required structural + * or configuration invariants. + */ +export class InvalidDecisionDefinitionError extends DecisionError { + /** + * Stable machine-readable error code. + */ + public readonly code = 'DECISION_DEFINITION_INVALID'; + + /** + * Creates an invalid decision definition error. + * + * @param message - Human-readable description of the validation failure. + * @param details - Structured metadata identifying the invalid definition. + * @param cause - Optional underlying error. + */ + public constructor( + message: string, + details?: Readonly>, + cause?: unknown, + ) { + super(message, details, cause); + } +} diff --git a/packages/decision-engine/src/domain/event/candidate-rejected-event.ts b/packages/decision-engine/src/domain/event/candidate-rejected-event.ts new file mode 100644 index 0000000..03c6ea5 --- /dev/null +++ b/packages/decision-engine/src/domain/event/candidate-rejected-event.ts @@ -0,0 +1,31 @@ +import type { ConstraintResult } from '../constraint/constraint-result.js'; +import type { DecisionEvent } from '../model/decision-event.js'; + +/** + * Payload emitted when a candidate is rejected during constraint evaluation. + */ +export interface CandidateRejectedEventPayload { + /** + * Stable external or domain-level reference for the rejected candidate. + * + * The decision framework does not define how candidate identity is represented. + */ + readonly candidateRef: string; + + /** + * Constraint results responsible for the rejection. + * + * Normally this collection contains only failed constraints. + */ + readonly failedConstraints: readonly ConstraintResult[]; +} + +/** + * Domain event representing a candidate rejection. + */ +export interface CandidateRejectedEvent extends DecisionEvent { + /** + * Stable event type used by subscribers and serializers. + */ + readonly type: 'candidate.rejected'; +} diff --git a/packages/decision-engine/src/domain/event/decision-completed-event.ts b/packages/decision-engine/src/domain/event/decision-completed-event.ts new file mode 100644 index 0000000..9e68829 --- /dev/null +++ b/packages/decision-engine/src/domain/event/decision-completed-event.ts @@ -0,0 +1,16 @@ +import type { DecisionEvent } from '../model/decision-event.js'; + +/** + * Payload emitted once a decision execution has completed. + */ +export interface DecisionCompletedEventPayload { + readonly candidateCount: number; + readonly eligibleCount: number; + readonly rejectedCount: number; + readonly selectedCandidateRefs: readonly string[]; +} + +/** Domain event representing completion of one decision execution. */ +export interface DecisionCompletedEvent extends DecisionEvent { + readonly type: 'decision.completed'; +} diff --git a/packages/decision-engine/src/domain/feature/feature-objective.ts b/packages/decision-engine/src/domain/feature/feature-objective.ts new file mode 100644 index 0000000..aad924c --- /dev/null +++ b/packages/decision-engine/src/domain/feature/feature-objective.ts @@ -0,0 +1,29 @@ +/** + * Defines whether a feature should be optimized upward or downward. + * + * This information is used by normalization strategies to convert + * raw feature values into comparable scores. + */ +export const FeatureObjective = { + /** + * Higher values represent better outcomes. + * + * Examples: + * - quality + * - reliability + * - performance + */ + MAXIMIZE: 'maximize', + /** + * Lower values represent better outcomes. + * + * Examples: + * - cost + * - latency + * - delivery time + */ + MINIMIZE: 'minimize', +} as const; + +export type FeatureObjective = + (typeof FeatureObjective)[keyof typeof FeatureObjective]; diff --git a/packages/decision-engine/src/domain/feature/feature-value.ts b/packages/decision-engine/src/domain/feature/feature-value.ts new file mode 100644 index 0000000..b297da8 --- /dev/null +++ b/packages/decision-engine/src/domain/feature/feature-value.ts @@ -0,0 +1,43 @@ +import type { FeatureObjective } from './feature-objective.js'; + +/** + * Represents a calculated value produced by a feature evaluation. + * + * A feature value contains the raw measurement. + * Normalization and scoring should not modify this object. + */ +export interface FeatureValue { + /** + * Stable identifier of the feature that produced this value. + * + * Example: + * "delivery-time" + */ + readonly key: string; + + /** Optimization direction declared by the feature that produced this value. */ + readonly objective: FeatureObjective; + + /** + * Raw numeric value produced by the feature. + * + * This value represents the original measurement before + * normalization or scoring. + * + * Examples: + * 5 days + * 120 km + * 0.92 reliability score + */ + readonly rawValue: number; + + /** + * Optional normalized value. + * + * This should only be populated by a normalization step. + * + * Typical range: + * 0..1 + */ + readonly normalizedValue?: number; +} diff --git a/packages/decision-engine/src/domain/feature/feature.ts b/packages/decision-engine/src/domain/feature/feature.ts new file mode 100644 index 0000000..d178245 --- /dev/null +++ b/packages/decision-engine/src/domain/feature/feature.ts @@ -0,0 +1,54 @@ +import type { FeatureObjective } from './feature-objective'; +import type { FeatureValue } from './feature-value'; + +/** + * Defines a measurable characteristic extracted from a candidate. + * + * Features must be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime facts available during evaluation. + * @typeParam Policy - Business policy configuration. + */ +export interface Feature { + /** + * Stable identifier of this feature. + * + * Used for: + * - audit records + * - debugging + * - feature lookup + * - scoring configuration + * + * Example: + * "delivery-time" + */ + readonly key: string; + + /** + * Defines whether higher or lower values are preferred. + * + * Used by normalization strategies. + */ + readonly objective: FeatureObjective; + + /** + * Calculates the raw feature value for a candidate. + * + * The implementation must: + * - not mutate candidate/context/policy + * - not perform external I/O + * - return deterministic output + * + * @param candidate Candidate being evaluated. + * @param context Runtime decision context. + * @param policy Active decision policy. + * + * @returns Numeric feature measurement. + */ + evaluate: ( + candidate: Candidate, + context: Context, + policy: Policy, + ) => FeatureValue; +} diff --git a/packages/decision-engine/src/domain/model/candidate-evaluation.ts b/packages/decision-engine/src/domain/model/candidate-evaluation.ts new file mode 100644 index 0000000..0feebfe --- /dev/null +++ b/packages/decision-engine/src/domain/model/candidate-evaluation.ts @@ -0,0 +1,64 @@ +import type { ConstraintResult } from '../constraint/constraint-result.js'; +import type { FeatureValue } from '../feature/feature-value.js'; + +/** + * Represents the complete evaluation outcome of a single candidate. + * + * The engine creates a new immutable evaluation as the candidate progresses + * through constraint evaluation, feature extraction, normalization, scoring, + * ranking, and selection. + * + * @typeParam Candidate - Domain-specific candidate type. + */ +export interface CandidateEvaluation { + /** + * Original candidate supplied to the decision engine. + * + * The candidate must never be mutated by the engine. + */ + readonly candidate: Candidate; + + /** + * Results produced by all evaluated constraints. + * + * These results provide the explanation for candidate eligibility. + */ + readonly constraints: readonly ConstraintResult[]; + + /** + * Indicates whether the candidate passed all mandatory constraints. + * + * Rejected candidates must not proceed to feature evaluation, + * normalization, scoring, ranking, or selection. + */ + readonly eligible: boolean; + + /** + * Feature measurements calculated for the candidate. + * + * Rejected candidates normally contain an empty collection. + */ + readonly features: readonly FeatureValue[]; + + /** + * Final score produced by the configured scoring strategy. + * + * Undefined when the candidate was rejected or scoring has not yet occurred. + */ + readonly score?: number; + + /** + * Final one-based ranking position. + * + * Undefined when the candidate is ineligible or ranking has not yet occurred. + */ + readonly rank?: number; + + /** + * Indicates whether the candidate was selected by the final + * selection strategy. + * + * This must be false for rejected candidates. + */ + readonly selected: boolean; +} diff --git a/packages/decision-engine/src/domain/model/decision-context.ts b/packages/decision-engine/src/domain/model/decision-context.ts new file mode 100644 index 0000000..b65095a --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-context.ts @@ -0,0 +1,10 @@ +/** + * Represents runtime facts available during a decision execution. + * + * A decision context should contain only immutable input data required by + * constraints, features, and decision strategies. + * + * It must not contain infrastructure dependencies such as repositories, + * network clients, loggers, clocks, or event publishers. + */ +export type DecisionContext = Readonly>; diff --git a/packages/decision-engine/src/domain/model/decision-definition.ts b/packages/decision-engine/src/domain/model/decision-definition.ts new file mode 100644 index 0000000..294ac00 --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-definition.ts @@ -0,0 +1,76 @@ +import type { CandidateRefResolver } from '../candidate/candidate-ref-resolver'; +import type { Constraint } from '../constraint/constraint'; +import type { Feature } from '../feature/feature'; +import type { Normalizer } from '../normalization/normalizer'; +import type { RankingStrategy } from '../ranking/ranking-strategy'; +import type { ScoringStrategy } from '../scoring/scoring-strategy'; +import type { SelectionStrategy } from '../selection/selection-strategy'; + +/** + * Defines the complete configuration required to execute a decision. + * + * A decision definition is immutable and reusable across multiple executions. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Business policy type. + */ +export interface DecisionDefinition { + /** + * Stable identifier of the decision definition. + * + * Example: + * "supplier-selection" + */ + readonly id: string; + + /** + * Version of the decision definition. + * + * Increment this when changes to constraints, features, or strategies + * can change decision outcomes. + * + * Example: + * "v2" + */ + readonly version: string; + + /** Resolves the stable reference used for diagnostics and events. */ + readonly candidateRefResolver: CandidateRefResolver; + + /** + * Hard eligibility rules applied to every candidate. + * + * A candidate must satisfy all configured constraints + * before feature evaluation and scoring. + */ + readonly constraints: readonly Constraint[]; + + /** + * Features extracted from eligible candidates. + * + * Feature keys must be unique within this definition. + */ + readonly features: readonly Feature[]; + + /** + * Strategy responsible for transforming raw feature values + * into comparable normalized values. + */ + readonly normalizer: Normalizer; + + /** + * Strategy responsible for calculating candidate scores. + */ + readonly scoringStrategy: ScoringStrategy; + + /** + * Strategy responsible for ordering scored candidates. + */ + readonly rankingStrategy: RankingStrategy; + + /** + * Strategy responsible for choosing the final candidate or candidates. + */ + readonly selectionStrategy: SelectionStrategy; +} diff --git a/packages/decision-engine/src/domain/model/decision-event.ts b/packages/decision-engine/src/domain/model/decision-event.ts new file mode 100644 index 0000000..fcd2cfd --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-event.ts @@ -0,0 +1,40 @@ +/** + * Base contract for events produced by the decision domain. + * + * Events describe facts that already happened during a decision execution. + * + * @typeParam Payload - Immutable event-specific payload. + */ +export interface DecisionEvent { + /** + * Stable event type. + * + * Examples: + * "decision.completed" + * "candidate.rejected" + */ + readonly type: string; + + /** + * Identifier of the decision definition that produced this event. + */ + readonly definitionId: string; + + /** + * Version of the decision definition used during execution. + */ + readonly definitionVersion: string; + + /** + * Optional correlation identifier inherited from the decision request. + */ + readonly correlationId?: string; + + /** + * Immutable event-specific data. + * + * Payload must be serializable and must not contain services, + * functions, mutable state, or infrastructure-specific objects. + */ + readonly payload: Payload; +} diff --git a/packages/decision-engine/src/domain/model/decision-execution.ts b/packages/decision-engine/src/domain/model/decision-execution.ts new file mode 100644 index 0000000..bec0fd8 --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-execution.ts @@ -0,0 +1,25 @@ +import type { DecisionResult } from '../../domain/model/decision-result.js'; +import type { DecisionEvent } from './decision-event.js'; + +/** + * Represents the complete output of a decision engine execution. + * + * It contains the final business result together with domain events that + * describe relevant facts produced during the execution. + * + * @typeParam Candidate - Domain-specific candidate type. + */ +export interface DecisionExecution { + /** + * Final immutable decision outcome. + */ + readonly result: DecisionResult; + + /** + * Domain events produced during the execution. + * + * The decision engine only creates these events. + * Publishing is handled separately by the application layer. + */ + readonly events: readonly DecisionEvent[]; +} diff --git a/packages/decision-engine/src/domain/model/decision-policy.ts b/packages/decision-engine/src/domain/model/decision-policy.ts new file mode 100644 index 0000000..050967e --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-policy.ts @@ -0,0 +1,32 @@ +/** + * Represents configurable business rules and preferences + * used during a decision execution. + * + * Policies must be versioned when changes affect decision outcomes, + * allowing historical decisions to be reproduced. + */ +export interface DecisionPolicy { + /** + * Stable identifier of the policy. + * + * Example: + * "supplier-selection" + */ + readonly id: string; + + /** + * Version of the policy configuration. + * + * Example: + * "v3" + */ + readonly version: string; + + /** + * Policy-specific configuration. + * + * The decision framework does not interpret this data. + * Individual constraints, features, and strategies consume it. + */ + readonly values: Readonly>; +} diff --git a/packages/decision-engine/src/domain/model/decision-request.ts b/packages/decision-engine/src/domain/model/decision-request.ts new file mode 100644 index 0000000..8fac42c --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-request.ts @@ -0,0 +1,41 @@ +/** + * Represents all runtime inputs required to execute a decision. + * + * The request is immutable and must not contain infrastructure dependencies. + * + * @typeParam Candidate - Domain-specific candidate type being evaluated. + * @typeParam Context - Runtime facts used during evaluation. + * @typeParam Policy - Business policy configuration used by constraints and strategies. + */ +export interface DecisionRequest { + /** + * Candidates available for evaluation. + * + * The engine must treat this collection as immutable. + */ + readonly candidates: readonly Candidate[]; + + /** + * Runtime facts available during this decision execution. + * + * Examples: + * current time, + * requested quantity, + * destination, + * operational state. + */ + readonly context: Context; + + /** + * Business policy configuration applied to this decision. + */ + readonly policy: Policy; + + /** + * Optional identifier used to correlate this decision with + * an external request, workflow, trace, command, or transaction. + * + * The decision engine must not generate this value implicitly. + */ + readonly correlationId?: string; +} diff --git a/packages/decision-engine/src/domain/model/decision-result.ts b/packages/decision-engine/src/domain/model/decision-result.ts new file mode 100644 index 0000000..613690e --- /dev/null +++ b/packages/decision-engine/src/domain/model/decision-result.ts @@ -0,0 +1,51 @@ +import type { CandidateEvaluation } from './candidate-evaluation.js'; + +/** + * Represents the final immutable outcome of a decision execution. + * + * @typeParam Candidate - Domain-specific candidate type. + */ +export interface DecisionResult { + /** + * Stable identifier of the decision definition that produced this result. + */ + readonly definitionId: string; + + /** + * Exact version of the decision definition used for execution. + */ + readonly definitionVersion: string; + + /** + * Stable identifier of the policy used during execution. + */ + readonly policyId: string; + + /** + * Exact policy version used during execution. + */ + readonly policyVersion: string; + + /** + * Optional correlation identifier inherited from the request. + * + * Used to associate the decision with an external command, request, + * workflow, trace, or business process. + */ + readonly correlationId?: string; + + /** + * Final selected candidate evaluations. + * + * The collection may legitimately be empty. + */ + readonly selected: readonly CandidateEvaluation[]; + + /** + * Complete evaluations for every candidate supplied to the engine. + * + * This collection is the canonical explanation of how the final + * decision was produced. + */ + readonly evaluations: readonly CandidateEvaluation[]; +} diff --git a/packages/decision-engine/src/domain/model/index.ts b/packages/decision-engine/src/domain/model/index.ts new file mode 100644 index 0000000..b19c96d --- /dev/null +++ b/packages/decision-engine/src/domain/model/index.ts @@ -0,0 +1,2 @@ +export * from './decision-context'; +export * from './decision-policy'; diff --git a/packages/decision-engine/src/domain/normalization/min-max-normalizer.ts b/packages/decision-engine/src/domain/normalization/min-max-normalizer.ts new file mode 100644 index 0000000..eebce75 --- /dev/null +++ b/packages/decision-engine/src/domain/normalization/min-max-normalizer.ts @@ -0,0 +1,96 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; +import type { Normalizer } from './normalizer.js'; + +import { DecisionExecutionError } from '../error/decision-execution-error.js'; + +/** + * Normalizes feature values into the range [0, 1] using min-max normalization. + * + * Feature objectives are respected so that higher normalized values + * always represent more desirable outcomes. + */ +export class MinMaxNormalizer implements Normalizer { + /** + * Stable identifier of this normalization strategy. + */ + public readonly id = 'min-max'; + + /** + * Normalizes raw feature values across all eligible candidates. + * + * @param evaluations - Eligible candidate evaluations containing + * raw feature values. + * + * @returns New immutable evaluations with normalized feature values. + * + * @throws DecisionExecutionError when feature values are invalid, + * inconsistent, or cannot be normalized safely. + */ + public normalize( + evaluations: readonly CandidateEvaluation[], + ): readonly CandidateEvaluation[] { + if (evaluations.length === 0) { + return []; + } + + const expectedFeatures = evaluations[0].features; + const valuesByKey = new Map(); + + for (const evaluation of evaluations) { + if (!evaluation.eligible) { + throw new DecisionExecutionError( + 'Min-max normalization accepts eligible evaluations only.', + ); + } + if (evaluation.features.length !== expectedFeatures.length) { + throw new DecisionExecutionError( + 'Eligible evaluations must expose identical feature sets.', + ); + } + evaluation.features.forEach((feature, index) => { + const expected = expectedFeatures[index]; + if ( + !expected || + feature.key !== expected.key || + feature.objective !== expected.objective + ) { + throw new DecisionExecutionError( + 'Eligible evaluations must expose identically ordered feature keys and objectives.', + ); + } + if (!Number.isFinite(feature.rawValue)) { + throw new DecisionExecutionError( + 'Feature raw values must be finite.', + { featureKey: feature.key }, + ); + } + const values = valuesByKey.get(feature.key) ?? []; + values.push(feature.rawValue); + valuesByKey.set(feature.key, values); + }); + } + + return evaluations.map(evaluation => ({ + ...evaluation, + features: evaluation.features.map(feature => { + const values = valuesByKey.get(feature.key); + if (!values) { + throw new DecisionExecutionError( + 'Feature values are missing during normalization.', + { featureKey: feature.key }, + ); + } + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const range = maximum - minimum; + const normalizedValue = + range === 0 + ? 1 + : feature.objective === 'maximize' + ? (feature.rawValue - minimum) / range + : (maximum - feature.rawValue) / range; + return { ...feature, normalizedValue }; + }), + })); + } +} diff --git a/packages/decision-engine/src/domain/normalization/normalizer.ts b/packages/decision-engine/src/domain/normalization/normalizer.ts new file mode 100644 index 0000000..129bd16 --- /dev/null +++ b/packages/decision-engine/src/domain/normalization/normalizer.ts @@ -0,0 +1,38 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; + +/** + * Normalizes raw feature values across eligible candidate evaluations. + * + * Implementations must be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate type. + */ +export interface Normalizer { + /** + * Stable identifier of the normalization strategy. + * + * Used for diagnostics, auditing, and reproducibility. + * + * Example: + * "min-max" + */ + readonly id: string; + + /** + * Normalizes feature values across eligible candidate evaluations. + * + * Implementations must not mutate the supplied evaluations, + * feature values, candidates, or any nested input state. + * + * @param evaluations - Eligible candidate evaluations containing raw + * feature values. + * + * @returns New candidate evaluations containing normalized feature values. + * + * @throws When feature values are invalid or normalization cannot + * produce a valid deterministic result. + */ + normalize: ( + evaluations: readonly CandidateEvaluation[], + ) => readonly CandidateEvaluation[]; +} diff --git a/packages/decision-engine/src/domain/ranking/ranking-strategy.ts b/packages/decision-engine/src/domain/ranking/ranking-strategy.ts new file mode 100644 index 0000000..dd18f77 --- /dev/null +++ b/packages/decision-engine/src/domain/ranking/ranking-strategy.ts @@ -0,0 +1,36 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; + +/** + * Defines how scored candidate evaluations are ordered. + * + * Implementations must be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context. + * @typeParam Policy - Active decision policy. + */ +export interface RankingStrategy { + /** + * Stable identifier of the ranking strategy. + * + * Example: + * "score-descending" + */ + readonly id: string; + + /** + * Orders eligible, scored candidate evaluations. + * + * Implementations must not mutate the supplied collection. + * + * @param evaluations - Eligible candidate evaluations with valid scores. + * @param context - Runtime decision context. + * @param policy - Active decision policy. + * @returns Candidate evaluations in deterministic ranking order. + */ + rank: ( + evaluations: readonly CandidateEvaluation[], + context: Context, + policy: Policy, + ) => readonly CandidateEvaluation[]; +} diff --git a/packages/decision-engine/src/domain/scoring/descending-score-ranking-strategy.ts b/packages/decision-engine/src/domain/scoring/descending-score-ranking-strategy.ts new file mode 100644 index 0000000..37013b0 --- /dev/null +++ b/packages/decision-engine/src/domain/scoring/descending-score-ranking-strategy.ts @@ -0,0 +1,62 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation'; +import type { RankingStrategy } from '../ranking/ranking-strategy'; + +import { DecisionExecutionError } from '../error/decision-execution-error.js'; + +/** + * Ranks eligible candidate evaluations by score in descending order. + * + * Candidates with equal scores preserve their original input order, + * providing deterministic and stable tie-breaking. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Active decision policy type. + */ +export class DescendingScoreRankingStrategy< + Candidate, + Context, + Policy, +> implements RankingStrategy { + /** + * Stable identifier of this ranking strategy. + */ + public readonly id = 'score-descending'; + + /** + * Orders candidate evaluations from highest score to lowest score. + * + * @param evaluations - Eligible candidate evaluations with valid scores. + * @param context - Runtime decision context. Unused by this strategy. + * @param policy - Active decision policy. Unused by this strategy. + * + * @returns New immutable collection ordered by descending score. + * + * @throws DecisionExecutionError when an evaluation is ineligible, + * has no score, or contains a non-finite score. + */ + public rank( + evaluations: readonly CandidateEvaluation[], + _context: Context, + _policy: Policy, + ): readonly CandidateEvaluation[] { + const scored = evaluations.map((evaluation, index) => { + const score = evaluation.score; + if ( + !evaluation.eligible || + typeof score !== 'number' || + !Number.isFinite(score) + ) { + throw new DecisionExecutionError( + 'Ranking requires eligible candidates with finite scores.', + ); + } + return { evaluation, index, score }; + }); + return scored + .sort( + (left, right) => right.score - left.score || left.index - right.index, + ) + .map(({ evaluation }) => evaluation); + } +} diff --git a/packages/decision-engine/src/domain/scoring/feature-weight-resolver.ts b/packages/decision-engine/src/domain/scoring/feature-weight-resolver.ts new file mode 100644 index 0000000..cfc4025 --- /dev/null +++ b/packages/decision-engine/src/domain/scoring/feature-weight-resolver.ts @@ -0,0 +1,15 @@ +/** + * Resolves the weight assigned to a feature for the active policy. + * + * @typeParam Policy - Domain-specific policy type. + */ +export interface FeatureWeightResolver { + /** + * Resolves the weight for a feature. + * + * @param featureKey - Stable feature identifier. + * @param policy - Active decision policy. + * @returns Finite weight. A policy may intentionally use a negative weight. + */ + resolve: (featureKey: string, policy: Policy) => number; +} diff --git a/packages/decision-engine/src/domain/scoring/scoring-strategy.ts b/packages/decision-engine/src/domain/scoring/scoring-strategy.ts new file mode 100644 index 0000000..32e00c6 --- /dev/null +++ b/packages/decision-engine/src/domain/scoring/scoring-strategy.ts @@ -0,0 +1,37 @@ +import type { FeatureValue } from '../feature/feature-value.js'; + +/** + * Calculates the final numeric score for an eligible candidate. + * + * Implementations must be deterministic, side-effect free, and must not + * mutate the supplied feature values or policy. + * + * @typeParam Policy - Business policy type used by the scoring strategy. + */ +export interface ScoringStrategy { + /** + * Stable identifier of the scoring strategy. + * + * Used for diagnostics, auditing, and reproducibility. + * + * Example: + * "weighted-sum" + */ + readonly id: string; + + /** + * Calculates the candidate score from normalized feature values. + * + * @param features - Feature values for one eligible candidate. + * Normalized values are expected to be available when required by + * the implementation. + * + * @param policy - Active decision policy. + * + * @returns Finite numeric score used by the ranking strategy. + * + * @throws An implementation-specific error when required feature values, + * policy configuration, or normalized values are invalid. + */ + score: (features: readonly FeatureValue[], policy: Policy) => number; +} diff --git a/packages/decision-engine/src/domain/scoring/weighted-sum-scoring-strategy.ts b/packages/decision-engine/src/domain/scoring/weighted-sum-scoring-strategy.ts new file mode 100644 index 0000000..1a395da --- /dev/null +++ b/packages/decision-engine/src/domain/scoring/weighted-sum-scoring-strategy.ts @@ -0,0 +1,100 @@ +import type { FeatureValue } from '../feature/feature-value.js'; +import type { FeatureWeightResolver } from './feature-weight-resolver.js'; +import type { ScoringStrategy } from './scoring-strategy.js'; + +import { DecisionExecutionError } from '../error/decision-execution-error.js'; +import { InvalidDecisionDefinitionError } from '../error/invalid-decision-definition-error.js'; + +/** + * Calculates candidate scores as the weighted sum of normalized + * feature values. + * + * Higher resulting scores represent more desirable candidates. + * + * @typeParam Policy - Domain-specific policy type. + */ +export class WeightedSumScoringStrategy< + Policy, +> implements ScoringStrategy { + /** + * Stable identifier of this scoring strategy. + */ + public readonly id = 'weighted-sum'; + + /** + * Resolves feature weights from the active policy. + */ + private readonly weightResolver: FeatureWeightResolver; + + /** + * Creates a weighted-sum scoring strategy. + * + * @param weightResolver - Resolver responsible for retrieving the + * weight associated with each feature. + */ + public constructor(weightResolver: FeatureWeightResolver) { + if (!weightResolver || typeof weightResolver.resolve !== 'function') { + throw new InvalidDecisionDefinitionError( + 'Feature weight resolver is required.', + ); + } + this.weightResolver = weightResolver; + } + + /** + * Calculates the weighted sum of normalized feature values. + * + * @param features - Normalized feature values of one eligible candidate. + * @param policy - Active decision policy. + * + * @returns Finite numeric candidate score. + * + * @throws DecisionExecutionError when normalized values or weights + * are invalid. + */ + public score(features: readonly FeatureValue[], policy: Policy): number { + const total = features.reduce( + (score, feature) => score + this.scoreFeature(feature, policy), + 0, + ); + + if (!Number.isFinite(total)) { + throw new DecisionExecutionError('Weighted score must be finite.', { + total, + }); + } + return total; + } + + private scoreFeature(feature: FeatureValue, policy: Policy): number { + const { key, normalizedValue } = feature; + if ( + typeof normalizedValue !== 'number' || + !Number.isFinite(normalizedValue) + ) { + throw new DecisionExecutionError( + 'Feature normalized value must be finite.', + { featureKey: key, normalizedValue }, + ); + } + + let weight: number; + try { + weight = this.weightResolver.resolve(key, policy); + } catch (error) { + throw new DecisionExecutionError( + 'Feature weight resolution failed.', + { featureKey: key }, + error, + ); + } + + if (!Number.isFinite(weight)) { + throw new DecisionExecutionError('Feature weight must be finite.', { + featureKey: key, + weight, + }); + } + return normalizedValue * weight; + } +} diff --git a/packages/decision-engine/src/domain/selection/select-above-threshold-strategy.ts b/packages/decision-engine/src/domain/selection/select-above-threshold-strategy.ts new file mode 100644 index 0000000..d0029e1 --- /dev/null +++ b/packages/decision-engine/src/domain/selection/select-above-threshold-strategy.ts @@ -0,0 +1,74 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; +import type { SelectionStrategy } from './selection-strategy.js'; + +import { DecisionExecutionError } from '../error/decision-execution-error.js'; +import { InvalidDecisionDefinitionError } from '../error/invalid-decision-definition-error.js'; +import { validateRankedEvaluations } from './validate-ranked-evaluations.js'; + +/** + * Selects all ranked candidates whose score is greater than or equal + * to a configured minimum threshold. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Active decision policy type. + */ +export class SelectAboveThresholdStrategy< + Candidate, + Context, + Policy, +> implements SelectionStrategy { + /** + * Stable identifier of this selection strategy. + */ + public readonly id = 'select-above-threshold'; + + /** + * Minimum acceptable candidate score. + */ + private readonly threshold: number; + + /** + * Creates a threshold-based selection strategy. + * + * @param threshold - Minimum score required for selection. + * + * @throws InvalidDecisionDefinitionError when the threshold is not finite. + */ + public constructor(threshold: number) { + if (!Number.isFinite(threshold)) { + throw new InvalidDecisionDefinitionError( + 'Selection threshold must be finite.', + { threshold }, + ); + } + this.threshold = threshold; + } + + /** + * Selects ranked candidates whose score is greater than or equal + * to the configured threshold. + * + * @param evaluations - Eligible candidate evaluations in ranking order. + * @param context - Runtime decision context. Unused by this strategy. + * @param policy - Active decision policy. Unused by this strategy. + * + * @returns Ranked subset satisfying the configured score threshold. + */ + public select( + evaluations: readonly CandidateEvaluation[], + _context: Context, + _policy: Policy, + ): readonly CandidateEvaluation[] { + validateRankedEvaluations(evaluations); + return evaluations.filter(evaluation => { + const score = evaluation.score; + if (typeof score !== 'number' || !Number.isFinite(score)) { + throw new DecisionExecutionError( + 'Threshold selection requires finite candidate scores.', + ); + } + return score >= this.threshold; + }); + } +} diff --git a/packages/decision-engine/src/domain/selection/select-first-strategy.ts b/packages/decision-engine/src/domain/selection/select-first-strategy.ts new file mode 100644 index 0000000..36c84d2 --- /dev/null +++ b/packages/decision-engine/src/domain/selection/select-first-strategy.ts @@ -0,0 +1,44 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; +import type { SelectionStrategy } from './selection-strategy.js'; + +import { validateRankedEvaluations } from './validate-ranked-evaluations.js'; + +/** + * Selects the first candidate from the ranked candidate evaluations. + * + * This strategy assumes the supplied evaluations are already ordered + * according to the active ranking strategy. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Active decision policy type. + */ +export class SelectFirstStrategy< + Candidate, + Context, + Policy, +> implements SelectionStrategy { + /** + * Stable identifier of this selection strategy. + */ + public readonly id = 'select-first'; + + /** + * Selects the first ranked candidate. + * + * @param evaluations - Eligible candidate evaluations in ranking order. + * @param context - Runtime decision context. Unused by this strategy. + * @param policy - Active decision policy. Unused by this strategy. + * + * @returns Empty collection when no candidates exist, otherwise a + * single-element collection containing the highest-ranked candidate. + */ + public select( + evaluations: readonly CandidateEvaluation[], + _context: Context, + _policy: Policy, + ): readonly CandidateEvaluation[] { + validateRankedEvaluations(evaluations); + return evaluations.length === 0 ? [] : [evaluations[0]]; + } +} diff --git a/packages/decision-engine/src/domain/selection/select-top-n-strategy.ts b/packages/decision-engine/src/domain/selection/select-top-n-strategy.ts new file mode 100644 index 0000000..c28b29e --- /dev/null +++ b/packages/decision-engine/src/domain/selection/select-top-n-strategy.ts @@ -0,0 +1,64 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; +import type { SelectionStrategy } from './selection-strategy.js'; + +import { InvalidDecisionDefinitionError } from '../error/invalid-decision-definition-error.js'; +import { validateRankedEvaluations } from './validate-ranked-evaluations.js'; + +/** + * Selects up to a configured number of highest-ranked candidates. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context type. + * @typeParam Policy - Active decision policy type. + */ +export class SelectTopNStrategy< + Candidate, + Context, + Policy, +> implements SelectionStrategy { + /** + * Stable identifier of this selection strategy. + */ + public readonly id = 'select-top-n'; + + /** + * Maximum number of candidates to select. + */ + private readonly limit: number; + + /** + * Creates a top-N selection strategy. + * + * @param limit - Maximum number of ranked candidates to select. + * + * @throws InvalidDecisionDefinitionError when the limit is not a + * positive integer. + */ + public constructor(limit: number) { + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new InvalidDecisionDefinitionError( + 'Selection limit must be a positive integer.', + { limit }, + ); + } + this.limit = limit; + } + + /** + * Selects up to the configured number of highest-ranked candidates. + * + * @param evaluations - Eligible candidate evaluations in ranking order. + * @param context - Runtime decision context. Unused by this strategy. + * @param policy - Active decision policy. Unused by this strategy. + * + * @returns A ranked subset containing at most `limit` candidates. + */ + public select( + evaluations: readonly CandidateEvaluation[], + _context: Context, + _policy: Policy, + ): readonly CandidateEvaluation[] { + validateRankedEvaluations(evaluations); + return evaluations.slice(0, this.limit); + } +} diff --git a/packages/decision-engine/src/domain/selection/selection-strategy.ts b/packages/decision-engine/src/domain/selection/selection-strategy.ts new file mode 100644 index 0000000..d840a81 --- /dev/null +++ b/packages/decision-engine/src/domain/selection/selection-strategy.ts @@ -0,0 +1,36 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; + +/** + * Defines how ranked candidate evaluations are selected as final outcomes. + * + * Implementations must be deterministic and side-effect free. + * + * @typeParam Candidate - Domain-specific candidate type. + * @typeParam Context - Runtime decision context. + * @typeParam Policy - Active decision policy. + */ +export interface SelectionStrategy { + /** + * Stable identifier of the selection strategy. + * + * Example: + * "select-first" + */ + readonly id: string; + + /** + * Selects zero, one, or multiple candidates from the ranked evaluations. + * + * Implementations must not mutate the supplied collection or evaluations. + * + * @param evaluations - Eligible candidate evaluations in ranking order. + * @param context - Runtime decision context. + * @param policy - Active decision policy. + * @returns A subset of the supplied evaluations representing the selection. + */ + select: ( + evaluations: readonly CandidateEvaluation[], + context: Context, + policy: Policy, + ) => readonly CandidateEvaluation[]; +} diff --git a/packages/decision-engine/src/domain/selection/validate-ranked-evaluations.ts b/packages/decision-engine/src/domain/selection/validate-ranked-evaluations.ts new file mode 100644 index 0000000..32cbb33 --- /dev/null +++ b/packages/decision-engine/src/domain/selection/validate-ranked-evaluations.ts @@ -0,0 +1,21 @@ +import type { CandidateEvaluation } from '../model/candidate-evaluation.js'; + +import { DecisionExecutionError } from '../error/decision-execution-error.js'; + +/** Verifies that a selection strategy received engine-ranked evaluations. */ +export function validateRankedEvaluations( + evaluations: readonly CandidateEvaluation[], +): void { + evaluations.forEach((evaluation, index) => { + if ( + !evaluation.eligible || + typeof evaluation.score !== 'number' || + !Number.isFinite(evaluation.score) || + evaluation.rank !== index + 1 + ) { + throw new DecisionExecutionError( + 'Selection requires eligible evaluations with finite scores in rank order.', + ); + } + }); +} diff --git a/packages/decision-engine/src/index.ts b/packages/decision-engine/src/index.ts new file mode 100644 index 0000000..721f0a6 --- /dev/null +++ b/packages/decision-engine/src/index.ts @@ -0,0 +1,35 @@ +export * from './application/decision-engine.js'; +export * from './application/decision-validator.js'; +export * from './application/default-decision-validator.js'; +export * from './application/port/decision-event-publisher.js'; +export * from './domain/candidate/candidate-ref-resolver.js'; +export * from './domain/constraint/constraint-result.js'; +export * from './domain/constraint/constraint.js'; +export * from './domain/error/decision-error.js'; +export * from './domain/error/decision-execution-error.js'; +export * from './domain/error/invalid-decision-definition-error.js'; +export * from './domain/event/candidate-rejected-event.js'; +export * from './domain/event/decision-completed-event.js'; +export * from './domain/feature/feature-objective.js'; +export * from './domain/feature/feature-value.js'; +export * from './domain/feature/feature.js'; +export * from './domain/model/candidate-evaluation.js'; +export * from './domain/model/decision-definition.js'; +export * from './domain/model/decision-event.js'; +export * from './domain/model/decision-execution.js'; +export * from './domain/model/decision-policy.js'; +export * from './domain/model/decision-request.js'; +export * from './domain/model/decision-result.js'; +export * from './domain/normalization/min-max-normalizer.js'; +export * from './domain/normalization/normalizer.js'; +export * from './domain/ranking/ranking-strategy.js'; +export * from './domain/scoring/descending-score-ranking-strategy.js'; +export * from './domain/scoring/feature-weight-resolver.js'; +export * from './domain/scoring/scoring-strategy.js'; +export * from './domain/scoring/weighted-sum-scoring-strategy.js'; +export * from './domain/selection/select-above-threshold-strategy.js'; +export * from './domain/selection/select-first-strategy.js'; +export * from './domain/selection/select-top-n-strategy.js'; +export * from './domain/selection/selection-strategy.js'; +export * from './infrastructure/event/decision-event-handler.js'; +export * from './infrastructure/event/in-memory-decision-event-publisher.js'; diff --git a/packages/decision-engine/src/infrastructure/event/decision-event-handler.ts b/packages/decision-engine/src/infrastructure/event/decision-event-handler.ts new file mode 100644 index 0000000..df3f983 --- /dev/null +++ b/packages/decision-engine/src/infrastructure/event/decision-event-handler.ts @@ -0,0 +1,20 @@ +import type { DecisionEvent } from '@/domain/model/decision-event'; + +/** + * Handles a decision event delivered by an in-process publisher. + * + * @typeParam Event - Concrete decision event type handled by the subscriber. + */ +export interface DecisionEventHandler< + Event extends DecisionEvent = DecisionEvent, +> { + /** + * Handles one immutable decision event. + * + * @param event - Event delivered by the publisher. + * @returns Completion after the subscriber has finished processing. + * + * @throws When the subscriber cannot process the event successfully. + */ + handle: (event: Event) => Promise; +} diff --git a/packages/decision-engine/src/infrastructure/event/in-memory-decision-event-publisher.ts b/packages/decision-engine/src/infrastructure/event/in-memory-decision-event-publisher.ts new file mode 100644 index 0000000..80b2db6 --- /dev/null +++ b/packages/decision-engine/src/infrastructure/event/in-memory-decision-event-publisher.ts @@ -0,0 +1,73 @@ +import type { DecisionEventPublisher } from '@/application/port/decision-event-publisher'; +import type { DecisionEvent } from '@/domain/model/decision-event'; + +import type { DecisionEventHandler } from './decision-event-handler'; + +/** + * Publishes decision events to subscribers registered within the current process. + * + * This implementation is suitable when durable delivery is not required. + * + * Events and handlers are processed deterministically in registration order. + */ +export class InMemoryDecisionEventPublisher implements DecisionEventPublisher { + /** + * Registered handlers grouped by event type. + */ + private readonly handlers: Map; + + /** + * Creates an empty in-memory event publisher. + */ + public constructor() { + this.handlers = new Map(); + } + + /** + * Publishes events sequentially in the supplied order. + * + * For each event, matching handlers are invoked sequentially + * in registration order. + * + * @param events - Decision events to publish. + * + * @throws When any registered handler fails. + */ + public async publish(events: readonly DecisionEvent[]): Promise { + for (const event of events) { + const handlers = this.handlers.get(event.type) ?? []; + for (const handler of handlers) { + // Sequential delivery is the documented adapter contract. + // eslint-disable-next-line no-await-in-loop + await handler.handle(event); + } + } + } + + /** + * Registers a handler for a specific decision event type. + * + * Handlers are invoked in registration order. + * + * @param eventType - Stable event type to subscribe to. + * @param handler - Handler that should receive matching events. + * + * @throws When the event type is empty. + */ + public subscribe( + eventType: Event['type'], + handler: DecisionEventHandler, + ): void { + if (typeof eventType !== 'string' || eventType.trim().length === 0) { + throw new TypeError('Decision event type must be a non-empty string.'); + } + if (!handler || typeof handler.handle !== 'function') { + throw new TypeError('Decision event handler is required.'); + } + const handlers = this.handlers.get(eventType) ?? []; + this.handlers.set(eventType, [ + ...handlers, + handler as DecisionEventHandler, + ]); + } +} diff --git a/packages/decision-engine/tsconfig.build.json b/packages/decision-engine/tsconfig.build.json new file mode 100644 index 0000000..d3aa5dc --- /dev/null +++ b/packages/decision-engine/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "tsup.config.ts"], + "exclude": ["dist", "node_modules", "**/*.{spec,test}.ts"] +} diff --git a/packages/decision-engine/tsconfig.json b/packages/decision-engine/tsconfig.json new file mode 100644 index 0000000..aee6525 --- /dev/null +++ b/packages/decision-engine/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "module": "node16", + "moduleResolution": "node16", + "noImplicitAny": true, + "noUnusedLocals": true, + "outDir": "./dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "paths": { + "@/*": ["./src/*"] + }, + "target": "es2022" + }, + "include": ["src/**/*", "tsup.config.ts", "vitest.config.mts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/decision-engine/tsup.config.ts b/packages/decision-engine/tsup.config.ts new file mode 100644 index 0000000..08cdec7 --- /dev/null +++ b/packages/decision-engine/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig(() => ({ + // eslint-disable-next-line n/no-process-env + minify: process.env.CI === 'true', + tsconfig: 'tsconfig.build.json', + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + splitting: false, + sourcemap: true, + clean: true, + dts: true, +})); diff --git a/packages/decision-engine/vitest.config.mts b/packages/decision-engine/vitest.config.mts new file mode 100644 index 0000000..143eb88 --- /dev/null +++ b/packages/decision-engine/vitest.config.mts @@ -0,0 +1,11 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['./src/**/*.spec.ts'], + globals: true, + root: './', + }, + plugins: [tsconfigPaths()], +}); diff --git a/plan/feature-decision-engine-1.md b/plan/feature-decision-engine-1.md new file mode 100644 index 0000000..3fd327d --- /dev/null +++ b/plan/feature-decision-engine-1.md @@ -0,0 +1,161 @@ +--- +goal: Deliver a usable, framework-agnostic decision execution engine +version: 1.0 +date_created: 2026-10-01 +last_updated: 2026-10-01 +owner: Rineex Team +status: Completed +tags: [feature, decision-engine, domain, validation] +--- + +# Introduction + +![Status: Completed](https://img.shields.io/badge/status-Completed-brightgreen) + +`@rineex/decision-engine` will be completed as a real decision-execution +library, not retained as a placeholder. The package already contains a 911-line +execution pipeline and contracts for constraints, features, normalization, +scoring, ranking, selection, and events. The completed work makes the intended +engine internally consistent, executable, exported, and verified through +end-to-end and adversarial tests. + +## 1. Requirements & Constraints + +- **REQ-001**: The published package must expose a usable `DecisionEngine`, + `DefaultDecisionValidator`, built-in strategies, domain contracts, and errors + from `packages/decision-engine/src/index.ts`. +- **REQ-002**: One canonical `DecisionDefinition` must include + `candidateRefResolver`; remove the duplicate definition currently placed in + `src/domain/event/decision-completed-event.ts`. +- **REQ-003**: A feature value must carry the feature objective required by + `DecisionEngine.evaluateFeatures` and `MinMaxNormalizer`. +- **REQ-004**: All built-in strategies must validate their inputs, avoid + mutation, and either return valid results or throw a decision-domain error. +- **REQ-005**: `DecisionExecution.result`, evaluations, event collections, and + built-in strategy outputs must be immutable from consumers' perspective. +- **REQ-006**: The engine must preserve input candidate order for evaluation; + descending-score ties must preserve that order. +- **REQ-007**: Candidate references must be non-empty, unique strings for one + execution; selected candidates must be a subset of ranked eligible candidates. +- **REQ-008**: The default validator must reject malformed definitions and + requests before candidate evaluation begins. +- **CON-001**: Keep the engine synchronous and framework-independent. Event + publishing stays an opt-in infrastructure adapter and is not called by + `DecisionEngine.execute`. +- **CON-002**: Do not publish the package until `check-types`, `test`, `lint`, + and `build` pass. +- **PAT-001**: Apply the pipeline in this order: validate, constrain, extract, + normalize, score, rank, select, build result, create events. + +## 2. Implementation Steps + +### Implementation Phase 1 + +- GOAL-001: Establish one coherent public decision model and a compiling API. + +| Task | Description | Completed | Date | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ---- | +| TASK-001 | Update `packages/decision-engine/src/domain/model/decision-definition.ts` to import `CandidateRefResolver` and add required `candidateRefResolver: CandidateRefResolver`. | | | +| TASK-002 | Replace the erroneous `DecisionDefinition` in `packages/decision-engine/src/domain/event/decision-completed-event.ts` with a `DecisionCompletedEventPayload` and `DecisionCompletedEvent` contract matching `DecisionEngine.createDecisionEvents`; remove its unrelated strategy imports. | | | +| TASK-003 | Update `packages/decision-engine/src/domain/feature/feature-value.ts` to include `objective: FeatureObjective`; update the feature interface and all tests/fixtures so feature evaluation returns `{ key, objective, rawValue }`. | | | +| TASK-004 | Change `DecisionEvent`'s default payload type to `unknown` (or a covariant object-safe equivalent) and update `DecisionExecution` plus event publisher contracts so `CandidateRejectedEvent` and `DecisionCompletedEvent` are assignable without casts. | | | +| TASK-005 | Implement constructors in `src/domain/error/decision-error.ts`, `decision-execution-error.ts`, and `invalid-decision-definition-error.ts`; call `super(message, { cause })`, set stable names, and retain `details`. | | | +| TASK-006 | Implement `DefaultDecisionValidator` in `src/application/default-decision-validator.ts` against the canonical model import. Validate non-empty IDs/versions, arrays, unique feature/constraint IDs, strategy objects and IDs, resolver presence, candidates array, policy ID/version, and optional correlation ID. | | | +| TASK-007 | Update `src/index.ts` to export only the supported public API: engine, validator, built-in strategies, event publisher, contracts, objectives, and errors. Do not export the legacy descriptor helper unless it is redefined as an engine factory with documented behavior. | | | + +### Implementation Phase 2 + +- GOAL-002: Implement deterministic built-in decision behavior. + +| Task | Description | Completed | Date | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-008 | Implement `MinMaxNormalizer.normalize` in `src/domain/normalization/min-max-normalizer.ts`: validate equal feature key/objective sets across eligible evaluations; normalize maximize features as `(value-min)/(max-min)` and minimize features as `(max-value)/(max-min)`; assign `1` to every candidate when a feature has zero range; return copied evaluations and copied feature values. | | | +| TASK-009 | Implement `WeightedSumScoringStrategy.score` in `src/domain/scoring/weighted-sum-scoring-strategy.ts`: require finite normalized values and finite resolver weights, then return the finite sum of `normalizedValue * weight`; wrap resolver errors as `DecisionExecutionError` with feature metadata. | | | +| TASK-010 | Implement `DescendingScoreRankingStrategy.rank` in `src/domain/scoring/descending-score-ranking-strategy.ts`: reject ineligible/missing/non-finite scores, return a copied stable descending ordering, and leave evaluation objects unchanged. | | | +| TASK-011 | Implement `SelectFirstStrategy.select`, `SelectTopNStrategy`, and `SelectAboveThresholdStrategy` under `src/domain/selection/`: validate constructor parameters, reject invalid ranking inputs where necessary, and return copied subsets without mutating input. | | | +| TASK-012 | Implement `InMemoryDecisionEventPublisher` in `src/infrastructure/event/in-memory-decision-event-publisher.ts` as a concrete class: initialize handlers, reject blank event types, register handlers in order, and publish events/handlers sequentially. | | | + +### Implementation Phase 3 + +- GOAL-003: Prove the engine through black-box execution tests and package + gates. + +| Task | Description | Completed | Date | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-013 | Replace `src/decision-engine.spec.ts`'s descriptor-only tests with end-to-end `DecisionEngine.execute` tests using a small candidate fixture. Cover constraints, maximize and minimize features, weighted scoring, descending ranking, top-N selection, selected flags, result metadata, correlation ID, rejection event, and completion event. | | | +| TASK-014 | Add focused specs beside the implementations for zero-range normalization, inconsistent feature output, non-finite feature/weight/score values, ranking ties, invalid limits/thresholds, duplicate/blank candidate references, selector attempts to return rejected or unknown candidates, and immutability/non-mutation guarantees. | | | +| TASK-015 | Add `InMemoryDecisionEventPublisher` tests for subscription order, event order, blank event type rejection, and handler failure propagation. | | | +| TASK-016 | Run `pnpm --filter @rineex/decision-engine check-types`, `test`, `lint`, and `build`. Fix every failure introduced by this package. Confirm the built declarations expose `DecisionEngine` and one end-to-end test imports it from the package root. | | | + +## 3. Alternatives + +- **ALT-001**: Delete all unfinished internals and preserve only + `initDecisionEngine`. Rejected because the WIP commit deliberately introduced + the full pipeline and its contracts, making a descriptor-only package + misleading and needlessly maintaining dead code. +- **ALT-002**: Export the existing pipeline before implementing it. Rejected + because it fails typechecking and exposes unusable runtime declarations. +- **ALT-003**: Build a rule DSL, persistence integration, or asynchronous + execution in this effort. Rejected because they are outside the current + synchronous, framework-agnostic model. + +## 4. Dependencies + +- **DEP-001**: TypeScript 5.9, Vitest 4, ESLint, and tsup already declared in + `packages/decision-engine/package.json`. +- **DEP-002**: No new production dependency is required. + +## 5. Files + +- **FILE-001**: `packages/decision-engine/src/index.ts` +- **FILE-002**: `packages/decision-engine/src/application/decision-engine.ts` +- **FILE-003**: + `packages/decision-engine/src/application/default-decision-validator.ts` +- **FILE-004**: + `packages/decision-engine/src/domain/model/decision-definition.ts`, + `decision-event.ts`, and `decision-execution.ts` +- **FILE-005**: + `packages/decision-engine/src/domain/event/decision-completed-event.ts` and + `candidate-rejected-event.ts` +- **FILE-006**: `packages/decision-engine/src/domain/error/*.ts` +- **FILE-007**: `packages/decision-engine/src/domain/feature/feature-value.ts` +- **FILE-008**: + `packages/decision-engine/src/domain/normalization/min-max-normalizer.ts` +- **FILE-009**: `packages/decision-engine/src/domain/scoring/*.ts` and + `src/domain/selection/*.ts` +- **FILE-010**: + `packages/decision-engine/src/infrastructure/event/in-memory-decision-event-publisher.ts` +- **FILE-011**: Unit and end-to-end specs under `packages/decision-engine/src/` +- **FILE-012**: `packages/decision-engine/README.md` + +## 6. Testing + +- **TEST-001**: A two-candidate execution normalizes a maximize and minimize + feature, produces expected weighted scores, ranks deterministically, and + selects the top candidate. +- **TEST-002**: A rejected candidate has no features, score, rank, or selection + and produces a complete rejection event. +- **TEST-003**: Invalid contracts fail before evaluation, and invalid strategy + output fails with a structured decision error. +- **TEST-004**: Equal raw values normalize to `1`; equal scores preserve input + order. +- **TEST-005**: Inputs and returned intermediate collections are never mutated. +- **TEST-006**: Root-package imports compile and execute; typecheck, tests, + lint, and build all pass. + +## 7. Risks & Assumptions + +- **RISK-001**: No in-repository callers currently constrain the public API; the + test suite and README must define the initial compatibility contract. +- **RISK-002**: Adding `objective` to `FeatureValue` is a breaking contract + correction, but the package is at `0.1.0` and is not currently functional. +- **RISK-003**: Runtime immutability requires deliberate copied/frozen outputs; + TypeScript `readonly` alone is insufficient. +- **ASSUMPTION-001**: The WIP pipeline reflects the intended product direction. +- **ASSUMPTION-002**: Negative weights are permitted because policy semantics, + rather than the framework, determine their meaning. + +## 8. Related Specifications / Further Reading + +- [Package README](../packages/decision-engine/README.md) +- [Initial publishable package commit](../packages/decision-engine/src/decision-engine.ts) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a48eb20..ae212a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 2.29.8(@types/node@24.10.4) '@fullstacksjs/eslint-config': specifier: 13.8.2 - version: 13.8.2(@types/eslint@9.6.1)(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react@19.2.3)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@4.0.16) + version: 13.8.2(@types/eslint@9.6.1)(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.44.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react@19.2.3)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@4.0.16) '@svitejs/changesets-changelog-github-compact': specifier: 1.2.0 version: 1.2.0 @@ -240,6 +240,33 @@ importers: tslib: specifier: 2.8.1 version: 2.8.1 + tsup: + specifier: 8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@6.0.3)(yaml@2.8.2) + type-fest: + specifier: 5.3.1 + version: 5.3.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite-tsconfig-paths: + specifier: 6.0.2 + version: 6.0.2(typescript@6.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + vitest: + specifier: 4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + + packages/decision-engine: + devDependencies: + '@rineex/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@rineex/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: 24.10.4 + version: 24.10.4 tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) @@ -260,7 +287,7 @@ importers: devDependencies: '@fullstacksjs/eslint-config': specifier: 13.8.2 - version: 13.8.2(@types/eslint@9.6.1)(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.44.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react@19.2.3)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@4.0.16) + version: 13.8.2(@types/eslint@9.6.1)(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(jest@29.7.0(@types/node@24.10.4))(prettier@3.7.4)(react@19.2.3)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@4.0.16) eslint-plugin-react-hooks: specifier: 7.0.1 version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -5641,6 +5668,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ua-is-frozen@0.1.2: resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} @@ -11529,6 +11561,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + tsconfck@3.1.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -11610,6 +11646,34 @@ snapshots: - tsx - yaml + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@6.0.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.2 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.55.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 6.0.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + turbo-darwin-64@2.6.3: optional: true @@ -11726,6 +11790,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ua-is-frozen@0.1.2: {} ua-parser-js@2.0.7: @@ -11829,6 +11895,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@6.0.2(typescript@6.0.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@6.0.3) + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + - typescript + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2