Skip to content

feat(llm-classifier): custom schema support + multi target (>2) routing support - #275

Merged
ayushag-nv merged 6 commits into
mainfrom
ayushag/custom-classifier-schema
Aug 4, 2026
Merged

feat(llm-classifier): custom schema support + multi target (>2) routing support#275
ayushag-nv merged 6 commits into
mainfrom
ayushag/custom-classifier-schema

Conversation

@ayushag-nv

@ayushag-nv ayushag-nv commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

Classifier prompts are configurable, but response schemas and verdict-to-target mapping were still hard-coded. Every new verdict shape required another Rust judge implementation, and the server route could only choose between two named tiers.

What

  • Add explicit capability, escalation, and custom classifier modes.
  • Accept an inner JSON Schema for custom verdicts and validate responses locally.
  • Add a deterministic target_selector policy for routing from a verdict field.
  • Route across two or more configured targets.
  • Use default_target when the judge fails, returns invalid JSON, fails schema validation, or selects an unknown label.
  • Preserve existing capability and escalation configurations when mode is omitted.

How

The classifier path is split into four small responsibilities:

  1. ClassifierInput chooses what the judge sees. TaskInput handles capability and custom routing; EscalationInput builds the condensed completed trajectory.
  2. ClassifierContract renders the system prompt, inserts the inner schema into the provider response-format wrapper, and owns the compiled validator for custom verdicts.
  3. StructuredJudge<I, D> assembles the judge request once. SerdeDecoder<V> parses the fixed capability and escalation verdict structs, while JsonSchemaDecoder parses a dynamic JSON value and validates it against the configured schema.
  4. JudgePolicy converts the decoded verdict into a routing decision. Capability and escalation keep their typed policies; custom mode applies TargetSelectorPolicy to a configured selector.

This keeps request construction and model calling shared without forcing every classifier into a dynamic JSON representation. Fixed contracts stay typed. Custom contracts pay for runtime schema validation only when that mode is configured.

The server preserves the flat TOML interface, validates fields against the selected mode, and normalizes the route into typed capability, escalation, or custom construction settings. The existing FallThrough composition still owns affinity and default fallback behavior.

Where to Start Review

  1. crates/libsy/src/algorithms/util/llm_judge.rs for StructuredJudge, input shaping, and decoder boundaries.
  2. crates/libsy/src/algorithms/util/classifier_contract.rs for schema wrapping, compilation, and verdict validation.
  3. crates/libsy/src/algorithms/llm_class.rs for mode construction, policy wiring, and fallback composition.
  4. crates/libsy/src/algorithms/util/target_selector.rs for deterministic multi-target selection.
  5. crates/switchyard-server/src/config.rs for mode selection and TOML validation.
  6. crates/switchyard-server/tests/server.rs for request-level compatibility and four-target routing coverage.

Test Plan

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • Legacy capability configuration with omitted mode
  • Explicit capability and escalation prompt/schema wiring
  • Four-target custom routing, schema validation, and default fallback
  • cd docs && make publish

Closes

Closes SWITCH-1180

Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
@ayushag-nv
ayushag-nv requested a review from a team as a code owner August 4, 2026 06:49
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-275/

Built to branch gh-pages at 2026-08-04 16:04 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Summary

The PR adds reusable structured LLM judges, JSON-schema verdict validation, custom JSON Pointer target selection, and explicit capability, escalation, and custom classifier modes. Server configuration, tests, exports, and documentation now support these modes.

Changes

LLM classifier modes

Layer / File(s) Summary
Shared structured-judge pipeline
Cargo.toml, crates/libsy/Cargo.toml, crates/libsy/src/algorithms/util/*
Shared judges build structured requests and decode typed or schema-validated verdicts. Classifier contracts compile and retain JSON Schema validators. Escalation uses the shared pipeline.
Capability and custom classifier algorithms
crates/libsy/src/algorithms/llm_class.rs, crates/libsy/src/algorithms/util/target_selector.rs, crates/libsy/src/lib.rs
Capability classification uses StructuredJudge. Custom classification adds public configuration, JSON Pointer target selection, validation, fallback, affinity, and public exports.
Route mode configuration and integration coverage
crates/switchyard-server/src/config.rs, crates/switchyard-server/tests/server.rs
Route configuration validates and constructs capability, escalation, and custom modes. Integration tests cover schema rendering, target selection, invalid-verdict fallback, and explicit modes.
Configuration and routing documentation
crates/switchyard-server/README.md, docs/getting_started.md, docs/reference/toml_schema.md, docs/routing_algorithms/*
Examples and reference material document explicit modes, custom schema-driven routing, escalation settings, and omitted-mode compatibility.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

I’m a rabbit with schemas tucked under my ear,
Structured judges now make verdicts clear.
JSON Pointers hop from label to route,
Fallbacks keep the journey en route.
Three modes now bloom in the config garden.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: custom classifier schema support and routing across more than two targets.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
crates/switchyard-server/tests/server.rs (1)

817-861: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the classifier call count and the untouched targets.

The loop asserts the selected model for each turn. It does not assert that the classifier target was called once per turn, so a cached or skipped judge call would still pass. It also does not assert that model/weak and model/middle are never selected, which is the core claim of routing over more than two targets.

💚 Proposed additional assertions
     let calls = upstream.calls.lock().await;
+    let judge_calls = calls
+        .iter()
+        .filter(|call| call["model"] == "model/classifier")
+        .count();
+    assert_eq!(judge_calls, 2, "{calls:?}");
+    assert!(
+        !calls
+            .iter()
+            .any(|call| call["model"] == "model/weak" || call["model"] == "model/middle"),
+        "{calls:?}"
+    );
     let judge_call = calls
         .iter()
         .find(|call| call["model"] == "model/classifier")
         .ok_or("custom classifier target was not called")?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/tests/server.rs` around lines 817 - 861, Extend the
test assertions after the request loop to verify the classifier target
model/classifier call count equals the number of turns, preventing cached or
skipped classifications from passing. Also assert that the selected-model
results never include model/weak or model/middle, while preserving the existing
assertions for model/premium and model/strong and the classifier prompt/schema
checks.
crates/libsy/src/algorithms/util/target_selector.rs (1)

107-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a rejection test for a root selector.

new rejects a root pointer at lines 31-35, but no test covers it. An empty selector ("") is a valid JSON Pointer that resolves to the whole verdict, so this branch is the only guard against it.

💚 Proposed additional test
     #[test]
     fn an_invalid_json_pointer_is_rejected() {
         let result = TargetSelectorPolicy::new("/target~2name", BTreeMap::new());
         assert!(matches!(result, Err(LibsyError::AlgorithmError { message })
                 if message.contains("valid JSON Pointer")));
     }
+
+    #[test]
+    fn a_root_selector_is_rejected() {
+        let result = TargetSelectorPolicy::new("", BTreeMap::new());
+        assert!(matches!(result, Err(LibsyError::AlgorithmError { message })
+                if message.contains("must identify a response field")));
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy/src/algorithms/util/target_selector.rs` around lines 107 - 112,
Add a test alongside an_invalid_json_pointer_is_rejected that calls
TargetSelectorPolicy::new with an empty selector and asserts it returns the
expected LibsyError::AlgorithmError for rejecting a root selector. Keep the test
focused on verifying that the valid JSON Pointer "" is not accepted because it
would select the entire verdict.
crates/libsy/src/algorithms/llm_class.rs (1)

605-708: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for new_custom validation in this module.

new_custom adds several new failure paths: fewer than two targets, an untrimmed or empty label, a duplicated label, a duplicated resolved semantic_name, and a default_target that is not a configured label. The tests in this file cover only the capability and escalation constructors, and the server integration test covers only the success and fallback paths. Add unit tests for these five rejections so a future change cannot relax them silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy/src/algorithms/llm_class.rs` around lines 605 - 708, Add unit
tests in the llm_class test module covering each new_custom rejection: fewer
than two targets, empty or surrounding-whitespace labels, duplicate labels,
duplicate target semantic_name values, and a default_target absent from the
configured labels. Reuse the existing test target/configuration helpers where
available, invoke new_custom for each case, and assert that construction returns
an error while preserving the existing successful and fallback coverage.
crates/switchyard-server/src/config.rs (2)

946-957: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend the mode tests to cover inference and the remaining rejections.

The new test covers one rejection. The mode logic added in classifier_mode has several other branches that no test exercises:

  • An omitted mode with escalation present selects escalation, and an omitted mode without escalation selects capability.
  • mode = "capability" with escalation present is rejected.
  • mode = "escalation" with base_threshold present is rejected.
  • Any non-custom mode with targets, default_target, response_schema, or policy is rejected.
  • mode = "capability" without base_threshold reports "requires base_threshold", which replaces the previous serde "missing field" error.

Add cases for these so the mode contract is pinned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/config.rs` around lines 946 - 957, Extend the
mode-related tests around mode_custom_rejects_capability_fields to cover
classifier_mode inference and every listed rejection: omitted mode with and
without escalation, capability with escalation, escalation with base_threshold,
non-custom modes using custom-only fields, and capability without base_threshold
expecting the classifier’s “requires base_threshold” message. Reuse existing
configuration fixtures and error_message helpers, adding focused cases that
assert the documented selected mode or error text.

390-394: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the mode inference and the mode.is_some() gate.

Two decisions in classifier_mode are not obvious from the code. First, lines 390-394 infer the mode from the presence of escalation so that configurations written before mode existed keep working. Second, line 449 gates the capability-settings rejection on mode.is_some(), so a legacy escalation route that still carries base_threshold is accepted and those settings are ignored instead of rejected. Add short comments stating both intents, so a later change does not remove the compatibility path or tighten it accidentally.

As per coding guidelines: "add concise comments for module intent, private helpers with non-obvious behavior, important tests, and complex validation, routing, configuration, async, lifecycle, or concurrency logic".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/config.rs` around lines 390 - 394, Add concise
comments around the selected_mode inference in classifier_mode explaining that
escalation presence preserves compatibility for configurations created before
mode existed. Also comment the mode.is_some() gate on capability-settings
rejection, noting that legacy escalation routes may retain base_threshold and
those settings are intentionally ignored rather than rejected.

Source: Coding guidelines

crates/libsy/src/algorithms/util/classifier_contract.rs (1)

64-85: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Validate strict JSON Schema constraints at construction.

from_inner_schema always sends "strict": true, but compile_schema checks only generic JSON Schema validity. OpenAI-style strict providers require every object to set additionalProperties: false and list every property in required. Invalid strict schemas can pass construction, cause every judge request to fail, and route every turn to default_target when the failure becomes None.

Validate these constraints recursively in from_inner_schema, or make strict mode configurable and document the required schema shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/libsy/src/algorithms/util/classifier_contract.rs` around lines 64 -
85, Update from_inner_schema to validate the complete schema recursively against
the strict JSON Schema constraints required by the generated "strict": true
response format: every object must set additionalProperties to false and include
all declared property names in required, including nested objects and objects
inside arrays or combinators. Reject violations with the existing Result error
path before calling from_response_format, while preserving compilation and
construction for valid schemas.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/reference/toml_schema.md`:
- Line 134: Update the policy table entry in toml_schema.md to explicitly
identify selector as the field that accepts the JSON Pointer, while
distinguishing it from the policy type value target_selector. Keep the existing
example and table structure unchanged.

In `@docs/routing_algorithms/llm_classifier_routing.md`:
- Around line 110-122: Update the custom multi-target routing example around the
routes.smart configuration to define the referenced fast, balanced, reasoning,
and premium targets with valid target tables, or explicitly indicate that this
block replaces the earlier target definitions. Ensure the copied configuration
passes target-reference validation.

---

Nitpick comments:
In `@crates/libsy/src/algorithms/llm_class.rs`:
- Around line 605-708: Add unit tests in the llm_class test module covering each
new_custom rejection: fewer than two targets, empty or surrounding-whitespace
labels, duplicate labels, duplicate target semantic_name values, and a
default_target absent from the configured labels. Reuse the existing test
target/configuration helpers where available, invoke new_custom for each case,
and assert that construction returns an error while preserving the existing
successful and fallback coverage.

In `@crates/libsy/src/algorithms/util/classifier_contract.rs`:
- Around line 64-85: Update from_inner_schema to validate the complete schema
recursively against the strict JSON Schema constraints required by the generated
"strict": true response format: every object must set additionalProperties to
false and include all declared property names in required, including nested
objects and objects inside arrays or combinators. Reject violations with the
existing Result error path before calling from_response_format, while preserving
compilation and construction for valid schemas.

In `@crates/libsy/src/algorithms/util/target_selector.rs`:
- Around line 107-112: Add a test alongside an_invalid_json_pointer_is_rejected
that calls TargetSelectorPolicy::new with an empty selector and asserts it
returns the expected LibsyError::AlgorithmError for rejecting a root selector.
Keep the test focused on verifying that the valid JSON Pointer "" is not
accepted because it would select the entire verdict.

In `@crates/switchyard-server/src/config.rs`:
- Around line 946-957: Extend the mode-related tests around
mode_custom_rejects_capability_fields to cover classifier_mode inference and
every listed rejection: omitted mode with and without escalation, capability
with escalation, escalation with base_threshold, non-custom modes using
custom-only fields, and capability without base_threshold expecting the
classifier’s “requires base_threshold” message. Reuse existing configuration
fixtures and error_message helpers, adding focused cases that assert the
documented selected mode or error text.
- Around line 390-394: Add concise comments around the selected_mode inference
in classifier_mode explaining that escalation presence preserves compatibility
for configurations created before mode existed. Also comment the mode.is_some()
gate on capability-settings rejection, noting that legacy escalation routes may
retain base_threshold and those settings are intentionally ignored rather than
rejected.

In `@crates/switchyard-server/tests/server.rs`:
- Around line 817-861: Extend the test assertions after the request loop to
verify the classifier target model/classifier call count equals the number of
turns, preventing cached or skipped classifications from passing. Also assert
that the selected-model results never include model/weak or model/middle, while
preserving the existing assertions for model/premium and model/strong and the
classifier prompt/schema checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 88bd5c89-9361-4215-a0f1-f622210021ab

📥 Commits

Reviewing files that changed from the base of the PR and between a9c04b3 and d057752.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (16)
  • Cargo.toml
  • crates/libsy/Cargo.toml
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/libsy/src/algorithms/util.rs
  • crates/libsy/src/algorithms/util/classifier_contract.rs
  • crates/libsy/src/algorithms/util/escalation.rs
  • crates/libsy/src/algorithms/util/llm_judge.rs
  • crates/libsy/src/algorithms/util/target_selector.rs
  • crates/libsy/src/lib.rs
  • crates/switchyard-server/README.md
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/tests/server.rs
  • docs/getting_started.md
  • docs/reference/toml_schema.md
  • docs/routing_algorithms/escalation_router_routing.md
  • docs/routing_algorithms/llm_classifier_routing.md

Comment thread docs/reference/toml_schema.md
Comment thread docs/routing_algorithms/llm_classifier_routing.md
Signed-off-by: ayushag <ayushag@nvidia.com>
Comment thread crates/libsy/README.md Outdated
@ryan-lempka

Copy link
Copy Markdown
Collaborator

Can we add an example 2+ model prompt in examples section?

@ryan-lempka

Copy link
Copy Markdown
Collaborator

Can we add an example 2+ model prompt in examples section?

This can be a follow-up PR

Signed-off-by: ayushag <ayushag@nvidia.com>
@ayushag-nv
ayushag-nv enabled auto-merge (squash) August 4, 2026 16:03
@ayushag-nv
ayushag-nv merged commit 4ae99c2 into main Aug 4, 2026
20 checks passed
@ayushag-nv
ayushag-nv deleted the ayushag/custom-classifier-schema branch August 4, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants