From c000d246ca0fe47fb278e99bb63390410e5a1664 Mon Sep 17 00:00:00 2001 From: Artur Karczmarczyk Date: Thu, 23 Jul 2026 13:18:08 +0200 Subject: [PATCH 1/2] #20 normalizations: minmax, max, sum --- src/utils/index.ts | 3 + src/utils/normalization.ts | 168 ++++++++++++++++++++++++++++++ tests/utils/normalization.test.ts | 153 +++++++++++++++++++++++++-- 3 files changed, 315 insertions(+), 9 deletions(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index d0b50d7..0d5fa22 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,8 @@ export { linearNormalizationCallback, + minMaxNormalizationCallback, + maxNormalizationCallback, vectorNormalizationCallback, + sumNormalizationCallback, } from "./normalization"; export { rank } from "./ranking"; diff --git a/src/utils/normalization.ts b/src/utils/normalization.ts index 06f9ded..59466e1 100644 --- a/src/utils/normalization.ts +++ b/src/utils/normalization.ts @@ -84,3 +84,171 @@ export function linearNormalizationCallback( }), ); } + +export function maxNormalizationCallback( + matrix: DecisionMatrix, + types: CriterionType[], +): DecisionMatrix { + if (!matrix.length) { + return matrix; + } + + const criteriaCount = matrix[0].length; + + const maxima = Array.from({ length: criteriaCount }, (_, criterionIndex) => { + const criterionValues = matrix.map((alternativeValues) => alternativeValues[criterionIndex]); + + return Math.max(...criterionValues); + }); + + const normalizedMatrix: DecisionMatrix = []; + + for (const alternativeValues of matrix) { + const normalizedAlternativeValues: number[] = []; + + for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) { + const maximum = maxima[criterionIndex] ?? 0; + + if (maximum === 0) { + throw new Error( + `Cannot normalize criterion at index ${criterionIndex} because the maximum value is zero.`, + ); + } + + const value = alternativeValues[criterionIndex]; + const normalizedValue = value / maximum; + + if (types[criterionIndex] === CriterionType.COST) { + normalizedAlternativeValues.push(1 - normalizedValue); + } else { + normalizedAlternativeValues.push(normalizedValue); + } + } + + normalizedMatrix.push(normalizedAlternativeValues); + } + + return normalizedMatrix; +} + +export function minMaxNormalizationCallback( + matrix: DecisionMatrix, + types: CriterionType[], +): DecisionMatrix { + if (!matrix.length) { + return matrix; + } + + const criteriaCount = matrix[0].length; + const ranges = Array.from({ length: criteriaCount }, (_, criterionIndex) => { + const criterionValues = matrix.map( + (alternativeValues) => alternativeValues[criterionIndex] ?? 0, + ); + + return { + min: Math.min(...criterionValues), + max: Math.max(...criterionValues), + }; + }); + + const normalizedMatrix: DecisionMatrix = []; + + for (const alternativeValues of matrix) { + const normalizedAlternativeValues: number[] = []; + + for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) { + const range = ranges[criterionIndex]; + + if (range === undefined) { + throw new Error("Min-max normalization criterion range is required."); + } + + const denominator = range.max - range.min; + + if (denominator === 0) { + throw new Error( + `Cannot normalize criterion at index ${criterionIndex} because all values are equal.`, + ); + } + + const value = alternativeValues[criterionIndex]; + + if (types[criterionIndex] === CriterionType.COST) { + normalizedAlternativeValues.push((range.max - value) / denominator); + } else { + normalizedAlternativeValues.push((value - range.min) / denominator); + } + } + + normalizedMatrix.push(normalizedAlternativeValues); + } + + return normalizedMatrix; +} + +export function sumNormalizationCallback( + matrix: DecisionMatrix, + types: CriterionType[], +): DecisionMatrix { + if (!matrix.length) { + return matrix; + } + + for (const alternativeValues of matrix) { + for (const alternativeValue of alternativeValues) { + if (alternativeValue < 0) { + throw new Error("Sum normalization requires that none of the values are negative"); + } + } + } + + const criteriaCount = matrix[0].length; + + const divisors = Array.from({ length: criteriaCount }, (_, criterionIndex) => { + if (types[criterionIndex] === CriterionType.COST) { + return matrix.reduce((sum, alternativeValues) => { + const value = alternativeValues[criterionIndex]; + + if (value === 0) { + throw new Error( + `Cannot normalize criterion at index ${criterionIndex} because it contains zero values`, + ); + } + + return sum + 1 / value; + }, 0); + } + + return matrix.reduce((sum, alternativeValues) => { + return sum + alternativeValues[criterionIndex]; + }, 0); + }); + + const normalizedMatrix: DecisionMatrix = []; + + for (const alternativeValues of matrix) { + const normalizedAlternativeValues: number[] = []; + + for (let criterionIndex = 0; criterionIndex < criteriaCount; criterionIndex++) { + const divisor = divisors[criterionIndex] ?? 0; + + if (divisor === 0) { + throw new Error( + `Cannot normalize criterion at index ${criterionIndex} because the divisor is zero`, + ); + } + + const value = alternativeValues[criterionIndex]; + + if (types[criterionIndex] === CriterionType.COST) { + normalizedAlternativeValues.push(1 / value / divisor); + } else { + normalizedAlternativeValues.push(value / divisor); + } + } + + normalizedMatrix.push(normalizedAlternativeValues); + } + + return normalizedMatrix; +} diff --git a/tests/utils/normalization.test.ts b/tests/utils/normalization.test.ts index 724be91..1f91be1 100644 --- a/tests/utils/normalization.test.ts +++ b/tests/utils/normalization.test.ts @@ -1,27 +1,33 @@ -import { describe, expect, test } from "@jest/globals"; +import { describe, expect, it } from "@jest/globals"; import { CriterionType } from "../../src/types"; -import { linearNormalizationCallback, vectorNormalizationCallback } from "../../src/utils"; +import { + linearNormalizationCallback, + maxNormalizationCallback, + minMaxNormalizationCallback, + sumNormalizationCallback, + vectorNormalizationCallback, +} from "../../src/utils"; describe("vectorNormalizationCallback", () => { - test("normalizes benefit criteria by vector divisor", () => { + it("normalizes benefit criteria by vector divisor", () => { const normalizedMatrix = vectorNormalizationCallback([[3], [4]], [CriterionType.BENEFIT]); expect(normalizedMatrix).toEqual([[0.6], [0.8]]); }); - test("normalizes cost criteria by inverted vector divisor", () => { + it("normalizes cost criteria by inverted vector divisor", () => { const normalizedMatrix = vectorNormalizationCallback([[4], [3]], [CriterionType.COST]); expect(normalizedMatrix).toEqual([[0.19999999999999996], [0.4]]); }); - test("throws when matrix has no alternatives", () => { + it("throws when matrix has no alternatives", () => { expect(() => vectorNormalizationCallback([], [])).toThrow( "Decision problem matrix requires at least one alternative.", ); }); - test("throws when criterion divisor is zero", () => { + it("throws when criterion divisor is zero", () => { expect(() => vectorNormalizationCallback([[0], [0]], [CriterionType.BENEFIT])).toThrow( "Cannot normalize criterion at index 0 because the divisor is zero.", ); @@ -29,21 +35,150 @@ describe("vectorNormalizationCallback", () => { }); describe("linearNormalizationCallback", () => { - test("normalizes benefit criteria from min to max", () => { + it("normalizes benefit criteria from min to max", () => { const normalizedMatrix = linearNormalizationCallback([[10], [8], [6]], [CriterionType.BENEFIT]); expect(normalizedMatrix).toEqual([[1], [0.5], [0]]); }); - test("normalizes cost criteria from max to min", () => { + it("normalizes cost criteria from max to min", () => { const normalizedMatrix = linearNormalizationCallback([[5], [7], [4]], [CriterionType.COST]); expect(normalizedMatrix).toEqual([[2 / 3], [0], [1]]); }); - test("returns zero for criteria with identical values", () => { + it("returns zero for criteria with identical values", () => { const normalizedMatrix = linearNormalizationCallback([[5], [5]], [CriterionType.BENEFIT]); expect(normalizedMatrix).toEqual([[0], [0]]); }); }); + +describe("maxNormalizationCallback", () => { + it("normalizes benefit criteria by dividing by max", () => { + const normalizedMatrix = maxNormalizationCallback([[1], [5], [10]], [CriterionType.BENEFIT]); + + expect(normalizedMatrix).toEqual([[0.1], [0.5], [1]]); + }); + + it("normalizes benefit criteria as 1 - x/max", () => { + const normalizedMatrix = maxNormalizationCallback([[1], [5], [10]], [CriterionType.COST]); + + expect(normalizedMatrix).toEqual([[0.9], [0.5], [0]]); + }); + + it("returns 1 for criteria with identical values", () => { + const normalizedMatrix = maxNormalizationCallback([[5], [5]], [CriterionType.BENEFIT]); + + expect(normalizedMatrix).toEqual([[1], [1]]); + }); + + it("normalizes data per criterion not per alternative", () => { + const normalizedMatrix = maxNormalizationCallback( + [ + [1, 1], + [5, 5], + [10, 10], + ], + [CriterionType.BENEFIT, CriterionType.COST], + ); + + expect(normalizedMatrix).toEqual([ + [0.1, 0.9], + [0.5, 0.5], + [1, 0], + ]); + }); + + it("throws if any max is 0", () => { + expect(() => maxNormalizationCallback([[0], [-5]], [CriterionType.BENEFIT])).toThrow( + "Cannot normalize criterion at index 0 because the maximum value is zero.", + ); + }); +}); + +describe("minMaxNormalizationCallback", () => { + it("normalizes data using min-max alg. per criterion not per alternative", () => { + const normalizedMatrix = minMaxNormalizationCallback( + [ + [1, 1], + [5, 5], + [9, 9], + ], + [CriterionType.BENEFIT, CriterionType.COST], + ); + + expect(normalizedMatrix).toEqual([ + [0, 1], + [0.5, 0.5], + [1, 0], + ]); + }); + + it("throws for criteria with identical values", () => { + expect(() => minMaxNormalizationCallback([[5], [5]], [CriterionType.BENEFIT])).toThrow( + "Cannot normalize criterion at index 0 because all values are equal.", + ); + }); +}); + +describe("sumNormalizationCallback", () => { + it("normalizes benefit criteria by dividing by sum", () => { + const normalizedMatrix = sumNormalizationCallback( + [[0.25], [0.25], [0.5]], + [CriterionType.BENEFIT], + ); + + expect(normalizedMatrix).toEqual([[0.25], [0.25], [0.5]]); + }); + it("normalizes cost criteria by dividing 1/x by sum(1/x)", () => { + const normalizedMatrix = sumNormalizationCallback( + [[0.25], [0.25], [0.5]], + [CriterionType.COST], + ); + + expect(normalizedMatrix).toEqual([[0.4], [0.4], [0.2]]); + }); + + it("normalizes data per criterion not per alternative", () => { + const normalizedMatrix = sumNormalizationCallback( + [ + [0.25, 0.25], + [0.25, 0.25], + [0.5, 0.5], + ], + [CriterionType.BENEFIT, CriterionType.COST], + ); + + expect(normalizedMatrix).toEqual([ + [0.25, 0.4], + [0.25, 0.4], + [0.5, 0.2], + ]); + }); + + it("throws if a cost criterion has any 0 value", () => { + expect(() => + sumNormalizationCallback( + [ + [5, 5], + [0, 0], + ], + [CriterionType.BENEFIT, CriterionType.COST], + ), + ).toThrow("Cannot normalize criterion at index 1 because it contains zero values"); + }); + + it("throws if any criterion has negative value", () => { + expect(() => + sumNormalizationCallback( + [ + [1, 1], + [2, 2], + [3, -3], + ], + [CriterionType.BENEFIT, CriterionType.BENEFIT], + ), + ).toThrow("Sum normalization requires that none of the values are negative"); + }); +}); From cb87f13ff19f12b961a1a1e525d1ef1cb4dcd9d4 Mon Sep 17 00:00:00 2001 From: Artur Karczmarczyk Date: Thu, 23 Jul 2026 13:22:13 +0200 Subject: [PATCH 2/2] #20 export tests --- tests/exports.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/exports.test.ts b/tests/exports.test.ts index 0881ec4..c154a8d 100644 --- a/tests/exports.test.ts +++ b/tests/exports.test.ts @@ -10,6 +10,14 @@ describe("MCDA Exports", () => { expect(mcda.PrometheeDecisionProblem).toBeDefined(); }); + test("should export VikorDecisionProblem", () => { + expect(mcda.VikorDecisionProblem).toBeDefined(); + }); + + test("should export SpotisDecisionProblem", () => { + expect(mcda.SpotisDecisionProblem).toBeDefined(); + }); + test("should export MabacDecisionProblem", () => { expect(mcda.MabacDecisionProblem).toBeDefined(); });