Skip to content

Commit ef00c0e

Browse files
committed
feat(actions): add RailOutcome rail-result contract
Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com>
1 parent 0bd2ab4 commit ef00c0e

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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: object) -> RailOutcome:
124+
if not isinstance(result, RailOutcome):
125+
raise TypeError(f"Output rail action must return RailOutcome, got {type(result).__name__}")
126+
return result

tests/test_rail_outcome.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
require_rail_outcome,
27+
)
28+
29+
30+
def test_allow_is_not_blocked():
31+
outcome = RailOutcome.allow()
32+
assert outcome.decision is RailDecision.ALLOW
33+
assert outcome.is_blocked is False
34+
assert outcome.is_transform is False
35+
assert outcome.transforms == ()
36+
37+
38+
def test_allow_carries_metadata():
39+
outcome = RailOutcome.allow(policy_violations=[], score=0.1)
40+
assert outcome.metadata == {"policy_violations": [], "score": 0.1}
41+
42+
43+
def test_block_is_blocked_with_reason_and_metadata():
44+
outcome = RailOutcome.block(reason="unsafe", policy_violations=["S1: Violence"])
45+
assert outcome.decision is RailDecision.BLOCK
46+
assert outcome.is_blocked is True
47+
assert outcome.is_transform is False
48+
assert outcome.reason == "unsafe"
49+
assert outcome.metadata == {"policy_violations": ["S1: Violence"]}
50+
assert outcome.transforms == ()
51+
52+
53+
def test_transform_targets_variables():
54+
outcome = RailOutcome.transform(
55+
[
56+
(TransformTarget.USER_MESSAGE, "masked input"),
57+
(TransformTarget.BOT_MESSAGE, "masked output"),
58+
]
59+
)
60+
assert outcome.decision is RailDecision.TRANSFORM
61+
assert outcome.is_blocked is False
62+
assert outcome.is_transform is True
63+
assert outcome.transforms == (
64+
TransformSpec(target=TransformTarget.USER_MESSAGE, text="masked input"),
65+
TransformSpec(target=TransformTarget.BOT_MESSAGE, text="masked output"),
66+
)
67+
assert outcome.transform_text == {
68+
"user_message": "masked input",
69+
"bot_message": "masked output",
70+
}
71+
72+
73+
def test_transform_payload_required_for_transform_decision():
74+
with pytest.raises(ValueError, match="transforms must be non-empty if and only if decision is TRANSFORM"):
75+
RailOutcome(decision=RailDecision.TRANSFORM)
76+
77+
78+
def test_transform_payload_forbidden_for_block_decision():
79+
with pytest.raises(ValueError, match="transforms must be non-empty if and only if decision is TRANSFORM"):
80+
RailOutcome(
81+
decision=RailDecision.BLOCK,
82+
transforms=(TransformSpec(target=TransformTarget.USER_MESSAGE, text="x"),),
83+
)
84+
85+
86+
def test_outcome_is_immutable():
87+
outcome = RailOutcome.allow()
88+
with pytest.raises(dataclasses.FrozenInstanceError):
89+
cast(Any, outcome).decision = RailDecision.BLOCK
90+
91+
92+
def test_require_rail_outcome_accepts_outcome():
93+
outcome = RailOutcome.allow()
94+
assert require_rail_outcome(outcome) is outcome
95+
96+
97+
@pytest.mark.parametrize("result", [None, False, {}, (RailOutcome.allow(), "success")])
98+
def test_require_rail_outcome_rejects_other_results(result):
99+
with pytest.raises(TypeError, match="Output rail action must return RailOutcome"):
100+
require_rail_outcome(result)

0 commit comments

Comments
 (0)