Skip to content

Commit 1e5839e

Browse files
committed
feat(actions): add RailOutcome rail-result contract
1 parent db31852 commit 1e5839e

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""The engine-neutral outcome of a single rail check.
17+
18+
``RailOutcome`` is the canonical, backend-agnostic verdict a rail produces. It
19+
carries only the DECISION and neutral evidence, never how a consequence is
20+
rendered: which exception to raise, which bot intent or refusal message to
21+
emit, and how to localize it are presentation concerns that each engine (the
22+
Colang flow, IORails) decides for itself from this outcome plus its config.
23+
24+
Fields:
25+
- ``decision``: ALLOW / BLOCK / TRANSFORM.
26+
- ``reason``: optional neutral, human-readable explanation.
27+
- ``metadata``: neutral evidence the decision is based on (policy violations,
28+
categories, scores). Never load-bearing for the decision itself.
29+
- ``transforms``: for TRANSFORM, which conversation variables are rewritten
30+
and to what. Transform outcomes are non-streaming; streaming output bypasses
31+
observe them as non-blocking and do not apply rewrites.
32+
33+
Deliberately NOT here (they are rendering, not decision): exception type,
34+
refusal intent/message, language, and Colang event/context-update channels.
35+
A BLOCK always means "stop"; there is no soft-block flag.
36+
"""
37+
38+
from collections.abc import Sequence
39+
from dataclasses import dataclass, field
40+
from enum import Enum
41+
from typing import Any
42+
43+
44+
class RailDecision(Enum):
45+
"""The three mutually exclusive things a rail can decide."""
46+
47+
ALLOW = "allow"
48+
BLOCK = "block"
49+
TRANSFORM = "transform"
50+
51+
52+
class TransformTarget(str, Enum):
53+
"""The conversation variable a transform rewrites."""
54+
55+
USER_MESSAGE = "user_message"
56+
BOT_MESSAGE = "bot_message"
57+
RELEVANT_CHUNKS = "relevant_chunks"
58+
59+
60+
@dataclass(frozen=True, slots=True)
61+
class TransformSpec:
62+
"""A rewrite of a single conversation variable."""
63+
64+
target: TransformTarget
65+
text: str
66+
67+
68+
@dataclass(frozen=True, slots=True)
69+
class RailOutcome:
70+
"""The engine-neutral verdict of one rail check.
71+
72+
``transforms`` is non-empty if and only if ``decision`` is TRANSFORM. Each
73+
engine renders the consequence of a BLOCK its own way; this object does not
74+
encode it.
75+
"""
76+
77+
decision: RailDecision
78+
reason: str | None = None
79+
metadata: dict[str, Any] = field(default_factory=dict)
80+
transforms: tuple[TransformSpec, ...] = ()
81+
82+
def __post_init__(self) -> None:
83+
if bool(self.transforms) != (self.decision is RailDecision.TRANSFORM):
84+
raise ValueError("transforms must be non-empty if and only if decision is TRANSFORM")
85+
86+
@property
87+
def is_blocked(self) -> bool:
88+
"""The single field both engines read to gate; rendering is theirs."""
89+
return self.decision is RailDecision.BLOCK
90+
91+
@property
92+
def is_transform(self) -> bool:
93+
return self.decision is RailDecision.TRANSFORM
94+
95+
@property
96+
def transform_text(self) -> dict[str, str]:
97+
return {spec.target.value: spec.text for spec in self.transforms}
98+
99+
@classmethod
100+
def allow(cls, *, reason: str | None = None, **metadata: Any) -> "RailOutcome":
101+
return cls(decision=RailDecision.ALLOW, reason=reason, metadata=dict(metadata))
102+
103+
@classmethod
104+
def block(cls, *, reason: str | None = None, **metadata: Any) -> "RailOutcome":
105+
return cls(decision=RailDecision.BLOCK, reason=reason, metadata=dict(metadata))
106+
107+
@classmethod
108+
def transform(
109+
cls,
110+
rewrites: Sequence[tuple[TransformTarget, str]],
111+
*,
112+
reason: str | None = None,
113+
**metadata: Any,
114+
) -> "RailOutcome":
115+
return cls(
116+
decision=RailDecision.TRANSFORM,
117+
reason=reason,
118+
transforms=tuple(TransformSpec(target=target, text=text) for target, text in rewrites),
119+
metadata=dict(metadata),
120+
)
121+
122+
123+
def require_rail_outcome(result: Any) -> RailOutcome:
124+
if isinstance(result, tuple):
125+
result = result[0]
126+
if not isinstance(result, RailOutcome):
127+
raise TypeError(f"Output rail action must return RailOutcome, got {type(result).__name__}")
128+
return result

tests/test_rail_outcome.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import dataclasses
17+
from typing import Any, cast
18+
19+
import pytest
20+
21+
from nemoguardrails.actions.rail_outcome import (
22+
RailDecision,
23+
RailOutcome,
24+
TransformSpec,
25+
TransformTarget,
26+
)
27+
28+
29+
def test_allow_is_not_blocked():
30+
outcome = RailOutcome.allow()
31+
assert outcome.decision is RailDecision.ALLOW
32+
assert outcome.is_blocked is False
33+
assert outcome.is_transform is False
34+
assert outcome.transforms == ()
35+
36+
37+
def test_allow_carries_metadata():
38+
outcome = RailOutcome.allow(policy_violations=[], score=0.1)
39+
assert outcome.metadata == {"policy_violations": [], "score": 0.1}
40+
41+
42+
def test_block_is_blocked_with_reason_and_metadata():
43+
outcome = RailOutcome.block(reason="unsafe", policy_violations=["S1: Violence"])
44+
assert outcome.decision is RailDecision.BLOCK
45+
assert outcome.is_blocked is True
46+
assert outcome.is_transform is False
47+
assert outcome.reason == "unsafe"
48+
assert outcome.metadata == {"policy_violations": ["S1: Violence"]}
49+
assert outcome.transforms == ()
50+
51+
52+
def test_transform_targets_variables():
53+
outcome = RailOutcome.transform(
54+
[
55+
(TransformTarget.USER_MESSAGE, "masked input"),
56+
(TransformTarget.BOT_MESSAGE, "masked output"),
57+
]
58+
)
59+
assert outcome.decision is RailDecision.TRANSFORM
60+
assert outcome.is_blocked is False
61+
assert outcome.is_transform is True
62+
assert outcome.transforms == (
63+
TransformSpec(target=TransformTarget.USER_MESSAGE, text="masked input"),
64+
TransformSpec(target=TransformTarget.BOT_MESSAGE, text="masked output"),
65+
)
66+
assert outcome.transform_text == {
67+
"user_message": "masked input",
68+
"bot_message": "masked output",
69+
}
70+
71+
72+
def test_transform_payload_required_for_transform_decision():
73+
with pytest.raises(ValueError, match="transforms must be non-empty if and only if decision is TRANSFORM"):
74+
RailOutcome(decision=RailDecision.TRANSFORM)
75+
76+
77+
def test_transform_payload_forbidden_for_block_decision():
78+
with pytest.raises(ValueError, match="transforms must be non-empty if and only if decision is TRANSFORM"):
79+
RailOutcome(
80+
decision=RailDecision.BLOCK,
81+
transforms=(TransformSpec(target=TransformTarget.USER_MESSAGE, text="x"),),
82+
)
83+
84+
85+
def test_outcome_is_immutable():
86+
outcome = RailOutcome.allow()
87+
with pytest.raises(dataclasses.FrozenInstanceError):
88+
cast(Any, outcome).decision = RailDecision.BLOCK

0 commit comments

Comments
 (0)