assignment-user-matcher
v1.10.0
Published
[](https://badge.fury.io/js/assignment-user-matcher) [](https://github.
Downloads
1,093
Readme
assignment-user-matcher: Lightning-Fast, Tag-Based User & Assignment Matching
Tired of inefficiently assigning tasks or struggling to connect the right users with the right work? assignment-user-matcher is a specialized Node.js library designed for high-performance, near real-time matching of a smaller pool of users to a large volume of assignments, primarily based on shared tags and priority.
It leverages the speed and efficiency of Redis to deliver a robust solution perfect for scenarios like call centers, customer support queues, back-office operations, and more.
The Challenge: Efficiently Connecting Users to Work
In many applications, from customer service platforms to internal task management systems, the core challenge remains the same: how do you quickly and accurately assign incoming work (assignments) to the best available person (user)? This becomes particularly complex when dealing with:
- Large volumes of incoming assignments: Tasks can pile up quickly, demanding immediate attention.
- Real-time requirements: Users expect instant responses, and assignments need prompt handling.
- Varying skill sets and priorities: Not all users are equipped for all tasks, and some assignments are more critical than others.
Traditional matching systems can become bottlenecks, leading to delays, suboptimal pairings, and frustrated users or customers.
Introducing assignment-user-matcher: Your Solution for Smart Task Distribution
assignment-user-matcher tackles these challenges head-on by providing a specialized engine optimized for a common pattern: a relatively small, active set of users (like support agents or back-office staff) processing a continuous, large stream of assignments.
Key Features:
- Tag-Based Matching: Intelligently connect users and assignments based on an intersection of descriptive tags (e.g., skills, language, department).
- Priority-Aware: Ensure that high-priority assignments are surfaced and processed first.
- Redis-Powered: Utilizes Redis for its in-memory data structures, enabling blazing-fast lookups and operations.
- Optimized for Specific Scenarios: Excels where a smaller group of users handles a large number of assignments.
- User Backlog Management: Prevents users from being overwhelmed by limiting the number of assignments they can be tentatively matched with.
Why assignment-user-matcher?
- 🚀 Speed: Designed for near real-time performance, ensuring assignments are matched and delivered swiftly.
- 🎯 Accuracy: Improves the quality of matches by considering relevant tags and priorities.
- ⚙️ Efficiency: Optimizes resource allocation, ensuring users are working on the most appropriate and urgent tasks.
- 😌 Improved Experience: Leads to faster response times for customers and a more streamlined workflow for users.
- 💡 Scalable (for its niche): Handles a large throughput of assignments efficiently.
Core Concepts: How It Works
assignment-user-matcher operates on a few key principles:
Users and Assignments Have Tags:
- Users: Each user is defined by an ID and a set of
tagsrepresenting their skills, capabilities, or any other relevant attributes (e.g.,['english', 'billing_support', 'tier_1']). - Assignments: Each assignment has an ID,
tagsdescribing its requirements (e.g.,['english', 'billing_inquiry']), and acreatedAttimestamp (or a custom priority score) to determine its urgency.
- Users: Each user is defined by an ID and a set of
Matching via Tag Intersection: The core matching logic finds users whose tags have a common intersection with an assignment's tags.
When using routingWeights with the built-in matcher:
- Only positive weights (
> 0) make assignments eligible. - Weight
0is a hard veto (exact tag or suffix wildcard likelang:*). - Wildcards are suffix-only (
prefix*). Patterns likeskill:*:nodeare not treated as wildcards. - If a user has
routingWeightsbut no positive entries, assignments stay queued.
Hard veto configuration example:
await assignmentMatcher.addUser({
id: 'agent_1',
tags: [],
routingWeights: {
'support:*': 100, // eligible
'lang:*': 0, // hard veto for all language-tagged assignments
default: 0, // hard veto for default matching fallback
},
});Note: usingDefaultMatchScore is an internal implementation detail and is not a public option.
Configure hard veto behavior only through routingWeights values.
Assignments can also veto specific users from their side: setting vetoedUsers: ['user_1'] on an
assignment guarantees those users are never matched to it, no matter how well their tags, weights,
or other criteria fit (this also overrides workflow targeting).
Prioritization Engine: Assignments are typically processed in the order they are received or by a custom prioritization function you can provide. This ensures that older or more critical assignments get attention first. The library aims to match the highest priority assignments to available, suitable users.
Redis as the Backbone: Redis is used to store user availability, assignment details, and manage the matching process. Its speed is crucial for the near real-time performance of the library. Users are maintained in sorted sets based on their backlog size and last activity, ensuring fair and efficient distribution.
User Backlog (
maxUserBacklogSize): To prevent any single user from being assigned too many tasks at once (even if they are a match),assignment-user-matchermaintains a backlog for each user. New assignments are only matched if a user's backlog is below this threshold.
Real-World Use Cases
assignment-user-matcher is particularly well-suited for:
- 📞 Call Centers: Routing incoming calls (assignments) to the agent (user) with the right language skills, product knowledge, and availability.
- 🎫 Customer Support Ticketing: Assigning new support tickets to support agents based on their expertise (e.g., "technical_issue," "api_support") and the ticket's urgency.
- 🗄️ Back-Office Operations: Distributing tasks like data entry, document verification, or claims processing to available staff members based on task type and employee skills.
- 🚗 Gig Economy Platforms (Niche): Matching available service providers to incoming customer requests where the provider pool is managed and assignments are plentiful (e.g., a dispatch system for a fleet of delivery drivers).
- ** Leads Distribution:** Assigning sales leads to representatives based on territory, product specialization, or lead score.
Getting Started
1. Installation
npm install assignment-user-matcher redis
# or
yarn add assignment-user-matcher redis
# or
pnpm add assignment-user-matcher redis2. Basic Usage
Here's a simple example to get you up and running:
import AssignmentMatcher from 'assignment-user-matcher'; // or `import { AssignmentMatcher } from 'assignment-user-matcher';`
import { createClient } from 'redis';
async function runExample() {
// Connect to your Redis instance
const redisClient = createClient({
// url: 'redis://your-redis-host:6379' // Optional: specify Redis URL if not default
});
await redisClient.connect();
console.log('Connected to Redis.');
const assignmentMatcher = new AssignmentMatcher(redisClient, {
redisPrefix: 'exampleApp:', // Good practice to namespace your Redis keys
maxUserBacklogSize: 5, // Max 5 assignments per user backlog
enableDefaultMatching: true,
});
// Add a user
await assignmentMatcher.addUser({
id: 'agent_007',
tags: ['english', 'technical_support', 'vip_clients'],
});
console.log("User 'agent_007' added.");
// Add an assignment
await assignmentMatcher.addAssignment({
id: 'ticket_12345',
tags: ['english', 'technical_support'],
createdAt: new Date().getTime(), // Use current time for priority
});
console.log("Assignment 'ticket_12345' added.");
// Run the matching process
// This will attempt to match pending assignments to available users
console.log('Running matching process...');
await assignmentMatcher.matchUsersAssignments();
// Check current assignments for the user
const userAssignments = await assignmentMatcher.getCurrentAssignmentsForUser('agent_007');
console.log(`Assignments for 'agent_007':`, userAssignments);
if (userAssignments && userAssignments.length > 0) {
console.log(`User 'agent_007' is matched with assignment '${userAssignments[0].id}'.`);
} else {
console.log(`User 'agent_007' has no assignments currently.`);
}
// Clean up (optional, depending on your Redis setup)
// await redisClient.flushDb(); // Be careful with this in production!
await redisClient.quit();
console.log('Disconnected from Redis.');
}
runExample().catch(console.error);3. Workflow Quick Start
Workflows are easiest to use with the builder helpers and the executeWorkflow() convenience method.
import AssignmentMatcher, { workflow } from 'assignment-user-matcher';
const matcher = new AssignmentMatcher(redisClient, {
enableWorkflows: true,
redisPrefix: 'exampleApp:',
});
// Optional if your Redis client is already connected.
// Useful when the matcher is created with a closed client.
await matcher.waitUntilReady();
const onboardingWorkflow = workflow('onboarding', 'Onboarding')
.step('profile')
.name('Complete profile')
.assignment({ tags: ['profile'], title: 'Complete your profile' })
.targetUser('initiator')
.defaultNext('review')
.done()
.step('review')
.name('Manager review')
.assignment({ tags: ['review'], title: 'Review onboarding' })
.targetUser({ tag: 'managers' })
.defaultNext(null)
.done()
.build();
// Registers the definition if needed, then starts the workflow instance.
const instance = await matcher.executeWorkflow(onboardingWorkflow, 'agent_007', {
source: 'signup',
});
console.log(instance.id, instance.currentStepId);You can still call registerWorkflow() and startWorkflow() separately if you want explicit lifecycle control.
Escalation ladders (escalateTo)
A step whose timeout expires normally fails the run (after any maxRetries). escalateTo(stepId) turns that timeout into a forward hop instead — the classic page-the-primary-then-the-secondary ladder:
const escalation = workflow('oncall', 'On-call escalation')
.step('page-primary')
.assignment({ tags: ['sev:1', 'oncall-primary'] })
.targetUser({ tag: 'oncall-primary' })
.timeout(60_000)
.escalateTo('page-secondary')
.route('result.acked === true', 'mitigate')
.done()
.step('page-secondary')
.assignment({ tags: ['sev:1', 'oncall-secondary'] })
.targetUser({ tag: 'oncall-secondary' })
.timeout(300_000)
.escalateTo('page-manager')
.done();
// … 'page-manager' has no escalateTo: the ladder ends and the run fails,
// which is the honest "escalated to the top and nobody answered" state.When a step escalates, its outstanding assignment is removed so a late responder cannot act on a superseded tier, context._escalatedFrom and context._escalationDepth are set, and a step.escalated transition is emitted (in addition to step.expired). Escalating does not consume maxRetries — a different person getting the work is not another attempt at the same one. maxEscalationDepth(n) (default 10) stops a mis-wired cycle from climbing forever.
Registration rejects an escalation target that doesn't exist, a step escalating to itself, escalation without a resolvable timeout, and escalation from inside a parallel group.
Targeting matters: targetUser({ tag: '…' }) routes the step's assignment through ordinary tag matching (so fairness, backlog caps, and the rolling-window grant cap all apply), while targetUser('<userId>') makes it workflow-targeted and therefore exempt from the fairness window cap. For tiered escalation you almost always want the tag form.
Plain object workflow definitions are also accepted. The library now fills in sensible defaults:
versiondefaults to1initialStepIddefaults to the first step ID- invalid definitions fail early during registration with clear validation errors
4. Scaling & Reliability for Workflows
The workflow engine is designed to run with many orchestrator replicas over large instance counts. The key building blocks:
- Indexed step timeouts — step expirations are tracked in a sorted set and claimed atomically, so
processExpiredWorkflowSteps()is O(due steps) instead of scanning every instance, and an expiry fires on exactly one replica. - In-place conflict retries — optimistic-lock (
VERSION_MISMATCH) conflicts are retried immediately with a fresh read instead of waiting for the orphan-reclaim window. - Delayed retry queue — failed events are scheduled for retry with exponential backoff (
workflowRetryBackoffMs, default 1000ms initial delay) and drained automatically by the orchestrator; retries are claimed atomically across replicas. - Flow rate control — tune orchestrator throughput per replica:
workflowEventBatchSize(XREADGROUP COUNT, default 10),workflowPollBlockMs(blocking poll wait, default 5000ms), andworkflowMaxEventsPerSecondto cap event processing (applies to both stream consumption and retry draining; unlimited when unset). - Per-event idempotency markers — processed-event markers carry their own TTL (
workflowIdempotencyTtlMs), and replayed step executions generate deterministic assignment IDs so crash-replays never create duplicate assignments. - Shared circuit breaker — set
circuitBreakerShared: trueto converge breaker state across replicas through a shared Redis failure counter (recommended for multi-replica deployments). - Instance retention — set
workflowInstanceRetentionMsto expire terminal (completed/failed/cancelled) instances and clean their registry/index entries. Unset (the default) keeps them forever, matching previous behavior; a value in the range of days to weeks is recommended for high-volume deployments.
Operational helpers:
const matcher = new AssignmentMatcher(redisClient, {
enableWorkflows: true,
circuitBreakerShared: true,
workflowInstanceRetentionMs: 30 * 24 * 60 * 60 * 1000, // 30 days
});
// Machine steps can run through named handlers with timeout enforcement
// (step.timeoutMs / defaultTimeoutMs); falls back to executeMachineTask.
matcher.registerMachineHandler('score-lead', async ({ instance, step }) => {
return { score: 42 };
});
// One-time migration for deployments created before the indexes existed
await matcher.backfillWorkflowIndexes();
// Periodic maintenance for deployments without retention configured
await matcher.pruneWorkflowInstances(30 * 24 * 60 * 60 * 1000);
// Monitoring: active instances, retry queue depth, DLQ size, stream stats
const metrics = await matcher.getWorkflowMetrics();API Reference
The main class provided is AssignmentMatcher.
new AssignmentMatcher(redisClient, options?)
Creates a new AssignmentMatcher instance.
redisClient: An initialized and connectedredisclient instance (version 4+).options(Optional): Configuration options for the matcher. See Options below.
addUser(user: User): Promise<void>
Adds or updates a user in the system.
user: An object representing the user.id: string: Unique identifier for the user.tags: string[]: Array of tags associated with the user.maxBacklogSize?: number: Optional per-user backlog cap overriding the matcher-widemaxUserBacklogSizein every matching path (0= receive nothing; negative or non-numeric values are ignored). The fairness rolling-window auto-cap derivation stays team-level and keeps using the global value.
addAssignment(assignment: Assignment): Promise<void>
Adds a new assignment to be matched.
assignment: An object representing the assignment.id: string: Unique identifier for the assignment.tags: string[]: Array of tags required for the assignment.createdAt: number: Timestamp (e.g.,new Date().getTime()) indicating when the assignment was created. Used for default prioritization (older assignments get higher priority). Can be influenced byprioritizationFunction.priority?: number | string: Optional explicit priority. IfprioritizationFunctionis used, this might be an input to it.vetoedUsers?: string[]: Optional list of user IDs that must never receive this assignment, regardless of any other matching criteria (tags,routingWeights, priority, or workflow targeting). Vetoed assignments are excluded from a user's candidate pool before scoring, so the veto adds no per-match overhead. To change an assignment's vetoes, remove and re-add the assignment.
matchUsersAssignments(): Promise<void>
Triggers the matching process. This method iterates through pending assignments and tries to match them with suitable, available users based on tags and user backlog capacity. With no userId, every eligible user is evaluated in parallel by default and contested assignments go to whoever's claim resolves first - see the fairness option in Options to make the best-scoring candidate win deterministically ('best-match'), or to additionally balance load across users ('balanced', 'spread-work').
getCurrentAssignmentsForUser(userId: string): Promise<Assignment[]>
Retrieves the current list of assignments that a user is tentatively matched with (i.e., in their backlog).
userId: string: The ID of the user.
getPendingAssignmentsWithAge(): Promise<PendingAssignmentInfo[]>
Retrieves current pending assignments, including who owns each assignment and how long it has been pending.
assignment: The assignment payload.ownerId: The current owner user ID, ornullif missing.pendingForMs: Elapsed pending time in milliseconds.pendingSince: Unix timestamp (ms) when the assignment entered pending state.expiresAt: Unix timestamp (ms) when pending expiration is scheduled.
pauseUser(userId: string): Promise<boolean> / resumeUser(userId: string): Promise<boolean>
Operator availability controls. A paused user stops receiving new assignments in every matching path but keeps their pending backlog and can still accept, complete, or reject work they already hold. Paused users are exempt from idle auto-removal (idleUserTimeoutMs) — pausing is deliberate absence, not idleness. pauseUser returns false when the user is not in the pool; resumeUser returns false when the user was not paused, and records activity (resets the idle clock) when it succeeds. Inspect state with isUserPaused(userId) and getPausedUsers().
When decision traces are enabled, paused users appear struck out in traces with a paused reason, and explainMatch() reports them as ineligible.
releaseUserAssignments(userId: string): Promise<string[]>
The redistribution counterpart to pauseUser: requeue every pending (matched but not yet accepted) assignment the user holds so other users can pick them up — for when a worker is gone rather than briefly away. Accepted assignments (work in progress) are never touched. Requeued assignments keep their original wait clock (getQueueStats().oldestWaitingMs does not reset), workflow subscribers receive an EXPIRED event per released assignment, and a still-paused user cannot win their released work back until resumed. Returns the released assignment ids ([] when the user holds nothing or is unknown). The idle auto-removal path (processIdleUsers) uses the same requeue mechanics internally.
assignToUser(assignmentId: string, userId: string, options?: { force?: boolean }): Promise<{ previousOwnerId: string | null }>
Operator override: hand an assignment directly to a user, bypassing tag/weight selection. Works on queued assignments and on pending assignments held by another user (the previous owner's backlog slot is released and the expiry clock restarts). Idempotent when the user already owns the assignment.
Hard rules still apply unless force: true: the user must not be paused, must have backlog headroom, must not be vetoed on the assignment, and must not have previously rejected it. The learning layer is never fed by manual assignments. When tracing is active the override is recorded as a decision trace with mode 'manual', so supervisor actions land in the same audit trail as organic matches.
Response deadlines & escalation (Assignment.escalation)
By default an assignment nobody responds to is requeued after the matcher-wide matchExpirationMs — and the same user may win it straight back. Attach an EscalationPolicy to make that behaviour explicit: a per-assignment deadline, an optional block on the non-responder, a priority climb, and an optional tier ladder that moves the work to a different pool on each hop. No workflow is required.
await matcher.addAssignment({
id: 'incident-1',
tags: ['sev:1', 'oncall-primary'],
priority: 1000,
escalation: {
respondWithinMs: 60_000,
onNoResponse: 'block', // don't offer it back to whoever ignored it
priorityBoost: 500, // ignored work climbs the queue
tiers: [['oncall-primary'], ['oncall-secondary'], ['oncall-manager']],
onExhausted: 'park', // nobody answered, all the way up
},
});
matcher.startMaintenance(); // deadlines don't fire unless something sweeps them| Field | Meaning | Default |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| respondWithinMs | Per-assignment response deadline, overriding matchExpirationMs | required |
| onNoResponse | 'block' treats a non-response as a soft rejection (same rejected set an explicit reject writes to); 'allow' keeps the historical behaviour | 'allow' |
| priorityBoost | Priority delta applied on each hop | 0 |
| tiers | Tag ladder. Tier tags are swapped per hop; tags not named by any tier survive untouched | none |
| maxEscalations | Hop ceiling | tiers.length - 1, else unlimited |
| onExhausted | 'queue' keeps recirculating; 'park' holds it out of matching | 'queue' |
The wait clock always survives an escalation: requeues go through addAssignment, whose NX keeps the original first-enqueue time, so getQueueStats().oldestWaitingMs measures the work item rather than the current tier.
Subscribe via onAssignmentLifecycle for escalated ({ fromWorkerId, level, blockedPreviousOwner }) and escalationExhausted ({ level, parked }) events; expired is still emitted first, so existing consumers are unaffected. Parked assignments are reachable with getParkedAssignments(), returned to the queue with unparkAssignment(id, { resetEscalation? }), and report _status: 'parked' from getAssignment() — they never read as "not found". getEscalationLevel(id) reports how far an assignment has climbed.
failed — the worker-reported outcome
failAssignment(userId, assignmentId, reason?) now emits { kind: 'failed', taskId, workerId, reason?, failedAt }. It previously emitted nothing at all, so an explicit failure was the one terminal transition invisible to onAssignmentLifecycle (the workflow event stream did hear it). It is deliberately distinct from completionBreached with action: 'fail': that is a deadline a policy acted on, this is a person reporting that the work could not be done. No assignment snapshot rides along — the record stays in the completed store carrying _failedBy / _failureReason, so getAssignment() can still answer for it.
A host that switches exhaustively over AssignmentLifecycleEvent will see a new kind here; the union is additive and every existing branch keeps its shape.
SLA policies (Assignment.sla)
Escalation owns the response side (how long a matched user has to accept). SlaPolicy owns everything after that: how long the accepting user has to finish, how many times the work may be refused before it is pulled from rotation, and an absolute freshness cutoff after which the work is moot.
await matcher.addAssignment({
id: 'order-42',
tags: ['fulfillment'],
sla: {
completeWithinMs: 30 * 60_000, // finish within 30 min of accepting
onCompletionBreach: 'requeue', // take it back if they don't
maxRejections: 3, // after 3 refusals, stop offering it
expireAfterMs: 24 * 3600_000, // moot after 24h no matter what
},
});
matcher.startMaintenance(); // SLA clocks are swept by the maintenance tick| Field | Meaning | Default |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| completeWithinMs | Completion deadline, measured from acceptAssignment(). Swept by processCompletionDeadlines() | none |
| expireAfterMs | Absolute freshness cutoff from first enqueue — survives requeues and applies in every state (queued, pending, accepted). Swept by processSlaExpiries() | none |
| maxRejections | Refusal budget. Explicit rejectAssignment() calls and blocking no-response expiries (escalation.onNoResponse: 'block') count; idle/operator releases don't | none |
| onCompletionBreach | 'notify' (event only, worker keeps it) / 'requeue' (take back, block breacher) / 'fail' / 'park' | 'notify' |
| onMaxRejections | 'park' / 'fail' / 'keep' (keep offering; budget becomes measurement-only) | 'park' |
| onExpire | 'drop' (remove entirely) / 'park' (retain for inspection) | 'drop' |
Semantics worth knowing:
- Fire-once. A breached completion deadline is removed from the deadline index before the action runs, so a
'notify'breach doesn't re-fire every tick; a requeued-after-breach assignment gets a fresh completion clock only on its next accept. - The TTL never extends. Rejection ping-pong re-adds the same JSON, whose
_enqueuedAtkeeps the original first-enqueue time — the freshness cutoff is a hard wall, not a sliding window. - Rejection budget outranks escalation. Once
maxRejectionsis exhausted the assignment is parked/failed instead of climbing further tiers. - Sweep granularity. Like response deadlines, SLA clocks fire on the maintenance tick, not at the exact deadline.
- Unparking.
unparkAssignment(id, { resetSla: true })clears the first-enqueue clock and rejection counter; without it an unparked TTL-expired item simply expires again on the next sweep.
Lifecycle events: completionBreached ({ workerId, action }), slaExpired ({ ownerId, action }), rejectionBudgetExhausted ({ rejections, action }).
SLO measurement. getSlaStats(tag?) returns aggregate attainment counters — global, or for one tag: offers, acceptedInTime, acceptanceBreaches, completionBreaches, ttlExpiries, rejectionParked, plus meanAcceptLatencyMs (matched→accepted) and meanCompleteLatencyMs (accepted→completed). Counters are best-effort HINCRBYs piggybacking paths that already touch Redis; only SLA-bearing assignments are measured.
Learning integration. Breach outcomes flow into the contextual bandit as ordinary rewards (TTL expiry and completion breach → expire; completion breach with 'fail' → fail), so per-user/per-tag stats and the automatic routing-weight vetoes pick up chronic breachers with no extra configuration. The default feature extractor also emits sla:hasDeadline and sla:tightness (normalized against learningSlaTightnessReferenceMs, default 1h) for assignments with a completion deadline, letting the model learn that tight-deadline work needs fast finishers. Custom feature extractors are unaffected.
Scheduled assignments (Assignment.schedule)
Escalation owns the response clock and SlaPolicy owns the post-accept contract; SchedulePolicy owns the offer window: when the work becomes visible to matching at all (notBefore), and how long an offer may go un-accepted before it is pulled (notAfter). Acceptance ends the schedule's authority — completion pressure is sla.completeWithinMs's job.
await matcher.addAssignment({
id: 'callback-42',
tags: ['callbacks'],
schedule: {
notBefore: Date.parse('2026-08-07T09:00:00Z'), // invisible to matching until 9:00
notAfter: Date.parse('2026-08-07T11:00:00Z'), // parked if nobody accepted by 11:00
onMiss: 'park', // or 'drop'
},
});
matcher.startMaintenance(); // schedule clocks are swept by the maintenance tick| Field | Meaning | Default |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| notBefore | Epoch ms. Held in a dedicated scheduled store — no matching, no workflow targeting, no wait clock — until the scheduled sweep enqueues it | none |
| notAfter | Epoch ms. Absolute offer deadline; applies only while un-accepted (scheduled, queued, or pending). Valid without notBefore. Must be > notBefore when both are set | none |
| onMiss | 'park' (retain for inspection) / 'drop' (remove entirely) when notAfter elapses un-accepted | 'park' |
Semantics worth knowing:
- Clocks anchor at activation. A held assignment has no wait clock and no SLA freshness stamp; both start when the sweep (or a forced early assign) enqueues it.
getQueueStats().oldestWaitingMsnever counts held work. - Acceptance kills the offer clock. Once accepted, a passing
notAfteris a non-event — late completion is governed bysla.completeWithinMsalone. - A miss beats activation. When one sweep finds both timestamps in the past, the assignment parks/drops directly; it never transits the queue or emits
scheduleActivated. - The offer deadline never extends.
notAfteris absolute, so rejection requeues carry the same deadline — a re-offered assignment can still miss. - Fire-once, replica-safe. Sweep entries are claimed with a
ZREMbefore acting, so concurrent replicas can run the sweep in parallel. - Sweep granularity. Schedule clocks fire on the maintenance tick (
processScheduledAssignments()), not at the exact millisecond. - Recurrence is out of scope. Materialize each occurrence as its own assignment from your scheduler/shift tool; re-adding an existing id never resets its clocks, so materialization is idempotent. The
schedulingmodule is that shift tool if you need one — it produces dated occurrences you can feed straight in. - Operator override.
assignToUser(id, userId)refuses held assignments;{ force: true }early-activates (clocks anchor there). If the user then rejects andnotBeforeis still in the future, the assignment returns to the scheduled store. - Unparking.
unparkAssignment(id, { resetSchedule: true })strips the schedule policy; without it a miss-parked assignment misses again on the next sweep.
Lifecycle events: scheduleActivated ({ taskId, at }) and scheduleMissed ({ ownerId, state, action, assignment } — the snapshot is the last surviving copy on the 'drop' path).
Introspection. Held assignments are listed by getScheduledAssignments(), read as _status: 'scheduled' from getAssignment(), and counted separately in getAssignmentCounts().scheduled and getQueueStats().scheduled (excluded from total and pagination, like parked items). Misses bump the scheduleMisses counter in getSlaStats(tag?).
Learning integration. Misses feed the contextual bandit as expire outcomes — a pending-state miss penalizes the user who sat on the offer, so automatic routing-weight vetoes pick up chronic missers with no extra configuration.
startMaintenance(options?) / stopMaintenance() / runMaintenanceOnce(options?)
Deadlines are not self-firing — something has to sweep them. startMaintenance() runs every enabled sweep on one tick:
scheduled(default on) — schedule activations and offer-window misses (schedule.notBefore/schedule.notAfter). Runs first in the pass so a just-activated assignment's other clocks start from the same tick.responseDeadlines(default on) — expiry, escalation, parking.completionDeadlines(default on) — SLA completion deadlines (sla.completeWithinMs).slaExpiries(default on) — SLA freshness TTLs (sla.expireAfterMs).workflowStepTimeouts(default on whenenableWorkflows) — the step-timeout index. Note:startOrchestrator()consumes the event stream but does not sweep step timeouts; without maintenance,timeoutMson a workflow step never fires.idleUsers(default on whenidleUserTimeoutMsis set).
runMaintenanceOnce() does one pass and returns a MaintenanceReport (expiredMatches, escalations, parked, completionBreaches, slaExpiries, scheduleActivations, scheduleMisses, expiredSteps, releasedIdleUsers, tookMs) — use it from a host that already owns a tick (a multi-tenant worker, a serverless schedule) instead of holding a timer per matcher. startAutoReleaseInterval() remains as a deprecated alias that sweeps response deadlines only.
getQueueStats(): Promise<QueueStats>
Live operational snapshot for dashboards:
queued/pending/scheduled: assignment counts by state (scheduled= held byschedule.notBefore, not yet in the queue).oldestWaitingMs: age of the longest-waiting unaccepted assignment, ornull. The wait clock starts at first enqueue and survives reject/expiry requeues; it stops when a user accepts the assignment or it is removed. Held (scheduled) assignments have no wait clock yet.perUser: every user'sbacklogdepth, effectivemaxBacklogSizecap, andpausedstate.
removeUser(userId: string): Promise<void>
Removes a user from the system and clears their assignment backlog.
removeAssignment(assignmentId: string, tags: string[]): Promise<void>
Removes a specific assignment from the system. Requires tags to efficiently locate the assignment in Redis.
completeAssignmentForUser(userId: string, assignmentId: string): Promise<void>
Marks an assignment as completed by a user, removing it from their backlog and potentially making them available for new assignments.
waitUntilReady(): Promise<AssignmentMatcher>
Waits for the matcher to finish connecting and initializing internal Redis and workflow resources.
executeWorkflow(workflowOrId, userId, initialContext?): Promise<WorkflowInstance>
The simplest workflow entrypoint.
- Pass a workflow ID to start an already-registered workflow.
- Pass a workflow definition or builder-produced definition to register it and start it in one call.
Options
You can pass an options object to the AssignmentMatcher constructor:
type Options = {
// How many of the highest-priority assignments to consider in one matching batch.
// A larger batch might find more matches but could be slower.
relevantBatchSize?: number; // Default: 50
// Prefix for all Redis keys used by this library. Useful for namespacing
// if you use the same Redis instance for multiple applications or purposes.
redisPrefix?: string; // Default: '' (empty string)
// The maximum number of assignments a user can have in their "tentative" backlog.
// Once a user reaches this limit, they won't be matched with new assignments
// until some are completed or removed.
maxUserBacklogSize?: number; // Default: 9
// Whether to enable default tag injection/matching behavior.
// Notes for built-in weighted matching (`routingWeights`):
// - only weights > 0 are eligible
// - weight 0 is a hard veto (exact and suffix wildcard)
// - if no positive weights exist, no assignment is pulled from queue
// If set to false, you should provide a custom `matchingFunction`.
enableDefaultMatching?: boolean; // Default: true
// Opt-in idle user auto-rejection. When set, users holding pending
// (not yet accepted/rejected) assignments with no activity for this many
// milliseconds are removed from the matching pool by `processIdleUsers()`,
// and their pending assignments are requeued for other users.
// Activity is recorded automatically on addUser/acceptAssignment/rejectAssignment,
// or explicitly via `touchUser(userId)` as a heartbeat.
// Use `startIdleUserInterval(intervalMs)` / `stopIdleUserInterval()` to run
// the check periodically. Disabled when undefined (default).
idleUserTimeoutMs?: number; // Default: undefined (disabled)
// Who wins when several users are eligible for the same assignment
// during bulk matching (matchUsersAssignments() with no userId)?
// 'first-come' - whoever's claim reaches Redis first (fastest; the
// winner is arbitrary). This is the default.
// 'best-match' - the highest-scoring eligible user, deterministically.
// 'balanced' - best match wins, but near-ties (within ~5% of the
// typical candidate score) go to whoever has less work.
// 'spread-work' - work is spread as evenly as skills allow: every
// assignment already on someone's plate discounts their
// next bid by half the typical candidate score, so
// finishing work fast never just means drowning in more.
// The numbers behind 'balanced' / 'spread-work' are derived automatically
// from each matching pass's candidate scores - nothing to calibrate. Both
// also include an hourly guardrail by default: nobody receives at more
// than double the team's average grant rate (see fairnessMaxPerWindow).
// Switchable at runtime via `setFairness(mode)` / `getFairness()`, or
// retune every fairness knob below at once with
// `setFairnessConfig(partial)` / `getFairnessConfig()` - changes apply on
// the next `matchUsersAssignments()` call, no reconstruction needed.
fairness?: 'first-come' | 'best-match' | 'balanced' | 'spread-work'; // Default: 'first-come'
// Hard ceiling on how many assignments one user can be *granted* within a
// rolling time window, in any fairness mode other than 'first-come'. The
// backlog cap alone can't protect diligent users: fast workers keep
// freeing backlog slots and keep winning, so speed is rewarded with ever
// more work. This counts grants regardless of how quickly they were
// cleared; at the cap, contested work spills to the next-best eligible
// user (or stays queued) until the window rolls. Workflow-targeted
// assignments are direct handoffs and bypass the cap.
// Left undefined, the 'balanced' / 'spread-work' presets default it to a
// team-relative guardrail - max(maxUserBacklogSize, 2x the team's average
// grants in the window), recomputed each pass, so it adapts to any
// deployment's volume. Set a number to pin the ceiling, or Infinity to
// disable the window cap entirely.
fairnessMaxPerWindow?: number; // Default: auto for 'balanced'/'spread-work', else disabled
fairnessWindowMs?: number; // Default: 3600000 (one hour)
// Expert alternative to `fairness` - the raw switches behind it:
// enableFairTiebreaker is exactly `fairness: 'best-match'` when true;
// fairnessLoadPenalty is an absolute score discount per assignment
// already on a user's backlog, and fairnessTieBand treats scores in the
// same band-sized bucket as tied (less-loaded user wins). Explicit values
// here override what a `fairness` preset would derive. Runtime toggles:
// `setFairTiebreaker(enabled)` / `isFairTiebreakerEnabled()`.
enableFairTiebreaker?: boolean; // Default: false
fairnessLoadPenalty?: number; // Default: 0 (off)
fairnessTieBand?: number; // Default: 0 (off)
// A custom function to determine the priority of an assignment.
// The function receives assignment objects (or undefined if fewer than 3 are available for comparison)
// and should return a numerical priority score. Higher scores mean higher priority.
// Useful if `createdAt` is not sufficient for your prioritization needs.
prioritizationFunction?: (...args: (Assignment | undefined)[]) => Promise<number>;
// A custom function to determine if a user can be matched with an assignment
// and the "cost" or "rank" of that match.
// If `enableDefaultMatching` is true, this function can augment or override the default logic.
// If `enableDefaultMatching` is false, this function is solely responsible for matching.
// It should return a tuple: `[matchRank, userCost]`.
// Important: built-in hard-veto/positive-only weighted semantics apply to the
// built-in matcher. With a custom matchingFunction, you own final scoring logic.
// - `matchRank`: A score indicating the quality of the match (higher is better).
// A rank of 0 or less means no match.
// - `userCost`: A score indicating the "cost" of assigning this task to this user
// (lower is better, used for tie-breaking among users).
matchingFunction?: (
user: User,
assignmentTags: string[],
assignmentPriority: number | string, // This is the score from prioritizationFunction or createdAt
assignmentId?: string,
) => Promise<[number, number]>;
// ========== Decision Traces & Explainability ==========
// Persist an auditable decision trace for every routing decision. Each
// matched assignment gets a MatchDecisionTrace — winner, arbitration mode,
// and every evaluated candidate with score breakdown and exclusion
// reasons — appended to a capped Redis stream and queryable via
// getDecisionTraces(). Captured while the decision happens, never
// reconstructed. Toggleable at runtime via setDecisionTraces(enabled).
enableDecisionTraces?: boolean; // Default: false
// Retention (entry count) for the trace stream, and how many candidates
// are stored per trace (the chosen candidate is always kept).
decisionTraceMaxEntries?: number; // Default: 1000
decisionTraceMaxCandidates?: number; // Default: 25
// Real-time hook invoked with each MatchDecisionTrace as decisions are
// made (e.g. push to a websocket). Setting it activates capture even when
// enableDecisionTraces is false — traces then stream to the callback only
// and are not persisted. Callback errors never affect matching.
onMatchDecision?: (trace: MatchDecisionTrace) => void;
// ========== Reinforcement Learning Options ==========
// Enable the adaptive learning layer (contextual bandit re-ranking).
enableLearning?: boolean; // Default: false
// SGD learning rate for online model updates.
learningRate?: number; // Default: 0.1
// Epsilon-greedy exploration rate in [0, 1]. With this probability, a random
// jitter is added to candidate scores so the model keeps gathering data on
// under-served candidates.
learningExplorationRate?: number; // Default: 0.05
// Shadow mode: record decisions and learn from outcomes, but never alter
// ranking. Useful for safely evaluating the model before going live.
learningShadowMode?: boolean; // Default: false
// Multiplier applied to the predicted reward when re-ranking candidates.
// Higher values let the learned model dominate over base priority.
learningBoostFactor?: number; // Default: 1
// Override rewards per lifecycle outcome (merged with defaults):
// { accept: 0.3, complete: 1, reject: -0.6, expire: -0.3, fail: -0.8 }
learningRewards?: Partial<Record<'accept' | 'complete' | 'reject' | 'expire' | 'fail', number>>;
// Custom feature extractor. Defaults to tag-match, normalized skill-weight,
// tag-overlap-ratio and embedding-similarity features.
learningFeatureExtractor?: (user: User, assignment: { id: string; tags: string[] }) => Record<string, number>;
// Reference duration (ms) used to normalize the `sla:tightness` feature
// emitted by the default extractor for assignments with sla.completeWithinMs.
learningSlaTightnessReferenceMs?: number; // Default: 3600000 (1 hour)
// TTL for stored decision contexts in ms.
learningDecisionTtlMs?: number; // Default: 604800000 (7 days)
// Weights applied to named external feedback signals when computing rewards.
// Signals not listed here default to weight 1. Negative weights turn a
// signal into a penalty (e.g. { errorRate: -2 }).
learningSignalWeights?: Record<string, number>;
// TTL for archived episodes awaiting late external feedback in ms.
learningFeedbackTtlMs?: number; // Default: 604800000 (7 days)
};Decision Traces & Explainability
Every routing decision the engine makes can be explained and audited. Two complementary tools cover the two questions teams actually ask:
- "Why did this assignment go to that user?" — decision traces, the auditable record captured while the decision was made.
- "Who could receive this assignment right now, and why (not)?" —
explainMatch(), an on-demand evaluation against live state.
Decision traces (enableDecisionTraces)
const matcher = new AssignmentMatcher(redisClient, {
enableDecisionTraces: true,
// Optional real-time feed (dashboards, sockets). Works even without
// persistence: setting only onMatchDecision streams traces to the
// callback and skips storage.
onMatchDecision: (trace) => io.emit('match:decision', trace),
});
await matcher.matchUsersAssignments();
const [trace] = await matcher.getDecisionTraces({ assignmentId: 'ticket-42' });
// {
// assignmentId: 'ticket-42',
// chosenUserId: 'alice',
// mode: 'best-match', // how the winner was arbitrated
// matchedAt: 1752404712345,
// candidates: [
// { userId: 'alice', eligible: true, chosen: true, score: 101,
// effectivePriority: 201, reasons: [ { kind: 'tagWeight', tag: 'english', weight: 100 }, ... ] },
// { userId: 'bob', eligible: false, chosen: false, score: 0,
// effectivePriority: 0, reasons: [ { kind: 'veto', tag: 'german', pattern: 'german' } ] },
// ],
// }Traces are appended to a capped Redis stream (decisionTraceMaxEntries,
default 1000), so the audit record survives restarts and is shared across
replicas. Query with getDecisionTraces({ assignmentId?, userId?, limit? })
(newest first), wipe with clearDecisionTraces(), and toggle at runtime with
setDecisionTraces(enabled) / isDecisionTracesEnabled().
What a trace contains:
- The winner, the arbitration mode (
'first-come','best-match','balanced','spread-work','direct'for per-user matching, or'workflow'for deterministic workflow-targeted handoffs), and the moment of the decision. - Every candidate evaluated in that matching pass with its full score
breakdown: matched routing weights (
tagWeight, wildcardpatternincluded), the implicitdefaultTag,tagOverlapfor unweighted users,geoDistance/geoBoost,learningBoost(withshadowModeflag),skillThresholdfailures,cidrMismatch, andcustomScorewhen a custommatchingFunctionowns scoring. - Users excluded before scoring by hard rules — zero-weight
vetoentries,assignmentVeto(the assignment'svetoedUsers), andrejectedPreviously— are struck out in the trace even though the Redis-side prefilter kept them out of the scoring pass entirely. - Where a veto came from. For users carrying
learnedRoutingWeights, eachvetoreason also hassource: 'learned' | 'manual', separating a veto the automatic routing-weight sync synthesized from one an operator configured. The field is absent for users with no learned weights, so traces are unchanged when the learning layer is off.
Honesty notes: in the default 'first-come' mode users evaluate and claim in
parallel, so a candidate whose snapshot read happened after the winner's claim
may be absent from the trace (they never evaluated it); fair modes evaluate
everyone read-only first and always capture the full candidate field. Reason
kinds are additive over time — treat unknown kind values as forward
compatibility, not errors.
On-demand explanations (explainMatch)
const explanation = await matcher.explainMatch('ticket-42');
// { assignmentId, status: 'queued' | 'pending' | 'accepted' | 'completed' | 'not_found',
// ownerId, evaluatedAt, candidates: MatchCandidateTrace[] }
// Or scope to specific users:
await matcher.explainMatch('ticket-42', { userIds: ['alice', 'bob'] });explainMatch() recomputes eligibility for every user (or the given ids)
against the assignment's current state using the engine's own scoring
internals — vetoes, prior rejections, backlog, thresholds, CIDR, geo, weight
breakdown, learning influence — and works in any lifecycle state (for matched
assignments the current owner is flagged chosen). Because it evaluates now
rather than at decision time, use it for support tooling and dry-runs
("who would get this?"), and decision traces for the compliance-grade record.
Hypothetical preview (previewMatch)
previewMatch() answers "who would receive this assignment if I created it?"
without persisting or claiming anything. It uses the same scoring and hard
rules as real matching, so the ranking mirrors what explainMatch() would
report after the assignment is added.
const preview = await matcher.previewMatch(
{ tags: ['english', 'billing'], priority: 100, skillThresholds: { english: 50 } },
// optional: restrict to specific users
{ userIds: ['alice', 'bob'] },
);
// { tags, priority, evaluatedAt, candidates: MatchCandidateTrace[] }Use it for autosuggestion UIs: render the ranked best-fit workers with score breakdowns and blocked reasons before the operator commits the task.
Pre-flight checks (lintAssignment / checkAssignmentReadiness)
The runtime is deliberately forgiving — an invalid schedule window or an
unusable SLA object normalizes away silently, and an assignment whose tags no
user carries just sits queued. The pre-flight helpers surface those problems
before addAssignment():
import { lintAssignment } from 'assignment-user-matcher';
// Pure and Redis-free — run it host-side, in a form validator, anywhere:
const issues = lintAssignment(assignment, { matchExpirationMs: 60000 });
// [{ severity: 'error', code: 'schedule-window-inverted', message: '...' }, ...]
// Live version: same lint plus checks against the current user pool:
const report = await matcher.checkAssignmentReadiness(assignment);
// {
// issues, // lint + live findings, each { severity, code, message, tag? }
// eligibleUserCount, // who could take it right now (same rules as previewMatch)
// uncoveredTags, // tags no active (non-paused) user can serve
// evaluatedAt,
// }Static codes (from lintAssignment): no-tags, schedule-window-inverted,
schedule-ignored, schedule-window-elapsed, schedule-notbefore-past,
offer-window-tight (offer window shorter than one response deadline),
schedule-notafter-shadowed-by-sla-ttl (the freshness TTL fires first, so
notAfter never does), sla-ignored, escalation-ignored.
Live codes (from checkAssignmentReadiness): duplicate-id (re-adding keeps
the original wait/TTL clocks), tag-uncovered (per tag; paused users don't
count as coverage), no-eligible-users.
Tag coverage mirrors the matching semantics exactly: users with
routingWeights cover a tag when the effective weight is positive (wildcards
honored, weight 0 is a veto); users without them cover it via tag
membership. checkAssignmentReadiness() is read-only and safe to call from
dashboards or admission pipelines; it never claims or records anything.
Queue overlook (auditQueue)
When work sits in the queue and nobody knows why, auditQueue() answers both
of the usual questions at once — who is blocked by what and is anything
sweeping the clocks:
const audit = await matcher.auditQueue({ minWaitingMs: 60_000 });
// {
// scanned, // queued assignments examined (longest-waiting first)
// entries: [{ // assignments nobody can take right now
// assignmentId, tags, waitingMs,
// eligibleUserCount, // 0 for stuck work
// uncoveredTags, // tags no active user can serve
// blockers, // reason -> blocked-user count, e.g.
// }], // { backlogFull: 3, paused: 1, rejectedPreviously: 1, noTagMatch: 4 }
// sweepBacklog: { // past-due entries sitting unswept in each deadline index
// scheduleActivations, scheduleMisses,
// responseDeadlines, completionDeadlines, slaExpiries,
// },
// }blockerstallies the hard-rule reasons across ineligible users, using the sameMatchTraceReasonkinds as decision traces (paused,backlogFull,rejectedPreviously,assignmentVeto,cidrMismatch,skillThreshold,geoDistance, …). Users excluded by plain tag/weight mismatch are counted asnoTagMatch.sweepBacklogcounts deadline-index entries already in the past. Values that stay nonzero across calls are the classic "assignments never process" cause: no maintenance tick is running — start one withstartMaintenance()or callrunMaintenanceOnce()from your own tick.- Note that with
enableDefaultMatchingon (the default), tag-mismatched work is still routable through the injecteddefaulttag, so stuckness then comes from paused users, full backlogs, vetoes, and rejections rather than tag gaps. - Options:
limit(default 100, longest-waiting first),minWaitingMs(ignore fresh work),includeHealthy(also report matchable entries). Read-only, O(scanned × users) — a diagnostic, not a hot path.
Adaptive Matching with Reinforcement Learning
The matcher includes an opt-in, Redis-backed contextual bandit that learns from
assignment outcomes and re-ranks candidates automatically. Hard matching rules
(tags, routingWeights, vetoes, CIDR, skillThresholds) always apply first —
the learned model only reorders assignments that are already eligible.
How it works:
- When a user is matched, a sparse feature vector is extracted for each
eligible candidate (tag matches, normalized skill weights, tag overlap, and
optional
embeddingcosine similarity if both user and assignment carry anembedding: number[]field). - Candidates are ranked by
combinedPriority + boostFactor * predictedReward. - The decision context is stored, and lifecycle outcomes (
accept,complete,reject,expire,fail) feed rewards back into the model via online SGD updates — fully automatic, no training pipeline needed.
const matcher = new AssignmentMatcher(redisClient, {
enableLearning: true,
// Start safe: observe without affecting ranking
learningShadowMode: true,
// Optional reward shaping
learningRewards: { complete: 2, reject: -1 },
});
// ... normal addUser / addAssignment / matchUsersAssignments usage ...
// Inspect what the model has learned
const model = await matcher.getLearningModel(); // { 'tag:english': '0.42', ... }
const stats = await matcher.getLearningStats(); // { decisions, rewards, totalReward, averageReward }
// Manual reward shaping for a matched assignment (e.g. CSAT score arrived later)
await matcher.recordLearningReward('assignment-123', 1.5);
// Start over
await matcher.resetLearningModel();Feeding External Data into the Model
Matchmaking quality signals often arrive after an assignment is closed — QA audits, accuracy reviews, customer satisfaction scores, handle-time analysis. The learning layer supports three external feed paths:
1. Named feedback signals (post-processing per assignment). When an
assignment reaches a terminal state, its feature context is archived as an
episode (kept for learningFeedbackTtlMs). External pipelines can attribute
late signals to it at any point within that window:
const matcher = new AssignmentMatcher(redisClient, {
enableLearning: true,
// Reward = sum(signalValue * signalWeight); unlisted signals default to 1
learningSignalWeights: {
accuracy: 2, // accuracy audits matter most
csat: 1, // customer satisfaction
handleTimePenalty: -0.5, // longer handling reduces reward
},
});
// ...assignment is matched, accepted, completed...
// Hours/days later, the QA pipeline reports back:
await matcher.recordLearningFeedback('assignment-123', {
accuracy: 0.95,
csat: 0.8,
handleTimePenalty: 0.3,
});
// reward = 0.95*2 + 0.8*1 + 0.3*-0.5 = 2.55, applied to the episode's features2. Raw reward shaping. recordLearningReward(assignmentId, reward) applies
an explicit numeric reward against an assignment's decision context when you
want full control over the value.
3. Offline batch training. trainLearningSamples(samples) updates the
model directly from raw (features, reward) pairs — no live decision context
needed. Use it to bootstrap the model from historical data or to run scheduled
imports from a data warehouse:
await matcher.trainLearningSamples([
{ features: { bias: 1, 'tag:english': 1, 'skill:english': 0.8 }, reward: 1.2 },
{ features: { bias: 1, 'tag:billing': 1 }, reward: -0.4 },
]);Feature names are arbitrary strings — as long as your
learningFeatureExtractor produces the same names at match time, externally
trained weights apply immediately to live ranking.
Rollout recommendation: enable with learningShadowMode: true first, watch
getLearningStats() and getLearningModel(), then flip shadow mode off and
tune learningBoostFactor / learningExplorationRate for your workload. For
domain-specific setups (custom embeddings, business features), supply a
learningFeatureExtractor.
Automatic Routing Weights (RL-Generated Tags/Weights)
Beyond re-ranking, the learning layer can fully automate routingWeights
authoring. With enableAutoRoutingWeights: true (requires enableLearning),
every reward observation also updates per-user, per-tag statistics in Redis
(atomic HINCRBY/HINCRBYFLOAT — O(tags) per outcome, no scans, no
read-modify-write), and weights are synthesized on demand with a configurable
bandit policy.
Policies:
policy: 'ucb1'(default): high-mean tags get high weights, an exploration bonus favors less-sampled tags, and bad tags are hard-vetoed at weight0.policy: 'confidence': uses upper-confidence-bound for the weight and a conservative lower-confidence-bound for the veto decision, so uncertainty works against exclusion.policy: 'thompson': samples from the per-tag posterior when mapping to a weight.
Guardrails (all opt-in):
minSamplesForVeto: a learned weight-0 may only override a manual (non-learned) weight after this many observations, preventing a few bad rolls from silently starving a user. Defaults tominSamples(5) for backward compatibility; 20 is a reasonable production floor.maxDeltaPerSync: clamps how far any single learned weight can move per sync, avoiding oscillation.minTotalSamples: skip users whose total evidence is still too thin.decayHalfLifeMs: exponentially decay older observations on read so stale history cannot veto a user's improving skills.terminalOnlyTagStats: whentrue, only terminal outcomes (complete/reject/expire/fail plus manual rewards/feedback) feed tag stats; the non-terminalacceptupdate is skipped.
const matcher = new AssignmentMatcher(redisClient, {
enableLearning: true,
enableAutoRoutingWeights: true,
// Optional: run the guarded sync automatically on one replica.
// A Redis lock prevents overlapping runs across replicas.
autoRoutingWeightsSyncIntervalMs: 60_000,
autoRoutingWeights: {
minSamples: 5, // observations before a tag's stats are trusted
vetoThreshold: -0.5, // mean reward at/below this → weight 0 (hard veto)
maxWeight: 100, // top of the synthesized weight scale
explorationBonus: 0.5, // UCB coefficient; higher → more exploration
priorWeight: 50, // optimistic weight for unexplored tags
policy: 'confidence', // or 'ucb1' (default) / 'thompson'
confidenceZ: 1.5, // z-score for the confidence policy
minSamplesForVeto: 20, // stricter bar for overriding manual weights
maxDeltaPerSync: 25, // max change per sync on a single weight
decayHalfLifeMs: 24 * 60 * 60 * 1000, // 24h half-life
terminalOnlyTagStats: false,
},
});
// Inspect what the system has learned for a user
const stats = await matcher.getLearnedTagStats('user-1');
const preview = await matcher.getLearnedRoutingWeights('user-1');
// Apply learned weights to one user, or to all tracked users (e.g. on a
// periodic job). Manual routingWeights entries for tags the learner has no
// data on are preserved; pass { overrideManual: true } to replace them.
await matcher.syncLearnedRoutingWeights('user-1');
await matcher.syncLearnedRoutingWeights(); // all tracked users
// Include exploration priors for known tags the user has never seen:
await matcher.syncLearnedRoutingWeights('user-1', { includeUnexploredTags: true });
// Preview the would-be map without writing anything:
const wouldBe = await matcher.syncLearnedRoutingWeights('user-1', { dryRun: true });
// Undo the last sync if the result looks wrong:
await matcher.revertLearnedRoutingWeights('user-1');Synthesis happens outside the matching hot path — matching itself reads the
user's stored routingWeights exactly as before, so the per-match cost is
unchanged. The last-applied learned map is stored on the user as
learnedRoutingWeights for observability, the previous map is snapshotted as
routingWeightsSnapshot for rollback, and resetLearningModel() clears all
per-user tag statistics. The pure synthesis function is exported as
synthesizeRoutingWeights for offline pipelines.
Run the regression gate to validate a new policy before shipping it:
./node_modules/.bin/ts-node benchmark-weights.tsRL Scalability & Redis Constraints
The RL layer adds controlled overhead: decision recording, outcome archival, and weight updates. Because these operations are write-heavy and per-assignment, scalability depends primarily on Redis memory, write throughput, and feature cardinality — not on learning math itself.
Redis Workload Breakdown
Each matched assignment now costs:
- Decision record (live): ~300 bytes for features + metadata; expires in 7 days (configurable).
- **Episod
