diff --git a/extensions/policy_routing/README.md b/extensions/policy_routing/README.md new file mode 100644 index 0000000..83f6fb3 --- /dev/null +++ b/extensions/policy_routing/README.md @@ -0,0 +1,66 @@ +# routellm-policy-routing + +Class-aware cache + routing policy mapping for routellm. + +Translates **c12n** classification labels into a routellm +**CachePolicy** (skip / lookup / store / TTL / namespace / match +strategy) per the cache policy table from showcase scenario 3 +(**T-0104**). + +## Status + +Paper-stable. Wire-up to routellm core's cache hook ships when +**US-0102** (cache-policy-hooks) lands. + +## Install + +```sh +pip install -e extensions/policy_routing +``` + +## Use + +```python +from policy_routing import classify_to_policy, CacheAction +from policy_routing.integrations import build_cache_hook + +# Direct mapping: +policy = classify_to_policy(request, classification_result) +if policy.action == CacheAction.SKIP: + ... + +# Or as a routellm cache hook (US-0102): +hook = build_cache_hook(classifier=my_c12n_classifier) +controller = Controller(..., cache_policy=hook) # planned API +``` + +## Cache policy table + +| c12n signal | action | ttl | match | namespace | +| ----------------------- | ----------------- | ------ | -------- | -------------- | +| `PII: EMAIL/PHONE/SSN` | skip | 0 | exact | tenant | +| `Jailbreak: high` | skip | 0 | exact | (n/a) | +| `Toxicity: high` | skip | 0 | exact | (n/a) | +| `CodeContent: ` | lookup_and_store | 7d | exact | workspace | +| `Domain: legal` | lookup_and_store | 15m | both | workspace | +| `Domain: math|cs` | lookup_and_store | 7d | both | workspace | +| easy + low-cost | skip | 0 | exact | (n/a) | +| hard + high-cost | lookup_and_store | 7d | semantic | workspace | +| `OutputFormat: code/json/yaml` | lookup_and_store | 7d | exact | workspace | +| (else) | lookup_and_store | 24h | both | workspace | + +Precedence top-to-bottom (safety wins). + +## Spec links + +- US-0102 — `docs/stories/US-0102-cache-policy-hooks.md` +- Scenario 3 — `~/.ops/.tlc/tracks/tools-showcase-scenarios/scenarios/3-class-aware-llm-gateway.md` +- T-0193 — workspace_id namespace default +- T-0179 — wsm intra-tenant cache leak audit + +## Test + +```sh +cd extensions/policy_routing +pytest +``` diff --git a/extensions/policy_routing/policy_routing/__init__.py b/extensions/policy_routing/policy_routing/__init__.py new file mode 100644 index 0000000..bc62a0d --- /dev/null +++ b/extensions/policy_routing/policy_routing/__init__.py @@ -0,0 +1,29 @@ +"""routellm.extensions.policy_routing. + +Class-aware cache + routing policy mapping. Translates c12n +ClassificationResult labels into a routellm CachePolicy per the +showcase scenario 3 spec table. + +Public API: + CachePolicy — dataclass mirroring routellm US-0102 spec + CacheAction — enum of legal action values + MatchStrategy — enum of legal match values + classify_to_policy — pure function: (request, classification) -> CachePolicy + DEFAULT_NAMESPACE_KEY — sentinel meaning "use workspace_id default" +""" + +from policy_routing.policy import ( + DEFAULT_NAMESPACE_KEY, + CacheAction, + CachePolicy, + MatchStrategy, + classify_to_policy, +) + +__all__ = [ + "CacheAction", + "CachePolicy", + "DEFAULT_NAMESPACE_KEY", + "MatchStrategy", + "classify_to_policy", +] diff --git a/extensions/policy_routing/policy_routing/integrations.py b/extensions/policy_routing/policy_routing/integrations.py new file mode 100644 index 0000000..dc50b63 --- /dev/null +++ b/extensions/policy_routing/policy_routing/integrations.py @@ -0,0 +1,65 @@ +"""Wire-up helpers for routellm's cache hook (US-0102). + +Until US-0102 lands in routellm core, this module ships a thin +adapter that stages the integration: a builder returns a callable +matching the planned hook signature, so consumers can wire it now +and flip the import path once core ships. + +Planned routellm contract (US-0102, line 32): + + cache_policy: Callable[[Request, ClassificationResult], CachePolicy] + +The classifier is injected as a separate callable so the hook itself +stays pure — easier to unit-test and swap. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from policy_routing.policy import ( + DEFAULT_POLICY, + CachePolicy, + classify_to_policy, +) + +# Type aliases for readability. `Any` because c12n bindings + routellm +# Request types may not be importable yet at adoption time. +Request = Any +ClassificationResult = Any +Classifier = Callable[[Request], ClassificationResult] +CacheHook = Callable[[Request, ClassificationResult], CachePolicy] + + +def build_cache_hook( + classifier: Optional[Classifier] = None, +) -> CacheHook: + """Return a cache_policy hook for routellm's controller. + + If `classifier` is provided AND the caller invokes the hook with + `classification=None`, the hook will run the classifier on the + request first. Otherwise the caller is expected to pass an + already-classified result (the common case — classification + middleware sits upstream of the cache hook). + + Returns a callable matching US-0102 contract. + """ + + def hook( + request: Request, + classification: ClassificationResult = None, + ) -> CachePolicy: + if classification is None and classifier is not None: + classification = classifier(request) + if classification is None: + return DEFAULT_POLICY + return classify_to_policy(request, classification) + + return hook + + +__all__ = [ + "CacheHook", + "Classifier", + "build_cache_hook", +] diff --git a/extensions/policy_routing/policy_routing/policy.py b/extensions/policy_routing/policy_routing/policy.py new file mode 100644 index 0000000..d3286e1 --- /dev/null +++ b/extensions/policy_routing/policy_routing/policy.py @@ -0,0 +1,259 @@ +"""classify_to_policy — c12n classification → routellm CachePolicy. + +Pure mapping logic; no I/O, no state. Consumers wire this into +routellm's cache hook (see US-0102) via integrations.build_cache_hook. + +The c12n ClassificationResult is duck-typed: any object exposing the +following attrs (any subset; missing = no signal) is accepted: + + pii_labels : Iterable[str] e.g. {"EMAIL", "PHONE", "SSN"} + jailbreak_score : float in [0, 1] + toxicity_score : float in [0, 1] + code_content : Optional[str] e.g. "python", "go", None + domain : Optional[str] e.g. "legal", "math", "cs", "general" + complexity : Optional[str] one of "easy", "medium", "hard" + cost_estimate : Optional[str] one of "low", "medium", "high" + output_format : Optional[str] e.g. "code", "json", "yaml", "prose" + +Policy table (showcase scenario 3, locked spec): + + PII (any of EMAIL/PHONE/SSN) skip cache; tenant-scoped namespace + Jailbreak score >= JAILBREAK_HIGH skip cache (audit handled upstream) + Toxicity score >= TOXICITY_HIGH skip cache + CodeContent: exact-match only; long TTL + Domain: legal workspace-scoped; short TTL + Domain: math|cs workspace-scoped; long TTL + Complexity: easy + cost_estimate: low skip cache (cheap to recompute) + Complexity: hard + cost_estimate: high semantic; long TTL + OutputFormat: code|json|yaml exact-match; high TTL + (else) DEFAULT policy + +Precedence: safety (PII / jailbreak / toxicity) wins over all +others. Then code_content. Then domain. Then complexity+cost. Then +output_format. Else default. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from typing import Any, Optional + +# --------------------------------------------------------------------------- +# Constants — single source of truth for thresholds + TTLs. +# --------------------------------------------------------------------------- + +# Sentinel namespace value: consumers (routellm cache hook) must +# resolve this to the request's workspace_id at lookup time. See +# US-0102 acceptance criteria. +DEFAULT_NAMESPACE_KEY = "__workspace__" + +# Tenant-scoped namespace sentinel: PII case requires tenant-level +# isolation rather than workspace-level (privacy spec calls for it +# explicitly because tenant covers the legal/billing boundary). +TENANT_NAMESPACE_KEY = "__tenant__" + +# TTL buckets (seconds). Tuned per spec rationale columns: +TTL_SHORT = 60 * 15 # 15 min — legal: confidentiality fades fast +TTL_DEFAULT = 60 * 60 * 24 # 24 h — matches CacheConfig default +TTL_LONG = 60 * 60 * 24 * 7 # 7 d — code/factual/structured: stable + +# Score thresholds. +JAILBREAK_HIGH = 0.7 +TOXICITY_HIGH = 0.7 + +# PII labels that trigger no-cache. Mirrors c12n SIGNALS.md PII set. +PII_BLOCKING_LABELS = frozenset({"EMAIL", "PHONE", "SSN"}) + +# Output formats that require exact-match (semantic match would +# corrupt structure-sensitive results). +STRUCTURED_OUTPUT_FORMATS = frozenset({"code", "json", "yaml"}) + +# Code-content languages — any non-empty value triggers exact-match. +# Stored as a marker; actual language string is opaque to mapping. + +# --------------------------------------------------------------------------- +# Public types — mirror routellm/types.py US-0102 spec. +# --------------------------------------------------------------------------- + + +class CacheAction(str, enum.Enum): + """Legal action values per US-0102.""" + + SKIP = "skip" + LOOKUP_ONLY = "lookup_only" + LOOKUP_AND_STORE = "lookup_and_store" + STORE_ONLY = "store_only" + + +class MatchStrategy(str, enum.Enum): + """Legal match values per US-0102.""" + + EXACT = "exact" + SEMANTIC = "semantic" + BOTH = "both" + + +@dataclass(frozen=True) +class CachePolicy: + """Cache-hook decision returned to routellm core. + + Mirrors routellm/types.py CachePolicy (planned by US-0102). + Frozen so it's hashable + safe to pass between threads. + + Attributes + ---------- + action: SKIP / LOOKUP_ONLY / LOOKUP_AND_STORE / STORE_ONLY. + ttl: store TTL in seconds; overrides CacheConfig.ttl_seconds. + namespace: cache key prefix. DEFAULT_NAMESPACE_KEY means + "resolve workspace_id from request"; TENANT_NAMESPACE_KEY + means "resolve tenant_id"; any other string is used verbatim + (e.g. "global:domain:math"). + match: exact / semantic / both. + reason: human-readable trace label for audit log + debugging. + metadata: free-form dict for downstream hooks (audit, routing). + """ + + action: CacheAction = CacheAction.LOOKUP_AND_STORE + ttl: int = TTL_DEFAULT + namespace: str = DEFAULT_NAMESPACE_KEY + match: MatchStrategy = MatchStrategy.BOTH + reason: str = "default" + metadata: tuple = field(default_factory=tuple) + + +# Module-level default — reused everywhere the table doesn't fire. +DEFAULT_POLICY = CachePolicy() + + +# --------------------------------------------------------------------------- +# Mapping function. +# --------------------------------------------------------------------------- + + +def _attr(obj: Any, name: str, default: Any = None) -> Any: + """Safe attribute getter — works for dataclasses, namedtuples, + plain classes, dict-like, TypedDict instances.""" + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(name, default) + return getattr(obj, name, default) + + +def _has_pii(classification: Any) -> bool: + labels = _attr(classification, "pii_labels") or () + return any(label in PII_BLOCKING_LABELS for label in labels) + + +def classify_to_policy(request: Any, classification: Any) -> CachePolicy: + """Map a (request, ClassificationResult) pair to a CachePolicy. + + `request` is unused by the current mapping rules but kept in the + signature to match the US-0102 hook contract; downstream rule + extensions may need request-local context (e.g. workspace_id + overrides keyed off auth claims). + """ + del request # reserved for future rules; matches hook signature. + + # ---- safety class — never cache ------------------------------------ + if _has_pii(classification): + return CachePolicy( + action=CacheAction.SKIP, + namespace=TENANT_NAMESPACE_KEY, + ttl=0, + match=MatchStrategy.EXACT, + reason="pii_detected", + ) + + if (_attr(classification, "jailbreak_score") or 0.0) >= JAILBREAK_HIGH: + return CachePolicy( + action=CacheAction.SKIP, + ttl=0, + match=MatchStrategy.EXACT, + reason="jailbreak_high", + ) + + if (_attr(classification, "toxicity_score") or 0.0) >= TOXICITY_HIGH: + return CachePolicy( + action=CacheAction.SKIP, + ttl=0, + match=MatchStrategy.EXACT, + reason="toxicity_high", + ) + + # ---- code content — determinism ------------------------------------ + code_content = _attr(classification, "code_content") + if code_content: + return CachePolicy( + action=CacheAction.LOOKUP_AND_STORE, + ttl=TTL_LONG, + match=MatchStrategy.EXACT, + reason=f"code_content:{code_content}", + ) + + # ---- domain class -------------------------------------------------- + domain = _attr(classification, "domain") + if domain == "legal": + return CachePolicy( + action=CacheAction.LOOKUP_AND_STORE, + ttl=TTL_SHORT, + namespace=DEFAULT_NAMESPACE_KEY, # workspace-scoped per T-0193 + match=MatchStrategy.BOTH, + reason="domain:legal", + ) + if domain in ("math", "cs"): + return CachePolicy( + action=CacheAction.LOOKUP_AND_STORE, + ttl=TTL_LONG, + namespace=DEFAULT_NAMESPACE_KEY, # workspace-scoped per T-0193 + match=MatchStrategy.BOTH, + reason=f"domain:{domain}", + ) + + # ---- complexity + cost interaction --------------------------------- + complexity = _attr(classification, "complexity") + cost_estimate = _attr(classification, "cost_estimate") + if complexity == "easy" and cost_estimate == "low": + return CachePolicy( + action=CacheAction.SKIP, + ttl=0, + reason="cheap_to_recompute", + ) + if complexity == "hard" and cost_estimate == "high": + return CachePolicy( + action=CacheAction.LOOKUP_AND_STORE, + ttl=TTL_LONG, + match=MatchStrategy.SEMANTIC, + reason="high_value_reuse", + ) + + # ---- output format ------------------------------------------------- + output_format = _attr(classification, "output_format") + if output_format in STRUCTURED_OUTPUT_FORMATS: + return CachePolicy( + action=CacheAction.LOOKUP_AND_STORE, + ttl=TTL_LONG, + match=MatchStrategy.EXACT, + reason=f"output_format:{output_format}", + ) + + return DEFAULT_POLICY + + +__all__ = [ + "CacheAction", + "CachePolicy", + "MatchStrategy", + "DEFAULT_NAMESPACE_KEY", + "DEFAULT_POLICY", + "JAILBREAK_HIGH", + "PII_BLOCKING_LABELS", + "STRUCTURED_OUTPUT_FORMATS", + "TENANT_NAMESPACE_KEY", + "TOXICITY_HIGH", + "TTL_DEFAULT", + "TTL_LONG", + "TTL_SHORT", + "classify_to_policy", +] diff --git a/extensions/policy_routing/pyproject.toml b/extensions/policy_routing/pyproject.toml new file mode 100644 index 0000000..ee626fb --- /dev/null +++ b/extensions/policy_routing/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "routellm-policy-routing" +version = "0.1.0" +description = "Class-aware cache + routing policy mapping for routellm. Wires c12n classification labels to routellm CachePolicy decisions." +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Jad Bitar" }] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + # Routellm core (sibling package); installed via local path / editable in dev + "routellm", +] + +[project.optional-dependencies] +# c12n Python bindings — drop in once they ship; currently the mapping +# layer is c12n-shape-agnostic (consumers pass any object exposing the +# documented signal attrs). Keep the extra so downstream pin sites have +# a single dep name to track. +c12n = [] +dev = ["pytest", "pytest-asyncio"] + +[project.urls] +"Showcase scenario" = "https://github.com/jadb-admin/.ops/tree/main/.tlc/tracks/tools-showcase-scenarios" + +[tool.setuptools.packages.find] +include = ["policy_routing*"] +exclude = ["tests*"] diff --git a/extensions/policy_routing/tests/__init__.py b/extensions/policy_routing/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extensions/policy_routing/tests/test_policy.py b/extensions/policy_routing/tests/test_policy.py new file mode 100644 index 0000000..9a02fc2 --- /dev/null +++ b/extensions/policy_routing/tests/test_policy.py @@ -0,0 +1,304 @@ +"""Tests covering each row of the cache policy table (scenario 3 spec). + +Uses a fake ClassificationResult dataclass so we don't pull c12n +bindings into the test loop. The mapping function only inspects +documented attributes; any duck-typed shape works. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Optional + +import pytest +from policy_routing import ( + DEFAULT_NAMESPACE_KEY, + CacheAction, + CachePolicy, + MatchStrategy, + classify_to_policy, +) +from policy_routing.policy import ( + DEFAULT_POLICY, + JAILBREAK_HIGH, + TENANT_NAMESPACE_KEY, + TOXICITY_HIGH, + TTL_DEFAULT, + TTL_LONG, + TTL_SHORT, +) + + +@dataclass +class FakeClassification: + """Stand-in for c12n.ClassificationResult shape.""" + + pii_labels: Iterable[str] = field(default_factory=tuple) + jailbreak_score: float = 0.0 + toxicity_score: float = 0.0 + code_content: Optional[str] = None + domain: Optional[str] = None + complexity: Optional[str] = None + cost_estimate: Optional[str] = None + output_format: Optional[str] = None + + +# Request is unused by current rules — sentinel keeps the signature +# real-world-ish. Tests should not rely on its structure. +REQUEST = object() + + +# --------------------------------------------------------------------------- +# Row 1 — PII labels (EMAIL / PHONE / SSN) → SKIP cache, tenant ns. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("label", ["EMAIL", "PHONE", "SSN"]) +def test_pii_label_skips_cache_with_tenant_namespace(label): + classification = FakeClassification(pii_labels=(label,)) + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.ttl == 0 + assert policy.namespace == TENANT_NAMESPACE_KEY + assert policy.reason == "pii_detected" + + +def test_pii_label_unknown_does_not_trigger(): + # Non-blocking PII labels (e.g. "NAME") should NOT skip cache; + # only the explicit blocking set fires. + classification = FakeClassification(pii_labels=("NAME",)) + policy = classify_to_policy(REQUEST, classification) + assert policy == DEFAULT_POLICY + + +# --------------------------------------------------------------------------- +# Row 2 — Jailbreak: high → SKIP cache. +# --------------------------------------------------------------------------- + + +def test_jailbreak_high_skips_cache(): + classification = FakeClassification(jailbreak_score=JAILBREAK_HIGH) + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.reason == "jailbreak_high" + + +def test_jailbreak_below_threshold_uses_default(): + classification = FakeClassification(jailbreak_score=JAILBREAK_HIGH - 0.01) + policy = classify_to_policy(REQUEST, classification) + assert policy == DEFAULT_POLICY + + +# --------------------------------------------------------------------------- +# Row 3 — Toxicity: high → SKIP cache. +# --------------------------------------------------------------------------- + + +def test_toxicity_high_skips_cache(): + classification = FakeClassification(toxicity_score=TOXICITY_HIGH) + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.reason == "toxicity_high" + + +# --------------------------------------------------------------------------- +# Row 4 — CodeContent: → exact-match only, long TTL. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("lang", ["python", "go", "rust", "typescript"]) +def test_code_content_uses_exact_match_long_ttl(lang): + classification = FakeClassification(code_content=lang) + policy = classify_to_policy(REQUEST, classification) + assert policy.match is MatchStrategy.EXACT + assert policy.ttl == TTL_LONG + assert policy.action is CacheAction.LOOKUP_AND_STORE + assert policy.reason == f"code_content:{lang}" + + +# --------------------------------------------------------------------------- +# Row 5 — Domain: legal → workspace-scoped, short TTL. +# --------------------------------------------------------------------------- + + +def test_domain_legal_workspace_scoped_short_ttl(): + classification = FakeClassification(domain="legal") + policy = classify_to_policy(REQUEST, classification) + assert policy.namespace == DEFAULT_NAMESPACE_KEY # workspace_id + assert policy.ttl == TTL_SHORT + assert policy.reason == "domain:legal" + + +# --------------------------------------------------------------------------- +# Row 6 — Domain: math/cs → workspace-scoped (per T-0193), long TTL. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("domain", ["math", "cs"]) +def test_domain_math_or_cs_workspace_scoped_long_ttl(domain): + classification = FakeClassification(domain=domain) + policy = classify_to_policy(REQUEST, classification) + assert policy.namespace == DEFAULT_NAMESPACE_KEY + assert policy.ttl == TTL_LONG + assert policy.action is CacheAction.LOOKUP_AND_STORE + assert policy.reason == f"domain:{domain}" + + +# --------------------------------------------------------------------------- +# Row 7 — Complexity: easy + cost: low → SKIP (not worth storing). +# --------------------------------------------------------------------------- + + +def test_easy_low_cost_skips_cache(): + classification = FakeClassification(complexity="easy", cost_estimate="low") + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.reason == "cheap_to_recompute" + + +def test_easy_alone_does_not_skip(): + # Without explicit low cost_estimate, fall through to default — + # the table requires BOTH conditions to fire. + classification = FakeClassification(complexity="easy") + policy = classify_to_policy(REQUEST, classification) + assert policy == DEFAULT_POLICY + + +# --------------------------------------------------------------------------- +# Row 8 — Complexity: hard + cost: high → semantic cache, long TTL. +# --------------------------------------------------------------------------- + + +def test_hard_high_cost_uses_semantic_long_ttl(): + classification = FakeClassification(complexity="hard", cost_estimate="high") + policy = classify_to_policy(REQUEST, classification) + assert policy.match is MatchStrategy.SEMANTIC + assert policy.ttl == TTL_LONG + assert policy.action is CacheAction.LOOKUP_AND_STORE + assert policy.reason == "high_value_reuse" + + +# --------------------------------------------------------------------------- +# Row 9 — OutputFormat: code/json/yaml → exact-match, high TTL. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fmt", ["code", "json", "yaml"]) +def test_structured_output_format_exact_match_long_ttl(fmt): + classification = FakeClassification(output_format=fmt) + policy = classify_to_policy(REQUEST, classification) + assert policy.match is MatchStrategy.EXACT + assert policy.ttl == TTL_LONG + assert policy.reason == f"output_format:{fmt}" + + +def test_prose_output_format_uses_default(): + classification = FakeClassification(output_format="prose") + policy = classify_to_policy(REQUEST, classification) + assert policy == DEFAULT_POLICY + + +# --------------------------------------------------------------------------- +# Default + precedence behavior. +# --------------------------------------------------------------------------- + + +def test_empty_classification_uses_default(): + policy = classify_to_policy(REQUEST, FakeClassification()) + assert policy == DEFAULT_POLICY + assert policy.action is CacheAction.LOOKUP_AND_STORE + assert policy.ttl == TTL_DEFAULT + assert policy.namespace == DEFAULT_NAMESPACE_KEY + assert policy.match is MatchStrategy.BOTH + + +def test_pii_takes_precedence_over_code_content(): + # Safety wins over determinism: a code-content prompt that also + # carries PII must never be cached. + classification = FakeClassification( + pii_labels=("EMAIL",), + code_content="python", + ) + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.reason == "pii_detected" + + +def test_jailbreak_takes_precedence_over_domain(): + classification = FakeClassification( + jailbreak_score=0.95, + domain="math", + ) + policy = classify_to_policy(REQUEST, classification) + assert policy.action is CacheAction.SKIP + assert policy.reason == "jailbreak_high" + + +def test_dict_shape_classification_works(): + # The mapper accepts dict-like classification objects (TypedDict + # consumers) — duck-typing path. + classification = {"domain": "legal"} + policy = classify_to_policy(REQUEST, classification) + assert policy.reason == "domain:legal" + assert policy.ttl == TTL_SHORT + + +# --------------------------------------------------------------------------- +# CachePolicy dataclass — shape contract. +# --------------------------------------------------------------------------- + + +def test_cache_policy_is_frozen_and_hashable(): + p1 = CachePolicy() + p2 = CachePolicy() + assert p1 == p2 + assert hash(p1) == hash(p2) + with pytest.raises(Exception): + p1.action = CacheAction.SKIP # type: ignore[misc] + + +def test_cache_action_string_round_trip(): + # Action values must round-trip as strings — needed for JSON + # serialisation in the audit log (US-0101). + assert CacheAction.SKIP.value == "skip" + assert CacheAction.LOOKUP_AND_STORE.value == "lookup_and_store" + assert MatchStrategy.EXACT.value == "exact" + assert MatchStrategy.BOTH.value == "both" + + +# --------------------------------------------------------------------------- +# Integrations — build_cache_hook smoke test. +# --------------------------------------------------------------------------- + + +def test_build_cache_hook_returns_callable_matching_contract(): + from policy_routing.integrations import build_cache_hook + + hook = build_cache_hook() + classification = FakeClassification(domain="legal") + policy = hook(REQUEST, classification) + assert policy.reason == "domain:legal" + + +def test_build_cache_hook_invokes_classifier_when_missing(): + from policy_routing.integrations import build_cache_hook + + calls = [] + + def classifier(req): + calls.append(req) + return FakeClassification(code_content="python") + + hook = build_cache_hook(classifier=classifier) + policy = hook(REQUEST, classification=None) + assert calls == [REQUEST] + assert policy.match is MatchStrategy.EXACT + assert policy.reason == "code_content:python" + + +def test_build_cache_hook_no_classifier_no_classification_returns_default(): + from policy_routing.integrations import build_cache_hook + + hook = build_cache_hook() + policy = hook(REQUEST, classification=None) + assert policy == DEFAULT_POLICY