@lp-labs/assignment-optimizer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Libin Prasanth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # @lp-labs/assignment-optimizer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Exact bounded unit-task assignment maximizing coverage then minimizing preference and convex load costs.
4
+
5
+ Original independent MIT TypeScript implementation with **zero runtime or peer dependencies**. ESM/CommonJS with separate declarations; Node 18+ and compatible browser runtimes. No external code is downloaded or service started at import. TypeScript/Prettier are development-only tools.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @lp-labs/assignment-optimizer
11
+ ```
12
+
13
+ ## Working example
14
+
15
+ ```js
16
+ import assert from 'node:assert/strict';
17
+ import { optimizeAssignments, AssignmentError } from '@lp-labs/assignment-optimizer';
18
+ const plan = optimizeAssignments(
19
+ [
20
+ { id: 'general', requires: [] },
21
+ { id: 'special', requires: ['sql'] },
22
+ ],
23
+ [
24
+ { id: 'expert', capacity: 1, skills: ['sql'] },
25
+ { id: 'generalist', capacity: 1, skills: [] },
26
+ ],
27
+ );
28
+ assert.equal(plan.assignments.length, 2);
29
+ assert.equal(plan.assignments.find((item) => item.task === 'special').worker, 'expert');
30
+ assert.equal(plan.unassigned.length, 0);
31
+ assert.equal(plan.objective, plan.cost + plan.fairnessCost);
32
+ ```
33
+
34
+ CommonJS: `const api = require('@lp-labs/assignment-optimizer')`. Use type-only imports for exported interfaces in TypeScript.
35
+
36
+ ## Behavior, customization, limits and performance
37
+
38
+ Real successive-shortest-augmenting-path min-cost maximum-flow optimizer for UNIT tasks. It maximizes assignment count FIRST, then minimizes sum(task-worker preference cost) + fairnessWeight * sum(afterLoad² - beforeLoad²). Defaults fairnessWeight1; integer0..1,000,000. More expensive feasible work is still assigned because coverage is primary. Existing load consumes capacity. Workers <=50, tasks<=100, capacity<=10,000, skills/requires <=64 unique strings each; preference costs0..1,000,000 for known workers, omitted costs0. Eligibility requires ALL declared skills, even when remaining capacity is zero. Unassigned reason no-eligible-worker vs capacity is explicit.
39
+
40
+ This is not a heuristic greedy planner: reverse residual edges can reroute previous assignments to satisfy constrained tasks. Bellman-Ford handles reverse negative edges; at most100 unit augmentations. Input order deterministically breaks equal-cost path ties, but equal-objective assignments have no fairness guarantee beyond the declared quadratic cost. Worst-case O(tasks * vertices * residualEdges), bounded for small interactive planning, not millions of tasks. Plan is pure, detached, safe-integer objective checked; no scheduling time, indivisible multi-unit demand, legal labor rules, priority tiers, reservations, worker execution or distributed locking. Skills supplied by caller, not inferred or verified credentials. Use the complete declared workload/availability snapshot and atomically reserve separately.
41
+
42
+ ## Errors and application integration
43
+
44
+ Use the exported error class and its documented code (where present); invalid structural input throws rather than silently defaulting. Await async methods and handle rejections. Trusted adapters/callbacks must meet documented ownership and isolation requirements. Hostile proxies and custom prototype objects are outside the contract; ordinary data properties are required. No runtime network/worker/database dependency is hidden behind the API. Examples use Node assertions for reproducible outcomes, not as package dependencies. The declared bounded subset is the supported functionality, not a claim of complete compatibility with another library.
45
+
46
+ ## Complete API and customization contract
47
+
48
+ The public declaration below specifies all exported functions, field names, options, return shapes and error codes. Configure explicit limits and supply callbacks/adapters through these interfaces; do not modify package internals. Optional fields follow the defaults and missing-data policies above. TypeScript consumers receive this same contract through both ESM and CommonJS exports.
49
+
50
+ ```ts
51
+ export interface AssignmentWorker {
52
+ id: string;
53
+ capacity: number;
54
+ load?: number;
55
+ skills: readonly string[];
56
+ }
57
+ export interface AssignmentTask {
58
+ id: string;
59
+ requires: readonly string[];
60
+ costs?: Readonly<Record<string, number>>;
61
+ }
62
+ export interface AssignmentOptions {
63
+ fairnessWeight?: number;
64
+ }
65
+ export interface AssignmentPlan {
66
+ assignments: {
67
+ task: string;
68
+ worker: string;
69
+ cost: number;
70
+ }[];
71
+ unassigned: {
72
+ task: string;
73
+ reason: 'no-eligible-worker' | 'capacity';
74
+ }[];
75
+ loads: {
76
+ worker: string;
77
+ before: number;
78
+ after: number;
79
+ }[];
80
+ objective: number;
81
+ cost: number;
82
+ fairnessCost: number;
83
+ }
84
+ export declare class AssignmentError extends Error {
85
+ readonly code: 'invalid' | 'limit' | 'overflow';
86
+ constructor(code: 'invalid' | 'limit' | 'overflow', message: string);
87
+ }
88
+ export declare function optimizeAssignments(
89
+ tasks: readonly AssignmentTask[],
90
+ workers: readonly AssignmentWorker[],
91
+ options?: AssignmentOptions,
92
+ ): AssignmentPlan;
93
+ ```
94
+
95
+ ## Support
96
+
97
+ If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
98
+
99
+ ## Author
100
+
101
+ Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
@@ -0,0 +1,38 @@
1
+ export interface AssignmentWorker {
2
+ id: string;
3
+ capacity: number;
4
+ load?: number;
5
+ skills: readonly string[];
6
+ }
7
+ export interface AssignmentTask {
8
+ id: string;
9
+ requires: readonly string[];
10
+ costs?: Readonly<Record<string, number>>;
11
+ }
12
+ export interface AssignmentOptions {
13
+ fairnessWeight?: number;
14
+ }
15
+ export interface AssignmentPlan {
16
+ assignments: {
17
+ task: string;
18
+ worker: string;
19
+ cost: number;
20
+ }[];
21
+ unassigned: {
22
+ task: string;
23
+ reason: 'no-eligible-worker' | 'capacity';
24
+ }[];
25
+ loads: {
26
+ worker: string;
27
+ before: number;
28
+ after: number;
29
+ }[];
30
+ objective: number;
31
+ cost: number;
32
+ fairnessCost: number;
33
+ }
34
+ export declare class AssignmentError extends Error {
35
+ readonly code: 'invalid' | 'limit' | 'overflow';
36
+ constructor(code: 'invalid' | 'limit' | 'overflow', message: string);
37
+ }
38
+ export declare function optimizeAssignments(tasks: readonly AssignmentTask[], workers: readonly AssignmentWorker[], options?: AssignmentOptions): AssignmentPlan;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AssignmentError = void 0;
4
+ exports.optimizeAssignments = optimizeAssignments;
5
+ class AssignmentError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'AssignmentError';
11
+ }
12
+ }
13
+ exports.AssignmentError = AssignmentError;
14
+ function names(values) {
15
+ if (!Array.isArray(values) ||
16
+ values.length > 64 ||
17
+ values.some((value) => typeof value !== 'string' || !value || value.length > 128) ||
18
+ new Set(values).size !== values.length)
19
+ throw new AssignmentError('invalid', 'Invalid skills.');
20
+ return new Set(values);
21
+ }
22
+ function optimizeAssignments(tasks, workers, options = {}) {
23
+ if (!Array.isArray(tasks) || !Array.isArray(workers) || tasks.length > 100 || workers.length > 50)
24
+ throw new AssignmentError('limit', 'At most 100 unit tasks and 50 workers supported.');
25
+ const fairnessWeight = options.fairnessWeight ?? 1;
26
+ if (!Number.isSafeInteger(fairnessWeight) || fairnessWeight < 0 || fairnessWeight > 1000000)
27
+ throw new AssignmentError('invalid', 'fairnessWeight must be an integer from 0 to 1000000.');
28
+ const ids = new Set();
29
+ const team = workers.map((worker) => {
30
+ if (!worker ||
31
+ typeof worker.id !== 'string' ||
32
+ !worker.id ||
33
+ worker.id.length > 256 ||
34
+ ids.has(worker.id) ||
35
+ !Number.isSafeInteger(worker.capacity) ||
36
+ worker.capacity < 0 ||
37
+ worker.capacity > 10000 ||
38
+ !Number.isSafeInteger(worker.load ?? 0) ||
39
+ (worker.load ?? 0) < 0 ||
40
+ (worker.load ?? 0) > worker.capacity)
41
+ throw new AssignmentError('invalid', 'Invalid worker/duplicate ID.');
42
+ ids.add(worker.id);
43
+ return {
44
+ id: worker.id,
45
+ capacity: worker.capacity,
46
+ load: worker.load ?? 0,
47
+ skills: names(worker.skills),
48
+ };
49
+ });
50
+ ids.clear();
51
+ const work = tasks.map((task) => {
52
+ if (!task ||
53
+ typeof task.id !== 'string' ||
54
+ !task.id ||
55
+ task.id.length > 256 ||
56
+ ids.has(task.id))
57
+ throw new AssignmentError('invalid', 'Invalid task/duplicate ID.');
58
+ ids.add(task.id);
59
+ const required = names(task.requires);
60
+ const costs = new Map();
61
+ if (task.costs !== undefined) {
62
+ if (!task.costs ||
63
+ Object.getPrototypeOf(task.costs) !== Object.prototype ||
64
+ Object.getOwnPropertySymbols(task.costs).length ||
65
+ Object.keys(task.costs).length > 50)
66
+ throw new AssignmentError('invalid', 'Invalid cost data.');
67
+ for (const id of Object.keys(task.costs)) {
68
+ const descriptor = Object.getOwnPropertyDescriptor(task.costs, id);
69
+ if (!team.some((worker) => worker.id === id) ||
70
+ !('value' in descriptor) ||
71
+ !Number.isSafeInteger(descriptor.value) ||
72
+ descriptor.value < 0 ||
73
+ descriptor.value > 1000000)
74
+ throw new AssignmentError('invalid', 'Cost must reference a known worker and be 0..1000000.');
75
+ costs.set(id, descriptor.value);
76
+ }
77
+ }
78
+ return { id: task.id, required, costs };
79
+ });
80
+ const source = 0, taskStart = 1, workerStart = taskStart + work.length, sink = workerStart + team.length;
81
+ const graph = Array.from({ length: sink + 1 }, () => []);
82
+ const add = (from, to, cost) => {
83
+ const forward = { to, reverse: graph[to].length, capacity: 1, cost };
84
+ const backward = { to: from, reverse: graph[from].length, capacity: 0, cost: -cost };
85
+ graph[from].push(forward);
86
+ graph[to].push(backward);
87
+ return forward;
88
+ };
89
+ const pairs = [];
90
+ const eligible = work.map(() => 0);
91
+ work.forEach((task, i) => {
92
+ add(source, taskStart + i, 0);
93
+ team.forEach((worker, j) => {
94
+ if ([...task.required].every((skill) => worker.skills.has(skill))) {
95
+ eligible[i] = eligible[i] + 1;
96
+ const cost = task.costs.get(worker.id) ?? 0;
97
+ pairs.push({ task: i, worker: j, edge: add(taskStart + i, workerStart + j, cost), cost });
98
+ }
99
+ });
100
+ });
101
+ team.forEach((worker, j) => {
102
+ const slots = Math.min(work.length, worker.capacity - worker.load);
103
+ for (let slot = 1; slot <= slots; slot++)
104
+ add(workerStart + j, sink, fairnessWeight * (2 * (worker.load + slot) - 1));
105
+ });
106
+ let objective = 0;
107
+ // Successive shortest augmenting paths retain reverse edges, so earlier assignments can be rerouted.
108
+ while (true) {
109
+ const distance = Array(graph.length).fill(Infinity);
110
+ const previous = Array.from({
111
+ length: graph.length,
112
+ });
113
+ distance[source] = 0;
114
+ for (let pass = 0; pass < graph.length - 1; pass++) {
115
+ let changed = false;
116
+ for (let from = 0; from < graph.length; from++) {
117
+ if (!Number.isFinite(distance[from]))
118
+ continue;
119
+ graph[from].forEach((edge, index) => {
120
+ const candidate = distance[from] + edge.cost;
121
+ if (edge.capacity && candidate < distance[edge.to]) {
122
+ distance[edge.to] = candidate;
123
+ previous[edge.to] = { node: from, edge: index };
124
+ changed = true;
125
+ }
126
+ });
127
+ }
128
+ if (!changed)
129
+ break;
130
+ }
131
+ if (!Number.isFinite(distance[sink]))
132
+ break;
133
+ objective += distance[sink];
134
+ if (!Number.isSafeInteger(objective))
135
+ throw new AssignmentError('overflow', 'Objective overflow.');
136
+ for (let node = sink; node !== source;) {
137
+ const step = previous[node];
138
+ if (!step)
139
+ throw new AssignmentError('invalid', 'Residual path invariant failed.');
140
+ const edge = graph[step.node][step.edge];
141
+ edge.capacity--;
142
+ graph[node][edge.reverse].capacity++;
143
+ node = step.node;
144
+ }
145
+ }
146
+ const assignments = [];
147
+ const assigned = new Set();
148
+ const counts = team.map(() => 0);
149
+ let cost = 0;
150
+ for (const pair of pairs)
151
+ if (pair.edge.capacity === 0) {
152
+ assignments.push({
153
+ task: work[pair.task].id,
154
+ worker: team[pair.worker].id,
155
+ cost: pair.cost,
156
+ });
157
+ assigned.add(pair.task);
158
+ counts[pair.worker] = counts[pair.worker] + 1;
159
+ cost += pair.cost;
160
+ }
161
+ const loads = team.map((worker, index) => ({
162
+ worker: worker.id,
163
+ before: worker.load,
164
+ after: worker.load + counts[index],
165
+ }));
166
+ const fairnessCost = loads.reduce((sum, item) => sum + fairnessWeight * (item.after ** 2 - item.before ** 2), 0);
167
+ if (cost + fairnessCost !== objective)
168
+ throw new AssignmentError('invalid', 'Objective accounting invariant failed.');
169
+ return {
170
+ assignments,
171
+ unassigned: work.flatMap((task, index) => assigned.has(index)
172
+ ? []
173
+ : [
174
+ {
175
+ task: task.id,
176
+ reason: eligible[index] === 0 ? 'no-eligible-worker' : 'capacity',
177
+ },
178
+ ]),
179
+ loads,
180
+ objective,
181
+ cost,
182
+ fairnessCost,
183
+ };
184
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,38 @@
1
+ export interface AssignmentWorker {
2
+ id: string;
3
+ capacity: number;
4
+ load?: number;
5
+ skills: readonly string[];
6
+ }
7
+ export interface AssignmentTask {
8
+ id: string;
9
+ requires: readonly string[];
10
+ costs?: Readonly<Record<string, number>>;
11
+ }
12
+ export interface AssignmentOptions {
13
+ fairnessWeight?: number;
14
+ }
15
+ export interface AssignmentPlan {
16
+ assignments: {
17
+ task: string;
18
+ worker: string;
19
+ cost: number;
20
+ }[];
21
+ unassigned: {
22
+ task: string;
23
+ reason: 'no-eligible-worker' | 'capacity';
24
+ }[];
25
+ loads: {
26
+ worker: string;
27
+ before: number;
28
+ after: number;
29
+ }[];
30
+ objective: number;
31
+ cost: number;
32
+ fairnessCost: number;
33
+ }
34
+ export declare class AssignmentError extends Error {
35
+ readonly code: 'invalid' | 'limit' | 'overflow';
36
+ constructor(code: 'invalid' | 'limit' | 'overflow', message: string);
37
+ }
38
+ export declare function optimizeAssignments(tasks: readonly AssignmentTask[], workers: readonly AssignmentWorker[], options?: AssignmentOptions): AssignmentPlan;
@@ -0,0 +1,179 @@
1
+ export class AssignmentError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'AssignmentError';
7
+ }
8
+ }
9
+ function names(values) {
10
+ if (!Array.isArray(values) ||
11
+ values.length > 64 ||
12
+ values.some((value) => typeof value !== 'string' || !value || value.length > 128) ||
13
+ new Set(values).size !== values.length)
14
+ throw new AssignmentError('invalid', 'Invalid skills.');
15
+ return new Set(values);
16
+ }
17
+ export function optimizeAssignments(tasks, workers, options = {}) {
18
+ if (!Array.isArray(tasks) || !Array.isArray(workers) || tasks.length > 100 || workers.length > 50)
19
+ throw new AssignmentError('limit', 'At most 100 unit tasks and 50 workers supported.');
20
+ const fairnessWeight = options.fairnessWeight ?? 1;
21
+ if (!Number.isSafeInteger(fairnessWeight) || fairnessWeight < 0 || fairnessWeight > 1000000)
22
+ throw new AssignmentError('invalid', 'fairnessWeight must be an integer from 0 to 1000000.');
23
+ const ids = new Set();
24
+ const team = workers.map((worker) => {
25
+ if (!worker ||
26
+ typeof worker.id !== 'string' ||
27
+ !worker.id ||
28
+ worker.id.length > 256 ||
29
+ ids.has(worker.id) ||
30
+ !Number.isSafeInteger(worker.capacity) ||
31
+ worker.capacity < 0 ||
32
+ worker.capacity > 10000 ||
33
+ !Number.isSafeInteger(worker.load ?? 0) ||
34
+ (worker.load ?? 0) < 0 ||
35
+ (worker.load ?? 0) > worker.capacity)
36
+ throw new AssignmentError('invalid', 'Invalid worker/duplicate ID.');
37
+ ids.add(worker.id);
38
+ return {
39
+ id: worker.id,
40
+ capacity: worker.capacity,
41
+ load: worker.load ?? 0,
42
+ skills: names(worker.skills),
43
+ };
44
+ });
45
+ ids.clear();
46
+ const work = tasks.map((task) => {
47
+ if (!task ||
48
+ typeof task.id !== 'string' ||
49
+ !task.id ||
50
+ task.id.length > 256 ||
51
+ ids.has(task.id))
52
+ throw new AssignmentError('invalid', 'Invalid task/duplicate ID.');
53
+ ids.add(task.id);
54
+ const required = names(task.requires);
55
+ const costs = new Map();
56
+ if (task.costs !== undefined) {
57
+ if (!task.costs ||
58
+ Object.getPrototypeOf(task.costs) !== Object.prototype ||
59
+ Object.getOwnPropertySymbols(task.costs).length ||
60
+ Object.keys(task.costs).length > 50)
61
+ throw new AssignmentError('invalid', 'Invalid cost data.');
62
+ for (const id of Object.keys(task.costs)) {
63
+ const descriptor = Object.getOwnPropertyDescriptor(task.costs, id);
64
+ if (!team.some((worker) => worker.id === id) ||
65
+ !('value' in descriptor) ||
66
+ !Number.isSafeInteger(descriptor.value) ||
67
+ descriptor.value < 0 ||
68
+ descriptor.value > 1000000)
69
+ throw new AssignmentError('invalid', 'Cost must reference a known worker and be 0..1000000.');
70
+ costs.set(id, descriptor.value);
71
+ }
72
+ }
73
+ return { id: task.id, required, costs };
74
+ });
75
+ const source = 0, taskStart = 1, workerStart = taskStart + work.length, sink = workerStart + team.length;
76
+ const graph = Array.from({ length: sink + 1 }, () => []);
77
+ const add = (from, to, cost) => {
78
+ const forward = { to, reverse: graph[to].length, capacity: 1, cost };
79
+ const backward = { to: from, reverse: graph[from].length, capacity: 0, cost: -cost };
80
+ graph[from].push(forward);
81
+ graph[to].push(backward);
82
+ return forward;
83
+ };
84
+ const pairs = [];
85
+ const eligible = work.map(() => 0);
86
+ work.forEach((task, i) => {
87
+ add(source, taskStart + i, 0);
88
+ team.forEach((worker, j) => {
89
+ if ([...task.required].every((skill) => worker.skills.has(skill))) {
90
+ eligible[i] = eligible[i] + 1;
91
+ const cost = task.costs.get(worker.id) ?? 0;
92
+ pairs.push({ task: i, worker: j, edge: add(taskStart + i, workerStart + j, cost), cost });
93
+ }
94
+ });
95
+ });
96
+ team.forEach((worker, j) => {
97
+ const slots = Math.min(work.length, worker.capacity - worker.load);
98
+ for (let slot = 1; slot <= slots; slot++)
99
+ add(workerStart + j, sink, fairnessWeight * (2 * (worker.load + slot) - 1));
100
+ });
101
+ let objective = 0;
102
+ // Successive shortest augmenting paths retain reverse edges, so earlier assignments can be rerouted.
103
+ while (true) {
104
+ const distance = Array(graph.length).fill(Infinity);
105
+ const previous = Array.from({
106
+ length: graph.length,
107
+ });
108
+ distance[source] = 0;
109
+ for (let pass = 0; pass < graph.length - 1; pass++) {
110
+ let changed = false;
111
+ for (let from = 0; from < graph.length; from++) {
112
+ if (!Number.isFinite(distance[from]))
113
+ continue;
114
+ graph[from].forEach((edge, index) => {
115
+ const candidate = distance[from] + edge.cost;
116
+ if (edge.capacity && candidate < distance[edge.to]) {
117
+ distance[edge.to] = candidate;
118
+ previous[edge.to] = { node: from, edge: index };
119
+ changed = true;
120
+ }
121
+ });
122
+ }
123
+ if (!changed)
124
+ break;
125
+ }
126
+ if (!Number.isFinite(distance[sink]))
127
+ break;
128
+ objective += distance[sink];
129
+ if (!Number.isSafeInteger(objective))
130
+ throw new AssignmentError('overflow', 'Objective overflow.');
131
+ for (let node = sink; node !== source;) {
132
+ const step = previous[node];
133
+ if (!step)
134
+ throw new AssignmentError('invalid', 'Residual path invariant failed.');
135
+ const edge = graph[step.node][step.edge];
136
+ edge.capacity--;
137
+ graph[node][edge.reverse].capacity++;
138
+ node = step.node;
139
+ }
140
+ }
141
+ const assignments = [];
142
+ const assigned = new Set();
143
+ const counts = team.map(() => 0);
144
+ let cost = 0;
145
+ for (const pair of pairs)
146
+ if (pair.edge.capacity === 0) {
147
+ assignments.push({
148
+ task: work[pair.task].id,
149
+ worker: team[pair.worker].id,
150
+ cost: pair.cost,
151
+ });
152
+ assigned.add(pair.task);
153
+ counts[pair.worker] = counts[pair.worker] + 1;
154
+ cost += pair.cost;
155
+ }
156
+ const loads = team.map((worker, index) => ({
157
+ worker: worker.id,
158
+ before: worker.load,
159
+ after: worker.load + counts[index],
160
+ }));
161
+ const fairnessCost = loads.reduce((sum, item) => sum + fairnessWeight * (item.after ** 2 - item.before ** 2), 0);
162
+ if (cost + fairnessCost !== objective)
163
+ throw new AssignmentError('invalid', 'Objective accounting invariant failed.');
164
+ return {
165
+ assignments,
166
+ unassigned: work.flatMap((task, index) => assigned.has(index)
167
+ ? []
168
+ : [
169
+ {
170
+ task: task.id,
171
+ reason: eligible[index] === 0 ? 'no-eligible-worker' : 'capacity',
172
+ },
173
+ ]),
174
+ loads,
175
+ objective,
176
+ cost,
177
+ fairnessCost,
178
+ };
179
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@lp-labs/assignment-optimizer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Exact bounded unit-task assignment maximizing coverage then minimizing preference and convex load costs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/cjs/index.js",
8
+ "module": "./dist/esm/index.js",
9
+ "types": "./dist/esm/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": {
13
+ "types": "./dist/esm/index.d.ts",
14
+ "default": "./dist/esm/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/cjs/index.d.ts",
18
+ "default": "./dist/cjs/index.js"
19
+ }
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "build": "node scripts/build.mjs",
34
+ "typecheck": "npm run build && node scripts/check-types.mjs",
35
+ "test": "npm run build && node --test tests/*.test.mjs",
36
+ "format": "prettier --write src scripts tests *.json README.md",
37
+ "format:check": "prettier --check src scripts tests *.json README.md",
38
+ "prepack": "npm run build"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.9.3",
42
+ "prettier": "^3.6.2"
43
+ },
44
+ "prettier": {
45
+ "singleQuote": true,
46
+ "semi": true,
47
+ "trailingComma": "all",
48
+ "printWidth": 100,
49
+ "endOfLine": "lf"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "registry": "https://registry.npmjs.org/"
54
+ },
55
+ "author": {
56
+ "name": "Libin Prasanth",
57
+ "url": "https://www.linkedin.com/in/libinprasanth/"
58
+ },
59
+ "funding": {
60
+ "type": "paypal",
61
+ "url": "https://www.paypal.me/LibinPrasanth"
62
+ }
63
+ }