diff --git a/package-lock.json b/package-lock.json index cf84e24..b7bca3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^5.1.0", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", + "mathjs": "^15.1.0", "minio": "^8.0.6", "multer": "^2.0.2", "pg": "^8.16.3", @@ -471,6 +472,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2269,6 +2279,19 @@ "node": ">=14" } }, + "node_modules/complex.js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", + "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -2410,6 +2433,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -2832,6 +2861,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -3179,6 +3214,19 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -3852,6 +3900,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", @@ -4748,6 +4802,29 @@ "node": ">= 0.4" } }, + "node_modules/mathjs": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.0.tgz", + "integrity": "sha512-HfnAcScQm9drGryodlDqeS3WAl4gUTYGDcOtcqL/8s23MZ28Ib1i8XnYK3ZdjNuaW/L4BAp9lIp8vxAMrcuu1w==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5682,9 +5759,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -5913,6 +5990,12 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6926,6 +7009,12 @@ "readable-stream": "3" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7016,6 +7105,15 @@ "node": ">= 0.6" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/package.json b/package.json index 7096a13..dd8707b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "express": "^5.1.0", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", + "mathjs": "^15.1.0", "minio": "^8.0.6", "multer": "^2.0.2", "pg": "^8.16.3", diff --git a/server.js b/server.js index 1588c22..426a195 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ import messageRoutes from './src/routes/messages.js'; import branchRoutes from './src/routes/branch.js'; import thirdPartiesRoutes from './src/routes/thirdparties.js'; import cashRequestRoutes from './src/routes/cashRequestRoutes.js'; +import ahpRoutes from './src/routes/ahpRoutes.js'; import authRoutes from './src/routes/auth.js'; const app = express(); @@ -32,6 +33,7 @@ app.use('/api/v1/users', userRoutes); app.use('/api/v1/messages', messageRoutes); app.use('/api/v1/branches', branchRoutes); app.use('/api/v1/thirdparties', thirdPartiesRoutes); +app.use('/api/v1/ahp', ahpRoutes); // Basic route app.get('/api/', (req, res) => { diff --git a/src/controllers/ahpController.js b/src/controllers/ahpController.js new file mode 100644 index 0000000..1af8108 --- /dev/null +++ b/src/controllers/ahpController.js @@ -0,0 +1,28 @@ +import { computeDataDrivenAHP } from "../utils/pairwiseMatrix.js"; + +export const dataDrivenAHP = (req, res) => { + const { criteriaNames, alternativeNames, dataMatrix, benefitFlags } = req.body || {}; + + if (!req.body) { + return res.status(400).json({ error: 'Request body is required. Please send JSON with dataMatrix, criteriaNames, alternativeNames, and benefitFlags.' }); + } + + if (!Array.isArray(dataMatrix)) { + return res.status(400).json({ error: 'dataMatrix must be an array.' }); + } + + try { + console.log("Start running AHP computation"); + + const ahpResults = computeDataDrivenAHP({ + dataMatrix, + criteriaNames, + alternativeNames, + benefitFlags + }); + + res.status(200).json(ahpResults); + } catch (error) { + res.status(500).json({ error: error.message }); + } +} diff --git a/src/routes/ahpRoutes.js b/src/routes/ahpRoutes.js new file mode 100644 index 0000000..f444024 --- /dev/null +++ b/src/routes/ahpRoutes.js @@ -0,0 +1,8 @@ +import express from 'express'; +import { dataDrivenAHP } from '../controllers/ahpController.js'; + +const router = express.Router(); + +router.post('/', dataDrivenAHP) + +export default router; \ No newline at end of file diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/ahs.js b/src/utils/ahs.js new file mode 100644 index 0000000..2d437f5 --- /dev/null +++ b/src/utils/ahs.js @@ -0,0 +1,116 @@ +import { create, all } from "mathjs"; +import matrixValidation from "./validateMatrix.js"; +import weightVectorGeometricMean from "./computePriorityVector.js"; +import { consistencyRatio } from "./computeConsistancyRatio.js"; + +const math = create(all, {}); + +/** + * Compute AHP results. + * @param {number[][]} criteriaMatrix n x n + * @param {Object.} alternativeMatrices for each criterion key: m x m + * @param {string[]} criteriaNames length n + * @param {string[]} alternativeNames length m + * @returns structured results + */ + +export function computeAHS( + criteriaMatrix, + alternativeMatrices, + criteriaNames, + alternativeNames +) { + + // --- validate criteria matrix --- + matrixValidation(criteriaMatrix, 'criteriaMatrix'); + const n = criteriaMatrix.length; + + // --- validate criteria names matrix --- + if (!Array.isArray(criteriaNames) || criteriaNames.length !== n) { + throw new Error("criteriaNames length must match criteriaMatrix size."); + } + + // --- weights for criteria (GM method) + CR --- + const criteriaWeights = weightVectorGeometricMean(criteriaMatrix); + const criteriaCR = consistencyRatio(criteriaMatrix, criteriaWeights); + + // --- for each criterion, compute alt weights + CR --- + const altLocalWeightsByCriterion = {}; // key -> vector length m + const altCRByCriterion = {}; // key -> number + let m = null; + + for (let k = 0; k < n; k++) { + const key = criteriaNames[k]; + const M = alternativeMatrices[key]; + + if (!M) { + throw new Error(`Missing alternative matrix for criterion: ${key}`); + } + + matrixValidation(M, `alternativeMatrices["${key}"]`); + + if (m === null) m = M.length; + if (M.length !== m) { + throw new Error("All alternative matrices must be the same size (m x m)"); + } + + const w = weightVectorGeometricMean(M); + const cr = consistencyRatio(M, w); + + altLocalWeightsByCriterion[key] = w; + altCRByCriterion[key] = cr; + + } + + if (!Array.isArray(alternativeNames) || alternativeNames.length !== m) { + throw new Error("alternativeNames length must match alternative matrix size"); + } + + + // --- aggregate global scores: sum_k (criteriaWeight[k] * altWeight_k[i]) --- + const globalScores = new Array(m).fill(0); + for (let i = 0; i < m; i++) { + let s = 0; + for (let k = 0; k < n; k++) { + const key = criteriaNames[k]; + s += criteriaWeights[k] * altLocalWeightsByCriterion[key][i]; + } + globalScores[i] = s; + } + + // --- ranking indices sorted desc --- + const idx = globalScores.map((v, i) => i) + .sort((i, j) => globalScores[j] - globalScores[i]); + + const ranking = idx.map((i) => ({ + name: alternativeNames[i], + score: Number(globalScores[i].toFixed(6)) + })); + + return { + criteria: criteriaNames.map((name, i) => ({ + name, + weight: Number(criteriaWeights[i].toFixed(6)) + })), + criteriaCR: Number(criteriaCR.toFixed(6)), + alternatives: alternativeNames, + localWeights: Object.fromEntries( + Object.entries(altLocalWeightsByCriterion).map(([k, w]) => [ + k, + w.map((x) => Number(x.toFixed(6))) + ]) + ), + localCR: Object.fromEntries( + Object.entries(altCRByCriterion).map(([k, cr]) => [k, Number(cr.toFixed(6))]) + ), + globalScores: alternativeNames.map((name, i) => ({ + name, + score: Number(globalScores[i].toFixed(6)) + })), + ranking + }; + +} + + + diff --git a/src/utils/computeConsistancyRatio.js b/src/utils/computeConsistancyRatio.js new file mode 100644 index 0000000..6dca526 --- /dev/null +++ b/src/utils/computeConsistancyRatio.js @@ -0,0 +1,30 @@ +import { create, all } from "mathjs"; + +const math = create(all, {}); + +const RI = { + 1: 0.00, 2: 0.00, 3: 0.58, 4: 0.90, 5: 1.12, + 6: 1.24, 7: 1.32, 8: 1.41, 9: 1.45, 10: 1.49, + 11: 1.51, 12: 1.48, 13: 1.56, 14: 1.57, 15: 1.59 +}; + + +/** Consistency Ratio for a pairwise matrix M with weights w */ +export function lambdaMax(M, w) { + const Aw = math.multiply(M, w); // vector + let sum = 0; + for (let i = 0; i < w.length; i++) { + sum += (Aw[i] / w[i]); + } + return sum / w.length; +} + +/** Consistency Ratio for a pairwise matrix M with weights w */ +export function consistencyRatio(M, w) { + const n = M.length; + if (n <= 2) return 0; // CR undefined but effectively 0 + const lam = lambdaMax(M, w); + const CI = (lam - n) / (n - 1); + const ri = RI[n] ?? RI[15]; // cap if very large n + return ri === 0 ? 0 : CI / ri; +} \ No newline at end of file diff --git a/src/utils/computePriorityVector.js b/src/utils/computePriorityVector.js new file mode 100644 index 0000000..a667435 --- /dev/null +++ b/src/utils/computePriorityVector.js @@ -0,0 +1,15 @@ + +/** Geometric-mean priority vector for a positive reciprocal matrix */ +const weightVectorGeometricMean = (M) => { + const n = M.length; + const gms = new Array(n).fill(0); + for (let i = 0; i < n; i++) { + let prod = 1; + for (let j = 0; j < n; j++) prod *= Number(M[i][j]); + gms[i] = Math.pow(prod, 1 / n); + } + const sum = gms.reduce((a, b) => a + b, 0); + return gms.map((x) => x / sum); +} + +export default weightVectorGeometricMean; \ No newline at end of file diff --git a/src/utils/pairwiseMatrix.js b/src/utils/pairwiseMatrix.js new file mode 100644 index 0000000..5872865 --- /dev/null +++ b/src/utils/pairwiseMatrix.js @@ -0,0 +1,68 @@ +import { computeAHS } from './ahs.js'; + +/** Build pairwise matrix automatically from raw data */ +export function buildPairwiseMatrixFromData(values, isBenefit = true) { + const n = values.length; + const M = Array.from({ length: n }, () => new Array(n).fill(1)); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (i === j) { + M[i][j] = 1; + } else { + const ratio = isBenefit + ? values[i] / values[j] + : values[j] / values[i]; + M[i][j] = ratio; + } + } + } + return M; +} + +/** Data-driven AHP: convert data matrix to AHP results */ +export function computeDataDrivenAHP({ + dataMatrix, + criteriaNames, + alternativeNames, + benefitFlags +}) { + const n = criteriaNames.length; + const m = alternativeNames.length; + + if (dataMatrix.length !== m) + throw new Error("dataMatrix rows must match number of alternatives"); + if (dataMatrix[0].length !== n) + throw new Error("dataMatrix columns must match number of criteria"); + + // build pairwise matrices automatically + const alternativeMatrices = {}; + for (let k = 0; k < n; k++) { + const colValues = dataMatrix.map((row) => row[k]); + const isBenefit = benefitFlags?.[k] ?? true; + alternativeMatrices[criteriaNames[k]] = buildPairwiseMatrixFromData( + colValues, + isBenefit + ); + } + + // criteria matrix = equal importance (or replaceable) + const criteriaMatrix = Array.from({ length: n }, () => Array(n).fill(1)); + + // debug: log matrix shapes (removed after diagnosing issues) + try { + // avoid large dumps; log dimensions and a sample + const altKeys = Object.keys(alternativeMatrices); + const altShapes = altKeys.map(k => ({ key: k, size: alternativeMatrices[k]?.length ?? 0 })); + console.log('computeDataDrivenAHP: criteriaMatrix size=', criteriaMatrix.length, 'alternativeMatrices=', altShapes); + } catch (e) { + console.error('computeDataDrivenAHP debug error', e); + } + + + return computeAHS( + criteriaMatrix, + alternativeMatrices, + criteriaNames, + alternativeNames + ); +} diff --git a/src/utils/validateMatrix.js b/src/utils/validateMatrix.js new file mode 100644 index 0000000..a4ceb8f --- /dev/null +++ b/src/utils/validateMatrix.js @@ -0,0 +1,37 @@ + +const matrixValidation = (matrix, name = 'matrix') => { + const n = matrix.length; + + console.log("Validating matrix:", matrix); + + + if (!Array.isArray(matrix) || n === 0 || !Array.isArray(matrix[0])) { + throw new Error("Input must be a non-empty square matrix."); + } + + for (let i = 0; i < n; i++) { + if (!Array.isArray(matrix[i]) || matrix[i].length !== n) { + throw new Error("Input must be a square matrix."); + } + + const diag = Number(matrix[i][i]); + if (!isFinite(diag) || Math.abs(diag - 1) > 1e-8) { + throw new Error("Input must be a reciprocal matrix."); + } + + for (let j = 0; j < n; j++) { + const aij = Number(matrix[i][j]); + const aji = Number(matrix[j][i]); + if (!isFinite(aij) || !isFinite(aji)) { + throw new Error(`${name} has non-finite entries at (${i},${j})`); + } + if (i !== j && Math.abs(aij * aji - 1) > 1e-6) { + throw new Error( + `${name} must be reciprocal: a[${i}][${j}] ≈ 1 / a[${j}][${i}]` + ); + } + } + } +}; + +export default matrixValidation; \ No newline at end of file