Routing Strategies

Routing Strategies

What a routing strategy is, the Strategy interface and hot path, the registry, capability interfaces, and how strategies plug into the routing pipeline.

A strategy is a pluggable algorithm that answers one question: given a set of eligible candidates for a unit of work, which one wins? It is the decision-making core of Ductor's routing pipeline — the swappable part that turns "these three candidates all qualify" into "route to candidate B, and here's why." A strategy produces a Route (Decision), and its explanation is the seed of the work's eventual Receipt.

Most strategies rank candidates by weight, load, score, or learned value. A few — shark_tank (broadcast-and-claim) and the capacity marketplace — clear work through a market: candidates effectively bid, and the winner is chosen by market rules rather than a fixed score. Those are the clearing-native strategies. Priced market clearing (auctions, ping/post) runs today for lead distribution. Agent RFQ — humans and agents bidding on work — extends the same machinery: typed bid terms, buyer-side clearing, authenticated submission, a published task spec, disclosure-scoped discovery, and an autonomous agent bidder all ship as opt-in surfaces (agent_rfq.enabled, and agent_rfq.bidder_enabled separately gating agent spending). Executing the won work — turning an award into a run — is on the roadmap.

Everything a strategy needs is handed to it in a single request, and everything it decides comes back in a single response. Strategies never touch the database, Redis, or the network directly — the pipeline loads the facts, calls the strategy, and commits the result. That separation is what lets you swap a naive round-robin for a revenue-maximizing scorer without changing anything else.

Where strategies run

Strategies execute in the Select stage of the routing pipeline, after candidates have been validated, enriched, and filtered. See Routing pipeline for the full Validate → Enrich → Filter → Select → Assign flow.

The Strategy interface

Every strategy implements two methods, defined in pkg/strategy/interface.go:

type Strategy interface {
    // Info returns metadata: name, versions, capabilities. Called FIRST,
    // during loading, for compatibility checking — before any Select call.
    Info() Info

    // Select chooses one candidate from req.Candidates (guaranteed non-empty).
    // MUST be safe for concurrent calls from many goroutines.
    Select(ctx context.Context, req *SelectRequest) (*SelectResponse, error)
}

Implementations must be:

  • Thread-safeSelect may be called concurrently from many goroutines. Any internal state (counters, cursors) must be synchronized.
  • Stateless, or backed by thread-safe state — a round-robin counter is fine if it uses atomics or a shared cache; a plain map is not.
  • Idempotent for identical inputs — the same request should produce the same decision. (Randomized and bandit strategies are the deliberate exception: their non-determinism is part of the contract.)

The hot path: Info first, then Select

Loading a strategy is a two-phase handshake:

  1. Info() is called once, at load time. It returns the strategy's Name, InterfaceVersion, PluginVersion, optional MinRouterVersion, and its declared Capabilities. Ductor checks interface-version compatibility here and refuses to load anything it can't speak to. Info() never runs on the hot path.
  2. Select() is called on every routing decision. This is the hot path: it receives a *SelectRequest, returns a *SelectResponse, and must be fast and concurrency-safe. req.Candidates is guaranteed non-empty — the pipeline returns an error before calling a strategy if filtering left nothing.

What a strategy receives and returns

The SelectRequest (in pkg/strategy/types.go) carries everything the strategy is allowed to see:

FieldTypePurpose
Routable*RoutableThe item being routed — attributes, location, priority.
Candidates[]CandidateThe eligible recipients, pre-filtered. Non-empty.
PoolIDstringThe pool being routed to.
OptionsDynamicMapStrategy-specific configuration for this call.
Context*SelectContextTrace ID, tenant ID, routing depth, DryRun flag.
Features*FeatureSnapshotTyped snapshot of outcome/quality/capacity/SLA facts.
State*StrategyStateReadSetFrozen state snapshot for stateful strategies (read-only).

Each Candidate exposes ID, Weight, CurrentLoad, MaxConcurrent, Location, Attributes, Tags, a pre-computed rule Score, and LastAssignedAt. A strategy reads whichever of these it needs.

The SelectResponse carries the decision back:

FieldTypePurpose
Selected*CandidateThe winner. Must be non-nil on success.
ReasonstringShort code, e.g. highest_weight, least_loaded, nearest_geo.
MetadataDynamicMapScores, distances, probabilities — anything worth surfacing.
Explain*SelectExplanationDetailed per-candidate reasoning (see ExplainStrategy).
Slate*RankedSlateAn ordered slate when the strategy ranks alternates.
StateMutationIntents[]…Deterministic state changes to apply after the decision commits.

State is read at Select, written after commit

Stateful strategies never mutate durable state inside Select. They read a frozen State snapshot and return StateMutationIntents describing what should change once the decision is durably accepted. This keeps selection replayable and free of side effects. See Strategy state plane.

The registry

Strategies are discovered and instantiated through the Registry (pkg/strategy/registry.go). It maps a name to a factory, creates instances lazily, and resolves aliases.

reg := strategy.NewRegistry()
builtin.RegisterBuiltins(reg)              // register the built-in pack
reg.RegisterAlias("rr", "smooth_weighted_round_robin")

s, err := reg.Get("smooth_weighted_round_robin") // lazy, lock-free hot path

Key properties:

  • Lazy, exactly-once instantiation. Factories run inside a sync.Once on first Get; after that, lookups are a lock-free sync.Map load. A factory can safely call back into the registry to resolve a dependency (for example, fair_catchup resolving its base strategy) without deadlocking.
  • Aliases. RegisterAlias(alias, target) points a second name at an existing factory. Alias chains resolve transitively with cycle protection. Every strategy's documented aliases below are real registry aliases.
  • Configured instances. Configure(instanceName, factoryName, config) and GetWithConfig(name, config) produce named or per-config instances so the same algorithm can be bound to different parameters in different pools.

A pool names the strategy it wants; the routing pipeline calls reg.Get(name) and runs Select. Swapping a pool's strategy is a config change, not a redeploy.

The built-in registry

Ductor includes the following built-in strategy names. Aliases resolve to the same strategy factory.

NameAliasesWhat it does
smooth_weighted_round_robinround_robin, weighted_round_robinFair weighted distribution (SWRR).
consistent_hashRoute the same key to the same recipient.
priority_scoreWeight + load + availability blend.
least_loadedLowest current load wins.
random_weightedWeighted random pick.
power_of_two_choicesp2cSample two, take the less-loaded.
failoveractive_passiveTiered active-passive HA.
fair_catchupcatch_upFair distribution with catch-up debt.
shark_tankbroadcast_claimBroadcast, then first to claim wins.
multi_objective_scoreTunable multi-signal scorer.
sla_deadlineDeadline- and catchall-aware SLA fit.
yield_optimizedMaximize expected commercial yield.
portfolio_balanceCap any one recipient's recent share.
availability_firstPrefer candidates with fresh free time.
soonest_availableEarliest qualifying free slot.
coverage_balancerBalance free minutes and busy load.
connector_quota_awareFilter/score by connector quota readiness.
reliability_weightedScore by reliability-readiness facts.
entity_sticky_assignmentPrefer an entity graph's sticky recipient.
capacity_weighted_allocatorBatch plan with capacity deductions.
portfolio_quota_allocatorBatch plan under portfolio quotas.

Two contrib modules add more names when installed and enabled:

  • predicted (modules/strategies/predicted/register.go) — predicted_value plus the bandits thompson_sampling, linucb, and bandit. See Learning.
  • geographic (modules/strategies/geographic) — geo_nearest and geo_weighted. See Geo.

Every name must resolve to a contract

A hard rule of the routing surface: no strategy name, alias, recipe reference, or MCP/API surface may exist unless it resolves to a strategy contract, and every new strategy must ship its descriptor and contract in the same change. Descriptors render the authoring UI; contracts tell headless tools and validators what a strategy can safely consume and produce. See Contracts.

Capability interfaces

The base Strategy interface is deliberately small. A strategy that can do more implements an optional capability interface and advertises it in Info().Capabilities. The pipeline type-asserts for the interface before using it, so a strategy only pays for what it supports.

InterfaceCapability constantMethod it adds
BatchStrategyCapabilityBatchSelect (batch_select)SelectBatch — score many routables in one call.
AllocationStrategyCapabilityAllocationPlan (allocation_plan)Allocate → a durable AllocationPlan with hard global constraints.
ExplainStrategyCapabilityExplainDecision (explain)SelectWithExplanation — populate Explain with reasoning.
HealthCheckStrategyCapabilityHealthCheck (health_check)HealthCheck — report readiness before use.
RankedStrategyCapabilityRankedSelect (ranked_select)SelectRanked → populate Slate with a ranked / multi-winner list.

The remaining capabilities are pure metadata (no method): CapabilityWeighted (honors candidate weights), CapabilityStateful (keeps state across selections), CapabilityGeoAware (uses location), CapabilityMetrics (exposes Prometheus metrics), CapabilityMultiWinner (marks several winners in one slate), and CapabilityPipelineStage (opts a strategy into bounded composite / pipeline execution). The complete set — eleven constants — lives in pkg/strategy/version.go; IsValidCapability and AllCapabilities enumerate them.

Strategies read facts only from Features

A strategy may read only what arrives on the SelectRequest — chiefly req.Features, the typed feature snapshot. Strategies must never reach into outcome, quality, or connector repositories directly. The pipeline loads the facts once, freezes them into the snapshot, and hands them over; that is what keeps every decision replayable, shadowable, and auditable.

Params, modes, and aliases

Throughout these pages each strategy is documented with three consistent attributes:

  • Params — typed, tunable knobs. Each has a kind (slider, number, boolean, string, enum), a default, and for numeric kinds a min/max/step. Params are validated against their spec before they reach a pool, so dead knobs never get persisted (pkg/strategy/validate_params.go).
  • Modes — the shape of selection a strategy operates in: weighted, scoring, single, ranked, multi_winner, or broadcast. A strategy may support more than one.
  • Aliases — alternative registry names that resolve to the same strategy.

Choosing a strategy

Start from the problem, not the algorithm:

If you need to…Reach forCategory
Spread load fairly and cheaplysmooth_weighted_round_robin, power_of_two_choicesBalance
Send identical keys to the same recipientconsistent_hashBalance
Fail over from a primary to a backupfailoverBalance
Blend several business signals into one scoremulti_objective_scoreScoring
Beat an SLA / deadlinesla_deadlineScoring
Maximize revenue per routeyield_optimizedScoring
Cap any one recipient's shareportfolio_balanceScoring
Learn who wins over timethompson_sampling, linucbLearning
Route by geographic proximitygeo_nearest, geo_weightedGeo
Let recipients race to claim workshark_tankMarkets
Produce ordered fallbacksranked slatesRanked
Optimize a whole batch at onceallocation plannersAllocation
Match on skills, licenses, territoryeligibility profilesEligibility
Drain traffic away from failing targetsreliability strategiesReliability
Route work to humans or AI agentswork assignmentWork
Compose stages into one primitivestrategy pipelinesPipelines
Version, replay, and govern behaviorcontracts & governanceContracts

Browse by category